feat: add message queue functionality with auto-send and queue mode toggle
- Improved development server hot module replacement (HMR) by reusing OpenCode processes. - Introduced queued message mode with batching and idle auto-send, including support for attachments. - Added a toggle for queue mode in OpenChamber settings with persistence across sessions. - Updated various components to handle queued messages, including ChatInput and QueuedMessageChips. - Modified settings payload and desktop settings to include queue mode configuration. - Enhanced message sending logic to support additional parts for queued messages.
This commit is contained in:
+6
-2
@@ -4,8 +4,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
- Added Intel Mac (x86_64) support for the desktop application.
|
- Added Intel Mac (x86_64) support for the desktop application (thanks to @rothnic).
|
||||||
- Build workflow now generates separate builds for Apple Silicon (arm64) and Intel (x86_64) Macs.
|
- Build workflow now generates separate builds for Apple Silicon (arm64) and Intel (x86_64) Macs (thanks to @rothnic).
|
||||||
|
- Improved dev server HMR by reusing a healthy OpenCode process to avoid zombie instances.
|
||||||
|
- Added queued message mode with chips, batching, and idle auto‑send (including attachments).
|
||||||
|
- Added queue mode toggle to OpenChamber settings (chat section) with persistence across runtimes.
|
||||||
|
|
||||||
|
|
||||||
## [1.3.7] - 2025-12-28
|
## [1.3.7] - 2025-12-28
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") {
|
if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") {
|
||||||
result_obj.insert("autoDeleteEnabled".to_string(), json!(b));
|
result_obj.insert("autoDeleteEnabled".to_string(), json!(b));
|
||||||
}
|
}
|
||||||
|
if let Some(Value::Bool(b)) = obj.get("queueModeEnabled") {
|
||||||
|
result_obj.insert("queueModeEnabled".to_string(), json!(b));
|
||||||
|
}
|
||||||
|
|
||||||
// Number fields
|
// Number fields
|
||||||
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
|
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||||
|
import type { AttachedFile, EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||||
import { AttachedFilesList } from './FileAttachment';
|
import { AttachedFilesList } from './FileAttachment';
|
||||||
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||||
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
||||||
@@ -23,6 +25,7 @@ import { ModelControls } from './ModelControls';
|
|||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
import { StatusRow } from './StatusRow';
|
import { StatusRow } from './StatusRow';
|
||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||||
@@ -35,6 +38,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||||
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
@@ -67,6 +71,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
||||||
const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt);
|
const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt);
|
||||||
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
||||||
|
const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags);
|
||||||
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
||||||
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
||||||
const addServerFile = useSessionStore((state) => state.addServerFile);
|
const addServerFile = useSessionStore((state) => state.addServerFile);
|
||||||
@@ -84,6 +89,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const prevWasAbortedRef = React.useRef(false);
|
const prevWasAbortedRef = React.useRef(false);
|
||||||
const sendTriggeredByPointerDownRef = React.useRef(false);
|
const sendTriggeredByPointerDownRef = React.useRef(false);
|
||||||
|
|
||||||
|
// Message queue
|
||||||
|
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
||||||
|
const queuedMessages = useMessageQueueStore(
|
||||||
|
React.useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!currentSessionId) return EMPTY_QUEUE;
|
||||||
|
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
||||||
|
},
|
||||||
|
[currentSessionId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
|
||||||
|
const clearQueue = useMessageQueueStore((state) => state.clearQueue);
|
||||||
|
|
||||||
|
// Session activity for auto-send on idle
|
||||||
|
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||||
|
const prevSessionPhaseRef = React.useRef(sessionPhase);
|
||||||
|
const autoSendTriggeredRef = React.useRef(false);
|
||||||
|
|
||||||
const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent<HTMLTextAreaElement>) => {
|
const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent<HTMLTextAreaElement>) => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
return;
|
return;
|
||||||
@@ -219,6 +243,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [chatInputAccent, softenBorderColor]);
|
}, [chatInputAccent, softenBorderColor]);
|
||||||
|
|
||||||
const hasContent = message.trim() || attachedFiles.length > 0;
|
const hasContent = message.trim() || attachedFiles.length > 0;
|
||||||
|
const hasQueuedMessages = queuedMessages.length > 0;
|
||||||
|
const canSend = hasContent || hasQueuedMessages;
|
||||||
|
|
||||||
const canAbort = working.isWorking;
|
const canAbort = working.isWorking;
|
||||||
|
|
||||||
@@ -227,16 +253,110 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
|
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
|
||||||
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
|
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
|
||||||
|
|
||||||
|
// Add message to queue instead of sending
|
||||||
|
const handleQueueMessage = React.useCallback(() => {
|
||||||
|
if (!hasContent || !currentSessionId) return;
|
||||||
|
|
||||||
|
const messageToQueue = message.replace(/^\n+|\n+$/g, '');
|
||||||
|
const attachmentsToQueue = attachedFiles.map((file) => ({ ...file }));
|
||||||
|
|
||||||
|
addToQueue(currentSessionId, {
|
||||||
|
content: messageToQueue,
|
||||||
|
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear input and attachments
|
||||||
|
setMessage('');
|
||||||
|
if (attachmentsToQueue.length > 0) {
|
||||||
|
clearAttachedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]);
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (!hasContent || (!currentSessionId && !newSessionDraftOpen)) return;
|
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return;
|
||||||
|
|
||||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
|
||||||
|
|
||||||
scrollToBottom?.({ instant: true, force: true });
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
|
|
||||||
const normalizedCommand = messageToSend.trimStart();
|
if (!currentProviderId || !currentModelId) {
|
||||||
|
console.warn('Cannot send message: provider or model not selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the primary message (first part) and additional parts
|
||||||
|
let primaryText = '';
|
||||||
|
let primaryAttachments: AttachedFile[] = [];
|
||||||
|
let agentMentionName: string | undefined;
|
||||||
|
const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = [];
|
||||||
|
|
||||||
|
// Process queued messages first
|
||||||
|
for (let i = 0; i < queuedMessages.length; i++) {
|
||||||
|
const queuedMsg = queuedMessages[i];
|
||||||
|
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
||||||
|
|
||||||
|
// Use agent mention from first message that has one
|
||||||
|
if (!agentMentionName && mention?.name) {
|
||||||
|
agentMentionName = mention.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
// First queued message becomes primary
|
||||||
|
primaryText = sanitizedText;
|
||||||
|
primaryAttachments = queuedMsg.attachments ?? [];
|
||||||
|
} else {
|
||||||
|
// Subsequent queued messages become additional parts
|
||||||
|
additionalParts.push({
|
||||||
|
text: sanitizedText,
|
||||||
|
attachments: queuedMsg.attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current input
|
||||||
|
if (hasContent) {
|
||||||
|
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||||
|
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||||
|
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
||||||
|
|
||||||
|
if (!agentMentionName && mention?.name) {
|
||||||
|
agentMentionName = mention.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queuedMessages.length === 0) {
|
||||||
|
// No queue - current input is primary
|
||||||
|
primaryText = sanitizedText;
|
||||||
|
primaryAttachments = attachmentsToSend;
|
||||||
|
} else {
|
||||||
|
// Has queue - current input is additional part
|
||||||
|
additionalParts.push({
|
||||||
|
text: sanitizedText,
|
||||||
|
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!primaryText && additionalParts.length === 0) return;
|
||||||
|
|
||||||
|
// Clear queue and input
|
||||||
|
if (currentSessionId && hasQueuedMessages) {
|
||||||
|
clearQueue(currentSessionId);
|
||||||
|
}
|
||||||
|
setMessage('');
|
||||||
|
if (attachedFiles.length > 0) {
|
||||||
|
clearAttachedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
textareaRef.current?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle /summarize command scroll
|
||||||
|
const normalizedCommand = primaryText.trimStart();
|
||||||
if (normalizedCommand.startsWith('/')) {
|
if (normalizedCommand.startsWith('/')) {
|
||||||
const commandName = normalizedCommand
|
const commandName = normalizedCommand
|
||||||
.slice(1)
|
.slice(1)
|
||||||
@@ -248,29 +368,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentProviderId || !currentModelId) {
|
// Collect all attachments for error recovery
|
||||||
|
const allAttachments = [
|
||||||
|
...primaryAttachments,
|
||||||
|
...additionalParts.flatMap(p => p.attachments ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
console.warn('Cannot send message: provider or model not selected');
|
await sendMessage(
|
||||||
return;
|
primaryText,
|
||||||
}
|
currentProviderId,
|
||||||
|
currentModelId,
|
||||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
currentAgentName,
|
||||||
const agentMentionName = mention?.name;
|
primaryAttachments,
|
||||||
|
agentMentionName,
|
||||||
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
additionalParts.length > 0 ? additionalParts : undefined
|
||||||
if (attachmentsToSend.length > 0) {
|
).catch((error: unknown) => {
|
||||||
clearAttachedFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage('');
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
textareaRef.current?.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName)
|
|
||||||
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
const rawMessage =
|
const rawMessage =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
@@ -293,12 +405,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
normalized === 'failed to send message';
|
normalized === 'failed to send message';
|
||||||
|
|
||||||
if (isSoftNetworkError) {
|
if (isSoftNetworkError) {
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentsToSend.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useFileStore.setState({ attachedFiles: attachmentsToSend });
|
useFileStore.setState({ attachedFiles: allAttachments });
|
||||||
}
|
}
|
||||||
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
||||||
});
|
});
|
||||||
@@ -306,9 +417,42 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keep a ref to handleSubmit for auto-send effect
|
||||||
|
const handleSubmitRef = React.useRef(handleSubmit);
|
||||||
|
handleSubmitRef.current = handleSubmit;
|
||||||
|
|
||||||
|
// Auto-send queued messages when session becomes idle (but not after abort)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown';
|
||||||
|
const isNowIdle = sessionPhase === 'idle';
|
||||||
|
|
||||||
|
// Check if session was recently aborted (within last 2 seconds)
|
||||||
|
const wasRecentlyAborted = currentSessionId && sessionAbortFlags.has(currentSessionId) && (() => {
|
||||||
|
const abortRecord = sessionAbortFlags.get(currentSessionId);
|
||||||
|
if (!abortRecord) return false;
|
||||||
|
const timeSinceAbort = Date.now() - abortRecord.timestamp;
|
||||||
|
return timeSinceAbort < 2000;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Detect transition from working to idle, but skip if aborted
|
||||||
|
if (wasWorking && isNowIdle && queuedMessages.length > 0 && !autoSendTriggeredRef.current && !wasRecentlyAborted) {
|
||||||
|
// Prevent double-triggering
|
||||||
|
autoSendTriggeredRef.current = true;
|
||||||
|
|
||||||
|
// Use setTimeout to avoid calling during render
|
||||||
|
setTimeout(() => {
|
||||||
|
if (currentSessionId && currentProviderId && currentModelId) {
|
||||||
|
void handleSubmitRef.current();
|
||||||
|
}
|
||||||
|
autoSendTriggeredRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSessionPhaseRef.current = sessionPhase;
|
||||||
|
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
|
||||||
if (showCommandAutocomplete && commandRef.current) {
|
if (showCommandAutocomplete && commandRef.current) {
|
||||||
@@ -341,9 +485,35 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Enter/Ctrl+Enter based on queue mode
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
|
||||||
|
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
// Queue mode: Enter queues, Ctrl+Enter sends
|
||||||
|
// Normal mode: Enter sends, Ctrl+Enter queues
|
||||||
|
// Note: Queueing only works when there's an existing session (currentSessionId)
|
||||||
|
// For new sessions (draft), always send immediately
|
||||||
|
const canQueue = hasContent && currentSessionId;
|
||||||
|
|
||||||
|
if (queueModeEnabled) {
|
||||||
|
if (isCtrlEnter || !canQueue) {
|
||||||
|
// Ctrl+Enter sends, or Enter when can't queue (new session)
|
||||||
|
handleSubmit();
|
||||||
|
} else {
|
||||||
|
// Enter queues when we have a session
|
||||||
|
handleQueueMessage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isCtrlEnter && canQueue) {
|
||||||
|
// Ctrl+Enter queues when we have a session
|
||||||
|
handleQueueMessage();
|
||||||
|
} else {
|
||||||
|
// Enter sends
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -839,13 +1009,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type={isMobile ? 'button' : 'submit'}
|
type={isMobile ? 'button' : 'submit'}
|
||||||
disabled={!hasContent || (!currentSessionId && !newSessionDraftOpen)}
|
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
||||||
onPointerDownCapture={(event) => {
|
onPointerDownCapture={(event) => {
|
||||||
if (!isMobile || event.pointerType !== 'touch') {
|
if (!isMobile || event.pointerType !== 'touch') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasContent || (!currentSessionId && !newSessionDraftOpen)) {
|
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,7 +1039,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
iconButtonBaseClass,
|
iconButtonBaseClass,
|
||||||
hasContent && (currentSessionId || newSessionDraftOpen)
|
canSend && (currentSessionId || newSessionDraftOpen)
|
||||||
? 'text-primary hover:text-primary'
|
? 'text-primary hover:text-primary'
|
||||||
: 'opacity-30'
|
: 'opacity-30'
|
||||||
)}
|
)}
|
||||||
@@ -1040,6 +1210,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AttachedFilesList />
|
<AttachedFilesList />
|
||||||
|
<QueuedMessageChips
|
||||||
|
onEditMessage={(content) => {
|
||||||
|
setMessage(content);
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border border-border/80 bg-input/10 dark:bg-input/30",
|
"rounded-xl border border-border/80 bg-input/10 dark:bg-input/30",
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
|
||||||
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
|
interface QueuedMessageChipProps {
|
||||||
|
message: QueuedMessage;
|
||||||
|
sessionId: string;
|
||||||
|
onEdit: (message: QueuedMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
|
||||||
|
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||||
|
|
||||||
|
// Get first line of message, truncated
|
||||||
|
const firstLine = React.useMemo(() => {
|
||||||
|
const lines = message.content.split('\n');
|
||||||
|
const first = lines[0] || '';
|
||||||
|
const maxLength = 50;
|
||||||
|
if (first.length > maxLength) {
|
||||||
|
return first.substring(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
return first + (lines.length > 1 ? '...' : '');
|
||||||
|
}, [message.content]);
|
||||||
|
|
||||||
|
const attachmentCount = message.attachments?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta group">
|
||||||
|
<RiMessage2Line className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit(message)}
|
||||||
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-left"
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[200px]">
|
||||||
|
{firstLine || '(empty)'}
|
||||||
|
</span>
|
||||||
|
{attachmentCount > 0 && (
|
||||||
|
<span className="text-muted-foreground flex-shrink-0">
|
||||||
|
+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromQueue(sessionId, message.id)}
|
||||||
|
className="ml-1 hover:text-destructive p-0.5 opacity-60 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
QueuedMessageChip.displayName = 'QueuedMessageChip';
|
||||||
|
|
||||||
|
interface QueuedMessageChipsProps {
|
||||||
|
onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||||
|
|
||||||
|
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
|
||||||
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
|
const queuedMessages = useMessageQueueStore(
|
||||||
|
React.useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!currentSessionId) return EMPTY_QUEUE;
|
||||||
|
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
||||||
|
},
|
||||||
|
[currentSessionId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const popToInput = useMessageQueueStore((state) => state.popToInput);
|
||||||
|
|
||||||
|
const handleEdit = React.useCallback((message: QueuedMessage) => {
|
||||||
|
if (!currentSessionId) return;
|
||||||
|
|
||||||
|
const popped = popToInput(currentSessionId, message.id);
|
||||||
|
if (popped) {
|
||||||
|
// Restore attachments to file store if any
|
||||||
|
if (popped.attachments && popped.attachments.length > 0) {
|
||||||
|
const currentAttachments = useFileStore.getState().attachedFiles;
|
||||||
|
useFileStore.setState({
|
||||||
|
attachedFiles: [...currentAttachments, ...popped.attachments]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onEditMessage(popped.content, popped.attachments);
|
||||||
|
}
|
||||||
|
}, [currentSessionId, popToInput, onEditMessage]);
|
||||||
|
|
||||||
|
if (queuedMessages.length === 0 || !currentSessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-2">
|
||||||
|
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
|
||||||
|
<span className="typography-meta text-muted-foreground font-medium">Queued:</span>
|
||||||
|
{queuedMessages.map((message) => (
|
||||||
|
<QueuedMessageChip
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
sessionId={currentSessionId}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
QueuedMessageChips.displayName = 'QueuedMessageChips';
|
||||||
@@ -69,9 +69,9 @@ const VisualSectionContent: React.FC = () => {
|
|||||||
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'spacing']} />;
|
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'spacing']} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chat section: Default Tool Output, Diff layout, Show reasoning traces
|
// Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode
|
||||||
const ChatSectionContent: React.FC = () => {
|
const ChatSectionContent: React.FC = () => {
|
||||||
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning']} />;
|
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning', 'queueMode']} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sessions section: Default model & agent, Session retention
|
// Sessions section: Default model & agent, Session retention
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RiRestartLine } from '@remixicon/react';
|
|||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import type { ThemeMode } from '@/types/theme';
|
import type { ThemeMode } from '@/types/theme';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ButtonSmall } from '@/components/ui/button-small';
|
import { ButtonSmall } from '@/components/ui/button-small';
|
||||||
import { NumberInput } from '@/components/ui/number-input';
|
import { NumberInput } from '@/components/ui/number-input';
|
||||||
@@ -55,7 +56,7 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'toolOutput' | 'diffLayout' | 'reasoning';
|
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'toolOutput' | 'diffLayout' | 'reasoning' | 'queueMode';
|
||||||
|
|
||||||
interface OpenChamberVisualSettingsProps {
|
interface OpenChamberVisualSettingsProps {
|
||||||
/** Which settings to show. If undefined, shows all. */
|
/** Which settings to show. If undefined, shows all. */
|
||||||
@@ -74,6 +75,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
const setPadding = useUIStore(state => state.setPadding);
|
const setPadding = useUIStore(state => state.setPadding);
|
||||||
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
|
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
|
||||||
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
|
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
|
||||||
|
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
|
||||||
|
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
|
||||||
const {
|
const {
|
||||||
themeMode,
|
themeMode,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
@@ -292,6 +295,27 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shouldShow('queueMode') && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3.5 w-3.5 accent-primary"
|
||||||
|
checked={queueModeEnabled}
|
||||||
|
onChange={(event) => setQueueMode(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="typography-ui-header font-semibold text-foreground">
|
||||||
|
Queue messages by default
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="typography-meta text-muted-foreground pl-5">
|
||||||
|
{queueModeEnabled
|
||||||
|
? 'Enter queues messages, Ctrl+Enter sends immediately.'
|
||||||
|
: 'Enter sends immediately, Ctrl+Enter queues messages.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export interface SettingsPayload {
|
|||||||
showReasoningTraces?: boolean;
|
showReasoningTraces?: boolean;
|
||||||
autoDeleteEnabled?: boolean;
|
autoDeleteEnabled?: boolean;
|
||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
|
queueModeEnabled?: boolean;
|
||||||
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type DesktopSettings = {
|
|||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
defaultModel?: string; // format: "provider/model"
|
defaultModel?: string; // format: "provider/model"
|
||||||
defaultAgent?: string;
|
defaultAgent?: string;
|
||||||
|
queueModeEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DesktopSettingsApi = {
|
export type DesktopSettingsApi = {
|
||||||
|
|||||||
@@ -393,6 +393,16 @@ class OpencodeService {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
url: string;
|
url: string;
|
||||||
}>;
|
}>;
|
||||||
|
/** Additional text/file parts to include (for batch sending queued messages) */
|
||||||
|
additionalParts?: Array<{
|
||||||
|
text: string;
|
||||||
|
files?: Array<{
|
||||||
|
type: 'file';
|
||||||
|
mime: string;
|
||||||
|
filename?: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
|
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
@@ -433,6 +443,28 @@ class OpencodeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add additional parts (for batch/queued messages)
|
||||||
|
if (params.additionalParts && params.additionalParts.length > 0) {
|
||||||
|
for (const additional of params.additionalParts) {
|
||||||
|
if (additional.text && additional.text.trim()) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
text: additional.text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (additional.files && additional.files.length > 0) {
|
||||||
|
for (const file of additional.files) {
|
||||||
|
parts.push({
|
||||||
|
type: 'file',
|
||||||
|
mime: file.mime,
|
||||||
|
filename: file.filename,
|
||||||
|
url: file.url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (params.agentMentions && params.agentMentions.length > 0) {
|
if (params.agentMentions && params.agentMentions.length > 0) {
|
||||||
const [first] = params.agentMentions;
|
const [first] = params.agentMentions;
|
||||||
if (first?.name) {
|
if (first?.name) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop';
|
import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop';
|
||||||
import type { DesktopSettings } from '@/lib/desktop';
|
import type { DesktopSettings } from '@/lib/desktop';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||||
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null
|
|||||||
|
|
||||||
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||||
const store = useUIStore.getState();
|
const store = useUIStore.getState();
|
||||||
|
const queueStore = useMessageQueueStore.getState();
|
||||||
|
|
||||||
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
||||||
store.setShowReasoningTraces(settings.showReasoningTraces);
|
store.setShowReasoningTraces(settings.showReasoningTraces);
|
||||||
@@ -68,6 +70,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
store.setAutoDeleteAfterDays(normalized);
|
store.setAutoDeleteAfterDays(normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
|
||||||
|
queueStore.setQueueMode(settings.queueModeEnabled);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||||
@@ -131,6 +136,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
|
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
|
||||||
result.defaultAgent = candidate.defaultAgent;
|
result.defaultAgent = candidate.defaultAgent;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||||
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
|
import type { AttachedFile } from './types/sessionTypes';
|
||||||
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
|
||||||
|
export interface QueuedMessage {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
attachments?: AttachedFile[];
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageQueueState {
|
||||||
|
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
|
||||||
|
queueModeEnabled: boolean; // global toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageQueueActions {
|
||||||
|
addToQueue: (sessionId: string, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
||||||
|
removeFromQueue: (sessionId: string, messageId: string) => void;
|
||||||
|
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
|
||||||
|
clearQueue: (sessionId: string) => void;
|
||||||
|
clearAllQueues: () => void;
|
||||||
|
setQueueMode: (enabled: boolean) => void;
|
||||||
|
getQueueForSession: (sessionId: string) => QueuedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
||||||
|
|
||||||
|
export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
queuedMessages: {},
|
||||||
|
queueModeEnabled: false,
|
||||||
|
|
||||||
|
addToQueue: (sessionId, message) => {
|
||||||
|
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
const queuedMessage: QueuedMessage = {
|
||||||
|
id,
|
||||||
|
content: message.content,
|
||||||
|
attachments: message.attachments,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||||
|
return {
|
||||||
|
queuedMessages: {
|
||||||
|
...state.queuedMessages,
|
||||||
|
[sessionId]: [...currentQueue, queuedMessage],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromQueue: (sessionId, messageId) => {
|
||||||
|
set((state) => {
|
||||||
|
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||||
|
const newQueue = currentQueue.filter((m) => m.id !== messageId);
|
||||||
|
|
||||||
|
if (newQueue.length === 0) {
|
||||||
|
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
|
||||||
|
void _removed;
|
||||||
|
return { queuedMessages: rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
queuedMessages: {
|
||||||
|
...state.queuedMessages,
|
||||||
|
[sessionId]: newQueue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
popToInput: (sessionId, messageId) => {
|
||||||
|
const state = get();
|
||||||
|
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||||
|
const message = currentQueue.find((m) => m.id === messageId);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
set((prevState) => {
|
||||||
|
const queue = prevState.queuedMessages[sessionId] ?? [];
|
||||||
|
const newQueue = queue.filter((m) => m.id !== messageId);
|
||||||
|
|
||||||
|
if (newQueue.length === 0) {
|
||||||
|
const { [sessionId]: _removed, ...rest } = prevState.queuedMessages;
|
||||||
|
void _removed;
|
||||||
|
return { queuedMessages: rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
queuedMessages: {
|
||||||
|
...prevState.queuedMessages,
|
||||||
|
[sessionId]: newQueue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearQueue: (sessionId) => {
|
||||||
|
set((state) => {
|
||||||
|
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
|
||||||
|
void _removed;
|
||||||
|
return { queuedMessages: rest };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAllQueues: () => {
|
||||||
|
set({ queuedMessages: {} });
|
||||||
|
},
|
||||||
|
|
||||||
|
setQueueMode: (enabled) => {
|
||||||
|
set({ queueModeEnabled: enabled });
|
||||||
|
// Persist to settings.json (async, fire-and-forget)
|
||||||
|
void updateDesktopSettings({ queueModeEnabled: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
getQueueForSession: (sessionId) => {
|
||||||
|
return get().queuedMessages[sessionId] ?? [];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'message-queue-store',
|
||||||
|
storage: createJSONStorage(() => getSafeStorage()),
|
||||||
|
partialize: (state) => ({
|
||||||
|
queuedMessages: state.queuedMessages,
|
||||||
|
queueModeEnabled: state.queueModeEnabled,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{
|
||||||
|
name: 'message-queue-store',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -351,7 +351,7 @@ interface MessageState {
|
|||||||
|
|
||||||
interface MessageActions {
|
interface MessageActions {
|
||||||
loadMessages: (sessionId: string) => Promise<void>;
|
loadMessages: (sessionId: string) => Promise<void>;
|
||||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null) => Promise<void>;
|
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise<void>;
|
||||||
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
||||||
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
||||||
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
||||||
@@ -551,7 +551,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null) => {
|
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
throw new Error("No session selected");
|
throw new Error("No session selected");
|
||||||
}
|
}
|
||||||
@@ -676,6 +676,17 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
return { pendingAssistantHeaderSessions: next, pendingUserMessageMetaBySession: nextUserMeta };
|
return { pendingAssistantHeaderSessions: next, pendingUserMessageMetaBySession: nextUserMeta };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Convert additional parts to SDK format
|
||||||
|
const additionalPartsPayload = additionalParts?.map((part) => ({
|
||||||
|
text: part.text,
|
||||||
|
files: part.attachments?.map((file) => ({
|
||||||
|
type: "file" as const,
|
||||||
|
mime: file.mimeType,
|
||||||
|
filename: file.filename,
|
||||||
|
url: file.dataUrl,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
await opencodeClient.sendMessage({
|
await opencodeClient.sendMessage({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
providerID,
|
providerID,
|
||||||
@@ -683,6 +694,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
text: effectiveContent,
|
text: effectiveContent,
|
||||||
agent,
|
agent,
|
||||||
files: filePayloads.length > 0 ? filePayloads : undefined,
|
files: filePayloads.length > 0 ? filePayloads : undefined,
|
||||||
|
additionalParts: additionalPartsPayload,
|
||||||
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
|
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export interface SessionStore {
|
|||||||
unshareSession: (id: string) => Promise<Session | null>;
|
unshareSession: (id: string) => Promise<Session | null>;
|
||||||
setCurrentSession: (id: string | null) => void;
|
setCurrentSession: (id: string | null) => void;
|
||||||
loadMessages: (sessionId: string) => Promise<void>;
|
loadMessages: (sessionId: string) => Promise<void>;
|
||||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string) => Promise<void>;
|
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise<void>;
|
||||||
abortCurrentOperation: () => Promise<void>;
|
abortCurrentOperation: () => Promise<void>;
|
||||||
acknowledgeSessionAbort: (sessionId: string) => void;
|
acknowledgeSessionAbort: (sessionId: string) => void;
|
||||||
armAbortPrompt: (durationMs?: number) => number | null;
|
armAbortPrompt: (durationMs?: number) => number | null;
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
get().evictLeastRecentlyUsed();
|
get().evictLeastRecentlyUsed();
|
||||||
},
|
},
|
||||||
loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId),
|
loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId),
|
||||||
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string) => {
|
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => {
|
||||||
const draft = get().newSessionDraft;
|
const draft = get().newSessionDraft;
|
||||||
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
try {
|
try {
|
||||||
return await useMessageStore
|
return await useMessageStore
|
||||||
.getState()
|
.getState()
|
||||||
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName);
|
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIdlePhase(created.id);
|
setIdlePhase(created.id);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -380,7 +380,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName);
|
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
setIdlePhase(currentSessionId);
|
setIdlePhase(currentSessionId);
|
||||||
|
|||||||
@@ -421,6 +421,9 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
|
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
|
||||||
result.defaultAgent = candidate.defaultAgent;
|
result.defaultAgent = candidate.defaultAgent;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||||
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -501,12 +504,26 @@ const persistSettings = async (changes) => {
|
|||||||
return formatSettingsResponse(next);
|
return formatSettingsResponse(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global state
|
// HMR-persistent state via globalThis
|
||||||
let openCodeProcess = null;
|
// These values survive Vite HMR reloads to prevent zombie OpenCode processes
|
||||||
let openCodePort = null;
|
const HMR_STATE_KEY = '__openchamberHmrState';
|
||||||
|
const getHmrState = () => {
|
||||||
|
if (!globalThis[HMR_STATE_KEY]) {
|
||||||
|
globalThis[HMR_STATE_KEY] = {
|
||||||
|
openCodeProcess: null,
|
||||||
|
openCodePort: null,
|
||||||
|
openCodeWorkingDirectory: process.cwd(),
|
||||||
|
isShuttingDown: false,
|
||||||
|
signalsAttached: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return globalThis[HMR_STATE_KEY];
|
||||||
|
};
|
||||||
|
const hmrState = getHmrState();
|
||||||
|
|
||||||
|
// Non-HMR state (safe to reset on reload)
|
||||||
let healthCheckInterval = null;
|
let healthCheckInterval = null;
|
||||||
let server = null;
|
let server = null;
|
||||||
let isShuttingDown = false;
|
|
||||||
let cachedModelsMetadata = null;
|
let cachedModelsMetadata = null;
|
||||||
let cachedModelsMetadataTimestamp = 0;
|
let cachedModelsMetadataTimestamp = 0;
|
||||||
let expressApp = null;
|
let expressApp = null;
|
||||||
@@ -522,10 +539,60 @@ let openCodePortWaiters = [];
|
|||||||
let isOpenCodeReady = false;
|
let isOpenCodeReady = false;
|
||||||
let openCodeNotReadySince = 0;
|
let openCodeNotReadySince = 0;
|
||||||
let exitOnShutdown = true;
|
let exitOnShutdown = true;
|
||||||
let signalsAttached = false;
|
|
||||||
let openCodeWorkingDirectory = process.cwd();
|
|
||||||
let uiAuthController = null;
|
let uiAuthController = null;
|
||||||
|
|
||||||
|
// Sync helper - call after modifying any HMR state variable
|
||||||
|
const syncToHmrState = () => {
|
||||||
|
hmrState.openCodeProcess = openCodeProcess;
|
||||||
|
hmrState.openCodePort = openCodePort;
|
||||||
|
hmrState.isShuttingDown = isShuttingDown;
|
||||||
|
hmrState.signalsAttached = signalsAttached;
|
||||||
|
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync helper - call to restore state from HMR (e.g., on module reload)
|
||||||
|
const syncFromHmrState = () => {
|
||||||
|
openCodeProcess = hmrState.openCodeProcess;
|
||||||
|
openCodePort = hmrState.openCodePort;
|
||||||
|
isShuttingDown = hmrState.isShuttingDown;
|
||||||
|
signalsAttached = hmrState.signalsAttached;
|
||||||
|
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Module-level variables that shadow HMR state
|
||||||
|
// These are synced to/from hmrState to survive HMR reloads
|
||||||
|
let openCodeProcess = hmrState.openCodeProcess;
|
||||||
|
let openCodePort = hmrState.openCodePort;
|
||||||
|
let isShuttingDown = hmrState.isShuttingDown;
|
||||||
|
let signalsAttached = hmrState.signalsAttached;
|
||||||
|
let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an existing OpenCode process is still alive and responding
|
||||||
|
* Used to reuse process across HMR reloads
|
||||||
|
*/
|
||||||
|
async function isOpenCodeProcessHealthy() {
|
||||||
|
if (!openCodeProcess || !openCodePort) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if process is still running
|
||||||
|
if (openCodeProcess.exitCode !== null || openCodeProcess.signalCode !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check via HTTP
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${openCodePort}/session`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const OPENCODE_BINARY_ENV =
|
const OPENCODE_BINARY_ENV =
|
||||||
process.env.OPENCODE_BINARY ||
|
process.env.OPENCODE_BINARY ||
|
||||||
process.env.OPENCHAMBER_BINARY ||
|
process.env.OPENCHAMBER_BINARY ||
|
||||||
@@ -657,6 +724,7 @@ function setOpenCodePort(port) {
|
|||||||
|
|
||||||
if (portChanged || openCodePort === null) {
|
if (portChanged || openCodePort === null) {
|
||||||
openCodePort = numericPort;
|
openCodePort = numericPort;
|
||||||
|
syncToHmrState();
|
||||||
console.log(`Detected OpenCode port: ${openCodePort}`);
|
console.log(`Detected OpenCode port: ${openCodePort}`);
|
||||||
|
|
||||||
if (portChanged) {
|
if (portChanged) {
|
||||||
@@ -1165,6 +1233,7 @@ async function startOpenCode() {
|
|||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
lastOpenCodeError = error.message;
|
lastOpenCodeError = error.message;
|
||||||
openCodePort = null;
|
openCodePort = null;
|
||||||
|
syncToHmrState();
|
||||||
settleFirstSignal();
|
settleFirstSignal();
|
||||||
return error;
|
return error;
|
||||||
});
|
});
|
||||||
@@ -1285,6 +1354,7 @@ async function restartOpenCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCodeProcess = null;
|
openCodeProcess = null;
|
||||||
|
syncToHmrState();
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
}
|
}
|
||||||
@@ -1294,6 +1364,7 @@ async function restartOpenCode() {
|
|||||||
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
|
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
|
||||||
} else {
|
} else {
|
||||||
openCodePort = null;
|
openCodePort = null;
|
||||||
|
syncToHmrState();
|
||||||
}
|
}
|
||||||
openCodeApiPrefixDetected = false;
|
openCodeApiPrefixDetected = false;
|
||||||
if (openCodeApiDetectionTimer) {
|
if (openCodeApiDetectionTimer) {
|
||||||
@@ -1304,6 +1375,7 @@ async function restartOpenCode() {
|
|||||||
|
|
||||||
lastOpenCodeError = null;
|
lastOpenCodeError = null;
|
||||||
openCodeProcess = await startOpenCode();
|
openCodeProcess = await startOpenCode();
|
||||||
|
syncToHmrState();
|
||||||
|
|
||||||
if (!ENV_CONFIGURED_OPENCODE_PORT) {
|
if (!ENV_CONFIGURED_OPENCODE_PORT) {
|
||||||
await waitForOpenCodePort();
|
await waitForOpenCodePort();
|
||||||
@@ -1322,6 +1394,7 @@ async function restartOpenCode() {
|
|||||||
lastOpenCodeError = error.message;
|
lastOpenCodeError = error.message;
|
||||||
if (!ENV_CONFIGURED_OPENCODE_PORT) {
|
if (!ENV_CONFIGURED_OPENCODE_PORT) {
|
||||||
openCodePort = null;
|
openCodePort = null;
|
||||||
|
syncToHmrState();
|
||||||
}
|
}
|
||||||
openCodeApiPrefixDetected = false;
|
openCodeApiPrefixDetected = false;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1684,6 +1757,7 @@ async function gracefulShutdown(options = {}) {
|
|||||||
if (isShuttingDown) return;
|
if (isShuttingDown) return;
|
||||||
|
|
||||||
isShuttingDown = true;
|
isShuttingDown = true;
|
||||||
|
syncToHmrState();
|
||||||
console.log('Starting graceful shutdown...');
|
console.log('Starting graceful shutdown...');
|
||||||
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown;
|
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown;
|
||||||
|
|
||||||
@@ -3026,6 +3100,7 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCodeWorkingDirectory = resolvedPath;
|
openCodeWorkingDirectory = resolvedPath;
|
||||||
|
syncToHmrState();
|
||||||
|
|
||||||
await refreshOpenCodeAfterConfigChange('directory change');
|
await refreshOpenCodeAfterConfigChange('directory change');
|
||||||
|
|
||||||
@@ -3489,15 +3564,24 @@ async function main(options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ENV_CONFIGURED_OPENCODE_PORT) {
|
// Check if we can reuse an existing OpenCode process from a previous HMR cycle
|
||||||
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
|
syncFromHmrState();
|
||||||
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
|
if (await isOpenCodeProcessHealthy()) {
|
||||||
|
console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`);
|
||||||
} else {
|
} else {
|
||||||
openCodePort = null;
|
// No healthy process, start fresh
|
||||||
}
|
if (ENV_CONFIGURED_OPENCODE_PORT) {
|
||||||
|
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
|
||||||
|
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
|
||||||
|
} else {
|
||||||
|
openCodePort = null;
|
||||||
|
syncToHmrState();
|
||||||
|
}
|
||||||
|
|
||||||
lastOpenCodeError = null;
|
lastOpenCodeError = null;
|
||||||
openCodeProcess = await startOpenCode();
|
openCodeProcess = await startOpenCode();
|
||||||
|
syncToHmrState();
|
||||||
|
}
|
||||||
await waitForOpenCodePort();
|
await waitForOpenCodePort();
|
||||||
try {
|
try {
|
||||||
await waitForOpenCodeReady();
|
await waitForOpenCodeReady();
|
||||||
@@ -3555,6 +3639,7 @@ async function main(options = {}) {
|
|||||||
process.on('SIGINT', gracefulShutdown);
|
process.on('SIGINT', gracefulShutdown);
|
||||||
process.on('SIGQUIT', gracefulShutdown);
|
process.on('SIGQUIT', gracefulShutdown);
|
||||||
signalsAttached = true;
|
signalsAttached = true;
|
||||||
|
syncToHmrState();
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user