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) {
67816781 return;
67826782 }
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
67846795 if (command === "thread.stop") {
67856796 // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog.
67866797 if (!canInterruptRunningThread) return;
......@@ -6810,6 +6821,7 @@ export default function ChatView(props: ChatViewProps) {
68106821 activeThreadPinned,
68116822 activeThreadSettled,
68126823 canInterruptRunningThread,
6824 activeThreadKey,
68136825 terminalUiState.terminalOpen,
68146826 terminalUiState.activeTerminalId,
68156827 activeThreadId,
......@@ -7497,11 +7509,13 @@ export default function ChatView(props: ChatViewProps) {
74977509 );
74987510 return;
74997511 }
7500 // A send during a running turn waits in the queue. It leaves on the next
7501 // tool boundary, when the turn ends, or when the user clicks Steer. The
7502 // provider treats a mid-turn send as a steer of the active turn, so the
7503 // dispatch below is the same either way.
7504 if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) {
7512 if (
7513 !queuedMessage &&
7514 !directAnnotation &&
7515 phase === "running" &&
7516 activeThreadKey &&
7517 settings.followUpBehavior === "queue"
7518 ) {
75057519 if (composerRef.current?.validateProviderInput(promptForSend) === false) {
75067520 return;
75077521 }
......@@ -9501,6 +9515,11 @@ export default function ChatView(props: ChatViewProps) {
95019515 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
95029516 queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages}
95039517 onSteerQueuedMessage={onSteerQueuedMessage}
9518 steerQueuedMessageShortcutLabel={shortcutLabelForCommand(
9519 keybindings,
9520 "thread.steerQueuedMessage",
9521 { context: { terminalFocus: false } },
9522 )}
95049523 onRemoveQueuedMessage={onRemoveQueuedMessage}
95059524 />
95069525
apps/web/src/components/chat/MessagesTimeline.tsx+11-1
......@@ -285,6 +285,7 @@ interface TimelineRowSharedState {
285285 onWorktreeSetupWorkLocally: (() => void) | null;
286286 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
287287 onSteerQueuedMessage: (id: string) => void;
288 steerQueuedMessageShortcutLabel: string | null;
288289 onRemoveQueuedMessage: (id: string) => void;
289290}
290291
......@@ -441,6 +442,7 @@ interface MessagesTimelineProps {
441442 /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */
442443 queuedMessages?: ReadonlyArray<QueuedComposerMessage>;
443444 onSteerQueuedMessage?: (id: string) => void;
445 steerQueuedMessageShortcutLabel?: string | null;
444446 onRemoveQueuedMessage?: (id: string) => void;
445447}
446448
......@@ -496,6 +498,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
496498 loadEarlier = null,
497499 queuedMessages = EMPTY_QUEUED_MESSAGES,
498500 onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
501 steerQueuedMessageShortcutLabel = null,
499502 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
500503}: MessagesTimelineProps) {
501504 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
......@@ -940,6 +943,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
940943 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
941944 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
942945 onSteerQueuedMessage,
946 steerQueuedMessageShortcutLabel,
943947 onRemoveQueuedMessage,
944948 }),
945949 [
......@@ -972,6 +976,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
972976 onWorktreeSetupWorkLocally,
973977 onOpenWorktreeSetupTerminal,
974978 onSteerQueuedMessage,
979 steerQueuedMessageShortcutLabel,
975980 onRemoveQueuedMessage,
976981 ],
977982 );
......@@ -1564,7 +1569,12 @@ function QueuedMessageTimelineRow({
15641569 >
15651570 <ArrowUpIcon className="size-3.5" aria-hidden />
15661571 </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>
15681578 </Tooltip>
15691579 <Tooltip>
15701580 <TooltipTrigger
apps/web/src/components/settings/SettingsPanels.tsx+46
......@@ -578,6 +578,9 @@ export function useSettingsRestore(onRestored?: () => void) {
578578 ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
579579 ? ["Collapse composer on scroll"]
580580 : []),
581 ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior
582 ? ["Follow-up behavior"]
583 : []),
581584 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
582585 ? ["Context window indicator"]
583586 : []),
......@@ -636,6 +639,7 @@ export function useSettingsRestore(onRestored?: () => void) {
636639 settings.confirmThreadDelete,
637640 settings.confirmThreadUnpin,
638641 settings.composerCollapseOnScroll,
642 settings.followUpBehavior,
639643 settings.addProjectBaseDirectory,
640644 settings.defaultThreadEnvMode,
641645 settings.newWorktreesStartFromOrigin,
......@@ -748,6 +752,7 @@ export function useSettingsRestore(onRestored?: () => void) {
748752 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
749753 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
750754 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
755 followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
751756 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
752757 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
753758 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
......@@ -2589,6 +2594,47 @@ export function GeneralSettingsPanel() {
25892594 }
25902595 />
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
25922638 <SettingsRow
25932639 serverScoped
25942640 settingKeys={["enableProviderUpdateChecks"]}
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [
298298 to: "/settings/general",
299299 searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"],
300300 },
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 },
301307 {
302308 id: "provider-update-checks",
303309 title: "Provider update checks",
apps/web/src/keybindings.test.ts+1
......@@ -1119,6 +1119,7 @@ describe("composer and pull request shortcuts", () => {
11191119 ["l", "composer.previousWorktree"],
11201120 ["c", "thread.copyReference"],
11211121 ["k", "pullRequest.copyNumber"],
1122 ["Enter", "thread.steerQueuedMessage"],
11221123 ] as const;
11231124
11241125 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
3131
3232## Send while the agent is working
3333
34A message sent during a running turn waits at the end of the conversation as a
34On web and desktop, a message sent during a running turn waits at the end of the conversation as a
3535dashed bubble. It goes out on its own when the agent finishes its next tool
3636call, or when the turn ends. Use the arrow under the bubble to send it right
3737away, or the X to move it back into the composer. Stop returns every queued
3838message 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
4049## Queue messages offline on mobile
4150
4251Mobile 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 =
3636
3737const THREAD_KEYBINDING_COMMANDS = [
3838 "thread.stop",
39 "thread.steerQueuedMessage",
3940 "thread.previous",
4041 "thread.next",
4142 "thread.copyReference",
packages/contracts/src/settings.test.ts+13
......@@ -529,6 +529,19 @@ describe("ClientSettings context window meter", () => {
529529 });
530530});
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
532545describe("ClientSettings composer collapse", () => {
533546 it("collapses on scroll by default and accepts opting out", () => {
534547 expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true);
packages/contracts/src/settings.ts+4
......@@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({
430430 // Desktop resting composer: scrolling an existing thread's conversation
431431 // settles the composer into its single-line layout. Losing focus never does.
432432 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
433 followUpBehavior: Schema.Literals(["queue", "steer"]).pipe(
434 Schema.withDecodingDefault(Effect.succeed("queue")),
435 ),
433436 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
434437 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
435438 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
......@@ -1499,6 +1502,7 @@ export const ClientSettingsPatch = Schema.Struct({
14991502 planModeEnabled: Schema.optionalKey(Schema.Boolean),
15001503 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
15011504 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
1505 followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])),
15021506 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
15031507 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
15041508 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
packages/shared/src/keybindings.ts+1
......@@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
4040 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
4141 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },
4242 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
43 { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },
4344 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
4445 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
4546 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },