* 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 <bash@opencode.itc.local> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Leonid Skorobogatyy
Bohdan Triapitsyn
parent
c184ddf185
commit
f13f6d5540
@@ -1,3 +1,6 @@
|
|||||||
|
# Agent memory
|
||||||
|
.graymatter/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1420,7 +1420,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Message queue
|
// Message queue
|
||||||
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior);
|
||||||
const queuedMessages = useMessageQueueStore(
|
const queuedMessages = useMessageQueueStore(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
@@ -1697,6 +1697,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
type SubmitOptions = {
|
type SubmitOptions = {
|
||||||
queuedOnly?: boolean;
|
queuedOnly?: boolean;
|
||||||
queuedMessageId?: string;
|
queuedMessageId?: string;
|
||||||
|
delivery?: 'steer';
|
||||||
};
|
};
|
||||||
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
|
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
|
||||||
|
|
||||||
@@ -1746,7 +1747,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleQueuedMessageSend = React.useCallback((messageId: string) => {
|
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(() => {
|
const handleOpenAgentPanel = React.useCallback(() => {
|
||||||
@@ -1768,6 +1770,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const handleSubmit = async (options?: SubmitOptions) => {
|
const handleSubmit = async (options?: SubmitOptions) => {
|
||||||
const queuedOnly = options?.queuedOnly ?? false;
|
const queuedOnly = options?.queuedOnly ?? false;
|
||||||
const queuedMessageId = options?.queuedMessageId;
|
const queuedMessageId = options?.queuedMessageId;
|
||||||
|
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||||
const inputSnapshot = getCurrentInputSnapshot();
|
const inputSnapshot = getCurrentInputSnapshot();
|
||||||
const queuedMessagesToSend = queuedMessageId
|
const queuedMessagesToSend = queuedMessageId
|
||||||
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
||||||
@@ -1816,6 +1819,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendMessageOptions = delivery ? { delivery } : undefined;
|
||||||
|
|
||||||
// Build the primary message (first part) and additional parts
|
// Build the primary message (first part) and additional parts
|
||||||
let primaryText = '';
|
let primaryText = '';
|
||||||
let primaryAttachments: AttachedFile[] = [];
|
let primaryAttachments: AttachedFile[] = [];
|
||||||
@@ -2024,6 +2029,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
[{ text: instructionsText, synthetic: true }],
|
[{ text: instructionsText, synthetic: true }],
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2046,6 +2052,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
[{ text: instructionsText, synthetic: true }],
|
[{ text: instructionsText, synthetic: true }],
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2072,6 +2079,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
[{ text: instructionsText, synthetic: true }],
|
[{ text: instructionsText, synthetic: true }],
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2094,6 +2102,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
[{ text: instructionsText, synthetic: true }],
|
[{ text: instructionsText, synthetic: true }],
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2116,6 +2125,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
[{ text: instructionsText, synthetic: true }],
|
[{ text: instructionsText, synthetic: true }],
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2138,6 +2148,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
[{ text: instructionsText, synthetic: true }],
|
[{ text: instructionsText, synthetic: true }],
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2160,6 +2171,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
[{ text: instructionsText, synthetic: true }],
|
[{ text: instructionsText, synthetic: true }],
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2208,7 +2220,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
agentMentionName,
|
agentMentionName,
|
||||||
additionalParts.length > 0 ? additionalParts : undefined,
|
additionalParts.length > 0 ? additionalParts : undefined,
|
||||||
variantToSend,
|
variantToSend,
|
||||||
inputMode
|
inputMode,
|
||||||
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -2279,16 +2292,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
// Update ref with latest handleSubmit on every render
|
// Update ref with latest handleSubmit on every render
|
||||||
handleSubmitRef.current = handleSubmit;
|
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 handlePrimaryAction = React.useCallback(() => {
|
||||||
const inputSnapshot = getCurrentInputSnapshot();
|
const inputSnapshot = getCurrentInputSnapshot();
|
||||||
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
||||||
if (queueModeEnabled && canQueue) {
|
if (followUpBehavior === 'queue' && canQueue) {
|
||||||
handleQueueMessage();
|
handleQueueMessage();
|
||||||
|
} else if (followUpBehavior === 'steer' && canQueue) {
|
||||||
|
void handleSubmitRef.current({ delivery: 'steer' });
|
||||||
} else {
|
} else {
|
||||||
void handleSubmitRef.current();
|
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.
|
// Draft welcome presets: populate the composer and submit immediately.
|
||||||
// getCurrentInputSnapshot reads textareaRef.current.value first, so setting it
|
// getCurrentInputSnapshot reads textareaRef.current.value first, so setting it
|
||||||
@@ -2527,33 +2542,28 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
return;
|
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)) {
|
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
// Queue mode: Enter queues, Ctrl+Enter sends
|
// Queueing / steering only works when there's an existing busy
|
||||||
// Normal mode: Enter sends, Ctrl+Enter queues
|
// session (or an active auto-review run).
|
||||||
// Note: Queueing only works when there's an existing session (currentSessionId)
|
|
||||||
// For new sessions (draft), always send immediately
|
|
||||||
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
||||||
|
|
||||||
if (queueModeEnabled) {
|
if (followUpBehavior === 'queue') {
|
||||||
if (isCtrlEnter || !canQueue) {
|
if (isCtrlEnter || !canQueue) {
|
||||||
// Ctrl+Enter sends, or Enter when can't queue (new session)
|
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
} else {
|
} else {
|
||||||
// Enter queues when we have a session
|
|
||||||
handleQueueMessage();
|
handleQueueMessage();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isCtrlEnter && canQueue) {
|
// steer: Enter steers into the running turn, Ctrl+Enter sends now.
|
||||||
// Ctrl+Enter queues when we have a session
|
if (isCtrlEnter || !canQueue) {
|
||||||
handleQueueMessage();
|
|
||||||
} else {
|
|
||||||
// Enter sends
|
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
|
} else {
|
||||||
|
handleSubmit({ delivery: 'steer' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = () => {
|
const ChatSectionContent: React.FC = () => {
|
||||||
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
|
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sessions section: Default model & agent, Session retention
|
// Sessions section: Default model & agent, Session retention
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
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 { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
|
||||||
import { cn, getModifierLabel } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { NumberInput } from '@/components/ui/number-input';
|
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<FollowUpBehavior>[] = [
|
||||||
|
{
|
||||||
|
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' => {
|
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||||
return mode === 'markdown' ? '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 {
|
interface OpenChamberVisualSettingsProps {
|
||||||
/** Which settings to show. If undefined, shows all. */
|
/** Which settings to show. If undefined, shows all. */
|
||||||
@@ -288,8 +299,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
|
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
|
||||||
const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap);
|
const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap);
|
||||||
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
|
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
|
||||||
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
|
const followUpBehavior = useMessageQueueStore(state => state.followUpBehavior);
|
||||||
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
|
const setFollowUpBehavior = useMessageQueueStore(state => state.setFollowUpBehavior);
|
||||||
const persistChatDraft = useUIStore(state => state.persistChatDraft);
|
const persistChatDraft = useUIStore(state => state.persistChatDraft);
|
||||||
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
|
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
|
||||||
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
|
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
|
||||||
@@ -550,7 +561,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
|| shouldShow('dotfiles')
|
|| shouldShow('dotfiles')
|
||||||
|| shouldShow('fileViewerPreview')
|
|| shouldShow('fileViewerPreview')
|
||||||
|| shouldShow('reasoning')
|
|| shouldShow('reasoning')
|
||||||
|| shouldShow('queueMode')
|
|| shouldShow('followUpBehavior')
|
||||||
|| shouldShow('persistDraft')
|
|| shouldShow('persistDraft')
|
||||||
|| shouldShow('showToolFileIcons')
|
|| shouldShow('showToolFileIcons')
|
||||||
|| shouldShow('expandedTools')
|
|| shouldShow('expandedTools')
|
||||||
@@ -1727,7 +1738,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(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')) && (
|
||||||
<section className="p-2 space-y-0.5">
|
<section className="p-2 space-y-0.5">
|
||||||
{shouldShow('reasoning') && (
|
{shouldShow('reasoning') && (
|
||||||
<div
|
<div
|
||||||
@@ -1979,38 +1990,40 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShow('queueMode') && (
|
{shouldShow('followUpBehavior') && (
|
||||||
<div
|
<section data-settings-item="chat.follow-up-behavior" className="p-2">
|
||||||
data-settings-item="chat.queue-mode"
|
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.followUpBehavior')}</h4>
|
||||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.followUpBehaviorAria')} className="mt-0.5 space-y-0">
|
||||||
role="button"
|
{FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => {
|
||||||
tabIndex={0}
|
const selected = followUpBehavior === option.id;
|
||||||
aria-pressed={queueModeEnabled}
|
return (
|
||||||
onClick={() => setQueueMode(!queueModeEnabled)}
|
<div
|
||||||
onKeyDown={(event) => {
|
key={option.id}
|
||||||
if (event.key === ' ' || event.key === 'Enter') {
|
role="button"
|
||||||
event.preventDefault();
|
tabIndex={0}
|
||||||
setQueueMode(!queueModeEnabled);
|
aria-pressed={selected}
|
||||||
}
|
onClick={() => setFollowUpBehavior(option.id)}
|
||||||
}}
|
onKeyDown={(event) => {
|
||||||
>
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
<Checkbox
|
event.preventDefault();
|
||||||
checked={queueModeEnabled}
|
setFollowUpBehavior(option.id);
|
||||||
onChange={setQueueMode}
|
}
|
||||||
ariaLabel={t('settings.openchamber.visual.field.queueMessagesByDefaultAria')}
|
}}
|
||||||
/>
|
className="flex w-full items-center gap-2 py-0 text-left"
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
>
|
||||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.queueMessagesByDefault')}</span>
|
<Radio
|
||||||
<Tooltip>
|
checked={selected}
|
||||||
<TooltipTrigger asChild>
|
onChange={() => setFollowUpBehavior(option.id)}
|
||||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })}
|
||||||
</TooltipTrigger>
|
/>
|
||||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||||
{t('settings.openchamber.visual.field.queueMessagesByDefaultTooltip', { modifier: getModifierLabel() })}
|
{tUnsafe(option.labelKey)}
|
||||||
</TooltipContent>
|
</span>
|
||||||
</Tooltip>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShow('persistDraft') && (
|
{shouldShow('persistDraft') && (
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ export interface SettingsPayload {
|
|||||||
autoDeleteEnabled?: boolean;
|
autoDeleteEnabled?: boolean;
|
||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
sessionRetentionAction?: 'archive' | 'delete';
|
sessionRetentionAction?: 'archive' | 'delete';
|
||||||
|
followUpBehavior?: 'steer' | 'queue';
|
||||||
queueModeEnabled?: boolean;
|
queueModeEnabled?: boolean;
|
||||||
gitmojiEnabled?: boolean;
|
gitmojiEnabled?: boolean;
|
||||||
inputSpellcheckEnabled?: boolean;
|
inputSpellcheckEnabled?: boolean;
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export type DesktopSettings = {
|
|||||||
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
|
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
|
||||||
openInAppId?: string;
|
openInAppId?: string;
|
||||||
autoCreateWorktree?: boolean;
|
autoCreateWorktree?: boolean;
|
||||||
|
followUpBehavior?: 'steer' | 'queue';
|
||||||
queueModeEnabled?: boolean;
|
queueModeEnabled?: boolean;
|
||||||
gitmojiEnabled?: boolean;
|
gitmojiEnabled?: boolean;
|
||||||
defaultFileViewerPreview?: boolean;
|
defaultFileViewerPreview?: boolean;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
'settings.magicPrompts.page.toast.resetFailed': 'Failed to reset prompt',
|
'settings.magicPrompts.page.toast.resetFailed': 'Failed to reset prompt',
|
||||||
'settings.magicPrompts.page.toast.resetAllSuccess': 'All prompt overrides reset',
|
'settings.magicPrompts.page.toast.resetAllSuccess': 'All prompt overrides reset',
|
||||||
'settings.magicPrompts.page.toast.resetAllFailed': 'Failed to reset all prompts',
|
'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;
|
} as const;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
"settings.promptTemplates.page.toast.created": "Plantilla creada",
|
"settings.promptTemplates.page.toast.created": "Plantilla creada",
|
||||||
"settings.promptTemplates.page.toast.createFailed": "Error al crear la plantilla",
|
"settings.promptTemplates.page.toast.createFailed": "Error al crear la plantilla",
|
||||||
"settings.promptTemplates.page.toast.saveUnexpectedError": "Ocurrió un error inesperado al guardar",
|
"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;
|
} as const;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
'settings.magicPrompts.page.toast.resetFailed': 'Échec de la réinitialisation du prompt',
|
'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.resetAllSuccess': 'Tous les prompts ont été réinitialisés',
|
||||||
'settings.magicPrompts.page.toast.resetAllFailed': 'Échec de la réinitialisation de tous les prompts',
|
'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;
|
} as const;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
'settings.magicPrompts.page.toast.resetFailed': 'プロンプトのリセットに失敗しました',
|
'settings.magicPrompts.page.toast.resetFailed': 'プロンプトのリセットに失敗しました',
|
||||||
'settings.magicPrompts.page.toast.resetAllSuccess': 'すべてのプロンプト上書きをリセットしました',
|
'settings.magicPrompts.page.toast.resetAllSuccess': 'すべてのプロンプト上書きをリセットしました',
|
||||||
'settings.magicPrompts.page.toast.resetAllFailed': 'すべてのプロンプトのリセットに失敗しました',
|
'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;
|
} as const;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
'settings.promptTemplates.page.toast.created': '템플릿이 생성되었습니다',
|
'settings.promptTemplates.page.toast.created': '템플릿이 생성되었습니다',
|
||||||
'settings.promptTemplates.page.toast.createFailed': '템플릿 생성 실패',
|
'settings.promptTemplates.page.toast.createFailed': '템플릿 생성 실패',
|
||||||
'settings.promptTemplates.page.toast.saveUnexpectedError': '저장 중 예기치 않은 오류가 발생했습니다',
|
'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;
|
} as const;
|
||||||
|
|||||||
@@ -1792,4 +1792,10 @@ export const settingsDict = {
|
|||||||
'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst',
|
'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst',
|
||||||
'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown',
|
'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown',
|
||||||
'settings.window.description': 'Okno ustawień OpenChamber.',
|
'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)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
"settings.promptTemplates.page.toast.created": "Template criado",
|
"settings.promptTemplates.page.toast.created": "Template criado",
|
||||||
"settings.promptTemplates.page.toast.createFailed": "Falha ao criar template",
|
"settings.promptTemplates.page.toast.createFailed": "Falha ao criar template",
|
||||||
"settings.promptTemplates.page.toast.saveUnexpectedError": "Ocorreu um erro inesperado ao salvar",
|
"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;
|
} as const;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
"settings.promptTemplates.page.toast.created": "Шаблон створено",
|
"settings.promptTemplates.page.toast.created": "Шаблон створено",
|
||||||
"settings.promptTemplates.page.toast.createFailed": "Не вдалося створити шаблон",
|
"settings.promptTemplates.page.toast.createFailed": "Не вдалося створити шаблон",
|
||||||
"settings.promptTemplates.page.toast.saveUnexpectedError": "Сталася неочікувана помилка під час збереження",
|
"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;
|
} as const;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@ export const settingsDict = {
|
|||||||
'settings.promptTemplates.page.toast.created': '模板已创建',
|
'settings.promptTemplates.page.toast.created': '模板已创建',
|
||||||
'settings.promptTemplates.page.toast.createFailed': '创建模板失败',
|
'settings.promptTemplates.page.toast.createFailed': '创建模板失败',
|
||||||
'settings.promptTemplates.page.toast.saveUnexpectedError': '保存时发生意外错误',
|
'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;
|
} as const;
|
||||||
|
|||||||
@@ -1800,4 +1800,10 @@
|
|||||||
'settings.usage.sidebar.field.showPredictions': '顯示預測',
|
'settings.usage.sidebar.field.showPredictions': '顯示預測',
|
||||||
'settings.view.home.cards.plugins.description': '管理 opencode 外掛',
|
'settings.view.home.cards.plugins.description': '管理 opencode 外掛',
|
||||||
'settings.view.home.cards.plugins.title': '外掛',
|
'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;
|
} as const;
|
||||||
|
|||||||
@@ -735,6 +735,7 @@ class OpencodeService {
|
|||||||
}>;
|
}>;
|
||||||
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 } }>;
|
||||||
|
delivery?: 'steer';
|
||||||
format?: {
|
format?: {
|
||||||
type: 'json_schema';
|
type: 'json_schema';
|
||||||
schema: Record<string, unknown>;
|
schema: Record<string, unknown>;
|
||||||
@@ -840,6 +841,7 @@ class OpencodeService {
|
|||||||
agent: params.agent,
|
agent: params.agent,
|
||||||
variant: params.variant,
|
variant: params.variant,
|
||||||
messageID: messageId,
|
messageID: messageId,
|
||||||
|
...(params.delivery ? { delivery: params.delivery } : {}),
|
||||||
...(params.format ? { format: params.format } : {}),
|
...(params.format ? { format: params.format } : {}),
|
||||||
parts,
|
parts,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { DesktopSettings } from '@/lib/desktop';
|
|||||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
|
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 { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
||||||
@@ -444,8 +444,14 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
|
let nextFollowUpBehavior: FollowUpBehavior | null = null;
|
||||||
queueStore.setQueueMode(settings.queueModeEnabled);
|
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) {
|
if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) {
|
||||||
@@ -838,8 +844,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.gitmojiEnabled === 'boolean') {
|
if (typeof candidate.gitmojiEnabled === 'boolean') {
|
||||||
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
||||||
}
|
}
|
||||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
if (isFollowUpBehavior(candidate.followUpBehavior)) {
|
||||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
result.followUpBehavior = candidate.followUpBehavior;
|
||||||
|
} else if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||||
|
result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled);
|
||||||
}
|
}
|
||||||
if (typeof candidate.showDeletionDialog === 'boolean') {
|
if (typeof candidate.showDeletionDialog === 'boolean') {
|
||||||
result.showDeletionDialog = candidate.showDeletionDialog;
|
result.showDeletionDialog = candidate.showDeletionDialog;
|
||||||
|
|||||||
@@ -217,11 +217,11 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
|||||||
isAvailable: (ctx) => !ctx.isVSCode,
|
isAvailable: (ctx) => !ctx.isVSCode,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chat.queue-mode',
|
id: 'chat.follow-up-behavior',
|
||||||
page: 'chat',
|
page: 'chat',
|
||||||
titleKey: 'settings.openchamber.visual.field.queueMessagesByDefault',
|
titleKey: 'settings.openchamber.visual.section.followUpBehavior',
|
||||||
descriptionKey: 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip',
|
descriptionKey: 'settings.openchamber.visual.field.followUpBehaviorDescription',
|
||||||
keywords: ['queue', 'enter', 'send'],
|
keywords: ['follow up', 'queue', 'steer', 'send immediately'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chat.persist-drafts',
|
id: 'chat.persist-drafts',
|
||||||
|
|||||||
@@ -4,6 +4,40 @@ import { getSafeStorage } from './utils/safeStorage';
|
|||||||
import type { AttachedFile } from './types/sessionTypes';
|
import type { AttachedFile } from './types/sessionTypes';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
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 {
|
export interface QueuedMessage {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -20,7 +54,7 @@ export interface QueuedMessage {
|
|||||||
|
|
||||||
interface MessageQueueState {
|
interface MessageQueueState {
|
||||||
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
|
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
|
||||||
queueModeEnabled: boolean; // global toggle
|
followUpBehavior: FollowUpBehavior;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageQueueActions {
|
interface MessageQueueActions {
|
||||||
@@ -30,18 +64,24 @@ interface MessageQueueActions {
|
|||||||
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
|
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
|
||||||
clearQueue: (sessionId: string) => void;
|
clearQueue: (sessionId: string) => void;
|
||||||
clearAllQueues: () => void;
|
clearAllQueues: () => void;
|
||||||
setQueueMode: (enabled: boolean) => void;
|
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
|
||||||
getQueueForSession: (sessionId: string) => QueuedMessage[];
|
getQueueForSession: (sessionId: string) => QueuedMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
||||||
|
|
||||||
|
type PersistedMessageQueueState = {
|
||||||
|
queuedMessages?: Record<string, QueuedMessage[]>;
|
||||||
|
followUpBehavior?: FollowUpBehavior;
|
||||||
|
queueModeEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const useMessageQueueStore = create<MessageQueueStore>()(
|
export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||||
devtools(
|
devtools(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
queuedMessages: {},
|
queuedMessages: {},
|
||||||
queueModeEnabled: true,
|
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||||
|
|
||||||
addToQueue: (sessionId, message) => {
|
addToQueue: (sessionId, message) => {
|
||||||
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
@@ -149,10 +189,9 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
|||||||
set({ queuedMessages: {} });
|
set({ queuedMessages: {} });
|
||||||
},
|
},
|
||||||
|
|
||||||
setQueueMode: (enabled) => {
|
setFollowUpBehavior: (behavior) => {
|
||||||
set({ queueModeEnabled: enabled });
|
set({ followUpBehavior: behavior });
|
||||||
// Persist to settings.json (async, fire-and-forget)
|
void updateDesktopSettings({ followUpBehavior: behavior });
|
||||||
void updateDesktopSettings({ queueModeEnabled: enabled });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getQueueForSession: (sessionId) => {
|
getQueueForSession: (sessionId) => {
|
||||||
@@ -161,11 +200,19 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'message-queue-store',
|
name: 'message-queue-store',
|
||||||
|
version: 1,
|
||||||
storage: createJSONStorage(() => getSafeStorage()),
|
storage: createJSONStorage(() => getSafeStorage()),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
queuedMessages: state.queuedMessages,
|
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),
|
||||||
|
};
|
||||||
|
},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export function routeMessage(params: {
|
|||||||
inputMode?: "normal" | "shell"
|
inputMode?: "normal" | "shell"
|
||||||
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
|
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 }> }>
|
additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
|
||||||
|
delivery?: 'steer'
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const requestDirectory = params.directory ?? undefined
|
const requestDirectory = params.directory ?? undefined
|
||||||
if (params.inputMode === "shell") {
|
if (params.inputMode === "shell") {
|
||||||
@@ -157,6 +158,7 @@ export function routeMessage(params: {
|
|||||||
variant: params.variant,
|
variant: params.variant,
|
||||||
files: params.files,
|
files: params.files,
|
||||||
additionalParts: params.additionalParts,
|
additionalParts: params.additionalParts,
|
||||||
|
delivery: params.delivery,
|
||||||
messageId: messageID,
|
messageId: messageID,
|
||||||
directory: requestDirectory,
|
directory: requestDirectory,
|
||||||
}).then(() => {}),
|
}).then(() => {}),
|
||||||
@@ -165,6 +167,7 @@ export function routeMessage(params: {
|
|||||||
|
|
||||||
type SendMessageOptions = {
|
type SendMessageOptions = {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
delivery?: 'steer'
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssistantMessageSessionExecution = {
|
type AssistantMessageSessionExecution = {
|
||||||
@@ -1040,6 +1043,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
variant,
|
variant,
|
||||||
inputMode,
|
inputMode,
|
||||||
files,
|
files,
|
||||||
|
delivery: options?.delivery,
|
||||||
additionalParts: mergedAdditionalParts?.map((p) => ({
|
additionalParts: mergedAdditionalParts?.map((p) => ({
|
||||||
text: p.text,
|
text: p.text,
|
||||||
synthetic: p.synthetic,
|
synthetic: p.synthetic,
|
||||||
@@ -1118,6 +1122,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
variant,
|
variant,
|
||||||
inputMode,
|
inputMode,
|
||||||
files,
|
files,
|
||||||
|
delivery: options?.delivery,
|
||||||
additionalParts: additionalParts?.map((p) => ({
|
additionalParts: additionalParts?.map((p) => ({
|
||||||
text: p.text,
|
text: p.text,
|
||||||
synthetic: p.synthetic,
|
synthetic: p.synthetic,
|
||||||
|
|||||||
@@ -106,6 +106,20 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
return fallback;
|
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) => {
|
const sanitizeSettingsUpdate = (payload) => {
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
return {};
|
return {};
|
||||||
@@ -361,8 +375,10 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
const trimmed = candidate.defaultGitIdentityId.trim();
|
const trimmed = candidate.defaultGitIdentityId.trim();
|
||||||
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
|
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
if (typeof candidate.followUpBehavior === 'string') {
|
||||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
result.followUpBehavior = normalizeFollowUpBehavior(candidate.followUpBehavior);
|
||||||
|
} else if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||||
|
result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled);
|
||||||
}
|
}
|
||||||
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
||||||
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
||||||
|
|||||||
Reference in New Issue
Block a user