feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)
After Stop (or at the cap, or from Settings → Diagnosztika's past sessions) one dialog drafts the issue: title, description, the steps drafted from the session's markers and page changes (a marker without a label is named by its time), the environment (version, browser, system, window, language). Beside it, what goes with it: the browser and server logs — each reviewable line by line, an unticked line is not sent — and the screenshots, removable, with paste / drop / pick to add more. - it saves as it goes (debug_sessions.draft, migration 0090): the rendered markdown in the user's language, the excluded rows, and its own fields to reopen as it was; a sent session is read-only - the zip export (issue.md, client.log, server.log, screenshots) is built from the saved draft by app/issues/bundle.py — the one definition the Gitea delivery (S4) will share; .log, not .jsonl, which Gitea refuses - the log line's level tags and clock move to components/logLineParts so the review list draws a level the way both log viewers do
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Camera, Flag, Square } from "lucide-react";
|
||||
|
||||
import type { DebugSessionLevel } from "../lib/api";
|
||||
import { api, type DebugSessionLevel } from "../lib/api";
|
||||
import { canCaptureTab } from "../lib/captureTab";
|
||||
import {
|
||||
closeRecorderStart,
|
||||
openComposer,
|
||||
openRecorderStart,
|
||||
useComposerSession,
|
||||
useRecorderStartOpen,
|
||||
useStepCount,
|
||||
} from "../lib/debugRecorder";
|
||||
@@ -19,8 +20,10 @@ import { dockUnderToasts } from "../lib/toastDock";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { inputCls } from "./ui/form";
|
||||
import IssueComposer from "./IssueComposer";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
|
||||
// The debug recorder (E16): Start → reproduce → Stop.
|
||||
@@ -47,6 +50,7 @@ export default function DebugRecorder() {
|
||||
const me = useMe();
|
||||
const { session } = useDebugRecording();
|
||||
const startOpen = useRecorderStartOpen();
|
||||
const composing = useComposerSession();
|
||||
const id = session?.id;
|
||||
|
||||
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
|
||||
@@ -66,7 +70,12 @@ export default function DebugRecorder() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [id, until, minutes, qc, t]);
|
||||
|
||||
return startOpen ? <StartDialog /> : null;
|
||||
return (
|
||||
<>
|
||||
{startOpen && <StartDialog />}
|
||||
{composing && <IssueComposer id={composing} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StartDialog() {
|
||||
@@ -216,8 +225,17 @@ function Elapsed({ since }: { since: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings → Diagnosztika: start a recording, or see the one running and stop it. */
|
||||
/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */
|
||||
export function RecorderSettings() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<RecorderControl />
|
||||
<SessionHistory />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecorderControl() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop } = useDebugRecording();
|
||||
if (!session)
|
||||
@@ -294,3 +312,53 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
|
||||
function SessionHistory() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
|
||||
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
|
||||
if (q.isError && !q.data)
|
||||
return (
|
||||
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
|
||||
{t("recorder.history.failed")}
|
||||
</StateMessage>
|
||||
);
|
||||
if (!q.data) return <LoadingState />;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-muted">{t("recorder.history.title")}</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("recorder.history.empty")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border/60 rounded-xl border border-border">
|
||||
{items.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="tabular-nums">
|
||||
{new Date(s.started_at).toLocaleString(i18n.language)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
|
||||
</span>
|
||||
<span className={`chip px-2 py-0.5 text-2xs ${s.issue_url ? "text-success" : ""}`}>
|
||||
{t(
|
||||
s.issue_url
|
||||
? "recorder.history.sent"
|
||||
: s.draft
|
||||
? "recorder.history.draft"
|
||||
: "recorder.history.unwritten",
|
||||
)}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
|
||||
{t("recorder.history.open")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user