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 IssueComposer from "./IssueComposer"; import { useDebugRecording } from "./useDebugRecording"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; // 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); }, []); // eslint-disable-next-line react-hooks/purity -- the clock is READ at render and the interval above only re-renders — a `now` held in state is the moment the component mounted, which is the defect DiagnosticsSettings records from prod UAT. 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 && } {composing && } ); } function StartDialog() { const { t } = useTranslation(); const me = useMe(); const { start } = useDebugRecording(); const [level, setLevel] = useState("DEBUG"); const days = me.diagnostics.retention_days; const minutes = me.diagnostics.session_minutes; const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"]; return (

{t("recorder.start.intro")}

{t("recorder.start.level")} ({ value: l, label: t(`settings.diagnostics.levels.${l}`), }))} value={level} onChange={setLevel} />
{t("recorder.start.whatTitle")}

{t(`recorder.start.what.${level}`)}

{t("settings.diagnostics.consent.never")}

{t("recorder.start.kept", { count: days })}{" "} {t("recorder.start.cap", { count: minutes })}

); } /** 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(null); const steps = useStepCount(session?.id ?? ""); if (!session) return null; return ( ); } 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. {t("recorder.pill.recording", { time })} {time} ); } /** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */ export function RecorderSettings() { return (
); } function RecorderControl() { const { t } = useTranslation(); const { session, stop, stopping } = useDebugRecording(); if (!session) return (

{t("recorder.settings.hint")}

); return (
); } function SettingsElapsed({ since }: { since: string }) { const { t } = useTranslation(); const time = useElapsed(since); return {t("recorder.settings.recording", { time })}; } /** "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) => { if (e.key === "Enter") { e.preventDefault(); onDone(label); } }; return (
setStepPrompt(e.target.value)} onKeyDown={onKeyDown} maxLength={200} />
{t("recorder.pill.stepHint")}
); } /** The five words a past session can read as. A union, not `string`, because `chipTone` below * matches on these same keys: untyped, a rename in one of them left the other matching nothing * and every chip untoned, with the gate green (deep-review round 4). */ type HistoryState = | "recorder.history.sending" | "recorder.history.draft" | "recorder.history.unwritten" | "recorder.history.partial" | "recorder.history.sent"; /** 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): HistoryState { // 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"; } /** The colour that reinforces that word. Exhaustive over `HistoryState`, so a key renamed in * `historyState` is a compile error here rather than a chip that quietly loses its tone. */ const chipTone = (s: DebugSession): string => { switch (historyState(s)) { case "recorder.history.sent": return "text-success"; case "recorder.history.sending": return "text-accent"; case "recorder.history.partial": return "text-warn"; case "recorder.history.draft": case "recorder.history.unwritten": return ""; } }; /** 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 ( void q.refetch()}> {t("recorder.history.failed")} ); if (!q.data) return ; return (
{t("recorder.history.title")}
{items.length === 0 ? (

{t("recorder.history.empty")}

) : (
    {items.map((s) => (
  • {new Date(s.started_at).toLocaleString(i18n.language)} {s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)} {t(historyState(s))}
  • ))}
)}
); }