From c40f31a355fcfcd2382a3638c915c1d0d87521ea Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sun, 8 Nov 2026 23:35:45 +0800 Subject: [PATCH] fix(desktop): scope keyboard actions and restore title focus --- .../chat/chat-question-keyboard.test.tsx | 207 ++++++++++++++++++ .../components/chat/chat-question.tsx | 74 +++---- .../renderer/components/chat/chat-view.tsx | 33 +-- .../components/root-layout-keyboard.ts | 2 +- .../session-shell-keyboard.test.tsx | 155 +++++++++++++ .../src/renderer/components/session-shell.tsx | 11 +- .../use-session-history-shortcuts.test.tsx | 140 ++++++++++++ .../hooks/use-session-history-shortcuts.ts | 53 +++++ 8 files changed, 604 insertions(+), 71 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/chat-question-keyboard.test.tsx create mode 100644 apps/desktop/src/renderer/components/session-shell-keyboard.test.tsx create mode 100644 apps/desktop/src/renderer/hooks/use-session-history-shortcuts.test.tsx create mode 100644 apps/desktop/src/renderer/hooks/use-session-history-shortcuts.ts diff --git a/apps/desktop/src/renderer/components/chat/chat-question-keyboard.test.tsx b/apps/desktop/src/renderer/components/chat/chat-question-keyboard.test.tsx new file mode 100644 index 00000000..7adbd475 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chat-question-keyboard.test.tsx @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Window } from "happy-dom" +import { act, type ComponentProps } from "react" +import type { Root } from "react-dom/client" +import type { QuestionAnswer, QuestionRequest } from "../../lib/types" + +const dom = new Window({ url: "http://localhost" }) +Object.assign(globalThis, { + window: dom, + document: dom.document, + navigator: dom.navigator, + Node: dom.Node, + Element: dom.Element, + HTMLElement: dom.HTMLElement, + HTMLInputElement: dom.HTMLInputElement, + KeyboardEvent: dom.KeyboardEvent, + requestAnimationFrame: dom.requestAnimationFrame.bind(dom), + cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), + IS_REACT_ACT_ENVIRONMENT: true, +}) +const { createRoot } = await import("react-dom/client") +const { ChatQuestionFlow } = await import("./chat-question") +const { handleSessionNavigationKeyDown } = await import("../root-layout-keyboard") +const request: QuestionRequest = { + id: "request-1", + sessionId: "session-1", + questions: [ + { + id: "question-1", + header: "Plan", + question: "Choose a path", + isOther: true, + isSecret: false, + options: [{ label: "Option A", description: "First path" }], + }, + ], +} +let root: Root | undefined +let mount: HTMLElement +let replies: [string, QuestionAnswer[]][] = [] +let rejections: string[] = [] + +async function renderCard(overrides: Partial> = {}) { + if (!root) { + mount = document.createElement("div") + document.body.append(mount) + root = createRoot(mount) + } + await act(async () => + root!.render( + { + replies.push([id, answers]) + }} + onReject={async (id) => { + rejections.push(id) + }} + {...overrides} + />, + ), + ) + return mount.querySelector('section[aria-label="Agent question"]')! +} + +async function key( + target: EventTarget, + key: string, + options: Pick< + KeyboardEventInit, + "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey" | "isComposing" | "keyCode" + > = {}, +) { + const event = new dom.KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }) + await act(async () => { + target.dispatchEvent(event as unknown as Event) + }) + return event +} + +async function answer(card: HTMLElement) { + await act(async () => { + card.querySelector('[role="radio"]')!.click() + }) +} + +afterEach(async () => { + await act(async () => root?.unmount()) + root = undefined + document.body.replaceChildren() + replies = [] + rejections = [] +}) + +describe("question card keyboard", () => { + test("unrelated buttons and inputs cannot advance, submit, skip, or navigate questions", async () => { + const card = await renderCard() + await answer(card) + for (const tag of ["button", "input"] as const) { + const outside = document.createElement(tag) + document.body.append(outside) + outside.focus() + for (const name of ["Enter", "Enter", "Escape", "ArrowRight", "ArrowLeft"]) { + expect((await key(outside, name)).defaultPrevented).toBe(false) + } + } + expect(card.textContent).toContain("Choose a path") + expect({ replies, rejections }).toEqual({ replies: [], rejections: [] }) + }) + + test("card Enter reviews then sends exactly one answer; arrows can return to editing", async () => { + const card = await renderCard() + await answer(card) + card.focus() + expect((await key(card, "Enter")).defaultPrevented).toBe(true) + expect(card.textContent).toContain("Your answers") + expect((await key(card, "ArrowLeft")).defaultPrevented).toBe(true) + expect(card.textContent).toContain("Choose a path") + await key(card, "ArrowRight") + await key(card, "Enter") + expect(replies).toEqual([["request-1", [["Option A"]]]]) + }) + + test("focused buttons retain native activation instead of triggering the card's Enter action", async () => { + const card = await renderCard() + await answer(card) + const skip = card.querySelector('[aria-label="Skip question"]')! + skip.focus() + expect((await key(skip, "Enter")).defaultPrevented).toBe(false) + expect(card.textContent).toContain("Choose a path") + // Happy DOM does not synthesize native button activation from keyboard events. + await act(async () => skip.click()) + expect({ replies, rejections }).toEqual({ replies: [], rejections: ["request-1"] }) + }) + + test("IME confirmation does not advance a custom answer, including legacy keyCode 229", async () => { + const card = await renderCard() + const input = card.querySelector("input")! + input.focus() + await act(async () => { + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call( + input, + "中文", + ) + input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event) + }) + for (const options of [{ isComposing: true }, { keyCode: 229 }]) { + expect((await key(input, "Enter", options)).defaultPrevented).toBe(false) + expect(card.textContent).toContain("Choose a path") + } + expect((await key(input, "Enter")).defaultPrevented).toBe(true) + expect(card.textContent).toContain("Your answers") + await key(card, "Enter") + expect(replies).toEqual([["request-1", [["中文"]]]]) + }) + + test("card Escape wins over document capture session navigation but ignores composition", async () => { + const card = await renderCard() + const navigations: string[] = [] + const onCapture = (event: KeyboardEvent) => { + handleSessionNavigationKeyDown(event, { + agents: [{ id: "session-1", projectSlug: "project" }], + sessionId: "session-1", + navigate: (target) => navigations.push(target.to), + }) + } + document.addEventListener("keydown", onCapture, { capture: true }) + try { + card.focus() + expect((await key(card, "Escape", { isComposing: true })).defaultPrevented).toBe(false) + expect((await key(card, "Escape")).defaultPrevented).toBe(true) + expect({ rejections, navigations }).toEqual({ rejections: ["request-1"], navigations: [] }) + } finally { + document.removeEventListener("keydown", onCapture, { capture: true }) + } + }) + + test("disabled and pending cards cannot be submitted or rejected twice", async () => { + const card = await renderCard({ disabled: true }) + expect((await key(card, "Escape")).defaultPrevented).toBe(false) + expect(rejections).toEqual([]) + let finish: (() => void) | undefined + await renderCard({ + onReply: async (id, answers) => { + replies.push([id, answers]) + await new Promise((resolve) => { + finish = resolve + }) + }, + }) + await answer(card) + await key(card, "Enter") + await key(card, "Enter") + await key(card, "Enter") + await key(card, "Escape") + expect({ replies, rejections }).toEqual({ + replies: [["request-1", [["Option A"]]]], + rejections: [], + }) + await act(async () => finish?.()) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/chat-question.tsx b/apps/desktop/src/renderer/components/chat/chat-question.tsx index 2092c877..ee71518e 100644 --- a/apps/desktop/src/renderer/components/chat/chat-question.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-question.tsx @@ -128,7 +128,12 @@ function QuestionSection({ value={customText} onChange={(e) => onCustomChange(index, e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { + if ( + e.key === "Enter" && + !e.shiftKey && + !e.nativeEvent.isComposing && + e.nativeEvent.keyCode !== 229 + ) { e.preventDefault() onSubmitCustom?.() } @@ -391,48 +396,15 @@ const QuestionRequestStepper = memo(function QuestionRequestStepper({ } }, [disabled, submitting, onReject, request.id]) - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (e.target instanceof HTMLInputElement && e.target.id?.startsWith("question-custom-")) { - return - } - if (e.key === "Enter" && !e.shiftKey) { - if (isReview ? allAnswered : currentAnswered) { - e.preventDefault() - handleAdvance() - } - } else if (e.key === "Escape") { - e.preventDefault() - handleSkip() - } else if (e.key === "ArrowLeft") { - e.preventDefault() - handlePrevPage() - } else if (e.key === "ArrowRight") { - e.preventDefault() - handleNextPage() - } - } - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [ - isReview, - allAnswered, - currentAnswered, - handleAdvance, - handleSkip, - handlePrevPage, - handleNextPage, - ]) - useEffect(() => { const timer = requestAnimationFrame(() => { if (isReview) { cardRef.current?.focus() return } - const customInput = document.getElementById( - `question-custom-${currentStep}`, - ) as HTMLInputElement | null + const customInput = cardRef.current?.querySelector( + `#question-custom-${currentStep}`, + ) if (customInput) { customInput.focus() } else { @@ -452,6 +424,34 @@ const QuestionRequestStepper = memo(function QuestionRequestStepper({ ref={cardRef} tabIndex={-1} aria-label="Agent question" + onKeyDown={(e) => { + if ( + e.defaultPrevented || + e.nativeEvent.isComposing || + e.nativeEvent.keyCode === 229 || + disabled || + submitting || + e.target instanceof HTMLInputElement + ) return + + if (e.key === "Enter" && !e.shiftKey) { + // Focused buttons keep their native activation (select, edit, skip, or send). + if (e.target !== e.currentTarget) return + if (isReview ? allAnswered : currentAnswered) { + e.preventDefault() + handleAdvance() + } + } else if (e.key === "Escape") { + e.preventDefault() + handleSkip() + } else if (e.key === "ArrowLeft") { + e.preventDefault() + handlePrevPage() + } else if (e.key === "ArrowRight") { + e.preventDefault() + handleNextPage() + } + }} className="devo-composer animate-in fade-in slide-in-from-bottom-2 bg-background/95 shadow-[0_8px_32px_rgba(0,0,0,0.05)] outline-none duration-200 dark:shadow-[0_10px_36px_rgba(0,0,0,0.28)]" >
diff --git a/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 062ec3c2..f3302c4e 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -1,3 +1,4 @@ +import { useSessionHistoryShortcuts } from "../../hooks/use-session-history-shortcuts" import { useTurnRecovery } from "../../hooks/use-turn-recovery" import { TurnRecoveryPanel } from "./turn-recovery-panel" import { @@ -1000,37 +1001,7 @@ export function ChatView({ [onDeny], ) - // Keyboard shortcuts for undo/redo - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't intercept Cmd/Ctrl+Z in any text input — let the browser - // handle native undo/redo. Session undo/redo is still available via - // /undo, /redo slash commands and the command palette. - const target = e.target as HTMLElement - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return - - // Cmd+Z / Ctrl+Z — Undo - if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { - if (canUndo && onUndo) { - e.preventDefault() - onUndo() - } - return - } - - // Cmd+Shift+Z / Ctrl+Shift+Z — Redo - if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { - if (canRedo && onRedo) { - e.preventDefault() - onRedo() - } - return - } - } - - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [canUndo, canRedo, onUndo, onRedo]) + useSessionHistoryShortcuts({ isActive, canUndo, canRedo, onUndo, onRedo }) // Width constraint class: remove max-w when review panel is open const contentWidthClass = reviewPanelOpen diff --git a/apps/desktop/src/renderer/components/root-layout-keyboard.ts b/apps/desktop/src/renderer/components/root-layout-keyboard.ts index fe39dd1c..f4bde7e3 100644 --- a/apps/desktop/src/renderer/components/root-layout-keyboard.ts +++ b/apps/desktop/src/renderer/components/root-layout-keyboard.ts @@ -1,6 +1,6 @@ /** Session shortcuts must not override controls or an open portal overlay. */ const INTERACTIVE_TARGET = - 'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"]), [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="slider"], [role="combobox"], [role="menuitem"], [role="option"], [aria-label="Tool permission request"]' + 'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"]), [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="slider"], [role="combobox"], [role="menuitem"], [role="option"], [aria-label="Tool permission request"], [aria-label="Agent question"]' const OPEN_OVERLAY = 'dialog[open], [role="dialog"][data-open], [role="alertdialog"][data-open], [role="dialog"][aria-modal="true"], [role="alertdialog"][aria-modal="true"], [data-slot="popover-content"][data-open], [data-slot="dropdown-menu-content"][data-open], [data-slot="context-menu-content"][data-open], [data-slot="select-content"][data-open], [data-slot="combobox-content"][data-open], [role="menu"][data-open]' diff --git a/apps/desktop/src/renderer/components/session-shell-keyboard.test.tsx b/apps/desktop/src/renderer/components/session-shell-keyboard.test.tsx new file mode 100644 index 00000000..959af1e1 --- /dev/null +++ b/apps/desktop/src/renderer/components/session-shell-keyboard.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Window } from "happy-dom" +import { Provider, createStore } from "jotai" +import { act } from "react" +import type { Root } from "react-dom/client" +import { reviewPanelOpenAtom, reviewPanelSettingsAtom } from "../atoms/ui" + +const dom = new Window({ url: "http://localhost" }) +Object.assign(globalThis, { + window: dom, + document: dom.document, + navigator: dom.navigator, + Node: dom.Node, + Element: dom.Element, + HTMLElement: dom.HTMLElement, + HTMLInputElement: dom.HTMLInputElement, + KeyboardEvent: dom.KeyboardEvent, + requestAnimationFrame: dom.requestAnimationFrame.bind(dom), + cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), + IS_REACT_ACT_ENVIRONMENT: true, +}) +const agent = { + id: "A", + sessionId: "A", + name: "Session title", + directory: "/demo", + projectSlug: "demo", + status: "idle", +} +mock.module("@tanstack/react-router", () => ({ + useNavigate: () => () => {}, + useParams: () => ({ projectSlug: "demo" }), +})) +mock.module("../hooks/use-session-controller", () => ({ + useSessionController: () => ({ + agent, + chatTurns: [], + chatLoading: false, + handleRenameSession: async () => {}, + undo: async () => {}, + redo: async () => {}, + }), +})) +mock.module("./chat/chat-view", () => ({ ChatView: () => null, ChatInputSection: () => null })) +mock.module("./chat", () => ({ ChatView: () => null })) +mock.module("./review/review-side-rail", () => ({ ReviewSideRail: () => null })) +mock.module("./context-usage-button", () => ({ ContextUsageButton: () => null })) +mock.module("./worktree-actions", () => ({ WorktreeActions: () => null })) +mock.module("../services/backend", () => ({ + isElectron: () => false, + fetchOpenInTargets: async () => ({ targets: [] }), + openInTarget: async () => {}, + setOpenInPreferred: async () => {}, +})) +const { createRoot } = await import("react-dom/client") +const { SessionShell } = await import("./session-shell") +let root: Root | undefined +let mount: HTMLElement +let store = createStore() + +async function renderShell() { + mount = document.createElement("div") + document.body.append(mount) + root = createRoot(mount) + store = createStore() + store.set(reviewPanelOpenAtom, false) + store.set(reviewPanelSettingsAtom, { ...store.get(reviewPanelSettingsAtom), expanded: false }) + await act(async () => + root!.render( + + + , + ), + ) +} + +async function key( + key: string, + options: Pick< + KeyboardEventInit, + "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey" | "isComposing" | "keyCode" + > = {}, + target: EventTarget = document.body, +) { + const event = new dom.KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }) + await act(async () => { + target.dispatchEvent(event as unknown as Event) + }) + return event +} + +afterEach(async () => { + await act(async () => root?.unmount()) + root = undefined + document.body.replaceChildren() +}) + +describe("session shell keyboard", () => { + test("Shift+D and Shift+F work with uppercase or lowercase keys, Ctrl or Meta, and repeated toggles", async () => { + await renderShell() + for (const modifier of [{ ctrlKey: true }, { metaKey: true }]) { + for (const letter of ["D", "d"]) { + expect((await key(letter, { ...modifier, shiftKey: true })).defaultPrevented).toBe(true) + expect(store.get(reviewPanelOpenAtom)).toBe(true) + await key("F", { ...modifier, shiftKey: true }) + expect(store.get(reviewPanelSettingsAtom).expanded).toBe(true) + await key("f", { ...modifier, shiftKey: true }) + expect(store.get(reviewPanelSettingsAtom).expanded).toBe(false) + await key(letter, { ...modifier, shiftKey: true }) + expect(store.get(reviewPanelOpenAtom)).toBe(false) + } + } + }) + + test("composition and unmodified keys do not toggle review; listeners are removed on unmount", async () => { + await renderShell() + for (const options of [ + { ctrlKey: true, shiftKey: true, isComposing: true }, + { ctrlKey: true, shiftKey: true, keyCode: 229 }, + { ctrlKey: true }, + { shiftKey: true }, + ]) { + expect((await key("D", options)).defaultPrevented).toBe(false) + } + expect(store.get(reviewPanelOpenAtom)).toBe(false) + await act(async () => root?.unmount()) + root = undefined + expect((await key("D", { ctrlKey: true, shiftKey: true })).defaultPrevented).toBe(false) + expect(store.get(reviewPanelOpenAtom)).toBe(false) + }) + + test("the real title editor focuses and selects the name on every edit, including after Escape", async () => { + await renderShell() + for (let attempt = 0; attempt < 2; attempt++) { + const title = [...mount.querySelectorAll("button")].find( + (button) => button.textContent === "Session title", + )! + title.focus() + await act(async () => title.click()) + const input = mount.querySelector("input")! + expect({ + focused: document.activeElement === input, + selection: [input.selectionStart, input.selectionEnd], + value: input.value, + }).toEqual({ focused: true, selection: [0, "Session title".length], value: "Session title" }) + await key("Escape", {}, input) + expect(mount.querySelector("input")).toBeNull() + } + }) +}) diff --git a/apps/desktop/src/renderer/components/session-shell.tsx b/apps/desktop/src/renderer/components/session-shell.tsx index 5f5480c6..a915b110 100644 --- a/apps/desktop/src/renderer/components/session-shell.tsx +++ b/apps/desktop/src/renderer/components/session-shell.tsx @@ -130,11 +130,12 @@ export function SessionShell({ activeSessionId, evictRef }: SessionShellProps) { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === "d") { + if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return + if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "d") { event.preventDefault() setReviewPanelOpen((prev) => !prev) } - if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === "f") { + if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "f") { event.preventDefault() if (reviewPanelOpen) { setReviewSettings((prev) => ({ ...prev, expanded: !prev.expanded })) @@ -173,6 +174,12 @@ export function SessionShell({ activeSessionId, evictRef }: SessionShellProps) { } }, [activeAgent?.name, activeAgent?.sessionId]) + useEffect(() => { + if (!isEditingTitle) return + titleInputRef.current?.focus() + titleInputRef.current?.select() + }, [isEditingTitle]) + useLayoutEffect(() => { if (setupPhase) { setComposerInset(0) diff --git a/apps/desktop/src/renderer/hooks/use-session-history-shortcuts.test.tsx b/apps/desktop/src/renderer/hooks/use-session-history-shortcuts.test.tsx new file mode 100644 index 00000000..aa484d34 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/use-session-history-shortcuts.test.tsx @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Window } from "happy-dom" +import { act } from "react" +import type { Root } from "react-dom/client" +import { useSessionHistoryShortcuts } from "./use-session-history-shortcuts" + +const dom = new Window({ url: "http://localhost" }) +Object.assign(globalThis, { + window: dom, + document: dom.document, + navigator: dom.navigator, + Node: dom.Node, + Element: dom.Element, + HTMLElement: dom.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}) +const { createRoot } = await import("react-dom/client") +let root: Root | undefined +let calls: string[] = [] + +function Transcript({ + id, + active, + canUndo = true, + canRedo = true, +}: { + id: string + active: boolean + canUndo?: boolean + canRedo?: boolean +}) { + useSessionHistoryShortcuts({ + isActive: active, + canUndo, + canRedo, + onUndo: async () => { + calls.push(`undo ${id}`) + return undefined + }, + onRedo: async () => { + calls.push(`redo ${id}`) + }, + }) + return
{id}
+} + +async function render(active: string, canUndo = true, canRedo = true) { + if (!root) { + const mount = document.createElement("div") + document.body.append(mount) + root = createRoot(mount) + } + await act(async () => + root!.render( + <> + + + , + ), + ) +} + +async function key( + options: Pick< + KeyboardEventInit, + "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey" | "isComposing" | "keyCode" + > = {}, + target: EventTarget = document.body, + prevented = false, +) { + const event = new dom.KeyboardEvent("keydown", { + key: "z", + ctrlKey: true, + bubbles: true, + cancelable: true, + ...options, + }) + if (prevented) event.preventDefault() + await act(async () => { + target.dispatchEvent(event as unknown as Event) + }) + return event +} + +afterEach(async () => { + await act(async () => root?.unmount()) + root = undefined + document.body.replaceChildren() + calls = [] +}) + +describe("session history shortcuts", () => { + test("only the active mounted transcript handles undo and uppercase redo, also after switching", async () => { + await render("A") + expect((await key()).defaultPrevented).toBe(true) + expect((await key({ key: "Z", shiftKey: true })).defaultPrevented).toBe(true) + await render("B") + await key({ ctrlKey: false, metaKey: true }) + await key({ key: "Z", shiftKey: true, ctrlKey: false, metaKey: true }) + expect(calls).toEqual(["undo A", "redo A", "undo B", "redo B"]) + }) + + test("native input, textarea, and nested rich text history remain untouched", async () => { + await render("A") + const input = document.createElement("input") + const textarea = document.createElement("textarea") + const editable = document.createElement("div") + editable.contentEditable = "true" + const span = document.createElement("span") + editable.append(span) + document.body.append(input, textarea, editable) + for (const target of [input, textarea, span]) { + expect((await key({}, target)).defaultPrevented).toBe(false) + expect((await key({ key: "Z", shiftKey: true }, target)).defaultPrevented).toBe(false) + } + expect(calls).toEqual([]) + }) + + test("handled events, composition, unavailable history, and unmounted panels do nothing", async () => { + await render("A") + await key({}, document.body, true) + for (const options of [ + { isComposing: true }, + { keyCode: 229 }, + { altKey: true }, + { ctrlKey: false }, + ]) { + expect((await key(options)).defaultPrevented).toBe(false) + } + await render("A", false, false) + expect((await key()).defaultPrevented).toBe(false) + expect((await key({ key: "Z", shiftKey: true })).defaultPrevented).toBe(false) + await render("none") + expect((await key()).defaultPrevented).toBe(false) + await act(async () => root?.unmount()) + root = undefined + expect((await key()).defaultPrevented).toBe(false) + expect(calls).toEqual([]) + }) +}) diff --git a/apps/desktop/src/renderer/hooks/use-session-history-shortcuts.ts b/apps/desktop/src/renderer/hooks/use-session-history-shortcuts.ts new file mode 100644 index 00000000..f3067e8a --- /dev/null +++ b/apps/desktop/src/renderer/hooks/use-session-history-shortcuts.ts @@ -0,0 +1,53 @@ +import { useEffect } from "react" + +/** Only the visible transcript owns session undo/redo; editors retain native history. */ +export function useSessionHistoryShortcuts({ + isActive, + canUndo, + canRedo, + onUndo, + onRedo, +}: { + isActive: boolean + canUndo?: boolean + canRedo?: boolean + onUndo?: () => Promise + onRedo?: () => Promise +}) { + useEffect(() => { + if (!isActive) return + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.isComposing || + event.keyCode === 229 || + !(event.metaKey || event.ctrlKey) || + event.altKey || + event.key.toLowerCase() !== "z" + ) + return + + // Keep native undo/redo inside inputs and rich text editors. + const target = event.target + if ( + target instanceof Element && + target.closest('input, textarea, [contenteditable]:not([contenteditable="false"])') + ) + return + + if (event.shiftKey) { + if (canRedo && onRedo) { + event.preventDefault() + void onRedo() + } + } else if (canUndo && onUndo) { + event.preventDefault() + void onUndo() + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isActive, canUndo, canRedo, onUndo, onRedo]) +}