Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
413 lines
16 KiB
TypeScript
413 lines
16 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);
|
|
}, []);
|
|
// 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 && <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>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<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>
|
|
);
|
|
}
|