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