diff --git a/CHANGELOG.md b/CHANGELOG.md index 857a40b5..8c4422df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Added Intel Mac (x86_64) support for the desktop application. -- Build workflow now generates separate builds for Apple Silicon (arm64) and Intel (x86_64) Macs. +- 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 (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 diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index 518efea1..3fd6ef19 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -152,6 +152,9 @@ fn sanitize_settings_update(payload: &Value) -> Value { if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") { 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 if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index e3cbb12a..14601330 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -11,9 +11,11 @@ import { import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; 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 { AttachedFilesList } from './FileAttachment'; +import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete'; import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete'; @@ -23,6 +25,7 @@ import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; +import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from 'sonner'; import { useFileStore } from '@/stores/fileStore'; import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; @@ -35,6 +38,7 @@ import { } from '@/components/ui/dropdown-menu'; const MAX_VISIBLE_TEXTAREA_LINES = 8; +const EMPTY_QUEUE: QueuedMessage[] = []; interface ChatInputProps { onOpenSettings?: () => void; @@ -67,6 +71,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId); const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt); const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt); + const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags); const attachedFiles = useSessionStore((state) => state.attachedFiles); const addAttachedFile = useSessionStore((state) => state.addAttachedFile); const addServerFile = useSessionStore((state) => state.addServerFile); @@ -84,6 +89,25 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const prevWasAbortedRef = 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) => { if (!isMobile) { return; @@ -219,6 +243,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [chatInputAccent, softenBorderColor]); const hasContent = message.trim() || attachedFiles.length > 0; + const hasQueuedMessages = queuedMessages.length > 0; + const canSend = hasContent || hasQueuedMessages; const canAbort = working.isWorking; @@ -227,16 +253,110 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt); }, [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) => { e?.preventDefault(); - if (!hasContent || (!currentSessionId && !newSessionDraftOpen)) return; - - const messageToSend = message.replace(/^\n+|\n+$/g, ''); + if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return; 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('/')) { const commandName = normalizedCommand .slice(1) @@ -248,29 +368,21 @@ export const ChatInput: React.FC = ({ 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'); - return; - } - - const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); - const agentMentionName = mention?.name; - - const attachmentsToSend = attachedFiles.map((file) => ({ ...file })); - if (attachmentsToSend.length > 0) { - clearAttachedFiles(); - } - - setMessage(''); - - if (isMobile) { - textareaRef.current?.blur(); - } - - await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName) - - .catch((error: unknown) => { + await sendMessage( + primaryText, + currentProviderId, + currentModelId, + currentAgentName, + primaryAttachments, + agentMentionName, + additionalParts.length > 0 ? additionalParts : undefined + ).catch((error: unknown) => { const rawMessage = error instanceof Error ? error.message @@ -293,12 +405,11 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo normalized === 'failed to send message'; if (isSoftNetworkError) { - return; } - if (attachmentsToSend.length > 0) { - useFileStore.setState({ attachedFiles: attachmentsToSend }); + if (allAttachments.length > 0) { + useFileStore.setState({ attachedFiles: allAttachments }); } toast.error(rawMessage || 'Message failed to send. Attachments restored.'); }); @@ -306,9 +417,42 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!isMobile) { 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) => { if (showCommandAutocomplete && commandRef.current) { @@ -341,9 +485,35 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo return; } + // Handle Enter/Ctrl+Enter based on queue mode if (e.key === 'Enter' && !e.shiftKey && !isMobile) { 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 = ({ onOpenSettings, scrollToBo ) : ( + + + ); +}); + +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 ( +
+
+ Queued: + {queuedMessages.map((message) => ( + + ))} +
+
+ ); +}); + +QueuedMessageChips.displayName = 'QueuedMessageChips'; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 043c79ec..d5b41b62 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -69,9 +69,9 @@ const VisualSectionContent: React.FC = () => { return ; }; -// 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 = () => { - return ; + return ; }; // Sessions section: Default model & agent, Session retention diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 3e47ba7b..0186da7c 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -4,6 +4,7 @@ import { RiRestartLine } from '@remixicon/react'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import type { ThemeMode } from '@/types/theme'; import { useUIStore } from '@/stores/useUIStore'; +import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { cn } from '@/lib/utils'; import { ButtonSmall } from '@/components/ui/button-small'; 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 { /** Which settings to show. If undefined, shows all. */ @@ -74,6 +75,8 @@ export const OpenChamberVisualSettings: React.FC const setPadding = useUIStore(state => state.setPadding); const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference); const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference); + const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled); + const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const { themeMode, setThemeMode, @@ -292,6 +295,27 @@ export const OpenChamberVisualSettings: React.FC )} + + {shouldShow('queueMode') && ( +
+ +

