From 67bcc2423ca8df09a60289440c45fffc49913ad3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 26 May 2026 20:49:37 +0200 Subject: [PATCH] Add per-row session delete + Reset all data; fix viewer URL fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX gaps that surfaced while running the demo: - ViewerShell hardcoded `/fixtures/pdfs/` for the PDF URL, ignoring the `document.uri` blob URL that uploaded PDFs carry. The viewer either 404'd or — worse — silently served a fixture whose filename happened to collide. Prefer document.uri when present. - SessionMenu only let you delete the *active* session. Added a small per-row "✕" button next to every session in the Switch-to list so a user can drop a session's data without first switching to it. Same click-to-confirm pattern as the existing Delete action. - Added a "Reset all data…" affordance in both the SessionMenu and the empty-state landing. Calls a new `clearAllSessionData()` helper that wipes every `citation-evidence:*` key from localStorage, then forces a reload so all in-memory caches start fresh. - `attachSessionPersister.writeOnDelete` was leaking the per-session `active-document-id:v1` key on every session delete. Now removed alongside the engine snapshot key. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- src/app/sessions/CreateFirstSession.tsx | 43 +++++++- src/app/sessions/SessionMenu.tsx | 128 +++++++++++++++++++++--- src/engine/services/index.ts | 1 + src/engine/services/sessions.ts | 23 +++++ src/work/ViewerShell.tsx | 7 ++ 5 files changed, 185 insertions(+), 17 deletions(-) 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<string | null>(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 ( <div data-testid="empty-state" @@ -99,6 +123,23 @@ export function CreateFirstSession() { {error} </div> )} + <button + type="button" + onClick={handleResetAll} + data-testid="empty-state-reset-all" + title="Wipe every session, uploaded PDF, and annotation from this browser — then reload." + style={{ + marginTop: 24, + fontSize: 11, + color: "#7a0000", + background: "transparent", + border: "none", + cursor: "pointer", + textDecoration: "underline", + }} + > + {pendingReset ? "Confirm — wipe everything?" : "Reset all data…"} + </button> </div> ); } 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<SessionId | null>(null); + const [pendingResetAll, setPendingResetAll] = useState(false); const [error, setError] = useState<string | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(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 ( <div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu"> <button @@ -155,22 +211,48 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session <div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}> Switch to… </div> - {sessions.map((s) => ( - <button - key={s.id} - type="button" - role="menuitem" - data-testid={`session-switch-${s.id}`} - onClick={() => switchTo(s.id)} - style={{ - ...menuItemStyle, - background: active?.id === s.id ? "#e8f0ff" : "transparent", - }} - > - {s.name} - {active?.id === s.id ? " · open" : ""} - </button> - ))} + {sessions.map((s) => { + const rowArmed = pendingRowDelete === s.id; + return ( + <div + key={s.id} + style={{ + display: "flex", + alignItems: "center", + background: active?.id === s.id ? "#e8f0ff" : "transparent", + }} + > + <button + type="button" + role="menuitem" + data-testid={`session-switch-${s.id}`} + onClick={() => switchTo(s.id)} + style={{ ...menuItemStyle, flex: 1 }} + > + {s.name} + {active?.id === s.id ? " · open" : ""} + </button> + <button + type="button" + aria-label={`Delete session ${s.name}`} + data-testid={`session-row-delete-${s.id}`} + onClick={(e) => handleRowDelete(e, s.id)} + title={rowArmed ? "Click again to confirm" : "Delete session and drop all its data"} + style={{ + background: rowArmed ? "#ffe5e5" : "transparent", + border: "none", + color: "#7a0000", + cursor: "pointer", + padding: "4px 8px", + fontSize: 14, + lineHeight: 1, + }} + > + {rowArmed ? "Confirm?" : "✕"} + </button> + </div> + ); + })} <hr style={dividerStyle} /> </> )} @@ -316,6 +398,20 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session </button> )} + <hr style={dividerStyle} /> + <button + type="button" + role="menuitem" + data-testid="session-menu-reset-all" + onClick={handleResetAll} + title="Wipe every session, every uploaded PDF, every annotation — and reload." + style={{ ...menuItemStyle, color: "#7a0000" }} + > + {pendingResetAll + ? "Confirm — wipe everything?" + : "Reset all data…"} + </button> + {error && ( <div data-testid="session-menu-error" diff --git a/src/engine/services/index.ts b/src/engine/services/index.ts index 19268a0..6e09266 100644 --- a/src/engine/services/index.ts +++ b/src/engine/services/index.ts @@ -15,6 +15,7 @@ export { export { ACTIVE_SESSION_KEY, attachSessionPersister, + clearAllSessionData, createSessionService, DuplicateSessionNameError, engineSnapshotKey, diff --git a/src/engine/services/sessions.ts b/src/engine/services/sessions.ts index 6919ae5..aad625a 100644 --- a/src/engine/services/sessions.ts +++ b/src/engine/services/sessions.ts @@ -238,6 +238,9 @@ export function attachSessionPersister( writeIndex(); try { storage.removeItem(engineSnapshotKey(sessionId)); + // Also drop the per-session active-document-id key — otherwise + // it gets orphaned and accumulates in localStorage forever. + storage.removeItem(`citation-evidence:session:${sessionId}:active-document-id:v1`); } catch (err) { console.warn("attachSessionPersister: snapshot cleanup failed", err); } @@ -265,6 +268,26 @@ export interface RestoreSessionsResult { * (which is attached after restore) doesn't immediately re-write what * we just read. */ +/** + * Wipe every `citation-evidence:*` key from storage. Intended for the + * "Reset all data" UX affordance — gives the user a clean slate without + * having to dig into devtools. Returns the number of keys removed. + * + * The implementation enumerates the storage's keys (via `Storage.length` + * + `Storage.key(i)`) because there is no namespaced `clear()` API. + */ +export function clearAllSessionData( + storage: Pick<Storage, "getItem" | "setItem" | "removeItem" | "key" | "length"> = 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]);