refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
|
||||
// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the
|
||||
// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the
|
||||
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
|
||||
// lives in a real query cache, as in the app.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { DebugSession, Me } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { order, session, startDebugSession, stopDebugSession, hold, past } = vi.hoisted(() => {
|
||||
const past: { items: unknown[] } = { items: [] };
|
||||
// A Stop held open until released, when a test needs it in flight.
|
||||
const hold = { stop: null as Promise<void> | null };
|
||||
const order: string[] = [];
|
||||
const session = (over: Partial<DebugSession> = {}): DebugSession => ({
|
||||
id: "0123456789abcdef",
|
||||
level: "DEBUG",
|
||||
started_at: new Date().toISOString(),
|
||||
until: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
ended_at: null,
|
||||
ended_reason: null,
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
sending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
});
|
||||
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
|
||||
Promise.resolve({ ok: true as const, session: session({ level }) }),
|
||||
);
|
||||
const stopDebugSession = vi.fn(async (id: string) => {
|
||||
order.push("stop");
|
||||
if (hold.stop) await hold.stop;
|
||||
return {
|
||||
ok: true as const,
|
||||
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
|
||||
};
|
||||
});
|
||||
return { order, session, startDebugSession, stopDebugSession, hold, past };
|
||||
});
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
startDebugSession,
|
||||
stopDebugSession,
|
||||
debugSessions: () => Promise.resolve(past),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/diagnostics", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/diagnostics")>();
|
||||
return {
|
||||
...actual,
|
||||
flushNow: () => {
|
||||
order.push("flush");
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import Toaster from "../../components/ui/Toaster";
|
||||
import {
|
||||
closeComposer,
|
||||
openRecorderStart,
|
||||
setStepPrompt,
|
||||
useComposerSession,
|
||||
} from "../../lib/debugRecorder";
|
||||
import {
|
||||
anyBlockingLayer,
|
||||
anyBlockingLayerAbove,
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
} from "../../lib/layerStack";
|
||||
import { events } from "../../lib/log";
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
hold.stop = null;
|
||||
sessionStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeComposer();
|
||||
setStepPrompt(null);
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
let composing: string | null = null;
|
||||
function ComposerProbe() {
|
||||
// eslint-disable-next-line react-hooks/globals -- a TEST probe: a module-level handle is how the test reads a hook's value from outside the tree without a wrapper that would change what is being measured.
|
||||
composing = useComposerSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount(me: Partial<Me>, extra: React.ReactNode = null) {
|
||||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
debug_session: null,
|
||||
...me,
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Toaster />
|
||||
<DebugRecorder />
|
||||
<ComposerProbe />
|
||||
{extra}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
const pill = () => screen.queryByRole("group", { name: "recorder.pill.label" });
|
||||
|
||||
/** A dialog the test opens and closes while the pill is up: the pill moves into its toast host. */
|
||||
function DialogToggle() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
|
||||
{open && (
|
||||
<Modal initialFocus="close" title="dialog" onClose={() => setOpen(false)}>
|
||||
<p>inside</p>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("the recorder pill", () => {
|
||||
it("shows while a session records, under the toasts and outside their live region", () => {
|
||||
mount({ debug_session: session() });
|
||||
const p = pill()!;
|
||||
expect(p).not.toBeNull();
|
||||
expect(p.closest("[data-toast-region]")).not.toBeNull();
|
||||
expect(p.closest("[aria-live]")).toBeNull();
|
||||
});
|
||||
|
||||
it("is not there for a session that ended or ran past its cap", () => {
|
||||
mount({ debug_session: session({ until: new Date(Date.now() - 1000).toISOString() }) });
|
||||
expect(pill()).toBeNull();
|
||||
cleanup();
|
||||
mount({ debug_session: session({ ended_at: new Date().toISOString() }) });
|
||||
expect(pill()).toBeNull();
|
||||
});
|
||||
|
||||
it("Stop sends what is waiting first, then stops, then opens the composer", async () => {
|
||||
const qc = mount({ debug_session: session() });
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
|
||||
await waitFor(() => expect(stopDebugSession).toHaveBeenCalledWith("0123456789abcdef"));
|
||||
expect(order).toEqual(["flush", "stop"]);
|
||||
await waitFor(() => expect(pill()).toBeNull());
|
||||
expect(qc.getQueryData<Me>(qk.me())?.debug_session).toBeNull();
|
||||
expect(composing).toBe("0123456789abcdef");
|
||||
});
|
||||
|
||||
it("a step is one tagged event, numbered, and the focus comes back to Step", () => {
|
||||
mount({ debug_session: session() });
|
||||
const step = within(pill()!).getByRole("button", { name: "recorder.pill.step" });
|
||||
fireEvent.click(step);
|
||||
const input = screen.getByLabelText("recorder.pill.stepPrompt:1");
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.change(input, { target: { value: "opened the player" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
const marked = events().filter((e) => e.event === "debug.step");
|
||||
expect(marked.slice(-1)[0]?.attrs).toMatchObject({ n: 1, label: "opened the player" });
|
||||
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
|
||||
expect(document.activeElement).toBe(step);
|
||||
expect(step.textContent).toContain("· 1");
|
||||
});
|
||||
|
||||
it("Escape in the step prompt closes the prompt, not the dialog the pill sits in", () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
{ debug_session: session() },
|
||||
<Modal initialFocus="close" title="dialog" onClose={onClose}>
|
||||
<p>inside</p>
|
||||
</Modal>,
|
||||
);
|
||||
// The pill moved into the dialog's own toast host.
|
||||
expect(pill()!.closest('[role="dialog"]')).not.toBeNull();
|
||||
const marked = () => events().filter((e) => e.event === "debug.step").length;
|
||||
const before = marked();
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
fireEvent.keyDown(screen.getByLabelText(/recorder.pill.stepPrompt/), { key: "Escape" });
|
||||
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(marked()).toBe(before);
|
||||
});
|
||||
|
||||
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
|
||||
mount(
|
||||
{ debug_session: session() },
|
||||
<Modal initialFocus="close" title="dialog" onClose={() => {}}>
|
||||
<p>inside</p>
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
|
||||
mark.focus();
|
||||
// The dialog's trap takes the Tab (jsdom has no layout, so it can only be seen taking it — the
|
||||
// browser would otherwise walk out of the dialog into the page behind its scrim).
|
||||
const unhandled = fireEvent.keyDown(mark, { key: "Tab" });
|
||||
expect(unhandled).toBe(false);
|
||||
});
|
||||
|
||||
it("the step prompt quiets the page's keys and leaves the Tab to the dialog it sits in", () => {
|
||||
mount({ debug_session: session() });
|
||||
expect(anyBlockingLayer()).toBe(false);
|
||||
// The pill docks inside whatever dialog is open; stand one in for it, under the prompt.
|
||||
const dialog = pushLayer();
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
|
||||
// The prompt's field has the keyboard, so `j` and the `g` chords stay off the page behind...
|
||||
expect(anyBlockingLayer()).toBe(true);
|
||||
// ...while the dialog around it keeps its focus trap. The prompt cannot be that trap: its two
|
||||
// controls are two of the dialog's many, and taking it left the rest of them untrapped, with
|
||||
// Tab walking out into the page behind the scrim.
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(false);
|
||||
// And the quiet is the prompt's own doing, not the stand-in's: it outlasts the dialog.
|
||||
removeLayer(dialog);
|
||||
expect(anyBlockingLayer()).toBe(true);
|
||||
});
|
||||
|
||||
it("the step prompt keeps what was typed when the pill moves to a dialog and back", () => {
|
||||
mount({ debug_session: session() }, <DialogToggle />);
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
fireEvent.change(screen.getByLabelText(/recorder.pill.stepPrompt/), {
|
||||
target: { value: "opened the player" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("toggle dialog"));
|
||||
expect(pill()!.closest('[role="dialog"]')).not.toBeNull(); // moved: a new pill
|
||||
expect((screen.getByLabelText(/recorder.pill.stepPrompt/) as HTMLInputElement).value).toBe(
|
||||
"opened the player",
|
||||
);
|
||||
});
|
||||
|
||||
it("a Stop still running keeps its button busy after the pill moves", async () => {
|
||||
let release = () => {};
|
||||
hold.stop = new Promise((r) => (release = r));
|
||||
mount({ debug_session: session() }, <DialogToggle />);
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
|
||||
await waitFor(() => expect(order).toContain("stop"));
|
||||
fireEvent.click(screen.getByText("toggle dialog"));
|
||||
const stop = within(pill()!).getByRole("button", { name: "recorder.pill.stop" });
|
||||
expect(stop.getAttribute("aria-busy")).toBe("true");
|
||||
release();
|
||||
await waitFor(() => expect(pill()).toBeNull());
|
||||
});
|
||||
|
||||
it("a Start or a Stop in another tab makes this one ask the server again", async () => {
|
||||
const qc = mount({ debug_session: session() });
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
const otherTab = new BroadcastChannel("siftlode.debugSession");
|
||||
otherTab.postMessage("changed");
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.me() }));
|
||||
otherTab.close();
|
||||
});
|
||||
|
||||
it("offers a screenshot only where the browser can capture a tab", () => {
|
||||
mount({ debug_session: session() });
|
||||
// jsdom has no Screen Capture API — like iOS Safari.
|
||||
expect(within(pill()!).queryByRole("button", { name: "recorder.pill.screenshot" })).toBeNull();
|
||||
});
|
||||
|
||||
it("at its cap it says so and opens the composer", () => {
|
||||
vi.useFakeTimers();
|
||||
mount({ debug_session: session({ until: new Date(Date.now() + 5_000).toISOString() }) });
|
||||
act(() => void vi.advanceTimersByTime(5_000));
|
||||
expect(screen.getByText("recorder.pill.expired:60")).not.toBeNull();
|
||||
expect(composing).toBe("0123456789abcdef");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the start dialog", () => {
|
||||
it("states the retention and the cap, and starts at the level chosen", async () => {
|
||||
const qc = mount({});
|
||||
act(() => openRecorderStart());
|
||||
expect(screen.getByText(/recorder.start.kept:7/)).not.toBeNull();
|
||||
expect(screen.getByText(/recorder.start.cap:60/)).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.levels.TRACE" }));
|
||||
expect(screen.getByText("recorder.start.what.TRACE")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /recorder.start.confirm/ }));
|
||||
await waitFor(() => expect(startDebugSession).toHaveBeenCalledWith("TRACE"));
|
||||
await waitFor(() => expect(pill()).not.toBeNull());
|
||||
expect(qc.getQueryData<Me>(qk.me())?.debug_session?.level).toBe("TRACE");
|
||||
expect(screen.queryByText("recorder.start.title")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the past sessions a user goes back to check", () => {
|
||||
function settings() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
debug_session: null,
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<RecorderSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("does not call a half-delivered report sent", async () => {
|
||||
// An issue that still owes its logs, or an edit that shows its screenshots, is not done. This
|
||||
// list is where a user checks back, and a green "Sent" here is how one gets forgotten.
|
||||
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
|
||||
past.items = [
|
||||
session({
|
||||
...over,
|
||||
id: "aaaaaaaaaaaaaaa1",
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["client.log"],
|
||||
}),
|
||||
session({
|
||||
...over,
|
||||
id: "aaaaaaaaaaaaaaa2",
|
||||
issue_url: "https://git.example/o/r/issues/8",
|
||||
images_pending: true,
|
||||
}),
|
||||
session({ ...over, id: "aaaaaaaaaaaaaaa3", issue_url: "https://git.example/o/r/issues/9" }),
|
||||
];
|
||||
settings();
|
||||
|
||||
expect(await screen.findAllByText("recorder.history.partial")).toHaveLength(2);
|
||||
expect(screen.getAllByText("recorder.history.sent")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says a send is still running rather than naming what it has got to so far", async () => {
|
||||
// This is the list a user opens TO CHECK on a send. Its two honest-looking words are both
|
||||
// wrong for a running one: "draft" before it has an issue, "half sent" after.
|
||||
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
|
||||
past.items = [
|
||||
session({
|
||||
...over,
|
||||
id: "bbbbbbbbbbbbbbb1",
|
||||
sending: true,
|
||||
draft: { title: "t", body: "b", excluded: [], form: {} },
|
||||
}),
|
||||
session({
|
||||
...over,
|
||||
id: "bbbbbbbbbbbbbbb2",
|
||||
sending: true,
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["client.log"],
|
||||
}),
|
||||
];
|
||||
settings();
|
||||
|
||||
expect(await screen.findAllByText("recorder.history.sending")).toHaveLength(2);
|
||||
expect(screen.queryByText("recorder.history.draft")).toBeNull();
|
||||
expect(screen.queryByText("recorder.history.partial")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Camera, Flag, Square } from "lucide-react";
|
||||
|
||||
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
||||
import { canCaptureTab } from "../../lib/captureTab";
|
||||
import {
|
||||
closeRecorderStart,
|
||||
onSessionChangeElsewhere,
|
||||
openComposer,
|
||||
openRecorderStart,
|
||||
setStepPrompt,
|
||||
useComposerSession,
|
||||
useRecorderStartOpen,
|
||||
useStepCount,
|
||||
useStepPrompt,
|
||||
} from "../../lib/debugRecorder";
|
||||
import { formatDateTimeSec, formatDuration } from "../../lib/format";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import IssueComposer from "./IssueComposer";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The debug recorder: Start → reproduce → Stop.
|
||||
//
|
||||
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
|
||||
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
|
||||
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
|
||||
// app shell, for a signed-in account.
|
||||
|
||||
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
|
||||
function useElapsed(since: string): string {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => tick((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/purity -- the clock is READ at render and the interval above only re-renders — a `now` held in state is the moment the component mounted, which is the defect DiagnosticsSettings records from prod UAT.
|
||||
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
|
||||
}
|
||||
|
||||
export default function DebugRecorder() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const { session } = useDebugRecording();
|
||||
const startOpen = useRecorderStartOpen();
|
||||
const composing = useComposerSession();
|
||||
const id = session?.id;
|
||||
|
||||
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
|
||||
// Started or stopped in another tab: this one asks the server what is in force now.
|
||||
useEffect(
|
||||
() => onSessionChangeElsewhere(() => void qc.invalidateQueries({ queryKey: qk.me() })),
|
||||
[qc],
|
||||
);
|
||||
|
||||
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
|
||||
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
|
||||
const until = session?.until;
|
||||
const minutes = me?.diagnostics?.session_minutes;
|
||||
useEffect(() => {
|
||||
if (!id || !until) return;
|
||||
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
||||
const timer = window.setTimeout(() => {
|
||||
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||
openComposer(id);
|
||||
void qc.invalidateQueries({ queryKey: qk.me() });
|
||||
}, wait);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [id, until, minutes, qc, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{startOpen && <StartDialog />}
|
||||
{composing && <IssueComposer id={composing} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StartDialog() {
|
||||
const { t } = useTranslation();
|
||||
const me = useMe();
|
||||
const { start } = useDebugRecording();
|
||||
const [level, setLevel] = useState<DebugSessionLevel>("DEBUG");
|
||||
const days = me.diagnostics.retention_days;
|
||||
const minutes = me.diagnostics.session_minutes;
|
||||
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
|
||||
return (
|
||||
<Modal initialFocus="field" title={t("recorder.start.title")} onClose={closeRecorderStart}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<span className="text-sm text-muted">{t("recorder.start.level")}</span>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("recorder.start.level")}
|
||||
options={levels.map((l) => ({
|
||||
value: l,
|
||||
label: t(`settings.diagnostics.levels.${l}`),
|
||||
}))}
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
|
||||
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
|
||||
<p>{t(`recorder.start.what.${level}`)}</p>
|
||||
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
|
||||
<p className="text-muted">
|
||||
{t("recorder.start.kept", { count: days })}{" "}
|
||||
{t("recorder.start.cap", { count: minutes })}
|
||||
</p>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={closeRecorderStart}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={start.isPending}
|
||||
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
||||
onClick={() => start.mutate(level)}
|
||||
>
|
||||
{t("recorder.start.confirm")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pill: how long it has been recording, and the three things to do while it does. */
|
||||
function RecorderPill() {
|
||||
const { t } = useTranslation();
|
||||
const { session, stop, stopping, shooting, markStep, screenshot } = useDebugRecording();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const stepOpen = useStepPrompt() !== null;
|
||||
const stepButton = useRef<HTMLButtonElement>(null);
|
||||
const steps = useStepCount(session?.id ?? "");
|
||||
if (!session) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("recorder.pill.label")}
|
||||
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 ${
|
||||
hidden ? "invisible" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="w-2.5 h-2.5 shrink-0 rounded-full bg-danger ring-4 ring-danger/20 motion-safe:animate-pulse"
|
||||
/>
|
||||
<Elapsed since={session.started_at} />
|
||||
<span className="chip hidden sm:inline-flex px-2 py-0.5 text-2xs">
|
||||
{t(`settings.diagnostics.levels.${session.level}`)}
|
||||
</span>
|
||||
<span aria-hidden className="hidden sm:block w-px h-6 bg-border" />
|
||||
<Button
|
||||
ref={stepButton}
|
||||
variant="outline-accent"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Flag className="w-4 h-4" />}
|
||||
label={t("recorder.pill.step")}
|
||||
aria-expanded={stepOpen}
|
||||
onClick={() => setStepPrompt(stepOpen ? null : "")}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
{t("recorder.pill.step")}
|
||||
{steps ? ` · ${steps}` : ""}
|
||||
</span>
|
||||
</Button>
|
||||
{canCaptureTab() && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Camera className="w-4 h-4" />}
|
||||
label={t("recorder.pill.screenshot")}
|
||||
loading={shooting}
|
||||
onClick={() => void screenshot(setHidden)}
|
||||
>
|
||||
<span className="hidden sm:inline tabular-nums">
|
||||
{t("recorder.pill.screenshot")}
|
||||
{session.screenshots ? ` · ${session.screenshots}` : ""}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
icon={<Square className="w-3.5 h-3.5 fill-current" />}
|
||||
label={t("recorder.pill.stop")}
|
||||
loading={stopping}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
|
||||
</Button>
|
||||
{stepOpen && (
|
||||
<StepPrompt
|
||||
id={session.id}
|
||||
onDone={(label) => {
|
||||
if (label !== null) markStep(label);
|
||||
setStepPrompt(null);
|
||||
stepButton.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Elapsed({ since }: { since: string }) {
|
||||
const { t } = useTranslation();
|
||||
const time = useElapsed(since);
|
||||
return (
|
||||
// On a phone only the clock: the pill has to fit the content column beside the rail, and the
|
||||
// red dot already says what it is.
|
||||
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
|
||||
<span className="hidden sm:inline">{t("recorder.pill.recording", { time })}</span>
|
||||
<span className="sm:hidden">{time}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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, stopping } = useDebugRecording();
|
||||
if (!session)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted max-w-prose">{t("recorder.settings.hint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
|
||||
onClick={openRecorderStart}
|
||||
>
|
||||
{t("recorder.settings.start")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3 py-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-danger" aria-hidden />
|
||||
<SettingsElapsed since={session.started_at} />
|
||||
</span>
|
||||
<Button
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
loading={stopping}
|
||||
onClick={() => stop.mutate(session.id)}
|
||||
>
|
||||
{t("recorder.settings.stop")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsElapsed({ since }: { since: string }) {
|
||||
const { t } = useTranslation();
|
||||
const time = useElapsed(since);
|
||||
return <span className="tabular-nums">{t("recorder.settings.recording", { time })}</span>;
|
||||
}
|
||||
|
||||
/** "What did you just do?" — optional; Enter marks the step, Escape leaves it unmarked. */
|
||||
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const n = useStepCount(id) + 1;
|
||||
const label = useStepPrompt() ?? "";
|
||||
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
|
||||
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last. It
|
||||
// OWNS the keyboard while it is up (an autofocused field does), so the page's own keys stay quiet.
|
||||
// It does NOT take the Tab, though: it is a field inside whatever dialog the pill is docked in,
|
||||
// not a dialog of its own, and the trap belongs to that one — it spans the prompt as it spans the
|
||||
// rest of the toast host. Claiming it here switched the dialog's off and left every control
|
||||
// OUTSIDE this little box untrapped.
|
||||
useLayer(true, () => onDone(null), { trapsTab: false });
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onDone(label);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
|
||||
>
|
||||
<label htmlFor="debug-step-label" className="text-sm text-muted">
|
||||
{t("recorder.pill.stepPrompt", { n })}
|
||||
</label>
|
||||
<Input
|
||||
className="w-full"
|
||||
id="debug-step-label"
|
||||
autoFocus
|
||||
value={label}
|
||||
onChange={(e) => setStepPrompt(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={200}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>{t("recorder.pill.stepHint")}</span>
|
||||
<Button variant="primary" size="sm" onClick={() => onDone(label)}>
|
||||
{t("recorder.pill.mark")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The five words a past session can read as. A union, not `string`, because `chipTone` below
|
||||
* matches on these same keys: untyped, a rename in one of them left the other matching nothing
|
||||
* and every chip untoned, with the gate green. */
|
||||
type HistoryState =
|
||||
| "recorder.history.sending"
|
||||
| "recorder.history.draft"
|
||||
| "recorder.history.unwritten"
|
||||
| "recorder.history.partial"
|
||||
| "recorder.history.sent";
|
||||
|
||||
/** What a past session reads as. An issue that still owes the tracker its attachments, or an edit
|
||||
* that shows its screenshots, is NOT "Sent": this list is where a user goes back to check, and a
|
||||
* green chip there is how a half-delivered report gets forgotten. */
|
||||
function historyState(s: DebugSession): HistoryState {
|
||||
// A send under way outranks every other word here: this is the list a user comes back to in
|
||||
// order to check on one, and both "draft" (no issue yet) and "half sent" (the steps it has
|
||||
// already committed) would describe a send that STOPPED.
|
||||
if (s.sending) return "recorder.history.sending";
|
||||
if (!s.issue_url) return s.draft ? "recorder.history.draft" : "recorder.history.unwritten";
|
||||
return s.pending_files.length > 0 || s.images_pending
|
||||
? "recorder.history.partial"
|
||||
: "recorder.history.sent";
|
||||
}
|
||||
|
||||
/** The colour that reinforces that word. Exhaustive over `HistoryState`, so a key renamed in
|
||||
* `historyState` is a compile error here rather than a chip that quietly loses its tone. */
|
||||
const chipTone = (s: DebugSession): string => {
|
||||
switch (historyState(s)) {
|
||||
case "recorder.history.sent":
|
||||
return "text-success";
|
||||
case "recorder.history.sending":
|
||||
return "text-accent";
|
||||
case "recorder.history.partial":
|
||||
return "text-warn";
|
||||
case "recorder.history.draft":
|
||||
case "recorder.history.unwritten":
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
|
||||
function SessionHistory() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
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">{formatDateTimeSec(s.started_at, lang)}</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 ${chipTone(s)}`}>
|
||||
{t(historyState(s))}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
|
||||
{t("recorder.history.open")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
|
||||
// One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
|
||||
// its lines through this): its cells on one line, and the row opens in place to its details. One frame
|
||||
// for all of them, so a row opens, announces itself and takes the keyboard the same on every tab.
|
||||
// Inside a NavGrid the keyboard walks the rows (`j`/`k`) and Enter opens one.
|
||||
|
||||
export default function HistoryRow({
|
||||
cells,
|
||||
grid,
|
||||
navIndex,
|
||||
navText,
|
||||
open,
|
||||
onToggle,
|
||||
title,
|
||||
line = false,
|
||||
children,
|
||||
}: {
|
||||
/** The row's cells, laid out by `grid`. */
|
||||
cells: ReactNode;
|
||||
/** The grid's column template classes (phone first, then wider screens). */
|
||||
grid: string;
|
||||
navIndex: number;
|
||||
/** What the keyboard's type-ahead matches. */
|
||||
navText: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
/** The row button's tooltip — the full date. */
|
||||
title?: string;
|
||||
/** A log line: tighter, its cells on one text baseline, its size the (monospace) list's own. */
|
||||
line?: boolean;
|
||||
/** The detail shown while the row is open. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="border-b border-border/50 last:border-b-0"
|
||||
{...navItemAttrs(navIndex, navText, { inset: true })}
|
||||
>
|
||||
<Pressable
|
||||
selectable
|
||||
layer={!open}
|
||||
data-nav-open
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
title={title}
|
||||
className={`grid w-full gap-x-2 px-3 text-left touch-target pointer-coarse:content-center ${
|
||||
line ? "items-baseline py-1" : "items-center py-1.5 text-xs"
|
||||
} ${grid} ${open ? "bg-accent/10" : ""}`}
|
||||
>
|
||||
{cells}
|
||||
</Pressable>
|
||||
{open && (
|
||||
<div className={`bg-accent/5 px-3 py-2 text-muted ${line ? "" : "text-xs"}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The issue composer. It drafts the steps from the session, saves as it goes (the rendered
|
||||
// text included — the export is built from what it SAVED), reopens as it was left, takes a log line
|
||||
// out of the evidence in the review, adds a pasted image, and a sent session is read-only. i18n is a
|
||||
// key-passthrough with its params; the API is stubbed.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { DebugDraft, DebugSession, ServerLogRow } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
session: null as unknown as DebugSession,
|
||||
rows: [] as ServerLogRow[],
|
||||
saved: [] as DebugDraft[],
|
||||
uploaded: [] as Blob[],
|
||||
failSession: false,
|
||||
failShots: false,
|
||||
sent: 0,
|
||||
previewed: [] as string[],
|
||||
// What happened, in order: a save is "save" when asked and "saved" when it answered.
|
||||
order: [] as string[],
|
||||
holdSend: null as Promise<void> | null,
|
||||
// The server's half-failure: the issue is committed, then an upload dies and the call 422s.
|
||||
failSendAfterCreate: false,
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
debugSession: () =>
|
||||
api.failSession ? Promise.reject(new Error("down")) : Promise.resolve(api.session),
|
||||
debugSessionEvents: () => Promise.resolve({ items: api.rows, total: api.rows.length }),
|
||||
debugScreenshots: () =>
|
||||
api.failShots ? Promise.reject(new Error("down")) : Promise.resolve({ items: [] }),
|
||||
saveDebugDraft: (_id: string, draft: DebugDraft) => {
|
||||
api.saved.push(draft);
|
||||
api.order.push("save");
|
||||
// Answers a task later, so a caller that does not wait for it is seen not waiting.
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
api.order.push("saved");
|
||||
resolve({ ok: true, session: { ...api.session, draft } });
|
||||
}, 0),
|
||||
);
|
||||
},
|
||||
addDebugScreenshot: (_id: string, image: Blob) => {
|
||||
api.uploaded.push(image);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
screenshot: { id: 1, mime: image.type, size: 4, created_at: "" },
|
||||
});
|
||||
},
|
||||
debugExport: () => {
|
||||
api.order.push("export");
|
||||
return Promise.resolve({ blob: new Blob(["zip"]), name: "siftlode-debug.zip" });
|
||||
},
|
||||
sendDebugIssue: async () => {
|
||||
api.order.push("send");
|
||||
if (api.holdSend) await api.holdSend;
|
||||
api.sent += 1;
|
||||
api.session = {
|
||||
...api.session,
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: api.failSendAfterCreate ? ["server.log"] : [],
|
||||
// What the server does on the way through: a send that got there shows the images too,
|
||||
// so the banner (and the Retry in it) goes. Leaving this true made the retry's own
|
||||
// banner outlive its success, which is not a state the server can produce.
|
||||
images_pending: false,
|
||||
};
|
||||
if (api.failSendAfterCreate) throw new Error("issues_unreachable");
|
||||
return { ok: true as const, session: api.session };
|
||||
},
|
||||
previewDebugIssue: (_id: string, text: string) => {
|
||||
api.previewed.push(text);
|
||||
return Promise.resolve({ ok: true, html: "<h3>Steps</h3><script>alert(1)</script>" });
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import IssueComposer from "./IssueComposer";
|
||||
|
||||
const SID = "0123456789abcdef";
|
||||
let n = 1;
|
||||
const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
|
||||
id: n++,
|
||||
ts: "2026-09-19T14:05:31.000Z",
|
||||
level: "INFO",
|
||||
source: "web",
|
||||
logger: "web",
|
||||
event: "x.y",
|
||||
msg: "m",
|
||||
attrs: null,
|
||||
exc: null,
|
||||
request_id: null,
|
||||
user_id: 1,
|
||||
job_id: null,
|
||||
run_id: null,
|
||||
debug_session_id: SID,
|
||||
version: null,
|
||||
...over,
|
||||
});
|
||||
const baseSession = (over: Partial<DebugSession> = {}): DebugSession => ({
|
||||
id: SID,
|
||||
level: "DEBUG",
|
||||
started_at: "2026-09-19T14:02:00.000Z",
|
||||
until: "2026-09-19T15:02:00.000Z",
|
||||
ended_at: "2026-09-19T14:16:00.000Z",
|
||||
ended_reason: "stopped",
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
sending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
api.session = baseSession();
|
||||
api.rows = [
|
||||
row({ event: "debug.step", msg: "resume", attrs: { n: 1, label: "Resume" } }),
|
||||
row({ event: "ui.click", msg: "private click" }),
|
||||
row({ source: "api", event: "http.request", msg: "GET /api/x 200" }),
|
||||
];
|
||||
api.saved = [];
|
||||
api.uploaded = [];
|
||||
api.failSession = false;
|
||||
api.failShots = false;
|
||||
api.order = [];
|
||||
api.holdSend = null;
|
||||
api.failSendAfterCreate = false;
|
||||
api.sent = 0;
|
||||
api.previewed = [];
|
||||
tracker = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
let tracker: { repo: string } | null = null;
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, issues: tracker });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<IssueComposer id={SID} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** jsdom lays nothing out, so every element reads as hidden to the dialog's focus rules. */
|
||||
const layOut = () =>
|
||||
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
|
||||
const lastSaved = () => api.saved[api.saved.length - 1]!;
|
||||
const title = () => screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
|
||||
/** A button once it can be pressed: `loading` holds it while the draft's first save settles. */
|
||||
async function ready(name: string): Promise<HTMLButtonElement> {
|
||||
const button = (await screen.findByRole("button", { name })) as HTMLButtonElement;
|
||||
await waitFor(() => expect(button.disabled).toBe(false));
|
||||
return button;
|
||||
}
|
||||
|
||||
describe("the issue composer", () => {
|
||||
it("drafts the steps from the session and saves the rendered text after a pause", async () => {
|
||||
mount();
|
||||
const first = await screen.findByLabelText("recorder.composer.stepLabel:1");
|
||||
expect((first as HTMLInputElement).value).toBe("Resume");
|
||||
fireEvent.change(screen.getByLabelText("recorder.composer.fieldTitle"), {
|
||||
target: { value: "Spinner stays" },
|
||||
});
|
||||
await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 });
|
||||
expect(lastSaved().title).toBe("Spinner stays");
|
||||
expect(lastSaved().body).toContain("1. Resume");
|
||||
expect(lastSaved().body).toContain("`client.log` — recorder.issue.clientLog:2");
|
||||
});
|
||||
|
||||
it("reopens a saved draft as it was left", async () => {
|
||||
api.session = baseSession({
|
||||
draft: {
|
||||
title: "Saved title",
|
||||
body: "",
|
||||
excluded: [],
|
||||
form: {
|
||||
title: "Saved title",
|
||||
description: "kept",
|
||||
steps: [{ id: "m1", kind: "manual", text: "typed" }],
|
||||
excluded: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
mount();
|
||||
expect(
|
||||
((await screen.findByLabelText("recorder.composer.fieldTitle")) as HTMLInputElement).value,
|
||||
).toBe("Saved title");
|
||||
expect((screen.getByLabelText("recorder.composer.stepLabel:1") as HTMLInputElement).value).toBe(
|
||||
"typed",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes an unticked line out of the evidence", async () => {
|
||||
mount();
|
||||
fireEvent.click(
|
||||
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "recorder.composer.keepRow:ui.click,private click" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.reviewDone" }));
|
||||
expect(screen.getByText(/recorder.composer.linesKept:1,2/)).not.toBeNull();
|
||||
await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 });
|
||||
const clickId = api.rows[1]!.id;
|
||||
expect(lastSaved().excluded).toEqual([clickId]);
|
||||
expect(lastSaved().body).toContain("recorder.issue.clientLog:1");
|
||||
});
|
||||
|
||||
it("adds a pasted image, and nothing else that was pasted", async () => {
|
||||
mount();
|
||||
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
|
||||
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
|
||||
const text = new File(["hi"], "note.txt", { type: "text/plain" });
|
||||
fireEvent.paste(zone, { clipboardData: { files: [png, text] } });
|
||||
await waitFor(() => expect(api.uploaded).toHaveLength(1));
|
||||
expect(api.uploaded[0]!.type).toBe("image/png");
|
||||
});
|
||||
|
||||
it("takes a dropped or picked image, and nothing else", async () => {
|
||||
mount();
|
||||
const zone = (await screen.findByText(/recorder.composer.drop/)).closest("div")!;
|
||||
const png = new File([new Uint8Array([137, 80, 78, 71])], "a.png", { type: "image/png" });
|
||||
const pdf = new File(["%PDF"], "a.pdf", { type: "application/pdf" });
|
||||
fireEvent.drop(zone, { dataTransfer: { files: [pdf, png] } });
|
||||
await waitFor(() => expect(api.uploaded).toHaveLength(1));
|
||||
expect(api.uploaded[0]!.type).toBe("image/png");
|
||||
});
|
||||
|
||||
it("takes an image pasted while typing, anywhere in the composer", async () => {
|
||||
mount();
|
||||
await screen.findByText("recorder.composer.attachments");
|
||||
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(title(), { clipboardData: { files: [png] } });
|
||||
await waitFor(() => expect(api.uploaded).toHaveLength(1));
|
||||
});
|
||||
|
||||
it("saves an edit still waiting when it is closed", async () => {
|
||||
mount();
|
||||
await ready("recorder.composer.export");
|
||||
fireEvent.change(title(), { target: { value: "Typed and closed" } });
|
||||
cleanup(); // closed well inside the pause
|
||||
await waitFor(() => expect(api.saved).toHaveLength(1));
|
||||
expect(lastSaved().title).toBe("Typed and closed");
|
||||
});
|
||||
|
||||
it("does not call a newer edit saved", async () => {
|
||||
mount();
|
||||
await ready("recorder.composer.export");
|
||||
fireEvent.change(title(), { target: { value: "one" } });
|
||||
expect(
|
||||
await screen.findByText("recorder.composer.saved", {}, { timeout: 2000 }),
|
||||
).not.toBeNull();
|
||||
fireEvent.change(title(), { target: { value: "two" } });
|
||||
expect(screen.queryByText("recorder.composer.saved")).toBeNull();
|
||||
});
|
||||
|
||||
it("says so when the screenshots cannot be read, and still counts them from the session", async () => {
|
||||
api.failShots = true;
|
||||
api.session = baseSession({ screenshots: 2 });
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.shotsFailed")).not.toBeNull();
|
||||
// Everything still works: the images are the one thing that could not be listed.
|
||||
expect(screen.queryByText("recorder.composer.attachments")).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "recorder.composer.export" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
// Including the control standing right beside the failed list: more can still be added.
|
||||
expect(screen.queryByText("recorder.composer.pick")).not.toBeNull();
|
||||
// The draft is still saved — and says two screenshots, the server's own count, not none.
|
||||
fireEvent.change(title(), { target: { value: "x" } });
|
||||
await waitFor(() => expect(api.saved).toHaveLength(1), { timeout: 2000 });
|
||||
expect(lastSaved().body).toContain("recorder.issue.screenshots:2");
|
||||
});
|
||||
|
||||
it("says a send is under way instead of calling it half-failed, and holds the draft", async () => {
|
||||
// Every step of a send commits its progress, so a reload or a second tab reads exactly the
|
||||
// shape of a send that STOPPED. Only the server can tell the two apart — and it does.
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["server.log", "screenshot-1.png"],
|
||||
sending: true,
|
||||
});
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
|
||||
expect(screen.queryByText(/recorder.composer.partial/)).toBeNull();
|
||||
// No retry to press: it would only be refused while the send it invites is still running.
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull();
|
||||
fireEvent.change(title(), { target: { value: "typed while it went out" } });
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
expect(api.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("holds the draft through a first send, before there is an issue to point at", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// A send with no issue_url yet is what the read-only lock is for: an edit made mid-flight
|
||||
// would be saved over the very body the send is at that moment uploading, and the issue that
|
||||
// arrives would be neither draft. The half-sent case above is locked by its link instead.
|
||||
api.session = baseSession({ sending: true });
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
|
||||
fireEvent.change(title(), { target: { value: "typed mid-flight" } });
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
expect(api.saved).toHaveLength(0);
|
||||
// Nor can a screenshot join what is already on its way up.
|
||||
expect(screen.queryByText("recorder.composer.pick")).toBeNull();
|
||||
// And the button that would start a SECOND send is out — the server would only refuse it.
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "recorder.composer.send" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("announces the half-sent banner instead of only drawing it", async () => {
|
||||
// A screen-reader user hears the failure toast — "couldn't send the report" — and nothing else.
|
||||
// WHICH attachments are missing, and that a Retry now stands beside them, live only in this
|
||||
// banner, which is inserted already carrying its first message. A live region created together
|
||||
// with its content is unreliably announced (the rule ui/Toaster.tsx states and follows), so the
|
||||
// announcement has to come from a ROLE, which announces on insertion.
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["server.log"],
|
||||
});
|
||||
mount();
|
||||
const said = await screen.findByText(/recorder\.composer\.partial/);
|
||||
const announced = said.closest('[role="status"]');
|
||||
expect(announced).not.toBeNull();
|
||||
// ...and the Retry is inside what gets read out, not left behind silently beside it.
|
||||
expect(announced!.querySelector("button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hands the focus on when a send half-fails, not only when it works", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// The issue is created and an upload then dies, so the call FAILS with the link already
|
||||
// committed. The refetch brings the link back, `sent` flips, and "Create issue" — the control
|
||||
// holding the focus — unmounts under it. Without a hand-off the focus lands on <body> in the
|
||||
// middle of an open dialog: a screen reader loses its place and the Retry that just appeared
|
||||
// is several Tabs away, past the dialog's own ✕.
|
||||
api.failSendAfterCreate = true;
|
||||
mount();
|
||||
const send = (await ready("recorder.composer.send")) as HTMLButtonElement;
|
||||
send.focus();
|
||||
expect(document.activeElement).toBe(send);
|
||||
fireEvent.click(send);
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
const retry = await screen.findByRole("button", { name: "recorder.composer.retry" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(retry));
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
|
||||
});
|
||||
|
||||
it("still builds the zip while a send holds the draft, instead of saving into a refusal", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// The zip is the only way out of a send that is stuck — a claim left by a dead request stands
|
||||
// for ten minutes. Its pre-save is pointless here (the draft is frozen, and the server would
|
||||
// 409 `issue_sending` and take the export down with it), and unnecessary: `GET /export` has no
|
||||
// send guard and builds from the saved draft, which is exactly what the send is uploading.
|
||||
api.session = baseSession({ sending: true });
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
|
||||
click.mockImplementation(() => void api.order.push("click"));
|
||||
const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL };
|
||||
URL.createObjectURL = () => "blob:zip";
|
||||
URL.revokeObjectURL = () => {};
|
||||
try {
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
|
||||
fireEvent.click(await ready("recorder.composer.export"));
|
||||
await waitFor(() => expect(click).toHaveBeenCalled());
|
||||
expect(api.order).toEqual(["export", "click"]);
|
||||
expect(api.saved).toHaveLength(0);
|
||||
} finally {
|
||||
click.mockRestore();
|
||||
URL.createObjectURL = real.make;
|
||||
URL.revokeObjectURL = real.revoke;
|
||||
}
|
||||
});
|
||||
|
||||
it("asks to show the images, not to send what is already there", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
images_pending: true,
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("recorder.composer.partialImages");
|
||||
// Nothing is missing from the issue here — only its text has still to show what it carries.
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retryImages" }));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
// The button just pressed is gone with its banner: the focus goes to what takes its place,
|
||||
// not to <body> in the middle of an open dialog.
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement?.getAttribute("href")).toBe("https://git.example/o/r/issues/7"),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an image pasted into a dialog opened over it alone", async () => {
|
||||
mount();
|
||||
await screen.findByText("recorder.composer.attachments");
|
||||
const above = document.createElement("div");
|
||||
above.setAttribute("aria-modal", "true");
|
||||
const field = document.createElement("input");
|
||||
above.appendChild(field);
|
||||
document.body.appendChild(above);
|
||||
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(field, { clipboardData: { files: [png] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(api.uploaded).toHaveLength(0);
|
||||
above.remove();
|
||||
});
|
||||
|
||||
it("moves the focus to what stays when a control goes", async () => {
|
||||
mount();
|
||||
fireEvent.click(
|
||||
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
|
||||
);
|
||||
const done = screen.getByRole("button", { name: "recorder.composer.reviewDone" });
|
||||
expect(document.activeElement).toBe(done);
|
||||
fireEvent.click(done);
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getAllByRole("button", { name: "recorder.composer.review" })[0],
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.removeStep:1" }));
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "recorder.composer.addStep" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a paste without an image to the field it was typed into", async () => {
|
||||
mount();
|
||||
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
|
||||
const pasted = fireEvent.paste(zone, { clipboardData: { files: [] } });
|
||||
expect(pasted).toBe(true); // not prevented
|
||||
expect(api.uploaded).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves before it asks for the zip, and hands it to the browser", async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
|
||||
click.mockImplementation(() => void api.order.push("click"));
|
||||
const urls = { made: 0, revoked: 0 };
|
||||
// jsdom implements neither, so there is nothing to spy on: they are DEFINED here — and put
|
||||
// back afterwards, or every later test in this file would keep running against them.
|
||||
const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL };
|
||||
URL.createObjectURL = () => {
|
||||
urls.made += 1;
|
||||
return "blob:zip";
|
||||
};
|
||||
URL.revokeObjectURL = () => void (urls.revoked += 1);
|
||||
mount();
|
||||
fireEvent.click(await ready("recorder.composer.export"));
|
||||
await waitFor(() => expect(click).toHaveBeenCalled());
|
||||
// A request, not a bare navigation: a refusal (the export's own pace) is shown like any other.
|
||||
expect(api.order).toEqual(["save", "saved", "export", "click"]);
|
||||
expect([urls.made, urls.revoked]).toEqual([1, 1]);
|
||||
click.mockRestore();
|
||||
URL.createObjectURL = real.make;
|
||||
URL.revokeObjectURL = real.revoke;
|
||||
});
|
||||
|
||||
it("a sent session is read-only and links its issue", async () => {
|
||||
api.session = baseSession({ issue_url: "https://git.example/o/r/issues/7" });
|
||||
mount();
|
||||
const link = await screen.findByRole("link", { name: /recorder.composer.openIssue/ });
|
||||
expect(link.getAttribute("href")).toBe("https://git.example/o/r/issues/7");
|
||||
const title = screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
|
||||
expect(title.readOnly).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens on the title, not on the Edit / Preview switch above it", async () => {
|
||||
// The form arrives after the dialog opens; its first control is the switch, and the focus
|
||||
// landed there — typing went nowhere.
|
||||
layOut();
|
||||
mount();
|
||||
await waitFor(() => expect(document.activeElement).toBe(title()));
|
||||
});
|
||||
|
||||
it("moves to the title once a retried load brings the form", async () => {
|
||||
layOut();
|
||||
api.failSession = true;
|
||||
mount();
|
||||
const message = await screen.findByText("recorder.composer.loadFailed");
|
||||
api.failSession = false;
|
||||
fireEvent.click(within(message.closest('[role="alert"]')!).getByRole("button"));
|
||||
await waitFor(() => expect(document.activeElement).toBe(title()));
|
||||
});
|
||||
|
||||
it("says so when the session cannot be loaded, with a retry", async () => {
|
||||
api.failSession = true;
|
||||
mount();
|
||||
const message = await screen.findByText("recorder.composer.loadFailed");
|
||||
expect(within(message.closest('[role="alert"]')!).getByRole("button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("offers the tracker only where one is set up, and sends the saved draft", async () => {
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "recorder.composer.export" });
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
|
||||
cleanup();
|
||||
|
||||
tracker = { repo: "o/r" };
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull();
|
||||
fireEvent.click(await ready("recorder.composer.send"));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
// Saved, and answered, first: the issue is built from what was saved.
|
||||
expect(api.order).toEqual(["save", "saved", "send"]);
|
||||
expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("holds the draft while it is being sent", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
let release = () => {};
|
||||
api.holdSend = new Promise((r) => (release = r));
|
||||
mount();
|
||||
fireEvent.click(await ready("recorder.composer.send"));
|
||||
await waitFor(() => expect(api.order).toContain("send"));
|
||||
// What is typed now would not reach the issue: the fields do not take it.
|
||||
expect(title().readOnly).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
|
||||
release();
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
});
|
||||
|
||||
it("finishes a send that stopped half-way, and says what is missing", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["server.log"],
|
||||
});
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.partial:server.log")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retry" }));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
expect(api.saved).toHaveLength(0); // sent: nothing to save, only the rest to send
|
||||
});
|
||||
|
||||
it("returns the focus to a reviewed log once, not on every way back from the preview", async () => {
|
||||
mount();
|
||||
fireEvent.click(
|
||||
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.reviewDone" }));
|
||||
const review = screen.getAllByRole("button", { name: "recorder.composer.review" })[0];
|
||||
expect(document.activeElement).toBe(review);
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.preview" }));
|
||||
const edit = screen.getByRole("button", { name: "recorder.composer.edit" });
|
||||
edit.focus();
|
||||
fireEvent.click(edit);
|
||||
await screen.findAllByRole("button", { name: "recorder.composer.review" });
|
||||
expect(document.activeElement).toBe(edit); // the toggle keeps it; the log's button does not take it
|
||||
});
|
||||
|
||||
it("previews through the tracker in a frame where no script runs", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
|
||||
const frame = (await screen.findByTitle("recorder.composer.previewTitle")) as HTMLIFrameElement;
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<h3>Steps</h3>");
|
||||
expect(api.previewed[0]).toContain("1. Resume");
|
||||
});
|
||||
|
||||
it("previews the zip's own text where there is no tracker", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
|
||||
expect(screen.getByText(/1\. Resume/).tagName).toBe("PRE");
|
||||
expect(api.previewed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,862 @@
|
||||
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 "../../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 <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, 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.
|
||||
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`: 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.
|
||||
<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"
|
||||
data-initial-focus
|
||||
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"
|
||||
>
|
||||
<RemoteImage
|
||||
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"
|
||||
fallback={<div className="h-10 w-16 shrink-0 rounded-md 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Level } from "../../lib/log";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||
|
||||
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
|
||||
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
|
||||
// in place to whatever detail its screen has. One component, so a level reads the same on both.
|
||||
// The row frame is the history lists' (./HistoryRow): inside a NavGrid the keyboard walks the lines
|
||||
// (`j`/`k`) and Enter opens one, as a click does.
|
||||
|
||||
export default function LogLine({
|
||||
ts,
|
||||
level,
|
||||
event,
|
||||
msg,
|
||||
open,
|
||||
onToggle,
|
||||
title,
|
||||
navIndex,
|
||||
children,
|
||||
}: {
|
||||
ts: string;
|
||||
level: Level;
|
||||
event: string;
|
||||
msg: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
/** The row button's tooltip — the admin console puts the full date there. */
|
||||
title?: string;
|
||||
/** The line's place in its NavGrid. */
|
||||
navIndex: number;
|
||||
/** The detail shown while the line is open. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tag = LEVEL_TAG[level];
|
||||
return (
|
||||
<HistoryRow
|
||||
line
|
||||
grid="grid-cols-[6.5em_3.5em_minmax(0,1fr)] sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)]"
|
||||
navIndex={navIndex}
|
||||
navText={`${event} ${msg}`}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
title={title}
|
||||
cells={
|
||||
<>
|
||||
<span className="tabular-nums text-muted">{logClock(ts)}</span>
|
||||
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
|
||||
<span className="truncate">{event}</span>
|
||||
{/* A phone has no room for a fourth column: the message takes a line of its own under the
|
||||
code, full width — sharing the code's line left it a few characters. */}
|
||||
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</HistoryRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Level } from "../../lib/log";
|
||||
|
||||
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
|
||||
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
|
||||
// LogLine.tsx because a component file exports only components (react-refresh).
|
||||
|
||||
export const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
|
||||
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
|
||||
DEBUG: { text: "DEBUG", className: "text-muted" },
|
||||
INFO: { text: "INFO", className: "text-info" },
|
||||
WARNING: { text: "WARN", className: "text-warn" },
|
||||
ERROR: { text: "ERROR", className: "text-danger" },
|
||||
CRITICAL: { text: "FATAL", className: "text-danger" },
|
||||
};
|
||||
|
||||
/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
|
||||
export function logClock(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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"];
|
||||
Reference in New Issue
Block a user