diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index 43006c81153ec..9f03774f2acee 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -2,13 +2,12 @@ import { useAtomValue } from "@effect/atom-react";
import * as Schema from "effect/Schema";
import {
useEffect,
- useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ReactNode,
} from "react";
-import { useLocation, useNavigate } from "@tanstack/react-router";
+import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router";
import { isCommandPaletteOpen } from "../commandPaletteBus";
import { isElectron } from "../env";
@@ -62,7 +61,9 @@ import {
useSidebarVisibility,
} from "./ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
-import { useThreadRouteRef } from "../hooks/useThreadRouteTarget";
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { registerCommands, registerMenuAction } from "../shortcuts/dispatcher";
+import { resolveThreadRouteRef } from "../threadRoutes";
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)";
@@ -124,7 +125,144 @@ function readInitialThreadSidebarWidth(): number {
}
}
+function readUsagePageOpen(router: AnyRouter) {
+ return router.state.location.pathname === "/usage";
+}
+
+function isKeybindingCaptureTarget(event: KeyboardEvent) {
+ return event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]");
+}
+
+function readRoutePanelsOpen(router: AnyRouter) {
+ const routeThreadRef = resolveThreadRouteRef(router.state.matches.at(-1)?.params ?? {});
+ if (!routeThreadRef) return { terminalOpen: false, previewOpen: false };
+ return {
+ terminalOpen: selectThreadTerminalUiState(
+ useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
+ routeThreadRef,
+ ).terminalOpen,
+ previewOpen:
+ selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
+ "preview",
+ };
+}
+
+export function registerNavigationHistoryCommands(router: AnyRouter): () => void {
+ return registerCommands(
+ {
+ "navigation.back": (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ window.history.back();
+ },
+ "navigation.forward": (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ window.history.forward();
+ },
+ },
+ {
+ when: (event) => !isKeybindingCaptureTarget(event),
+ context: () => ({
+ ...readRoutePanelsOpen(router),
+ usagePageOpen: readUsagePageOpen(router),
+ }),
+ },
+ );
+}
+
+const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);
+
+// A held key undoes once. Module state: undoing an archive navigates, and the
+// hold must survive whatever that re-renders.
+let heldUndoKey: string | null = null;
+
+// Undoes the thread actions in the sidebar's notice. Settings and Usage own
+// mod+z while open. Captured so mod+z reaches it before the composer's own
+// undo handler consumes the key.
+function registerThreadUndoCommands(
+ router: AnyRouter,
+ readSidebarVisible: () => boolean,
+): () => void {
+ const threadSidebarShown = () => !isAppModalPage(router.state.location.pathname);
+ const unregister = registerCommands(
+ {},
+ {
+ capture: true,
+ when: threadSidebarShown,
+ onKeyDown: (event) => {
+ // Cancel a held key's repeats so they cannot fall through to the
+ // editor's own undo, even after the last notice is gone.
+ if (event.repeat) {
+ if (event.code !== heldUndoKey) return false;
+ event.preventDefault();
+ event.stopPropagation();
+ return true;
+ }
+ // A modifier tapped mid-hold leaves the held key repeating.
+ if (!MODIFIER_KEYS.has(event.key)) heldUndoKey = null;
+ const command = resolveShortcutCommand(
+ event,
+ appAtomRegistry.get(primaryServerKeybindingsAtom),
+ {
+ context: {
+ terminalFocus: isTerminalFocused(),
+ previewFocus: isPreviewFocused(),
+ ...readRoutePanelsOpen(router),
+ // A collapsed sidebar hides the notice, so text fields keep their own undo.
+ editableFocus:
+ isEditableFocused(event.target) &&
+ !(readSidebarVisible() && threadUndoClaimsShortcut()),
+ modelPickerOpen: isModelPickerOpen(),
+ },
+ },
+ );
+ if (command !== "thread.undo") {
+ if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut();
+ return false;
+ }
+ if (isCommandPaletteOpen() || isModelPickerOpen()) return false;
+ if (!undoLatestThreadAction()) return false;
+ heldUndoKey = event.code;
+ event.preventDefault();
+ event.stopPropagation();
+ return true;
+ },
+ },
+ );
+ // Pastes from a menu, dictation and drops edit text without a keypress.
+ const onInput = () => {
+ if (threadSidebarShown()) releaseThreadUndoShortcut();
+ };
+ window.addEventListener("input", onInput, true);
+ return () => {
+ unregister();
+ window.removeEventListener("input", onInput, true);
+ };
+}
+
+/** Keyboard and menu commands that live as long as the app shell. */
+function registerAppShellCommands(router: AnyRouter, shellElement: Element): () => void {
+ // The provider mirrors the visibility it renders onto its wrapper, so the
+ // committed state is readable when a key arrives.
+ const readSidebarVisible = () =>
+ shellElement.closest('[data-slot="sidebar-wrapper"]')?.getAttribute("data-sidebar-state") ===
+ "expanded";
+ const disposers = [
+ registerThreadUndoCommands(router, readSidebarVisible),
+ registerNavigationHistoryCommands(router),
+ registerMenuAction("open-settings", () => {
+ if (!/^\/settings(\/|$)/.test(router.state.location.pathname))
+ void router.navigate({ to: "/settings" });
+ }),
+ ];
+ return () => {
+ for (const dispose of disposers) dispose();
+ };
+}
+
function SidebarControl() {
+ const router = useRouter();
const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();
@@ -176,8 +314,16 @@ function SidebarControl() {
};
}, [keybindings, toggleSidebar, usagePageOpen]);
+ // The control lives as long as the shell, so it carries the shell's commands. Created
+ // once so re-renders never re-register them.
+ const [appShellCommandsRef] = useState(
+ () => (node: HTMLDivElement | null) =>
+ node === null ? undefined : registerAppShellCommands(router, node),
+ );
+
return (
) {
- if (!routeThreadRef) return { terminalOpen: false, previewOpen: false };
- return {
- terminalOpen: selectThreadTerminalUiState(
- useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
- routeThreadRef,
- ).terminalOpen,
- previewOpen:
- selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
- "preview",
- };
-}
-
-export function NavigationHistoryShortcuts() {
- const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
- const keybindings = useAtomValue(primaryServerKeybindingsAtom);
- const routeThreadRef = useThreadRouteRef();
-
- useEffect(() => {
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.defaultPrevented) return;
- if (
- event.target instanceof HTMLElement &&
- event.target.closest("[data-keybinding-capture]")
- ) {
- return;
- }
- const command = resolveShortcutCommand(event, keybindings, {
- context: {
- terminalFocus: isTerminalFocused(),
- previewFocus: isPreviewFocused(),
- ...routePanelsOpen(routeThreadRef),
- editableFocus: isEditableFocused(event.target),
- modelPickerOpen: isModelPickerOpen(),
- usagePageOpen,
- },
- });
- if (command !== "navigation.back" && command !== "navigation.forward") return;
-
- event.preventDefault();
- event.stopPropagation();
- if (command === "navigation.back") window.history.back();
- else window.history.forward();
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [keybindings, routeThreadRef, usagePageOpen]);
-
- return null;
-}
-
-const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);
-
-// Undoes the thread actions in the sidebar's notice. Settings and Usage own
-// mod+z while open. Listens in the capture phase
-// so mod+z reaches it before the composer's own undo handler consumes the key.
-function ThreadUndoShortcut() {
- const keybindings = useAtomValue(primaryServerKeybindingsAtom);
- const routeThreadRef = useThreadRouteRef();
- // A collapsed sidebar hides the notice, so text fields keep their own undo.
- const noticeVisible = useSidebarVisibility();
- // Outlives the effect: undoing an archive navigates, which re-runs it mid-hold.
- const heldUndoKey = useRef
(null);
-
- useEffect(() => {
- const onKeyDown = (event: KeyboardEvent) => {
- // A held key undoes once. Cancel its repeats so they cannot fall through
- // to the editor's own undo, even after the last notice is gone.
- if (event.repeat) {
- if (event.code === heldUndoKey.current) {
- event.preventDefault();
- event.stopPropagation();
- }
- return;
- }
- // A modifier tapped mid-hold leaves the held key repeating.
- if (!MODIFIER_KEYS.has(event.key)) heldUndoKey.current = null;
- const command = resolveShortcutCommand(event, keybindings, {
- context: {
- terminalFocus: isTerminalFocused(),
- previewFocus: isPreviewFocused(),
- ...routePanelsOpen(routeThreadRef),
- editableFocus:
- isEditableFocused(event.target) && !(noticeVisible && threadUndoClaimsShortcut()),
- modelPickerOpen: isModelPickerOpen(),
- },
- });
- if (command !== "thread.undo") {
- if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut();
- return;
- }
- if (isCommandPaletteOpen() || isModelPickerOpen()) return;
- if (undoLatestThreadAction()) {
- heldUndoKey.current = event.code;
- event.preventDefault();
- event.stopPropagation();
- }
- };
- // Pastes from a menu, dictation and drops edit text without a keypress.
- const onInput = () => releaseThreadUndoShortcut();
-
- window.addEventListener("keydown", onKeyDown, true);
- window.addEventListener("input", onInput, true);
- return () => {
- window.removeEventListener("keydown", onKeyDown, true);
- window.removeEventListener("input", onInput, true);
- };
- }, [keybindings, noticeVisible, routeThreadRef]);
-
- return null;
-}
-
export function AppSidebarLayout({ children }: { children: ReactNode }) {
- const navigate = useNavigate();
const legacySidebarEnabled = useLegacySidebarEnabled();
const [sidebarRouteReveal] = useState(() => ({
observedKey: null,
@@ -331,7 +363,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const pathname = useLocation({ select: (location) => location.pathname });
const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname);
const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
- const appModalOpen = isAppModalPage(pathname);
const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
// A clamped drag may not change width, so viewport changes need a separate subscription.
@@ -362,26 +393,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
...windowControlsInsetStyle,
} as CSSProperties;
- useEffect(() => {
- const onMenuAction = window.desktopBridge?.onMenuAction;
- if (typeof onMenuAction !== "function") {
- return;
- }
-
- const unsubscribe = onMenuAction((action) => {
- if (action === "open-settings") {
- const isSettingsRoute = /^\/settings(\/|$)/.test(pathname);
- if (!isSettingsRoute) {
- void navigate({ to: "/settings" });
- }
- }
- });
-
- return () => {
- unsubscribe?.();
- };
- }, [navigate, pathname]);
-
return (
-
- {appModalOpen ? null : }
diff --git a/apps/web/src/components/UsageToggleShortcut.browser.test.tsx b/apps/web/src/components/UsageToggleShortcut.browser.test.tsx
index 1efaa2837b008..a93990dccd435 100644
--- a/apps/web/src/components/UsageToggleShortcut.browser.test.tsx
+++ b/apps/web/src/components/UsageToggleShortcut.browser.test.tsx
@@ -16,12 +16,23 @@ vi.mock(import("@effect/atom-react"), async (original) => ({
...(await original()),
useAtomValue: (() => keybindings) as never,
}));
+vi.mock(import("../state/server"), async (original) => {
+ const actual = await original();
+ const { Atom } = await import("effect/reactivity");
+ const { compileResolvedKeybindingsConfig: compile } = await import("@t3tools/shared/keybindings");
+ // Factories are hoisted above the module's own constants.
+ const compiled = compile([
+ { key: "mod+u", command: "navigation.back", when: "usagePageOpen" },
+ { key: "mod+u", command: "usage.open", when: "!usagePageOpen" },
+ ]);
+ return { ...actual, primaryServerKeybindingsAtom: Atom.make(compiled) };
+});
vi.mock(import("@tanstack/react-router"), async (original) => ({
...(await original()),
useNavigate: (() => state.navigate) as never,
}));
-import { NavigationHistoryShortcuts } from "./AppSidebarLayout";
+import { registerNavigationHistoryCommands } from "./AppSidebarLayout";
import { CommandPalette } from "./CommandPalette";
// regression: #21357
@@ -29,7 +40,8 @@ describe("usage toggle shortcut on Usage", () => {
it("goes back", async () => {
vi.clearAllMocks();
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64");
- await using _screen = await render(, { router: "/usage" });
+ await using screen = await render(null, { router: "/usage" });
+ using _commands = { [Symbol.dispose]: registerNavigationHistoryCommands(screen.router!) };
using back = vi.spyOn(window.history, "back").mockImplementation(() => undefined);
await userEvent.keyboard("{Control>}u{/Control}");
diff --git a/apps/web/src/shortcuts/dispatcher.browser.test.ts b/apps/web/src/shortcuts/dispatcher.browser.test.ts
new file mode 100644
index 0000000000000..308b26fdf2696
--- /dev/null
+++ b/apps/web/src/shortcuts/dispatcher.browser.test.ts
@@ -0,0 +1,148 @@
+import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ commandScopeRef,
+ dispatchMenuAction,
+ registerCommands,
+ registerMenuAction,
+} from "./dispatcher";
+
+function bindings(entries: ReadonlyArray<[string, KeybindingCommand]>): ResolvedKeybindingsConfig {
+ return entries.map(([key, command]) => ({
+ command,
+ shortcut: {
+ key,
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ modKey: false,
+ },
+ }));
+}
+
+const keybindings = () =>
+ bindings([
+ ["k", "chat.new"],
+ ["j", "chat.newLocal"],
+ ]);
+
+function press(key: string): KeyboardEvent {
+ const event = new KeyboardEvent("keydown", { key, cancelable: true, bubbles: true });
+ window.dispatchEvent(event);
+ return event;
+}
+
+function mountScope(...args: Parameters): () => void {
+ return commandScopeRef(...args)(document.body) ?? (() => {});
+}
+
+describe("command dispatcher", () => {
+ it("runs the newest scope's handler and nothing below it", () => {
+ const global = vi.fn();
+ const outer = vi.fn();
+ const inner = vi.fn();
+ using stack = new DisposableStack();
+ stack.defer(registerCommands({ "chat.new": global }, { keybindings }));
+ stack.defer(mountScope({ "chat.new": outer }, { keybindings }));
+ stack.defer(mountScope({ "chat.new": inner }, { keybindings }));
+
+ press("k");
+
+ expect(inner).toHaveBeenCalledOnce();
+ expect(outer).not.toHaveBeenCalled();
+ expect(global).not.toHaveBeenCalled();
+ });
+
+ it("falls through scopes that return false, skip the command, or fail their guard", () => {
+ const global = vi.fn();
+ const declined = vi.fn(() => false);
+ const guarded = vi.fn();
+ using stack = new DisposableStack();
+ stack.defer(registerCommands({ "chat.new": global }, { keybindings }));
+ stack.defer(mountScope({ "chat.new": declined }, { keybindings }));
+ stack.defer(mountScope({ "chat.newLocal": vi.fn() }, { keybindings }));
+ stack.defer(mountScope({ "chat.new": guarded }, { keybindings, when: () => false }));
+
+ press("k");
+
+ expect(guarded).not.toHaveBeenCalled();
+ expect(declined).toHaveBeenCalledOnce();
+ expect(global).toHaveBeenCalledOnce();
+ });
+
+ it("element scopes win over global commands registered after them", () => {
+ const element = vi.fn();
+ const global = vi.fn();
+ using stack = new DisposableStack();
+ stack.defer(mountScope({ "chat.new": element }, { keybindings }));
+ stack.defer(registerCommands({ "chat.new": global }, { keybindings }));
+
+ press("k");
+
+ expect(element).toHaveBeenCalledOnce();
+ expect(global).not.toHaveBeenCalled();
+ });
+
+ it("stops once a handler prevents default, and unregisters on cleanup", () => {
+ const lower = vi.fn();
+ const upper = vi.fn((event: KeyboardEvent) => {
+ event.preventDefault();
+ return false;
+ });
+ using stack = new DisposableStack();
+ stack.defer(mountScope({ "chat.new": lower }, { keybindings }));
+ const unmountUpper = mountScope({ "chat.new": upper }, { keybindings });
+
+ press("k");
+ expect(lower).not.toHaveBeenCalled();
+
+ unmountUpper();
+ press("k");
+ expect(upper).toHaveBeenCalledOnce();
+ expect(lower).toHaveBeenCalledOnce();
+ });
+
+ it("lets a scope claim raw keys before command lookup", () => {
+ const onEscape = vi.fn((event: KeyboardEvent) => (event.key === "Escape" ? undefined : false));
+ const command = vi.fn();
+ using stack = new DisposableStack();
+ stack.defer(mountScope({ "chat.new": command }, { keybindings, onKeyDown: onEscape }));
+
+ press("Escape");
+ press("k");
+
+ expect(onEscape).toHaveBeenCalledTimes(2);
+ expect(command).toHaveBeenCalledOnce();
+ });
+
+ it("tells observers whether a scope consumed the key, after the scopes ran", () => {
+ const calls: Array = [];
+ using stack = new DisposableStack();
+ stack.defer(
+ registerCommands({}, { observe: (_event, consumed) => calls.push(`observe:${consumed}`) }),
+ );
+ stack.defer(mountScope({ "chat.new": () => void calls.push("command") }, { keybindings }));
+
+ press("k");
+ press("x");
+
+ expect(calls).toEqual(["command", "observe:true", "observe:false"]);
+ });
+
+ it("routes a menu action to its newest handler", () => {
+ const older = vi.fn();
+ const newer = vi.fn();
+ using stack = new DisposableStack();
+ stack.defer(registerMenuAction("test-action", older));
+ const unregisterNewer = registerMenuAction("test-action", newer);
+
+ dispatchMenuAction("test-action");
+ unregisterNewer();
+ dispatchMenuAction("test-action");
+
+ expect(newer).toHaveBeenCalledOnce();
+ expect(older).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/web/src/shortcuts/dispatcher.ts b/apps/web/src/shortcuts/dispatcher.ts
new file mode 100644
index 0000000000000..bb5b13a9980bf
--- /dev/null
+++ b/apps/web/src/shortcuts/dispatcher.ts
@@ -0,0 +1,154 @@
+import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts";
+
+import { resolveShortcutCommand, type ShortcutMatchContext } from "../keybindings";
+import { isEditableFocused } from "../lib/editableFocus";
+import { isPreviewFocused } from "../lib/previewFocus";
+import { isTerminalFocused } from "../lib/terminalFocus";
+import { isModelPickerOpen } from "../modelPickerVisibility";
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { primaryServerKeybindingsAtom } from "../state/server";
+
+/** Return `false` to let the next scope handle the event. */
+export type CommandHandler = (event: KeyboardEvent) => boolean | void;
+
+export type CommandHandlers = Partial>;
+
+export interface CommandScopeOptions {
+ /** Read at event time; a scope whose guard fails is skipped. */
+ readonly when?: (event: KeyboardEvent) => boolean;
+ /** When-clause context the dispatcher cannot know (terminalOpen, previewOpen), read at event time. */
+ readonly context?: () => Partial;
+ readonly keybindings?: () => ResolvedKeybindingsConfig;
+ /** For shortcuts that are not keybinding commands (Escape, "/"); runs before command lookup. */
+ readonly onKeyDown?: CommandHandler;
+ /**
+ * App-global only: run in the capture phase, ahead of focused widgets that consume the key
+ * themselves (the terminal encodes Ctrl+Shift+T and stops propagation).
+ */
+ readonly capture?: boolean;
+ /**
+ * App-global only: hear every bubbling keydown after the scopes ran, with whether one of them
+ * (or the page) consumed it. Observers never consume. For listeners that react to keys other
+ * scopes may also handle, such as scroll keys.
+ */
+ readonly observe?: (event: KeyboardEvent, consumed: boolean) => void;
+}
+
+interface CommandScope {
+ readonly commands: CommandHandlers;
+ readonly options: CommandScopeOptions;
+}
+
+// Element scopes sit above app-global scopes; within each list the newest wins.
+const elementScopes: CommandScope[] = [];
+const globalScopes: CommandScope[] = [];
+const captureScopes: CommandScope[] = [];
+const observerScopes: CommandScope[] = [];
+
+function readFocusContext(event: KeyboardEvent): Partial {
+ return {
+ terminalFocus: isTerminalFocused(),
+ previewFocus: isPreviewFocused(),
+ editableFocus: isEditableFocused(event.target),
+ modelPickerOpen: isModelPickerOpen(),
+ };
+}
+
+function runScope(scope: CommandScope, event: KeyboardEvent): boolean {
+ const { commands, options } = scope;
+ if (options.when && !options.when(event)) return false;
+ if (options.onKeyDown && options.onKeyDown(event) !== false) return true;
+ const command = resolveShortcutCommand(
+ event,
+ options.keybindings?.() ?? appAtomRegistry.get(primaryServerKeybindingsAtom),
+ { context: { ...readFocusContext(event), ...options.context?.() } },
+ );
+ const handler = command === null ? undefined : commands[command];
+ return handler !== undefined && handler(event) !== false;
+}
+
+function runScopes(scopes: readonly CommandScope[], event: KeyboardEvent): boolean {
+ for (let index = scopes.length - 1; index >= 0; index -= 1) {
+ if (event.defaultPrevented) return true;
+ const scope = scopes[index];
+ if (scope && runScope(scope, event)) return true;
+ }
+ return false;
+}
+
+function dispatchCaptureKeyDown(event: KeyboardEvent): void {
+ runScopes(captureScopes, event);
+}
+
+function dispatchKeyDown(event: KeyboardEvent): void {
+ const consumed =
+ runScopes(elementScopes, event) || runScopes(globalScopes, event) || event.defaultPrevented;
+ for (const scope of observerScopes.slice()) scope.options.observe?.(event, consumed);
+}
+
+let keydownInstalled = false;
+function ensureKeydownListener() {
+ if (keydownInstalled || typeof window === "undefined") return;
+ keydownInstalled = true;
+ window.addEventListener("keydown", dispatchCaptureKeyDown, true);
+ window.addEventListener("keydown", dispatchKeyDown);
+}
+
+function pushScope(list: CommandScope[], scope: CommandScope): () => void {
+ ensureKeydownListener();
+ list.push(scope);
+ return () => {
+ const index = list.lastIndexOf(scope);
+ if (index !== -1) list.splice(index, 1);
+ };
+}
+
+/** App-global commands, registered once at module level or bootstrap. */
+export function registerCommands(
+ commands: CommandHandlers,
+ options: CommandScopeOptions = {},
+): () => void {
+ const scopes = options.observe ? observerScopes : options.capture ? captureScopes : globalScopes;
+ return pushScope(scopes, { commands, options });
+}
+
+/**
+ * Ref callback that keeps a command scope registered while its element is
+ * mounted. Handlers should read changing values from stores at event time so
+ * the callback stays stable across renders.
+ */
+export function commandScopeRef(
+ commands: CommandHandlers,
+ options: CommandScopeOptions = {},
+): (node: Element | null) => (() => void) | undefined {
+ return (node) => (node === null ? undefined : pushScope(elementScopes, { commands, options }));
+}
+
+type MenuActionHandler = () => void;
+const menuActionHandlers = new Map();
+let menuActionInstalled = false;
+
+export function dispatchMenuAction(action: string): void {
+ const handlers = menuActionHandlers.get(action);
+ handlers?.at(-1)?.();
+}
+
+function ensureMenuActionListener() {
+ if (menuActionInstalled || typeof window === "undefined") return;
+ const bridge = window.desktopBridge;
+ if (typeof bridge?.onMenuAction !== "function") return;
+ menuActionInstalled = true;
+ bridge.onMenuAction(dispatchMenuAction);
+}
+
+/** Desktop menu actions; the newest handler for an action wins. */
+export function registerMenuAction(action: string, handler: MenuActionHandler): () => void {
+ ensureMenuActionListener();
+ const handlers = menuActionHandlers.get(action) ?? [];
+ handlers.push(handler);
+ menuActionHandlers.set(action, handlers);
+ return () => {
+ const index = handlers.lastIndexOf(handler);
+ if (index !== -1) handlers.splice(index, 1);
+ };
+}