Files
citation-evidence/src/app/sessions/SessionMenu.tsx
tegwick 67bcc2423c 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>
2026-05-26 20:49:37 +02:00

459 lines
14 KiB
TypeScript

/**
* SessionMenu — top-bar dropdown that drives the SessionService.
*
* Holds the only place in the UI where sessions get created, renamed,
* deleted, and switched. Export/Import ZIP menu items are slots —
* T06/T07 wire them.
*
* Switching sessions writes the new id into the URL hash; the routing
* layer is the source of truth (see `routing.ts`). That keeps deep
* links + browser back/forward behaving naturally.
*/
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";
interface SessionMenuProps {
readonly onExportZip?: () => void;
readonly onImportZip?: () => void;
readonly onOpenSamples?: () => void;
}
export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: SessionMenuProps) {
const service = useSessionService();
const tick = useSessionListTick();
const active = useActiveSession();
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
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);
const sessions = useMemo(() => {
// sorted by lastOpenedAt desc, then by createdAt desc
void tick;
const list = [...service.list()];
list.sort((a: Session, b: Session) => {
const aKey = a.lastOpenedAt ?? a.createdAt;
const bKey = b.lastOpenedAt ?? b.createdAt;
return bKey.localeCompare(aKey);
});
return list;
}, [service, tick]);
// Click outside closes the menu.
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!wrapperRef.current) return;
if (!wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
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" });
setOpen(false);
},
[],
);
const handleCreate = useCallback(() => {
setError(null);
try {
const created = service.create(newName);
setNewName("");
setCreating(false);
setOpen(false);
navigateTo({ sessionId: created.id, mode: "review" });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [newName, service]);
const handleRename = useCallback(() => {
if (!active) return;
setError(null);
try {
service.rename(active.id, renameValue);
setRenaming(false);
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [active, renameValue, service]);
const handleDelete = useCallback(() => {
if (!active) return;
if (!pendingDelete) {
setPendingDelete(true);
return;
}
service.delete(active.id);
setPendingDelete(false);
setOpen(false);
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
type="button"
aria-haspopup="menu"
aria-expanded={open}
data-testid="session-menu-toggle"
onClick={() => setOpen((v) => !v)}
style={{
fontSize: 12,
padding: "4px 10px",
border: "1px solid #888",
background: "white",
cursor: "pointer",
minWidth: 160,
textAlign: "left",
}}
>
{active ? active.name : "No session"}
<span style={{ float: "right", color: "#888" }}></span>
</button>
{open && (
<div
role="menu"
data-testid="session-menu-panel"
style={{
position: "absolute",
top: 28,
left: 0,
zIndex: 30,
background: "white",
border: "1px solid #888",
borderRadius: 3,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
padding: 4,
minWidth: 240,
fontSize: 12,
}}
>
{sessions.length > 0 && (
<>
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
Switch to
</div>
{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} />
</>
)}
{!creating && (
<button
type="button"
role="menuitem"
data-testid="session-menu-new"
onClick={() => {
setError(null);
setCreating(true);
setNewName("");
}}
style={menuItemStyle}
>
New session
</button>
)}
{creating && (
<div style={{ padding: 4, display: "flex", gap: 4 }}>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Session name"
data-testid="session-new-input"
style={{ flex: 1, fontSize: 12, padding: 4 }}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") setCreating(false);
}}
/>
<button
type="button"
onClick={handleCreate}
data-testid="session-new-confirm"
style={smallButtonStyle}
>
Create
</button>
</div>
)}
{active && (
<>
<hr style={dividerStyle} />
{!renaming && (
<button
type="button"
role="menuitem"
data-testid="session-menu-rename"
onClick={() => {
setError(null);
setRenaming(true);
setRenameValue(active.name);
}}
style={menuItemStyle}
>
Rename
</button>
)}
{renaming && (
<div style={{ padding: 4, display: "flex", gap: 4 }}>
<input
autoFocus
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
data-testid="session-rename-input"
style={{ flex: 1, fontSize: 12, padding: 4 }}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
if (e.key === "Escape") setRenaming(false);
}}
/>
<button
type="button"
onClick={handleRename}
data-testid="session-rename-confirm"
style={smallButtonStyle}
>
Save
</button>
</div>
)}
<button
type="button"
role="menuitem"
data-testid="session-menu-delete"
onClick={handleDelete}
style={{ ...menuItemStyle, color: "#7a0000" }}
>
{pendingDelete ? "Confirm delete?" : "Delete…"}
</button>
</>
)}
{(onExportZip || onImportZip || onOpenSamples) && (
<hr style={dividerStyle} />
)}
{onExportZip && active && (
<button
type="button"
role="menuitem"
data-testid="session-menu-export"
onClick={() => {
setOpen(false);
onExportZip();
}}
style={menuItemStyle}
>
Export ZIP
</button>
)}
{onImportZip && (
<button
type="button"
role="menuitem"
data-testid="session-menu-import"
onClick={() => {
setOpen(false);
onImportZip();
}}
style={menuItemStyle}
>
Import ZIP
</button>
)}
{onOpenSamples && (
<button
type="button"
role="menuitem"
data-testid="session-menu-samples"
onClick={() => {
setOpen(false);
onOpenSamples();
}}
style={menuItemStyle}
>
Sample sessions
</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"
style={{
padding: 6,
background: "#fff4f4",
color: "#7a0000",
fontSize: 11,
marginTop: 4,
}}
>
{error}
</div>
)}
</div>
)}
</div>
);
}
const menuItemStyle: CSSProperties = {
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: "4px 8px",
cursor: "pointer",
fontSize: 12,
};
const smallButtonStyle: CSSProperties = {
fontSize: 12,
padding: "2px 8px",
border: "1px solid #888",
background: "white",
cursor: "pointer",
};
const dividerStyle: CSSProperties = {
border: "none",
borderTop: "1px solid #eee",
margin: "4px 0",
};