diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 22a91b7c1504..acc040b5a93f 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -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", @@ -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", () => { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index da54e86e42e1..e97cc32f039d 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -1,4 +1,5 @@ import { + STATIC_KEYBINDING_COMMANDS, type KeybindingCommand, type KeybindingShortcut, type KeybindingWhenNode, @@ -255,7 +256,7 @@ export function buildWhenVariableOptions(): ReadonlyArray { export function buildKeybindingCommandOptions( keybindings: ResolvedKeybindingsConfig, ): ReadonlyArray { - const commands = new Set(); + const commands = new Set(STATIC_KEYBINDING_COMMANDS); for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { commands.add(binding.command); } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 11aa97dc8e86..d47a8811d9c4 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -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, { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..5594603c461a 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -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"; @@ -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"; @@ -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); @@ -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(); @@ -167,6 +193,7 @@ function ChatRouteGlobalShortcuts() { projectGroupCount, routeThreadRef, selectedThreadKeysSize, + settleThread, legacySidebarEnabled, terminalOpen, ]); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f7f6facbe594..c7c1e3560288 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -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. diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 342e44678938..d0318f1c4975 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -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, [ diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3fcbf6ef5fdb..08ce70a72d5e 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -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]; @@ -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",