diff --git a/src/app/sessions/CreateFirstSession.tsx b/src/app/sessions/CreateFirstSession.tsx index c18b826..1c278ec 100644 --- a/src/app/sessions/CreateFirstSession.tsx +++ b/src/app/sessions/CreateFirstSession.tsx @@ -4,10 +4,15 @@ * Inline name input + Create button. On success, navigates the hash to * the new session so the rest of the app mounts. Used both on first * launch (no sessions yet) and after the last session was deleted. + * + * Also surfaces a "Reset all data" affordance for users who want a + * clean slate without digging into devtools — wipes every + * `citation-evidence:*` key from localStorage and reloads. */ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { clearAllSessionData } from "@engine/index"; import { useSessionService } from "@work/index"; import { navigateTo } from "./routing"; @@ -16,8 +21,15 @@ export function CreateFirstSession() { const service = useSessionService(); const [name, setName] = useState(""); const [error, setError] = useState(null); + const [pendingReset, setPendingReset] = useState(false); const hasOthers = service.list().length > 0; + useEffect(() => { + if (!pendingReset) return; + const t = setTimeout(() => setPendingReset(false), 5000); + return () => clearTimeout(t); + }, [pendingReset]); + const handleCreate = useCallback(() => { setError(null); try { @@ -28,6 +40,18 @@ export function CreateFirstSession() { } }, [name, service]); + const handleResetAll = useCallback(() => { + if (!pendingReset) { + setPendingReset(true); + return; + } + clearAllSessionData(); + if (typeof window !== "undefined") { + window.location.hash = ""; + window.location.reload(); + } + }, [pendingReset]); + return (
)} +
); } diff --git a/src/app/sessions/SessionMenu.tsx b/src/app/sessions/SessionMenu.tsx index 36a6758..d9cb8b8 100644 --- a/src/app/sessions/SessionMenu.tsx +++ b/src/app/sessions/SessionMenu.tsx @@ -13,8 +13,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; +import type { SessionId } from "@shared/ids"; import type { Session } from "@shared/session"; import { useActiveSession, useSessionListTick, useSessionService } from "@work/index"; +import { clearAllSessionData } from "@engine/index"; import { navigateTo } from "./routing"; @@ -35,6 +37,9 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const [pendingDelete, setPendingDelete] = useState(false); + /** Per-row delete confirmation: id of the session armed for delete. */ + const [pendingRowDelete, setPendingRowDelete] = useState(null); + const [pendingResetAll, setPendingResetAll] = useState(false); const [error, setError] = useState(null); const wrapperRef = useRef(null); @@ -60,12 +65,28 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session setCreating(false); setRenaming(false); setPendingDelete(false); + setPendingRowDelete(null); + setPendingResetAll(false); } }; window.addEventListener("mousedown", handler); return () => window.removeEventListener("mousedown", handler); }, [open]); + // Auto-clear pending row-delete after a few seconds so the user + // doesn't accidentally double-click much later and lose data. + useEffect(() => { + if (!pendingRowDelete) return; + const t = setTimeout(() => setPendingRowDelete(null), 3000); + return () => clearTimeout(t); + }, [pendingRowDelete]); + + useEffect(() => { + if (!pendingResetAll) return; + const t = setTimeout(() => setPendingResetAll(false), 5000); + return () => clearTimeout(t); + }, [pendingResetAll]); + const switchTo = useCallback( (sessionId: import("@shared/ids").SessionId) => { navigateTo({ sessionId, mode: "review" }); @@ -111,6 +132,41 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session navigateTo({ sessionId: null, mode: "review" }); }, [active, pendingDelete, service]); + const handleRowDelete = useCallback( + (e: React.MouseEvent, id: SessionId) => { + e.stopPropagation(); + if (pendingRowDelete !== id) { + setPendingRowDelete(id); + return; + } + const wasActive = active?.id === id; + service.delete(id); + setPendingRowDelete(null); + if (wasActive) { + setOpen(false); + navigateTo({ sessionId: null, mode: "review" }); + } + }, + [active, pendingRowDelete, service], + ); + + const handleResetAll = useCallback(() => { + if (!pendingResetAll) { + setPendingResetAll(true); + return; + } + clearAllSessionData(); + setPendingResetAll(false); + setOpen(false); + // Force a full reload so every in-memory cache (sessions repo, + // byte stores, engine snapshots) starts fresh from the cleared + // localStorage. + if (typeof window !== "undefined") { + window.location.hash = ""; + window.location.reload(); + } + }, [pendingResetAll]); + return (
- {sessions.map((s) => ( - - ))} + {sessions.map((s) => { + const rowArmed = pendingRowDelete === s.id; + return ( +
+ + +
+ ); + })}
)} @@ -316,6 +398,20 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session )} +
+ + {error && (
= globalThis.localStorage, +): number { + const toRemove: string[] = []; + for (let i = 0; i < storage.length; i++) { + const k = storage.key(i); + if (k && k.startsWith("citation-evidence:")) toRemove.push(k); + } + for (const k of toRemove) storage.removeItem(k); + return toRemove.length; +} + export function restoreSessionsFromStorage( repo: SessionRepository, service: SessionService, diff --git a/src/work/ViewerShell.tsx b/src/work/ViewerShell.tsx index 96d1d4e..1807776 100644 --- a/src/work/ViewerShell.tsx +++ b/src/work/ViewerShell.tsx @@ -44,6 +44,13 @@ export function ViewerShell() { const fileUrl = useMemo(() => { if (!document) return null; + // CE-WP-0005: uploads + sample sessions stash a `blob:` URL on + // `document.uri` via the per-session `PdfByteStore`. Prefer that + // over the legacy fixture-path fallback so user uploads don't get + // resolved against `/fixtures/pdfs/` (which would either 404 or — + // worse — silently return the wrong file when the filename happens + // to collide with a bundled fixture). + if (document.uri) return document.uri; const titleOrId = document.title ?? document.id; return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`; }, [document]);