import { useEffect, useEffectEvent, useMemo, useRef, useState, type DragEvent } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, FileText, Plus, Server, X } from "lucide-react"; import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api"; import { closeComposer } from "../../lib/debugRecorder"; import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format"; import { notify } from "../../lib/notifications"; import { useMe } from "../../lib/useMe"; import { draftSteps, environment, renderIssue, type DraftForm, type DraftStep, type IssueText, } from "../../lib/issueDraft"; import { moduleDef } from "../navigation/moduleRegistry"; import type { Page } from "../../lib/urlState"; import { qk } from "../../lib/queryKeys"; import { focusIsUnclaimed } from "../../lib/useFocusKeeper"; import { LEVEL_TAG, logClock } from "./logLineParts"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; import Modal, { ModalFooter } from "../../components/ui/Modal"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import SegmentedToggle from "../../components/ui/SegmentedToggle"; import { Textarea, Input } from "../../components/ui/Input"; import { Checkbox } from "../../components/ui/NativeControls"; import { SURFACE } from "../../components/ui/surfaces"; import RemoteImage from "../../components/ui/RemoteImage"; // The issue composer: one dialog — the draft on the left (title, description, the // steps drafted from the session, the environment), what goes with it on the right (the two logs, // each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick // to add more — an image pasted anywhere in it is taken). It saves as it goes, and what is still // waiting to be saved when it closes is saved then, so closing it loses nothing: Settings → // Diagnosztika reopens any past session. The export and the issue are built from what it SAVED. const SAVE_DELAY_MS = 800; type Source = "web" | "server"; const sourceOf = (row: ServerLogRow): Source => (row.source === "web" ? "web" : "server"); export default function IssueComposer({ id }: { id: string }) { const { t } = useTranslation(); const sessionQ = useQuery({ queryKey: qk.debugSession(id), queryFn: () => api.debugSession(id) }); const eventsQ = useQuery({ queryKey: qk.debugSessionEvents(id), queryFn: () => api.debugSessionEvents(id), }); const failed = sessionQ.isError || eventsQ.isError; // Closed after a Stop, the focus has nowhere to go back to — the Stop button went with the pill — // and would be left on . The page the user is on is the next best place. After the dialog's // own restore (a timeout: its cleanup runs after this one), and only when that had nothing. useEffect( () => () => { window.setTimeout(() => { if (focusIsUnclaimed()) document.querySelector('nav [aria-current="page"]')?.focus(); }, 0); }, [], ); return ( {failed && !(sessionQ.data && eventsQ.data) ? ( { void sessionQ.refetch(); void eventsQ.refetch(); }} > {t("recorder.composer.loadFailed")} ) : !sessionQ.data || !eventsQ.data ? ( ) : ( )} ); } /** The saved form if it has the shape this composer writes; otherwise null (an older or foreign * draft starts over from the session rather than half-applying). */ function savedForm(session: DebugSession): DraftForm | null { const form = session.draft?.form as Partial | undefined; if (!form || typeof form.title !== "string" || !Array.isArray(form.steps)) return null; return { title: form.title, description: typeof form.description === "string" ? form.description : "", steps: form.steps as DraftStep[], excluded: Array.isArray(form.excluded) ? (form.excluded as number[]) : [], }; } function ComposerBody({ session, rows, total, }: { session: DebugSession; rows: ServerLogRow[]; total: number; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); const pageName = (page: string) => { const def = moduleDef(page as Page); return def ? t(def.labelKey) : page; }; const [form, setForm] = useState( () => savedForm(session) ?? { title: "", description: "", steps: draftSteps(rows, pageName, logClock), excluded: [], }, ); const [reviewing, setReviewing] = useState(null); const issueLink = useRef(null); const retryButton = useRef(null); // The log whose review just closed: its Review button takes the focus back when the attachments // mount again (they unmount while a review is open). const [reviewed, setReviewed] = useState(null); const sent = session.issue_url !== null; const shotsQ = useQuery({ queryKey: qk.debugScreenshots(session.id), queryFn: () => api.debugScreenshots(session.id), }); const shots = shotsQ.data?.items ?? []; // How many the ISSUE will carry. From the list while it is loaded, else from the session itself // (the server's own count) — a failed read must not quietly become "no screenshots". const shotCount = shotsQ.data ? shots.length : session.screenshots; const excluded = useMemo(() => new Set(form.excluded), [form.excluded]); const kept = (source: Source) => rows.filter((r) => sourceOf(r) === source && !excluded.has(r.id)).length; const env = useMemo(() => environment(i18n.language), [i18n.language]); const text: IssueText = { steps: t("recorder.issue.steps"), environment: t("recorder.issue.environment"), envKeys: Object.fromEntries(env.map(([k]) => [k, t(`recorder.composer.env.${k}`)])), screenshot: t("recorder.issue.screenshot"), attachments: t("recorder.issue.attachments"), clientLog: (count) => t("recorder.issue.clientLog", { count }), serverLog: (count) => t("recorder.issue.serverLog", { count }), screenshots: (count) => t("recorder.issue.screenshots", { count }), truncated: total > rows.length ? t("recorder.composer.truncated", { kept: rows.length, total }) : undefined, }; const body = renderIssue( form, env, { client: kept("web"), server: kept("server"), screenshots: shotCount }, text, ); // Saves as it goes — after the typing pauses, not on every key. `edits` counts the edits, so a // save that finishes after a newer edit does not call that one saved. const edits = useRef(0); const [unsaved, setUnsaved] = useState(false); const save = useMutation({ mutationFn: async () => { const upTo = edits.current; const r = await api.saveDebugDraft(session.id, { title: form.title.trim(), body, excluded: form.excluded, form: form as unknown as Record, }); return { ...r, upTo }; }, meta: { failTitle: "recorder.composer.saveFailed" }, onSuccess: (r) => { qc.setQueryData(qk.debugSession(session.id), r.session); void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true }); if (r.upTo === edits.current) setUnsaved(false); }, }); const edit = (next: (f: DraftForm) => DraftForm) => { edits.current += 1; setUnsaved(true); setForm(next); }; // The issue, like the export, is built from the SAVED draft: save what is on screen first. // A send that stopped half-way is finished by the same call, which does only what is missing. // While it runs the draft is held as it was saved: nothing typed then would reach the issue. const target = useMe()?.issues ?? null; const pending = session.pending_files; // A send is running — this tab's or another's. Its progress is committed step by step, so the // half-sent shape below is also what a LIVE send looks like; only the server can tell them apart. const sending = session.sending; const send = useMutation({ mutationFn: async () => { if (!sent) await save.mutateAsync(); return api.sendDebugIssue(session.id); }, meta: { failTitle: "recorder.composer.sendFailed" }, onSuccess: (r) => { qc.setQueryData(qk.debugSession(session.id), r.session); notify({ level: "success", message: t("recorder.composer.sentOk") }); }, // A failure half-way still created the issue: show what the server now has. onSettled: async () => { void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true }); await qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true }); // Whichever way it went, the button that was just pressed is gone: a success unmounts the // Retry's banner, and a half-failure unmounts Create issue (the refetch above brings back the // issue_url it created before it died). Either leaves the focus on inside an open // dialog, so hand it on — to whatever now stands where the pressed control was. AFTER the // refetch, not in onSuccess/onError: before it lands, neither target is rendered yet. window.setTimeout(() => (retryButton.current ?? issueLink.current)?.focus(), 0); }, }); const locked = sent || send.isPending || sending; const saveNow = useEffectEvent(() => save.mutate()); useEffect(() => { if (locked || !unsaved) return; const timer = window.setTimeout(() => saveNow(), SAVE_DELAY_MS); return () => window.clearTimeout(timer); }, [body, form.title, locked, unsaved]); // Closed before the typing paused: save what is still waiting, now. const flush = useEffectEvent(() => { if (unsaved && !locked) save.mutate(); }); useEffect(() => () => flush(), []); // Screenshots are added here, not in the attachments column: an image pasted ANYWHERE in the // composer is taken — while typing the description is exactly when one is pasted. const addShot = useMutation({ mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image), meta: { failTitle: "recorder.pill.shotFailed" }, onSuccess: () => refreshShots(qc, session.id), }); const addImage = useEffectEvent((image: Blob) => addShot.mutate(image)); const root = useRef(null); useEffect(() => { if (locked) return; // Only an image paste is taken over: text pasted into the title or a step goes where it was typed. // And only OUR dialog's: something opened over the composer (the command palette) keeps its own. const onPaste = (e: ClipboardEvent) => { const dialog = e.target instanceof Element ? e.target.closest('[aria-modal="true"]') : null; if (dialog && !dialog.contains(root.current)) return; const images = [...(e.clipboardData?.files ?? [])].filter(isImage); if (!images.length) return; e.preventDefault(); for (const image of images) addImage(image); }; document.addEventListener("paste", onPaste); return () => document.removeEventListener("paste", onPaste); }, [locked]); const [mode, setMode] = useState<"edit" | "preview">("edit"); // The export is built from the SAVED draft: save what is on screen first — except when the // draft is already frozen (`locked`), where the save is both pointless and refused. While a send // holds the claim the server 409s `issue_sending`, and that refusal would take the zip with it: // `GET /export` has no send guard and would have served exactly what the send is uploading, so // the one tracker-free way out of a stuck send used to fail at the last step. const exportZip = useMutation({ mutationFn: async () => { if (!locked) await save.mutateAsync(); return api.debugExport(session.id); }, meta: { failTitle: "recorder.composer.exportFailed" }, onSuccess: ({ blob, name }) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); }, }); const started = new Date(session.started_at); const minutes = Math.max( 1, Math.round( (Date.parse(session.ended_at ?? new Date().toISOString()) - started.getTime()) / 60_000, ), ); return (

