generated from coulomb/repo-seed
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
export {
|
||||
ACTIVE_SESSION_KEY,
|
||||
attachSessionPersister,
|
||||
clearAllSessionData,
|
||||
createSessionService,
|
||||
DuplicateSessionNameError,
|
||||
engineSnapshotKey,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user