From ac062eda380ad91e136076a33d4eb47d27a18b69 Mon Sep 17 00:00:00 2001 From: Jonathan McGuire <146113654+jbmcguire@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:19:28 -0500 Subject: [PATCH] feat(web): add thread.settle keyboard shortcut (mod+shift+s) Adds a thread.settle keybinding command so the active thread can be settled from the keyboard. Defaults to mod+shift+s outside terminal focus, reuses useThreadActions' settleThread (so the existing can-settle guardrails and version-skew checks apply), and surfaces failures via the standard toast. Documented in docs/user/keybindings.md. Co-Authored-By: Claude Fable 5 --- apps/server/src/keybindings.test.ts | 1 + apps/web/src/keybindings.test.ts | 29 +++++++++++++++++++++++++++ apps/web/src/routes/_chat.tsx | 29 +++++++++++++++++++++++++++ docs/user/keybindings.md | 4 +++- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac84167..f7f861638cbe 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd554..ff3ca3e5806f 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -128,6 +128,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, + { + shortcut: modShortcut("s", { shiftKey: true }), + command: "thread.settle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, { shortcut: modShortcut("2"), command: "thread.jump.2" }, { shortcut: modShortcut("3"), command: "thread.jump.3" }, @@ -497,6 +502,30 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches thread.settle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "s", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "thread.settle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "s", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "thread.settle", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "s", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + "thread.settle", + ); + }); + it("matches commandPalette.toggle shortcut outside terminal focus", () => { assert.strictEqual( resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 75c517dc33f6..7f39d1161ab2 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 sidebarV2Enabled = useSidebarV2Enabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -108,6 +114,28 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "thread.settle") { + event.preventDefault(); + event.stopPropagation(); + if (!routeThreadRef) return; + // Settle intentionally stays on the thread so the user can keep + // reading; only failures surface, matching the sidebar's behavior. + 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 +195,7 @@ function ChatRouteGlobalShortcuts() { projectGroupCount, routeThreadRef, selectedThreadKeysSize, + settleThread, sidebarV2Enabled, terminalOpen, ]); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a05..37c2f0eece68 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -33,7 +33,8 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, - { "key": "mod+o", "command": "editor.openFavorite" } + { "key": "mod+o", "command": "editor.openFavorite" }, + { "key": "mod+shift+s", "command": "thread.settle", "when": "!terminalFocus" } ] ``` @@ -67,6 +68,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor +- `thread.settle`: settle the active thread (blocked while the thread still needs attention) - `script.{id}.run`: run a project script by id (for example `script.test.run`) ### Key Syntax diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d966c1e7e61a..25bed0d2fed9 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]; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 0688cf072546..3ccfd51e4a12 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -43,6 +43,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, + { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command,