The send, the draft and the screenshots take the session's row lock, so an autosave or a paste during a send waits for it and then sees it, and two sends cannot open two issues. A save keeps the delivery's progress; a failed edit that shows the screenshots is finished by the retry; an answer that is not the tracker's API (a redirect, a proxy page) is refused, not a crash that loses the issue's record. Only an attempt that opened an issue counts toward the pace. Screenshots: deleting one after the send is refused, a user's storage is capped across sessions, new sessions and zip exports are paced, images are stored uncompressed in the zip. One liveness rule, one session view, one image type table. Logging: a session's elevation is tracked apart from the user's own, so the browser's own requests (no header) no longer lower the floor a session needs; Stop ends it at once. Composer: an image pasted anywhere is taken; what is still unsaved is saved on close, and "saved" is shown only when it is; the draft is held while it is sent; a failed screenshot read says so instead of reading as none; focus moves to what stays; the preview follows the text size. Pill: the step prompt and a running Stop survive the pill moving between hosts, the prompt keeps the dialog's Tab trap, the toast region lets clicks through, other tabs hear of a Start or Stop, the last seconds are sent before the cap, a capture with no frame is an error.
126 lines
4.8 KiB
TypeScript
126 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 (E16), 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"));
|
|
} finally {
|
|
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"];
|