| author | |
| committer | |
| log | 719a76ca1dbf5490f1aa33ffb9966301e02be9a9 |
| tree | 153d573dcfbc5771f671a99386edb8bb7d6d3014 |
| parent | 96bddf81258090690bfcf4e6a6414aa0dcf1910a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 6781 | return; |
| 6782 | 6782 | } |
| 6783 | 6783 | |
| 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 | 6795 | if (command === "thread.stop") { |
| 6785 | 6796 | // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog. |
| 6786 | 6797 | if (!canInterruptRunningThread) return; |
| ... | ... | @@ -6810,6 +6821,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6810 | 6821 | activeThreadPinned, |
| 6811 | 6822 | activeThreadSettled, |
| 6812 | 6823 | canInterruptRunningThread, |
| 6824 | activeThreadKey, | |
| 6813 | 6825 | terminalUiState.terminalOpen, |
| 6814 | 6826 | terminalUiState.activeTerminalId, |
| 6815 | 6827 | activeThreadId, |
| ... | ... | @@ -7497,11 +7509,13 @@ export default function ChatView(props: ChatViewProps) { |
| 7497 | 7509 | ); |
| 7498 | 7510 | return; |
| 7499 | 7511 | } |
| 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 | ) { | |
| 7505 | 7519 | if (composerRef.current?.validateProviderInput(promptForSend) === false) { |
| 7506 | 7520 | return; |
| 7507 | 7521 | } |
| ... | ... | @@ -9501,6 +9515,11 @@ export default function ChatView(props: ChatViewProps) { |
| 9501 | 9515 | loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} |
| 9502 | 9516 | queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages} |
| 9503 | 9517 | onSteerQueuedMessage={onSteerQueuedMessage} |
| 9518 | steerQueuedMessageShortcutLabel={shortcutLabelForCommand( | |
| 9519 | keybindings, | |
| 9520 | "thread.steerQueuedMessage", | |
| 9521 | { context: { terminalFocus: false } }, | |
| 9522 | )} | |
| 9504 | 9523 | onRemoveQueuedMessage={onRemoveQueuedMessage} |
| 9505 | 9524 | /> |
| 9506 | 9525 |
apps/web/src/components/chat/MessagesTimeline.tsx+11-1| ... | ... | @@ -285,6 +285,7 @@ interface TimelineRowSharedState { |
| 285 | 285 | onWorktreeSetupWorkLocally: (() => void) | null; |
| 286 | 286 | onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; |
| 287 | 287 | onSteerQueuedMessage: (id: string) => void; |
| 288 | steerQueuedMessageShortcutLabel: string | null; | |
| 288 | 289 | onRemoveQueuedMessage: (id: string) => void; |
| 289 | 290 | } |
| 290 | 291 | |
| ... | ... | @@ -441,6 +442,7 @@ interface MessagesTimelineProps { |
| 441 | 442 | /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */ |
| 442 | 443 | queuedMessages?: ReadonlyArray<QueuedComposerMessage>; |
| 443 | 444 | onSteerQueuedMessage?: (id: string) => void; |
| 445 | steerQueuedMessageShortcutLabel?: string | null; | |
| 444 | 446 | onRemoveQueuedMessage?: (id: string) => void; |
| 445 | 447 | } |
| 446 | 448 | |
| ... | ... | @@ -496,6 +498,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 496 | 498 | loadEarlier = null, |
| 497 | 499 | queuedMessages = EMPTY_QUEUED_MESSAGES, |
| 498 | 500 | onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, |
| 501 | steerQueuedMessageShortcutLabel = null, | |
| 499 | 502 | onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, |
| 500 | 503 | }: MessagesTimelineProps) { |
| 501 | 504 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| ... | ... | @@ -940,6 +943,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 940 | 943 | onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, |
| 941 | 944 | onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, |
| 942 | 945 | onSteerQueuedMessage, |
| 946 | steerQueuedMessageShortcutLabel, | |
| 943 | 947 | onRemoveQueuedMessage, |
| 944 | 948 | }), |
| 945 | 949 | [ |
| ... | ... | @@ -972,6 +976,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 972 | 976 | onWorktreeSetupWorkLocally, |
| 973 | 977 | onOpenWorktreeSetupTerminal, |
| 974 | 978 | onSteerQueuedMessage, |
| 979 | steerQueuedMessageShortcutLabel, | |
| 975 | 980 | onRemoveQueuedMessage, |
| 976 | 981 | ], |
| 977 | 982 | ); |
| ... | ... | @@ -1564,7 +1569,12 @@ function QueuedMessageTimelineRow({ |
| 1564 | 1569 | > |
| 1565 | 1570 | <ArrowUpIcon className="size-3.5" aria-hidden /> |
| 1566 | 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 | 1578 | </Tooltip> |
| 1569 | 1579 | <Tooltip> |
| 1570 | 1580 | <TooltipTrigger |
apps/web/src/components/settings/SettingsPanels.tsx+46| ... | ... | @@ -578,6 +578,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 578 | 578 | ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll |
| 579 | 579 | ? ["Collapse composer on scroll"] |
| 580 | 580 | : []), |
| 581 | ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior | |
| 582 | ? ["Follow-up behavior"] | |
| 583 | : []), | |
| 581 | 584 | ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled |
| 582 | 585 | ? ["Context window indicator"] |
| 583 | 586 | : []), |
| ... | ... | @@ -636,6 +639,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 636 | 639 | settings.confirmThreadDelete, |
| 637 | 640 | settings.confirmThreadUnpin, |
| 638 | 641 | settings.composerCollapseOnScroll, |
| 642 | settings.followUpBehavior, | |
| 639 | 643 | settings.addProjectBaseDirectory, |
| 640 | 644 | settings.defaultThreadEnvMode, |
| 641 | 645 | settings.newWorktreesStartFromOrigin, |
| ... | ... | @@ -748,6 +752,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 748 | 752 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, |
| 749 | 753 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, |
| 750 | 754 | composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, |
| 755 | followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, | |
| 751 | 756 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, |
| 752 | 757 | environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, |
| 753 | 758 | glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, |
| ... | ... | @@ -2589,6 +2594,47 @@ export function GeneralSettingsPanel() { |
| 2589 | 2594 | } |
| 2590 | 2595 | /> |
| 2591 | 2596 | |
| 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 | 2638 | <SettingsRow |
| 2593 | 2639 | serverScoped |
| 2594 | 2640 | settingKeys={["enableProviderUpdateChecks"]} |
apps/web/src/components/settings/settingsSearch.ts+6| ... | ... | @@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 298 | 298 | to: "/settings/general", |
| 299 | 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 | 308 | id: "provider-update-checks", |
| 303 | 309 | title: "Provider update checks", |
apps/web/src/keybindings.test.ts+1| ... | ... | @@ -1119,6 +1119,7 @@ describe("composer and pull request shortcuts", () => { |
| 1119 | 1119 | ["l", "composer.previousWorktree"], |
| 1120 | 1120 | ["c", "thread.copyReference"], |
| 1121 | 1121 | ["k", "pullRequest.copyNumber"], |
| 1122 | ["Enter", "thread.steerQueuedMessage"], | |
| 1122 | 1123 | ] as const; |
| 1123 | 1124 | |
| 1124 | 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 | 31 | |
| 32 | 32 | ## Send while the agent is working |
| 33 | 33 | |
| 34 | A message sent during a running turn waits at the end of the conversation as a | |
| 34 | On web and desktop, a message sent during a running turn waits at the end of the conversation as a | |
| 35 | 35 | dashed bubble. It goes out on its own when the agent finishes its next tool |
| 36 | 36 | call, or when the turn ends. Use the arrow under the bubble to send it right |
| 37 | 37 | away, or the X to move it back into the composer. Stop returns every queued |
| 38 | 38 | message to the composer. |
| 39 | 39 | |
| 40 | In **Settings → General → Follow-up behavior**, choose **Queue** to keep this | |
| 41 | behavior or **Steer** to send new messages immediately. This setting applies to | |
| 42 | the current client. Messages already queued keep their place. | |
| 43 | ||
| 44 | Use `Cmd+Shift+Enter` on macOS or `Ctrl+Shift+Enter` on Windows and Linux to send | |
| 45 | the oldest queued message now. Change `thread.steerQueuedMessage` in | |
| 46 | **Settings → Keybindings** to use another shortcut. It leaves the current draft | |
| 47 | in the composer and waits if the agent needs an approval or an answer. | |
| 48 | ||
| 40 | 49 | ## Queue messages offline on mobile |
| 41 | 50 | |
| 42 | 51 | Mobile 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 | 36 | |
| 37 | 37 | const THREAD_KEYBINDING_COMMANDS = [ |
| 38 | 38 | "thread.stop", |
| 39 | "thread.steerQueuedMessage", | |
| 39 | 40 | "thread.previous", |
| 40 | 41 | "thread.next", |
| 41 | 42 | "thread.copyReference", |
packages/contracts/src/settings.test.ts+13| ... | ... | @@ -529,6 +529,19 @@ describe("ClientSettings context window meter", () => { |
| 529 | 529 | }); |
| 530 | 530 | }); |
| 531 | 531 | |
| 532 | describe("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 | ||
| 532 | 545 | describe("ClientSettings composer collapse", () => { |
| 533 | 546 | it("collapses on scroll by default and accepts opting out", () => { |
| 534 | 547 | expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true); |
packages/contracts/src/settings.ts+4| ... | ... | @@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 430 | 430 | // Desktop resting composer: scrolling an existing thread's conversation |
| 431 | 431 | // settles the composer into its single-line layout. Losing focus never does. |
| 432 | 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 | 436 | proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 434 | 437 | showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 435 | 438 | // Legacy sidebar (the original per-project tree). Deliberately a fresh key |
| ... | ... | @@ -1499,6 +1502,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1499 | 1502 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 1500 | 1503 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), |
| 1501 | 1504 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), |
| 1505 | followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), | |
| 1502 | 1506 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), |
| 1503 | 1507 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |
| 1504 | 1508 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
packages/shared/src/keybindings.ts+1| ... | ... | @@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 40 | 40 | { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, |
| 41 | 41 | { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, |
| 42 | 42 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, |
| 43 | { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, | |
| 43 | 44 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 44 | 45 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 45 | 46 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |