diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx b/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx
deleted file mode 100644
index b3ae39981..000000000
--- a/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { act } from "@testing-library/react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
- navigateToWorkspaceMock: vi.fn(),
- routerMock: {
- back: vi.fn(),
- push: vi.fn(),
- replace: vi.fn(),
- },
- pathState: {
- pathname: "/h/srv/workspace/ws-2",
- },
-}));
-
-vi.hoisted(() => {
- (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
-});
-
-vi.mock("expo-router", () => ({
- usePathname: () => pathState.pathname,
- useRouter: () => routerMock,
-}));
-
-vi.mock("@/constants/layout", () => ({
- getIsElectronRuntime: () => true,
-}));
-
-vi.mock("@/constants/platform", () => ({
- isNative: false,
- isWeb: true,
-}));
-
-vi.mock("@/utils/shortcut-platform", () => ({
- getShortcutOs: () => "mac",
-}));
-
-vi.mock("@/hooks/use-active-server-id", () => ({
- useActiveServerId: () => "srv",
-}));
-
-vi.mock("@/hooks/use-open-project-picker", () => ({
- useOpenProjectPicker: () => vi.fn(),
-}));
-
-vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({
- useKeyboardShortcutOverrides: () => ({ overrides: {} }),
-}));
-
-vi.mock("@/hooks/use-workspace-navigation", () => ({
- navigateToWorkspace: navigateToWorkspaceMock,
-}));
-
-import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
-import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
-import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
-import {
- activateNavigationWorkspaceSelection,
- syncNavigationActiveWorkspace,
-} from "@/stores/navigation-active-workspace-store";
-
-function Probe() {
- useKeyboardShortcuts({
- enabled: true,
- isMobile: false,
- toggleAgentList: vi.fn(),
- });
-
- return null;
-}
-
-describe("useKeyboardShortcuts", () => {
- let root: Root | null = null;
- let container: HTMLElement | null = null;
-
- beforeEach(() => {
- navigateToWorkspaceMock.mockReset();
- routerMock.back.mockReset();
- routerMock.push.mockReset();
- routerMock.replace.mockReset();
- pathState.pathname = "/h/srv/workspace/ws-2";
- syncNavigationActiveWorkspace({ current: null });
- useKeyboardShortcutsStore.setState({
- capturingShortcut: false,
- commandCenterOpen: false,
- sidebarShortcutWorkspaceTargets: [
- { serverId: "srv", workspaceId: "ws-1" },
- { serverId: "srv", workspaceId: "ws-2" },
- { serverId: "srv", workspaceId: "ws-3" },
- { serverId: "srv", workspaceId: "ws-4" },
- { serverId: "srv", workspaceId: "ws-5" },
- ],
- });
-
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
- });
-
- afterEach(() => {
- if (root) {
- act(() => {
- root?.unmount();
- });
- }
- root = null;
- container?.remove();
- container = null;
- syncNavigationActiveWorkspace({ current: null });
- });
-
- it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => {
- activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" });
-
- await act(async () => {
- root?.render();
- });
-
- const event = new KeyboardEvent("keydown", {
- key: "]",
- code: "BracketRight",
- metaKey: true,
- bubbles: true,
- cancelable: true,
- });
- window.dispatchEvent(event);
-
- expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", {
- currentPathname: "/h/srv/workspace/ws-2",
- });
- expect(event.defaultPrevented).toBe(true);
- });
-
- it("dispatches Escape as an agent interrupt action", async () => {
- const handleInterrupt = vi.fn(() => true);
- const unregister = keyboardActionDispatcher.registerHandler({
- handlerId: "test-agent-interrupt",
- actions: ["agent.interrupt"],
- enabled: true,
- priority: 100,
- isActive: () => true,
- handle: handleInterrupt,
- });
-
- await act(async () => {
- root?.render();
- });
-
- const event = new KeyboardEvent("keydown", {
- key: "Escape",
- code: "Escape",
- bubbles: true,
- cancelable: true,
- });
- window.dispatchEvent(event);
-
- expect(handleInterrupt).toHaveBeenCalledTimes(1);
- unregister();
- });
-
- it("returns from desktop settings to the retained workspace without browser history back", async () => {
- syncNavigationActiveWorkspace({
- current: {
- getCurrentRoute: () => ({ path: "/h/srv/workspace/ws-2" }),
- },
- });
- pathState.pathname = "/settings/general";
-
- await act(async () => {
- root?.render();
- });
-
- const event = new KeyboardEvent("keydown", {
- key: ",",
- code: "Comma",
- metaKey: true,
- bubbles: true,
- cancelable: true,
- });
- window.dispatchEvent(event);
-
- expect(routerMock.replace).toHaveBeenCalledWith("/h/srv/workspace/ws-2");
- expect(routerMock.back).not.toHaveBeenCalled();
- expect(event.defaultPrevented).toBe(true);
- });
-});
diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts
index 12056c9a6..fbc4cb64e 100644
--- a/packages/app/src/hooks/use-keyboard-shortcuts.ts
+++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts
@@ -3,16 +3,7 @@ import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
-import {
- buildHostWorkspaceRoute,
- buildSettingsRoute,
- parseHostWorkspaceRouteFromPathname,
-} from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
-import {
- type MessageInputKeyboardActionKind,
- type KeyboardShortcutPayload,
-} from "@/keyboard/actions";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
type ChordState,
@@ -20,26 +11,23 @@ import {
buildEffectiveBindings,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
+import {
+ routeKeyboardShortcut,
+ type ShortcutAction,
+ type ShortcutCallbackName,
+} from "@/keyboard/route-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
-import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
getLastNavigationWorkspaceRouteSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
-function hasPayloadKey(
- payload: KeyboardShortcutPayload,
- key: K,
-): payload is KeyboardShortcutPayload & Record {
- return !!payload && typeof payload === "object" && key in payload;
-}
-
export function useKeyboardShortcuts({
enabled,
isMobile,
@@ -82,241 +70,57 @@ export function useKeyboardShortcuts({
return true;
};
- const navigateToWorkspaceShortcut = (index: number): boolean => {
- const state = useKeyboardShortcutsStore.getState();
- const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null;
- if (!target) {
- return false;
- }
-
- navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
- return true;
- };
- const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
- const state = useKeyboardShortcutsStore.getState();
- const targets = state.sidebarShortcutWorkspaceTargets;
- if (targets.length === 0) {
- return false;
- }
-
- const workspaceRoute =
- getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
- const target = getRelativeSidebarShortcutTarget({
- targets,
- currentTarget: workspaceRoute
- ? {
- serverId: workspaceRoute.serverId,
- workspaceId: workspaceRoute.workspaceId,
- }
- : null,
- delta,
- });
- if (!target) {
- return false;
- }
- navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
- return true;
+ const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const targetEl =
+ target?.closest?.("textarea, input, [contenteditable='true']") ??
+ (target instanceof HTMLElement ? target : null);
+ const active = document.activeElement;
+ const activeEl = active instanceof HTMLElement ? active : null;
+ setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
};
- const openProjectPicker = (): boolean => {
- void openProjectPickerAction();
- return true;
+ const callbacksByName: Record void) | undefined> = {
+ "toggle-agent-list": toggleAgentList,
+ "toggle-both-sidebars": toggleBothSidebars,
+ "toggle-focus-mode": toggleFocusMode,
+ "cycle-theme": cycleTheme,
};
- const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => {
- switch (kind) {
- case "focus":
- return keyboardActionDispatcher.dispatch({
- id: "message-input.focus",
- scope: "message-input",
- });
- case "send":
- return keyboardActionDispatcher.dispatch({
- id: "message-input.send",
- scope: "message-input",
- });
- case "dictation-toggle":
- return keyboardActionDispatcher.dispatch({
- id: "message-input.dictation-toggle",
- scope: "message-input",
- });
- case "dictation-cancel":
- return keyboardActionDispatcher.dispatch({
- id: "message-input.dictation-cancel",
- scope: "message-input",
- });
- case "dictation-confirm":
- return keyboardActionDispatcher.dispatch({
- id: "message-input.dictation-confirm",
- scope: "message-input",
- });
- case "voice-toggle":
- return keyboardActionDispatcher.dispatch({
- id: "message-input.voice-toggle",
- scope: "message-input",
- });
- case "voice-mute-toggle":
- return keyboardActionDispatcher.dispatch({
- id: "message-input.voice-mute-toggle",
- scope: "message-input",
- });
- default:
+ const performShortcutAction = (action: ShortcutAction, event: KeyboardEvent): boolean => {
+ switch (action.kind) {
+ case "none":
return false;
- }
- };
- const handleDispatchOnlyAction = (action: string): boolean | null => {
- switch (action) {
- case "agent.interrupt":
- return keyboardActionDispatcher.dispatch({ id: "agent.interrupt", scope: "global" });
- case "workspace.tab.new":
- return keyboardActionDispatcher.dispatch({ id: "workspace.tab.new", scope: "workspace" });
- case "worktree.archive":
- return keyboardActionDispatcher.dispatch({ id: "worktree.archive", scope: "sidebar" });
- case "worktree.new":
- return keyboardActionDispatcher.dispatch({ id: "worktree.new", scope: "sidebar" });
- case "workspace.terminal.new":
- return keyboardActionDispatcher.dispatch({
- id: "workspace.terminal.new",
- scope: "workspace",
- });
- case "workspace.tab.close.current":
- return keyboardActionDispatcher.dispatch({
- id: "workspace.tab.close-current",
- scope: "workspace",
- });
- case "sidebar.toggle.right":
- return keyboardActionDispatcher.dispatch({
- id: "sidebar.toggle.right",
- scope: "sidebar",
- });
- case "workspace.pane.split.right":
- case "workspace.pane.split.down":
- case "workspace.pane.focus.left":
- case "workspace.pane.focus.right":
- case "workspace.pane.focus.up":
- case "workspace.pane.focus.down":
- case "workspace.pane.move-tab.left":
- case "workspace.pane.move-tab.right":
- case "workspace.pane.move-tab.up":
- case "workspace.pane.move-tab.down":
- case "workspace.pane.close":
- return keyboardActionDispatcher.dispatch({ id: action, scope: "workspace" });
- default:
- return null;
- }
- };
-
- const handlePayloadAction = (
- action: string,
- payload: KeyboardShortcutPayload,
- ): boolean | null => {
- switch (action) {
- case "workspace.tab.navigate.index":
- if (!hasPayloadKey(payload, "index")) return false;
- return keyboardActionDispatcher.dispatch({
- id: "workspace.tab.navigate-index",
- scope: "workspace",
- index: payload.index,
- });
- case "workspace.tab.navigate.relative":
- if (!hasPayloadKey(payload, "delta")) return false;
- return keyboardActionDispatcher.dispatch({
- id: "workspace.tab.navigate-relative",
- scope: "workspace",
- delta: payload.delta,
- });
- case "workspace.navigate.index":
- if (!hasPayloadKey(payload, "index")) return false;
- return navigateToWorkspaceShortcut(payload.index);
- case "workspace.navigate.relative":
- if (!hasPayloadKey(payload, "delta")) return false;
- return navigateRelativeWorkspace(payload.delta);
- case "message-input.action":
- if (!hasPayloadKey(payload, "kind")) return false;
- return dispatchMessageInputAction(payload.kind);
- default:
- return null;
- }
- };
-
- const handleSettingsToggle = (): boolean => {
- if (pathname.startsWith("/settings")) {
- if (!isMobile) {
- const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
- if (lastWorkspaceRoute) {
- router.replace(
- buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
- );
- return true;
- }
- }
- router.back();
- return true;
- }
- router.push(buildSettingsRoute());
- return true;
- };
-
- const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
- const store = useKeyboardShortcutsStore.getState();
- if (!store.commandCenterOpen) {
- const target = event.target instanceof Element ? event.target : null;
- const targetEl =
- target?.closest?.("textarea, input, [contenteditable='true']") ??
- (target instanceof HTMLElement ? target : null);
- const active = document.activeElement;
- const activeEl = active instanceof HTMLElement ? active : null;
- setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
- }
- store.setCommandCenterOpen(!store.commandCenterOpen);
- return true;
- };
-
- const handleAction = (input: {
- action: string;
- payload: KeyboardShortcutPayload;
- event: KeyboardEvent;
- }): boolean => {
- const dispatchOnlyResult = handleDispatchOnlyAction(input.action);
- if (dispatchOnlyResult !== null) {
- return dispatchOnlyResult;
- }
- const payloadResult = handlePayloadAction(input.action, input.payload);
- if (payloadResult !== null) {
- return payloadResult;
- }
- switch (input.action) {
- case "agent.new":
- return openProjectPicker();
- case "sidebar.toggle.left":
- toggleAgentList();
+ case "dispatch":
+ return keyboardActionDispatcher.dispatch(action.action);
+ case "navigate-workspace":
+ navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
- case "settings.toggle":
- return handleSettingsToggle();
- case "sidebar.toggle.both":
- if (toggleBothSidebars) {
- toggleBothSidebars();
- }
+ case "router-replace":
+ router.replace(action.route);
return true;
- case "view.toggle.focus":
- if (toggleFocusMode) {
- toggleFocusMode();
- }
+ case "router-back":
+ router.back();
return true;
- case "theme.cycle":
- if (cycleTheme) {
- cycleTheme();
- }
+ case "router-push":
+ router.push(action.route);
return true;
- case "command-center.toggle":
- return handleCommandCenterToggle(input.event);
- case "shortcuts.dialog.toggle": {
- const store = useKeyboardShortcutsStore.getState();
- store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
+ case "open-project-picker":
+ void openProjectPickerAction();
+ return true;
+ case "callback":
+ callbacksByName[action.name]?.();
+ return true;
+ case "command-center-toggle": {
+ if (action.nextOpen) {
+ captureCommandCenterFocusRestore(event);
+ }
+ useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
return true;
}
- default:
- return false;
+ case "shortcuts-dialog-toggle":
+ useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
+ return true;
}
};
@@ -384,11 +188,20 @@ export function useKeyboardShortcuts({
return;
}
- const handled = handleAction({
- action: result.match.action,
- payload: result.match.payload,
- event,
- });
+ const shortcutAction = routeKeyboardShortcut(
+ { action: result.match.action, payload: result.match.payload },
+ {
+ pathname,
+ isMobile,
+ sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
+ navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
+ lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
+ commandCenterOpen: store.commandCenterOpen,
+ shortcutsDialogOpen: store.shortcutsDialogOpen,
+ },
+ );
+
+ const handled = performShortcutAction(shortcutAction, event);
if (!handled) {
return;
}
diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts
new file mode 100644
index 000000000..4030ca7d1
--- /dev/null
+++ b/packages/app/src/keyboard/route-shortcut.test.ts
@@ -0,0 +1,357 @@
+import { describe, expect, it } from "vitest";
+import {
+ routeKeyboardShortcut,
+ type ShortcutAction,
+ type ShortcutRoutingContext,
+} from "./route-shortcut";
+
+const SIDEBAR_TARGETS = [
+ { serverId: "srv", workspaceId: "ws-1" },
+ { serverId: "srv", workspaceId: "ws-2" },
+ { serverId: "srv", workspaceId: "ws-3" },
+ { serverId: "srv", workspaceId: "ws-4" },
+ { serverId: "srv", workspaceId: "ws-5" },
+] as const;
+
+function makeCtx(overrides: Partial = {}): ShortcutRoutingContext {
+ return {
+ pathname: "/h/srv/workspace/ws-2",
+ isMobile: false,
+ sidebarShortcutTargets: SIDEBAR_TARGETS,
+ navigationActiveWorkspace: null,
+ lastNavigationWorkspaceRoute: null,
+ commandCenterOpen: false,
+ shortcutsDialogOpen: false,
+ ...overrides,
+ };
+}
+
+describe("routeKeyboardShortcut — dispatch passthroughs", () => {
+ it.each([
+ ["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
+ ["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
+ ["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
+ ["worktree.new", { id: "worktree.new", scope: "sidebar" }],
+ ["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
+ ["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
+ ["sidebar.toggle.right", { id: "sidebar.toggle.right", scope: "sidebar" }],
+ ["workspace.pane.split.right", { id: "workspace.pane.split.right", scope: "workspace" }],
+ ["workspace.pane.split.down", { id: "workspace.pane.split.down", scope: "workspace" }],
+ ["workspace.pane.focus.left", { id: "workspace.pane.focus.left", scope: "workspace" }],
+ ["workspace.pane.focus.right", { id: "workspace.pane.focus.right", scope: "workspace" }],
+ ["workspace.pane.focus.up", { id: "workspace.pane.focus.up", scope: "workspace" }],
+ ["workspace.pane.focus.down", { id: "workspace.pane.focus.down", scope: "workspace" }],
+ ["workspace.pane.move-tab.left", { id: "workspace.pane.move-tab.left", scope: "workspace" }],
+ ["workspace.pane.move-tab.right", { id: "workspace.pane.move-tab.right", scope: "workspace" }],
+ ["workspace.pane.move-tab.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }],
+ ["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", scope: "workspace" }],
+ ["workspace.pane.close", { id: "workspace.pane.close", scope: "workspace" }],
+ ])("%s → dispatch %j", (action, expected) => {
+ expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({
+ kind: "dispatch",
+ action: expected,
+ });
+ });
+});
+
+describe("routeKeyboardShortcut — workspace.tab.navigate", () => {
+ it("forwards index payloads to the workspace.tab.navigate-index dispatch", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.tab.navigate.index", payload: { index: 3 } },
+ makeCtx(),
+ ),
+ ).toEqual({
+ kind: "dispatch",
+ action: { id: "workspace.tab.navigate-index", scope: "workspace", index: 3 },
+ });
+ });
+
+ it("returns none when index payload is missing", () => {
+ expect(
+ routeKeyboardShortcut({ action: "workspace.tab.navigate.index", payload: null }, makeCtx()),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("forwards delta payloads to the workspace.tab.navigate-relative dispatch", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.tab.navigate.relative", payload: { delta: -1 } },
+ makeCtx(),
+ ),
+ ).toEqual({
+ kind: "dispatch",
+ action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
+ });
+ });
+
+ it("returns none when delta payload is missing", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.tab.navigate.relative", payload: null },
+ makeCtx(),
+ ),
+ ).toEqual({ kind: "none" });
+ });
+});
+
+describe("routeKeyboardShortcut — workspace.navigate.index", () => {
+ it("navigates to the sidebar target at index-1", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.index", payload: { index: 4 } },
+ makeCtx(),
+ ),
+ ).toEqual({
+ kind: "navigate-workspace",
+ serverId: "srv",
+ workspaceId: "ws-4",
+ });
+ });
+
+ it("returns none when the target index is out of range", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.index", payload: { index: 99 } },
+ makeCtx(),
+ ),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("returns none when the index payload is missing", () => {
+ expect(
+ routeKeyboardShortcut({ action: "workspace.navigate.index", payload: null }, makeCtx()),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("returns none when there are no sidebar targets", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.index", payload: { index: 1 } },
+ makeCtx({ sidebarShortcutTargets: [] }),
+ ),
+ ).toEqual({ kind: "none" });
+ });
+});
+
+describe("routeKeyboardShortcut — workspace.navigate.relative", () => {
+ it("uses the retained navigation workspace selection over a stale pathname", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.relative", payload: { delta: 1 } },
+ makeCtx({
+ pathname: "/h/srv/workspace/ws-2",
+ navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-4" },
+ }),
+ ),
+ ).toEqual({
+ kind: "navigate-workspace",
+ serverId: "srv",
+ workspaceId: "ws-5",
+ });
+ });
+
+ it("falls back to the pathname workspace when no retained selection exists", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.relative", payload: { delta: -1 } },
+ makeCtx({ pathname: "/h/srv/workspace/ws-3", navigationActiveWorkspace: null }),
+ ),
+ ).toEqual({
+ kind: "navigate-workspace",
+ serverId: "srv",
+ workspaceId: "ws-2",
+ });
+ });
+
+ it("wraps from the last target forward to the first", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.relative", payload: { delta: 1 } },
+ makeCtx({ navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-5" } }),
+ ),
+ ).toEqual({
+ kind: "navigate-workspace",
+ serverId: "srv",
+ workspaceId: "ws-1",
+ });
+ });
+
+ it("returns none when there are no sidebar targets", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.relative", payload: { delta: 1 } },
+ makeCtx({ sidebarShortcutTargets: [] }),
+ ),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("returns none when the delta payload is missing", () => {
+ expect(
+ routeKeyboardShortcut({ action: "workspace.navigate.relative", payload: null }, makeCtx()),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("falls back to the first target when the current workspace is not in the sidebar", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "workspace.navigate.relative", payload: { delta: 1 } },
+ makeCtx({
+ pathname: "/settings/general",
+ navigationActiveWorkspace: { serverId: "other", workspaceId: "ws-x" },
+ }),
+ ),
+ ).toEqual({
+ kind: "navigate-workspace",
+ serverId: "srv",
+ workspaceId: "ws-1",
+ });
+ });
+});
+
+describe("routeKeyboardShortcut — message-input.action", () => {
+ it.each([
+ ["focus", "message-input.focus"],
+ ["send", "message-input.send"],
+ ["dictation-toggle", "message-input.dictation-toggle"],
+ ["dictation-cancel", "message-input.dictation-cancel"],
+ ["dictation-confirm", "message-input.dictation-confirm"],
+ ["voice-toggle", "message-input.voice-toggle"],
+ ["voice-mute-toggle", "message-input.voice-mute-toggle"],
+ ] as const)("kind=%s → dispatch %s", (kind, id) => {
+ expect(
+ routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),
+ ).toEqual({
+ kind: "dispatch",
+ action: { id, scope: "message-input" },
+ });
+ });
+
+ it("returns none for unsupported message-input kinds (queue)", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "message-input.action", payload: { kind: "queue" } },
+ makeCtx(),
+ ),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("returns none when kind is missing", () => {
+ expect(
+ routeKeyboardShortcut({ action: "message-input.action", payload: null }, makeCtx()),
+ ).toEqual({ kind: "none" });
+ });
+});
+
+describe("routeKeyboardShortcut — settings.toggle", () => {
+ it("pushes to the settings root when not currently in settings", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "settings.toggle", payload: null },
+ makeCtx({ pathname: "/h/srv/workspace/ws-2" }),
+ ),
+ ).toEqual({ kind: "router-push", route: "/settings" });
+ });
+
+ it("replaces to the retained workspace route when leaving settings on desktop", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "settings.toggle", payload: null },
+ makeCtx({
+ pathname: "/settings/general",
+ isMobile: false,
+ lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
+ }),
+ ),
+ ).toEqual({
+ kind: "router-replace",
+ route: "/h/srv/workspace/ws-2",
+ });
+ });
+
+ it("falls back to router.back() when no retained workspace exists", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "settings.toggle", payload: null },
+ makeCtx({ pathname: "/settings/general", lastNavigationWorkspaceRoute: null }),
+ ),
+ ).toEqual({ kind: "router-back" });
+ });
+
+ it("falls back to router.back() on mobile even if a retained workspace exists", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "settings.toggle", payload: null },
+ makeCtx({
+ pathname: "/settings/general",
+ isMobile: true,
+ lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
+ }),
+ ),
+ ).toEqual({ kind: "router-back" });
+ });
+});
+
+describe("routeKeyboardShortcut — callbacks and pickers", () => {
+ it.each([
+ ["sidebar.toggle.left", "toggle-agent-list"],
+ ["sidebar.toggle.both", "toggle-both-sidebars"],
+ ["view.toggle.focus", "toggle-focus-mode"],
+ ["theme.cycle", "cycle-theme"],
+ ] as const)("%s → callback %s", (action, name) => {
+ expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({
+ kind: "callback",
+ name,
+ });
+ });
+
+ it("agent.new → open-project-picker", () => {
+ expect(
+ routeKeyboardShortcut({ action: "agent.new", payload: null }, makeCtx()),
+ ).toEqual({ kind: "open-project-picker" });
+ });
+});
+
+describe("routeKeyboardShortcut — toggle dialogs", () => {
+ it("opens the command center when closed", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "command-center.toggle", payload: null },
+ makeCtx({ commandCenterOpen: false }),
+ ),
+ ).toEqual({ kind: "command-center-toggle", nextOpen: true });
+ });
+
+ it("closes the command center when open", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "command-center.toggle", payload: null },
+ makeCtx({ commandCenterOpen: true }),
+ ),
+ ).toEqual({ kind: "command-center-toggle", nextOpen: false });
+ });
+
+ it("toggles the shortcuts dialog", () => {
+ expect(
+ routeKeyboardShortcut(
+ { action: "shortcuts.dialog.toggle", payload: null },
+ makeCtx({ shortcutsDialogOpen: false }),
+ ),
+ ).toEqual({ kind: "shortcuts-dialog-toggle", nextOpen: true });
+
+ expect(
+ routeKeyboardShortcut(
+ { action: "shortcuts.dialog.toggle", payload: null },
+ makeCtx({ shortcutsDialogOpen: true }),
+ ),
+ ).toEqual({ kind: "shortcuts-dialog-toggle", nextOpen: false });
+ });
+});
+
+describe("routeKeyboardShortcut — unknown actions", () => {
+ it("returns none for unknown action ids", () => {
+ expect(
+ routeKeyboardShortcut({ action: "totally.made.up", payload: null }, makeCtx()),
+ ).toEqual({ kind: "none" });
+ });
+});
diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts
new file mode 100644
index 000000000..c655d3345
--- /dev/null
+++ b/packages/app/src/keyboard/route-shortcut.ts
@@ -0,0 +1,217 @@
+import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
+import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
+import {
+ buildHostWorkspaceRoute,
+ buildSettingsRoute,
+ parseHostWorkspaceRouteFromPathname,
+} from "@/utils/host-routes";
+import {
+ getRelativeSidebarShortcutTarget,
+ type SidebarShortcutWorkspaceTarget,
+} from "@/utils/sidebar-shortcuts";
+
+export interface ShortcutRoutingContext {
+ pathname: string;
+ isMobile: boolean;
+ sidebarShortcutTargets: ReadonlyArray;
+ navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
+ lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
+ commandCenterOpen: boolean;
+ shortcutsDialogOpen: boolean;
+}
+
+export interface ShortcutRoutingInput {
+ action: string;
+ payload: KeyboardShortcutPayload;
+}
+
+export type ShortcutCallbackName =
+ | "toggle-agent-list"
+ | "toggle-both-sidebars"
+ | "toggle-focus-mode"
+ | "cycle-theme";
+
+export type ShortcutAction =
+ | { kind: "none" }
+ | { kind: "dispatch"; action: KeyboardActionDefinition }
+ | { kind: "navigate-workspace"; serverId: string; workspaceId: string }
+ | { kind: "router-replace"; route: string }
+ | { kind: "router-back" }
+ | { kind: "router-push"; route: string }
+ | { kind: "open-project-picker" }
+ | { kind: "callback"; name: ShortcutCallbackName }
+ | { kind: "command-center-toggle"; nextOpen: boolean }
+ | { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
+
+const NONE: ShortcutAction = { kind: "none" };
+
+// Action ids whose routing is a no-payload pass-through to the dispatcher.
+const PASSTHROUGH_DISPATCH: Record = {
+ "agent.interrupt": { id: "agent.interrupt", scope: "global" },
+ "workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
+ "worktree.archive": { id: "worktree.archive", scope: "sidebar" },
+ "worktree.new": { id: "worktree.new", scope: "sidebar" },
+ "workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
+ "workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
+ "sidebar.toggle.right": { id: "sidebar.toggle.right", scope: "sidebar" },
+ "workspace.pane.split.right": { id: "workspace.pane.split.right", scope: "workspace" },
+ "workspace.pane.split.down": { id: "workspace.pane.split.down", scope: "workspace" },
+ "workspace.pane.focus.left": { id: "workspace.pane.focus.left", scope: "workspace" },
+ "workspace.pane.focus.right": { id: "workspace.pane.focus.right", scope: "workspace" },
+ "workspace.pane.focus.up": { id: "workspace.pane.focus.up", scope: "workspace" },
+ "workspace.pane.focus.down": { id: "workspace.pane.focus.down", scope: "workspace" },
+ "workspace.pane.move-tab.left": { id: "workspace.pane.move-tab.left", scope: "workspace" },
+ "workspace.pane.move-tab.right": { id: "workspace.pane.move-tab.right", scope: "workspace" },
+ "workspace.pane.move-tab.up": { id: "workspace.pane.move-tab.up", scope: "workspace" },
+ "workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", scope: "workspace" },
+ "workspace.pane.close": { id: "workspace.pane.close", scope: "workspace" },
+};
+
+const SIMPLE_CALLBACKS: Record = {
+ "sidebar.toggle.left": "toggle-agent-list",
+ "sidebar.toggle.both": "toggle-both-sidebars",
+ "view.toggle.focus": "toggle-focus-mode",
+ "theme.cycle": "cycle-theme",
+};
+
+const MESSAGE_INPUT_DISPATCH: Record<
+ MessageInputKeyboardActionKind,
+ KeyboardActionDefinition | null
+> = {
+ focus: { id: "message-input.focus", scope: "message-input" },
+ send: { id: "message-input.send", scope: "message-input" },
+ "dictation-toggle": { id: "message-input.dictation-toggle", scope: "message-input" },
+ "dictation-cancel": { id: "message-input.dictation-cancel", scope: "message-input" },
+ "dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
+ "voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
+ "voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
+ queue: null,
+};
+
+function hasPayloadKey(
+ payload: KeyboardShortcutPayload,
+ key: K,
+): payload is Extract> {
+ return !!payload && typeof payload === "object" && key in payload;
+}
+
+function dispatch(action: KeyboardActionDefinition): ShortcutAction {
+ return { kind: "dispatch", action };
+}
+
+function routeWorkspaceTabNavigateIndex(payload: KeyboardShortcutPayload): ShortcutAction {
+ if (!hasPayloadKey(payload, "index")) return NONE;
+ return dispatch({
+ id: "workspace.tab.navigate-index",
+ scope: "workspace",
+ index: payload.index,
+ });
+}
+
+function routeWorkspaceTabNavigateRelative(payload: KeyboardShortcutPayload): ShortcutAction {
+ if (!hasPayloadKey(payload, "delta")) return NONE;
+ return dispatch({
+ id: "workspace.tab.navigate-relative",
+ scope: "workspace",
+ delta: payload.delta,
+ });
+}
+
+function routeWorkspaceNavigateIndex(
+ payload: KeyboardShortcutPayload,
+ ctx: ShortcutRoutingContext,
+): ShortcutAction {
+ if (!hasPayloadKey(payload, "index")) return NONE;
+ const target = ctx.sidebarShortcutTargets[payload.index - 1] ?? null;
+ if (!target) return NONE;
+ return {
+ kind: "navigate-workspace",
+ serverId: target.serverId,
+ workspaceId: target.workspaceId,
+ };
+}
+
+function routeWorkspaceNavigateRelative(
+ payload: KeyboardShortcutPayload,
+ ctx: ShortcutRoutingContext,
+): ShortcutAction {
+ if (!hasPayloadKey(payload, "delta")) return NONE;
+ if (ctx.sidebarShortcutTargets.length === 0) return NONE;
+
+ const currentWorkspace =
+ ctx.navigationActiveWorkspace ?? parseHostWorkspaceRouteFromPathname(ctx.pathname);
+ const target = getRelativeSidebarShortcutTarget({
+ targets: ctx.sidebarShortcutTargets,
+ currentTarget: currentWorkspace
+ ? { serverId: currentWorkspace.serverId, workspaceId: currentWorkspace.workspaceId }
+ : null,
+ delta: payload.delta,
+ });
+ if (!target) return NONE;
+ return {
+ kind: "navigate-workspace",
+ serverId: target.serverId,
+ workspaceId: target.workspaceId,
+ };
+}
+
+function routeMessageInputAction(payload: KeyboardShortcutPayload): ShortcutAction {
+ if (!hasPayloadKey(payload, "kind")) return NONE;
+ const action = MESSAGE_INPUT_DISPATCH[payload.kind];
+ if (!action) return NONE;
+ return dispatch(action);
+}
+
+function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
+ if (!ctx.pathname.startsWith("/settings")) {
+ return { kind: "router-push", route: buildSettingsRoute() };
+ }
+ if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) {
+ return {
+ kind: "router-replace",
+ route: buildHostWorkspaceRoute(
+ ctx.lastNavigationWorkspaceRoute.serverId,
+ ctx.lastNavigationWorkspaceRoute.workspaceId,
+ ),
+ };
+ }
+ return { kind: "router-back" };
+}
+
+export function routeKeyboardShortcut(
+ input: ShortcutRoutingInput,
+ ctx: ShortcutRoutingContext,
+): ShortcutAction {
+ const passthrough = PASSTHROUGH_DISPATCH[input.action];
+ if (passthrough) {
+ return dispatch(passthrough);
+ }
+
+ const callback = SIMPLE_CALLBACKS[input.action];
+ if (callback) {
+ return { kind: "callback", name: callback };
+ }
+
+ switch (input.action) {
+ case "workspace.tab.navigate.index":
+ return routeWorkspaceTabNavigateIndex(input.payload);
+ case "workspace.tab.navigate.relative":
+ return routeWorkspaceTabNavigateRelative(input.payload);
+ case "workspace.navigate.index":
+ return routeWorkspaceNavigateIndex(input.payload, ctx);
+ case "workspace.navigate.relative":
+ return routeWorkspaceNavigateRelative(input.payload, ctx);
+ case "message-input.action":
+ return routeMessageInputAction(input.payload);
+ case "agent.new":
+ return { kind: "open-project-picker" };
+ case "settings.toggle":
+ return routeSettingsToggle(ctx);
+ case "command-center.toggle":
+ return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
+ case "shortcuts.dialog.toggle":
+ return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
+ default:
+ return NONE;
+ }
+}