From e7923844256c48eaba7e2aab76556434c4871635 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 19 Sep 2026 10:51:03 -0400 Subject: [PATCH] feat(web): edit the last queued message with Option+Up Adds a thread.editQueuedMessage keybinding, bound to alt+arrowup in the composer by default. It opens the most recently queued message for editing, but only when the caret sits at the start of the draft with nothing selected, so Option+Up keeps moving the caret everywhere else. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ChatView.tsx | 16 ++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 6 +++++ .../src/components/chat/QueuedRunsControl.tsx | 22 ++++++++++++++++++- .../KeybindingsSettings.logic.test.ts | 1 + .../settings/KeybindingsSettings.logic.ts | 1 + apps/web/src/keybindings.test.ts | 19 ++++++++++++++++ docs/user/composer.md | 4 ++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 9 files changed, 70 insertions(+), 1 deletion(-) 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",