+ {queueModeEnabled + ? 'Enter queues messages, Ctrl+Enter sends immediately.' + : 'Enter sends immediately, Ctrl+Enter queues messages.'} +

+
+ )} ); }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 0f520976..d6249169 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -332,6 +332,7 @@ export interface SettingsPayload { showReasoningTraces?: boolean; autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; + queueModeEnabled?: boolean; [key: string]: unknown; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 88e499a4..ccadea7f 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -43,6 +43,7 @@ export type DesktopSettings = { autoDeleteAfterDays?: number; defaultModel?: string; // format: "provider/model" defaultAgent?: string; + queueModeEnabled?: boolean; }; export type DesktopSettingsApi = { diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index b33c3bc7..5780610d 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -393,6 +393,16 @@ class OpencodeService { filename?: 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; agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>; }): Promise { @@ -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) { const [first] = params.agentMentions; if (first?.name) { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 33007f7f..35898e4a 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1,6 +1,7 @@ import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop'; import type { DesktopSettings } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; +import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -55,6 +56,7 @@ const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null const applyDesktopUiPreferences = (settings: DesktopSettings) => { const store = useUIStore.getState(); + const queueStore = useMessageQueueStore.getState(); if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) { store.setShowReasoningTraces(settings.showReasoningTraces); @@ -68,6 +70,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setAutoDeleteAfterDays(normalized); } } + if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { + queueStore.setQueueMode(settings.queueModeEnabled); + } }; 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) { result.defaultAgent = candidate.defaultAgent; } + if (typeof candidate.queueModeEnabled === 'boolean') { + result.queueModeEnabled = candidate.queueModeEnabled; + } return result; }; diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts new file mode 100644 index 00000000..386500c1 --- /dev/null +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -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; // sessionId → queue + queueModeEnabled: boolean; // global toggle +} + +interface MessageQueueActions { + addToQueue: (sessionId: string, message: Omit) => 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()( + 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', + } + ) +); diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index 04394619..a63a64c5 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -351,7 +351,7 @@ interface MessageState { interface MessageActions { loadMessages: (sessionId: string) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null) => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise; abortCurrentOperation: (currentSessionId?: string) => Promise; _addStreamingPartImmediate: (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()( }); }, - 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) { throw new Error("No session selected"); } @@ -676,6 +676,17 @@ export const useMessageStore = create()( 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({ id: sessionId, providerID, @@ -683,6 +694,7 @@ export const useMessageStore = create()( text: effectiveContent, agent, files: filePayloads.length > 0 ? filePayloads : undefined, + additionalParts: additionalPartsPayload, agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, }); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index ce9b56f1..4cdcbc4f 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -125,7 +125,7 @@ export interface SessionStore { unshareSession: (id: string) => Promise; setCurrentSession: (id: string | null) => void; loadMessages: (sessionId: string) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string) => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise; abortCurrentOperation: () => Promise; acknowledgeSessionAbort: (sessionId: string) => void; armAbortPrompt: (durationMs?: number) => number | null; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 9bcc8db4..fc8d831b 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -278,7 +278,7 @@ export const useSessionStore = create()( get().evictLeastRecentlyUsed(); }, 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 trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; @@ -353,7 +353,7 @@ export const useSessionStore = create()( try { return await useMessageStore .getState() - .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName); + .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts); } catch (error) { setIdlePhase(created.id); throw error; @@ -380,7 +380,7 @@ export const useSessionStore = create()( } 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) { if (currentSessionId) { setIdlePhase(currentSessionId); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 3d1d3fb7..44ce8257 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -421,6 +421,9 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) { result.defaultAgent = candidate.defaultAgent; } + if (typeof candidate.queueModeEnabled === 'boolean') { + result.queueModeEnabled = candidate.queueModeEnabled; + } return result; }; @@ -501,12 +504,26 @@ const persistSettings = async (changes) => { return formatSettingsResponse(next); }; -// Global state -let openCodeProcess = null; -let openCodePort = null; +// HMR-persistent state via globalThis +// These values survive Vite HMR reloads to prevent zombie OpenCode processes +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 server = null; -let isShuttingDown = false; let cachedModelsMetadata = null; let cachedModelsMetadataTimestamp = 0; let expressApp = null; @@ -522,10 +539,60 @@ let openCodePortWaiters = []; let isOpenCodeReady = false; let openCodeNotReadySince = 0; let exitOnShutdown = true; -let signalsAttached = false; -let openCodeWorkingDirectory = process.cwd(); 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 = process.env.OPENCODE_BINARY || process.env.OPENCHAMBER_BINARY || @@ -657,6 +724,7 @@ function setOpenCodePort(port) { if (portChanged || openCodePort === null) { openCodePort = numericPort; + syncToHmrState(); console.log(`Detected OpenCode port: ${openCodePort}`); if (portChanged) { @@ -1165,6 +1233,7 @@ async function startOpenCode() { }).catch((error) => { lastOpenCodeError = error.message; openCodePort = null; + syncToHmrState(); settleFirstSignal(); return error; }); @@ -1285,6 +1354,7 @@ async function restartOpenCode() { } openCodeProcess = null; + syncToHmrState(); await new Promise((resolve) => setTimeout(resolve, 250)); } @@ -1294,6 +1364,7 @@ async function restartOpenCode() { setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); } else { openCodePort = null; + syncToHmrState(); } openCodeApiPrefixDetected = false; if (openCodeApiDetectionTimer) { @@ -1304,6 +1375,7 @@ async function restartOpenCode() { lastOpenCodeError = null; openCodeProcess = await startOpenCode(); + syncToHmrState(); if (!ENV_CONFIGURED_OPENCODE_PORT) { await waitForOpenCodePort(); @@ -1322,6 +1394,7 @@ async function restartOpenCode() { lastOpenCodeError = error.message; if (!ENV_CONFIGURED_OPENCODE_PORT) { openCodePort = null; + syncToHmrState(); } openCodeApiPrefixDetected = false; throw error; @@ -1684,6 +1757,7 @@ async function gracefulShutdown(options = {}) { if (isShuttingDown) return; isShuttingDown = true; + syncToHmrState(); console.log('Starting graceful shutdown...'); const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown; @@ -3026,6 +3100,7 @@ async function main(options = {}) { } openCodeWorkingDirectory = resolvedPath; + syncToHmrState(); await refreshOpenCodeAfterConfigChange('directory change'); @@ -3489,15 +3564,24 @@ async function main(options = {}) { }); try { - if (ENV_CONFIGURED_OPENCODE_PORT) { - console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); - setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); + // Check if we can reuse an existing OpenCode process from a previous HMR cycle + syncFromHmrState(); + if (await isOpenCodeProcessHealthy()) { + console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); } 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; - openCodeProcess = await startOpenCode(); + lastOpenCodeError = null; + openCodeProcess = await startOpenCode(); + syncToHmrState(); + } await waitForOpenCodePort(); try { await waitForOpenCodeReady(); @@ -3555,6 +3639,7 @@ async function main(options = {}) { process.on('SIGINT', gracefulShutdown); process.on('SIGQUIT', gracefulShutdown); signalsAttached = true; + syncToHmrState(); } process.on('unhandledRejection', (reason, promise) => {