Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -135,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 supported commands and resolved project bindings", () => {
const options = buildKeybindingCommandOptions([
{
command: "script.setup-db.run",
Expand All @@ -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", "thread.settle", "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
@@ -1,4 +1,5 @@
import {
STATIC_KEYBINDING_COMMANDS,
type KeybindingCommand,
type KeybindingShortcut,
type KeybindingWhenNode,
Expand Down Expand Up @@ -255,7 +256,7 @@ export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
export function buildKeybindingCommandOptions(
keybindings: ResolvedKeybindingsConfig,
): ReadonlyArray<KeybindingCommandOption> {
const commands = new Set<KeybindingCommand>();
const commands = new Set<KeybindingCommand>(STATIC_KEYBINDING_COMMANDS);
for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {
commands.add(binding.command);
}
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,31 @@ describe("model picker navigation helpers", () => {
});

describe("chat/editor shortcuts", () => {
it("matches a configured thread settle shortcut", () => {
const bindings = compile([
{
shortcut: modShortcut("s", { shiftKey: true }),
command: "thread.settle",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
]);

assert.strictEqual(
resolveShortcutCommand(event({ key: "s", metaKey: true, shiftKey: true }), bindings, {
platform: "MacIntel",
context: { terminalFocus: false },
}),
"thread.settle",
);
assert.strictEqual(
resolveShortcutCommand(event({ key: "s", metaKey: true, shiftKey: true }), bindings, {
platform: "MacIntel",
context: { terminalFocus: true },
}),
null,
);
});

it("matches chat.new shortcut", () => {
assert.isTrue(
isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { useAtomValue } from "@effect/atom-react";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { useEffect, useMemo } from "react";

import { isCommandPaletteOpen } from "../commandPaletteBus";
Expand All @@ -11,6 +15,7 @@ import { selectProjectGroupingSettings } from "../logicalProject";
import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
import { dispatchPreviewAction } from "../components/preview/previewActionBus";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { useThreadActions } from "../hooks/useThreadActions";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
import { isPreviewFocused } from "../lib/previewFocus";
import { isTerminalFocused } from "../lib/terminalFocus";
Expand All @@ -27,6 +32,7 @@ function ChatRouteGlobalShortcuts() {
const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
useHandleNewThread();
const { settleThread } = useThreadActions();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const legacySidebarEnabled = useLegacySidebarEnabled();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
Expand Down Expand Up @@ -108,6 +114,26 @@ function ChatRouteGlobalShortcuts() {
return;
}

if (command === "thread.settle") {
event.preventDefault();
event.stopPropagation();
if (!routeThreadRef || event.repeat) return;
void (async () => {
const result = await settleThread(routeThreadRef);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to settle thread",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
})();
return;
}

if (command === "preview.toggle") {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -167,6 +193,7 @@ function ChatRouteGlobalShortcuts() {
projectGroupCount,
routeThreadRef,
selectedThreadKeysSize,
settleThread,
legacySidebarEnabled,
terminalOpen,
]);
Expand Down
3 changes: 3 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`.
Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and
`chat.new`. Project scripts are addressable as `script.{id}.run`, for example `script.test.run`.

`thread.settle` settles the open thread when it no longer needs attention. It has no default
shortcut; add one from **Settings** → **Keybindings** if you want a keyboard-first settle action.

`filePicker.toggle` opens file search for the active project and defaults to `mod+p`.
`projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`.
Repeating either shortcut closes that search, and switching shortcuts replaces the open search.
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ it.effect("accepts dynamic script run commands", () =>
}),
);

it.effect("accepts the configurable thread settle command", () =>
Effect.gen(function* () {
const parsed = yield* decode(KeybindingRule, {
key: "mod+shift+s",
command: "thread.settle",
when: "!terminalFocus",
});
assert.strictEqual(parsed.command, "thread.settle");
}),
);

it.effect("parses keybindings array payload", () =>
Effect.gen(function* () {
const parsed = yield* decode(KeybindingsConfig, [
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 @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =
export const THREAD_KEYBINDING_COMMANDS = [
"thread.previous",
"thread.next",
"thread.settle",
...THREAD_JUMP_KEYBINDING_COMMANDS,
] as const;
export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
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