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:
@@ -7,12 +7,16 @@ import { api, type DebugSessionLevel } from "../lib/api";
|
||||
import { canCaptureTab } from "../lib/captureTab";
|
||||
import {
|
||||
closeRecorderStart,
|
||||
onSessionChangeElsewhere,
|
||||
openComposer,
|
||||
openRecorderStart,
|
||||
setStepPrompt,
|
||||
useComposerSession,
|
||||
useRecorderStartOpen,
|
||||
useStepCount,
|
||||
useStepPrompt,
|
||||
} from "../lib/debugRecorder";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -33,15 +37,14 @@ import { useDebugRecording } from "./useDebugRecording";
|
||||
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
|
||||
// app shell, for a signed-in account other than the demo.
|
||||
|
||||
/** `m:ss` since `since`, re-rendered every second. */
|
||||
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
|
||||
function useElapsed(since: string): string {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => tick((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const s = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
|
||||
}
|
||||
|
||||
export default function DebugRecorder() {
|
||||
@@ -54,6 +57,11 @@ export default function DebugRecorder() {
|
||||
const id = session?.id;
|
||||
|
||||
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
|
||||
// Started or stopped in another tab: this one asks the server what is in force now.
|
||||
useEffect(
|
||||
() => onSessionChangeElsewhere(() => void qc.invalidateQueries({ queryKey: qk.me() })),
|
||||
[qc],
|
||||
);
|
||||
|
||||
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
|
||||
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
|
||||
@@ -133,9 +141,9 @@ function StartDialog() {
|
||||
/** The pill: how long it has been recording, and the three things to do while it does. */
|
||||
function RecorderPill() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop, shoot, markStep, screenshot } = useDebugRecording();
|
||||
const { session, stop, stopping, shooting, markStep, screenshot } = useDebugRecording();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [stepOpen, setStepOpen] = useState(false);
|
||||
const stepOpen = useStepPrompt() !== null;
|
||||
const stepButton = useRef<HTMLButtonElement>(null);
|
||||
const steps = useStepCount(session?.id ?? "");
|
||||
if (!session) return null;
|
||||
@@ -164,7 +172,7 @@ function RecorderPill() {
|
||||
icon={<Flag className="w-4 h-4" />}
|
||||
label={t("recorder.pill.step")}
|
||||
aria-expanded={stepOpen}
|
||||
onClick={() => setStepOpen((v) => !v)}
|
||||
onClick={() => setStepPrompt(stepOpen ? null : "")}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
{t("recorder.pill.step")}
|
||||
@@ -178,7 +186,7 @@ function RecorderPill() {
|
||||
className="rounded-full"
|
||||
icon={<Camera className="w-4 h-4" />}
|
||||
label={t("recorder.pill.screenshot")}
|
||||
loading={shoot.isPending}
|
||||
loading={shooting}
|
||||
onClick={() => void screenshot(setHidden)}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
@@ -193,7 +201,7 @@ function RecorderPill() {
|
||||
className="rounded-full"
|
||||
icon={<Square className="w-3.5 h-3.5 fill-current" />}
|
||||
label={t("recorder.pill.stop")}
|
||||
loading={stop.isPending}
|
||||
loading={stopping}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
|
||||
@@ -203,7 +211,7 @@ function RecorderPill() {
|
||||
id={session.id}
|
||||
onDone={(label) => {
|
||||
if (label !== null) markStep(label);
|
||||
setStepOpen(false);
|
||||
setStepPrompt(null);
|
||||
stepButton.current?.focus();
|
||||
}}
|
||||
/>
|
||||
@@ -237,7 +245,7 @@ export function RecorderSettings() {
|
||||
|
||||
function RecorderControl() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop } = useDebugRecording();
|
||||
const { session, stop, stopping } = useDebugRecording();
|
||||
if (!session)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -260,7 +268,7 @@ function RecorderControl() {
|
||||
<Button
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
loading={stop.isPending}
|
||||
loading={stopping}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
{t("recorder.settings.stop")}
|
||||
@@ -279,10 +287,13 @@ function SettingsElapsed({ since }: { since: string }) {
|
||||
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const n = useStepCount(id) + 1;
|
||||
const [label, setLabel] = useState("");
|
||||
const label = useStepPrompt() ?? "";
|
||||
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
|
||||
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last.
|
||||
useLayer(true, () => onDone(null));
|
||||
// Passive: it takes Escape, not the Tab — the dialog under it keeps its focus trap, which spans
|
||||
// its toast host and so this prompt too; a blocking layer would switch that trap off and let Tab
|
||||
// walk out of the dialog into the page behind its scrim.
|
||||
useLayer(true, () => onDone(null), { passive: true });
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -298,7 +309,7 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
id="debug-step-label"
|
||||
autoFocus
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onChange={(e) => setStepPrompt(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={200}
|
||||
className={`${inputCls} w-full`}
|
||||
|
||||
Reference in New Issue
Block a user