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.
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Camera, Flag, Square } from "lucide-react";
|
|
|
|
import { api, type DebugSession, 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";
|
|
import { dockUnderToasts } from "../lib/toastDock";
|
|
import { useMe } from "../lib/useMe";
|
|
import { Button } from "./ui/Button";
|
|
import Modal from "./ui/Modal";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { inputCls } from "./ui/form";
|
|
import IssueComposer from "./IssueComposer";
|
|
import { useDebugRecording } from "./useDebugRecording";
|
|
|
|
// The debug recorder (E16): Start → reproduce → Stop.
|
|
//
|
|
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
|
|
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
|
|
// 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.
|
|
|
|
/** 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);
|
|
}, []);
|
|
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
|
|
}
|
|
|
|
export default function DebugRecorder() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const me = useMe();
|
|
const { session } = useDebugRecording();
|
|
const startOpen = useRecorderStartOpen();
|
|
const composing = useComposerSession();
|
|
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.
|
|
const until = session?.until;
|
|
const minutes = me?.diagnostics?.session_minutes;
|
|
useEffect(() => {
|
|
if (!id || !until) return;
|
|
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
|
const timer = window.setTimeout(() => {
|
|
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
|
|
openComposer(id);
|
|
void qc.invalidateQueries({ queryKey: qk.me() });
|
|
}, wait);
|
|
return () => window.clearTimeout(timer);
|
|
}, [id, until, minutes, qc, t]);
|
|
|
|
return (
|
|
<>
|
|
{startOpen && <StartDialog />}
|
|
{composing && <IssueComposer id={composing} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StartDialog() {
|
|
const { t } = useTranslation();
|
|
const me = useMe();
|
|
const { start } = useDebugRecording();
|
|
const [level, setLevel] = useState<DebugSessionLevel>("DEBUG");
|
|
const days = me.diagnostics.retention_days;
|
|
const minutes = me.diagnostics.session_minutes;
|
|
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
|
|
return (
|
|
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
|
|
<div className="flex flex-col items-start gap-1.5">
|
|
<span className="text-sm text-muted">{t("recorder.start.level")}</span>
|
|
<SegmentedToggle
|
|
size="md"
|
|
ariaLabel={t("recorder.start.level")}
|
|
options={levels.map((l) => ({
|
|
value: l,
|
|
label: t(`settings.diagnostics.levels.${l}`),
|
|
}))}
|
|
value={level}
|
|
onChange={setLevel}
|
|
/>
|
|
</div>
|
|
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
|
|
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
|
|
<p>{t(`recorder.start.what.${level}`)}</p>
|
|
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
|
|
<p className="text-muted">
|
|
{t("recorder.start.kept", { count: days })}{" "}
|
|
{t("recorder.start.cap", { count: minutes })}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={closeRecorderStart}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
loading={start.isPending}
|
|
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
|
onClick={() => start.mutate(level)}
|
|
>
|
|
{t("recorder.start.confirm")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** 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, stopping, shooting, markStep, screenshot } = useDebugRecording();
|
|
const [hidden, setHidden] = useState(false);
|
|
const stepOpen = useStepPrompt() !== null;
|
|
const stepButton = useRef<HTMLButtonElement>(null);
|
|
const steps = useStepCount(session?.id ?? "");
|
|
if (!session) return null;
|
|
return (
|
|
<div
|
|
role="group"
|
|
aria-label={t("recorder.pill.label")}
|
|
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
|
|
hidden ? "invisible" : ""
|
|
}`}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="w-2.5 h-2.5 shrink-0 rounded-full bg-danger ring-4 ring-danger/20 motion-safe:animate-pulse"
|
|
/>
|
|
<Elapsed since={session.started_at} />
|
|
<span className="chip hidden sm:inline-flex px-2 py-0.5 text-2xs">
|
|
{t(`settings.diagnostics.levels.${session.level}`)}
|
|
</span>
|
|
<span aria-hidden className="hidden sm:block w-px h-6 bg-border" />
|
|
<Button
|
|
ref={stepButton}
|
|
variant="outline-accent"
|
|
size="sm"
|
|
className="rounded-full"
|
|
icon={<Flag className="w-4 h-4" />}
|
|
label={t("recorder.pill.step")}
|
|
aria-expanded={stepOpen}
|
|
onClick={() => setStepPrompt(stepOpen ? null : "")}
|
|
>
|
|
<span className="hidden sm:inline tabular-nums">
|
|
{t("recorder.pill.step")}
|
|
{steps ? ` · ${steps}` : ""}
|
|
</span>
|
|
</Button>
|
|
{canCaptureTab() && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="rounded-full"
|
|
icon={<Camera className="w-4 h-4" />}
|
|
label={t("recorder.pill.screenshot")}
|
|
loading={shooting}
|
|
onClick={() => void screenshot(setHidden)}
|
|
>
|
|
<span className="hidden sm:inline tabular-nums">
|
|
{t("recorder.pill.screenshot")}
|
|
{session.screenshots ? ` · ${session.screenshots}` : ""}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
className="rounded-full"
|
|
icon={<Square className="w-3.5 h-3.5 fill-current" />}
|
|
label={t("recorder.pill.stop")}
|
|
loading={stopping}
|
|
onClick={() => stop.mutate(session.id)}
|
|
>
|
|
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
|
|
</Button>
|
|
{stepOpen && (
|
|
<StepPrompt
|
|
id={session.id}
|
|
onDone={(label) => {
|
|
if (label !== null) markStep(label);
|
|
setStepPrompt(null);
|
|
stepButton.current?.focus();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Elapsed({ since }: { since: string }) {
|
|
const { t } = useTranslation();
|
|
const time = useElapsed(since);
|
|
return (
|
|
// On a phone only the clock: the pill has to fit the content column beside the rail, and the
|
|
// red dot already says what it is.
|
|
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
|
|
<span className="hidden sm:inline">{t("recorder.pill.recording", { time })}</span>
|
|
<span className="sm:hidden">{time}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */
|
|
export function RecorderSettings() {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<RecorderControl />
|
|
<SessionHistory />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RecorderControl() {
|
|
const { t } = useTranslation();
|
|
const { session, stop, stopping } = useDebugRecording();
|
|
if (!session)
|
|
return (
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-sm text-muted max-w-prose">{t("recorder.settings.hint")}</p>
|
|
<Button
|
|
variant="outline"
|
|
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
|
onClick={openRecorderStart}
|
|
>
|
|
{t("recorder.settings.start")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
return (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3 py-2 text-sm">
|
|
<span className="inline-flex items-center gap-2">
|
|
<span className="h-2 w-2 shrink-0 rounded-full bg-danger" aria-hidden />
|
|
<SettingsElapsed since={session.started_at} />
|
|
</span>
|
|
<Button
|
|
variant="danger-soft"
|
|
size="sm"
|
|
loading={stopping}
|
|
onClick={() => stop.mutate(session.id)}
|
|
>
|
|
{t("recorder.settings.stop")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsElapsed({ since }: { since: string }) {
|
|
const { t } = useTranslation();
|
|
const time = useElapsed(since);
|
|
return <span className="tabular-nums">{t("recorder.settings.recording", { time })}</span>;
|
|
}
|
|
|
|
/** "What did you just do?" — optional; Enter marks the step, Escape leaves it unmarked. */
|
|
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
|
|
const { t } = useTranslation();
|
|
const n = useStepCount(id) + 1;
|
|
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. It
|
|
// OWNS the keyboard while it is up (an autofocused field does), so the page's own keys stay quiet.
|
|
// It does NOT take the Tab, though: it is a field inside whatever dialog the pill is docked in,
|
|
// not a dialog of its own, and the trap belongs to that one — it spans the prompt as it spans the
|
|
// rest of the toast host. Claiming it here switched the dialog's off and left every control
|
|
// OUTSIDE this little box untrapped (deep-review round 3).
|
|
useLayer(true, () => onDone(null), { trapsTab: false });
|
|
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
onDone(label);
|
|
}
|
|
};
|
|
return (
|
|
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
|
|
<label htmlFor="debug-step-label" className="text-sm text-muted">
|
|
{t("recorder.pill.stepPrompt", { n })}
|
|
</label>
|
|
<input
|
|
id="debug-step-label"
|
|
autoFocus
|
|
value={label}
|
|
onChange={(e) => setStepPrompt(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
maxLength={200}
|
|
className={`${inputCls} w-full`}
|
|
/>
|
|
<div className="flex items-center justify-between gap-2 text-xs text-muted">
|
|
<span>{t("recorder.pill.stepHint")}</span>
|
|
<Button variant="primary" size="sm" onClick={() => onDone(label)}>
|
|
{t("recorder.pill.mark")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** What a past session reads as. An issue that still owes the tracker its attachments, or an edit
|
|
* that shows its screenshots, is NOT "Sent": this list is where a user goes back to check, and a
|
|
* green chip there is how a half-delivered report gets forgotten. */
|
|
function historyState(s: DebugSession): string {
|
|
// A send under way outranks every other word here: this is the list a user comes back to in
|
|
// order to check on one, and both "draft" (no issue yet) and "half sent" (the steps it has
|
|
// already committed) would describe a send that STOPPED.
|
|
if (s.sending) return "recorder.history.sending";
|
|
if (!s.issue_url) return s.draft ? "recorder.history.draft" : "recorder.history.unwritten";
|
|
return s.pending_files.length > 0 || s.images_pending
|
|
? "recorder.history.partial"
|
|
: "recorder.history.sent";
|
|
}
|
|
|
|
const chipTone = (s: DebugSession): string => {
|
|
const state = historyState(s);
|
|
if (state === "recorder.history.sent") return "text-success";
|
|
if (state === "recorder.history.sending") return "text-accent";
|
|
return state === "recorder.history.partial" ? "text-warn" : "";
|
|
};
|
|
|
|
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
|
|
function SessionHistory() {
|
|
const { t, i18n } = useTranslation();
|
|
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
|
|
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
|
|
if (q.isError && !q.data)
|
|
return (
|
|
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
|
|
{t("recorder.history.failed")}
|
|
</StateMessage>
|
|
);
|
|
if (!q.data) return <LoadingState />;
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="text-sm text-muted">{t("recorder.history.title")}</div>
|
|
{items.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("recorder.history.empty")}</p>
|
|
) : (
|
|
<ul className="flex flex-col divide-y divide-border/60 rounded-xl border border-border">
|
|
{items.map((s) => (
|
|
<li
|
|
key={s.id}
|
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
|
>
|
|
<span className="tabular-nums">
|
|
{new Date(s.started_at).toLocaleString(i18n.language)}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-muted">
|
|
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
|
|
</span>
|
|
<span className={`chip px-2 py-0.5 text-2xs ${chipTone(s)}`}>
|
|
{t(historyState(s))}
|
|
</span>
|
|
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
|
|
{t("recorder.history.open")}
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|