fix(diagnostics): deep-review round 2 — a send that claims, not one that blocks

The send no longer holds the session's row lock while it talks to the tracker:
it claims the session, gives the lock back, and checkpoints each step. A Stop
is never held behind a send, while a draft, a screenshot or a second send are
refused at once instead of queueing. The claim ages out, so a request that
died mid-send does not strand the session.

What the composer says is what happened: an attachment that went is no longer
named as one that did not (the missing body edit is its own line), and the
issue's screenshot count comes from the server when the list cannot be read,
so a failed read no longer blocks saving the draft. The zip is fetched as a
request, so its pace is a message and not a saved error page. Screenshots of a
finished report can be removed again; only one still being sent is held.

A recording's log elevation can no longer outlive it. The step prompt keeps
the keyboard (the page's keys stay quiet) and traps Tab itself. An image
pasted into a dialog opened over the composer stays that dialog's.
This commit is contained in:
2026-09-20 00:51:12 +02:00
parent 134ec9a68e
commit 3f983e6d76
17 changed files with 347 additions and 126 deletions
@@ -38,6 +38,7 @@ const { order, session, startDebugSession, stopDebugSession, hold } = vi.hoisted
ended_reason: null,
issue_url: null,
pending_files: [],
images_pending: false,
screenshots: 0,
draft: null,
...over,
@@ -79,6 +80,7 @@ import {
setStepPrompt,
useComposerSession,
} from "../lib/debugRecorder";
import { anyBlockingLayer } from "../lib/layerStack";
import { events } from "../lib/log";
beforeEach(() => {
@@ -215,6 +217,17 @@ describe("the recorder pill", () => {
expect(unhandled).toBe(false);
});
it("the step prompt owns the keyboard: the page's keys stay quiet and Tab stays in it", () => {
mount({ debug_session: session() });
expect(anyBlockingLayer()).toBe(false);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
// No dialog under it here: the prompt itself is what keeps `j`/`g h` off the page behind.
expect(anyBlockingLayer()).toBe(true);
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
mark.focus();
expect(fireEvent.keyDown(mark, { key: "Tab" })).toBe(false); // trapped in the prompt
});
it("the step prompt keeps what was typed when the pill moves to a dialog and back", () => {
mount({ debug_session: session() }, <DialogToggle />);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));