generated from coulomb/repo-seed
Implement CE-WP-0005 T01-T08: demo app — sessions, uploads, ZIP archive
Turn the MVP into a self-contained demo. Users now:
1. Land on an empty-state and create a named session.
2. Drag-drop or pick arbitrary PDFs into that session.
3. Annotate, build evidence, link to form fields — all session-scoped.
4. Export the whole session as a single .zip archive (manifest +
per-document PDFs).
5. Import a .zip back — into a new session, or merged into an
existing one (documents deduped by SHA-256 fingerprint;
annotations/evidence/links added additively).
Architecture:
- New shared types: SessionId, Session, SessionArchiveManifest +
parseSessionArchiveManifest with schema-version validation.
- SessionService (engine/services/sessions.ts) handles lifecycle
(create/rename/delete/setActive) + emits 4 new events through its
own bus; SharedContracts.md §4 lists the additions.
- SessionProvider (work/SessionContext.tsx) owns the cross-session
state: service, per-session PdfByteStore registry, per-session
version counter that drives EngineProvider remounts after imports.
- EngineProvider becomes session-aware (sessionId prop drives per-
session localStorage keys). Bumping engineRevision after
restoreFromStorage forces consumers to re-render so restored repos
show up immediately.
- PdfByteStore (source/pdf/byte-store.ts) holds Uint8Array bytes per
document and mints blob URLs; ingestPdfFromFile is the upload
entry-point that wraps the existing ingestPdf pipeline.
- ADR-0008 locks the ZIP layout (manifest.json + documents/<id>.pdf),
the manifest schema (schemaVersion 1), and the merge-on-collision
policy. JSZip is the only new dependency.
- App.tsx restructured: SessionProvider at the root, EngineProvider
keyed by ${sessionId}:${version}, hash routing #/s/<id>[/forms/demo],
SessionMenu top-bar, CreateFirstSession empty state.
- New DocumentRemoved event for per-document delete cleanup in
CollectionList; engine.documents.remove() is the new service method.
Tests:
- Unit: 16 SessionService lifecycle + persistence tests;
per-session snapshot round-trip; PdfByteStore + ingestPdfFromFile;
SessionArchive parser; exportSessionZip + importSessionZip with
create + merge + corrupt-archive paths.
- DOM: UploadDropzone, session-scoped CollectionList delete,
SessionMenu create/switch/rename, routing parser.
- E2E: tests/integration/session-export-reimport.dom.test.tsx walks
the full create → annotate → export → reimport flow and asserts
the additive merge (deduped doc + doubled evidence rows).
- Legacy E2Es updated to use a seed-session helper instead of the
removed fixture-button flow.
Known limitation (documented in ADR-0008): re-importing your own
freshly-exported ZIP creates duplicate annotations. Forward pointer
left for an importBundleId follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
362
src/app/sessions/SessionMenu.tsx
Normal file
362
src/app/sessions/SessionMenu.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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 { Session } from "@shared/session";
|
||||
import { useActiveSession, useSessionListTick, useSessionService } from "@work/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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousedown", handler);
|
||||
return () => window.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
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]);
|
||||
|
||||
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) => (
|
||||
<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>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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",
|
||||
};
|
||||
Reference in New Issue
Block a user