Files
siftlode/frontend/src/components/useDebugRecording.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

125 lines
4.8 KiB
TypeScript

import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { captureTab } from "../lib/captureTab";
import {
announceSessionChange,
closeRecorderStart,
nextStep,
openComposer,
setStepPrompt,
} from "../lib/debugRecorder";
import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { updateMeCache, useMe } from "../lib/useMe";
/** The debug recording's actions, shared by everything that drives it: the pill, Settings →
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
* answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const session = liveSession(me?.debug_session);
const setSession = (next: DebugSession | null) => {
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
announceSessionChange();
setStepPrompt(null); // a prompt left open belongs to the session that ended
};
const start = useMutation({
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
meta: { failTitle: "recorder.start.failed" },
onSuccess: (r) => {
setSession(r.session);
closeRecorderStart();
},
});
const stop = useMutation({
mutationKey: STOP_KEY,
mutationFn: async (id: string) => {
log.info("debug.stop", "recording stopped by the user");
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
// while the session still tags them: once it is closed the server no longer does.
await flushNow();
return api.stopDebugSession(id);
},
meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => {
setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") });
openComposer(r.session.id);
},
});
const shoot = useMutation({
mutationKey: SHOOT_KEY,
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
api.addDebugScreenshot(id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: (r) => {
updateMeCache(qc, (prev) =>
prev.debug_session
? {
...prev,
debug_session: {
...prev.debug_session,
screenshots: prev.debug_session.screenshots + 1,
},
}
: prev,
);
// In the evidence too, where the step list is drafted from: which step it belongs to.
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
notify({ level: "success", message: t("recorder.pill.shotSaved") });
},
});
// In flight anywhere, not only through this hook's own observer: the pill remounts when it moves
// to another toast host, and a Stop still running must keep its button busy there.
const stopping = useIsMutating({ mutationKey: STOP_KEY }) > 0;
const shooting = useIsMutating({ mutationKey: SHOOT_KEY }) > 0;
/** Mark the next step; `label` is what the user says they just did (may be empty). */
const sessionId = session?.id;
const markStep = useCallback(
(label: string) => {
if (!sessionId) return;
const n = nextStep(sessionId);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
},
[sessionId, t],
);
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
* frame. Nothing happens when the user cancels the browser's picker. */
const screenshot = async (hide: (hidden: boolean) => void) => {
if (!session) return;
hide(true);
let image: Blob | null = null;
try {
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab();
} catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed"));
}
hide(false);
if (image) shoot.mutate({ id: session.id, image });
};
return { session, start, stop, stopping, shoot, shooting, markStep, screenshot };
}
const STOP_KEY = ["debug-session", "stop"];
const SHOOT_KEY = ["debug-session", "screenshot"];