authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-16 00:29:28+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 14:29:28-07:00
log719a76ca1dbf5490f1aa33ffb9966301e02be9a9
tree153d573dcfbc5771f671a99386edb8bb7d6d3014
parent96bddf81258090690bfcf4e6a6414aa0dcf1910a
signature Signed by PGP key B5690EEEBB952194

feat(web): choose queue or steer for follow-up messages (#11964)


10 files changed, 117 insertions(+), 7 deletions(-)

apps/web/src/components/ChatView.tsx+24-5
...@@ -6781,6 +6781,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -6781,6 +6781,17 @@ export default function ChatView(props: ChatViewProps) {
6781 return;6781 return;
6782 }6782 }
67836783
6784 if (command === "thread.steerQueuedMessage") {
6785 const message = activeThreadKey
6786 ? useQueuedMessageStore.getState().queuesByThreadKey[activeThreadKey]?.[0]
6787 : undefined;
6788 if (!message) return;
6789 event.preventDefault();
6790 event.stopPropagation();
6791 if (!event.repeat) queuedMessageActionsRef.current.steer(message.id);
6792 return;
6793 }
6794
6784 if (command === "thread.stop") {6795 if (command === "thread.stop") {
6785 // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog.6796 // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog.
6786 if (!canInterruptRunningThread) return;6797 if (!canInterruptRunningThread) return;
...@@ -6810,6 +6821,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6810,6 +6821,7 @@ export default function ChatView(props: ChatViewProps) {
6810 activeThreadPinned,6821 activeThreadPinned,
6811 activeThreadSettled,6822 activeThreadSettled,
6812 canInterruptRunningThread,6823 canInterruptRunningThread,
6824 activeThreadKey,
6813 terminalUiState.terminalOpen,6825 terminalUiState.terminalOpen,
6814 terminalUiState.activeTerminalId,6826 terminalUiState.activeTerminalId,
6815 activeThreadId,6827 activeThreadId,
...@@ -7497,11 +7509,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -7497,11 +7509,13 @@ export default function ChatView(props: ChatViewProps) {
7497 );7509 );
7498 return;7510 return;
7499 }7511 }
7500 // A send during a running turn waits in the queue. It leaves on the next7512 if (
7501 // tool boundary, when the turn ends, or when the user clicks Steer. The7513 !queuedMessage &&
7502 // provider treats a mid-turn send as a steer of the active turn, so the7514 !directAnnotation &&
7503 // dispatch below is the same either way.7515 phase === "running" &&
7504 if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) {7516 activeThreadKey &&
7517 settings.followUpBehavior === "queue"
7518 ) {
7505 if (composerRef.current?.validateProviderInput(promptForSend) === false) {7519 if (composerRef.current?.validateProviderInput(promptForSend) === false) {
7506 return;7520 return;
7507 }7521 }
...@@ -9501,6 +9515,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -9501,6 +9515,11 @@ export default function ChatView(props: ChatViewProps) {
9501 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}9515 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
9502 queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages}9516 queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages}
9503 onSteerQueuedMessage={onSteerQueuedMessage}9517 onSteerQueuedMessage={onSteerQueuedMessage}
9518 steerQueuedMessageShortcutLabel={shortcutLabelForCommand(
9519 keybindings,
9520 "thread.steerQueuedMessage",
9521 { context: { terminalFocus: false } },
9522 )}
9504 onRemoveQueuedMessage={onRemoveQueuedMessage}9523 onRemoveQueuedMessage={onRemoveQueuedMessage}
9505 />9524 />
95069525
apps/web/src/components/chat/MessagesTimeline.tsx+11-1
...@@ -285,6 +285,7 @@ interface TimelineRowSharedState {...@@ -285,6 +285,7 @@ interface TimelineRowSharedState {
285 onWorktreeSetupWorkLocally: (() => void) | null;285 onWorktreeSetupWorkLocally: (() => void) | null;
286 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;286 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
287 onSteerQueuedMessage: (id: string) => void;287 onSteerQueuedMessage: (id: string) => void;
288 steerQueuedMessageShortcutLabel: string | null;
288 onRemoveQueuedMessage: (id: string) => void;289 onRemoveQueuedMessage: (id: string) => void;
289}290}
290291
...@@ -441,6 +442,7 @@ interface MessagesTimelineProps {...@@ -441,6 +442,7 @@ interface MessagesTimelineProps {
441 /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */442 /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */
442 queuedMessages?: ReadonlyArray<QueuedComposerMessage>;443 queuedMessages?: ReadonlyArray<QueuedComposerMessage>;
443 onSteerQueuedMessage?: (id: string) => void;444 onSteerQueuedMessage?: (id: string) => void;
445 steerQueuedMessageShortcutLabel?: string | null;
444 onRemoveQueuedMessage?: (id: string) => void;446 onRemoveQueuedMessage?: (id: string) => void;
445}447}
446448
...@@ -496,6 +498,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -496,6 +498,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
496 loadEarlier = null,498 loadEarlier = null,
497 queuedMessages = EMPTY_QUEUED_MESSAGES,499 queuedMessages = EMPTY_QUEUED_MESSAGES,
498 onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,500 onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
501 steerQueuedMessageShortcutLabel = null,
499 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,502 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
500}: MessagesTimelineProps) {503}: MessagesTimelineProps) {
501 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());504 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
...@@ -940,6 +943,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -940,6 +943,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
940 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,943 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
941 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,944 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
942 onSteerQueuedMessage,945 onSteerQueuedMessage,
946 steerQueuedMessageShortcutLabel,
943 onRemoveQueuedMessage,947 onRemoveQueuedMessage,
944 }),948 }),
945 [949 [
...@@ -972,6 +976,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -972,6 +976,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
972 onWorktreeSetupWorkLocally,976 onWorktreeSetupWorkLocally,
973 onOpenWorktreeSetupTerminal,977 onOpenWorktreeSetupTerminal,
974 onSteerQueuedMessage,978 onSteerQueuedMessage,
979 steerQueuedMessageShortcutLabel,
975 onRemoveQueuedMessage,980 onRemoveQueuedMessage,
976 ],981 ],
977 );982 );
...@@ -1564,7 +1569,12 @@ function QueuedMessageTimelineRow({...@@ -1564,7 +1569,12 @@ function QueuedMessageTimelineRow({
1564 >1569 >
1565 <ArrowUpIcon className="size-3.5" aria-hidden />1570 <ArrowUpIcon className="size-3.5" aria-hidden />
1566 </TooltipTrigger>1571 </TooltipTrigger>
1567 <TooltipPopup side="bottom">Send now</TooltipPopup>1572 <TooltipPopup side="bottom">
1573 Send now
1574 {row.isNext && ctx.steerQueuedMessageShortcutLabel
1575 ? ` (${ctx.steerQueuedMessageShortcutLabel})`
1576 : null}
1577 </TooltipPopup>
1568 </Tooltip>1578 </Tooltip>
1569 <Tooltip>1579 <Tooltip>
1570 <TooltipTrigger1580 <TooltipTrigger
apps/web/src/components/settings/SettingsPanels.tsx+46
...@@ -578,6 +578,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -578,6 +578,9 @@ export function useSettingsRestore(onRestored?: () => void) {
578 ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll578 ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
579 ? ["Collapse composer on scroll"]579 ? ["Collapse composer on scroll"]
580 : []),580 : []),
581 ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior
582 ? ["Follow-up behavior"]
583 : []),
581 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled584 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
582 ? ["Context window indicator"]585 ? ["Context window indicator"]
583 : []),586 : []),
...@@ -636,6 +639,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -636,6 +639,7 @@ export function useSettingsRestore(onRestored?: () => void) {
636 settings.confirmThreadDelete,639 settings.confirmThreadDelete,
637 settings.confirmThreadUnpin,640 settings.confirmThreadUnpin,
638 settings.composerCollapseOnScroll,641 settings.composerCollapseOnScroll,
642 settings.followUpBehavior,
639 settings.addProjectBaseDirectory,643 settings.addProjectBaseDirectory,
640 settings.defaultThreadEnvMode,644 settings.defaultThreadEnvMode,
641 settings.newWorktreesStartFromOrigin,645 settings.newWorktreesStartFromOrigin,
...@@ -748,6 +752,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -748,6 +752,7 @@ export function useSettingsRestore(onRestored?: () => void) {
748 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,752 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
749 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,753 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
750 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,754 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
755 followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
751 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,756 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
752 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,757 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
753 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,758 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
...@@ -2589,6 +2594,47 @@ export function GeneralSettingsPanel() {...@@ -2589,6 +2594,47 @@ export function GeneralSettingsPanel() {
2589 }2594 }
2590 />2595 />
25912596
2597 <SettingsRow
2598 {...searchableSetting("follow-up-behavior")}
2599 description="Queue follow-ups while the agent runs or steer the current turn."
2600 resetAction={
2601 settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? (
2602 <SettingResetButton
2603 label="follow-up behavior"
2604 onClick={() =>
2605 updateSettings({
2606 followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
2607 })
2608 }
2609 />
2610 ) : null
2611 }
2612 control={
2613 <Select
2614 value={settings.followUpBehavior}
2615 onValueChange={(value) => {
2616 if (value === "queue" || value === "steer") {
2617 updateSettings({ followUpBehavior: value });
2618 }
2619 }}
2620 >
2621 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Follow-up behavior">
2622 <SelectValue>
2623 {settings.followUpBehavior === "queue" ? "Queue" : "Steer"}
2624 </SelectValue>
2625 </SelectTrigger>
2626 <SelectPopup align="end" alignItemWithTrigger={false}>
2627 <SelectItem hideIndicator value="queue">
2628 Queue
2629 </SelectItem>
2630 <SelectItem hideIndicator value="steer">
2631 Steer
2632 </SelectItem>
2633 </SelectPopup>
2634 </Select>
2635 }
2636 />
2637
2592 <SettingsRow2638 <SettingsRow
2593 serverScoped2639 serverScoped
2594 settingKeys={["enableProviderUpdateChecks"]}2640 settingKeys={["enableProviderUpdateChecks"]}
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [
298 to: "/settings/general",298 to: "/settings/general",
299 searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"],299 searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"],
300 },300 },
301 {
302 id: "follow-up-behavior",
303 title: "Follow-up behavior",
304 to: "/settings/general",
305 searchTerms: ["queue steer running turn send default behavior composer"],
306 },
301 {307 {
302 id: "provider-update-checks",308 id: "provider-update-checks",
303 title: "Provider update checks",309 title: "Provider update checks",
apps/web/src/keybindings.test.ts+1
...@@ -1119,6 +1119,7 @@ describe("composer and pull request shortcuts", () => {...@@ -1119,6 +1119,7 @@ describe("composer and pull request shortcuts", () => {
1119 ["l", "composer.previousWorktree"],1119 ["l", "composer.previousWorktree"],
1120 ["c", "thread.copyReference"],1120 ["c", "thread.copyReference"],
1121 ["k", "pullRequest.copyNumber"],1121 ["k", "pullRequest.copyNumber"],
1122 ["Enter", "thread.steerQueuedMessage"],
1122 ] as const;1123 ] as const;
11231124
1124 for (const platform of ["MacIntel", "Win32", "Linux"]) {1125 for (const platform of ["MacIntel", "Win32", "Linux"]) {
docs/user/composer.md+10-1
...@@ -31,12 +31,21 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin...@@ -31,12 +31,21 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin
3131
32## Send while the agent is working32## Send while the agent is working
3333
34A message sent during a running turn waits at the end of the conversation as a34On web and desktop, a message sent during a running turn waits at the end of the conversation as a
35dashed bubble. It goes out on its own when the agent finishes its next tool35dashed bubble. It goes out on its own when the agent finishes its next tool
36call, or when the turn ends. Use the arrow under the bubble to send it right36call, or when the turn ends. Use the arrow under the bubble to send it right
37away, or the X to move it back into the composer. Stop returns every queued37away, or the X to move it back into the composer. Stop returns every queued
38message to the composer.38message to the composer.
3939
40In **Settings → General → Follow-up behavior**, choose **Queue** to keep this
41behavior or **Steer** to send new messages immediately. This setting applies to
42the current client. Messages already queued keep their place.
43
44Use `Cmd+Shift+Enter` on macOS or `Ctrl+Shift+Enter` on Windows and Linux to send
45the oldest queued message now. Change `thread.steerQueuedMessage` in
46**Settings → Keybindings** to use another shortcut. It leaves the current draft
47in the composer and waits if the agent needs an approval or an answer.
48
40## Queue messages offline on mobile49## Queue messages offline on mobile
4150
42Mobile keeps local copies of draft attachments, so you can preview them and queue51Mobile keeps local copies of draft attachments, so you can preview them and queue
packages/contracts/src/keybindings.ts+1
...@@ -36,6 +36,7 @@ export type ModelPickerJumpKeybindingCommand =...@@ -36,6 +36,7 @@ export type ModelPickerJumpKeybindingCommand =
3636
37const THREAD_KEYBINDING_COMMANDS = [37const THREAD_KEYBINDING_COMMANDS = [
38 "thread.stop",38 "thread.stop",
39 "thread.steerQueuedMessage",
39 "thread.previous",40 "thread.previous",
40 "thread.next",41 "thread.next",
41 "thread.copyReference",42 "thread.copyReference",
packages/contracts/src/settings.test.ts+13
...@@ -529,6 +529,19 @@ describe("ClientSettings context window meter", () => {...@@ -529,6 +529,19 @@ describe("ClientSettings context window meter", () => {
529 });529 });
530});530});
531531
532describe("ClientSettings follow-up behavior", () => {
533 it("defaults to queue and accepts either behavior", () => {
534 expect(decodeClientSettings({}).followUpBehavior).toBe("queue");
535 for (const followUpBehavior of ["queue", "steer"]) {
536 expect(decodeClientSettings({ followUpBehavior }).followUpBehavior).toBe(followUpBehavior);
537 expect(decodeClientSettingsPatch({ followUpBehavior }).followUpBehavior).toBe(
538 followUpBehavior,
539 );
540 }
541 expect(() => decodeClientSettingsPatch({ followUpBehavior: "invalid" })).toThrow();
542 });
543});
544
532describe("ClientSettings composer collapse", () => {545describe("ClientSettings composer collapse", () => {
533 it("collapses on scroll by default and accepts opting out", () => {546 it("collapses on scroll by default and accepts opting out", () => {
534 expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true);547 expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true);
packages/contracts/src/settings.ts+4
...@@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({
430 // Desktop resting composer: scrolling an existing thread's conversation430 // Desktop resting composer: scrolling an existing thread's conversation
431 // settles the composer into its single-line layout. Losing focus never does.431 // settles the composer into its single-line layout. Losing focus never does.
432 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),432 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
433 followUpBehavior: Schema.Literals(["queue", "steer"]).pipe(
434 Schema.withDecodingDefault(Effect.succeed("queue")),
435 ),
433 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),436 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
434 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),437 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
435 // Legacy sidebar (the original per-project tree). Deliberately a fresh key438 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
...@@ -1499,6 +1502,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1499,6 +1502,7 @@ export const ClientSettingsPatch = Schema.Struct({
1499 planModeEnabled: Schema.optionalKey(Schema.Boolean),1502 planModeEnabled: Schema.optionalKey(Schema.Boolean),
1500 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),1503 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1501 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),1504 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
1505 followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])),
1502 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),1506 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
1503 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),1507 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
1504 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),1508 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
packages/shared/src/keybindings.ts+1
...@@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
40 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },40 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
41 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },41 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },
42 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },42 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
43 { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },
43 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },44 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
44 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },45 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
45 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },46 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },