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"];