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
|
* 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
|
* 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.
|
* 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 { useSessionService } from "@work/index";
|
||||||
|
|
||||||
import { navigateTo } from "./routing";
|
import { navigateTo } from "./routing";
|
||||||
@@ -16,8 +21,15 @@ export function CreateFirstSession() {
|
|||||||
const service = useSessionService();
|
const service = useSessionService();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingReset, setPendingReset] = useState(false);
|
||||||
const hasOthers = service.list().length > 0;
|
const hasOthers = service.list().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingReset) return;
|
||||||
|
const t = setTimeout(() => setPendingReset(false), 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [pendingReset]);
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
const handleCreate = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -28,6 +40,18 @@ export function CreateFirstSession() {
|
|||||||
}
|
}
|
||||||
}, [name, service]);
|
}, [name, service]);
|
||||||
|
|
||||||
|
const handleResetAll = useCallback(() => {
|
||||||
|
if (!pendingReset) {
|
||||||
|
setPendingReset(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearAllSessionData();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.hash = "";
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [pendingReset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="empty-state"
|
data-testid="empty-state"
|
||||||
@@ -99,6 +123,23 @@ export function CreateFirstSession() {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
import type { Session } from "@shared/session";
|
import type { Session } from "@shared/session";
|
||||||
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index";
|
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index";
|
||||||
|
import { clearAllSessionData } from "@engine/index";
|
||||||
|
|
||||||
import { navigateTo } from "./routing";
|
import { navigateTo } from "./routing";
|
||||||
|
|
||||||
@@ -35,6 +37,9 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [pendingDelete, setPendingDelete] = useState(false);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -60,12 +65,28 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
setCreating(false);
|
setCreating(false);
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
setPendingDelete(false);
|
setPendingDelete(false);
|
||||||
|
setPendingRowDelete(null);
|
||||||
|
setPendingResetAll(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("mousedown", handler);
|
window.addEventListener("mousedown", handler);
|
||||||
return () => window.removeEventListener("mousedown", handler);
|
return () => window.removeEventListener("mousedown", handler);
|
||||||
}, [open]);
|
}, [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(
|
const switchTo = useCallback(
|
||||||
(sessionId: import("@shared/ids").SessionId) => {
|
(sessionId: import("@shared/ids").SessionId) => {
|
||||||
navigateTo({ sessionId, mode: "review" });
|
navigateTo({ sessionId, mode: "review" });
|
||||||
@@ -111,6 +132,41 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
navigateTo({ sessionId: null, mode: "review" });
|
navigateTo({ sessionId: null, mode: "review" });
|
||||||
}, [active, pendingDelete, service]);
|
}, [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 (
|
return (
|
||||||
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
|
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
|
||||||
<button
|
<button
|
||||||
@@ -155,22 +211,48 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
|
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
|
||||||
Switch to…
|
Switch to…
|
||||||
</div>
|
</div>
|
||||||
{sessions.map((s) => (
|
{sessions.map((s) => {
|
||||||
<button
|
const rowArmed = pendingRowDelete === s.id;
|
||||||
key={s.id}
|
return (
|
||||||
type="button"
|
<div
|
||||||
role="menuitem"
|
key={s.id}
|
||||||
data-testid={`session-switch-${s.id}`}
|
style={{
|
||||||
onClick={() => switchTo(s.id)}
|
display: "flex",
|
||||||
style={{
|
alignItems: "center",
|
||||||
...menuItemStyle,
|
background: active?.id === s.id ? "#e8f0ff" : "transparent",
|
||||||
background: active?.id === s.id ? "#e8f0ff" : "transparent",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<button
|
||||||
{s.name}
|
type="button"
|
||||||
{active?.id === s.id ? " · open" : ""}
|
role="menuitem"
|
||||||
</button>
|
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} />
|
<hr style={dividerStyle} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -316,6 +398,20 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
</button>
|
</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 && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
data-testid="session-menu-error"
|
data-testid="session-menu-error"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
ACTIVE_SESSION_KEY,
|
ACTIVE_SESSION_KEY,
|
||||||
attachSessionPersister,
|
attachSessionPersister,
|
||||||
|
clearAllSessionData,
|
||||||
createSessionService,
|
createSessionService,
|
||||||
DuplicateSessionNameError,
|
DuplicateSessionNameError,
|
||||||
engineSnapshotKey,
|
engineSnapshotKey,
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ export function attachSessionPersister(
|
|||||||
writeIndex();
|
writeIndex();
|
||||||
try {
|
try {
|
||||||
storage.removeItem(engineSnapshotKey(sessionId));
|
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) {
|
} catch (err) {
|
||||||
console.warn("attachSessionPersister: snapshot cleanup failed", 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
|
* (which is attached after restore) doesn't immediately re-write what
|
||||||
* we just read.
|
* 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(
|
export function restoreSessionsFromStorage(
|
||||||
repo: SessionRepository,
|
repo: SessionRepository,
|
||||||
service: SessionService,
|
service: SessionService,
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export function ViewerShell() {
|
|||||||
|
|
||||||
const fileUrl = useMemo(() => {
|
const fileUrl = useMemo(() => {
|
||||||
if (!document) return null;
|
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;
|
const titleOrId = document.title ?? document.id;
|
||||||
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
||||||
}, [document]);
|
}, [document]);
|
||||||
|
|||||||
Reference in New Issue
Block a user