From 9f24b507fce467ecc00e0ab2d27c11e4e9786f9b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 12:08:38 -0700 Subject: [PATCH] FN-7507: prioritize topmost dashboard shortcut dismissal Refine dashboard keyboard shortcut behavior and coverage for configurable popup handling. - Factor App Escape dismissal into a pure helper that closes one topmost surface at a time. - Prioritize popped-out tasks ahead of Quick Chat, Terminal, and fixed dashboard modals. - Add regression coverage for configurable shortcut bindings, editable focus guards, default-prevented events, and Escape ordering. - Update dashboard shortcut documentation and release-note wording. Files changed: .changeset/fn-7494-keyboard-shortcuts.md | 4 +- docs/dashboard-guide.md | 2 +- packages/dashboard/app/App.tsx | 110 +++++++++----- .../app/__tests__/App.keyboard-shortcuts.test.tsx | 158 +++++++++++++++++++++ .../app/utils/__tests__/keyboardShortcuts.test.ts | 1 + 5 files changed, 236 insertions(+), 39 deletions(-) Fusion-Task-Id: FN-7507 Fusion-Task-Lineage: 804986ef-ec6c-4404-a168-6d154f75b118 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7494-keyboard-shortcuts.md | 4 +- docs/dashboard-guide.md | 2 +- packages/dashboard/app/App.tsx | 110 ++++++++---- .../__tests__/App.keyboard-shortcuts.test.tsx | 158 ++++++++++++++++++ .../utils/__tests__/keyboardShortcuts.test.ts | 1 + 5 files changed, 236 insertions(+), 39 deletions(-) create mode 100644 packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx diff --git a/.changeset/fn-7494-keyboard-shortcuts.md b/.changeset/fn-7494-keyboard-shortcuts.md index 2a8e2b18be..48eefcefe7 100644 --- a/.changeset/fn-7494-keyboard-shortcuts.md +++ b/.changeset/fn-7494-keyboard-shortcuts.md @@ -2,6 +2,6 @@ "@runfusion/fusion": minor --- -summary: Add configurable dashboard shortcuts for Quick Chat and Terminal. +summary: Add configurable dashboard keyboard shortcuts for Quick Chat and Terminal. category: feature -dev: Adds global shortcut settings, guarded runtime key handling, and Escape popup dismissal. +dev: Global dashboardKeyboardShortcuts settings, guarded document-level key handling, and Escape topmost-popup dismissal. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 665879dbe4..0b7f2745c6 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -25,7 +25,7 @@ Leave a shortcut field blank to disable that action. Settings validates each sho Shortcut handling is intentionally guarded. Fusion ignores global shortcuts while focus is inside inputs, textareas, selects, contenteditable editors, chat composers, task fields, Settings fields, search boxes, and terminal input, so typing Space or shortcut letters never opens another surface unexpectedly. Hardware keyboards on desktop, tablet, and mobile use the same bindings when focus is on the page/body. -Press `Escape` to close the current/topmost dashboard popup. Floating Quick Chat and popped-out task windows close before fixed app modals such as Terminal, Settings, Files, or Task Detail, and only one surface closes per key press. Nested editors and menus that already handle Escape keep first ownership by preventing the global handler. +Press `Escape` to close the current/topmost dashboard popup. Popped-out task windows and floating Quick Chat close before fixed app modals such as Terminal, Settings, Files, or Task Detail, and only one surface closes per key press. Nested editors and menus that already handle Escape keep first ownership by preventing the global handler. ## Mobile/PWA app icons diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index d902f106c3..f8d21af104 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -208,6 +208,46 @@ export function getBoardTaskOpenRoute(options: { return "main-panel"; } +export interface DashboardShortcutPopupState { + poppedOutTaskIds: string[]; + quickChatOpen: boolean; + terminalOpen: boolean; + modalClosers: Array<[boolean, () => void]>; +} + +export interface DashboardShortcutPopupHandlers { + closePoppedOutTask: (taskId: string) => void; + closeQuickChat: () => void; + closeTerminal: () => void; +} + +/* +FNXC:DashboardShortcuts 2026-07-04-12:02: +The App-level Escape close order is factored into a pure helper so regression tests can prove the real dashboard shell ordering without rendering every lazy dashboard surface. The helper must close exactly one surface and return false when no popup is open so component-local Escape handlers remain authoritative. +*/ +export function closeTopmostDashboardPopupForShortcut( + state: DashboardShortcutPopupState, + handlers: DashboardShortcutPopupHandlers, +): boolean { + const lastPoppedOutTaskId = state.poppedOutTaskIds[state.poppedOutTaskIds.length - 1]; + if (lastPoppedOutTaskId) { + handlers.closePoppedOutTask(lastPoppedOutTaskId); + return true; + } + if (state.quickChatOpen) { + handlers.closeQuickChat(); + return true; + } + if (state.terminalOpen) { + handlers.closeTerminal(); + return true; + } + const match = state.modalClosers.find(([open]) => open); + if (!match) return false; + match[1](); + return true; +} + function AppInner() { const { t } = useTranslation("app"); const { toasts, addToast, removeToast } = useToast(); @@ -958,43 +998,41 @@ function AppInner() { /* FNXC:DashboardShortcuts 2026-07-04-00:00: Escape should close only one visible dashboard popup per key press. Floating user surfaces close before fixed app modals so a Quick Chat or task popout on top does not accidentally dismiss the underlying Terminal, Settings, or Task Detail modal. + + FNXC:DashboardShortcuts 2026-07-04-12:02: + Popped-out tasks are the most recent floating work surface and must close before Quick Chat. This preserves the topmost-popup invariant when a task popout overlays chat, then falls back to Terminal and modal-manager surfaces one layer per Escape. */ - if (quickChatOpen) { - setQuickChatOpen(false); - return true; - } - const lastPoppedOutTask = poppedOutTasks[poppedOutTasks.length - 1]; - if (lastPoppedOutTask) { - closePoppedOutTask(lastPoppedOutTask.id); - return true; - } - if (modalManager.terminalOpen) { - closeTerminalWithNav(); - return true; - } - const modalClosers: Array<[boolean, () => void]> = [ - [modalManager.filesOpen, modalManager.closeFiles], - [modalManager.workflowEditorOpen, modalManager.closeWorkflowEditor], - [modalManager.gitManagerOpen, modalManager.closeGitManager], - [modalManager.activityLogOpen, modalManager.closeActivityLog], - [modalManager.scriptsOpen, modalManager.closeScripts], - [modalManager.agentsOpen, modalManager.closeAgents], - [modalManager.usageOpen, modalManager.closeUsage], - [modalManager.schedulesOpen, modalManager.closeSchedules], - [modalManager.githubImportOpen, modalManager.closeGitHubImport], - [modalManager.settingsOpen, modalManager.closeSettings], - [Boolean(modalManager.detailTask), modalManager.closeDetailTask], - [Boolean(modalManager.groupModalGroupId), modalManager.closeGroupModal], - [modalManager.isSubtaskOpen, modalManager.closeSubtask], - [modalManager.isPlanningOpen, modalManager.closePlanning], - [modalManager.newTaskModalOpen, modalManager.closeNewTask], - [modalManager.setupWizardOpen, modalManager.closeSetupWizard], - [modalManager.modelOnboardingOpen, modalManager.closeModelOnboarding], - ]; - const match = modalClosers.find(([open]) => open); - if (!match) return false; - match[1](); - return true; + return closeTopmostDashboardPopupForShortcut( + { + poppedOutTaskIds: poppedOutTasks.map((task) => task.id), + quickChatOpen, + terminalOpen: modalManager.terminalOpen, + modalClosers: [ + [modalManager.filesOpen, modalManager.closeFiles], + [modalManager.workflowEditorOpen, modalManager.closeWorkflowEditor], + [modalManager.gitManagerOpen, modalManager.closeGitManager], + [modalManager.activityLogOpen, modalManager.closeActivityLog], + [modalManager.scriptsOpen, modalManager.closeScripts], + [modalManager.agentsOpen, modalManager.closeAgents], + [modalManager.usageOpen, modalManager.closeUsage], + [modalManager.schedulesOpen, modalManager.closeSchedules], + [modalManager.githubImportOpen, modalManager.closeGitHubImport], + [modalManager.settingsOpen, modalManager.closeSettings], + [Boolean(modalManager.detailTask), modalManager.closeDetailTask], + [Boolean(modalManager.groupModalGroupId), modalManager.closeGroupModal], + [modalManager.isSubtaskOpen, modalManager.closeSubtask], + [modalManager.isPlanningOpen, modalManager.closePlanning], + [modalManager.newTaskModalOpen, modalManager.closeNewTask], + [modalManager.setupWizardOpen, modalManager.closeSetupWizard], + [modalManager.modelOnboardingOpen, modalManager.closeModelOnboarding], + ], + }, + { + closePoppedOutTask, + closeQuickChat: () => setQuickChatOpen(false), + closeTerminal: closeTerminalWithNav, + }, + ); }, [closePoppedOutTask, closeTerminalWithNav, modalManager, poppedOutTasks, quickChatOpen]); useDashboardKeyboardShortcuts({ diff --git a/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx b/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx new file mode 100644 index 0000000000..f9d443e411 --- /dev/null +++ b/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx @@ -0,0 +1,158 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { closeTopmostDashboardPopupForShortcut } from "../App"; +import { useDashboardKeyboardShortcuts } from "../hooks/useDashboardKeyboardShortcuts"; + +/* +FNXC:DashboardShortcuts 2026-07-04-12:02: +FN-7507 closes the FN-7494 Code Review gap by proving the dashboard shortcut/Escape invariants at the App-owned seam without rendering every lazy dashboard surface. The hook assertions cover settings-to-document key handling, while closeTopmostDashboardPopupForShortcut covers the App shell's one-popup Escape ordering. +*/ +function press(init: KeyboardEventInit, target: Document | HTMLElement = document) { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + target.dispatchEvent(event); + return event; +} + +describe("App dashboard keyboard shortcuts", () => { + it("opens Quick Chat with the default Space binding from document focus", () => { + const openQuickChat = vi.fn(); + + renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal: vi.fn() })); + const event = press({ key: " " }); + + expect(openQuickChat).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it("uses configured Terminal bindings and leaves disabled bindings inert", () => { + const openQuickChat = vi.fn(); + const toggleTerminal = vi.fn(); + + renderHook(() => useDashboardKeyboardShortcuts({ + shortcuts: { quickChat: "", terminal: "Alt+T" }, + openQuickChat, + toggleTerminal, + })); + + const disabledQuickChatEvent = press({ key: " " }); + expect(openQuickChat).not.toHaveBeenCalled(); + expect(disabledQuickChatEvent.defaultPrevented).toBe(false); + + const terminalEvent = press({ key: "t", altKey: true }); + expect(toggleTerminal).toHaveBeenCalledTimes(1); + expect(terminalEvent.defaultPrevented).toBe(true); + }); + + it("does not capture Space or Escape while an editable field owns the key", () => { + const openQuickChat = vi.fn(); + const closeTopmostPopup = vi.fn(() => true); + const input = document.createElement("input"); + document.body.append(input); + + renderHook(() => useDashboardKeyboardShortcuts({ + openQuickChat, + toggleTerminal: vi.fn(), + closeTopmostPopup, + })); + + input.focus(); + const spaceEvent = press({ key: " " }, input); + const escapeEvent = press({ key: "Escape" }, input); + + expect(openQuickChat).not.toHaveBeenCalled(); + expect(closeTopmostPopup).not.toHaveBeenCalled(); + expect(spaceEvent.defaultPrevented).toBe(false); + expect(escapeEvent.defaultPrevented).toBe(false); + + input.remove(); + }); + + it("lets nested handlers keep default-prevented shortcut events", () => { + const openQuickChat = vi.fn(); + const closeTopmostPopup = vi.fn(() => true); + + renderHook(() => useDashboardKeyboardShortcuts({ + openQuickChat, + toggleTerminal: vi.fn(), + closeTopmostPopup, + })); + + const menuSpace = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }); + Object.defineProperty(menuSpace, "defaultPrevented", { value: true }); + document.dispatchEvent(menuSpace); + + const menuEscape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + Object.defineProperty(menuEscape, "defaultPrevented", { value: true }); + document.dispatchEvent(menuEscape); + + expect(openQuickChat).not.toHaveBeenCalled(); + expect(closeTopmostPopup).not.toHaveBeenCalled(); + }); + + it("closes exactly one topmost App popup per Escape in shell order", () => { + const closePoppedOutTask = vi.fn(); + const closeQuickChat = vi.fn(); + const closeTerminal = vi.fn(); + const closeSettings = vi.fn(); + const closeTaskDetail = vi.fn(); + + expect(closeTopmostDashboardPopupForShortcut( + { + poppedOutTaskIds: ["FN-1", "FN-2"], + quickChatOpen: true, + terminalOpen: true, + modalClosers: [[true, closeSettings], [true, closeTaskDetail]], + }, + { closePoppedOutTask, closeQuickChat, closeTerminal }, + )).toBe(true); + expect(closePoppedOutTask).toHaveBeenCalledWith("FN-2"); + expect(closeQuickChat).not.toHaveBeenCalled(); + expect(closeTerminal).not.toHaveBeenCalled(); + expect(closeSettings).not.toHaveBeenCalled(); + + expect(closeTopmostDashboardPopupForShortcut( + { poppedOutTaskIds: [], quickChatOpen: true, terminalOpen: true, modalClosers: [[true, closeSettings]] }, + { closePoppedOutTask, closeQuickChat, closeTerminal }, + )).toBe(true); + expect(closeQuickChat).toHaveBeenCalledTimes(1); + expect(closeTerminal).not.toHaveBeenCalled(); + + expect(closeTopmostDashboardPopupForShortcut( + { poppedOutTaskIds: [], quickChatOpen: false, terminalOpen: true, modalClosers: [[true, closeSettings]] }, + { closePoppedOutTask, closeQuickChat, closeTerminal }, + )).toBe(true); + expect(closeTerminal).toHaveBeenCalledTimes(1); + expect(closeSettings).not.toHaveBeenCalled(); + + expect(closeTopmostDashboardPopupForShortcut( + { poppedOutTaskIds: [], quickChatOpen: false, terminalOpen: false, modalClosers: [[false, closeSettings], [true, closeTaskDetail]] }, + { closePoppedOutTask, closeQuickChat, closeTerminal }, + )).toBe(true); + expect(closeTaskDetail).toHaveBeenCalledTimes(1); + expect(closeSettings).not.toHaveBeenCalled(); + + expect(closeTopmostDashboardPopupForShortcut( + { poppedOutTaskIds: [], quickChatOpen: false, terminalOpen: false, modalClosers: [[false, closeSettings]] }, + { closePoppedOutTask, closeQuickChat, closeTerminal }, + )).toBe(false); + }); + + it("prevents Escape only when the App shell closes a popup", () => { + const closeTopmostPopup = vi.fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + renderHook(() => useDashboardKeyboardShortcuts({ + openQuickChat: vi.fn(), + toggleTerminal: vi.fn(), + closeTopmostPopup, + })); + + const handled = press({ key: "Escape" }); + const unhandled = press({ key: "Escape" }); + + expect(closeTopmostPopup).toHaveBeenCalledTimes(2); + expect(handled.defaultPrevented).toBe(true); + expect(unhandled.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts b/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts index 5841d8d223..0197dd958f 100644 --- a/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts +++ b/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts @@ -44,6 +44,7 @@ describe("keyboard shortcut utilities", () => { expect(shortcutMatchesEvent("Escape", keydown({ key: "Escape" }))).toBe(true); expect(shortcutMatchesEvent("Ctrl+`", keydown({ key: "`", ctrlKey: true }))).toBe(true); expect(shortcutMatchesEvent("Meta+K", keydown({ key: "k", metaKey: true }))).toBe(true); + expect(shortcutMatchesEvent("Alt+T", keydown({ key: "t", altKey: true }))).toBe(true); expect(shortcutMatchesEvent("Ctrl+K", keydown({ key: "k" }))).toBe(false); expect(shortcutMatchesEvent("", keydown({ key: " " }))).toBe(false); });