generated from coulomb/repo-seed
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>
459 lines
14 KiB
TypeScript
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",
|
|
};
|