fix(diagnostics): deep-review round 3 — a keyboard owner that is not a Tab trap

Round 2 made the step prompt a blocking layer so that `j` and the `g` chords
would not reach the page behind it. The dialog it sits in gave up its Tab trap
because of that, while the prompt's own replacement trap covered only its own
two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or
Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now
separates the two questions it had folded into one flag: a layer can own the
keyboard and still leave the Tab trap to the dialog below it. The mirror case,
a dialog opening over the prompt, falls out of the same split.

A send's claim is now a lease with an owner. Every progress report re-stamps
it, so a send slower than the lease cannot lose it while it is still running,
and only the send that took a claim can give it back. The issue_create audit
row is staged inside the send and rides the same checkpoint as `issue_url`, so
the trail cannot be missing for an issue that exists. The attachment-name
prefix has one definition, shared by the producer and both readers.

A running send is reported to the UI. A reload or a second tab used to render
it exactly like a send that STOPPED, and offered a retry the server was certain
to refuse; the composer now says the report is being sent, holds the draft, and
puts both the retry and Create issue out. The past-sessions list says the same
for the same reason — it is the list a user opens to check on a send, and both
"draft" and "half sent" describe one that died. `/preview` and the retry path
got a pace of their own, and the per-user byte cap took the user-scoped lock
that makes it a cap instead of a guess.

