* 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
@@ -1420,7 +1420,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
type SubmitOptions = {
|
||||
queuedOnly?: boolean;
|
||||
queuedMessageId?: string;
|
||||
delivery?: 'steer';
|
||||
};
|
||||
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) => {
|
||||
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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[{ text: instructionsText, synthetic: true }],
|
||||
variantToSend,
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
@@ -2046,6 +2052,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[{ text: instructionsText, synthetic: true }],
|
||||
variantToSend,
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
@@ -2072,6 +2079,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[{ text: instructionsText, synthetic: true }],
|
||||
variantToSend,
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
@@ -2094,6 +2102,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[{ text: instructionsText, synthetic: true }],
|
||||
variantToSend,
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
@@ -2116,6 +2125,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[{ text: instructionsText, synthetic: true }],
|
||||
variantToSend,
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
@@ -2138,6 +2148,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[{ text: instructionsText, synthetic: true }],
|
||||
variantToSend,
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
@@ -2160,6 +2171,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[{ text: instructionsText, synthetic: true }],
|
||||
variantToSend,
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
@@ -2208,7 +2220,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
agentMentionName,
|
||||
additionalParts.length > 0 ? additionalParts : undefined,
|
||||
variantToSend,
|
||||
inputMode
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -2279,16 +2292,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user