From 719a76ca1dbf5490f1aa33ffb9966301e02be9a9 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:29:28 +0300 Subject: [PATCH] feat(web): choose queue or steer for follow-up messages (#11964) --- apps/web/src/components/ChatView.tsx | 29 ++++++++++-- .../src/components/chat/MessagesTimeline.tsx | 12 ++++- .../components/settings/SettingsPanels.tsx | 46 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ apps/web/src/keybindings.test.ts | 1 + docs/user/composer.md | 11 ++++- packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/settings.test.ts | 13 ++++++ packages/contracts/src/settings.ts | 4 ++ packages/shared/src/keybindings.ts | 1 + 10 files changed, 117 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d0d05146c489f17702bf6c882acb32d7745d060b..38f773cf037e0141d5a61cb326db2a92af551223 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6781,6 +6781,17 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "thread.steerQueuedMessage") { + const message = activeThreadKey + ? useQueuedMessageStore.getState().queuesByThreadKey[activeThreadKey]?.[0] + : undefined; + if (!message) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) queuedMessageActionsRef.current.steer(message.id); + return; + } + if (command === "thread.stop") { // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog. if (!canInterruptRunningThread) return; @@ -6810,6 +6821,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadPinned, activeThreadSettled, canInterruptRunningThread, + activeThreadKey, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, @@ -7497,11 +7509,13 @@ export default function ChatView(props: ChatViewProps) { ); return; } - // A send during a running turn waits in the queue. It leaves on the next - // tool boundary, when the turn ends, or when the user clicks Steer. The - // provider treats a mid-turn send as a steer of the active turn, so the - // dispatch below is the same either way. - if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) { + if ( + !queuedMessage && + !directAnnotation && + phase === "running" && + activeThreadKey && + settings.followUpBehavior === "queue" + ) { if (composerRef.current?.validateProviderInput(promptForSend) === false) { return; } @@ -9501,6 +9515,11 @@ export default function ChatView(props: ChatViewProps) { loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages} onSteerQueuedMessage={onSteerQueuedMessage} + steerQueuedMessageShortcutLabel={shortcutLabelForCommand( + keybindings, + "thread.steerQueuedMessage", + { context: { terminalFocus: false } }, + )} onRemoveQueuedMessage={onRemoveQueuedMessage} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 61381ddb71f7cf14049679491a1e04ed1c78389f..307a893342f38ce42c66f7712a582244c92e14c1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -285,6 +285,7 @@ interface TimelineRowSharedState { onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; onSteerQueuedMessage: (id: string) => void; + steerQueuedMessageShortcutLabel: string | null; onRemoveQueuedMessage: (id: string) => void; } @@ -441,6 +442,7 @@ interface MessagesTimelineProps { /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */ queuedMessages?: ReadonlyArray; onSteerQueuedMessage?: (id: string) => void; + steerQueuedMessageShortcutLabel?: string | null; onRemoveQueuedMessage?: (id: string) => void; } @@ -496,6 +498,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ loadEarlier = null, queuedMessages = EMPTY_QUEUED_MESSAGES, onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, + steerQueuedMessageShortcutLabel = null, onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); @@ -940,6 +943,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, onSteerQueuedMessage, + steerQueuedMessageShortcutLabel, onRemoveQueuedMessage, }), [ @@ -972,6 +976,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, onSteerQueuedMessage, + steerQueuedMessageShortcutLabel, onRemoveQueuedMessage, ], ); @@ -1564,7 +1569,12 @@ function QueuedMessageTimelineRow({ > - Send now + + Send now + {row.isNext && ctx.steerQueuedMessageShortcutLabel + ? ` (${ctx.steerQueuedMessageShortcutLabel})` + : null} + void) { ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? ["Collapse composer on scroll"] : []), + ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior + ? ["Follow-up behavior"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -636,6 +639,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnScroll, + settings.followUpBehavior, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -748,6 +752,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2589,6 +2594,47 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, + }) + } + /> + ) : null + } + control={ + + } + /> + { ["l", "composer.previousWorktree"], ["c", "thread.copyReference"], ["k", "pullRequest.copyNumber"], + ["Enter", "thread.steerQueuedMessage"], ] as const; for (const platform of ["MacIntel", "Win32", "Linux"]) { diff --git a/docs/user/composer.md b/docs/user/composer.md index 8ed45837388d6534ed6f1213a1743ed9c4b933c8..f3be7a5f342d2ca75da5dacb5e7c9b08ff38644f 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -31,12 +31,21 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin ## Send while the agent is working -A message sent during a running turn waits at the end of the conversation as a +On web and desktop, a message sent during a running turn waits at the end of the conversation as a dashed bubble. It goes out on its own when the agent finishes its next tool call, or when the turn ends. Use the arrow under the bubble to send it right away, or the X to move it back into the composer. Stop returns every queued message to the composer. +In **Settings → General → Follow-up behavior**, choose **Queue** to keep this +behavior or **Steer** to send new messages immediately. This setting applies to +the current client. Messages already queued keep their place. + +Use `Cmd+Shift+Enter` on macOS or `Ctrl+Shift+Enter` on Windows and Linux to send +the oldest queued message now. Change `thread.steerQueuedMessage` in +**Settings → Keybindings** to use another shortcut. It leaves the current draft +in the composer and waits if the agent needs an approval or an answer. + ## Queue messages offline on mobile Mobile keeps local copies of draft attachments, so you can preview them and queue diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index a285d2fcf4ac8f569ba0c954f74288d6e9390bb1..1256dcc77786154426ce776029574dd4f9a2e793 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -36,6 +36,7 @@ export type ModelPickerJumpKeybindingCommand = const THREAD_KEYBINDING_COMMANDS = [ "thread.stop", + "thread.steerQueuedMessage", "thread.previous", "thread.next", "thread.copyReference", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b090b47fba5b97673f7e2b5679aa28918bdf9f0e..ba612edaf26b00be4b30717af86ad82107467258 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -529,6 +529,19 @@ describe("ClientSettings context window meter", () => { }); }); +describe("ClientSettings follow-up behavior", () => { + it("defaults to queue and accepts either behavior", () => { + expect(decodeClientSettings({}).followUpBehavior).toBe("queue"); + for (const followUpBehavior of ["queue", "steer"]) { + expect(decodeClientSettings({ followUpBehavior }).followUpBehavior).toBe(followUpBehavior); + expect(decodeClientSettingsPatch({ followUpBehavior }).followUpBehavior).toBe( + followUpBehavior, + ); + } + expect(() => decodeClientSettingsPatch({ followUpBehavior: "invalid" })).toThrow(); + }); +}); + describe("ClientSettings composer collapse", () => { it("collapses on scroll by default and accepts opting out", () => { expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7b3715d704beb91ed6b357e0930bd1d25b4cbcac..1c53b36037ba3981c783c7b36f3a275867b1282c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({ // Desktop resting composer: scrolling an existing thread's conversation // settles the composer into its single-line layout. Losing focus never does. composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( + Schema.withDecodingDefault(Effect.succeed("queue")), + ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key @@ -1499,6 +1502,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), + followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8d73c07f34ab4ab5f1547bf9dabdec47f0efd1c9..0cc18680e6d509852f9248b666a5b21319e7389f 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, + { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, -- 2.54.0