E32 review round 1 (F1, F2). - Issue composer: the review's focus-return token became state in S4 and was never spent, so every Preview → Edit round trip remounted the attachments and moved the focus from the toggle to the log's Review button. The token is cleared once it has been used, as the ref was. - Modal (initialFocus "field"): the late-form watch moved the focus to the first control once and stopped. A failed load's Retry then gave way to the spinner, its focus fell to <body> inside the aria-modal dialog, and the form's first field never got it. The watch now re-arms when the focus falls to <body>: it waits on the ✕ and moves to the first field when the form arrives. Focus in a dialog stacked on top is left alone.
861 lines
33 KiB
TypeScript
861 lines
33 KiB
TypeScript
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 "../lib/modules";
|
|
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 "./ui/Button";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import Modal, { ModalFooter } from "./ui/Modal";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { Textarea, Input } from "./ui/Input";
|
|
import { Checkbox } from "./ui/NativeControls";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// The issue composer (E16 S3, D4 A): 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 (S4) 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 <body>. 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<HTMLElement>('nav [aria-current="page"]')?.focus();
|
|
}, 0);
|
|
},
|
|
[],
|
|
);
|
|
return (
|
|
<Modal
|
|
initialFocus="field"
|
|
title={t("recorder.composer.title")}
|
|
onClose={closeComposer}
|
|
maxWidth="max-w-5xl"
|
|
>
|
|
{failed && !(sessionQ.data && eventsQ.data) ? (
|
|
<StateMessage
|
|
tone="error"
|
|
onRetry={() => {
|
|
void sessionQ.refetch();
|
|
void eventsQ.refetch();
|
|
}}
|
|
>
|
|
{t("recorder.composer.loadFailed")}
|
|
</StateMessage>
|
|
) : !sessionQ.data || !eventsQ.data ? (
|
|
<LoadingState />
|
|
) : (
|
|
<ComposerBody
|
|
session={sessionQ.data}
|
|
rows={eventsQ.data.items}
|
|
total={eventsQ.data.total}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** 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<DraftForm> | 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<DraftForm>(
|
|
() =>
|
|
savedForm(session) ?? {
|
|
title: "",
|
|
description: "",
|
|
steps: draftSteps(rows, pageName, logClock),
|
|
excluded: [],
|
|
},
|
|
);
|
|
const [reviewing, setReviewing] = useState<Source | null>(null);
|
|
const issueLink = useRef<HTMLAnchorElement>(null);
|
|
const retryButton = useRef<HTMLButtonElement>(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<Source | null>(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<string, unknown>,
|
|
});
|
|
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 (S4), 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 <body> 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<HTMLDivElement>(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 (round 4).
|
|
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 (
|
|
<div ref={root} className="flex flex-col gap-4">
|
|
<p className="text-sm text-muted">
|
|
{t("recorder.composer.meta", {
|
|
start: formatDateTimeSec(started.toISOString(), lang),
|
|
minutes: formatUnit(minutes, "minute", lang),
|
|
level: t(`settings.diagnostics.levels.${session.level}`),
|
|
})}
|
|
</p>
|
|
{(sending || (sent && (pending.length > 0 || session.images_pending))) && (
|
|
<div
|
|
className={`flex flex-wrap items-center justify-between gap-2 rounded-xl border px-3 py-2 text-sm ${
|
|
sending ? "border-accent/40 bg-accent/10" : "border-warn/40 bg-warn/10"
|
|
}`}
|
|
// `role="status"` rather than `aria-live`: this box is mounted TOGETHER with its first
|
|
// message (a half-failed send inserts it already populated), and a live region created
|
|
// with its content is unreliably announced — the rule ui/Toaster.tsx states and follows.
|
|
// A role announces on insertion, and its implied atomic re-read is right for one line.
|
|
role="status"
|
|
>
|
|
<span>
|
|
{sending
|
|
? t("recorder.composer.sendingNow")
|
|
: pending.length > 0
|
|
? t("recorder.composer.partial", { files: pending.join(", ") })
|
|
: t("recorder.composer.partialImages")}
|
|
</span>
|
|
{/* Nothing to retry while it is still going — the button would only be refused. */}
|
|
{!sending && (
|
|
<Button
|
|
ref={retryButton}
|
|
variant="outline"
|
|
size="sm"
|
|
loading={send.isPending}
|
|
onClick={() => send.mutate()}
|
|
>
|
|
{t(pending.length > 0 ? "recorder.composer.retry" : "recorder.composer.retryImages")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{sent && (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-success/40 bg-success/10 px-3 py-2 text-sm">
|
|
<span>{t("recorder.composer.sent")}</span>
|
|
<a
|
|
ref={issueLink}
|
|
href={session.issue_url!}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline"
|
|
>
|
|
{t("recorder.composer.openIssue")}
|
|
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
|
|
</a>
|
|
</div>
|
|
)}
|
|
{!reviewing && (
|
|
<SegmentedToggle
|
|
size="md"
|
|
className="self-start"
|
|
ariaLabel={t("recorder.composer.previewTitle")}
|
|
options={[
|
|
{ value: "edit", label: t("recorder.composer.edit") },
|
|
{ value: "preview", label: t("recorder.composer.preview") },
|
|
]}
|
|
value={mode}
|
|
onChange={setMode}
|
|
/>
|
|
)}
|
|
{mode === "preview" && !reviewing ? (
|
|
<IssuePreview
|
|
sessionId={session.id}
|
|
text={`# ${form.title.trim() || "…"}\n\n${body}`}
|
|
viaTracker={!!target}
|
|
/>
|
|
) : reviewing ? (
|
|
<ReviewList
|
|
rows={rows.filter((r) => 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);
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
|
<DraftFields form={form} edit={edit} env={env} readOnly={locked} />
|
|
<Attachments
|
|
session={session}
|
|
shotsQ={shotsQ}
|
|
counts={{
|
|
web: [kept("web"), rows.filter((r) => 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}
|
|
/>
|
|
</div>
|
|
)}
|
|
<ModalFooter>
|
|
<span className="mr-auto flex flex-col text-xs text-muted">
|
|
{target && <span>{t("recorder.composer.target", { repo: target.repo })}</span>}
|
|
<span aria-live="polite">
|
|
{send.isPending
|
|
? t("recorder.composer.sending")
|
|
: save.isPending
|
|
? t("recorder.composer.saving")
|
|
: save.isSuccess && !unsaved
|
|
? t("recorder.composer.saved")
|
|
: ""}
|
|
</span>
|
|
</span>
|
|
<Button variant="ghost" onClick={closeComposer}>
|
|
{t("recorder.composer.close")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
loading={save.isPending || exportZip.isPending}
|
|
disabled={send.isPending}
|
|
onClick={() => exportZip.mutate()}
|
|
>
|
|
{t("recorder.composer.export")}
|
|
</Button>
|
|
{target && !sent && (
|
|
// `sending` is a send ALREADY under way — from a reload, or from the other tab this one
|
|
// is a copy of. The button would only earn an `issue_sending` refusal; the banner above
|
|
// says why it is out.
|
|
<Button
|
|
variant="primary"
|
|
loading={send.isPending}
|
|
disabled={sending}
|
|
onClick={() => send.mutate()}
|
|
>
|
|
{t("recorder.composer.send")}
|
|
</Button>
|
|
)}
|
|
</ModalFooter>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLButtonElement>(null);
|
|
const setStep = (i: number, patch: Partial<DraftStep>) =>
|
|
edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
|
return (
|
|
<div className="flex flex-col gap-4 min-w-0">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="issue-title" className="text-sm text-muted">
|
|
{t("recorder.composer.fieldTitle")}
|
|
</label>
|
|
<Input
|
|
className="w-full"
|
|
id="issue-title"
|
|
value={form.title}
|
|
readOnly={readOnly}
|
|
maxLength={200}
|
|
placeholder={t("recorder.composer.titlePlaceholder")}
|
|
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="issue-description" className="text-sm text-muted">
|
|
{t("recorder.composer.description")}
|
|
</label>
|
|
<Textarea
|
|
className="w-full resize-y"
|
|
id="issue-description"
|
|
value={form.description}
|
|
readOnly={readOnly}
|
|
rows={4}
|
|
placeholder={t("recorder.composer.descriptionPlaceholder")}
|
|
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<fieldset className="flex flex-col gap-1.5 min-w-0">
|
|
<legend className="mb-1.5 text-sm text-muted">{t("recorder.composer.steps")}</legend>
|
|
<ol className="flex flex-col gap-1.5">
|
|
{form.steps.map((step, i) => (
|
|
<li
|
|
key={step.id}
|
|
className="flex items-center gap-2 rounded-lg bg-surface/60 pl-2.5 pr-1 py-1"
|
|
>
|
|
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
|
|
{i + 1}.
|
|
</span>
|
|
<Input
|
|
className="flex-1"
|
|
size="sm"
|
|
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
|
|
value={step.text}
|
|
readOnly={readOnly}
|
|
placeholder={t("recorder.composer.stepPlaceholder")}
|
|
onChange={(e) => setStep(i, { text: e.target.value })}
|
|
/>
|
|
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
|
|
{t(`recorder.composer.stepKind.${step.kind}`)}
|
|
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
|
|
</span>
|
|
{!readOnly && (
|
|
<IconButton
|
|
label={t("recorder.composer.removeStep", { n: i + 1 })}
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
|
|
addStep.current?.focus();
|
|
}}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</IconButton>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
{!readOnly && (
|
|
<Button
|
|
ref={addStep}
|
|
variant="link"
|
|
className="self-start"
|
|
icon={<Plus className="w-4 h-4" />}
|
|
onClick={() =>
|
|
edit((f) => ({
|
|
...f,
|
|
steps: [...f.steps, { id: `m${Date.now()}`, kind: "manual", text: "" }],
|
|
}))
|
|
}
|
|
>
|
|
{t("recorder.composer.addStep")}
|
|
</Button>
|
|
)}
|
|
</fieldset>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="text-sm text-muted">{t("recorder.composer.environment")}</div>
|
|
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm">
|
|
{env.map(([k, v]) => (
|
|
<div key={k} className="contents">
|
|
<dt className="text-muted">{t(`recorder.composer.env.${k}`)}</dt>
|
|
<dd className="break-words tabular-nums">{v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The types the server keeps (backend debugsession.IMAGE_TYPES) — offered by the picker, and the
|
|
// only ones a paste or a drop hands over.
|
|
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
const isImage = (file: File) => IMAGE_TYPES.includes(file.type);
|
|
|
|
/** What changes with a screenshot: its list, and the session's count — not the session's events,
|
|
* which share the session's key prefix and are thousands of rows. */
|
|
function refreshShots(qc: ReturnType<typeof useQueryClient>, id: string) {
|
|
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(id) });
|
|
void qc.invalidateQueries({ queryKey: qk.debugSession(id), exact: true });
|
|
}
|
|
|
|
function Attachments({
|
|
session,
|
|
shotsQ,
|
|
counts,
|
|
readOnly,
|
|
adding,
|
|
onAdd,
|
|
onReview,
|
|
reviewed,
|
|
setReviewed,
|
|
}: {
|
|
session: DebugSession;
|
|
shotsQ: { data?: { items: DebugScreenshot[] }; isError: boolean; refetch: () => unknown };
|
|
counts: Record<Source, [kept: number, total: number]>;
|
|
readOnly: boolean;
|
|
adding: boolean;
|
|
onAdd: (files: Iterable<File>) => void;
|
|
onReview: (source: Source) => void;
|
|
reviewed: Source | null;
|
|
setReviewed: (spent: null) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const qc = useQueryClient();
|
|
const shots = shotsQ.data?.items ?? [];
|
|
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
|
|
const pick = useRef<HTMLInputElement>(null);
|
|
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
|
|
// On mount after a review closed (this list unmounts while one is open), that log's Review button
|
|
// takes the focus back — once: the spent token keeps a later remount (back from the preview) off it.
|
|
useEffect(() => {
|
|
if (!reviewed) return;
|
|
reviewButtons.current[reviewed]?.focus();
|
|
setReviewed(null);
|
|
}, [reviewed, setReviewed]);
|
|
const remove = useMutation({
|
|
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
|
|
meta: { failTitle: "recorder.composer.removeShotFailed" },
|
|
onSuccess: () => refreshShots(qc, session.id),
|
|
});
|
|
const onDrop = (e: DragEvent) => {
|
|
e.preventDefault();
|
|
onAdd(e.dataTransfer.files);
|
|
};
|
|
|
|
const logRow = (source: Source, icon: React.ReactNode) => {
|
|
const [kept, total] = counts[source];
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5">
|
|
{icon}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="text-sm">
|
|
{t(source === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog")}
|
|
</span>
|
|
<span className="text-xs text-muted tabular-nums">
|
|
{kept === total
|
|
? t("recorder.composer.lines", { count: total })
|
|
: t("recorder.composer.linesKept", { kept, total })}
|
|
{` · ${source === "web" ? "client.log" : "server.log"}`}
|
|
</span>
|
|
</div>
|
|
{total > 0 && (
|
|
<Button
|
|
ref={(el) => {
|
|
reviewButtons.current[source] = el;
|
|
}}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onReview(source)}
|
|
>
|
|
{t("recorder.composer.review")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={`${SURFACE.card} flex flex-col gap-3 min-w-0 p-3 md:p-4`}>
|
|
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
|
|
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
|
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
|
{shotsQ.isError && !shotsQ.data && (
|
|
<StateMessage tone="error" compact onRetry={() => void shotsQ.refetch()}>
|
|
{t("recorder.composer.shotsFailed")}
|
|
</StateMessage>
|
|
)}
|
|
{shots.map((shot, i) => (
|
|
<div
|
|
key={shot.id}
|
|
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
|
|
>
|
|
<img
|
|
src={api.debugScreenshotUrl(session.id, shot.id)}
|
|
alt={t("recorder.composer.screenshot", { n: i + 1 })}
|
|
className="h-10 w-16 shrink-0 rounded-md object-cover bg-surface"
|
|
/>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
|
|
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size, lang)}</span>
|
|
</div>
|
|
{!readOnly && (
|
|
<IconButton
|
|
label={t("recorder.composer.removeShot", { n: i + 1 })}
|
|
size="sm"
|
|
variant="ghost"
|
|
loading={remove.isPending && remove.variables === shot.id}
|
|
onClick={() => {
|
|
remove.mutate(shot.id);
|
|
pick.current?.focus();
|
|
}}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</IconButton>
|
|
)}
|
|
</div>
|
|
))}
|
|
{!readOnly && (
|
|
<div
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={onDrop}
|
|
aria-busy={adding}
|
|
className="rounded-xl border border-dashed border-border px-3 py-4 text-center text-sm text-muted"
|
|
>
|
|
{t("recorder.composer.drop")}{" "}
|
|
<label className="cursor-pointer rounded font-semibold text-accent hover:underline focus-within:underline focus-within:ring-2 focus-within:ring-accent/60">
|
|
{t("recorder.composer.pick")}
|
|
<input
|
|
ref={pick}
|
|
type="file"
|
|
accept={IMAGE_TYPES.join(",")}
|
|
multiple
|
|
className="sr-only"
|
|
onChange={(e) => {
|
|
onAdd(e.target.files ?? []);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** One log, line by line: an unticked line is not sent. */
|
|
function ReviewList({
|
|
rows,
|
|
name,
|
|
excluded,
|
|
readOnly,
|
|
onToggle,
|
|
onDone,
|
|
}: {
|
|
rows: ServerLogRow[];
|
|
name: string;
|
|
excluded: Set<number>;
|
|
readOnly: boolean;
|
|
onToggle: (id: number) => void;
|
|
onDone: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// Opened from its Review button, which is gone now: the focus comes here.
|
|
const done = useRef<HTMLButtonElement>(null);
|
|
useEffect(() => done.current?.focus(), []);
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<div className="font-semibold">{t("recorder.composer.reviewTitle", { name })}</div>
|
|
<div className="text-xs text-muted">{t("recorder.composer.reviewHint")}</div>
|
|
</div>
|
|
<Button ref={done} variant="primary" size="sm" onClick={onDone}>
|
|
{t("recorder.composer.reviewDone")}
|
|
</Button>
|
|
</div>
|
|
<ul className="max-h-[60dvh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
|
|
{rows.map((row) => {
|
|
const tag = LEVEL_TAG[row.level];
|
|
const off = excluded.has(row.id);
|
|
return (
|
|
<li key={row.id} className="border-b border-border/50 last:border-b-0">
|
|
<label
|
|
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 state-layer sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
|
|
off ? "opacity-50 line-through" : ""
|
|
}`}
|
|
>
|
|
<Checkbox
|
|
checked={!off}
|
|
disabled={readOnly}
|
|
aria-label={t("recorder.composer.keepRow", {
|
|
event: row.event ?? "",
|
|
msg: row.msg,
|
|
})}
|
|
onChange={() => onToggle(row.id)}
|
|
/>
|
|
<span className="tabular-nums text-muted">{logClock(row.ts)}</span>
|
|
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
|
|
<span className="truncate">{row.event}</span>
|
|
<span className="col-span-4 truncate sm:col-span-1">{row.msg}</span>
|
|
</label>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The report as it will read. Through the tracker, its OWN rendering (Gitea's markdown and
|
|
* sanitiser) — drawn in a sandboxed frame: no script runs in it, and nothing it holds reaches the
|
|
* app's document. Without a tracker, the markdown itself: it is the zip's issue.md. */
|
|
function IssuePreview({
|
|
sessionId,
|
|
text,
|
|
viaTracker,
|
|
}: {
|
|
sessionId: string;
|
|
text: string;
|
|
viaTracker: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const q = useQuery({
|
|
queryKey: qk.debugSessionPreview(sessionId, text),
|
|
queryFn: () => api.previewDebugIssue(sessionId, text),
|
|
enabled: viaTracker,
|
|
});
|
|
const frame = useRef<HTMLIFrameElement>(null);
|
|
const [height, setHeight] = useState(240);
|
|
if (!viaTracker)
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<p className="text-xs text-muted">{t("recorder.composer.previewLocal")}</p>
|
|
<pre className="max-h-[60dvh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
|
|
{text}
|
|
</pre>
|
|
</div>
|
|
);
|
|
if (q.isError && !q.data)
|
|
return (
|
|
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
|
|
{t("recorder.composer.previewFailed")}
|
|
</StateMessage>
|
|
);
|
|
if (!q.data) return <LoadingState />;
|
|
const css = getComputedStyle(document.documentElement);
|
|
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
|
|
// The frame is a document of its own: the app's text size (the root font, which the text-size
|
|
// setting drives) is handed to it, and everything in it is sized off that.
|
|
const doc = `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
|
|
html{font-size:${css.fontSize}}
|
|
body{margin:0;padding:.75rem .875rem;font:.875rem/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
|
|
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:.25rem .5rem}
|
|
code{font-family:ui-monospace,Consolas,monospace;font-size:.75rem}h1,h2,h3{line-height:1.25}
|
|
</style></head><body>${q.data.html}</body></html>`;
|
|
return (
|
|
<iframe
|
|
ref={frame}
|
|
title={t("recorder.composer.previewTitle")}
|
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
srcDoc={doc}
|
|
onLoad={() => {
|
|
const body = frame.current?.contentDocument?.body;
|
|
if (body) setHeight(Math.min(body.scrollHeight + 4, window.innerHeight * 0.6));
|
|
}}
|
|
style={{ height }}
|
|
className="w-full rounded-xl border border-border"
|
|
/>
|
|
);
|
|
}
|