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
53 changes: 35 additions & 18 deletions apps/web/src/components/ChatView.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4310,6 +4310,20 @@ function ChatViewContent(props: ChatViewProps) {
}
}
}, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
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 backgroundLivenessBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
if (activeBackgroundLiveness === null || !activeThread) {
return null;
Expand Down Expand Up @@ -4593,7 +4607,18 @@ function ChatViewContent(props: ChatViewProps) {
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
};

const command = resolveShortcutCommand(event, keybindings, {
context: shortcutContext,
});

// chat.interrupt outranks type-to-focus while a turn is running: a
// printable-key binding would otherwise be typed into the composer
// instead of stopping the turn. When idle, the key keeps its usual
// meaning, so bindings like plain Escape stay safe.
const interruptClaimsKey = command === "chat.interrupt" && phase === "running";

if (
!interruptClaimsKey &&
!shortcutContext.terminalFocus &&
!shortcutContext.modelPickerOpen &&
shouldTypeToFocusComposer(event)
Expand All @@ -4605,9 +4630,6 @@ function ChatViewContent(props: ChatViewProps) {
}
}

const command = resolveShortcutCommand(event, keybindings, {
context: shortcutContext,
});
if (!command) return;

if (command === "terminal.toggle") {
Expand Down Expand Up @@ -4692,6 +4714,14 @@ function ChatViewContent(props: ChatViewProps) {
return;
}

if (command === "chat.interrupt") {
if (!interruptClaimsKey) return;
event.preventDefault();
event.stopPropagation();
void onInterrupt();
return;
}

const scriptId = projectScriptIdFromCommand(command);
if (!scriptId || !activeProject) return;
const script = activeProject.scripts.find((entry) => entry.id === scriptId);
Expand All @@ -4717,7 +4747,9 @@ function ChatViewContent(props: ChatViewProps) {
splitTerminal,
splitPanelTerminal,
keybindings,
onInterrupt,
onToggleDiff,
phase,
toggleRightPanel,
toggleTerminalVisibility,
composerRef,
Expand Down Expand Up @@ -5236,21 +5268,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
Expand Up @@ -150,7 +150,9 @@ describe("KeybindingsSettings.logic", () => {
},
] satisfies ResolvedKeybindingsConfig);

expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"]));
expect(options).toEqual(
expect.arrayContaining(["chat.new", "chat.interrupt", "script.setup-db.run"]),
);
});

it("reports unknown when variables without rejecting parseable expressions", () => {
Expand Down
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,7 +256,9 @@ export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
export function buildKeybindingCommandOptions(
keybindings: ResolvedKeybindingsConfig,
): ReadonlyArray<KeybindingCommandOption> {
const commands = new Set<KeybindingCommand>();
// Static commands are listed directly so commands that ship without a
// default binding (like chat.interrupt) stay bindable from the UI.
const commands = new Set<KeybindingCommand>(STATIC_KEYBINDING_COMMANDS);
for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {
commands.add(binding.command);
}
Expand Down
5 changes: 5 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ Use **Inspect** to pick an element in the app and reveal its color token. Inspec
successful pick; its hover glow and badge preview the element and token that click will select.
**Cancel** or `Escape` exits Inspect and clears its selection and spotlight.

`chat.interrupt` stops the active thread's running turn, same as the composer's stop button. It
ships without a default shortcut; bind one in **Settings** → **Keybindings**. The shortcut only
takes effect while a turn is running, so a key you bind keeps its normal behavior the rest of the
time.

The command palette searches active thread titles, projects, branches, user messages, and final
agent responses across connected environments. Message matches show one labeled excerpt while
keeping the thread's project, branch, and machine context visible. Message search begins after two
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 @@ -84,6 +84,12 @@ it.effect("parses keybinding rules", () =>
});
assert.strictEqual(parsedLocal.command, "chat.newLocal");

const parsedInterrupt = yield* decode(KeybindingRule, {
key: "mod+shift+c",
command: "chat.interrupt",
});
assert.strictEqual(parsedInterrupt.command, "chat.interrupt");

const parsedModelPickerToggle = yield* decode(KeybindingRule, {
key: "mod+shift+m",
command: "modelPicker.toggle",
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 @@ -47,7 +47,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 All @@ -69,6 +69,7 @@ const STATIC_KEYBINDING_COMMANDS = [
"composer.stash",
"chat.new",
"chat.newLocal",
"chat.interrupt",
"editor.openFavorite",
...MODEL_PICKER_KEYBINDING_COMMANDS,
...THREAD_KEYBINDING_COMMANDS,
Expand Down
Loading