generated from coulomb/repo-seed
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>
347 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|