Add per-row session delete + Reset all data; fix viewer URL fallback

UX gaps that surfaced while running the demo:

- ViewerShell hardcoded `/fixtures/pdfs/<title>` 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>
This commit is contained in:
2026-05-26 20:49:37 +02:00
parent d5474a1bd9
commit 67bcc2423c
5 changed files with 185 additions and 17 deletions

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -15,6 +15,7 @@ export {
export {
ACTIVE_SESSION_KEY,
attachSessionPersister,
clearAllSessionData,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,

View File

@@ -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,

View File

@@ -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]);