From 1955fa63718b4859777af71415ef92dd5d80ba83 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 4 May 2026 23:33:12 +0800 Subject: [PATCH] refactor(app): extract keyboard shortcut routing into a pure function (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 8-mock hook test (expo-router, layout, platform, navigation, 4 stores) with a pure unit test of the routing decision. The hook now reads pathname/layout/key, calls routeKeyboardShortcut, and dispatches the resulting ShortcutAction — no behaviour change. --- .../src/hooks/use-keyboard-shortcuts.test.tsx | 191 ---------- .../app/src/hooks/use-keyboard-shortcuts.ts | 305 +++------------ .../app/src/keyboard/route-shortcut.test.ts | 357 ++++++++++++++++++ packages/app/src/keyboard/route-shortcut.ts | 217 +++++++++++ 4 files changed, 633 insertions(+), 437 deletions(-) delete mode 100644 packages/app/src/hooks/use-keyboard-shortcuts.test.tsx create mode 100644 packages/app/src/keyboard/route-shortcut.test.ts create mode 100644 packages/app/src/keyboard/route-shortcut.ts 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; + } +}