Verified in a real browser on localdev: Tab cycles the whole dialog with the
prompt open, the prompt still swallows the page's keys, and the issue link is
focusable where the composer puts it. All 24 regression tests mutation-checked.
This commit is contained in:
2026-09-20 02:43:56 +02:00
parent bc03e82687
commit 2fe58073e4
20 changed files with 718 additions and 137 deletions
+34
View File
@@ -216,6 +216,40 @@ describe("req — a structured refusal reads in the user's language", () => {
);
});
describe("req — a file, not a parsed answer", () => {
it("hands back the response itself so the caller can read it as a blob", async () => {
// A zip. Without the `raw` branch this falls into `r.json()`, throws on the first byte, and the
// download dies as an opaque failure — which no lane would see, because the only consumer test
// mocks the api method wholesale.
const zip = new Response(new Blob(["PK"]), {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition": 'attachment; filename="siftlode-debug-260919.zip"',
},
});
vi.spyOn(globalThis, "fetch").mockResolvedValue(zip);
const r = await req<Response>("/api/debug-sessions/abc/export", {}, { raw: true });
expect(r).toBe(zip);
expect(r.headers.get("content-disposition")).toContain("siftlode-debug-260919.zip");
expect(await r.blob()).toBeInstanceOf(Blob); // the body is still there to be read
});
it("still refuses before the body is touched, so a refusal is shown and not saved", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "refused", reason: "export_rate_limited" } }), {
status: 429,
headers: { "content-type": "application/json" },
}),
);
await expect(
req<Response>("/api/debug-sessions/abc/export", {}, { raw: true, quiet: true }),
).rejects.toMatchObject({ status: 429, detail: "errors.refused.export_rate_limited" });
});
});
describe("req — what it has already put in front of the user", () => {
// A caller that toasts a failed action on top of the dialog `req` already opened reported one fault
// twice. `alreadyReported` is how `notifyActionError` tells the two apart.
+5 -3
View File
@@ -360,9 +360,11 @@ export async function req<T = unknown>(
reportFailure(err, !!cfg.quiet);
throw err;
}
// A file (`cfg.raw`): the answer itself, for the caller to read as a blob. A download that went
// out as a plain `<a href>` instead would carry no refusal the app could show — the browser
// would simply save the error.
// A file (`cfg.raw`): the answer itself, for the caller to read as a blob. For a SMALL generated
// file whose endpoint can refuse (a report, an export): sent as a plain `<a href>` it would
// carry no refusal the app could show — the browser would simply save the error page. A large
// stored file stays a plain navigation (the download centre, the Plex original), so the browser
// streams it to disk instead of buffering gigabytes into the tab.
if (cfg.raw) return r as T;
// The single audited cast in this layer: the server contract is asserted here, once, so
// every typed api.* method (T comes from its declared return type) is checked from here up.
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
//
// The zip export is the one api method that reads a RAW response instead of a parsed answer, so it
// carries contract of its own: the bytes, and the name the server picked. The composer's DOM test
// mocks this method wholesale, so without this the blob and the filename parse ship untested — and
// a broken header regex would silently save every report under the fallback name.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
vi.mock("../notifications", () => ({
notify: vi.fn(() => 1),
remove: vi.fn(),
getActiveToasts: vi.fn((): { id: number }[] => []),
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { debugSessionsApi } from "./diagnostics";
const zip = (disposition?: string) =>
new Response("PK", {
status: 200,
headers: {
"content-type": "application/zip",
...(disposition ? { "content-disposition": disposition } : {}),
},
});
beforeEach(() => vi.restoreAllMocks());
describe("debugExport", () => {
it("hands back the bytes and the name the server chose", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
zip('attachment; filename="siftlode-debug-20260919-1402-abc123.zip"'),
);
const out = await debugSessionsApi.debugExport("0123456789abcdef");
expect(out.name).toBe("siftlode-debug-20260919-1402-abc123.zip");
// The body is read as bytes, not parsed: `r.json()` on a zip would throw on its first one.
expect(out.blob).toBeInstanceOf(Blob);
expect(out.blob.size).toBe(4);
});
it("names the file itself when the answer does not", async () => {
// A proxy that strips the header must not leave the browser saving something called "export".
vi.spyOn(globalThis, "fetch").mockResolvedValue(zip());
const out = await debugSessionsApi.debugExport("0123456789abcdef");
expect(out.name).toBe("siftlode-debug-0123456789abcdef.zip");
});
});
+3
View File
@@ -57,6 +57,9 @@ export interface DebugSession {
pending_files: string[];
/** Whether the issue's body has still to be edited to show the screenshots it already carries. */
images_pending: boolean;
/** Whether a send is under way RIGHT NOW. Every step of a send commits its progress, so without
* this a reload or a second tab reads a running send as a failed one. */
sending: boolean;
screenshots: number;
/** The issue being written from it (the composer saves as it goes); null until it first does. */
draft: DebugDraft | null;
@@ -39,6 +39,7 @@ const recording = (level: "DEBUG" | "TRACE", minutes = 60, over = {}) => ({
issue_url: null,
pending_files: [],
images_pending: false,
sending: false,
screenshots: 0,
draft: null,
...over,
+36
View File
@@ -12,6 +12,8 @@ import {
pushLayer,
removeLayer,
anyLayerAbove,
anyBlockingLayer,
anyBlockingLayerAbove,
isTopmost,
markEscapeHandled,
escapeHandled,
@@ -77,3 +79,37 @@ describe("layerStack Escape coordination (UX-8)", () => {
expect(escapeHandled(second)).toBe(false);
});
});
// A layer answers TWO questions, and round 3 found they are not the same one: "do the page's own
// keys stay quiet?" and "do I take the Tab trap from the dialog below?". The recorder's step prompt
// is the case that separates them — a field inside whatever dialog the pill is docked in, so it
// owns the keyboard but its two controls are no trap for that dialog's many.
describe("owning the keyboard and owning the Tab trap", () => {
it("an inline prompt silences the page and still leaves the dialog its trap", () => {
const dialog = open();
const prompt = pushLayer({ trapsTab: false });
opened.push(prompt);
expect(anyBlockingLayer()).toBe(true); // `j`, `g h` and the rest stay off the page behind
expect(anyBlockingLayerAbove(dialog)).toBe(false); // ...and the dialog keeps trapping Tab
});
it("a real dialog above does take the trap from the one below", () => {
const dialog = open();
const above = open();
expect(anyBlockingLayerAbove(dialog)).toBe(true);
expect(anyBlockingLayerAbove(above)).toBe(false);
});
it("a passive layer takes neither: a hover card must silence nothing", () => {
const card = pushLayer({ passive: true });
opened.push(card);
expect(anyBlockingLayer()).toBe(false); // `j` still moves the list under a resting pointer
const dialog = open();
const over = pushLayer({ passive: true });
opened.push(over);
expect(anyBlockingLayerAbove(dialog)).toBe(false); // ...and it is no trap either
});
});
+21 -9
View File
@@ -20,17 +20,25 @@ let nextId = 1;
// stack for Escape ordering like any other layer, but `anyBlockingLayer` looks past them — otherwise
// a tooltip that happens to be open under a resting mouse pointer would silence the page's `j`/`k`.
const passive = new Set<number>();
// Layers that DO own the keyboard but leave the Tab trap to the dialog below: a small inline prompt
// inside one (the recorder's step field). It must silence the page's keys — an autofocused field
// does — yet it is not a dialog of its own, so claiming the trap left the dialog around it with
// none, and Tab from anything outside the prompt walked into the page behind the scrim. The two
// questions are separate; a layer answers each for itself (deep-review round 3).
const defers = new Set<number>();
export function pushLayer(opts?: { passive?: boolean }): number {
export function pushLayer(opts?: { passive?: boolean; trapsTab?: boolean }): number {
const id = nextId++;
stack.push(id);
if (opts?.passive) passive.add(id);
if (opts?.trapsTab === false) defers.add(id);
return id;
}
export function removeLayer(id: number): void {
stack = stack.filter((x) => x !== id);
passive.delete(id);
defers.delete(id);
}
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
@@ -51,12 +59,13 @@ export function anyLayerAbove(id: number): boolean {
return i !== -1 && i < stack.length - 1;
}
/** True when a layer that owns the keyboard sits above `id` — the predicate for a dialog's Tab trap.
* A tooltip or hover card open under a resting pointer takes Escape first, but not the Tab: deferring
* the trap to it let Shift+Tab walk out of the dialog into the page behind its scrim. */
/** True when a layer that TRAPS THE TAB sits above `id` — the predicate for a dialog's Tab trap. A
* tooltip open under a resting pointer (passive) and an inline prompt inside the dialog itself
* (`trapsTab: false`) both take Escape first but neither takes the Tab: deferring the trap to one
* of those left Shift+Tab walking out of the dialog into the page behind its scrim. */
export function anyBlockingLayerAbove(id: number): boolean {
const i = stack.indexOf(id);
return i !== -1 && stack.slice(i + 1).some((x) => !passive.has(x));
return i !== -1 && stack.slice(i + 1).some((x) => !passive.has(x) && !defers.has(x));
}
// UX-8 — a layer that ACTS on an Escape stamps the event here; a lower layer with its own key handler
@@ -83,17 +92,20 @@ export function escapeHandled(e: KeyboardEvent): boolean {
export function useLayer(
active: boolean,
onEscape?: () => void,
opts?: { passive?: boolean },
opts?: { passive?: boolean; trapsTab?: boolean },
): { isTopmost: () => boolean; anyAbove: () => boolean; blockingAbove: () => boolean } {
const idRef = useRef<number | null>(null);
const escRef = useRef(onEscape);
escRef.current = onEscape;
const passiveRef = useRef(!!opts?.passive);
passiveRef.current = !!opts?.passive;
const optsRef = useRef(opts);
optsRef.current = opts;
useEffect(() => {
if (!active) return;
const id = pushLayer({ passive: passiveRef.current });
const id = pushLayer({
passive: !!optsRef.current?.passive,
trapsTab: optsRef.current?.trapsTab,
});
idRef.current = id;
let onKey: ((e: KeyboardEvent) => void) | undefined;
if (escRef.current) {