layered Esc: an open overlay claims Esc before the abort chain (owner 2026-10-04)

Esc over a mermaid focus, the plan popup, or the setup dialog opened the
question-dismiss confirm underneath — KeyboardShortcuts' window listener
registers first, so the overlays' own self-closing Esc listeners could
never outrun the lh:kbd-abort-turn dispatch. The state gate in ChatView
is the defense (the architecture's own pattern from the abort dialog):
escAbortTarget, exported pure, rules priority by stacking order — abort
confirm (claimed by its dialog) > mermaid (paints above plan) > plan >
setup (self-closing) > question > turn. The gate closes the overlay; the
overlays' own listeners stay (idempotent). Pinned in esc-layering.test.
This commit is contained in:
amosblomqvist
2026-10-04 18:39:34 +03:00
parent 17a497a87c
commit 5653a394ac
2 changed files with 98 additions and 3 deletions
+52 -3
View File
@@ -306,14 +306,28 @@ export function Chat({
// popup EVERYWHERE (streams and pending questions alike). Both window
// listeners fire per Esc, so the state gate here — not stopPropagation
// ordering — is what keeps the popup's own Esc from chaining.
// LAYERED ESC (owner, 2026-10-04): an open overlay CLAIMS Esc — closing
// itself — before the abort chain may open its confirm. A mermaid focus,
// the plan popup, or the setup dialog over a pending question must not
// dismiss the question underneath.
const [abortAsk, setAbortAsk] = useState<AbortMode | null>(null);
const abortAskRef = useRef<AbortMode | null>(null);
abortAskRef.current = abortAsk;
useEffect(() => {
const onAbort = () => {
if (abortAskRef.current !== null) return; // the popup's Esc — claimed
if (pendingRef.current) setAbortAsk("question");
else if (busyRef.current) setAbortAsk("turn");
const target = escAbortTarget({
abortAsk: abortAskRef.current !== null,
mermaidFocus: mermaidFocusRef.current !== null,
planOpen: planOpenRef.current,
setupOpen: setupOpenRef.current,
pending: pendingRef.current !== null,
busy: busyRef.current,
});
if (target === "question") setAbortAsk("question");
else if (target === "turn") setAbortAsk("turn");
else if (target === "mermaid") setMermaidFocus(null);
else if (target === "plan") setPlanOpen(false);
// setup closes itself; null = nothing to do — both claimed.
};
window.addEventListener("lh:kbd-abort-turn", onAbort);
return () => window.removeEventListener("lh:kbd-abort-turn", onAbort);
@@ -648,6 +662,15 @@ export function Chat({
// popup) mount INSIDE the session-area div — both are session-scoped, so
// the drag bar stays sharp and draggable while a focus owns the area.
const [mermaidFocus, setMermaidFocus] = useState<string | null>(null);
// Overlay mirrors for the layered-Esc gate (owner 2026-10-04): the
// lh:kbd-abort-turn handler reads these at EVENT time — declared here
// beside their states, consumed below wherever the states live.
const mermaidFocusRef = useRef(mermaidFocus);
mermaidFocusRef.current = mermaidFocus;
const planOpenRef = useRef(planOpen);
planOpenRef.current = planOpen;
const setupOpenRef = useRef(setupOpen);
setupOpenRef.current = setupOpen;
// The session's live plan (drives the top-bar chip; the marginalia
// derives its own from the same transcript).
const planTree = useMemo(() => latestTree(messages), [messages]);
@@ -1395,6 +1418,32 @@ export function Chat({
* nothing left to wait for and the scroll height it already had is the
* honest height.
*/
/** The layered-Esc ruling (owner, 2026-10-04): an open overlay claims Esc —
* closing itself — before the abort chain may open its confirm. A mermaid
* focus, the plan popup, or the setup dialog over a pending question must
* close the OVERLAY, never dismiss the question underneath. Priority is
* stacking order: the abort confirm (topmost, claims via its own dialog),
* then the session overlays (mermaid paints above plan — later in DOM),
* then setup (self-closing — the gate only must not fire), then the
* 2026-10-02 abort chain proper (question before turn). */
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "turn" | null;
export function escAbortTarget(s: {
abortAsk: boolean;
mermaidFocus: boolean;
planOpen: boolean;
setupOpen: boolean;
pending: boolean;
busy: boolean;
}): EscAbortTarget {
if (s.abortAsk) return null; // the popup's own Esc — claimed by the dialog
if (s.mermaidFocus) return "mermaid";
if (s.planOpen) return "plan";
if (s.setupOpen) return "setup";
if (s.pending) return "question";
if (s.busy) return "turn";
return null;
}
export function revealArrival(wasQuestion: boolean): { expanded: boolean; revealed: boolean } {
return wasQuestion ? { expanded: true, revealed: true } : { expanded: false, revealed: false };
}