From e523bea67052c83a39fe7f5b55d4699b5113ee55 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 7 Oct 2026 00:08:49 +0300 Subject: [PATCH] =?UTF-8?q?keyboard:=20the=20one-Esc=20law=20=E2=80=94=20a?= =?UTF-8?q?=20surfaced=20layer=20claims=20at=20the=20document=20level=20(k?= =?UTF-8?q?eyboard=204=20of=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling (keyboard round 2026-10-06): Esc (or any key) does ONE action, on the topmost active layer. The plan-open-while-dictating double-act was the symptom: the plan popup listened at WINDOW level with no claim, and same-node window listeners all fire — the global chain (registered first) cancelled the dictation while the plan's own listener closed the plan. One Esc, two actions. The law (now the house pattern, documented at the chain): - field-level handlers claim at their element (focus in a field: the field's action) — unchanged, already correct - every SURFACED layer claims at the DOCUMENT level with preventDefault + stopPropagation — document fires before the window chain, so the chain stays silent under any surface - the window chain is the bottom-up fallback: dictation (a live capture, NOT a surface — it survives the Esc that closes a plan or overlay), then abort (escAbortTarget layers its own targets) Converted to the claim: plan popup, session overlay, chain table, settings dialog, setup dialog, the delete/abort dialogs (window-level stopPropagation was never enough — same-node listeners all fire), and the stats popover. Popovers (popover-dismiss), menus, model picker, and the sidebar context menu already claimed correctly. 1427 tests, tsc, lint, build green. --- components/abort-turn-dialog.tsx | 8 ++++++-- components/chain-table.tsx | 13 ++++++++++--- components/delete-node-dialog.tsx | 8 ++++++-- components/delete-session-dialog.tsx | 8 ++++++-- components/keyboard-shortcuts.tsx | 16 ++++++++++++---- components/plan-block.tsx | 18 ++++++++++++++---- components/session-overlay.tsx | 14 ++++++++++---- components/settings/settings-dialog.tsx | 13 ++++++++++--- components/setup-dialog.tsx | 10 ++++++++-- components/stats-bar.tsx | 8 +++++++- 10 files changed, 89 insertions(+), 27 deletions(-) diff --git a/components/abort-turn-dialog.tsx b/components/abort-turn-dialog.tsx index bfe6e72..62ba9e1 100644 --- a/components/abort-turn-dialog.tsx +++ b/components/abort-turn-dialog.tsx @@ -124,12 +124,16 @@ export function AbortTurnDialog({ if (mode === null) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { + // THE ONE-ESC LAW (keyboard round 2026-10-06): claim at the + // document level — a window-level stopPropagation cannot stop + // the earlier-registered global window listener. + e.preventDefault(); e.stopPropagation(); onClose(); } }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); }, [mode, onClose]); if (mode === null || typeof document === "undefined") return null; diff --git a/components/chain-table.tsx b/components/chain-table.tsx index 678c397..75c08e4 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -640,10 +640,17 @@ export function ChainTableOverlay({ useEffect(() => { if (!open || deleteOpen) return; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim + // at the document level — the window chain (dictation → + // abort) must not also act. One Esc, one action. + e.preventDefault(); + e.stopPropagation(); + onClose(); + } }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); }, [open, deleteOpen, onClose]); if (!open) return null; diff --git a/components/delete-node-dialog.tsx b/components/delete-node-dialog.tsx index 22a3d5c..b26dd06 100644 --- a/components/delete-node-dialog.tsx +++ b/components/delete-node-dialog.tsx @@ -158,12 +158,16 @@ export function DeleteNodeDialog({ if (!nodes || nodes.length === 0) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { + // THE ONE-ESC LAW (keyboard round 2026-10-06): claim at the + // document level — a window-level stopPropagation cannot stop + // the earlier-registered global window listener. + e.preventDefault(); e.stopPropagation(); onClose(); } }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); }, [nodes, onClose]); // Sequential full deaths — never parallel (the store's revision diff --git a/components/delete-session-dialog.tsx b/components/delete-session-dialog.tsx index 97c4ec1..c0fb004 100644 --- a/components/delete-session-dialog.tsx +++ b/components/delete-session-dialog.tsx @@ -68,12 +68,16 @@ export function DeleteSessionDialog({ if (!chat) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { + // THE ONE-ESC LAW (keyboard round 2026-10-06): claim at the + // document level — a window-level stopPropagation cannot stop + // the earlier-registered global window listener. + e.preventDefault(); e.stopPropagation(); onClose(); } }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); }, [chat, onClose]); const run = async () => { diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx index 729f70e..df809b8 100644 --- a/components/keyboard-shortcuts.tsx +++ b/components/keyboard-shortcuts.tsx @@ -82,10 +82,18 @@ export function KeyboardShortcuts() { e.preventDefault(); return; } - // Popovers stopPropagation on their own Esc, so reaching here - // means none was open. Dictation first, then abort the turn - // (a server-side turn has no client request to abort — the - // abort seam in Chat decides what's honestly stoppable). + // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): one + // Esc, one action, on the topmost active layer. Field-level + // handlers claim at their element (focus in a field: the + // field's action); every SURFACED layer (popovers, plan, + // overlays, dialogs) claims at the DOCUMENT level, which + // fires before this window listener — so reaching here means + // NOTHING is surfaced and no field is focused. This chain is + // the fallback, bottom-up: dictation (a live capture, not a + // surface — it survives Esc that closes a plan or overlay), + // then abort the turn (a server-side turn has no client + // request to abort — the abort seam in Chat decides what's + // honestly stoppable, layered by escAbortTarget). if (isDictationActive()) { e.preventDefault(); window.dispatchEvent(new CustomEvent("lh:kbd-cancel-dictation")); diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 5d6828d..9a68652 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -639,13 +639,23 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa ); const statusOf = (id: string): PlanNodeStatus => (statuses ? derivedNodeStatus(statuses, id) : "upcoming"); - // Esc closes. + // Esc closes — claimed at the DOCUMENT level (THE ONE-ESC LAW, owner + // keyboard round 2026-10-06): a surfaced layer's Esc is exactly one + // action. The window-level chain (dictation cancel → abort) is the + // fallback for when NOTHING is surfaced; a window listener here could + // never stop it — same-node listeners all fire — which was the + // plan-open-while-dictating double-act (Esc closed the plan AND + // cancelled the dictation). useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); }, [onClose]); // ── pan/zoom viewport (harvested semantics: fit floor = the default) ── diff --git a/components/session-overlay.tsx b/components/session-overlay.tsx index cc581f8..ebe52c7 100644 --- a/components/session-overlay.tsx +++ b/components/session-overlay.tsx @@ -74,13 +74,19 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: () }; }, [chart, attempt]); - // Esc closes. + // Esc closes — claimed at the document level (THE ONE-ESC LAW, owner + // keyboard round 2026-10-06): one Esc, one action; the window chain + // (dictation → abort) never fires under a surfaced layer. useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); }, [onClose]); // ── pan/zoom viewport (same wiring as the plan popup) ── diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 75848f3..1a2b7a3 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -206,10 +206,17 @@ export function SettingsDialogBody({ // dialog's outer layer). useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim + // at the document level — the window chain (dictation → + // abort) must not also act. One Esc, one action. + e.preventDefault(); + e.stopPropagation(); + onClose(); + } }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); }, [onClose]); /** ONE writer for the whole dialog: PUT the whole section, keep the diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 619d592..4d8a489 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -304,6 +304,12 @@ export function SetupDialog({ if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; + // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at + // the document level before the layered dismissal — every branch + // below acts, so the window chain (dictation → abort) must + // never also fire. One Esc, one action. + e.preventDefault(); + e.stopPropagation(); if (keyMenuOpen) { setKeyMenuOpen(false); return; @@ -319,10 +325,10 @@ export function SetupDialog({ if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="${triggerTestid}"]`)) return; setOpenMenu(null); }; - window.addEventListener("keydown", onKey); + document.addEventListener("keydown", onKey); document.addEventListener("pointerdown", onPointerDown); return () => { - window.removeEventListener("keydown", onKey); + document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; }, [open, onClose, openMenu, keyMenuOpen]); diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index cd4cc25..b41ec55 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -58,7 +58,13 @@ export function StatsBar({ if (barRef.current && !barRef.current.contains(e.target as Node)) setOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); + if (e.key === "Escape") { + // THE ONE-ESC LAW (keyboard round 2026-10-06): claim — the + // window chain must not also act under this popover. + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + } }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown);