From f13f6d55408dd963a89965e9f2f5f25f0fff6e24 Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:28:20 +1100 Subject: [PATCH] feat(#1766): support OpenCode steer delivery / follow-up behavior settings (#1781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support OpenCode steer delivery / follow-up behavior settings Implements issue #1766 — steer delivery mode for mid-turn message insertion, replacing the old boolean queue-mode toggle with a tri-state follow-up behavior setting (Steer / Queue / Send immediately). - Plumbing: threaded optional delivery: 'steer' through sendMessage -> routeMessage -> opencodeClient.sendMessage -> promptAsync - Store: messageQueueStore stores followUpBehavior; migration from legacy queueModeEnabled persisted state - Settings: Chat -> Follow-up behavior shows three radio options using existing settings UI patterns - Composer: when session is busy, a floating queue button remains; force-sending a queued message (via chip click) uses delivery: 'steer' during a busy session; Steer button intentionally omitted — steer is available via the two-gesture path (Enter to queue -> chip to steer) - Keyboard: queue mode = Enter queues, Ctrl+Enter sends; otherwise Enter sends, Ctrl+Enter queues - Persistence: DesktopSettings, web settings payload, and server-side sanitizer handle the new key with legacy fallback - i18n: follow-up behavior section and option labels in all 9 locales plus new chat.chatInput.actions.queue label - Search: settings registry updated from chat.queue-mode to chat.follow-up-behavior Validation: type-check passes (no new errors), lint clean. * fix(#1766): make steer mode actually steer The followUpBehavior === 'steer' branch in handlePrimaryAction and the keyboard handler was a no-op — both fell into the else branch and sent without the delivery: 'steer' flag, so selecting 'Steer (insert into the running turn)' in settings produced identical behavior to 'Send immediately'. - handlePrimaryAction: when steer mode is selected and the session is busy, call handleSubmit({ delivery: 'steer' }) directly - Keyboard handler: in steer mode, Enter steers and Ctrl+Enter sends immediately (consistent with queue mode where Ctrl+Enter bypasses the special handling) Also removes the unused chat.chatInput.actions.queue i18n key from all 9 locales (it was a dead key after the Steer button was removed from the composer). Validation: type-check clean, lint clean. * refactor(#1766): flatten nested ternary in followUpBehavior resolution Replace nested ternary with explicit if/else chain per project code style (CONTRIBUTING.md). Import FollowUpBehavior type explicitly for the new let declaration. * feat(chat): drop redundant 'immediate' follow-up mode, keep Queue + Steer 'Immediate' was wire-identical to 'Steer' on a busy session: OpenCode only supports delivery 'steer' | 'queue' and defaults to 'steer', so an immediate send (no delivery flag) already steered into the running turn. The three-mode UI therefore exposed two settings that did the same thing. Collapse to two modes — Queue (unchanged: client-side queue with edit/reorder) and Steer. Any persisted/legacy 'immediate' (and legacy queueModeEnabled=false) now maps to 'steer', preserving prior behavior. Removes the immediate option, its keyboard branch, the i18n label across all locales, and narrows the followUpBehavior union to 'steer' | 'queue'. --------- Co-authored-by: Leonid Skorobogatyy Co-authored-by: Bohdan Triapitsyn --- .gitignore | 3 + packages/ui/src/components/chat/ChatInput.tsx | 48 ++++++---- .../sections/openchamber/OpenChamberPage.tsx | 4 +- .../openchamber/OpenChamberVisualSettings.tsx | 89 +++++++++++-------- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 1 + .../ui/src/lib/i18n/messages/en.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 6 ++ packages/ui/src/lib/opencode/client.ts | 2 + packages/ui/src/lib/persistence.ts | 18 ++-- packages/ui/src/lib/settings/search.ts | 8 +- packages/ui/src/stores/messageQueueStore.ts | 63 +++++++++++-- packages/ui/src/sync/session-ui-store.ts | 5 ++ .../server/lib/opencode/settings-helpers.js | 20 ++++- 22 files changed, 244 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index f69072c8..305aec4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Agent memory +.graymatter/ + # Logs logs *.log diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 91e972c6..bee0518b 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1420,7 +1420,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } | null>(null); // Message queue - const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled); + const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { @@ -1697,6 +1697,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo type SubmitOptions = { queuedOnly?: boolean; queuedMessageId?: string; + delivery?: 'steer'; }; const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {}); @@ -1746,7 +1747,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, []); const handleQueuedMessageSend = React.useCallback((messageId: string) => { - void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId }); + // Force-sending from the queue during a busy session counts as steer + void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId, delivery: 'steer' }); }, []); const handleOpenAgentPanel = React.useCallback(() => { @@ -1768,6 +1770,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; + const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const inputSnapshot = getCurrentInputSnapshot(); const queuedMessagesToSend = queuedMessageId ? queuedMessages.filter((message) => message.id === queuedMessageId) @@ -1816,6 +1819,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } + const sendMessageOptions = delivery ? { delivery } : undefined; + // Build the primary message (first part) and additional parts let primaryText = ''; let primaryAttachments: AttachedFile[] = []; @@ -2024,6 +2029,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2046,6 +2052,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2072,6 +2079,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2094,6 +2102,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2116,6 +2125,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2138,6 +2148,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2160,6 +2171,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2208,7 +2220,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo agentMentionName, additionalParts.length > 0 ? additionalParts : undefined, variantToSend, - inputMode + inputMode, + sendMessageOptions, ); if (typeof window === 'undefined') { @@ -2279,16 +2292,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Update ref with latest handleSubmit on every render handleSubmitRef.current = handleSubmit; - // Primary action for send button - respects queue mode setting + // Primary action for send/queue button — respects selected follow-up behavior const handlePrimaryAction = React.useCallback(() => { const inputSnapshot = getCurrentInputSnapshot(); const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); - if (queueModeEnabled && canQueue) { + if (followUpBehavior === 'queue' && canQueue) { handleQueueMessage(); + } else if (followUpBehavior === 'steer' && canQueue) { + void handleSubmitRef.current({ delivery: 'steer' }); } else { void handleSubmitRef.current(); } - }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, queueModeEnabled, handleQueueMessage]); + }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]); // Draft welcome presets: populate the composer and submit immediately. // getCurrentInputSnapshot reads textareaRef.current.value first, so setting it @@ -2527,33 +2542,28 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - // Handle Enter/Ctrl+Enter based on queue mode + // Handle Enter/Ctrl+Enter based on selected follow-up behavior. if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) { e.preventDefault(); 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 + // Queueing / steering only works when there's an existing busy + // session (or an active auto-review run). const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); - if (queueModeEnabled) { + if (followUpBehavior === 'queue') { 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 + // steer: Enter steers into the running turn, Ctrl+Enter sends now. + if (isCtrlEnter || !canQueue) { handleSubmit(); + } else { + handleSubmit({ delivery: 'steer' }); } } } diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 3cd6dfe0..d149ca43 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -142,9 +142,9 @@ const VisualSectionContent: React.FC = () => { ]} />; }; -// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft +// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft 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 c1efef3d..433d5053 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -5,8 +5,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useThemeSystem } from '@/contexts/useThemeSystem'; import type { ThemeMode } from '@/types/theme'; import { useUIStore } from '@/stores/useUIStore'; -import { useMessageQueueStore } from '@/stores/messageQueueStore'; -import { cn, getModifierLabel } from '@/lib/utils'; +import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; +import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { NumberInput } from '@/components/ui/number-input'; @@ -230,11 +230,22 @@ const WEEK_START_OPTIONS: Option<'auto' | 'monday' | 'sunday'>[] = [ }, ]; +const FOLLOW_UP_BEHAVIOR_OPTIONS: Option[] = [ + { + id: 'steer', + labelKey: 'settings.openchamber.visual.option.followUpBehavior.steer.label', + }, + { + id: 'queue', + labelKey: 'settings.openchamber.visual.option.followUpBehavior.queue.label', + }, +]; + const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; +type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -288,8 +299,8 @@ export const OpenChamberVisualSettings: React.FC const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop); const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap); const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap); - const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled); - const setQueueMode = useMessageQueueStore(state => state.setQueueMode); + const followUpBehavior = useMessageQueueStore(state => state.followUpBehavior); + const setFollowUpBehavior = useMessageQueueStore(state => state.setFollowUpBehavior); const persistChatDraft = useUIStore(state => state.persistChatDraft); const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled); @@ -550,7 +561,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('reasoning') - || shouldShow('queueMode') + || shouldShow('followUpBehavior') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('expandedTools') @@ -1727,7 +1738,7 @@ export const OpenChamberVisualSettings: React.FC )} - {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('followUpBehavior') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{shouldShow('reasoning') && (
)} - {shouldShow('queueMode') && ( -
setQueueMode(!queueModeEnabled)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setQueueMode(!queueModeEnabled); - } - }} - > - -
- {t('settings.openchamber.visual.field.queueMessagesByDefault')} - - - - - - {t('settings.openchamber.visual.field.queueMessagesByDefaultTooltip', { modifier: getModifierLabel() })} - - + {shouldShow('followUpBehavior') && ( +
+

{t('settings.openchamber.visual.section.followUpBehavior')}

+
+ {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => { + const selected = followUpBehavior === option.id; + return ( +
setFollowUpBehavior(option.id)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setFollowUpBehavior(option.id); + } + }} + className="flex w-full items-center gap-2 py-0 text-left" + > + setFollowUpBehavior(option.id)} + ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })} + /> + + {tUnsafe(option.labelKey)} + +
+ ); + })}
-
+
)} {shouldShow('persistDraft') && ( diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c8bfd37e..d3539c29 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -638,6 +638,7 @@ export interface SettingsPayload { autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; sessionRetentionAction?: 'archive' | 'delete'; + followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 9bdcdcba..867a2929 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -115,6 +115,7 @@ export type DesktopSettings = { defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; + followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index c425b72e..81ad24fb 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'Failed to reset prompt', 'settings.magicPrompts.page.toast.resetAllSuccess': 'All prompt overrides reset', 'settings.magicPrompts.page.toast.resetAllFailed': 'Failed to reset all prompts', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 06851680..b97d5417 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Plantilla creada", "settings.promptTemplates.page.toast.createFailed": "Error al crear la plantilla", "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocurrió un error inesperado al guardar", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 8d1daedd..b1204653 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'Échec de la réinitialisation du prompt', 'settings.magicPrompts.page.toast.resetAllSuccess': 'Tous les prompts ont été réinitialisés', 'settings.magicPrompts.page.toast.resetAllFailed': 'Échec de la réinitialisation de tous les prompts', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 160bcd77..8362c34f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'プロンプトのリセットに失敗しました', 'settings.magicPrompts.page.toast.resetAllSuccess': 'すべてのプロンプト上書きをリセットしました', 'settings.magicPrompts.page.toast.resetAllFailed': 'すべてのプロンプトのリセットに失敗しました', + 'settings.openchamber.visual.section.followUpBehavior': 'フォローアップの動作', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'フォローアップの動作', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'フォローアップの動作: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア(実行中のターンに挿入)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー(現在のターンの後に送信)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 7aac2181..f578af38 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.promptTemplates.page.toast.created': '템플릿이 생성되었습니다', 'settings.promptTemplates.page.toast.createFailed': '템플릿 생성 실패', 'settings.promptTemplates.page.toast.saveUnexpectedError': '저장 중 예기치 않은 오류가 발생했습니다', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index cd62d24d..21f47137 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1792,4 +1792,10 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst', 'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown', 'settings.window.description': 'Okno ustawień OpenChamber.', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', }; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 3c38e551..34b0e241 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Template criado", "settings.promptTemplates.page.toast.createFailed": "Falha ao criar template", "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocorreu um erro inesperado ao salvar", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8d4f408d..14872f01 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Шаблон створено", "settings.promptTemplates.page.toast.createFailed": "Не вдалося створити шаблон", "settings.promptTemplates.page.toast.saveUnexpectedError": "Сталася неочікувана помилка під час збереження", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 49a1db3e..2bb3c8df 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.promptTemplates.page.toast.created': '模板已创建', 'settings.promptTemplates.page.toast.createFailed': '创建模板失败', 'settings.promptTemplates.page.toast.saveUnexpectedError': '保存时发生意外错误', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 24af70a1..b4067efd 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1800,4 +1800,10 @@ 'settings.usage.sidebar.field.showPredictions': '顯示預測', 'settings.view.home.cards.plugins.description': '管理 opencode 外掛', 'settings.view.home.cards.plugins.title': '外掛', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index aac2bd42..216ef690 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -735,6 +735,7 @@ class OpencodeService { }>; messageId?: string; agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>; + delivery?: 'steer'; format?: { type: 'json_schema'; schema: Record; @@ -840,6 +841,7 @@ class OpencodeService { agent: params.agent, variant: params.variant, messageID: messageId, + ...(params.delivery ? { delivery: params.delivery } : {}), ...(params.format ? { format: params.format } : {}), parts, }); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 90cbae22..30adc009 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -2,7 +2,7 @@ import type { DesktopSettings } from '@/lib/desktop'; import { createProjectIdFromPath } from '@/lib/projectId'; import { useUIStore } from '@/stores/useUIStore'; import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions'; -import { useMessageQueueStore } from '@/stores/messageQueueStore'; +import { isFollowUpBehavior, normalizeFollowUpBehavior, useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; @@ -444,8 +444,14 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { } } - if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { - queueStore.setQueueMode(settings.queueModeEnabled); + let nextFollowUpBehavior: FollowUpBehavior | null = null; + if (isFollowUpBehavior(settings.followUpBehavior)) { + nextFollowUpBehavior = settings.followUpBehavior; + } else if (typeof settings.queueModeEnabled === 'boolean') { + nextFollowUpBehavior = normalizeFollowUpBehavior(undefined, settings.queueModeEnabled); + } + if (nextFollowUpBehavior && nextFollowUpBehavior !== queueStore.followUpBehavior) { + queueStore.setFollowUpBehavior(nextFollowUpBehavior); } if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) { @@ -838,8 +844,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; + if (isFollowUpBehavior(candidate.followUpBehavior)) { + result.followUpBehavior = candidate.followUpBehavior; + } else if (typeof candidate.queueModeEnabled === 'boolean') { + result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled); } if (typeof candidate.showDeletionDialog === 'boolean') { result.showDeletionDialog = candidate.showDeletionDialog; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 3a74ce8e..89032db3 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -217,11 +217,11 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ isAvailable: (ctx) => !ctx.isVSCode, }, { - id: 'chat.queue-mode', + id: 'chat.follow-up-behavior', page: 'chat', - titleKey: 'settings.openchamber.visual.field.queueMessagesByDefault', - descriptionKey: 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip', - keywords: ['queue', 'enter', 'send'], + titleKey: 'settings.openchamber.visual.section.followUpBehavior', + descriptionKey: 'settings.openchamber.visual.field.followUpBehaviorDescription', + keywords: ['follow up', 'queue', 'steer', 'send immediately'], }, { id: 'chat.persist-drafts', diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index e7cf4e52..d27862a6 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -4,6 +4,40 @@ import { getSafeStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { updateDesktopSettings } from '@/lib/persistence'; +export type FollowUpBehavior = 'steer' | 'queue'; + +export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue'; + +export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => ( + value === 'steer' || value === 'queue' +); + +export const normalizeFollowUpBehavior = ( + value: unknown, + legacyQueueModeEnabled?: boolean | null, +): FollowUpBehavior => { + // "immediate" was removed: on a busy session it was wire-identical to + // "steer" (OpenCode only supports delivery "steer" | "queue", defaulting + // to "steer"), so collapse any persisted/legacy "immediate" onto "steer". + if (value === 'immediate') { + return 'steer'; + } + + if (isFollowUpBehavior(value)) { + return value; + } + + if (legacyQueueModeEnabled === false) { + return 'steer'; + } + + if (legacyQueueModeEnabled === true) { + return 'queue'; + } + + return DEFAULT_FOLLOW_UP_BEHAVIOR; +}; + export interface QueuedMessage { id: string; content: string; @@ -20,7 +54,7 @@ export interface QueuedMessage { interface MessageQueueState { queuedMessages: Record; // sessionId → queue - queueModeEnabled: boolean; // global toggle + followUpBehavior: FollowUpBehavior; } interface MessageQueueActions { @@ -30,18 +64,24 @@ interface MessageQueueActions { popToInput: (sessionId: string, messageId: string) => QueuedMessage | null; clearQueue: (sessionId: string) => void; clearAllQueues: () => void; - setQueueMode: (enabled: boolean) => void; + setFollowUpBehavior: (behavior: FollowUpBehavior) => void; getQueueForSession: (sessionId: string) => QueuedMessage[]; } type MessageQueueStore = MessageQueueState & MessageQueueActions; +type PersistedMessageQueueState = { + queuedMessages?: Record; + followUpBehavior?: FollowUpBehavior; + queueModeEnabled?: boolean; +}; + export const useMessageQueueStore = create()( devtools( persist( (set, get) => ({ queuedMessages: {}, - queueModeEnabled: true, + followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, addToQueue: (sessionId, message) => { const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -149,10 +189,9 @@ export const useMessageQueueStore = create()( set({ queuedMessages: {} }); }, - setQueueMode: (enabled) => { - set({ queueModeEnabled: enabled }); - // Persist to settings.json (async, fire-and-forget) - void updateDesktopSettings({ queueModeEnabled: enabled }); + setFollowUpBehavior: (behavior) => { + set({ followUpBehavior: behavior }); + void updateDesktopSettings({ followUpBehavior: behavior }); }, getQueueForSession: (sessionId) => { @@ -161,11 +200,19 @@ export const useMessageQueueStore = create()( }), { name: 'message-queue-store', + version: 1, storage: createJSONStorage(() => getSafeStorage()), partialize: (state) => ({ queuedMessages: state.queuedMessages, - queueModeEnabled: state.queueModeEnabled, + followUpBehavior: state.followUpBehavior, }), + migrate: (persistedState) => { + const state = (persistedState ?? {}) as PersistedMessageQueueState; + return { + queuedMessages: state.queuedMessages ?? {}, + followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null), + }; + }, } ), { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index f32c837e..dabad03c 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -83,6 +83,7 @@ export function routeMessage(params: { inputMode?: "normal" | "shell" files?: Array<{ type: "file"; mime: string; url: string; filename: string }> additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> + delivery?: 'steer' }): Promise { const requestDirectory = params.directory ?? undefined if (params.inputMode === "shell") { @@ -157,6 +158,7 @@ export function routeMessage(params: { variant: params.variant, files: params.files, additionalParts: params.additionalParts, + delivery: params.delivery, messageId: messageID, directory: requestDirectory, }).then(() => {}), @@ -165,6 +167,7 @@ export function routeMessage(params: { type SendMessageOptions = { sessionId?: string + delivery?: 'steer' } type AssistantMessageSessionExecution = { @@ -1040,6 +1043,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + delivery: options?.delivery, additionalParts: mergedAdditionalParts?.map((p) => ({ text: p.text, synthetic: p.synthetic, @@ -1118,6 +1122,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + delivery: options?.delivery, additionalParts: additionalParts?.map((p) => ({ text: p.text, synthetic: p.synthetic, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index fd995069..f02f959e 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -106,6 +106,20 @@ export const createSettingsHelpers = (dependencies) => { return fallback; }; + const normalizeFollowUpBehavior = (value, legacyQueueModeEnabled = null) => { + // "immediate" was removed (it was wire-identical to "steer"); collapse it. + if (value === 'immediate') { + return 'steer'; + } + if (value === 'steer' || value === 'queue') { + return value; + } + if (legacyQueueModeEnabled === false) { + return 'steer'; + } + return 'queue'; + }; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -361,8 +375,10 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.defaultGitIdentityId.trim(); result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; + if (typeof candidate.followUpBehavior === 'string') { + result.followUpBehavior = normalizeFollowUpBehavior(candidate.followUpBehavior); + } else if (typeof candidate.queueModeEnabled === 'boolean') { + result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled); } if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree;