# Files changed · PR \#23097 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23097/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[refactor\(web\): chat shortcuts live with the hooks that own their actions](https://git.cafe/pingdotgg/t3code/pull/23097.md)

Version: 8

Head commit: 300d3ced33bc5b8afd5a823cd7bd27e9102db4c3

Base commit: 4d5d85a12e0d632a56dacb4849a956d4ec1d87b8

Comparison base: 4d5d85a12e0d632a56dacb4849a956d4ec1d87b8

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/ChatView.tsx

Status: modified; +14; −122

```diff
@@ -47,13 +47,12 @@
 import { feedbackBannerItem } from "./chat/ComposerFeedback";
 import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel";
 import { useWorktreeSetup } from "./chat/useWorktreeSetup";
-import { useChatShortcuts, type ChatShortcutHandler } from "./chat/useChatShortcuts";
+import { useChatShortcuts } from "./chat/useChatShortcuts";
 import * as Schema from "effect/Schema";
 import {
   AuthOrchestrationOperateScope,
   AuthSourceControlWriteScope,
   AuthPreviewOperateScope,
-  AuthTerminalReadScope,
   AuthTerminalOperateScope,
   type AssistantCitation,
   type ChatFileAttachment,
@@ -71,7 +70,6 @@
   resolveIdleCompaction,
   RuntimeMode,
 } from "@t3tools/contracts";
-import { useThreadActions } from "../hooks/useThreadActions";
 import {
   deriveProviderSubagentStatus,
   deriveReportedModelSelection,
@@ -138,7 +136,6 @@
 import { DEFAULT_INTERACTION_MODE, type ChatMessage } from "../types";
 import { useTheme } from "../hooks/useTheme";
 import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
-import { settleAndAdvance } from "../settleAndAdvance";
 import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";
 import { useMediaQuery } from "../hooks/useMediaQuery";
 import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider";
@@ -411,10 +408,8 @@
   } = props;
   const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
   const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
-  const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope);
   const draftId = routeKind === "draft" ? props.draftId : null;
   const handleNewThread = useNewThreadHandler();
-  const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
   const routeThreadRef = scopeThreadRef(environmentId, threadId);
   const routeThreadKey = scopedThreadKey(routeThreadRef);
   const currentRouteThreadKeyRef = useRef<string | null>(routeThreadKey);
@@ -1847,17 +1842,13 @@
     addTerminalContextToDraftStable,
     addTerminalSurfaceStable,
     closePanelTerminalStable,
-    createNewTerminalStable,
     lastInvokedScriptByProjectId,
-    requestClosePanelTerminalStable,
-    requestCloseTerminalStable,
     runProjectScriptStable,
     runShellCommandStable,
-    setTerminalOpenStable,
     splitPanelTerminalStable,
     splitPanelTerminalVerticalStable,
-    splitTerminalStable,
     toggleTerminalVisibilityStable,
+    shortcuts: terminalShortcuts,
   } = useChatTerminalActions({
     environmentId,
     composerRef,
@@ -2382,7 +2373,6 @@
       },
     );
   };
-  const copyActiveThreadReferenceStable = useStableCallback(copyActiveThreadReference);
   const pullRequestPanelTarget = activeThread
     ? threadPullRequestPanelTarget({
         projectId: activeThread.projectId,
@@ -2404,12 +2394,12 @@
     createBrowserSurfaceStable,
     moveRightPanelSurfaceStable,
     onOpenTurnDiffStable,
-    onToggleDiffStable,
     openChangesFromThreadPanelStable,
     openFileSurfaceStable,
     toggleRightPanelMaximizedStable,
     toggleRightPanelStable,
     toggleThreadPanelStable,
+    shortcuts: rightPanelShortcuts,
   } = useRightPanelActions({
     activeThreadRef,
     activeProject,
@@ -2434,29 +2424,26 @@
     explicitDiffOpenRef,
     setTerminalFocusRequestId,
     onDiffPanelOpen,
+    closeRightPanelSurface,
   });
   const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null;
   const nowMinute = useNowMinute();
   const {
     acknowledgeActiveThreadWoke,
     activeBranchMismatchKey,
-    activeThreadPinned,
-    activeThreadSettled,
     backgroundWorkBanner,
     childInputBanner,
     goalBanner,
     handleSwitchCheckoutToThread,
     handleSwitchCheckoutToThreadStable,
-    handleUnsettleActiveThreadStable,
     isRestoringThreadBranch,
     onOpenRelatedThread,
     onOpenRelatedThreadStable,
     sendStandaloneCommand,
     setBranchMismatchDismissTick,
     showBranchMismatchBanner,
-    supportsPinning,
-    supportsSettlement,
     threadStatusLine,
+    shortcuts: threadLifecycleShortcuts,
   } = useThreadLifecycle({
     environmentId,
     routeThreadKey,
@@ -2705,120 +2692,31 @@
   const threadFindControlsRef = useRef<ThreadFindControls | null>(null);
   const [isThreadFindActive, setIsThreadFindActive] = useState(false);
   const openThreadFind = () => threadFindControlsRef.current?.open();
-  const openThreadFindStable = useStableCallback(openThreadFind);
   const closeThreadFind = () => threadFindControlsRef.current?.close();
-  const closeThreadFindStable = useStableCallback(closeThreadFind);
 
-  const toastPinFailure = (title: string) => (result: AtomCommandResult<unknown, unknown>) => {
-    if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
-    const error = squashAtomCommandFailure(result);
-    toastManager.add(
-      stackedThreadToast({
-        type: "error",
-        title,
-        description: error instanceof Error ? error.message : "An error occurred.",
-      }),
-    );
-  };
-  const openTerminalIfClosed = () => {
-    if (!terminalUiState.terminalOpen) setTerminalOpenStable(true);
-  };
-  const openComposerControl = (command: Parameters<ChatComposerHandle["openControl"]>[0]) =>
-    (({ event }) => {
-      if (!event.repeat) composerRef.current?.openControl(command);
-    }) satisfies ChatShortcutHandler;
   useChatShortcuts({
     enabled: activeThreadId !== null,
     keybindings,
     scriptKeybindings,
-    getShortcutContext: getShortcutContextStable,
+    getShortcutContext,
     threadFindActive: isThreadFindActive,
-    typeIntoComposer: (key) => composerRef.current?.insertTextAtEnd(key) === true,
+    composerRef,
+    branchToolbarRef,
     closeThreadFind: () => {
-      closeThreadFindStable();
+      closeThreadFind();
       focusComposerStable();
     },
     commands: {
+      ...terminalShortcuts,
+      ...rightPanelShortcuts,
+      ...threadLifecycleShortcuts,
       "thread.copyReference": ({ event }) => {
-        if (!event.repeat) copyActiveThreadReferenceStable();
-      },
-      "thread.settle": () => {
-        if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
-        if (!isServerThread || !activeThreadRef || !supportsSettlement) return;
-        if (activeThreadSettled) {
-          void handleUnsettleActiveThreadStable();
-          return;
-        }
-        if (settleAndAdvance(activeThreadRef)) return;
-        void settleThread(activeThreadRef).then(toastPinFailure("Failed to settle thread"));
-      },
-      "thread.pin": () => {
-        if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
-        if (!isServerThread || !activeThreadRef || !supportsPinning) return;
-        void (
+        if (!event.repeat) copyActiveThreadReference();
-          activeThreadPinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)
-        ).then(
-          toastPinFailure(activeThreadPinned ? "Failed to unpin thread" : "Failed to pin thread"),
-        );
       },
-      "terminal.toggle": () => {
-        if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return false;
-        toggleTerminalVisibilityStable();
-      },
       "chat.find": () => {
         if (!isServerThread || serverConfig?.threadFind !== true) return false;
-        openThreadFindStable();
-      },
-      "rightPanel.toggle": () => toggleRightPanelStable(),
-      "rightPanel.toggleMaximized": ({ event }) => {
-        if (!event.repeat) toggleRightPanelMaximizedStable();
-      },
-      "threadPanel.toggle": () => toggleThreadPanelStable(),
-      "rightPanel.close": ({ event }) => {
-        if (!activeRightPanelSurface) return false;
-        if (!event.repeat) closeRightPanelSurfaceStable(activeRightPanelSurface);
-      },
-      "terminal.split": ({ terminalFocusOwner }) => {
-        if (!canOperateTerminal) return false;
-        if (terminalFocusOwner === "right-panel") return void splitPanelTerminalStable();
-        openTerminalIfClosed();
-        splitTerminalStable();
-      },
-      "terminal.splitVertical": ({ terminalFocusOwner }) => {
-        if (!canOperateTerminal) return false;
-        if (terminalFocusOwner === "right-panel") return void splitPanelTerminalStable("vertical");
-        openTerminalIfClosed();
-        splitTerminalStable("vertical");
-      },
-      "terminal.close": ({ terminalFocusOwner }) => {
-        if (!canOperateTerminal) return false;
-        if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
-          requestClosePanelTerminalStable(activeRightPanelSurface.activeTerminalId);
-          return;
-        }
-        if (terminalUiState.terminalOpen)
-          requestCloseTerminalStable(terminalUiState.activeTerminalId);
-      },
-      "terminal.new": ({ terminalFocusOwner }) => {
-        if (!canOperateTerminal) return false;
-        if (terminalFocusOwner === "right-panel") return void addTerminalSurfaceStable();
-        openTerminalIfClosed();
-        createNewTerminalStable();
-      },
-      "diff.toggle": () => onToggleDiffStable(),
-      "modelPicker.toggle": ({ event }) => {
-        if (!event.repeat) composerRef.current?.toggleModelPicker();
+        openThreadFind();
       },
-      "composer.increaseEffort": ({ event }) => {
-        if (!event.repeat) composerRef.current?.stepReasoningEffort(1);
-      },
-      "composer.decreaseEffort": ({ event }) => {
-        if (!event.repeat) composerRef.current?.stepReasoningEffort(-1);
-      },
-      "composer.host": openComposerControl("composer.host"),
-      "composer.effort": openComposerControl("composer.effort"),
-      "composer.mode": openComposerControl("composer.mode"),
-      "composer.workspace": openComposerControl("composer.workspace"),
       "composer.cycleHost": ({ event }) => {
         if (!canChangeEnvironment) return false;
         if (event.repeat) return;
@@ -2829,12 +2727,6 @@
         const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length];
         if (next) onEnvironmentChangeStable(next.environmentId);
       },
-      "composer.branch": ({ event }) => {
-        if (!event.repeat) branchToolbarRef.current?.openBranchPicker();
-      },
-      "composer.previousWorktree": ({ event }) => {
-        if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree();
-      },
       "thread.steerQueuedMessage": ({ event }) =>
         routeKind !== "draft" && queuedRunsControlRef.current?.steerNext(event.repeat) === true,
       "thread.editQueuedMessage": ({ event }) =>
```

## apps/web/src/components/chat/composerShortcuts.ts

Status: added; +38; −0

```diff
@@ -1,0 +1,38 @@
+import type { ComposerHandleRef } from "../../composerHandleContext";
+import type { BranchToolbarHandle } from "../BranchToolbar";
+import type { ChatComposerHandle } from "./ChatComposer";
+import type { ChatShortcutCommands } from "./useChatShortcuts";
+
+const openControl =
+  (composerRef: ComposerHandleRef, control: Parameters<ChatComposerHandle["openControl"]>[0]) =>
+  ({ event }: { readonly event: KeyboardEvent }) => {
+    if (!event.repeat) composerRef.current?.openControl(control);
+  };
+
+/** Chords that open or step the composer's controls and its branch toolbar. Held keys do not repeat them. */
+export function composerShortcutCommands(
+  composerRef: ComposerHandleRef,
+  branchToolbarRef: { readonly current: BranchToolbarHandle | null },
+): ChatShortcutCommands {
+  return {
+    "modelPicker.toggle": ({ event }) => {
+      if (!event.repeat) composerRef.current?.toggleModelPicker();
+    },
+    "composer.increaseEffort": ({ event }) => {
+      if (!event.repeat) composerRef.current?.stepReasoningEffort(1);
+    },
+    "composer.decreaseEffort": ({ event }) => {
+      if (!event.repeat) composerRef.current?.stepReasoningEffort(-1);
+    },
+    "composer.host": openControl(composerRef, "composer.host"),
+    "composer.effort": openControl(composerRef, "composer.effort"),
+    "composer.mode": openControl(composerRef, "composer.mode"),
+    "composer.workspace": openControl(composerRef, "composer.workspace"),
+    "composer.branch": ({ event }) => {
+      if (!event.repeat) branchToolbarRef.current?.openBranchPicker();
+    },
+    "composer.previousWorktree": ({ event }) => {
+      if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree();
+    },
+  } satisfies ChatShortcutCommands;
+}
```

## apps/web/src/components/chat/useChatShortcuts.ts

Status: modified; +69; −64

```diff
@@ -1,5 +1,5 @@
 import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts";
-import { useEffect, useLayoutEffect, useRef } from "react";
+import { useEffect, useEffectEvent } from "react";
 import { isCommandPaletteOpen } from "../../commandPaletteBus";
 import { resolveChatShortcutCommand, type ShortcutMatchContext } from "../../keybindings";
 import { isTerminalCloseConfirmPending } from "../../lib/terminalCloseConfirm";
@@ -8,7 +8,10 @@
   preventTerminalCloseShortcut,
 } from "../../lib/terminalCloseShortcut";
 import { getTerminalFocusOwner, type TerminalFocusOwner } from "../../lib/terminalFocus";
+import type { ComposerHandleRef } from "../../composerHandleContext";
+import type { BranchToolbarHandle } from "../BranchToolbar";
 import { shouldTypeToFocusComposer } from "./composerFocusRedirect";
+import { composerShortcutCommands } from "./composerShortcuts";
 
 /** Runs a command. Returning false lets the key event through untouched. */
 export type ChatShortcutHandler = (context: {
@@ -16,6 +19,8 @@
   readonly terminalFocusOwner: TerminalFocusOwner | null;
 }) => boolean | void;
 
+export type ChatShortcutCommands = Partial<Record<KeybindingCommand, ChatShortcutHandler>>;
+
 export interface ChatShortcutsInput {
   readonly enabled: boolean;
   readonly keybindings: ResolvedKeybindingsConfig;
@@ -24,83 +29,83 @@
     readonly modelPickerOpen: boolean;
   };
   readonly threadFindActive: boolean;
+  /** Takes type-to-focus keys and the composer and branch toolbar chords. */
+  readonly composerRef: ComposerHandleRef;
-  /** Types a printable key into the composer; false when it could not take it. */
-  readonly typeIntoComposer: (key: string) => boolean;
+  readonly branchToolbarRef: { readonly current: BranchToolbarHandle | null };
   readonly closeThreadFind: () => void;
-  readonly commands: Partial<Record<KeybindingCommand, ChatShortcutHandler>>;
+  readonly commands: ChatShortcutCommands;
   /** Handles commands not in `commands`, such as project scripts. */
   readonly fallback: (command: KeybindingCommand) => ChatShortcutHandler | undefined;
 }
 
 /**
  * The chat's window keyboard shortcuts, plus Escape closing thread find.
- * Listeners attach once and read the latest input on each event, so handlers
+ * Listeners attach once; the effect events read the latest input, so handlers
  * act on the current render without re-registering on every change.
  */
 export function useChatShortcuts(input: ChatShortcutsInput) {
-  const latest = useRef(input);
+  const handleShortcut = useEffectEvent((event: KeyboardEvent) => {
+    const { keybindings } = input;
+    if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {
+      event.stopPropagation();
+      return;
+    }
+    if (input.threadFindActive && event.key === "Escape") return;
+    if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) {
+      event.stopPropagation();
-  useLayoutEffect(() => {
-    latest.current = input;
+      return;
+    }
+    if (!input.enabled || isCommandPaletteOpen()) return;
+    const terminalFocusOwner = getTerminalFocusOwner();
+    if (event.defaultPrevented && terminalFocusOwner === null) return;
+    const shortcutContext = input.getShortcutContext(event.target);
-  });
-  useEffect(() => {
-    const handler = (event: KeyboardEvent) => {
-      const current = latest.current;
-      const { keybindings } = current;
-      if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {
-        event.stopPropagation();
-        return;
-      }
-      if (current.threadFindActive && event.key === "Escape") return;
-      if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) {
-        event.stopPropagation();
-        return;
-      }
-      if (!current.enabled || isCommandPaletteOpen()) return;
-      const terminalFocusOwner = getTerminalFocusOwner();
-      if (event.defaultPrevented && terminalFocusOwner === null) return;
-      const shortcutContext = current.getShortcutContext(event.target);
 
-      if (
-        !shortcutContext.terminalFocus &&
-        !shortcutContext.modelPickerOpen &&
-        shouldTypeToFocusComposer(event) &&
-        current.typeIntoComposer(event.key)
-      ) {
-        event.preventDefault();
-        event.stopPropagation();
-        return;
+    if (
+      !shortcutContext.terminalFocus &&
+      !shortcutContext.modelPickerOpen &&
+      shouldTypeToFocusComposer(event) &&
+      input.composerRef.current?.insertTextAtEnd(event.key) === true
+    ) {
-      }
-
-      const command = resolveChatShortcutCommand(event, keybindings, current.scriptKeybindings, {
-        context: shortcutContext,
-      });
-      if (!command) return;
-      const run = current.commands[command] ?? current.fallback(command);
-      if (!run || run({ event, terminalFocusOwner }) === false) return;
       event.preventDefault();
       event.stopPropagation();
+      return;
-    };
-    const dismissFind = (event: KeyboardEvent) => {
-      const current = latest.current;
+    }
+
+    const command = resolveChatShortcutCommand(event, keybindings, input.scriptKeybindings, {
+      context: shortcutContext,
+    });
-      if (
-        event.key !== "Escape" ||
-        !current.threadFindActive ||
-        event.defaultPrevented ||
-        event.isComposing ||
-        event.keyCode === 229 ||
-        isCommandPaletteOpen()
-      )
-        return;
-      const context = current.getShortcutContext(event.target);
-      if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return;
-      event.preventDefault();
-      current.closeThreadFind();
-    };
-    window.addEventListener("keydown", handler, true);
-    window.addEventListener("keydown", dismissFind);
+    if (!command) return;
+    const run =
+      input.commands[command] ??
+      composerShortcutCommands(input.composerRef, input.branchToolbarRef)[command] ??
+      input.fallback(command);
+    if (!run || run({ event, terminalFocusOwner }) === false) return;
+    event.preventDefault();
+    event.stopPropagation();
+  });
+  const dismissFind = useEffectEvent((event: KeyboardEvent) => {
+    if (
+      event.key !== "Escape" ||
+      !input.threadFindActive ||
+      event.defaultPrevented ||
+      event.isComposing ||
+      event.keyCode === 229 ||
+      isCommandPaletteOpen()
+    )
+      return;
+    const context = input.getShortcutContext(event.target);
+    if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return;
+    event.preventDefault();
+    input.closeThreadFind();
+  });
+  useEffect(() => {
+    const onKeyDownCapture = (event: KeyboardEvent) => handleShortcut(event);
+    const onKeyDown = (event: KeyboardEvent) => dismissFind(event);
+    window.addEventListener("keydown", onKeyDownCapture, true);
+    window.addEventListener("keydown", onKeyDown);
     return () => {
-      window.removeEventListener("keydown", handler, true);
-      window.removeEventListener("keydown", dismissFind);
+      window.removeEventListener("keydown", onKeyDownCapture, true);
+      window.removeEventListener("keydown", onKeyDown);
     };
   }, []);
 }
```

## apps/web/src/components/chat/useChatTerminalActions.ts

Status: modified; +43; −0

```diff
@@ -41,6 +41,7 @@
 import { previewEnvironment } from "../../state/preview";
 import { readEnvironmentScope } from "../../state/session";
 import { useAtomCommand } from "../../state/use-atom-command";
+import type { ChatShortcutCommands } from "./useChatShortcuts";
 const SCRIPT_TERMINAL_COLS = 120;
 const SCRIPT_TERMINAL_ROWS = 30;
 
@@ -388,7 +389,49 @@
     });
   };
   const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal);
+  const openTerminalIfClosed = () => {
+    if (!terminalUiState.terminalOpen) setTerminalOpen(true);
+  };
+  // A focused right-panel terminal takes the split/new/close chords; otherwise they act on the drawer.
+  const shortcuts = {
+    "terminal.toggle": () => {
+      if (
+        !terminalUiState.terminalOpen &&
+        !readEnvironmentScope(environmentId, AuthTerminalReadScope) &&
+        !hasTerminalWriteAccess()
+      )
+        return false;
+      toggleTerminalVisibility();
+    },
+    "terminal.split": ({ terminalFocusOwner }) => {
+      if (!hasTerminalWriteAccess()) return false;
+      if (terminalFocusOwner === "right-panel") return void splitPanelTerminal();
+      openTerminalIfClosed();
+      splitTerminal();
+    },
+    "terminal.splitVertical": ({ terminalFocusOwner }) => {
+      if (!hasTerminalWriteAccess()) return false;
+      if (terminalFocusOwner === "right-panel") return void splitPanelTerminal("vertical");
+      openTerminalIfClosed();
+      splitTerminal("vertical");
+    },
+    "terminal.close": ({ terminalFocusOwner }) => {
+      if (!hasTerminalWriteAccess()) return false;
+      if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
+        requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId);
+        return;
+      }
+      if (terminalUiState.terminalOpen) requestCloseTerminal(terminalUiState.activeTerminalId);
+    },
+    "terminal.new": ({ terminalFocusOwner }) => {
+      if (!hasTerminalWriteAccess()) return false;
+      if (terminalFocusOwner === "right-panel") return void addTerminalSurface();
+      openTerminalIfClosed();
+      createNewTerminal();
+    },
+  } satisfies ChatShortcutCommands;
   return {
+    shortcuts,
     activatePanelTerminalStable,
     addTerminalContextToDraftStable,
     addTerminalSurfaceStable,
```

## apps/web/src/components/chat/useRightPanelActions.ts

Status: modified; +17; −0

```diff
@@ -30,6 +30,7 @@
 import { openDeviceSetupDialog } from "./ChatOverlayHosts";
 import { previewEnvironment } from "../../state/preview";
 import { useAtomCommand } from "../../state/use-atom-command";
+import type { ChatShortcutCommands } from "./useChatShortcuts";
 interface RightPanelActionsInput {
   readonly activeThreadRef: ScopedThreadRef | null;
   readonly activeProject: EnvironmentProject | null;
@@ -54,6 +55,7 @@
   readonly explicitDiffOpenRef: { current: ScopedThreadRef | null };
   readonly setTerminalFocusRequestId: Dispatch<SetStateAction<number>>;
   readonly onDiffPanelOpen: (() => void) | undefined;
+  readonly closeRightPanelSurface: (surface: RightPanelSurface) => void;
 }
 
 /**
@@ -62,6 +64,7 @@
  */
 export function useRightPanelActions(input: RightPanelActionsInput) {
   const {
+    closeRightPanelSurface,
     activeThreadRef,
     activeProject,
     activeRightPanelSurface,
@@ -290,7 +293,21 @@
     [togglePreviewPanelStable],
   );
 
+  const shortcuts = {
+    "rightPanel.toggle": () => toggleRightPanel(),
+    "rightPanel.toggleMaximized": ({ event }) => {
+      if (!event.repeat) toggleRightPanelMaximized();
+    },
+    "threadPanel.toggle": () => toggleThreadPanel(),
+    "rightPanel.close": ({ event }) => {
+      if (!activeRightPanelSurface) return false;
+      if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface);
+    },
+    "diff.toggle": () => onToggleDiff(),
+  } satisfies ChatShortcutCommands;
+
   return {
+    shortcuts,
     activateRightPanelSurfaceStable,
     addDeviceSurfaceStable,
     addDiffSurfaceStable,
```

## apps/web/src/components/chat/useThreadLifecycle.tsx

Status: modified; +40; −6

```diff
@@ -23,7 +23,9 @@
   RuntimeMode,
 } from "@t3tools/contracts";
 import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
+import { useAcknowledgeThreadWoke, useThreadActions } from "../../hooks/useThreadActions";
+import { settleAndAdvance } from "../../settleAndAdvance";
-import { useAcknowledgeThreadWoke } from "../../hooks/useThreadActions";
+import type { ChatShortcutCommands } from "./useChatShortcuts";
 import { resolveThreadLastVisitedAt } from "../Sidebar.logic";
 import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
 import { type SetStateAction, useEffect, useRef, useState } from "react";
@@ -148,6 +150,7 @@
   } = input;
   const navigate = useNavigate();
   const router = useRouter();
+  const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
   const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
   const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
   const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
@@ -558,25 +561,56 @@
       onAction={acknowledgeActiveThreadWokeStable}
     />
   ) : null;
+  const toastThreadActionFailure =
+    (title: string) => (result: AtomCommandResult<unknown, unknown>) => {
+      if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
+      const error = squashAtomCommandFailure(result);
+      toastManager.add(
+        stackedThreadToast({
+          type: "error",
+          title,
+          description: error instanceof Error ? error.message : "An error occurred.",
+        }),
+      );
+    };
+  const shortcuts = {
+    "thread.settle": () => {
+      if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
+      if (!activeThreadShell || !activeThreadRef || !supportsSettlement) return;
+      if (activeThreadSettled) {
+        void handleUnsettleActiveThread();
+        return;
+      }
+      if (settleAndAdvance(activeThreadRef)) return;
+      void settleThread(activeThreadRef).then(toastThreadActionFailure("Failed to settle thread"));
+    },
+    "thread.pin": () => {
+      if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
+      if (!activeThreadShell || !activeThreadRef || !supportsPinning) return;
+      void (
+        activeThreadPinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)
+      ).then(
+        toastThreadActionFailure(
+          activeThreadPinned ? "Failed to unpin thread" : "Failed to pin thread",
+        ),
+      );
+    },
+  } satisfies ChatShortcutCommands;
   return {
+    shortcuts,
     acknowledgeActiveThreadWoke,
     activeBranchMismatchKey,
-    activeThreadPinned,
-    activeThreadSettled,
     backgroundWorkBanner,
     childInputBanner,
     goalBanner,
     handleSwitchCheckoutToThread,
     handleSwitchCheckoutToThreadStable,
-    handleUnsettleActiveThreadStable,
     isRestoringThreadBranch,
     onOpenRelatedThread,
     onOpenRelatedThreadStable,
     sendStandaloneCommand,
     setBranchMismatchDismissTick,
     showBranchMismatchBanner,
-    supportsPinning,
-    supportsSettlement,
     threadStatusLine,
   };
 }
```