{t("recorder.composer.meta", { start: formatDateTimeSec(started.toISOString(), lang), minutes: formatUnit(minutes, "minute", lang), level: t(`settings.diagnostics.levels.${session.level}`), })}

{(sending || (sent && (pending.length > 0 || session.images_pending))) && (
{sending ? t("recorder.composer.sendingNow") : pending.length > 0 ? t("recorder.composer.partial", { files: pending.join(", ") }) : t("recorder.composer.partialImages")} {/* Nothing to retry while it is still going — the button would only be refused. */} {!sending && ( )}
)} {sent && (
{t("recorder.composer.sent")} {t("recorder.composer.openIssue")}
)} {!reviewing && ( )} {mode === "preview" && !reviewing ? ( ) : reviewing ? ( sourceOf(r) === reviewing)} name={t( reviewing === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog", )} excluded={excluded} readOnly={locked} onToggle={(rowId) => edit((f) => ({ ...f, excluded: f.excluded.includes(rowId) ? f.excluded.filter((x) => x !== rowId) : [...f.excluded, rowId], })) } onDone={() => { setReviewed(reviewing); setReviewing(null); }} /> ) : (
sourceOf(r) === "web").length], server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length], }} readOnly={locked} adding={addShot.isPending} onAdd={(files) => { for (const file of files) if (isImage(file)) addShot.mutate(file); }} onReview={setReviewing} reviewed={reviewed} setReviewed={setReviewed} />
)} {target && {t("recorder.composer.target", { repo: target.repo })}} {send.isPending ? t("recorder.composer.sending") : save.isPending ? t("recorder.composer.saving") : save.isSuccess && !unsaved ? t("recorder.composer.saved") : ""} {target && !sent && ( // `sending`: a send already under way (a reload's, or another tab's). The button would only // earn an `issue_sending` refusal; the banner above says why it is out. )}
); } function DraftFields({ form, edit, env, readOnly, }: { form: DraftForm; edit: (next: (f: DraftForm) => DraftForm) => void; env: [string, string][]; readOnly: boolean; }) { const { t } = useTranslation(); // A removed step's ✕ goes with it: the focus moves to "add a step", which stays. const addStep = useRef(null); const setStep = (i: number, patch: Partial) => edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); return (
edit((f) => ({ ...f, title: e.target.value }))} />