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:
tegwick 2026-05-26 14:57:28 +02:00
parent 8632f7b04a
commit 779ae0d317
53 changed files with 5657 additions and 372 deletions

View file

@ -1,81 +1,198 @@
/**
* App the citation-evidence MVP shell.
* App citation-evidence demo shell (CE-WP-0005).
*
* Composes the two top-level layouts:
* Composition:
*
* - Review mode (CE-WP-0002): collection list / viewer / evidence sidebar.
* - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip,
* with click-to-link interaction.
* SessionProvider (cross-session)
* AppShell owns routing + the top bar
* if no active session CreateFirstSession (empty state)
* else
* EngineProvider key={sessionId} sessionId={sessionId}
* BinderProvider bus={engine.bus}
* ReviewLayout | FormsApp (per `mode`)
*
* Mode selection is driven by `location.hash`: `#/forms/demo` lands in
* Forms mode; anything else (including empty) lands in Review mode. The
* top bar toggles between them. We keep the hash sync so reload + deep
* links work; T08's E2E asserts the `/forms/demo` navigation path.
*
* Engine and binder providers are both mounted at the App root so
* evidence/annotations/links survive switching tabs.
* The hash is the single source of truth for `{sessionId, mode}`. The
* SessionService's active id is kept in sync with the hash via a
* useEffect inside `AppShell`. Deep links to unknown sessions redirect
* to the empty state with a toast.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BinderProvider } from "@binder/index";
import {
EngineProvider,
SessionProvider,
useActiveSession,
useEngine,
usePdfByteStore,
useSessionByteStoreRegistry,
useSessionService,
useSessionsHydrated,
useSessionVersion,
useSessionVersionBumper,
} from "@work/index";
import { FormsApp } from "./forms/FormsApp";
import { ReviewLayout } from "./ReviewLayout";
type Mode = "review" | "forms";
import {
CreateFirstSession,
EMPTY_ROUTE,
exportSessionZip,
importSessionZip,
parseRoute,
navigateTo,
SessionMenu,
sessionZipFilename,
Toast,
triggerSessionDownload,
UploadDropzone,
useToast,
type AppMode,
type AppRoute,
} from "./sessions";
const FORMS_HASH = "#/forms/demo";
function readModeFromHash(): Mode {
if (typeof window === "undefined") return "review";
return window.location.hash === FORMS_HASH ? "forms" : "review";
function readRoute(): AppRoute {
if (typeof window === "undefined") return EMPTY_ROUTE;
return parseRoute(window.location.hash);
}
function writeModeToHash(mode: Mode) {
if (typeof window === "undefined") return;
const target = mode === "forms" ? FORMS_HASH : "";
if (window.location.hash !== target) {
if (target) {
window.location.hash = target;
} else {
// Clear hash without leaving "#" trailing in the URL bar.
history.replaceState(null, "", window.location.pathname + window.location.search);
}
}
}
function ModeRouter() {
const [mode, setMode] = useState<Mode>(() => readModeFromHash());
function useHashRoute(): AppRoute {
const [route, setRoute] = useState<AppRoute>(() => readRoute());
useEffect(() => {
function onHash() {
setMode(readModeFromHash());
}
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
const handler = () => setRoute(readRoute());
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, []);
return route;
}
const handleModeChange = (next: Mode) => {
writeModeToHash(next);
setMode(next);
};
function AppShell() {
const route = useHashRoute();
const service = useSessionService();
const hydrated = useSessionsHydrated();
const toast = useToast();
// Guards the "unknown session id → toast + redirect" path against an
// infinite loop: `useToast.show` creates a fresh `toast` object every
// render, which would otherwise re-fire the effect.
const lastHandledSessionIdRef = useRef<string | null>(null);
// Sync hash → SessionService.setActive. Unknown session ids fall back
// to the empty state with a toast.
useEffect(() => {
if (!hydrated) return;
const key = route.sessionId ?? "";
if (lastHandledSessionIdRef.current === key) return;
lastHandledSessionIdRef.current = key;
if (route.sessionId === null) {
service.setActive(null);
return;
}
const exists = service.get(route.sessionId);
if (exists) {
service.setActive(route.sessionId);
} else {
toast.show("Session not found — opened the empty state instead", "error");
navigateTo(EMPTY_ROUTE);
}
}, [route.sessionId, service, hydrated, toast]);
if (!hydrated) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
fontFamily: "system-ui, sans-serif",
color: "#888",
}}
>
Loading
</div>
);
}
if (route.sessionId === null) {
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<EmptyTopBar />
<div style={{ flex: 1, minHeight: 0 }}>
<CreateFirstSession />
</div>
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</div>
);
}
return <ActiveAppFrame route={route} toast={toast} />;
}
function ActiveAppFrame({
route,
toast,
}: {
route: AppRoute;
toast: ReturnType<typeof useToast>;
}) {
// EngineProvider remounts whenever the session id OR the per-session
// version counter changes. Import-into-active-session bumps the version
// so the new state from storage is picked up.
const sessionId = route.sessionId!;
const version = useSessionVersion(sessionId);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
<TopBar mode={mode} onModeChange={handleModeChange} />
<div style={{ flex: 1, minHeight: 0 }}>
{mode === "review" ? <ReviewLayout /> : <FormsApp />}
</div>
<EngineProvider key={`${sessionId}:${version}`} sessionId={sessionId}>
<ActiveTopBar route={route} showToast={toast.show} />
<div style={{ flex: 1, minHeight: 0 }}>
<SessionScopedTree mode={route.mode} />
</div>
</EngineProvider>
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</div>
);
}
function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
function SessionScopedTree({ mode }: { mode: AppMode }) {
const engine = useEngine();
return (
<BinderProvider bus={engine.bus}>
{mode === "forms" ? <FormsApp /> : <ReviewLayout upload={<UploadDropzone />} />}
</BinderProvider>
);
}
function EmptyTopBar() {
const sessionService = useSessionService();
const registry = useSessionByteStoreRegistry();
const bumpVersion = useSessionVersionBumper();
const toast = useToast(); // local toast — empty state has its own
const handleImport = useCallback(async (file: File) => {
try {
const result = await importSessionZip(file, {
sessionService,
getOrCreateByteStore: registry.getOrCreateByteStore,
bumpSessionVersion: bumpVersion,
});
navigateTo({ sessionId: result.sessionId, mode: "review" });
toast.show(
result.outcome === "created"
? "Imported as a new session"
: "Merged into existing session",
"success",
);
} catch (err) {
toast.show(
err instanceof Error ? `Import failed: ${err.message}` : "Import failed",
"error",
);
}
}, [sessionService, registry, bumpVersion, toast]);
return (
<header
style={{
@ -89,20 +206,122 @@ function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
}}
>
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
<button
onClick={() => onModeChange("review")}
aria-pressed={mode === "review"}
style={tabStyle(mode === "review")}
>
Review
</button>
<button
onClick={() => onModeChange("forms")}
aria-pressed={mode === "forms"}
style={tabStyle(mode === "forms")}
>
Forms
</button>
<SessionMenu onImportZip={() => pickAndImport(handleImport)} />
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
</header>
);
}
function pickAndImport(onPicked: (file: File) => void): void {
if (typeof document === "undefined") return;
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip,application/zip";
input.onchange = () => {
const file = input.files?.[0];
if (file) onPicked(file);
};
input.click();
}
function ActiveTopBar({
route,
showToast,
}: {
route: AppRoute;
showToast: (msg: string, tone?: "success" | "error" | "info") => void;
}) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const session = useActiveSession();
const sessionService = useSessionService();
const registry = useSessionByteStoreRegistry();
const bumpVersion = useSessionVersionBumper();
const handleModeChange = useCallback(
(next: AppMode) => {
if (!route.sessionId) return;
navigateTo({ sessionId: route.sessionId, mode: next });
},
[route.sessionId],
);
const handleExport = useCallback(async () => {
if (!session) return;
try {
const blob = await exportSessionZip(engine, byteStore, session);
triggerSessionDownload(blob, sessionZipFilename(session));
showToast("Session exported", "success");
} catch (err) {
showToast(
err instanceof Error ? `Export failed: ${err.message}` : "Export failed",
"error",
);
}
}, [engine, byteStore, session, showToast]);
const handleImport = useCallback(
async (file: File) => {
try {
const result = await importSessionZip(file, {
sessionService,
getOrCreateByteStore: registry.getOrCreateByteStore,
bumpSessionVersion: bumpVersion,
});
navigateTo({ sessionId: result.sessionId, mode: "review" });
const totals = result.stats;
const summary =
result.outcome === "created"
? `Imported new session — ${totals.documentsAdded} document${totals.documentsAdded === 1 ? "" : "s"}, ${totals.annotationsAdded} annotation${totals.annotationsAdded === 1 ? "" : "s"}`
: `Merged into existing — ${totals.documentsAdded} new doc${totals.documentsAdded === 1 ? "" : "s"}, ${totals.documentsDeduped} deduped`;
showToast(summary, "success");
} catch (err) {
showToast(
err instanceof Error ? `Import failed: ${err.message}` : "Import failed",
"error",
);
}
},
[sessionService, registry, bumpVersion, showToast],
);
const tabs = useMemo(
() => [
{ id: "review" as const, label: "Review" },
{ id: "forms" as const, label: "Forms" },
],
[],
);
return (
<header
style={{
display: "flex",
gap: 8,
padding: "6px 12px",
borderBottom: "1px solid #ddd",
background: "#fafafa",
fontFamily: "system-ui, sans-serif",
alignItems: "center",
}}
>
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
<SessionMenu
onExportZip={() => void handleExport()}
onImportZip={() => pickAndImport((file) => void handleImport(file))}
/>
<div style={{ display: "flex", gap: 4, marginLeft: 12 }}>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => handleModeChange(t.id)}
aria-pressed={route.mode === t.id}
style={tabStyle(route.mode === t.id)}
>
{t.label}
</button>
))}
</div>
</header>
);
}
@ -118,19 +337,10 @@ function tabStyle(active: boolean) {
};
}
function AppInner() {
const engine = useEngine();
return (
<BinderProvider bus={engine.bus}>
<ModeRouter />
</BinderProvider>
);
}
export function App() {
return (
<EngineProvider>
<AppInner />
</EngineProvider>
<SessionProvider>
<AppShell />
</SessionProvider>
);
}

View file

@ -5,15 +5,27 @@
* Collection Document Viewer Evidence
* List Sidebar
*
*
* CE-WP-0005 added an `upload` slot for the active session's upload
* dropzone, threaded in by the app composition root so this component
* stays inside the `work` boundary (which cannot import `app`).
*/
import type { ReactNode } from "react";
import {
CollectionList,
EvidenceSidebar,
ViewerShell,
useActiveSession,
} from "@work/index";
export function ReviewLayout() {
export interface ReviewLayoutProps {
readonly upload?: ReactNode;
}
export function ReviewLayout({ upload }: ReviewLayoutProps) {
const session = useActiveSession();
return (
<div
style={{
@ -22,7 +34,7 @@ export function ReviewLayout() {
fontFamily: "system-ui, sans-serif",
}}
>
<CollectionList />
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
<ViewerShell />
<EvidenceSidebar />
</div>

View file

@ -0,0 +1,104 @@
/**
* Empty-state landing shown when no session is active.
*
* 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.
*/
import { useCallback, useState } from "react";
import { useSessionService } from "@work/index";
import { navigateTo } from "./routing";
export function CreateFirstSession() {
const service = useSessionService();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const hasOthers = service.list().length > 0;
const handleCreate = useCallback(() => {
setError(null);
try {
const created = service.create(name);
navigateTo({ sessionId: created.id, mode: "review" });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [name, service]);
return (
<div
data-testid="empty-state"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
fontFamily: "system-ui, sans-serif",
background: "#fafafa",
gap: 12,
}}
>
<h1 style={{ fontSize: 22, margin: 0 }}>citation-evidence</h1>
<p style={{ fontSize: 14, color: "#555", margin: 0 }}>
{hasOthers
? "Pick a session from the menu above, or create a new one."
: "Create your first session to get started."}
</p>
<div style={{ display: "flex", gap: 6 }}>
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Session name (e.g. Lease 2024)"
data-testid="empty-state-input"
style={{
fontSize: 14,
padding: "6px 10px",
border: "1px solid #888",
borderRadius: 3,
minWidth: 260,
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
<button
type="button"
onClick={handleCreate}
data-testid="empty-state-create"
style={{
fontSize: 14,
padding: "6px 14px",
border: "1px solid #0050b3",
background: "#0050b3",
color: "white",
borderRadius: 3,
cursor: "pointer",
}}
>
Create session
</button>
</div>
{error && (
<div
data-testid="empty-state-error"
style={{
fontSize: 12,
color: "#7a0000",
background: "#fff4f4",
padding: "4px 10px",
border: "1px solid #f5cccc",
borderRadius: 3,
}}
>
{error}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,125 @@
/**
* SampleSessions optional fixture-driven quick-start.
*
* The MVP collection list (pre-CE-WP-0005) ingested fixture PDFs over
* `fetch`. After the session refactor that workflow is no longer the
* default; it survives here as an optional way to seed the active
* session with a sample document for demo and testing.
*
* Mounted by `SessionMenu` (T04) under a "Sample sessions ▸" entry
* and by the integration tests under CE-WP-0002-T09 / -T05 that need
* a known-good document.
*/
import { useCallback, useState } from "react";
import { ingestPdf } from "@source/index";
import type { DocumentId } from "@shared/ids";
import {
useActiveDocumentId,
useEngine,
usePdfByteStore,
} from "@work/index";
import manifest from "../../../fixtures/pdfs/manifest.json";
interface Fixture {
id: string;
filename: string;
description: string;
page_count: number;
}
const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures;
export function SampleSessions() {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { id: activeId, setId } = useActiveDocumentId();
const [loadingFixtureId, setLoadingFixtureId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [byFixture, setByFixture] = useState<Record<string, DocumentId>>({});
const handleLoad = useCallback(
async (fixture: Fixture) => {
setError(null);
const existing = byFixture[fixture.id];
if (existing) {
setId(existing);
return;
}
setLoadingFixtureId(fixture.id);
try {
const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`fetch ${url}${response.status}`);
}
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
const { document, representation } = await ingestPdf(bytes, {
filename: fixture.filename,
});
// Push the bytes into the byte store so the viewer can mount them via
// the same blob URL machinery used by the upload path. The document
// record carries the blob URL on `uri` for the viewer adapter.
const record = byteStore.put(document.id, bytes);
engine.documents.register({
document: { ...document, uri: record.blobUrl },
representation,
});
setByFixture((prev) => ({ ...prev, [fixture.id]: document.id }));
setId(document.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingFixtureId(null);
}
},
[byFixture, byteStore, engine, setId],
);
return (
<div data-testid="sample-sessions">
<p style={{ fontSize: 12, color: "#555", margin: "0 0 6px" }}>
Load a fixture PDF as a sample document for the active session.
</p>
{error && (
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
{error}
</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{FIXTURES.map((f) => {
const isLoading = loadingFixtureId === f.id;
const documentId = byFixture[f.id];
const isActive = documentId !== undefined && documentId === activeId;
return (
<li key={f.id} style={{ marginBottom: 6 }}>
<button
onClick={() => void handleLoad(f)}
disabled={isLoading}
style={{
display: "block",
width: "100%",
textAlign: "left",
background: isActive ? "#e8f0ff" : "white",
border: "1px solid #ccc",
padding: 6,
cursor: isLoading ? "wait" : "pointer",
fontSize: 12,
}}
>
<div style={{ fontWeight: 600 }}>{f.id}</div>
<div style={{ color: "#666", fontSize: 11 }}>
{f.page_count} page{f.page_count === 1 ? "" : "s"}
{isLoading ? " · loading…" : isActive ? " · open" : ""}
</div>
</button>
</li>
);
})}
</ul>
</div>
);
}

View file

@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { useEffect, useState } from "react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionId } from "@shared/ids";
import { SessionProvider, useSessionService } from "@work/index";
import { SessionMenu } from "./SessionMenu";
import { parseRoute } from "./routing";
function HashSync() {
// Mirrors the production AppShell effect: hash changes drive
// SessionService.setActive so useActiveSession() resolves correctly.
const service = useSessionService();
const [tick, setTick] = useState(0);
useEffect(() => {
const onHash = () => setTick((t) => t + 1);
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
useEffect(() => {
const route = parseRoute(window.location.hash);
if (route.sessionId && service.get(route.sessionId as SessionId)) {
service.setActive(route.sessionId as SessionId);
} else {
service.setActive(null);
}
}, [tick, service]);
return null;
}
function Wrap({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<HashSync />
{children}
</SessionProvider>
);
}
function CurrentHash() {
return <span data-testid="current-hash">{window.location.hash || "(empty)"}</span>;
}
function SeedTwo() {
const service = useSessionService();
if (service.list().length === 0) {
service.create({ name: "Alpha" });
service.create({ name: "Beta" });
}
return null;
}
beforeEach(() => {
globalThis.localStorage?.clear();
history.replaceState(null, "", window.location.pathname);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("SessionMenu", () => {
it("creating a new session navigates the hash to /s/<id>", async () => {
render(
<Wrap>
<CurrentHash />
<SessionMenu />
</Wrap>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(screen.getByTestId("session-menu-new"));
await user.type(screen.getByTestId("session-new-input"), "Demo");
await user.click(screen.getByTestId("session-new-confirm"));
await waitFor(() => {
const route = parseRoute(window.location.hash);
expect(route.sessionId).toMatch(/^sess_/);
expect(route.mode).toBe("review");
});
});
it("switching sessions writes the chosen id into the hash", async () => {
render(
<Wrap>
<SeedTwo />
<CurrentHash />
<SessionMenu />
</Wrap>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId("session-menu-toggle"));
const alphaBtn = await screen.findByText(/Alpha/);
await user.click(alphaBtn);
await waitFor(() => {
const route = parseRoute(window.location.hash);
expect(route.sessionId).not.toBeNull();
expect(route.mode).toBe("review");
});
});
it("rename rejects a duplicate name with an inline error", async () => {
render(
<Wrap>
<SeedTwo />
<SessionMenu />
</Wrap>,
);
const user = userEvent.setup();
// Switch to Alpha first so it becomes active and rename becomes available.
await user.click(screen.getByTestId("session-menu-toggle"));
const alphaBtn = await screen.findByText(/Alpha/);
await user.click(alphaBtn);
// Re-open menu (it closed after switch) and try rename → Beta (taken).
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(screen.getByTestId("session-menu-rename"));
const input = screen.getByTestId("session-rename-input") as HTMLInputElement;
// Clear existing value and type new
await user.clear(input);
await user.type(input, "Beta");
await user.click(screen.getByTestId("session-rename-confirm"));
const error = await screen.findByTestId("session-menu-error");
expect(error.textContent).toMatch(/already exists/);
});
});

View 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",
};

View file

@ -0,0 +1,94 @@
/**
* Small reusable toast for session-scoped messages.
*
* Mirrors the CE-WP-0004 EvidenceSidebar pattern. Used by SessionMenu
* for "no such session" redirects, by T06 for export success/error,
* and by T07 for import results.
*/
import { useEffect, useState } from "react";
export type ToastTone = "success" | "error" | "info";
export interface ToastApi {
show(message: string, tone?: ToastTone): void;
dismiss(): void;
}
export interface ToastProps {
readonly toast: { readonly message: string; readonly tone: ToastTone; readonly key: number } | null;
readonly onDismiss: () => void;
readonly timeoutMs?: number;
}
export function Toast({ toast, onDismiss, timeoutMs = 3500 }: ToastProps) {
useEffect(() => {
if (!toast) return;
const t = setTimeout(onDismiss, timeoutMs);
return () => clearTimeout(t);
}, [toast, onDismiss, timeoutMs]);
if (!toast) return null;
return (
<div
role="status"
aria-live="polite"
data-testid="session-toast"
data-tone={toast.tone}
style={{
position: "fixed",
bottom: 16,
right: 16,
zIndex: 50,
padding: "8px 12px",
fontSize: 12,
background:
toast.tone === "success"
? "#d6f0d6"
: toast.tone === "error"
? "#f9d6d6"
: "#e0e8f5",
color:
toast.tone === "success"
? "#0a5a0a"
: toast.tone === "error"
? "#7a0000"
: "#003a7a",
border: `1px solid ${
toast.tone === "success"
? "#0a5a0a"
: toast.tone === "error"
? "#7a0000"
: "#003a7a"
}`,
borderRadius: 3,
fontFamily: "system-ui, sans-serif",
}}
>
{toast.message}
</div>
);
}
export function useToast(): {
toast: { message: string; tone: ToastTone; key: number } | null;
show(message: string, tone?: ToastTone): void;
dismiss(): void;
} {
const [toast, setToast] = useState<{ message: string; tone: ToastTone; key: number } | null>(
null,
);
const [, setCounter] = useState(0);
return {
toast,
show(message, tone = "info") {
setCounter((c) => {
const next = c + 1;
setToast({ message, tone, key: next });
return next;
});
},
dismiss() {
setToast(null);
},
};
}

View file

@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import { EngineProvider } from "@work/index";
import { UploadDropzone } from "./UploadDropzone";
// Bypass PDF.js extraction in this DOM test. Mock `ingestPdfFromFile`
// (the entry point the dropzone calls) so it stamps a synthetic
// document onto the byte store without ever opening pdfjs.
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdfFromFile: vi.fn(
async (file: File | Blob, store: import("@source/index").PdfByteStore) => {
const filename =
"name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf";
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
const representationId = ("rep_test_" +
Math.random().toString(36).slice(2, 10)) as RepresentationId;
const bytes = new Uint8Array(await file.arrayBuffer());
const record = store.put(documentId, bytes);
const document: Document = {
id: documentId,
mediaType: "application/pdf",
title: filename,
uri: record.blobUrl,
fingerprint: `synthetic-${documentId}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: `synthetic-${documentId}`,
canonicalText: "synthetic body",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 14, pageLength: 14 }],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
},
),
};
});
beforeEach(() => {
globalThis.localStorage?.clear();
// happy-dom's URL.createObjectURL returns blob:null/...; that's fine for tests.
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("UploadDropzone", () => {
it(
"ingests a dropped PDF and reports a 'done' progress entry",
{ timeout: 10000 },
async () => {
render(
<EngineProvider>
<UploadDropzone />
</EngineProvider>,
);
// happy-dom doesn't synthesise drag events well, so go through the
// file input — same processFiles path either way.
const input = screen.getByTestId("upload-file-input") as HTMLInputElement;
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
const file = new File([bytes], "demo.pdf", { type: "application/pdf" });
const user = userEvent.setup();
await user.upload(input, file);
await waitFor(() => {
const items = screen.getByTestId("upload-progress").querySelectorAll("li");
expect(items.length).toBe(1);
expect(items[0]?.getAttribute("data-status")).toBe("done");
});
},
);
});

View file

@ -0,0 +1,189 @@
/**
* UploadDropzone drag-drop + file-picker for uploading PDFs into the
* active session.
*
* On every successful drop:
* 1. read each File as bytes,
* 2. run the source-layer `ingestPdfFromFile` (mints the blob URL
* via the session's `PdfByteStore`),
* 3. register the resulting `{document, representation}` with the
* engine,
* 4. activate the most-recently-uploaded document.
*
* Failures (non-PDFs, ingest errors) are surfaced inline above the
* dropzone; the caller doesn't need a separate toast for them.
*/
import { useCallback, useRef, useState } from "react";
import { ingestPdfFromFile } from "@source/index";
import {
useActiveDocumentId,
useEngine,
usePdfByteStore,
} from "@work/index";
interface UploadEntry {
readonly file: File;
status: "queued" | "uploading" | "done" | "error";
error?: string;
}
export interface UploadDropzoneProps {
/** Optional callback fired after each successful upload. */
readonly onUploaded?: (documentId: import("@shared/ids").DocumentId) => void;
}
export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { setId } = useActiveDocumentId();
const [entries, setEntries] = useState<readonly UploadEntry[]>([]);
const [isOver, setIsOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const processFiles = useCallback(
async (files: readonly File[]) => {
if (files.length === 0) return;
const initial: UploadEntry[] = files.map((file) => {
const isPdf =
file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
if (isPdf) return { file, status: "queued" };
return {
file,
status: "error",
error: "Not a PDF (only application/pdf accepted)",
};
});
setEntries((prev) => [...prev, ...initial]);
let lastDocumentId: import("@shared/ids").DocumentId | null = null;
for (const entry of initial) {
if (entry.status === "error") continue;
entry.status = "uploading";
setEntries((prev) => [...prev]);
try {
const { document, representation } = await ingestPdfFromFile(
entry.file,
byteStore,
);
engine.documents.register({ document, representation });
entry.status = "done";
lastDocumentId = document.id;
onUploaded?.(document.id);
} catch (err) {
entry.status = "error";
entry.error = err instanceof Error ? err.message : String(err);
}
setEntries((prev) => [...prev]);
}
if (lastDocumentId) setId(lastDocumentId);
},
[byteStore, engine, onUploaded, setId],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsOver(false);
const files = Array.from(e.dataTransfer.files);
void processFiles(files);
},
[processFiles],
);
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsOver(true);
}, []);
const onDragLeave = useCallback(() => {
setIsOver(false);
}, []);
const openPicker = useCallback(() => {
fileInputRef.current?.click();
}, []);
const onPicked = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [];
void processFiles(files);
// Reset so the same filename can be picked again.
e.target.value = "";
},
[processFiles],
);
return (
<div data-testid="upload-dropzone">
<div
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
role="region"
aria-label="PDF upload"
style={{
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
background: isOver ? "#e8f0ff" : "#fafafa",
padding: 16,
textAlign: "center",
fontSize: 12,
color: "#555",
borderRadius: 4,
}}
>
<div>Drop PDF files here</div>
<div style={{ margin: "6px 0", color: "#888" }}>or</div>
<button
type="button"
onClick={openPicker}
data-testid="upload-pick-button"
style={{
fontSize: 12,
padding: "4px 10px",
border: "1px solid #888",
background: "white",
cursor: "pointer",
}}
>
Choose PDF
</button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
multiple
onChange={onPicked}
style={{ display: "none" }}
data-testid="upload-file-input"
/>
</div>
{entries.length > 0 && (
<ul
data-testid="upload-progress"
style={{ listStyle: "none", padding: 0, margin: "8px 0 0", fontSize: 11 }}
>
{entries.map((entry, i) => (
<li
key={`${entry.file.name}-${i}`}
data-status={entry.status}
style={{
padding: "2px 4px",
color:
entry.status === "error"
? "#7a0000"
: entry.status === "done"
? "#0a5a0a"
: "#333",
}}
>
{entry.file.name} {entry.status}
{entry.error ? `: ${entry.error}` : ""}
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,154 @@
/**
* Round-trip an exported session through JSZip and assert the
* archive matches ADR-0008 (manifest + per-document PDF bytes).
*/
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { createEngine } from "@engine/index";
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import { parseSessionArchiveManifest } from "@shared/session-archive";
import { createPdfByteStore } from "@source/index";
import { exportSessionZip, sessionZipFilename } from "./exportSessionZip";
function makeSession(id: string, name: string): Session {
return {
id: id as SessionId,
name,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
}
describe("exportSessionZip", () => {
it("produces a ZIP with manifest.json + documents/<id>.pdf for each binding", async () => {
const engine = createEngine();
const byteStore = createPdfByteStore({
createObjectURL: () => "blob:test-1",
revokeObjectURL: () => {},
});
const docId = "doc_test" as DocumentId;
const repId = "rep_test" as RepresentationId;
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
byteStore.put(docId, bytes);
engine.documents.register({
document: {
id: docId,
mediaType: "application/pdf",
title: "demo.pdf",
fingerprint: "fingerprint-abc",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: "fingerprint-abc",
canonicalText: "Quoted passage.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 15, pageLength: 15 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
// Add an annotation + evidence item so the snapshot exercises that path.
const ann = engine.annotations.create({
documentId: docId,
representationId: repId,
quote: "Quoted",
selectors: [{ type: "TextQuoteSelector", exact: "Quoted" }],
});
engine.evidence.create({ annotationIds: [ann.id], commentary: "hi" });
const session = makeSession("sess_x", "Demo session");
const blob = await exportSessionZip(engine, byteStore, session, {
exportedAt: "2026-05-25T12:00:00.000Z",
});
expect(blob.size).toBeGreaterThan(0);
const arrayBuffer = await blob.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
expect(zip.file("manifest.json")).not.toBeNull();
expect(zip.file(`documents/${docId}.pdf`)).not.toBeNull();
const manifestText = await zip.file("manifest.json")!.async("string");
const manifest = parseSessionArchiveManifest(JSON.parse(manifestText));
expect(manifest.schemaVersion).toBe(1);
expect(manifest.session.id).toBe("sess_x");
expect(manifest.session.name).toBe("Demo session");
expect(manifest.documentBindings).toHaveLength(1);
expect(manifest.documentBindings[0]).toMatchObject({
documentId: docId,
filename: "demo.pdf",
fingerprint: "fingerprint-abc",
});
expect(manifest.engine.documents).toHaveLength(1);
expect(manifest.engine.representations).toHaveLength(1);
expect(manifest.engine.annotations).toHaveLength(1);
expect(manifest.engine.evidenceItems).toHaveLength(1);
const storedBytes = await zip.file(`documents/${docId}.pdf`)!.async("uint8array");
expect(Array.from(storedBytes)).toEqual(Array.from(bytes));
});
it("skips the binary file when the byte store has no bytes for a document", async () => {
const engine = createEngine();
const byteStore = createPdfByteStore({
createObjectURL: () => "blob:test-noop",
revokeObjectURL: () => {},
});
const docId = "doc_no_bytes" as DocumentId;
engine.documents.register({
document: {
id: docId,
mediaType: "application/pdf",
title: "ghost.pdf",
fingerprint: "ghost-fp",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: "rep_no_bytes" as RepresentationId,
documentId: docId,
representationType: "pdf-text",
contentHash: "ghost-fp",
canonicalText: "",
pageMap: [],
offsetMap: [],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
const blob = await exportSessionZip(
engine,
byteStore,
makeSession("sess_nb", "No Bytes"),
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
expect(zip.file(`documents/${docId}.pdf`)).toBeNull();
const manifestText = await zip.file("manifest.json")!.async("string");
const manifest = parseSessionArchiveManifest(JSON.parse(manifestText));
expect(manifest.documentBindings).toHaveLength(1);
});
});
describe("sessionZipFilename", () => {
it("slugifies the session name and stamps the date in UTC", () => {
const session = makeSession("sess_a", "Lease — 2024 / München!");
const fixed = new Date(Date.UTC(2026, 4, 25, 14, 7));
expect(sessionZipFilename(session, fixed)).toBe("lease-2024-m-nchen-20260525-1407.zip");
});
it("falls back to 'session' when slugification produces empty string", () => {
const session = makeSession("sess_x", "!!!");
const fixed = new Date(Date.UTC(2026, 0, 1));
expect(sessionZipFilename(session, fixed)).toBe("session-20260101-0000.zip");
});
});

View file

@ -0,0 +1,148 @@
/**
* `exportSessionZip` pack a session's engine snapshot + uploaded PDF
* bytes into a single `.zip` archive (ADR-0008 layout).
*
* Steps:
* 1. Build the manifest from `captureSnapshot(engine)` + session
* metadata + per-document `{filename, fingerprint}` derived from
* `engine.documents`.
* 2. For each binding, push `bytes` into `documents/<documentId>.pdf`.
* 3. Push `manifest.json` (pretty-printed JSON).
* 4. `zip.generateAsync({ type: "blob" })`.
*
* `triggerSessionDownload` creates an `<a download>` link and clicks
* it. The filename is `<slug>-<isoDate>.zip` so two exports of the
* same session don't collide on disk.
*/
import JSZip from "jszip";
import { captureSnapshot, type Engine } from "@engine/index";
import type { DocumentId } from "@shared/ids";
import type { Session } from "@shared/session";
import {
SESSION_ARCHIVE_SCHEMA_VERSION,
type SessionArchiveDocumentBinding,
type SessionArchiveManifest,
} from "@shared/session-archive";
import type { PdfByteStore } from "@source/index";
export interface ExportSessionZipOptions {
/** Override the timestamp embedded in the manifest. */
readonly exportedAt?: string;
}
export async function exportSessionZip(
engine: Engine,
byteStore: PdfByteStore,
session: Session,
options: ExportSessionZipOptions = {},
): Promise<Blob> {
const snapshot = captureSnapshot(engine);
const documents = engine.documents.list();
const bindings: SessionArchiveDocumentBinding[] = [];
const zip = new JSZip();
const documentsFolder = zip.folder("documents");
if (!documentsFolder) {
throw new Error("exportSessionZip: JSZip refused to create 'documents/' folder");
}
for (const doc of documents) {
const filename =
doc.title ??
(typeof doc.metadata?.["filename"] === "string"
? (doc.metadata["filename"] as string)
: `${doc.id}.pdf`);
const fingerprint = doc.fingerprint ?? "";
bindings.push({ documentId: doc.id, filename, fingerprint });
const record = byteStore.get(doc.id);
if (record) {
documentsFolder.file(`${doc.id}.pdf`, record.bytes);
}
// If bytes are missing (e.g. fixture-loaded doc whose bytes weren't
// pushed into the store), the manifest still lists the binding but
// the binary is absent — the importer surfaces this as a warning
// in T07.
}
const manifest: SessionArchiveManifest = {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt: options.exportedAt ?? new Date().toISOString(),
session: {
id: session.id,
name: session.name,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
},
engine: snapshot,
documentBindings: bindings,
};
zip.file("manifest.json", JSON.stringify(manifest, null, 2));
return zip.generateAsync({ type: "blob" });
}
export function sessionZipFilename(session: Session, now: Date = new Date()): string {
const slug =
session.name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "session";
// YYYYMMDD-HHMM
const pad = (n: number) => String(n).padStart(2, "0");
const stamp = `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}`;
return `${slug}-${stamp}.zip`;
}
export interface TriggerDownloadHooks {
/** Override the `<a>` creation — used by tests to intercept the click. */
readonly createAnchor?: () => HTMLAnchorElement;
readonly createObjectURL?: (blob: Blob) => string;
readonly revokeObjectURL?: (url: string) => void;
}
export function triggerSessionDownload(
blob: Blob,
filename: string,
hooks: TriggerDownloadHooks = {},
): void {
const createObjectURL =
hooks.createObjectURL ??
((b: Blob) => {
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
throw new Error("triggerSessionDownload: URL.createObjectURL unavailable");
}
return URL.createObjectURL(b);
});
const revokeObjectURL =
hooks.revokeObjectURL ??
((url: string) => {
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(url);
}
});
const createAnchor =
hooks.createAnchor ??
(() => {
if (typeof document === "undefined") {
throw new Error("triggerSessionDownload: document is not available");
}
return document.createElement("a");
});
const url = createObjectURL(blob);
const a = createAnchor();
a.href = url;
a.download = filename;
a.click();
// Revoke after the click so the browser has a chance to start the download.
setTimeout(() => revokeObjectURL(url), 1_000);
}
// Re-export the DocumentId type so consumers can write
// `exportSessionZip(...)` without an extra import. Tree-shakeable.
export type { DocumentId };

View file

@ -0,0 +1,276 @@
import { describe, expect, it } from "vitest";
import {
createEngine,
createEventBus,
createInMemorySessionRepository,
createSessionService,
engineSnapshotKey,
restoreFromStorage,
type SessionService,
} from "@engine/index";
import type {
DocumentId,
RepresentationId,
SessionId,
} from "@shared/ids";
import type { Session } from "@shared/session";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import { exportSessionZip } from "./exportSessionZip";
import {
importSessionZip,
SessionImportError,
type ImportSessionServices,
} from "./importSessionZip";
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function makeService(): SessionService {
const repo = createInMemorySessionRepository();
const bus = createEventBus();
return createSessionService(repo, bus);
}
function freshStores() {
const stores = new Map<SessionId, PdfByteStore>();
return {
stores,
get(sessionId: SessionId): PdfByteStore {
let s = stores.get(sessionId);
if (!s) {
s = createPdfByteStore({
createObjectURL: () => `blob:t-${sessionId}-${Math.random()}`,
revokeObjectURL: () => {},
});
stores.set(sessionId, s);
}
return s;
},
};
}
interface Harness {
service: SessionService;
stores: ReturnType<typeof freshStores>["stores"];
byteStoreFor(sessionId: SessionId): PdfByteStore;
bumps: SessionId[];
storage: ReturnType<typeof memoryStorage>;
services: ImportSessionServices;
}
function harness(): Harness {
const service = makeService();
const stores = freshStores();
const bumps: SessionId[] = [];
const storage = memoryStorage();
return {
service,
stores: stores.stores,
byteStoreFor: stores.get,
bumps,
storage,
services: {
sessionService: service,
getOrCreateByteStore: stores.get,
bumpSessionVersion: (id) => bumps.push(id),
storage,
},
};
}
async function seedAndExport(opts: {
sessionName: string;
storage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}): Promise<{ blob: Blob; session: Session; docId: DocumentId }> {
const engine = createEngine();
const byteStore = createPdfByteStore({
createObjectURL: () => "blob:src",
revokeObjectURL: () => {},
});
const session: Session = {
id: "sess_src" as SessionId,
name: opts.sessionName,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const docId = "doc_src" as DocumentId;
const repId = "rep_src" as RepresentationId;
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
byteStore.put(docId, bytes);
engine.documents.register({
document: {
id: docId,
mediaType: "application/pdf",
title: "src.pdf",
fingerprint: "fp-shared",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: "fp-shared",
canonicalText: "The quote.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 10, pageLength: 10 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
const ann = engine.annotations.create({
documentId: docId,
representationId: repId,
quote: "The quote.",
selectors: [{ type: "TextQuoteSelector", exact: "The quote." }],
});
engine.evidence.create({ annotationIds: [ann.id], commentary: "important" });
const blob = await exportSessionZip(engine, byteStore, session);
// The "blob" JSZip produces inside a node test isn't a real Blob —
// re-pack as a fresh Blob over an ArrayBuffer so JSZip.loadAsync (in
// the importer) can consume it.
const buf = await blob.arrayBuffer();
const portableBlob = new Blob([buf], { type: "application/zip" });
// Silence unused-storage lint
void opts.storage;
return { blob: portableBlob, session, docId };
}
describe("importSessionZip — create path", () => {
it("imports a fresh session and stamps a new engine snapshot in storage", async () => {
const h = harness();
const { blob } = await seedAndExport({
sessionName: "From Export",
storage: h.storage,
});
const result = await importSessionZip(blob, h.services);
expect(result.outcome).toBe("created");
expect(result.sessionId).toMatch(/^sess_/);
expect(result.stats.documentsAdded).toBe(1);
expect(result.stats.documentsDeduped).toBe(0);
expect(result.stats.annotationsAdded).toBe(1);
expect(result.stats.evidenceAdded).toBe(1);
// The session record exists in the service.
const created = h.service.get(result.sessionId);
expect(created?.name).toBe("From Export");
// The engine snapshot was persisted to localStorage at the per-
// session key.
const raw = h.storage.getItem(engineSnapshotKey(result.sessionId));
expect(raw).not.toBeNull();
const restored = createEngine();
restoreFromStorage(restored, {
key: engineSnapshotKey(result.sessionId),
storage: h.storage,
});
expect(restored.documents.list()).toHaveLength(1);
expect(restored.annotations.listByDocument(restored.documents.list()[0]!.id)).toHaveLength(1);
// The byte store registry got the bytes.
const bytesStore = h.byteStoreFor(result.sessionId);
expect(bytesStore.list()).toHaveLength(1);
// setActive was called + version bumped.
expect(h.service.getActive()).toBe(result.sessionId);
expect(h.bumps).toContain(result.sessionId);
});
});
describe("importSessionZip — merge path", () => {
it("dedupes documents by fingerprint and adds annotations additively", async () => {
const h = harness();
// Pre-create a session with the same name + same fingerprint
// document so the merge has something to dedupe against.
const targetSession = h.service.create({ name: "Demo" });
{
const seedEngine = createEngine();
const seedStore = h.byteStoreFor(targetSession.id);
seedStore.put("doc_pre" as DocumentId, new Uint8Array([1]));
seedEngine.documents.register({
document: {
id: "doc_pre" as DocumentId,
mediaType: "application/pdf",
title: "pre.pdf",
fingerprint: "fp-shared",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: "rep_pre" as RepresentationId,
documentId: "doc_pre" as DocumentId,
representationType: "pdf-text",
contentHash: "fp-shared",
canonicalText: "x",
pageMap: [],
offsetMap: [],
generatedAt: "2026-05-25T00:00:00.000Z",
},
});
const seedSnap = await import("@engine/index").then((m) => m.captureSnapshot(seedEngine));
h.storage.setItem(engineSnapshotKey(targetSession.id), JSON.stringify(seedSnap));
}
const { blob } = await seedAndExport({
sessionName: "Demo",
storage: h.storage,
});
const result = await importSessionZip(blob, h.services);
expect(result.outcome).toBe("merged-into");
expect(result.sessionId).toBe(targetSession.id);
expect(result.stats.documentsAdded).toBe(0);
expect(result.stats.documentsDeduped).toBe(1);
expect(result.stats.annotationsAdded).toBe(1);
expect(result.stats.evidenceAdded).toBe(1);
// Re-load the snapshot — there should still be ONE document
// (deduped), and the annotation/evidence we added are now visible
// on that existing document.
const restored = createEngine();
restoreFromStorage(restored, {
key: engineSnapshotKey(targetSession.id),
storage: h.storage,
});
expect(restored.documents.list()).toHaveLength(1);
expect(restored.documents.list()[0]!.id).toBe("doc_pre" as DocumentId);
const annsOnDoc = restored.annotations.listByDocument("doc_pre" as DocumentId);
expect(annsOnDoc).toHaveLength(1);
expect(annsOnDoc[0]!.quote).toBe("The quote.");
});
});
describe("importSessionZip — error path", () => {
it("rejects an archive with a malformed manifest", async () => {
const h = harness();
// Build a minimal zip with a malformed manifest.
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
zip.file("manifest.json", JSON.stringify({ schemaVersion: 999, exportedAt: "x" }));
const buf = await zip.generateAsync({ type: "arraybuffer" });
const blob = new Blob([buf], { type: "application/zip" });
await expect(importSessionZip(blob, h.services)).rejects.toThrow(SessionImportError);
});
it("rejects an archive without a manifest", async () => {
const h = harness();
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
zip.file("something-else.txt", "hello");
const buf = await zip.generateAsync({ type: "arraybuffer" });
const blob = new Blob([buf], { type: "application/zip" });
await expect(importSessionZip(blob, h.services)).rejects.toThrow(/manifest\.json missing/);
});
});

View file

@ -0,0 +1,314 @@
/**
* `importSessionZip` read a session ZIP archive, dedupe documents by
* fingerprint, additively merge annotations/evidence/links into the
* target session. ADR-0008 is the authoritative spec.
*
* Target session resolution:
* - If a session with the manifest's `session.name` exists (case
* insensitive, matching SessionService rules), that's the target
* and `outcome` is `"merged-into"`.
* - Otherwise a fresh session is created with the imported name and
* `outcome` is `"created"`.
*
* Per-archive document handling:
* - SHA-256 fingerprint match against the target session's existing
* documents reuse the existing `documentId`, skip the binary,
* record a remap.
* - No match mint a new branded `documentId`, push the bytes into
* the target's byte store, register with the target's engine,
* record the remap.
*
* Per-archive annotation/evidence/link handling:
* - Always mint fresh ids; rewrite any `documentId` / `annotationId`
* / `evidenceItemId` references via the remap.
*
* Known limitation: re-importing your own export creates duplicate
* annotations (no idempotency). See ADR-0008 §"Known limitation" for
* the planned `importBundleId` follow-up.
*
* The importer works against a *fresh* off-React `Engine` for the
* target session and writes the resulting snapshot directly to
* `localStorage` at `engineSnapshotKey(targetSession.id)`. Callers
* then invoke `bumpSessionVersion(target.id)` to force the React
* EngineProvider to remount + restore the new snapshot.
*/
import JSZip from "jszip";
import type { Annotation } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import {
newId,
type AnnotationId,
type DocumentId,
type RepresentationId,
type SessionId,
} from "@shared/ids";
import {
parseSessionArchiveManifest,
type SessionArchiveDocumentBinding,
type SessionArchiveManifest,
} from "@shared/session-archive";
import {
captureSnapshot,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type SessionService,
} from "@engine/index";
import type { PdfByteStore } from "@source/index";
export interface ImportSessionServices {
readonly sessionService: SessionService;
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
bumpSessionVersion(sessionId: SessionId): void;
/** Storage shim — defaults to globalThis.localStorage. */
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}
export type ImportOutcome = "created" | "merged-into";
export interface ImportSessionStats {
readonly documentsAdded: number;
readonly documentsDeduped: number;
readonly annotationsAdded: number;
readonly evidenceAdded: number;
readonly linksAdded: number;
}
export interface ImportSessionResult {
readonly sessionId: SessionId;
readonly outcome: ImportOutcome;
readonly stats: ImportSessionStats;
}
export class SessionImportError extends Error {
constructor(message: string) {
super(`Session import failed: ${message}`);
this.name = "SessionImportError";
}
}
export async function importSessionZip(
file: File | Blob,
services: ImportSessionServices,
): Promise<ImportSessionResult> {
const storage = services.storage ?? globalThis.localStorage;
if (!storage) {
throw new SessionImportError("no storage available");
}
// 1. Open the zip + parse the manifest.
const zip = await loadZip(file);
const manifestEntry = zip.file("manifest.json");
if (!manifestEntry) {
throw new SessionImportError("manifest.json missing from archive");
}
let manifest: SessionArchiveManifest;
try {
const text = await manifestEntry.async("string");
manifest = parseSessionArchiveManifest(JSON.parse(text));
} catch (err) {
throw new SessionImportError(
err instanceof Error ? err.message : `manifest parse failed: ${String(err)}`,
);
}
// 2. Read all binary files referenced by the manifest. We tolerate
// missing files — they appear as 0 documents added for that binding.
const incomingBytes = new Map<DocumentId, Uint8Array>();
for (const binding of manifest.documentBindings) {
const entry = zip.file(`documents/${binding.documentId}.pdf`);
if (entry) {
incomingBytes.set(binding.documentId, await entry.async("uint8array"));
}
}
// 3. Resolve target session.
const matchingExisting = services.sessionService
.list()
.find((s) => s.name.trim().toLocaleLowerCase() === manifest.session.name.trim().toLocaleLowerCase());
let targetSessionId: SessionId;
let outcome: ImportOutcome;
if (matchingExisting) {
targetSessionId = matchingExisting.id;
outcome = "merged-into";
} else {
const created = services.sessionService.create({ name: manifest.session.name });
targetSessionId = created.id;
outcome = "created";
}
// 4. Build an off-React engine for the target — populated either from
// the target's existing snapshot (merge path) or empty (create path).
const targetEngine = createEngine();
if (outcome === "merged-into") {
restoreFromStorage(targetEngine, {
key: engineSnapshotKey(targetSessionId),
storage,
});
}
const targetByteStore = services.getOrCreateByteStore(targetSessionId);
// 5. Build the document remap.
const docRemap = new Map<DocumentId, DocumentId>();
const existingByFingerprint = new Map<string, DocumentId>();
for (const doc of targetEngine.documents.list()) {
if (doc.fingerprint) existingByFingerprint.set(doc.fingerprint, doc.id);
}
let documentsAdded = 0;
let documentsDeduped = 0;
const incomingDocs = manifest.engine.documents as readonly Document[];
const incomingReps = manifest.engine.representations as readonly DocumentRepresentation[];
for (const binding of manifest.documentBindings) {
const remappedExisting = existingByFingerprint.get(binding.fingerprint);
if (remappedExisting) {
docRemap.set(binding.documentId, remappedExisting);
documentsDeduped += 1;
continue;
}
const incomingDoc = incomingDocs.find((d) => d.id === binding.documentId);
if (!incomingDoc) {
// Manifest pointed to a binding without an engine record for it —
// skip silently, matches the "tolerate missing files" rule.
continue;
}
const newDocId = newId("document");
const incomingDocReps = incomingReps.filter((r) => r.documentId === binding.documentId);
// Push bytes into the byte store; mint a fresh blob URL on the way.
const bytes = incomingBytes.get(binding.documentId);
const blobUrl = bytes ? targetByteStore.put(newDocId, bytes).blobUrl : undefined;
const newDoc: Document = {
...incomingDoc,
id: newDocId,
...(blobUrl !== undefined ? { uri: blobUrl } : {}),
};
const newReps: DocumentRepresentation[] = incomingDocReps.map((rep) => ({
...rep,
id: newId("representation") as RepresentationId,
documentId: newDocId,
}));
const firstRep = newReps[0];
if (firstRep) {
// Use the service for the first rep so events fire + dedup logic
// in the repos runs. Extra reps go in via the repo directly.
targetEngine.documents.register({ document: newDoc, representation: firstRep });
for (let i = 1; i < newReps.length; i++) {
targetEngine.repos.representations.create(newReps[i]!);
}
} else {
// Engine snapshot somehow lacks a representation — push the doc
// directly so the snapshot stays self-consistent.
targetEngine.repos.documents.create(newDoc);
}
docRemap.set(binding.documentId, newDocId);
documentsAdded += 1;
}
// 6. Remap annotations.
const annRemap = new Map<AnnotationId, AnnotationId>();
let annotationsAdded = 0;
const incomingAnns = manifest.engine.annotations as readonly Annotation[];
for (const ann of incomingAnns) {
const newDocId = docRemap.get(ann.documentId);
if (!newDocId) continue; // orphan — no doc imported
const newAnnId = newId("annotation");
const newAnn: Annotation = {
...ann,
id: newAnnId,
documentId: newDocId,
};
// Write through the repo + emit AnnotationCreated so any future
// listeners (none in T07 itself) get the event. Mirrors the
// snapshot-restore pattern.
targetEngine.repos.annotations.create(newAnn);
targetEngine.bus.emit({
type: "AnnotationCreated",
annotationId: newAnnId,
annotation: newAnn,
});
annRemap.set(ann.id, newAnnId);
annotationsAdded += 1;
}
// 7. Remap evidence items.
let evidenceAdded = 0;
const incomingEvidence = manifest.engine.evidenceItems as readonly EvidenceItem[];
for (const item of incomingEvidence) {
const newAnnIds: AnnotationId[] = [];
for (const aid of item.annotationIds) {
const remapped = annRemap.get(aid);
if (remapped) newAnnIds.push(remapped);
}
if (newAnnIds.length === 0) continue;
const newEvId = newId("evidence");
const newItem: EvidenceItem = {
...item,
id: newEvId,
annotationIds: newAnnIds,
};
targetEngine.repos.evidenceItems.create(newItem);
targetEngine.bus.emit({
type: "EvidenceItemCreated",
evidenceItemId: newEvId,
evidenceItem: newItem,
});
evidenceAdded += 1;
}
// 8. EvidenceLinks live on the binder, not the engine snapshot. The
// schema-version-1 manifest does not carry them yet — `linksAdded`
// stays 0 until a future ADR extends the snapshot.
const linksAdded = 0;
// 9. Persist the merged snapshot directly to the per-session storage
// key. The version bump (below) forces the EngineProvider to remount
// and restore from there.
const snapshot = captureSnapshot(targetEngine);
try {
storage.setItem(engineSnapshotKey(targetSessionId), JSON.stringify(snapshot));
} catch (err) {
throw new SessionImportError(
`failed to persist target snapshot: ${err instanceof Error ? err.message : String(err)}`,
);
}
// 10. Make the target active + bump its version so React picks up the
// new state.
services.sessionService.setActive(targetSessionId);
services.bumpSessionVersion(targetSessionId);
return {
sessionId: targetSessionId,
outcome,
stats: {
documentsAdded,
documentsDeduped,
annotationsAdded,
evidenceAdded,
linksAdded,
},
};
}
async function loadZip(file: File | Blob): Promise<JSZip> {
try {
// Convert to ArrayBuffer first — JSZip can't always consume a Blob
// in Node (which the test runner uses), but ArrayBuffer is portable.
const buf = await file.arrayBuffer();
return await JSZip.loadAsync(buf);
} catch (err) {
throw new SessionImportError(
`corrupt ZIP: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// Re-export the binding type for callers that want to inspect manifests.
export type { SessionArchiveDocumentBinding };

28
src/app/sessions/index.ts Normal file
View file

@ -0,0 +1,28 @@
export { UploadDropzone, type UploadDropzoneProps } from "./UploadDropzone";
export { SampleSessions } from "./SampleSessions";
export { SessionMenu } from "./SessionMenu";
export { CreateFirstSession } from "./CreateFirstSession";
export { Toast, useToast, type ToastTone } from "./Toast";
export {
EMPTY_ROUTE,
navigateTo,
parseRoute,
serializeRoute,
type AppMode,
type AppRoute,
} from "./routing";
export {
exportSessionZip,
sessionZipFilename,
triggerSessionDownload,
type ExportSessionZipOptions,
type TriggerDownloadHooks,
} from "./exportSessionZip";
export {
importSessionZip,
SessionImportError,
type ImportOutcome,
type ImportSessionResult,
type ImportSessionServices,
type ImportSessionStats,
} from "./importSessionZip";

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { SessionId } from "@shared/ids";
import { EMPTY_ROUTE, parseRoute, serializeRoute } from "./routing";
describe("routing.parseRoute", () => {
it("returns the empty route for an empty hash", () => {
expect(parseRoute("")).toEqual(EMPTY_ROUTE);
expect(parseRoute("#")).toEqual(EMPTY_ROUTE);
expect(parseRoute("#/")).toEqual(EMPTY_ROUTE);
});
it("parses #/s/<id> as review mode for that session", () => {
const route = parseRoute("#/s/sess_abc");
expect(route.sessionId).toBe("sess_abc");
expect(route.mode).toBe("review");
});
it("parses #/s/<id>/forms/demo as forms mode", () => {
const route = parseRoute("#/s/sess_xyz/forms/demo");
expect(route.sessionId).toBe("sess_xyz");
expect(route.mode).toBe("forms");
});
it("treats legacy #/forms/demo as the empty route (session must be chosen first)", () => {
expect(parseRoute("#/forms/demo")).toEqual(EMPTY_ROUTE);
});
it("trims trailing slashes", () => {
expect(parseRoute("#/s/sess_abc/")).toMatchObject({ sessionId: "sess_abc" });
});
});
describe("routing.serializeRoute", () => {
it("returns empty string for the empty route", () => {
expect(serializeRoute(EMPTY_ROUTE)).toBe("");
});
it("round-trips review mode", () => {
const route = { sessionId: "sess_abc" as SessionId, mode: "review" as const };
expect(serializeRoute(route)).toBe("#/s/sess_abc");
expect(parseRoute(serializeRoute(route))).toEqual(route);
});
it("round-trips forms mode", () => {
const route = { sessionId: "sess_xyz" as SessionId, mode: "forms" as const };
expect(serializeRoute(route)).toBe("#/s/sess_xyz/forms/demo");
expect(parseRoute(serializeRoute(route))).toEqual(route);
});
});

View file

@ -0,0 +1,61 @@
/**
* Hash routing for the demo app.
*
* #/ empty state ("create your first session")
* #/s/<sessionId> review mode, scoped to <sessionId>
* #/s/<sessionId>/forms/demo forms mode, scoped to <sessionId>
*
* The hash is the single source of truth for the active session and the
* active mode. `SessionProvider.setActive(...)` is wired as a side
* effect of hash changes so back/forward and deep links behave
* naturally.
*/
import type { SessionId } from "@shared/ids";
export type AppMode = "review" | "forms";
export interface AppRoute {
readonly sessionId: SessionId | null;
readonly mode: AppMode;
}
export const EMPTY_ROUTE: AppRoute = { sessionId: null, mode: "review" };
export function parseRoute(hash: string): AppRoute {
// Normalise: drop leading "#", trim any trailing slashes.
const cleaned = hash.replace(/^#/, "").replace(/^\/+|\/+$/g, "");
if (cleaned === "") return EMPTY_ROUTE;
const parts = cleaned.split("/");
if (parts.length >= 2 && parts[0] === "s") {
const sessionId = parts[1]! as SessionId;
const mode: AppMode =
parts[2] === "forms" && parts[3] === "demo" ? "forms" : "review";
return { sessionId, mode };
}
// Legacy `#/forms/demo` (pre-CE-WP-0005) maps to the empty state — the
// user has to pick a session first.
return EMPTY_ROUTE;
}
export function serializeRoute(route: AppRoute): string {
if (!route.sessionId) return "";
const base = `#/s/${route.sessionId}`;
return route.mode === "forms" ? `${base}/forms/demo` : base;
}
export function navigateTo(route: AppRoute): void {
if (typeof window === "undefined") return;
const target = serializeRoute(route);
if (target === "") {
// Clear the hash entirely so the URL stays clean.
history.replaceState(null, "", window.location.pathname + window.location.search);
// history.replaceState doesn't fire hashchange — dispatch one so
// subscribers re-read.
window.dispatchEvent(new HashChangeEvent("hashchange"));
return;
}
if (window.location.hash !== target) {
window.location.hash = target;
}
}