diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 96c366ccd897..702cd9238c11 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7407,6 +7407,17 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "thread.editQueuedMessage") { + if (routeKind === "draft") return; + // Anywhere else in the draft the key keeps moving the caret, so a + // second press from the first paragraph reaches the queue. + if (!composerRef.current?.isCaretAtStart()) return; + if (!queuedRunsControlRef.current?.editLatest(event.repeat)) return; + event.preventDefault(); + event.stopPropagation(); + return; + } + if (command === "thread.stop") { // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog. if (!canInterruptRunningThread) return; @@ -10636,6 +10647,11 @@ export default function ChatView(props: ChatViewProps) { "thread.steerQueuedMessage", { context: { terminalFocus: false } }, )} + editShortcutLabel={shortcutLabelForCommand( + keybindings, + "thread.editQueuedMessage", + { context: { composerFocus: true } }, + )} environmentId={activeThread.environmentId} threadId={activeThread.id} optimisticMessages={optimisticUserMessages} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 54689eee2e2c..610201d613d4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1429,6 +1429,8 @@ export interface ChatComposerHandle { toggleModelPicker: () => void; openControl: (command: KeybindingCommand) => void; isModelPickerOpen: () => boolean; + /** True when a collapsed caret sits before everything in the draft, including when it is empty. */ + isCaretAtStart: () => boolean; compactContext: () => void; readSnapshot: () => { value: string; @@ -6281,6 +6283,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, compactContext: compactThreadContext, isModelPickerOpen: () => isComposerModelPickerOpen, + isCaretAtStart: () => { + const range = composerEditorRef.current?.readSelectionRange(); + return range !== undefined && range.start === 0 && range.end === 0; + }, readSnapshot: () => { return readComposerSnapshot(); }, diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 6b0ccc525be9..c4cf2e407a5f 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -45,6 +45,7 @@ const QUEUED_RUN_DRAG_TYPE = "application/x-t3code-queued-run"; export interface QueuedRunsControlHandle { steerNext: (repeat: boolean) => boolean; + editLatest: (repeat: boolean) => boolean; } export function QueuedRunsControl({ @@ -53,6 +54,7 @@ export function QueuedRunsControl({ }: { readonly ref?: Ref; readonly steerShortcutLabel?: string | null; + readonly editShortcutLabel?: string | null; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly optimisticMessages: ReadonlyArray< @@ -212,6 +214,22 @@ export function QueuedRunsControl({ if (!repeat && busyRunId === null) void steer(next.run.id); return true; }, + // Declines while a queued message is already being edited so the key keeps + // moving the caret inside that draft. + editLatest(repeat) { + const latest = queued.at(-1); + if (!latest || props.editingRunId !== null || busyRunId !== null) return false; + if (!repeat) { + setExpanded(true); + props.onEditQueuedRun({ + runId: latest.run.id, + messageId: latest.run.userMessageId, + text: latest.text, + attachments: latest.attachments, + }); + } + return true; + }, })); if (items.length === 0) return null; @@ -449,7 +467,9 @@ export function QueuedRunsControl({ > - Edit in the composer + + {`Edit in the composer${item.serverIndex === queued.length - 1 && props.editShortcutLabel ? ` (${props.editShortcutLabel})` : ""}`} + }> diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 1eb1c6a87f19..2485e628614b 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -23,6 +23,7 @@ describe("KeybindingsSettings.logic", () => { "composer.sendAlternate", "composer.sendBackground", "thread.steerQueuedMessage", + "thread.editQueuedMessage", "composer.host", "composer.effort", "composer.mode", diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 5510a3c8e763..636eeeafbdf6 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -286,6 +286,7 @@ export function commandLabel(command: KeybindingCommand): string { if (command === "composer.sendAlternate") return "Composer: Opposite Queue or Steer Action"; if (command === "composer.sendBackground") return "Composer: Start in Background"; if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message as Steer"; + if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message"; if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index b9b83110c5c5..bb17adc89c27 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1298,6 +1298,25 @@ describe("composer and pull request shortcuts", () => { ); } + for (const platform of ["MacIntel", "Win32", "Linux"]) { + it(`edits the last queued message with Alt+ArrowUp from the composer on ${platform}`, () => { + const input = event({ key: "ArrowUp", altKey: true }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { composerFocus: true }, + }), + "thread.editQueuedMessage", + ); + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { composerFocus: false }, + }), + ); + }); + } + for (const platform of ["MacIntel", "Win32", "Linux"]) { it.each([ ["s", "thread.settle"], diff --git a/docs/user/composer.md b/docs/user/composer.md index 7dae8ee8a485..7b5161945b40 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -47,6 +47,10 @@ the oldest queued message as a steer. This leaves the current draft intact and requires an active turn that supports steering. Change `thread.steerQueuedMessage` in **Settings → Keybindings** to use another shortcut. +Press `Option+Up` on macOS or `Alt+Up` on Windows and Linux with the cursor at the +start of the composer to edit the most recently queued message. Change +`thread.editQueuedMessage` to use another shortcut. + Mobile has the same choice under **Settings → Follow-ups**. While a turn is running the send button shows which action it will take. Long-press it to use the other action for a single message, or hold `Cmd` while sending from a hardware diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index f3e2143e4b3c..8eda4f00c839 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = const THREAD_KEYBINDING_COMMANDS = [ "thread.stop", "thread.steerQueuedMessage", + "thread.editQueuedMessage", "thread.previous", "thread.next", "thread.copyReference", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 92d880d65ef3..fe36adfbe647 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -43,6 +43,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { 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: "alt+arrowup", command: "thread.editQueuedMessage", when: "composerFocus" }, { key: "mod+enter", command: "composer.sendAlternate", when: "composerFocus && turnRunning" }, { key: "mod+alt+enter",