diff --git a/src/app/useHotkeys.test.tsx b/src/app/useHotkeys.test.tsx index 71d38e76..a697450c 100644 --- a/src/app/useHotkeys.test.tsx +++ b/src/app/useHotkeys.test.tsx @@ -43,11 +43,22 @@ describe("useHotkeys — Console-only listener (#1218)", () => { expect(ev.defaultPrevented).toBe(false); }); - it("does not navigate screens from off-Console (F1–F6 are Console-only now)", () => { + it("navigates screens from off-Console via the always-on F-key listener (#2403)", () => { + // #1218 accidentally made F-key screen nav Console-only; the global listener restores it everywhere. useAppStore.setState({ activeWorkspace: "settings" }); renderHook(() => useHotkeys()); - const ev = pressKey({ code: "F1" }); // screen-console hotkey + const ev = pressKey({ code: "F6" }); // screen-github + + expect(useAppStore.getState().activeWorkspace).toBe("github"); + expect(ev.defaultPrevented).toBe(true); + }); + + it("F1 no longer navigates — the console screen hotkey was removed (#2403)", () => { + useAppStore.setState({ activeWorkspace: "settings" }); + renderHook(() => useHotkeys()); + + const ev = pressKey({ code: "F1" }); expect(useAppStore.getState().activeWorkspace).toBe("settings"); expect(ev.defaultPrevented).toBe(false); diff --git a/src/app/useHotkeys.ts b/src/app/useHotkeys.ts index 67fb2b47..a7d3c68f 100644 --- a/src/app/useHotkeys.ts +++ b/src/app/useHotkeys.ts @@ -14,6 +14,17 @@ import { type RebindableId, } from "@/features/settings"; import { VIEW_ORDER } from "@/app/console/panes/viewDefs"; +import type { Workspace } from "@/app/chrome/Rail"; + +/** If `e` matches a screen-navigation F-key (per the active bindings), return the target workspace, else + * null. Shared by the always-on global screen-nav listener (every page) and the Console pane-hotkey + * effect, so the F-key → screen map lives in exactly one place. */ +function screenForEvent(e: KeyboardEvent, bindings: Parameters[1]): Workspace | null { + for (const h of SCREEN_HOTKEYS) { + if (matchesBinding(e, bindings, `screen-${h.screen}` as RebindableId)) return h.screen; + } + return null; +} function keyToTermBytes(e: KeyboardEvent): string | null { const { key, ctrlKey, altKey, shiftKey } = e; @@ -83,6 +94,24 @@ export function useHotkeys() { const digitBufferRef = useRef(""); const commitTimerRef = useRef | null>(null); + // Screen-navigation F-keys switch rail workspaces, so they must fire on EVERY page — NOT just the + // Console (the pane hotkeys below are Console-only per #1218, which accidentally made F-key nav + // Console-only too — #2403). This always-on listener handles them OFF the Console page; on the Console + // page the pane-hotkey effect below handles nav itself (after its broadcast intercept, so an F-key still + // broadcasts in broadcast mode), so this one is gated off-Console to avoid double-firing. + useEffect(() => { + if (activeWorkspace === "console") return; + function onKeyDown(e: KeyboardEvent) { + const t = e.target as HTMLElement; + // Don't yank focus away while the user is typing in a field (matches the Console effect's guard). + if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return; + const navTo = screenForEvent(e, useAppStore.getState().keybindings); + if (navTo) { e.preventDefault(); setWorkspace(navTo); } + } + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [activeWorkspace, setWorkspace]); + useEffect(() => { // #1218: the hotkeys are Console-only. Don't attach the document listeners at all unless the // Console page is active AND no pane is maximized — so every other screen, and a maximized @@ -263,13 +292,14 @@ export function useHotkeys() { // Plain typing in inputs is fine; modifier combos still fire if (inInput && !e.ctrlKey && !e.metaKey && !e.altKey) return; - // Screen navigation (F1–F6 by default, rebindable per screen #773). - for (const h of SCREEN_HOTKEYS) { - if (matchesBinding(e, bindings, `screen-${h.screen}` as RebindableId)) { - e.preventDefault(); - setWorkspace(h.screen); - return; - } + // Screen navigation (F-keys, rebindable per screen #773) — handled here too so it works on the + // Console page. It sits AFTER the broadcast intercept above, so in broadcast mode an F-key is sent + // to the panes rather than navigating (unchanged). Off-Console, the always-on effect above owns it. + const navTo = screenForEvent(e, bindings); + if (navTo) { + e.preventDefault(); + setWorkspace(navTo); + return; } // ── CTRL = SELECT ───────────────────────────────────────────────────── diff --git a/src/features/settings/cards/KeyboardCard.test.tsx b/src/features/settings/cards/KeyboardCard.test.tsx index 27180e23..4cc788af 100644 --- a/src/features/settings/cards/KeyboardCard.test.tsx +++ b/src/features/settings/cards/KeyboardCard.test.tsx @@ -63,11 +63,11 @@ describe("KeyboardCard", () => { it("screen-nav and zoom rows are now rebindable (#773)", () => { render(); - // Screen nav: "Go to Console" can be rebound. - const navBtn = screen.getByRole("button", { name: /Rebind Go to Console/ }); + // Screen nav: "Go to Planner" can be rebound. + const navBtn = screen.getByRole("button", { name: /Rebind Go to Planner/ }); fireEvent.click(navBtn); fireEvent.keyDown(document, { code: "F9" }); - expect(useAppStore.getState().keybindings["screen-console"]).toBe("F9"); + expect(useAppStore.getState().keybindings["screen-projects"]).toBe("F9"); // Zoom: "Increase terminal font size" can be rebound. const zoomBtn = screen.getByRole("button", { name: /Rebind Increase terminal font size/ }); diff --git a/src/features/settings/lib/keybindings.test.ts b/src/features/settings/lib/keybindings.test.ts index 466a5f72..24a2536d 100644 --- a/src/features/settings/lib/keybindings.test.ts +++ b/src/features/settings/lib/keybindings.test.ts @@ -116,16 +116,16 @@ describe("registry wiring", () => { }); it("screen-nav and zoom are rebindable single chords (#773)", () => { - expect(DEFAULT_BINDINGS["screen-console"]).toBe("F1"); + expect(DEFAULT_BINDINGS["screen-projects"]).toBe("F2"); expect(DEFAULT_BINDINGS["screen-github"]).toBe("F6"); expect(DEFAULT_BINDINGS["screen-settings"]).toBe("F8"); expect(DEFAULT_BINDINGS["zoom-in"]).toBe("Ctrl+Equal"); expect(DEFAULT_BINDINGS["zoom-reset"]).toBe("Ctrl+Digit0"); // A bare F-key serializes to just the code, with no modifiers. - expect(eventToChord(ev({ code: "F1" }))).toBe("F1"); - expect(matchesBinding(ev({ code: "F1" }), {}, "screen-console")).toBe(true); + expect(eventToChord(ev({ code: "F2" }))).toBe("F2"); + expect(matchesBinding(ev({ code: "F2" }), {}, "screen-projects")).toBe(true); // Conflict detection spans the whole rebindable set, screen + console actions included. - expect(findConflict({}, "screen-automation", "F1")).toBe("screen-console"); + expect(findConflict({}, "screen-automation", "F2")).toBe("screen-projects"); }); it("chordToCaps labels the zoom symbol codes", () => { diff --git a/src/features/settings/lib/keybindings.ts b/src/features/settings/lib/keybindings.ts index 0d0c1cb2..54269a43 100644 --- a/src/features/settings/lib/keybindings.ts +++ b/src/features/settings/lib/keybindings.ts @@ -24,7 +24,6 @@ export const REBINDABLE_IDS = [ "clear-input", "redraw-pane", "send-all-enter", - "screen-console", "screen-projects", "screen-skills", "screen-automation", @@ -52,7 +51,6 @@ export const DEFAULT_BINDINGS: Record = { "clear-input": "Ctrl+Shift+Backspace", "redraw-pane": "Ctrl+Shift+KeyR", "send-all-enter": "Alt+Shift+Enter", - "screen-console": "F1", "screen-projects": "F2", "screen-skills": "F3", "screen-automation": "F4", diff --git a/src/features/settings/lib/shortcuts.test.ts b/src/features/settings/lib/shortcuts.test.ts index 0e181e0d..6fb0349c 100644 --- a/src/features/settings/lib/shortcuts.test.ts +++ b/src/features/settings/lib/shortcuts.test.ts @@ -7,8 +7,9 @@ describe("shortcuts registry", () => { for (const h of SCREEN_HOTKEYS) { expect(SCREEN_KEY_MAP[h.key]).toBe(h.screen); } - // Spot-check the canonical F-key bindings. - expect(SCREEN_KEY_MAP.F1).toBe("console"); + // Spot-check the canonical F-key bindings. F1 → Console was retired (#2372/#2403). + expect(SCREEN_KEY_MAP.F1).toBeUndefined(); + expect(SCREEN_KEY_MAP.F2).toBe("projects"); expect(SCREEN_KEY_MAP.F6).toBe("github"); expect(SCREEN_KEY_MAP.F7).toBe("agents"); expect(SCREEN_KEY_MAP.F8).toBe("settings"); diff --git a/src/features/settings/lib/shortcuts.ts b/src/features/settings/lib/shortcuts.ts index 1d64def7..c61f9414 100644 --- a/src/features/settings/lib/shortcuts.ts +++ b/src/features/settings/lib/shortcuts.ts @@ -16,10 +16,9 @@ export interface ScreenHotkey { label: string; } -/** F1–F6 navigate the rail screens. The one definition both the handler and the - * reference derive from. */ +/** F2–F8 navigate the rail screens. The one definition both the handler and the reference derive from. + * (F1 → Console was removed with the console page's retirement, #2372/#2403.) */ export const SCREEN_HOTKEYS: ScreenHotkey[] = [ - { key: "F1", screen: "console", label: "Console" }, { key: "F2", screen: "projects", label: "Planner" }, { key: "F3", screen: "skills", label: "Skills" }, { key: "F4", screen: "automation", label: "Automations" }, @@ -107,7 +106,6 @@ export interface ShortcutDef { /** Single source of truth for every keyboard shortcut in the app. The Keyboard settings page * derives its reference list from this registry; the `useHotkeys` handler (app shell) consumes it. */ export const SHORTCUT_REGISTRY: ShortcutDef[] = [ - { id: "screen-console", label: "Go to Console", keys: "F1", description: "Switch to the Console screen" }, { id: "screen-automation", label: "Go to Automations", keys: "F3", description: "Switch to the Automations screen" }, { id: "screen-github", label: "Go to GitHub", keys: "F4", description: "Switch to the GitHub screen" }, { id: "screen-projects", label: "Go to Projects", keys: "F5", description: "Switch to the Projects screen" },