Skip to content
Open
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
38 changes: 23 additions & 15 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,21 @@ function ChatViewContent(props: ChatViewProps) {
[draftId, routeThreadKey, routeThreadRef, serverThread],
);

const onInterrupt = useCallback(async () => {
if (!activeThread) return;
const result = await interruptThreadTurn({
environmentId,
input: buildThreadTurnInterruptInput(activeThread),
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
setThreadError(
activeThread.id,
error instanceof Error ? error.message : "Failed to interrupt the current turn.",
);
}
}, [activeThread, environmentId, interruptThreadTurn, setThreadError]);

const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();
}, [composerRef]);
Expand Down Expand Up @@ -3931,6 +3946,13 @@ function ChatViewContent(props: ChatViewProps) {
return;
}

if (command === "thread.stop") {
event.preventDefault();
event.stopPropagation();
void onInterrupt();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Held stop key repeats interrupt

Low Severity

The thread.stop keydown branch does not ignore event.repeat, so holding the bound key can enqueue multiple onInterrupt calls. Other thread-related shortcuts in the sidebar explicitly bail out when event.repeat is true.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7ae0496. Configure here.

Comment on lines +3949 to +3952

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate thread.stop to running turns

When a custom thread.stop shortcut is pressed on an active thread after its turn has settled, this block still calls onInterrupt() even though the provider session may only be ready. The server accepts thread.turn.interrupt for any non-stopped session and adapters such as OpenCode abort the session without requiring an active turn, while the existing Stop button is only exposed when phase === "running". Gate this shortcut on the same running/activeTurnId condition so pressing the binding on an idle thread is a no-op instead of aborting provider state.

Useful? React with 👍 / 👎.

return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop shortcut ignores running state

Medium Severity

The new thread.stop keybinding unconditionally calls onInterrupt. Unlike the UI stop button, which is only active when a turn is running, this allows interrupting idle threads or drafts without an active server turn, which can lead to unexpected thread errors or interrupt failure messages.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7ae0496. Configure here.

}

const scriptId = projectScriptIdFromCommand(command);
if (!scriptId || !activeProject) return;
const script = activeProject.scripts.find((entry) => entry.id === scriptId);
Expand All @@ -3956,6 +3978,7 @@ function ChatViewContent(props: ChatViewProps) {
splitTerminal,
splitPanelTerminal,
keybindings,
onInterrupt,
onToggleDiff,
toggleRightPanel,
toggleTerminalVisibility,
Expand Down Expand Up @@ -4411,21 +4434,6 @@ function ChatViewContent(props: ChatViewProps) {
}
};

const onInterrupt = async () => {
if (!activeThread) return;
const result = await interruptThreadTurn({
environmentId,
input: buildThreadTurnInterruptInput(activeThread),
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
setThreadError(
activeThread.id,
error instanceof Error ? error.message : "Failed to interrupt the current turn.",
);
}
};

const onRespondToApproval = useCallback(
async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
if (!activeThreadId) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";

import {
buildKeybindingRows,
Expand Down Expand Up @@ -134,7 +135,7 @@ describe("KeybindingsSettings.logic", () => {
expect(options).not.toContain("customModeActive");
});

it("builds command options from defaults and resolved project bindings", () => {
it("builds command options from all static commands and resolved project bindings", () => {
const options = buildKeybindingCommandOptions([
{
command: "script.setup-db.run",
Expand All @@ -149,7 +150,12 @@ describe("KeybindingsSettings.logic", () => {
},
] satisfies ResolvedKeybindingsConfig);

expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"]));
expect(options).toEqual(
expect.arrayContaining(["chat.new", "thread.stop", "script.setup-db.run"]),
);
expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe(
false,
);
});

it("reports unknown when variables without rejecting parseable expressions", () => {
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/components/settings/KeybindingsSettings.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type KeybindingWhenNode,
type ResolvedKeybindingRule,
type ResolvedKeybindingsConfig,
STATIC_KEYBINDING_COMMANDS,
} from "@t3tools/contracts";
import {
DEFAULT_RESOLVED_KEYBINDINGS,
Expand Down Expand Up @@ -255,10 +256,7 @@ export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
export function buildKeybindingCommandOptions(
keybindings: ResolvedKeybindingsConfig,
): ReadonlyArray<KeybindingCommandOption> {
const commands = new Set<KeybindingCommand>();
for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {
commands.add(binding.command);
}
const commands = new Set<KeybindingCommand>(STATIC_KEYBINDING_COMMANDS);
for (const binding of keybindings) {
commands.add(binding.command);
}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,17 @@ describe("cross-command precedence", () => {
});

describe("resolveShortcutCommand", () => {
it("resolves a custom stop-thread shortcut", () => {
const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]);

assert.strictEqual(
resolveShortcutCommand(event({ key: "Escape", metaKey: true }), keybindings, {
platform: "MacIntel",
}),
"thread.stop",
);
});

it("returns dynamic script commands", () => {
const keybindings = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);

Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ it.effect("parses keybinding rules", () =>
command: "thread.previous",
});
assert.strictEqual(parsedThreadPrevious.command, "thread.previous");

const parsedThreadStop = yield* decode(KeybindingRule, {
key: "mod+escape",
command: "thread.stop",
});
assert.strictEqual(parsedThreadStop.command, "thread.stop");
}),
);

Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand =
(typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number];

export const THREAD_KEYBINDING_COMMANDS = [
"thread.stop",
"thread.previous",
"thread.next",
...THREAD_JUMP_KEYBINDING_COMMANDS,
Expand All @@ -47,7 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [
] as const;
export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];

const STATIC_KEYBINDING_COMMANDS = [
export const STATIC_KEYBINDING_COMMANDS = [
"sidebar.toggle",
"terminal.toggle",
"terminal.split",
Expand Down
Loading