keyboard: the one-Esc law — a surfaced layer claims at the document level (keyboard 4 of 4)
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.
This commit is contained in:
@@ -124,12 +124,16 @@ export function AbortTurnDialog({
|
|||||||
if (mode === null) return;
|
if (mode === null) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
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();
|
e.stopPropagation();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [mode, onClose]);
|
}, [mode, onClose]);
|
||||||
|
|
||||||
if (mode === null || typeof document === "undefined") return null;
|
if (mode === null || typeof document === "undefined") return null;
|
||||||
|
|||||||
@@ -640,10 +640,17 @@ export function ChainTableOverlay({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || deleteOpen) return;
|
if (!open || deleteOpen) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
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);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [open, deleteOpen, onClose]);
|
}, [open, deleteOpen, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|||||||
@@ -158,12 +158,16 @@ export function DeleteNodeDialog({
|
|||||||
if (!nodes || nodes.length === 0) return;
|
if (!nodes || nodes.length === 0) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
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();
|
e.stopPropagation();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [nodes, onClose]);
|
}, [nodes, onClose]);
|
||||||
|
|
||||||
// Sequential full deaths — never parallel (the store's revision
|
// Sequential full deaths — never parallel (the store's revision
|
||||||
|
|||||||
@@ -68,12 +68,16 @@ export function DeleteSessionDialog({
|
|||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
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();
|
e.stopPropagation();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [chat, onClose]);
|
}, [chat, onClose]);
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
|||||||
@@ -82,10 +82,18 @@ export function KeyboardShortcuts() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Popovers stopPropagation on their own Esc, so reaching here
|
// THE ONE-ESC LAW (owner, keyboard round 2026-10-06): one
|
||||||
// means none was open. Dictation first, then abort the turn
|
// Esc, one action, on the topmost active layer. Field-level
|
||||||
// (a server-side turn has no client request to abort — the
|
// handlers claim at their element (focus in a field: the
|
||||||
// abort seam in Chat decides what's honestly stoppable).
|
// 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()) {
|
if (isDictationActive()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.dispatchEvent(new CustomEvent("lh:kbd-cancel-dictation"));
|
window.dispatchEvent(new CustomEvent("lh:kbd-cancel-dictation"));
|
||||||
|
|||||||
@@ -639,13 +639,23 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
);
|
);
|
||||||
const statusOf = (id: string): PlanNodeStatus => (statuses ? derivedNodeStatus(statuses, id) : "upcoming");
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// ── pan/zoom viewport (harvested semantics: fit floor = the default) ──
|
// ── pan/zoom viewport (harvested semantics: fit floor = the default) ──
|
||||||
|
|||||||
@@ -74,13 +74,19 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: ()
|
|||||||
};
|
};
|
||||||
}, [chart, attempt]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// ── pan/zoom viewport (same wiring as the plan popup) ──
|
// ── pan/zoom viewport (same wiring as the plan popup) ──
|
||||||
|
|||||||
@@ -206,10 +206,17 @@ export function SettingsDialogBody({
|
|||||||
// dialog's outer layer).
|
// dialog's outer layer).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
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);
|
document.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
/** ONE writer for the whole dialog: PUT the whole section, keep the
|
/** ONE writer for the whole dialog: PUT the whole section, keep the
|
||||||
|
|||||||
@@ -304,6 +304,12 @@ export function SetupDialog({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
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) {
|
if (keyMenuOpen) {
|
||||||
setKeyMenuOpen(false);
|
setKeyMenuOpen(false);
|
||||||
return;
|
return;
|
||||||
@@ -319,10 +325,10 @@ export function SetupDialog({
|
|||||||
if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="${triggerTestid}"]`)) return;
|
if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="${triggerTestid}"]`)) return;
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
document.addEventListener("pointerdown", onPointerDown);
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
document.removeEventListener("keydown", onKey);
|
||||||
document.removeEventListener("pointerdown", onPointerDown);
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
};
|
};
|
||||||
}, [open, onClose, openMenu, keyMenuOpen]);
|
}, [open, onClose, openMenu, keyMenuOpen]);
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ export function StatsBar({
|
|||||||
if (barRef.current && !barRef.current.contains(e.target as Node)) setOpen(false);
|
if (barRef.current && !barRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
};
|
};
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
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("pointerdown", onPointerDown);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|||||||
Reference in New Issue
Block a user