fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself

The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.

Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.

Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
This commit is contained in:
2026-09-20 00:09:52 +02:00
parent 9cf177b198
commit 134ec9a68e
25 changed files with 1007 additions and 208 deletions
+10
View File
@@ -52,6 +52,10 @@ const DETAIL = { INFO: 0, DEBUG: 1, TRACE: 2 } as const;
let applied: string | null = null;
let lapseTimer: number | null = null;
// A recording's last seconds are sent BEFORE its cap: at the cap the server stops tagging (or, at the
// base level, taking) them, and the regular flush could be up to FLUSH_MS behind.
const CAP_FLUSH_LEAD_MS = 1_500;
let capFlushTimer: number | null = null;
let onLapsed: (() => void) | null = null;
let lastState: DiagnosticsState | null | undefined = null;
let lastSession: DebugSession | null | undefined = null;
@@ -79,6 +83,8 @@ export function applyDiagnostics(
applied = key;
if (lapseTimer !== null) window.clearTimeout(lapseTimer);
lapseTimer = null;
if (capFlushTimer !== null) window.clearTimeout(capFlushTimer);
capFlushTimer = null;
if (level === "INFO") {
stopStreaming();
setLevel("INFO");
@@ -99,6 +105,10 @@ export function applyDiagnostics(
const ends = [until, live?.until].filter((t): t is string => !!t).map((t) => Date.parse(t));
const wait = Math.min(Math.min(...ends) - Date.now(), 2 ** 31 - 1);
lapseTimer = window.setTimeout(lapse, wait);
if (live) {
const lead = Date.parse(live.until) - CAP_FLUSH_LEAD_MS - Date.now();
if (lead > 0 && lead < wait) capFlushTimer = window.setTimeout(() => void flush(), lead);
}
}
/** Something in force ran out: apply what is left of it (the clock decides), then let `me` catch up. */