fix(diagnostics): deep-review round 2 — a send that claims, not one that blocks
The send no longer holds the session's row lock while it talks to the tracker: it claims the session, gives the lock back, and checkpoints each step. A Stop is never held behind a send, while a draft, a screenshot or a second send are refused at once instead of queueing. The claim ages out, so a request that died mid-send does not strand the session. What the composer says is what happened: an attachment that went is no longer named as one that did not (the missing body edit is its own line), and the issue's screenshot count comes from the server when the list cannot be read, so a failed read no longer blocks saving the draft. The zip is fetched as a request, so its pace is a message and not a saved error page. Screenshots of a finished report can be removed again; only one still being sent is held. A recording's log elevation can no longer outlive it. The step prompt keeps the keyboard (the page's keys stay quiet) and traps Tab itself. An image pasted into a dialog opened over the composer stays that dialog's.
This commit is contained in:
@@ -38,6 +38,7 @@ const { order, session, startDebugSession, stopDebugSession, hold } = vi.hoisted
|
||||
ended_reason: null,
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
setStepPrompt,
|
||||
useComposerSession,
|
||||
} from "../lib/debugRecorder";
|
||||
import { anyBlockingLayer } from "../lib/layerStack";
|
||||
import { events } from "../lib/log";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -215,6 +217,17 @@ describe("the recorder pill", () => {
|
||||
expect(unhandled).toBe(false);
|
||||
});
|
||||
|
||||
it("the step prompt owns the keyboard: the page's keys stay quiet and Tab stays in it", () => {
|
||||
mount({ debug_session: session() });
|
||||
expect(anyBlockingLayer()).toBe(false);
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
// No dialog under it here: the prompt itself is what keeps `j`/`g h` off the page behind.
|
||||
expect(anyBlockingLayer()).toBe(true);
|
||||
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
|
||||
mark.focus();
|
||||
expect(fireEvent.keyDown(mark, { key: "Tab" })).toBe(false); // trapped in the prompt
|
||||
});
|
||||
|
||||
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" }));
|
||||
|
||||
@@ -23,6 +23,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../lib/toastDock";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import { trapTab } from "./ui/dialogFocus";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
@@ -286,14 +287,15 @@ function SettingsElapsed({ since }: { since: string }) {
|
||||
/** "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 box = useRef<HTMLDivElement>(null);
|
||||
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.
|
||||
// Passive: it takes Escape, not the Tab — the dialog under it keeps its focus trap, which spans
|
||||
// its toast host and so this prompt too; a blocking layer would switch that trap off and let Tab
|
||||
// walk out of the dialog into the page behind its scrim.
|
||||
useLayer(true, () => onDone(null), { passive: true });
|
||||
// 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
|
||||
// — and it therefore traps Tab itself, between its field and its button: a dialog under it defers
|
||||
// its trap to the layer above, and without one of our own Tab walked out into the page behind.
|
||||
useLayer(true, () => onDone(null));
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -301,7 +303,13 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
|
||||
<div
|
||||
ref={box}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab" && box.current) trapTab(e.nativeEvent, box.current);
|
||||
}}
|
||||
className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg"
|
||||
>
|
||||
<label htmlFor="debug-step-label" className="text-sm text-muted">
|
||||
{t("recorder.pill.stepPrompt", { n })}
|
||||
</label>
|
||||
|
||||
@@ -65,7 +65,10 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
screenshot: { id: 1, mime: image.type, size: 4, created_at: "" },
|
||||
});
|
||||
},
|
||||
debugExportUrl: () => "/api/debug-sessions/0123456789abcdef/export",
|
||||
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;
|
||||
@@ -115,6 +118,7 @@ const baseSession = (over: Partial<DebugSession> = {}): DebugSession => ({
|
||||
ended_reason: "stopped",
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
@@ -264,17 +268,42 @@ describe("the issue composer", () => {
|
||||
expect(screen.queryByText("recorder.composer.saved")).toBeNull();
|
||||
});
|
||||
|
||||
it("says so when the screenshots cannot be read, and saves nothing that would miscount them", async () => {
|
||||
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();
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "recorder.composer.export" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
// The draft is still saved — and says two screenshots, the server's own count, not none.
|
||||
fireEvent.change(title(), { target: { value: "x" } });
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
expect(api.saved).toHaveLength(0);
|
||||
await waitFor(() => expect(api.saved).toHaveLength(1), { timeout: 2000 });
|
||||
expect(lastSaved().body).toContain("recorder.issue.screenshots:2");
|
||||
});
|
||||
|
||||
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("says when the issue is there but does not show its screenshots yet", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
images_pending: true,
|
||||
});
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.partialImages")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retry" }));
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
});
|
||||
|
||||
it("moves the focus to what stays when a control goes", async () => {
|
||||
@@ -302,13 +331,21 @@ describe("the issue composer", () => {
|
||||
expect(api.uploaded).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves before it downloads the zip", async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
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 };
|
||||
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());
|
||||
expect(api.order).toEqual(["save", "saved", "click"]);
|
||||
// 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -128,10 +128,10 @@ function ComposerBody({
|
||||
queryKey: qk.debugScreenshots(session.id),
|
||||
queryFn: () => api.debugScreenshots(session.id),
|
||||
});
|
||||
// Until the screenshots are known, the body cannot say how many there are: nothing is saved or
|
||||
// sent from it before then (a failed read must not become "no screenshots" in the issue).
|
||||
const shotsReady = shotsQ.data !== undefined;
|
||||
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) =>
|
||||
@@ -155,7 +155,7 @@ function ComposerBody({
|
||||
const body = renderIssue(
|
||||
form,
|
||||
env,
|
||||
{ client: kept("web"), server: kept("server"), screenshots: shots.length },
|
||||
{ client: kept("web"), server: kept("server"), screenshots: shotCount },
|
||||
text,
|
||||
);
|
||||
|
||||
@@ -213,14 +213,14 @@ function ComposerBody({
|
||||
const saveRef = useRef(save.mutate);
|
||||
saveRef.current = save.mutate;
|
||||
useEffect(() => {
|
||||
if (locked || !unsaved || !shotsReady) return;
|
||||
if (locked || !unsaved) return;
|
||||
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [body, form.title, locked, unsaved, shotsReady]);
|
||||
}, [body, form.title, locked, unsaved]);
|
||||
// Closed before the typing paused: save what is still waiting, now.
|
||||
const flushRef = useRef(() => {});
|
||||
flushRef.current = () => {
|
||||
if (unsaved && !locked && shotsReady) save.mutate();
|
||||
if (unsaved && !locked) save.mutate();
|
||||
};
|
||||
useEffect(() => () => flushRef.current(), []);
|
||||
|
||||
@@ -233,10 +233,14 @@ function ComposerBody({
|
||||
});
|
||||
const addRef = useRef(addShot.mutate);
|
||||
addRef.current = addShot.mutate;
|
||||
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();
|
||||
@@ -248,19 +252,21 @@ function ComposerBody({
|
||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
||||
|
||||
// The export is built from the SAVED draft: save what is on screen first.
|
||||
const exportZip = async () => {
|
||||
if (!sent) {
|
||||
try {
|
||||
await save.mutateAsync();
|
||||
} catch {
|
||||
return; // the save's own message says why
|
||||
}
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = api.debugExportUrl(session.id);
|
||||
a.download = "";
|
||||
a.click();
|
||||
};
|
||||
const exportZip = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sent) 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(
|
||||
@@ -271,7 +277,7 @@ function ComposerBody({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div ref={root} className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
{t("recorder.composer.meta", {
|
||||
start: started.toLocaleString(i18n.language),
|
||||
@@ -279,9 +285,13 @@ function ComposerBody({
|
||||
level: t(`settings.diagnostics.levels.${session.level}`),
|
||||
})}
|
||||
</p>
|
||||
{sent && pending.length > 0 && (
|
||||
{sent && (pending.length > 0 || session.images_pending) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm">
|
||||
<span>{t("recorder.composer.partial", { files: pending.join(", ") })}</span>
|
||||
<span>
|
||||
{pending.length > 0
|
||||
? t("recorder.composer.partial", { files: pending.join(", ") })
|
||||
: t("recorder.composer.partialImages")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -384,19 +394,14 @@ function ComposerBody({
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={save.isPending}
|
||||
disabled={!shotsReady || send.isPending}
|
||||
onClick={() => void exportZip()}
|
||||
loading={save.isPending || exportZip.isPending}
|
||||
disabled={send.isPending}
|
||||
onClick={() => exportZip.mutate()}
|
||||
>
|
||||
{t("recorder.composer.export")}
|
||||
</Button>
|
||||
{target && !sent && (
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={send.isPending}
|
||||
disabled={!shotsReady}
|
||||
onClick={() => send.mutate()}
|
||||
>
|
||||
<Button variant="primary" loading={send.isPending} onClick={() => send.mutate()}>
|
||||
{t("recorder.composer.send")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -647,7 +652,7 @@ function Attachments({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && shotsQ.data && (
|
||||
{!readOnly && (
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import Toaster from "./Toaster";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -50,12 +51,21 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("lets a click through wherever there is no toast and nothing docked, in every host", () => {
|
||||
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
|
||||
const Docked = () => <button>dock</button>;
|
||||
const stop = dockUnderToasts(Docked);
|
||||
notify({ level: "info", message: "hello" });
|
||||
render(<Toaster />);
|
||||
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
|
||||
// The empty part of the region (beside and above a docked control) must not swallow the page's
|
||||
// clicks; the cards and the dock opt back in.
|
||||
// clicks — and the two things that ARE there must still take them.
|
||||
expect(region.className).toContain("pointer-events-none");
|
||||
const card = screen.getByText("hello").closest(".toast-card")!;
|
||||
expect(card.className).toContain("pointer-events-auto");
|
||||
const dock = screen.getByText("dock").parentElement!;
|
||||
expect(dock.className).toContain("pointer-events-auto");
|
||||
expect(dock.closest("[data-toast-region]")).toBe(region);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("does not let a click on a toast reach the overlay's backdrop", () => {
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"issues_rate_limited": "You can send at most {{limit}} reports an hour. Try again later.",
|
||||
"debug_session_rate_limited": "You can start at most {{limit}} new recordings an hour. Try again later.",
|
||||
"export_rate_limited": "Too many downloads in a row. Wait a minute.",
|
||||
"screenshot_storage_full": "You've reached the {{limit}} MB screenshot allowance. Remove images from older sessions, or wait until they expire."
|
||||
"screenshot_storage_full": "You've reached the {{limit}} MB screenshot allowance. Remove images from older sessions, or wait until they expire.",
|
||||
"issue_sending": "The report is being sent right now. Wait for it to finish."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,9 @@
|
||||
"previewTitle": "Preview of the report",
|
||||
"previewLocal": "Without an issue tracker, this is the zip's issue.md.",
|
||||
"shotsFailed": "Couldn't load the screenshots",
|
||||
"removeShotFailed": "Couldn't remove the screenshot"
|
||||
"removeShotFailed": "Couldn't remove the screenshot",
|
||||
"partialImages": "The issue was created, but its text does not show the screenshots yet.",
|
||||
"exportFailed": "Couldn't build the zip"
|
||||
},
|
||||
"issue": {
|
||||
"steps": "Steps",
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"issues_rate_limited": "Óránként legfeljebb {{limit}} hibajegyet küldhetsz. Próbáld újra később.",
|
||||
"debug_session_rate_limited": "Óránként legfeljebb {{limit}} új rögzítést indíthatsz. Próbáld újra később.",
|
||||
"export_rate_limited": "Túl sok letöltés egymás után. Várj egy percet.",
|
||||
"screenshot_storage_full": "Elérted a képernyőképek {{limit}} MB-os keretét. Törölj régebbi munkamenetekből képeket, vagy várd meg, amíg lejárnak."
|
||||
"screenshot_storage_full": "Elérted a képernyőképek {{limit}} MB-os keretét. Törölj régebbi munkamenetekből képeket, vagy várd meg, amíg lejárnak.",
|
||||
"issue_sending": "A hibajegy küldése épp folyamatban van. Várd meg, amíg befejeződik."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,9 @@
|
||||
"previewTitle": "A hibajegy előnézete",
|
||||
"previewLocal": "Hibajegy-kezelő nélkül ez a zip issue.md fájlja.",
|
||||
"shotsFailed": "Nem sikerült betölteni a képernyőképeket",
|
||||
"removeShotFailed": "Nem sikerült törölni a képernyőképet"
|
||||
"removeShotFailed": "Nem sikerült törölni a képernyőképet",
|
||||
"partialImages": "A hibajegy létrejött, de a képernyőképek még nem látszanak a szövegében.",
|
||||
"exportFailed": "Nem sikerült elkészíteni a zipet"
|
||||
},
|
||||
"issue": {
|
||||
"steps": "Lépések",
|
||||
|
||||
@@ -225,6 +225,8 @@ function reportFailure(err: HttpError, quiet: boolean): void {
|
||||
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
|
||||
export interface ReqConfig {
|
||||
idempotent?: boolean;
|
||||
// Return the `Response` itself instead of parsed JSON — for a file (see the return below).
|
||||
raw?: boolean;
|
||||
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
|
||||
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
|
||||
quiet?: boolean;
|
||||
@@ -358,6 +360,10 @@ export async function req<T = unknown>(
|
||||
reportFailure(err, !!cfg.quiet);
|
||||
throw err;
|
||||
}
|
||||
// A file (`cfg.raw`): the answer itself, for the caller to read as a blob. A download that went
|
||||
// out as a plain `<a href>` instead would carry no refusal the app could show — the browser
|
||||
// would simply save the error.
|
||||
if (cfg.raw) return r as T;
|
||||
// The single audited cast in this layer: the server contract is asserted here, once, so
|
||||
// every typed api.* method (T comes from its declared return type) is checked from here up.
|
||||
return (r.status === 204 ? null : await r.json()) as T;
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface DebugSession {
|
||||
issue_url: string | null;
|
||||
/** Files a send that stopped half-way has still to deliver (retrying sends only these). */
|
||||
pending_files: string[];
|
||||
/** Whether the issue's body has still to be edited to show the screenshots it already carries. */
|
||||
images_pending: boolean;
|
||||
screenshots: number;
|
||||
/** The issue being written from it (the composer saves as it goes); null until it first does. */
|
||||
draft: DebugDraft | null;
|
||||
@@ -102,12 +104,13 @@ export const debugSessionsApi = {
|
||||
}),
|
||||
saveDebugDraft: (id: string, draft: DebugDraft): Promise<{ ok: true; session: DebugSession }> =>
|
||||
req(`${SESSIONS}/${id}/draft`, { method: "PUT", body: JSON.stringify(draft) }),
|
||||
/** The issue as a zip, built from the SAVED draft. A navigation, not a `req` — so the account
|
||||
* rides as `?account=`, as for every download. */
|
||||
debugExportUrl: (id: string): string => {
|
||||
const a = getActiveAccount();
|
||||
const url = `${SESSIONS}/${id}/export`;
|
||||
return a != null ? `${url}?account=${a}` : url;
|
||||
/** The report as a zip, built from the SAVED draft — a request, not a navigation, so a refusal
|
||||
* (the export's own pace) reaches the user as a message instead of a saved error page. */
|
||||
debugExport: async (id: string): Promise<{ blob: Blob; name: string }> => {
|
||||
const r = await req<Response>(`${SESSIONS}/${id}/export`, {}, { raw: true });
|
||||
const disposition = r.headers.get("content-disposition") ?? "";
|
||||
const name = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `siftlode-debug-${id}.zip`;
|
||||
return { blob: await r.blob(), name };
|
||||
},
|
||||
/** Send the SAVED draft to the issue tracker, or finish a send that stopped half-way. */
|
||||
sendDebugIssue: (id: string): Promise<{ ok: true; session: DebugSession }> =>
|
||||
|
||||
@@ -38,6 +38,7 @@ const recording = (level: "DEBUG" | "TRACE", minutes = 60, over = {}) => ({
|
||||
ended_reason: null,
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
|
||||
Reference in New Issue
Block a user