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:
+52
-3
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { escAbortTarget } from "../components/chat.tsx";
|
||||
|
||||
/**
|
||||
* THE LAYERED ESC (owner report, 2026-10-04): Esc over an open overlay —
|
||||
* a mermaid focus, the plan popup, the setup dialog — must CLOSE THE
|
||||
* OVERLAY, never dismiss a pending question (or abort a turn) underneath.
|
||||
* KeyboardShortcuts dispatches lh:kbd-abort-turn on every unclaimed Esc
|
||||
* and its window listener registers FIRST, so the overlays' own listeners
|
||||
* can never outrun it — the state gate in ChatView (this ruling, exported
|
||||
* pure) is the defense. Priority = stacking order.
|
||||
*/
|
||||
describe("escAbortTarget (the layered-Esc ruling)", () => {
|
||||
const none = { abortAsk: false, mermaidFocus: false, planOpen: false, setupOpen: false, pending: false, busy: false };
|
||||
|
||||
it("no overlays: the 2026-10-02 chain stands (question before turn)", () => {
|
||||
assert.equal(escAbortTarget({ ...none, pending: true }), "question");
|
||||
assert.equal(escAbortTarget({ ...none, busy: true }), "turn");
|
||||
assert.equal(escAbortTarget(none), null);
|
||||
assert.equal(escAbortTarget({ ...none, pending: true, busy: true }), "question", "a pending question outranks the turn");
|
||||
});
|
||||
|
||||
it("an open overlay claims Esc over a pending question — the owner's report", () => {
|
||||
for (const overlay of ["mermaidFocus", "planOpen", "setupOpen"] as const) {
|
||||
const target = escAbortTarget({ ...none, [overlay]: true, pending: true });
|
||||
assert.notEqual(target, "question", `${overlay} open must not dismiss the question`);
|
||||
assert.notEqual(target, "turn", `${overlay} open must not abort the turn`);
|
||||
}
|
||||
assert.equal(escAbortTarget({ ...none, mermaidFocus: true, pending: true }), "mermaid");
|
||||
assert.equal(escAbortTarget({ ...none, planOpen: true, pending: true }), "plan");
|
||||
assert.equal(escAbortTarget({ ...none, setupOpen: true, pending: true }), "setup");
|
||||
});
|
||||
|
||||
it("the abort confirm is topmost — its own Esc is claimed by the dialog", () => {
|
||||
assert.equal(
|
||||
escAbortTarget({ ...none, abortAsk: true, mermaidFocus: true, pending: true }),
|
||||
null,
|
||||
"never chain: the dialog owns that Esc",
|
||||
);
|
||||
});
|
||||
|
||||
it("stacking order among overlays: mermaid paints above plan (later in DOM)", () => {
|
||||
assert.equal(escAbortTarget({ ...none, mermaidFocus: true, planOpen: true, pending: true }), "mermaid");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user