Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
},
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/components/chat/QueuedRunsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -53,6 +54,7 @@ export function QueuedRunsControl({
}: {
readonly ref?: Ref<QueuedRunsControlHandle>;
readonly steerShortcutLabel?: string | null;
readonly editShortcutLabel?: string | null;
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly optimisticMessages: ReadonlyArray<
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -449,7 +467,9 @@ export function QueuedRunsControl({
>
<PencilIcon />
</TooltipTrigger>
<TooltipPopup>Edit in the composer</TooltipPopup>
<TooltipPopup>
{`Edit in the composer${item.serverIndex === queued.length - 1 && props.editShortcutLabel ? ` (${props.editShortcutLabel})` : ""}`}
</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger render={<span className="flex shrink-0" />}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("KeybindingsSettings.logic", () => {
"composer.sendAlternate",
"composer.sendBackground",
"thread.steerQueuedMessage",
"thread.editQueuedMessage",
"composer.host",
"composer.effort",
"composer.mode",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
4 changes: 4 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =
const THREAD_KEYBINDING_COMMANDS = [
"thread.stop",
"thread.steerQueuedMessage",
"thread.editQueuedMessage",
"thread.previous",
"thread.next",
"thread.copyReference",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ 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",
Expand Down
Loading