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

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