Files
citation-evidence/src/app/App.tsx
tegwick 779ae0d317 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>
2026-05-26 14:57:28 +02:00

347 lines
9.9 KiB
TypeScript

/**
* App — citation-evidence demo shell (CE-WP-0005).
*
* Composition:
*
* 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`)
*
* 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 { 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";
import {
CreateFirstSession,
EMPTY_ROUTE,
exportSessionZip,
importSessionZip,
parseRoute,
navigateTo,
SessionMenu,
sessionZipFilename,
Toast,
triggerSessionDownload,
UploadDropzone,
useToast,
type AppMode,
type AppRoute,
} from "./sessions";
function readRoute(): AppRoute {
if (typeof window === "undefined") return EMPTY_ROUTE;
return parseRoute(window.location.hash);
}
function useHashRoute(): AppRoute {
const [route, setRoute] = useState<AppRoute>(() => readRoute());
useEffect(() => {
const handler = () => setRoute(readRoute());
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, []);
return route;
}
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" }}>
<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 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={{
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 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>
);
}
function tabStyle(active: boolean) {
return {
padding: "4px 12px",
fontSize: 12,
border: "1px solid #ccc",
borderBottom: active ? "2px solid #0050b3" : "1px solid #ccc",
background: active ? "#e8f0ff" : "white",
cursor: "pointer" as const,
};
}
export function App() {
return (
<SessionProvider>
<AppShell />
</SessionProvider>
);
}