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

View File

@@ -0,0 +1,134 @@
# ADR-0008 — Session archive format (ZIP layout, manifest schema, merge policy)
- Status: accepted
- Date: 2026-05-25
- Workplan: CE-WP-0005-T05 (schema), CE-WP-0005-T06 (export),
CE-WP-0005-T07 (import)
- Spec refs: `wiki/ProductRequirementsDocument.md` §20,
`wiki/ArchitectureOverview.md` §3.4, §14.3
## Context
The CE-WP-0005 demo loop ends with a user exporting an entire session
(documents, annotations, evidence, links) into a single `.zip`
archive and importing it back later. The archive needs to be the
**only** persistence mechanism the demo provides beyond a tab close —
no IndexedDB in this workplan — so its shape needs to be locked
before two parallel tasks (T06, T07) and the integration test (T08)
land on top of it.
Three things need a written contract:
1. **ZIP layout** — what files live in the archive, named how.
2. **manifest.json shape** — versioned JSON schema, validated on
import.
3. **Conflict policy** — what happens when an imported session's name
already exists in the receiving repository.
## Decision
### ZIP layout
```
manifest.json
documents/
<documentId>.pdf
```
- `<documentId>` is the engine-minted branded id (`doc_<uuid>`). Using
it as the filename means the manifest's `documentBindings[i]` can
cross-reference the binary file without an additional lookup table.
- Per-representation files (e.g. an extracted-text JSON alongside each
PDF) are intentionally deferred. The canonical text + selectors are
embedded in the engine snapshot inside `manifest.json`, so a
re-import can regenerate everything from the binary.
- Future archive variants (multi-attachment documents, Markdown
documents) extend by adding subdirectories under the archive root.
Importers must ignore unknown top-level entries so older clients
remain compatible with newer archives that add new file types.
### `manifest.json` shape (schemaVersion 1)
```ts
interface SessionArchiveManifest {
schemaVersion: 1;
exportedAt: string; // ISO-8601 UTC timestamp
session: {
id: SessionId; // sess_<uuid>
name: string; // trimmed display name
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
};
engine: EngineSnapshot; // shape from src/engine/persistence.ts
documentBindings: Array<{
documentId: DocumentId; // matches the engine's record
filename: string; // original filename from upload
fingerprint: string; // SHA-256 — used by the importer for dedup
}>;
}
```
The `engine` field is the same shape that `captureSnapshot()` produces
in `src/engine/persistence.ts`. Re-using it verbatim keeps the
in-memory ↔ archive round-trip a one-way conversion (snapshot ↔
JSON) instead of growing a parallel schema that would drift.
Unknown fields at the top level **must be preserved** on import (a
future client can write them) but unknown fields inside `session` or
`documentBindings[i]` are dropped — the import constructs typed
domain objects from the validated subset.
### Merge-on-name-collision policy (T07)
When an imported manifest's `session.name` matches an existing
session, the existing session is the **target** (`outcome:
"merged-into"`). Otherwise a fresh session is created with the
imported name (`outcome: "created"`).
Within the target session:
- **Documents** are deduped by `fingerprint` (SHA-256 over the PDF
bytes). If a document with the same fingerprint already exists,
the import keeps the existing `documentId` and records a remap
from the incoming id. The binary file is **skipped** (we already
have the bytes). Otherwise a fresh `documentId` is minted and the
bytes go into the per-session byte store.
- **Annotations**, **evidence items**, and **evidence links** are
imported **additively**: each gets a freshly minted id, with any
`documentId`/`annotationId`/`evidenceItemId` references rewritten
via the remap. No update-in-place, no overwrite-by-id.
#### Known limitation: re-importing your own export duplicates annotations
Because annotations/evidence/links are always added with fresh ids,
re-importing a ZIP you just exported into the same session creates a
second copy of every annotation (the existing PDF bytes dedupe
correctly via fingerprint, but the annotations have nothing to
de-dupe against).
This is intentional for the demo loop and documented here so it's not
mistaken for a bug. A future workplan can introduce an
`importBundleId` field (a UUID minted at export time, stamped onto
the manifest and on every annotation/evidence-link the import
creates) plus a dedupe pass that skips entities already imported
under the same bundle id.
## Consequences
- **One source of truth for the engine snapshot.** Same shape on disk
and in memory; the persistence helpers stay re-usable.
- **Fingerprint-based dedup is byte-stable.** Two users converting
the same PDF end up with identical fingerprints; merging their
archives works as expected.
- **Idempotency is opt-in, not the default.** A user who wants exact
round-trips must use a future `importBundleId` flow, not the basic
T07 import.
- **Forward-compatible additions are cheap.** New top-level keys land
by adding fields; old importers preserve them and new importers
consume them.
## Status
Accepted. The TypeScript types + `parseSessionArchiveManifest` in
`src/shared/session-archive.ts` are the executable contract for
schemaVersion 1.

View File

@@ -19,6 +19,7 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"jszip": "^3.10.1",
"pdfjs-dist": "^4.4.168",
"react": "^18.3.1",
"react-dom": "^18.3.1",

80
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
jszip:
specifier: ^3.10.1
version: 3.10.1
pdfjs-dist:
specifier: ^4.4.168
version: 4.10.38
@@ -1500,6 +1503,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1892,6 +1898,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1900,6 +1909,9 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -2007,6 +2019,9 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -2043,6 +2058,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2050,6 +2068,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2231,6 +2252,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2312,6 +2336,9 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2346,6 +2373,9 @@ packages:
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -2378,6 +2408,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2434,6 +2467,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2568,6 +2604,9 @@ packages:
'@types/react':
optional: true
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -4052,6 +4091,8 @@ snapshots:
convert-source-map@2.0.0: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4542,6 +4583,8 @@ snapshots:
ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4549,6 +4592,8 @@ snapshots:
imurmurhash@0.1.4: {}
inherits@2.0.4: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4667,6 +4712,8 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
isarray@1.0.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -4691,6 +4738,13 @@ snapshots:
json5@2.2.3: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -4700,6 +4754,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -4876,6 +4934,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
process-nextick-args@2.0.1: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -4975,6 +5035,16 @@ snapshots:
dependencies:
loose-envify: 1.4.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -5054,6 +5124,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -5095,6 +5167,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.2
setimmediate@1.0.5: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -5167,6 +5241,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
@@ -5326,6 +5404,8 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.29
util-deprecate@1.0.2: {}
vite-node@2.1.9(@types/node@20.19.41):
dependencies:
cac: 6.7.14

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;
}
}

View File

@@ -21,7 +21,9 @@ import type {
EvidenceItemId,
EvidenceLinkId,
RepresentationId,
SessionId,
} from "@shared/ids";
import type { Session } from "@shared/session";
export interface DocumentImportedEvent {
readonly type: "DocumentImported";
@@ -36,6 +38,11 @@ export interface DocumentRepresentationGeneratedEvent {
readonly representation: DocumentRepresentation;
}
export interface DocumentRemovedEvent {
readonly type: "DocumentRemoved";
readonly documentId: DocumentId;
}
export interface AnnotationCreatedEvent {
readonly type: "AnnotationCreated";
readonly annotationId: AnnotationId;
@@ -92,9 +99,34 @@ export interface FormFieldActivatedEvent {
readonly previousTarget?: EvidenceTarget;
}
export interface SessionCreatedEvent {
readonly type: "SessionCreated";
readonly sessionId: SessionId;
readonly session: Session;
}
export interface SessionRenamedEvent {
readonly type: "SessionRenamed";
readonly sessionId: SessionId;
readonly session: Session;
readonly previousName: string;
}
export interface SessionDeletedEvent {
readonly type: "SessionDeleted";
readonly sessionId: SessionId;
}
export interface SessionActivatedEvent {
readonly type: "SessionActivated";
readonly sessionId: SessionId | null;
readonly previousSessionId: SessionId | null;
}
export type EngineEvent =
| DocumentImportedEvent
| DocumentRepresentationGeneratedEvent
| DocumentRemovedEvent
| AnnotationCreatedEvent
| AnnotationResolvedEvent
| AnnotationResolutionFailedEvent
@@ -103,7 +135,11 @@ export type EngineEvent =
| EvidenceItemActivatedEvent
| EvidenceLinkCreatedEvent
| EvidenceLinkUpdatedEvent
| FormFieldActivatedEvent;
| FormFieldActivatedEvent
| SessionCreatedEvent
| SessionRenamedEvent
| SessionDeletedEvent
| SessionActivatedEvent;
export type EngineEventType = EngineEvent["type"];

View File

@@ -0,0 +1,47 @@
/**
* In-memory `Map`-backed `SessionRepository`.
*
* Sister to `in-memory.ts` but for `Session` objects. The session repo
* lives *outside* the per-session `Engine` (one repo for the whole app;
* each session's engine snapshot is keyed by `session.id`). Keeping it
* in the same `engine/repos` directory keeps the storage layer
* conventions together so the eventual ADR-0005 swap touches one
* neighbourhood.
*/
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
export interface SessionRepository {
create(session: Session): Session;
get(id: SessionId): Session | null;
list(): readonly Session[];
update(session: Session): Session;
delete(id: SessionId): boolean;
}
export function createInMemorySessionRepository(): SessionRepository {
const sessions = new Map<SessionId, Session>();
return {
create(session) {
sessions.set(session.id, session);
return session;
},
get(id) {
return sessions.get(id) ?? null;
},
list() {
return [...sessions.values()];
},
update(session) {
if (!sessions.has(session.id)) {
throw new Error(`SessionRepository.update: unknown id ${session.id}`);
}
sessions.set(session.id, session);
return session;
},
delete(id) {
return sessions.delete(id);
},
};
}

View File

@@ -25,12 +25,14 @@ export interface DocumentRepository {
get(id: DocumentId): Document | null;
list(): readonly Document[];
update(document: Document): Document;
delete(id: DocumentId): boolean;
}
export interface RepresentationRepository {
create(representation: DocumentRepresentation): DocumentRepresentation;
get(id: RepresentationId): DocumentRepresentation | null;
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
deleteByDocument(documentId: DocumentId): number;
}
export interface AnnotationRepository {
@@ -82,6 +84,9 @@ export function createInMemoryRepos(): InMemoryRepos {
documents.set(document.id, document);
return document;
},
delete(id) {
return documents.delete(id);
},
},
representations: {
create(representation) {
@@ -98,6 +103,16 @@ export function createInMemoryRepos(): InMemoryRepos {
}
return out;
},
deleteByDocument(documentId) {
let removed = 0;
for (const [id, rep] of representations) {
if (rep.documentId === documentId) {
representations.delete(id);
removed += 1;
}
}
return removed;
},
},
annotations: {
create(annotation) {

View File

@@ -6,3 +6,7 @@ export {
type AnnotationRepository,
type EvidenceItemRepository,
} from "./in-memory";
export {
createInMemorySessionRepository,
type SessionRepository,
} from "./in-memory-sessions";

View File

@@ -23,6 +23,7 @@ export interface DocumentService {
list(): readonly Document[];
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
remove(id: DocumentId): boolean;
}
export function createDocumentService(
@@ -59,5 +60,15 @@ export function createDocumentService(
listRepresentations(documentId) {
return representations.listByDocument(documentId);
},
remove(id) {
const existing = documents.get(id);
if (!existing) return false;
representations.deleteByDocument(id);
const removed = documents.delete(id);
if (removed) {
bus.emit({ type: "DocumentRemoved", documentId: id });
}
return removed;
},
};
}

View File

@@ -12,3 +12,16 @@ export {
type EvidenceService,
type CreateEvidenceItemInput,
} from "./evidence";
export {
ACTIVE_SESSION_KEY,
attachSessionPersister,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,
restoreSessionsFromStorage,
SESSIONS_INDEX_KEY,
type CreateSessionInput,
type RestoreSessionsResult,
type SessionPersisterOptions,
type SessionService,
} from "./sessions";

View File

@@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import { createEventBus, type EventBus, type EngineEvent } from "../events";
import {
createInMemorySessionRepository,
type SessionRepository,
} from "../repos";
import {
ACTIVE_SESSION_KEY,
attachSessionPersister,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,
restoreSessionsFromStorage,
SESSIONS_INDEX_KEY,
type SessionService,
} from "./sessions";
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 freshService(): {
service: SessionService;
repo: SessionRepository;
bus: EventBus;
events: EngineEvent[];
} {
const repo = createInMemorySessionRepository();
const bus = createEventBus();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
const service = createSessionService(repo, bus);
return { service, repo, bus, events };
}
describe("engineSnapshotKey", () => {
it("formats as citation-evidence:session:<id>:engine-snapshot:v1", () => {
expect(engineSnapshotKey("sess_abc" as SessionId)).toBe(
"citation-evidence:session:sess_abc:engine-snapshot:v1",
);
});
});
describe("SessionService — lifecycle", () => {
let s: ReturnType<typeof freshService>;
beforeEach(() => {
s = freshService();
});
it("creates a session and emits SessionCreated", () => {
const created = s.service.create("Lease 2024");
expect(created.name).toBe("Lease 2024");
expect(created.id).toMatch(/^sess_/);
expect(s.events).toHaveLength(1);
expect(s.events[0]!.type).toBe("SessionCreated");
});
it("trims whitespace in names", () => {
const created = s.service.create(" Trimmed ");
expect(created.name).toBe("Trimmed");
});
it("rejects empty names", () => {
expect(() => s.service.create(" ")).toThrow(/must not be empty/);
});
it("rejects case-insensitive duplicates", () => {
s.service.create("Demo");
expect(() => s.service.create("demo")).toThrow(DuplicateSessionNameError);
expect(() => s.service.create(" Demo ")).toThrow(DuplicateSessionNameError);
});
it("rename emits SessionRenamed with previousName", () => {
const created = s.service.create("Old");
s.events.length = 0;
const renamed = s.service.rename(created.id, "New");
expect(renamed.name).toBe("New");
expect(s.events).toHaveLength(1);
const evt = s.events[0]!;
expect(evt.type).toBe("SessionRenamed");
if (evt.type === "SessionRenamed") {
expect(evt.previousName).toBe("Old");
}
});
it("rename rejects a duplicate (but allows renaming to your own current name)", () => {
const a = s.service.create("Alpha");
s.service.create("Beta");
expect(() => s.service.rename(a.id, "Beta")).toThrow(DuplicateSessionNameError);
// self-rename is fine
const same = s.service.rename(a.id, "Alpha");
expect(same.name).toBe("Alpha");
});
it("delete emits SessionDeleted and clears active if it was the active one", () => {
const created = s.service.create("To Delete");
s.service.setActive(created.id);
s.events.length = 0;
const ok = s.service.delete(created.id);
expect(ok).toBe(true);
const types = s.events.map((e) => e.type);
expect(types).toContain("SessionActivated"); // active cleared first
expect(types).toContain("SessionDeleted");
expect(s.service.getActive()).toBeNull();
});
it("delete on an unknown id is a no-op (returns false, no events)", () => {
const ok = s.service.delete("sess_missing" as SessionId);
expect(ok).toBe(false);
expect(s.events).toHaveLength(0);
});
it("setActive on an unknown id throws", () => {
expect(() => s.service.setActive("sess_nope" as SessionId)).toThrow(/unknown session/);
});
it("setActive emits SessionActivated with previousSessionId", () => {
const a = s.service.create("A");
const b = s.service.create("B");
s.events.length = 0;
s.service.setActive(a.id);
s.service.setActive(b.id);
const activated = s.events.filter((e) => e.type === "SessionActivated");
expect(activated).toHaveLength(2);
if (activated[1]!.type === "SessionActivated") {
expect(activated[1]!.previousSessionId).toBe(a.id);
expect(activated[1]!.sessionId).toBe(b.id);
}
});
it("setActive to the same id is a no-op (no event)", () => {
const a = s.service.create("A");
s.service.setActive(a.id);
s.events.length = 0;
s.service.setActive(a.id);
expect(s.events).toHaveLength(0);
});
it("setActive stamps lastOpenedAt", () => {
const a = s.service.create("A");
expect(a.lastOpenedAt).toBeUndefined();
s.service.setActive(a.id);
const reread = s.service.get(a.id);
expect(reread?.lastOpenedAt).toBeTruthy();
});
});
describe("attachSessionPersister + restoreSessionsFromStorage", () => {
it("round-trips a session index through storage", () => {
const storage = memoryStorage();
const src = freshService();
attachSessionPersister(src.service, src.bus, { storage });
const a = src.service.create("Alpha");
const b = src.service.create("Beta");
src.service.setActive(b.id);
// Read-back into a fresh service.
const dst = freshService();
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
expect(result.restored).toBe(true);
expect(result.sessions.map((s: Session) => s.name).sort()).toEqual(["Alpha", "Beta"]);
expect(result.activeSessionId).toBe(b.id);
expect(dst.service.getActive()).toBe(b.id);
// a is still in the repo too
expect(dst.service.get(a.id)?.name).toBe("Alpha");
});
it("returns {restored:false} when storage is empty", () => {
const storage = memoryStorage();
const dst = freshService();
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
expect(result.restored).toBe(false);
expect(result.sessions).toHaveLength(0);
expect(result.activeSessionId).toBeNull();
});
it("delete clears both the index entry and the per-session snapshot key", () => {
const storage = memoryStorage();
const src = freshService();
attachSessionPersister(src.service, src.bus, { storage });
const created = src.service.create("Doomed");
// Pretend an engine snapshot was written by the per-session persister.
storage.setItem(engineSnapshotKey(created.id), "{}");
src.service.delete(created.id);
expect(storage.getItem(engineSnapshotKey(created.id))).toBeNull();
const raw = storage.getItem(SESSIONS_INDEX_KEY);
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.sessions).toHaveLength(0);
expect(storage.getItem(ACTIVE_SESSION_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,302 @@
/**
* SessionService — lifecycle for `Session` records.
*
* Lives *above* the per-session `Engine` (the engine itself is recreated
* each time the active session changes). The service owns its own
* `EventBus` instance — independent of any engine bus — but uses the
* same `EngineEvent` shape so consumers can subscribe with the standard
* `bus.on("SessionCreated", …)` pattern.
*
* Per-session engine snapshot persistence is handled by attaching the
* existing `attachPersister(engine, { key: engineSnapshotKey(sessionId) })`
* inside the app's `EngineProvider`. The helpers in this file own the
* *cross-session* storage: the session index + the active-session
* pointer.
*
* Naming rules:
* - Names are trimmed on input.
* - Case-insensitive uniqueness — two sessions cannot coexist with
* names that differ only in case ("Demo" vs "demo"). This avoids
* surprising the ZIP-merge path in T07, which uses `session.name`
* to find an existing target.
*/
import { newId } from "@shared/ids";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import type { EventBus } from "../events";
import type { SessionRepository } from "../repos";
const SESSIONS_INDEX_KEY = "citation-evidence:sessions:v1";
const ACTIVE_SESSION_KEY = "citation-evidence:active-session-id:v1";
export { SESSIONS_INDEX_KEY, ACTIVE_SESSION_KEY };
/**
* Build the engine-snapshot storage key for a given session.
*
* Format: `citation-evidence:session:<sessionId>:engine-snapshot:v1`.
* The `v1` tail leaves room for a future migration that changes the
* snapshot shape without sweeping the legacy keys.
*/
export function engineSnapshotKey(sessionId: SessionId): string {
return `citation-evidence:session:${sessionId}:engine-snapshot:v1`;
}
export class DuplicateSessionNameError extends Error {
constructor(name: string) {
super(`Session with name "${name}" already exists`);
this.name = "DuplicateSessionNameError";
}
}
export interface CreateSessionInput {
readonly name: string;
/** Override the generated id — primarily for tests and importers. */
readonly id?: SessionId;
readonly now?: string;
}
export interface SessionService {
create(input: string | CreateSessionInput): Session;
rename(id: SessionId, name: string): Session;
delete(id: SessionId): boolean;
list(): readonly Session[];
get(id: SessionId): Session | null;
setActive(id: SessionId | null): void;
getActive(): SessionId | null;
/** Record an "I just opened this" timestamp on the session. */
touch(id: SessionId): Session | null;
}
function nowIso(now?: string): string {
return now ?? new Date().toISOString();
}
function normalisedName(name: string): { display: string; key: string } {
const display = name.trim();
return { display, key: display.toLocaleLowerCase() };
}
export function createSessionService(
repo: SessionRepository,
bus: EventBus,
): SessionService {
let activeId: SessionId | null = null;
function assertUniqueName(name: string, exceptId?: SessionId) {
const { key } = normalisedName(name);
for (const existing of repo.list()) {
if (exceptId && existing.id === exceptId) continue;
if (existing.name.trim().toLocaleLowerCase() === key) {
throw new DuplicateSessionNameError(name);
}
}
}
return {
create(input) {
const { name, id, now } =
typeof input === "string" ? { name: input, id: undefined, now: undefined } : input;
const { display } = normalisedName(name);
if (display.length === 0) {
throw new Error("SessionService.create: name must not be empty");
}
assertUniqueName(display);
const ts = nowIso(now);
const session: Session = {
id: id ?? newId("session"),
name: display,
createdAt: ts,
updatedAt: ts,
};
const stored = repo.create(session);
bus.emit({ type: "SessionCreated", sessionId: stored.id, session: stored });
return stored;
},
rename(id, name) {
const existing = repo.get(id);
if (!existing) {
throw new Error(`SessionService.rename: unknown session ${id}`);
}
const { display } = normalisedName(name);
if (display.length === 0) {
throw new Error("SessionService.rename: name must not be empty");
}
assertUniqueName(display, id);
const previousName = existing.name;
const next: Session = {
...existing,
name: display,
updatedAt: nowIso(),
};
const stored = repo.update(next);
bus.emit({
type: "SessionRenamed",
sessionId: stored.id,
session: stored,
previousName,
});
return stored;
},
delete(id) {
const removed = repo.delete(id);
if (removed) {
if (activeId === id) {
const previousSessionId = activeId;
activeId = null;
bus.emit({
type: "SessionActivated",
sessionId: null,
previousSessionId,
});
}
bus.emit({ type: "SessionDeleted", sessionId: id });
}
return removed;
},
list() {
return repo.list();
},
get(id) {
return repo.get(id);
},
setActive(id) {
if (id !== null && !repo.get(id)) {
throw new Error(`SessionService.setActive: unknown session ${id}`);
}
if (id === activeId) return;
const previousSessionId = activeId;
activeId = id;
if (id) {
const existing = repo.get(id);
if (existing) {
repo.update({ ...existing, lastOpenedAt: nowIso() });
}
}
bus.emit({
type: "SessionActivated",
sessionId: id,
previousSessionId,
});
},
getActive() {
return activeId;
},
touch(id) {
const existing = repo.get(id);
if (!existing) return null;
return repo.update({ ...existing, lastOpenedAt: nowIso() });
},
};
}
// ---------------------------------------------------------------------------
// Cross-session persistence (the session index + active-session pointer).
// Per-session engine snapshots are handled by `attachPersister` against
// `engineSnapshotKey(sessionId)`.
// ---------------------------------------------------------------------------
export interface SessionPersisterOptions {
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}
interface SessionsFile {
readonly version: 1;
readonly sessions: readonly Session[];
readonly activeSessionId: SessionId | null;
}
export function attachSessionPersister(
service: SessionService,
bus: EventBus,
options: SessionPersisterOptions = {},
): () => void {
const storage = options.storage ?? globalThis.localStorage;
const writeIndex = () => {
const file: SessionsFile = {
version: 1,
sessions: service.list(),
activeSessionId: service.getActive(),
};
try {
storage.setItem(SESSIONS_INDEX_KEY, JSON.stringify(file));
if (file.activeSessionId) {
storage.setItem(ACTIVE_SESSION_KEY, file.activeSessionId);
} else {
storage.removeItem(ACTIVE_SESSION_KEY);
}
} catch (err) {
console.warn("attachSessionPersister: write failed", err);
}
};
const writeIndexAndCleanup = () => {
writeIndex();
};
const writeOnDelete = (sessionId: SessionId) => {
writeIndex();
try {
storage.removeItem(engineSnapshotKey(sessionId));
} catch (err) {
console.warn("attachSessionPersister: snapshot cleanup failed", err);
}
};
const offs = [
bus.on("SessionCreated", writeIndexAndCleanup),
bus.on("SessionRenamed", writeIndexAndCleanup),
bus.on("SessionActivated", writeIndexAndCleanup),
bus.on("SessionDeleted", (e) => writeOnDelete(e.sessionId)),
];
return () => {
for (const off of offs) off();
};
}
export interface RestoreSessionsResult {
readonly restored: boolean;
readonly sessions: readonly Session[];
readonly activeSessionId: SessionId | null;
}
/**
* Hydrate the session repo from storage *without* firing events. Mirrors
* `restoreSnapshot`'s "direct repo write" pattern so the persister
* (which is attached after restore) doesn't immediately re-write what
* we just read.
*/
export function restoreSessionsFromStorage(
repo: SessionRepository,
service: SessionService,
options: SessionPersisterOptions = {},
): RestoreSessionsResult {
const storage = options.storage ?? globalThis.localStorage;
const raw = storage.getItem(SESSIONS_INDEX_KEY);
if (!raw) return { restored: false, sessions: [], activeSessionId: null };
try {
const parsed = JSON.parse(raw) as Partial<SessionsFile>;
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
return { restored: false, sessions: [], activeSessionId: null };
}
for (const s of parsed.sessions) repo.create(s);
const activeRaw =
typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null;
const fallbackActiveRaw = storage.getItem(ACTIVE_SESSION_KEY);
const candidate = (activeRaw ?? fallbackActiveRaw) as SessionId | null;
let activeSessionId: SessionId | null = null;
if (candidate && repo.get(candidate)) {
activeSessionId = candidate;
// Use service.setActive to keep the in-memory activeId aligned —
// suppress the resulting event so we don't bounce the persister.
// The bus listener attached *after* restore is what does the
// persistence, so emitting here is harmless either way; but
// skipping it keeps restore strictly side-effect-free from the
// listener's point of view.
service.setActive(activeSessionId);
}
return { restored: true, sessions: repo.list(), activeSessionId };
} catch (err) {
console.warn("restoreSessionsFromStorage: parse failed", err);
return { restored: false, sessions: [], activeSessionId: null };
}
}

View File

@@ -0,0 +1,98 @@
/**
* Per-session engine snapshot round-trip.
*
* The workplan (CE-WP-0005-T01) requires that two sessions persisted
* under the per-session key scheme can each be restored independently
* — proving the storage layout actually partitions data by session.
*/
import { describe, expect, it } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
import {
attachPersister,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type Engine,
type EngineSnapshot,
} from "./index";
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 seedDoc(engine: Engine, label: string): { id: DocumentId } {
const id = `doc_${label}` as DocumentId;
const repId = `rep_${label}` as RepresentationId;
const document: Document = {
id,
mediaType: "application/pdf",
title: `Doc ${label}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: repId,
documentId: id,
representationType: "pdf-text",
contentHash: `hash-${label}`,
canonicalText: `text for ${label}`,
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 12, pageLength: 12 }],
generatedAt: "2026-05-25T00:00:00.000Z",
};
engine.documents.register({ document, representation });
return { id };
}
describe("per-session engine snapshot round-trip", () => {
it("keeps two sessions' snapshots isolated under per-session storage keys", () => {
const storage = memoryStorage();
const sessA = "sess_aaa" as SessionId;
const sessB = "sess_bbb" as SessionId;
// Author session A.
const engineA = createEngine();
const offA = attachPersister(engineA, { key: engineSnapshotKey(sessA), storage });
const a1 = seedDoc(engineA, "a1");
const a2 = seedDoc(engineA, "a2");
offA();
// Author session B with completely different documents.
const engineB = createEngine();
const offB = attachPersister(engineB, { key: engineSnapshotKey(sessB), storage });
const b1 = seedDoc(engineB, "b1");
offB();
// Restore each into its own fresh engine; assert isolation.
const restoredA = createEngine();
const resA = restoreFromStorage(restoredA, { key: engineSnapshotKey(sessA), storage });
expect(resA.restored).toBe(true);
const aIds = restoredA.documents.list().map((d) => d.id).sort();
expect(aIds).toEqual([a1.id, a2.id].sort());
const restoredB = createEngine();
const resB = restoreFromStorage(restoredB, { key: engineSnapshotKey(sessB), storage });
expect(resB.restored).toBe(true);
const bIds = restoredB.documents.list().map((d) => d.id);
expect(bIds).toEqual([b1.id]);
// Sanity: each snapshot key really does hold a distinct snapshot.
const rawA = storage.getItem(engineSnapshotKey(sessA));
const rawB = storage.getItem(engineSnapshotKey(sessB));
expect(rawA).not.toBeNull();
expect(rawB).not.toBeNull();
const snapA = JSON.parse(rawA!) as EngineSnapshot;
const snapB = JSON.parse(rawB!) as EngineSnapshot;
expect(snapA.documents).toHaveLength(2);
expect(snapB.documents).toHaveLength(1);
});
});

View File

@@ -19,6 +19,7 @@ export type EvidenceSetId = Brand<string, "EvidenceSetId">;
export type EvidenceLinkId = Brand<string, "EvidenceLinkId">;
export type CitationCardId = Brand<string, "CitationCardId">;
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
export type SessionId = Brand<string, "SessionId">;
export type IdKindMap = {
document: DocumentId;
@@ -29,6 +30,7 @@ export type IdKindMap = {
"evidence-link": EvidenceLinkId;
"citation-card": CitationCardId;
"citation-recovery": CitationRecoveryAttemptId;
session: SessionId;
};
export type IdKind = keyof IdKindMap;
@@ -42,6 +44,7 @@ const PREFIXES: Record<IdKind, string> = {
"evidence-link": "evlink",
"citation-card": "card",
"citation-recovery": "crec",
session: "sess",
};
/**

View File

@@ -8,4 +8,6 @@ export * from "./evidence-set";
export * from "./citation-card";
export * from "./citation-card-source";
export * from "./open-context-url";
export * from "./session";
export * from "./session-archive";
export { normalize, NORMALIZE_VERSION } from "./text/normalize";

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { DocumentId, SessionId } from "./ids";
import {
parseSessionArchiveManifest,
SESSION_ARCHIVE_SCHEMA_VERSION,
SessionArchiveParseError,
type SessionArchiveManifest,
} from "./session-archive";
function validManifest(): SessionArchiveManifest {
return {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt: "2026-05-25T00:00:00.000Z",
session: {
id: "sess_abc" as SessionId,
name: "Demo",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
engine: {
version: 1,
documents: [],
representations: [],
annotations: [],
evidenceItems: [],
},
documentBindings: [
{
documentId: "doc_abc" as DocumentId,
filename: "demo.pdf",
fingerprint: "abc123",
},
],
};
}
describe("parseSessionArchiveManifest", () => {
it("round-trips a valid manifest through JSON.stringify + parse", () => {
const m = validManifest();
const round = parseSessionArchiveManifest(JSON.parse(JSON.stringify(m)));
expect(round).toEqual(m);
});
it("rejects an unsupported schemaVersion", () => {
const m = { ...validManifest(), schemaVersion: 999 as unknown };
expect(() => parseSessionArchiveManifest(m)).toThrow(SessionArchiveParseError);
expect(() => parseSessionArchiveManifest(m)).toThrow(/unsupported schemaVersion/);
});
it("rejects a missing required top-level field", () => {
const m = validManifest();
const broken = { ...m, exportedAt: undefined as unknown };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/exportedAt/);
});
it("rejects a malformed session sub-object", () => {
const m = validManifest();
const broken = { ...m, session: { ...m.session, id: 12345 as unknown } };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/session\.id/);
});
it("rejects a malformed engine snapshot", () => {
const m = validManifest();
const broken = { ...m, engine: { ...m.engine, version: "1" as unknown } };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/engine\.version/);
});
it("rejects a non-array documentBindings", () => {
const m = validManifest();
const broken = { ...m, documentBindings: "nope" as unknown };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings/);
});
it("rejects a malformed documentBindings entry", () => {
const m = validManifest();
const broken = {
...m,
documentBindings: [{ documentId: "doc_x", fingerprint: "abc" }] as unknown[],
};
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings\[0\]\.filename/);
});
it("rejects a non-object root", () => {
expect(() => parseSessionArchiveManifest("oops")).toThrow(/manifest/);
expect(() => parseSessionArchiveManifest(null)).toThrow(/manifest/);
});
});

View File

@@ -0,0 +1,150 @@
/**
* SessionArchiveManifest — JSON contract for `manifest.json` inside a
* session ZIP archive.
*
* Schema version 1; see `docs/decisions/ADR-0008-session-archive-format.md`
* for the authoritative spec. This module is the executable contract:
* `parseSessionArchiveManifest` validates an `unknown` JSON value and
* narrows it to `SessionArchiveManifest` or throws with a useful
* message.
*
* The `engine` field re-uses the in-memory `EngineSnapshot` shape so
* the in-memory ↔ archive round-trip stays a one-way conversion.
* Only minimal structural validation runs here; the engine helpers
* (`restoreSnapshot`) handle deeper validation when actually
* hydrating an engine.
*/
import type { DocumentId, SessionId } from "./ids";
export const SESSION_ARCHIVE_SCHEMA_VERSION = 1 as const;
export interface SessionArchiveSessionRecord {
readonly id: SessionId;
readonly name: string;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface SessionArchiveDocumentBinding {
readonly documentId: DocumentId;
readonly filename: string;
readonly fingerprint: string;
}
/**
* Mirror of `EngineSnapshot` — kept loose here (record of unknown) to
* avoid the cross-package dependency back into `@engine`. The engine
* persistence layer owns the authoritative shape.
*/
export interface SessionArchiveEngineSnapshot {
readonly version: number;
readonly documents: readonly unknown[];
readonly representations: readonly unknown[];
readonly annotations: readonly unknown[];
readonly evidenceItems: readonly unknown[];
}
export interface SessionArchiveManifest {
readonly schemaVersion: typeof SESSION_ARCHIVE_SCHEMA_VERSION;
readonly exportedAt: string;
readonly session: SessionArchiveSessionRecord;
readonly engine: SessionArchiveEngineSnapshot;
readonly documentBindings: readonly SessionArchiveDocumentBinding[];
}
export class SessionArchiveParseError extends Error {
constructor(message: string) {
super(`SessionArchiveManifest parse failed: ${message}`);
this.name = "SessionArchiveParseError";
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertString(value: unknown, field: string): string {
if (typeof value !== "string") {
throw new SessionArchiveParseError(`field "${field}" must be a string`);
}
return value;
}
function assertObject(value: unknown, field: string): Record<string, unknown> {
if (!isObject(value)) {
throw new SessionArchiveParseError(`field "${field}" must be an object`);
}
return value;
}
function assertArray(value: unknown, field: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new SessionArchiveParseError(`field "${field}" must be an array`);
}
return value;
}
function parseSessionRecord(raw: unknown): SessionArchiveSessionRecord {
const obj = assertObject(raw, "session");
return {
id: assertString(obj.id, "session.id") as SessionId,
name: assertString(obj.name, "session.name"),
createdAt: assertString(obj.createdAt, "session.createdAt"),
updatedAt: assertString(obj.updatedAt, "session.updatedAt"),
};
}
function parseDocumentBinding(
raw: unknown,
index: number,
): SessionArchiveDocumentBinding {
const obj = assertObject(raw, `documentBindings[${index}]`);
return {
documentId: assertString(obj.documentId, `documentBindings[${index}].documentId`) as DocumentId,
filename: assertString(obj.filename, `documentBindings[${index}].filename`),
fingerprint: assertString(obj.fingerprint, `documentBindings[${index}].fingerprint`),
};
}
function parseEngineSnapshot(raw: unknown): SessionArchiveEngineSnapshot {
const obj = assertObject(raw, "engine");
const version = obj.version;
if (typeof version !== "number") {
throw new SessionArchiveParseError(`field "engine.version" must be a number`);
}
const documents = assertArray(obj.documents, "engine.documents");
const representations = assertArray(obj.representations, "engine.representations");
const annotations = assertArray(obj.annotations, "engine.annotations");
const evidenceItems = assertArray(obj.evidenceItems, "engine.evidenceItems");
return {
version,
documents,
representations,
annotations,
evidenceItems,
};
}
export function parseSessionArchiveManifest(raw: unknown): SessionArchiveManifest {
const obj = assertObject(raw, "manifest");
const schemaVersion = obj.schemaVersion;
if (schemaVersion !== SESSION_ARCHIVE_SCHEMA_VERSION) {
throw new SessionArchiveParseError(
`unsupported schemaVersion ${String(schemaVersion)} — expected ${SESSION_ARCHIVE_SCHEMA_VERSION}`,
);
}
const exportedAt = assertString(obj.exportedAt, "exportedAt");
const session = parseSessionRecord(obj.session);
const engine = parseEngineSnapshot(obj.engine);
const documentBindings = assertArray(obj.documentBindings, "documentBindings").map(
(entry, i) => parseDocumentBinding(entry, i),
);
return {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt,
session,
engine,
documentBindings,
};
}

26
src/shared/session.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* `Session` — a user-named workspace that owns one engine snapshot.
*
* Sessions partition the demo app: each one holds its own documents,
* annotations, evidence items, and links. Membership is implicit — a
* session "owns" whatever lives in its engine snapshot. The session
* record itself only carries the human metadata (name, timestamps) and
* the branded id used as a key in `localStorage` and the ZIP archive
* (see ADR-0008).
*
* The id is opaque (`sess_<uuid>` per `ids.ts`). The name is the human
* label; uniqueness is enforced by the `SessionService` on create and
* rename. Names are *trimmed* before storage so a leading/trailing
* space does not let two sessions coexist with effectively the same
* label.
*/
import type { SessionId } from "./ids";
export interface Session {
readonly id: SessionId;
readonly name: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly lastOpenedAt?: string;
}

View File

@@ -6,3 +6,13 @@ export {
} from "./pdf/ingest";
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
export { fingerprintBytes } from "./pdf/fingerprint";
export {
createPdfByteStore,
type CreatePdfByteStoreOptions,
type PdfByteRecord,
type PdfByteStore,
} from "./pdf/byte-store";
export {
ingestPdfFromFile,
type IngestPdfFromFileOptions,
} from "./pdf/upload";

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import type { DocumentId } from "@shared/ids";
import { createPdfByteStore } from "./byte-store";
function stubUrlHelpers() {
let counter = 0;
const created: string[] = [];
const revoked: string[] = [];
const createObjectURL = vi.fn(() => {
const url = `blob:stub-${++counter}`;
created.push(url);
return url;
});
const revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
return { createObjectURL, revokeObjectURL, created, revoked };
}
describe("PdfByteStore", () => {
it("put / get round-trips bytes and exposes a blob URL", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF
const record = store.put("doc_a" as DocumentId, bytes);
expect(record.blobUrl).toBe("blob:stub-1");
expect(store.get("doc_a" as DocumentId)?.bytes).toBe(bytes);
expect(store.has("doc_a" as DocumentId)).toBe(true);
expect(store.list()).toEqual(["doc_a"]);
expect(store.size()).toBe(4);
});
it("put replaces an existing entry and revokes the old URL", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const id = "doc_a" as DocumentId;
const first = store.put(id, new Uint8Array([1, 2]));
const second = store.put(id, new Uint8Array([3, 4, 5]));
expect(helpers.revoked).toEqual([first.blobUrl]);
expect(store.get(id)?.bytes).toHaveLength(3);
expect(second.blobUrl).not.toBe(first.blobUrl);
});
it("delete revokes the blob URL exactly once and is idempotent", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const id = "doc_a" as DocumentId;
const record = store.put(id, new Uint8Array([1, 2, 3]));
expect(store.delete(id)).toBe(true);
expect(helpers.revoked).toEqual([record.blobUrl]);
expect(store.delete(id)).toBe(false);
// No additional revoke calls on the second delete.
expect(helpers.revoked).toHaveLength(1);
expect(store.get(id)).toBeNull();
expect(store.has(id)).toBe(false);
});
it("clear revokes every URL and empties the store", () => {
const helpers = stubUrlHelpers();
const store = createPdfByteStore(helpers);
const a = store.put("doc_a" as DocumentId, new Uint8Array([1]));
const b = store.put("doc_b" as DocumentId, new Uint8Array([2]));
store.clear();
expect(helpers.revoked.sort()).toEqual([a.blobUrl, b.blobUrl].sort());
expect(store.list()).toEqual([]);
expect(store.size()).toBe(0);
});
it("uses URL.createObjectURL by default when no override is supplied", () => {
const createObjectURL = vi.fn(() => "blob:built-in");
const revokeObjectURL = vi.fn();
const originalURL = globalThis.URL;
// happy-dom's URL has createObjectURL; node sometimes does not. Stub it.
Object.defineProperty(globalThis, "URL", {
configurable: true,
writable: true,
value: Object.assign(Object.create(originalURL.prototype as object), {
createObjectURL,
revokeObjectURL,
}),
});
try {
const store = createPdfByteStore();
const rec = store.put("doc_z" as DocumentId, new Uint8Array([9]));
expect(rec.blobUrl).toBe("blob:built-in");
expect(createObjectURL).toHaveBeenCalledTimes(1);
store.delete("doc_z" as DocumentId);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:built-in");
} finally {
Object.defineProperty(globalThis, "URL", {
configurable: true,
writable: true,
value: originalURL,
});
}
});
});

View File

@@ -0,0 +1,113 @@
/**
* `PdfByteStore` — in-memory store for uploaded PDF bytes, keyed by
* `DocumentId`.
*
* CE-WP-0005 stores uploaded PDFs in memory only (per the workplan
* scoping decision). Bytes survive within a tab session; reloading the
* page loses them unless the user exported a ZIP. Re-importing the ZIP
* restores them.
*
* One store instance per active session. The session-management layer
* is responsible for swapping the active store when the user switches
* sessions. The store also owns a small registry of issued `blob:`
* URLs so it can revoke them on delete/clear — no cross-cutting
* cleanup at the app layer.
*/
import type { DocumentId } from "@shared/ids";
export interface PdfByteRecord {
readonly bytes: Uint8Array;
/** A `blob:` URL the viewer can consume directly. */
readonly blobUrl: string;
}
export interface PdfByteStore {
put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord;
get(documentId: DocumentId): PdfByteRecord | null;
has(documentId: DocumentId): boolean;
delete(documentId: DocumentId): boolean;
list(): readonly DocumentId[];
/** Revoke every blob URL and clear the store. */
clear(): void;
/** Total bytes currently held — useful for UI dashboards. */
size(): number;
}
export interface CreatePdfByteStoreOptions {
/**
* Mint a URL for the given bytes. Defaults to `URL.createObjectURL` in
* environments that have it; tests can inject a deterministic stub.
*/
readonly createObjectURL?: (blob: Blob) => string;
/** Revoke a URL previously minted by `createObjectURL`. */
readonly revokeObjectURL?: (url: string) => void;
}
export function createPdfByteStore(
options: CreatePdfByteStoreOptions = {},
): PdfByteStore {
const createUrl =
options.createObjectURL ??
((blob: Blob) => {
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
throw new Error(
"createPdfByteStore: URL.createObjectURL not available — inject a stub via options",
);
}
return URL.createObjectURL(blob);
});
const revokeUrl =
options.revokeObjectURL ??
((url: string) => {
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(url);
}
});
const records = new Map<DocumentId, PdfByteRecord>();
return {
put(documentId, bytes) {
// Replace previous record (revoking the prior URL) if any.
const prior = records.get(documentId);
if (prior) revokeUrl(prior.blobUrl);
// Cast: Blob() does accept Uint8Array at runtime, but TS narrows the
// buffer type to ArrayBufferLike (could be SharedArrayBuffer) and
// refuses without help. The bytes here always come from a fresh
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
const blob = new Blob([bytes as unknown as ArrayBuffer], {
type: "application/pdf",
});
const blobUrl = createUrl(blob);
const record: PdfByteRecord = { bytes, blobUrl };
records.set(documentId, record);
return record;
},
get(documentId) {
return records.get(documentId) ?? null;
},
has(documentId) {
return records.has(documentId);
},
delete(documentId) {
const record = records.get(documentId);
if (!record) return false;
revokeUrl(record.blobUrl);
records.delete(documentId);
return true;
},
list() {
return [...records.keys()];
},
clear() {
for (const record of records.values()) revokeUrl(record.blobUrl);
records.clear();
},
size() {
let total = 0;
for (const r of records.values()) total += r.bytes.byteLength;
return total;
},
};
}

View File

@@ -0,0 +1,91 @@
/**
* `ingestPdfFromFile` end-to-end: pipes a fixture PDF through the
* upload path, asserts the byte store keeps the bytes and the document
* record carries the minted `blob:` URL.
*/
import { readFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import { createPdfByteStore } from "./byte-store";
import { ingestPdfFromFile } from "./upload";
const FIXTURE_PATH = new URL(
"../../../fixtures/pdfs/Fristsetzung zur Bezifferung GÜ an Gegenseite 3 Wochen.pdf",
import.meta.url,
);
async function fixtureBytes(): Promise<Uint8Array> {
return new Uint8Array(await readFile(FIXTURE_PATH));
}
class FakeFile {
readonly name: string;
private readonly bytes: Uint8Array;
constructor(bytes: Uint8Array, name: string) {
this.bytes = bytes;
this.name = name;
}
async arrayBuffer(): Promise<ArrayBuffer> {
const out = new ArrayBuffer(this.bytes.byteLength);
new Uint8Array(out).set(this.bytes);
return out;
}
}
describe("ingestPdfFromFile", () => {
it("round-trips a fixture PDF through ingest + byte store + blob URL", async () => {
const bytes = await fixtureBytes();
const file = new FakeFile(bytes, "demo.pdf") as unknown as File;
let counter = 0;
const store = createPdfByteStore({
createObjectURL: () => `blob:upload-stub-${++counter}`,
revokeObjectURL: () => {},
});
const { document, representation } = await ingestPdfFromFile(file, store);
// Bytes are stored, retrievable by document id.
const stored = store.get(document.id);
expect(stored).not.toBeNull();
expect(stored!.bytes.byteLength).toBe(bytes.byteLength);
// Document carries the blob URL minted by the store.
expect(document.uri).toBe(`blob:upload-stub-${counter}`);
expect(document.title).toBe("demo.pdf");
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
// Representation is the standard pdf-text one.
expect(representation.representationType).toBe("pdf-text");
expect((representation.canonicalText ?? "").length).toBeGreaterThan(0);
}, 30_000);
it("falls through to ingestPdf with no filename when given a plain Blob", async () => {
const bytes = await fixtureBytes();
const blob = {
async arrayBuffer() {
const out = new ArrayBuffer(bytes.byteLength);
new Uint8Array(out).set(bytes);
return out;
},
} as Blob;
const store = createPdfByteStore({
createObjectURL: () => "blob:no-name",
revokeObjectURL: () => {},
});
const { document } = await ingestPdfFromFile(blob, store);
expect(document.title).toBeUndefined();
expect(document.uri).toBe("blob:no-name");
}, 30_000);
it("explicit title option overrides the filename", async () => {
const bytes = await fixtureBytes();
const file = new FakeFile(bytes, "anonymous-name.pdf") as unknown as File;
const store = createPdfByteStore({
createObjectURL: vi.fn(() => "blob:override"),
revokeObjectURL: vi.fn(),
});
const { document } = await ingestPdfFromFile(file, store, { title: "Custom" });
expect(document.title).toBe("Custom");
}, 30_000);
});

45
src/source/pdf/upload.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Upload-side ingest path.
*
* The fixture-loading path in `App.tsx` fetches a known URL and calls
* `ingestPdf` directly; that path stays untouched for the optional
* "Sample sessions" quick-start. Uploaded files flow through here
* instead:
*
* 1. Read `file.arrayBuffer()` once into a `Uint8Array`.
* 2. Run the existing `ingestPdf(bytes, { filename })` pipeline to
* produce `{document, representation}`.
* 3. Push the bytes into the per-session `PdfByteStore`, which mints
* a `blob:` URL and stamps it onto `document.uri` so the viewer
* adapter can mount the PDF directly.
* 4. Hand the engine inputs back to the caller, which wires them via
* `engine.documents.register(...)`.
*
* Keeping URL-minting inside the byte store (rather than at the call
* site) means there is exactly one place that creates `blob:` URLs and
* exactly one place that revokes them.
*/
import { ingestPdf, type IngestPdfResult } from "./ingest";
import type { PdfByteStore } from "./byte-store";
export interface IngestPdfFromFileOptions {
/** Override the filename used as the document title. */
readonly title?: string;
}
export async function ingestPdfFromFile(
file: File | Blob,
store: PdfByteStore,
options: IngestPdfFromFileOptions = {},
): Promise<IngestPdfResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
const ingested = await ingestPdf(bytes, {
...(filename !== undefined ? { filename } : {}),
...(options.title !== undefined ? { title: options.title } : {}),
});
const record = store.put(ingested.document.id, bytes);
const document = { ...ingested.document, uri: record.blobUrl };
return { document, representation: ingested.representation };
}

View File

@@ -0,0 +1,112 @@
// @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 { CollectionList, EngineProvider, useEngine, usePdfByteStore } from "./index";
function makeDoc(suffix: string): { document: Document; representation: DocumentRepresentation } {
const id = `doc_${suffix}` as DocumentId;
const repId = `rep_${suffix}` as RepresentationId;
return {
document: {
id,
mediaType: "application/pdf",
title: `Doc ${suffix}`,
fingerprint: `hash-${suffix}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: id,
representationType: "pdf-text",
contentHash: `hash-${suffix}`,
canonicalText: `body ${suffix}`,
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 6, pageLength: 6 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
};
}
function Seed() {
const engine = useEngine();
const store = usePdfByteStore();
if (engine.documents.list().length === 0) {
const a = makeDoc("alpha");
const b = makeDoc("beta");
store.put(a.document.id, new Uint8Array([1, 2]));
store.put(b.document.id, new Uint8Array([3, 4]));
engine.documents.register(a);
engine.documents.register(b);
}
return null;
}
beforeEach(() => {
globalThis.localStorage?.clear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("CollectionList (session-scoped)", () => {
it("renders one row per registered document", async () => {
render(
<EngineProvider>
<Seed />
<CollectionList title="Demo session" />
</EngineProvider>,
);
await waitFor(() => {
expect(screen.getByText("Doc alpha")).toBeTruthy();
expect(screen.getByText("Doc beta")).toBeTruthy();
});
expect(screen.getByText("Demo session")).toBeTruthy();
});
it(
"per-row delete asks for confirmation, then removes the row and revokes the blob URL",
{ timeout: 8000 },
async () => {
let revokedUrl: string | null = null;
// Patch URL.revokeObjectURL so we can confirm the byte store fired it.
const original = URL.revokeObjectURL;
URL.revokeObjectURL = (url: string) => {
revokedUrl = url;
};
try {
render(
<EngineProvider>
<Seed />
<CollectionList />
</EngineProvider>,
);
await screen.findByText("Doc alpha");
const user = userEvent.setup();
const deleteBtn = await screen.findByTestId("collection-delete-doc_alpha");
// First click → confirm prompt
await user.click(deleteBtn);
expect(deleteBtn.textContent).toContain("Confirm");
// Second click → commit
await user.click(deleteBtn);
await waitFor(() => {
expect(screen.queryByText("Doc alpha")).toBeNull();
});
expect(revokedUrl).not.toBeNull();
expect(revokedUrl!).toMatch(/^blob:/);
} finally {
URL.revokeObjectURL = original;
}
},
);
});

View File

@@ -1,71 +1,76 @@
/**
* CollectionList — the left pane.
*
* Lists the fixture corpus (the MVP stand-in for a real document collection).
* Clicking a fixture fetches the bytes, runs `ingestPdf` (PDF.js extraction
* + fingerprint + canonical text), registers the result with the engine
* (emitting §4 events), and activates it as the current document.
* CE-WP-0005 turned this into a *session-scoped* list. It shows the
* documents currently registered with the active session's engine,
* with per-row Open + Delete actions and an inline upload affordance.
*
* Per CE-WP-0002-T06, the loaded fixture set is hard-wired to
* `fixtures/pdfs/manifest.json`. Real collections arrive in a later
* workplan.
* Fixture-driven quick-start lives in
* `src/app/sessions/SampleSessions.tsx` and is no longer the default.
*/
import { useCallback, useState } from "react";
import { ingestPdf } from "@source/index";
import { useEngine, useActiveDocumentId } from "./EngineContext";
import type { DocumentId } from "@shared/ids";
import manifest from "../../fixtures/pdfs/manifest.json";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import type { ReactNode } from "react";
interface Fixture {
id: string;
filename: string;
description: string;
page_count: number;
import type { DocumentId } from "@shared/ids";
import {
useActiveDocumentId,
useEngine,
useEngineEventTick,
useEngineRevision,
usePdfByteStore,
} from "./EngineContext";
export interface CollectionListProps {
/**
* Slot rendered above the list — typically the upload affordance.
* Kept as a slot so this component stays in `work/` (which cannot
* import `app/`).
*/
readonly upload?: ReactNode;
/** Optional session header text — typically the active session name. */
readonly title?: string;
}
const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures;
export function CollectionList() {
export function CollectionList({ upload, title }: CollectionListProps) {
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);
// Remember which fixture-id maps to which loaded documentId so re-clicking
// a fixture activates the existing engine record rather than re-ingesting.
const [byFixture, setByFixture] = useState<Record<string, DocumentId>>({});
const handleLoad = useCallback(
async (fixture: Fixture) => {
setError(null);
const importedTick = useEngineEventTick("DocumentImported");
const removedTick = useEngineEventTick("DocumentRemoved");
const revision = useEngineRevision();
const existing = byFixture[fixture.id];
if (existing) {
setId(existing);
const documents = useMemo(
() => engine.documents.list(),
[engine, importedTick, removedTick, revision],
);
// Confirm-on-delete UX without a modal: clicking Delete asks "Confirm?",
// a second click within ~3s commits. Esc clears the pending state.
const [pendingDeleteId, setPendingDeleteId] = useState<DocumentId | null>(null);
useEffect(() => {
if (!pendingDeleteId) return;
const t = setTimeout(() => setPendingDeleteId(null), 3000);
return () => clearTimeout(t);
}, [pendingDeleteId]);
const handleDelete = useCallback(
(id: DocumentId) => {
if (pendingDeleteId !== id) {
setPendingDeleteId(id);
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 { document, representation } = await ingestPdf(new Uint8Array(buffer), {
filename: fixture.filename,
});
engine.documents.register({ document, representation });
setByFixture((prev) => ({ ...prev, [fixture.id]: document.id }));
setId(document.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingFixtureId(null);
}
// Active doc was just deleted — clear the pointer so the viewer
// unmounts before the engine drops the record.
if (activeId === id) setId(null);
byteStore.delete(id);
engine.documents.remove(id);
setPendingDeleteId(null);
},
[byFixture, engine, setId],
[activeId, byteStore, engine, pendingDeleteId, setId],
);
return (
@@ -78,44 +83,66 @@ export function CollectionList() {
flex: "0 0 280px",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Collection</h2>
<h2 style={{ marginTop: 0, fontSize: 16 }}>
{title ?? "Collection"}
</h2>
<p style={{ fontSize: 12, color: "#555", marginTop: 0 }}>
{FIXTURES.length} fixture PDF{FIXTURES.length === 1 ? "" : "s"}
{documents.length} document{documents.length === 1 ? "" : "s"}
</p>
{error && (
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
{error}
{upload && <div style={{ marginBottom: 8 }}>{upload}</div>}
{documents.length === 0 && !upload && (
<p style={{ fontSize: 12, color: "#888" }}>
No documents yet. Upload a PDF to get started.
</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;
<ul
data-testid="collection-list-items"
style={{ listStyle: "none", padding: 0, margin: 0 }}
>
{documents.map((doc) => {
const isActive = doc.id === activeId;
const isPending = pendingDeleteId === doc.id;
return (
<li key={f.id} style={{ marginBottom: 6 }}>
<button
onClick={() => {
void handleLoad(f);
}}
disabled={isLoading}
<li key={doc.id} style={{ marginBottom: 6 }}>
<div
style={{
display: "block",
width: "100%",
textAlign: "left",
background: isActive ? "#e8f0ff" : "white",
border: "1px solid #ccc",
padding: 6,
cursor: isLoading ? "wait" : "pointer",
background: isActive ? "#e8f0ff" : "white",
display: "flex",
flexDirection: "column",
fontSize: 12,
}}
data-testid={`collection-item-${doc.id}`}
>
<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" : ""}
<button
onClick={() => setId(doc.id)}
data-testid={`collection-open-${doc.id}`}
style={openButtonStyle}
>
<div style={{ fontWeight: 600 }}>{doc.title ?? doc.id}</div>
<div style={{ color: "#666", fontSize: 11 }}>
{doc.id}
{isActive ? " · open" : ""}
</div>
</button>
<div style={{ display: "flex", justifyContent: "flex-end", padding: 4, gap: 4 }}>
<button
type="button"
onClick={() => handleDelete(doc.id)}
data-testid={`collection-delete-${doc.id}`}
style={{
fontSize: 11,
padding: "2px 8px",
border: "1px solid #b00",
background: isPending ? "#ffe5e5" : "white",
color: "#7a0000",
cursor: "pointer",
}}
>
{isPending ? "Confirm delete?" : "Delete"}
</button>
</div>
</button>
</div>
</li>
);
})}
@@ -123,3 +150,14 @@ export function CollectionList() {
</aside>
);
}
const openButtonStyle: CSSProperties = {
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
cursor: "pointer",
fontSize: 12,
};

View File

@@ -21,24 +21,39 @@ import {
type ReactNode,
} from "react";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { AnnotationId, DocumentId } from "@shared/ids";
import type { AnnotationId, DocumentId, SessionId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import {
attachPersister,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type Engine,
} from "@engine/index";
import type { PdfSelectionCapture } from "@anchor/index";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import { useContext as useReactContext } from "react";
import { SessionInternalContext } from "./SessionContextInternal";
/**
* localStorage keys for the engine snapshot and the UI's "what was open"
* pointer. ADR-0005 frames both as deliberately temporary — real
* persistence later.
* Legacy single-bucket storage keys, kept for any user landing on a
* build without sessions. CE-WP-0005 switched persistence to per-session
* keys (`engineSnapshotKey(sessionId)`); the unscoped keys below are
* only consulted when no `sessionId` is provided to the provider.
*/
const STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
const LEGACY_STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
const ACTIVE_KEY = "citation-evidence:active-document-id:v1";
function storageKeyFor(sessionId: SessionId | null): string {
return sessionId ? engineSnapshotKey(sessionId) : LEGACY_STORAGE_KEY;
}
function activeDocumentKeyFor(sessionId: SessionId | null): string {
return sessionId
? `citation-evidence:session:${sessionId}:active-document-id:v1`
: ACTIVE_KEY;
}
/**
* The pending selection lives in context (not local component state) because
* the toolbar that consumes it is rendered above the viewer, not inside it.
@@ -51,6 +66,7 @@ export interface PendingSelection {
interface EngineContextValue {
readonly engine: Engine;
readonly byteStore: PdfByteStore;
readonly activeDocumentId: DocumentId | null;
setActiveDocumentId(id: DocumentId | null): void;
readonly pendingSelection: PendingSelection | null;
@@ -60,6 +76,13 @@ interface EngineContextValue {
* so a second click on the same evidence item still triggers a scroll. */
readonly scrollVersion: number;
scrollToAnnotation(id: AnnotationId | null): void;
/**
* Bumps each time the engine's repos are mutated outside the normal
* event-emitting service path — currently only on `restoreFromStorage`.
* Consumers that cache `engine.documents.list()` via `useMemo` add this
* to their deps so the restored state is reflected on remount.
*/
readonly engineRevision: number;
}
const EngineContext = createContext<EngineContextValue | null>(null);
@@ -68,31 +91,67 @@ interface EngineProviderProps {
readonly children: ReactNode;
/** Inject a pre-built engine for tests; production uses the default. */
readonly engine?: Engine;
/**
* Active session id. Drives the per-session storage key for the engine
* snapshot and the active-document pointer. `null`/omitted falls back
* to the legacy unscoped keys for back-compat with pre-CE-WP-0005
* builds.
*
* To switch sessions, parents should *re-key* this provider
* (`<EngineProvider key={sessionId} sessionId={sessionId}>`) so React
* unmounts the subtree and a fresh engine is created.
*/
readonly sessionId?: SessionId | null;
}
export function EngineProvider({ children, engine: injected }: EngineProviderProps) {
export function EngineProvider({
children,
engine: injected,
sessionId = null,
}: EngineProviderProps) {
const engine = useMemo(() => injected ?? createEngine(), [injected]);
// Prefer the SessionProvider's per-session byte store registry when
// available; fall back to a provider-local store for tests that mount
// EngineProvider on its own.
const sessionCtx = useReactContext(SessionInternalContext);
const [fallbackByteStore] = useState<PdfByteStore>(() => createPdfByteStore());
const byteStore =
sessionId && sessionCtx
? sessionCtx.getOrCreateByteStore(sessionId)
: fallbackByteStore;
const [activeDocumentId, setActiveDocumentIdState] = useState<DocumentId | null>(null);
// `restoreFromStorage` writes directly to the engine's repos without
// firing engine events (by design — see persistence.ts). That means
// consuming components (CollectionList etc.) wouldn't normally
// re-render to reflect the restored state. Bumping `engineRevision`
// after restore is what consumers add to their `useMemo` deps so
// the restored state shows up on (re-)mount.
const [engineRevision, setEngineRevision] = useState(0);
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
id: null,
version: 0,
});
const snapshotKey = storageKeyFor(sessionId);
const activeDocKey = activeDocumentKeyFor(sessionId);
// Restore from localStorage on first mount, then attach the persister.
// The injected-engine path skips persistence (tests own their lifecycle).
useEffect(() => {
if (injected) return;
if (typeof globalThis.localStorage === "undefined") return;
const result = restoreFromStorage(engine, { key: STORAGE_KEY });
const result = restoreFromStorage(engine, { key: snapshotKey });
if (result.restored) {
const saved = globalThis.localStorage.getItem(ACTIVE_KEY);
const saved = globalThis.localStorage.getItem(activeDocKey);
if (saved && engine.documents.get(saved as DocumentId)) {
setActiveDocumentIdState(saved as DocumentId);
}
// Force a re-render so consumers see the restored repos.
setEngineRevision((n) => n + 1);
}
return attachPersister(engine, { key: STORAGE_KEY });
}, [engine, injected]);
return attachPersister(engine, { key: snapshotKey });
}, [engine, injected, snapshotKey, activeDocKey]);
// Persist the active-document pointer alongside the engine snapshot so a
// reload lands the user back where they were.
@@ -100,11 +159,11 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
if (injected) return;
if (typeof globalThis.localStorage === "undefined") return;
if (activeDocumentId) {
globalThis.localStorage.setItem(ACTIVE_KEY, activeDocumentId);
globalThis.localStorage.setItem(activeDocKey, activeDocumentId);
} else {
globalThis.localStorage.removeItem(ACTIVE_KEY);
globalThis.localStorage.removeItem(activeDocKey);
}
}, [activeDocumentId, injected]);
}, [activeDocumentId, injected, activeDocKey]);
// Switching the active document discards any pending selection — it
// belongs to the previous document's viewer state.
@@ -121,6 +180,7 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
const value = useMemo<EngineContextValue>(
() => ({
engine,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
@@ -128,8 +188,18 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
scrollToAnnotationId: scrollState.id,
scrollVersion: scrollState.version,
scrollToAnnotation,
engineRevision,
}),
[engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation],
[
engine,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
scrollState,
scrollToAnnotation,
engineRevision,
],
);
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
@@ -141,6 +211,18 @@ export function useEngine(): Engine {
return ctx.engine;
}
export function usePdfByteStore(): PdfByteStore {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("usePdfByteStore: missing EngineProvider");
return ctx.byteStore;
}
export function useEngineRevision(): number {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useEngineRevision: missing EngineProvider");
return ctx.engineRevision;
}
export function useActiveDocumentId(): {
readonly id: DocumentId | null;
setId(id: DocumentId | null): void;

View File

@@ -26,6 +26,7 @@ import {
useActiveDocument,
useEngine,
useEngineEventTick,
useEngineRevision,
useLastActivatedEvidence,
useScrollToAnnotation,
} from "./EngineContext";
@@ -75,11 +76,12 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
const createTick = useEngineEventTick("EvidenceItemCreated");
const updateTick = useEngineEventTick("EvidenceItemUpdated");
const revision = useEngineRevision();
const items = useMemo<readonly EvidenceItem[]>(() => {
if (!document) return [];
return engine.evidence.listByDocument(document.id);
}, [document, engine, createTick, updateTick]);
}, [document, engine, createTick, updateTick, revision]);
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
const [toast, setToast] = useState<ToastState | null>(null);

241
src/work/SessionContext.tsx Normal file
View File

@@ -0,0 +1,241 @@
/**
* SessionProvider — owns the cross-session services.
*
* Layers above the per-session `EngineProvider`. Responsibilities:
*
* - hold the `SessionService` + its own bus instance
* - hydrate sessions from `localStorage` on first mount
* - expose `useSessionService()`, `useActiveSession()`, hooks to
* subscribe to session bus events
*
* Switching sessions is a side effect of calling
* `useSessionService().setActive(...)`. The hook tracks the active id
* via the bus's `SessionActivated` event so the value stays a single
* source of truth.
*
* NB: this module does *not* mount the `EngineProvider`. T04 wires the
* top-level App so the EngineProvider is keyed by the active session id
* (`<EngineProvider key={activeId} sessionId={activeId} />`). Keeping
* the two providers separate lets tests target one without the other.
*/
import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import {
attachSessionPersister,
createEventBus,
createInMemorySessionRepository,
createSessionService,
restoreSessionsFromStorage,
type EventBus,
type SessionService,
} from "@engine/index";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import {
SessionInternalContext,
type SessionInternalContextValue,
} from "./SessionContextInternal";
const SessionContext = SessionInternalContext;
type SessionContextValue = SessionInternalContextValue;
interface SessionProviderProps {
readonly children: ReactNode;
/** Inject a pre-built service for tests; production uses the default. */
readonly service?: SessionService;
readonly bus?: EventBus;
}
export function SessionProvider({
children,
service: injectedService,
bus: injectedBus,
}: SessionProviderProps) {
const bus = useMemo(() => injectedBus ?? createEventBus(), [injectedBus]);
const [repo] = useState(() => createInMemorySessionRepository());
const service = useMemo(
() => injectedService ?? createSessionService(repo, bus),
[injectedService, repo, bus],
);
const [activeId, setActiveId] = useState<SessionId | null>(null);
const [hydrated, setHydrated] = useState<boolean>(false);
const [byteStores] = useState<Map<SessionId, PdfByteStore>>(() => new Map());
const [sessionVersions, setSessionVersions] = useState<ReadonlyMap<SessionId, number>>(
() => new Map(),
);
const getOrCreateByteStore = useCallback(
(sessionId: SessionId) => {
let store = byteStores.get(sessionId);
if (!store) {
store = createPdfByteStore();
byteStores.set(sessionId, store);
}
return store;
},
[byteStores],
);
const getSessionVersion = useCallback(
(sessionId: SessionId) => sessionVersions.get(sessionId) ?? 0,
[sessionVersions],
);
const bumpSessionVersion = useCallback((sessionId: SessionId) => {
setSessionVersions((prev) => {
const next = new Map(prev);
next.set(sessionId, (prev.get(sessionId) ?? 0) + 1);
return next;
});
}, []);
// Hydrate from storage, then attach the persister.
useEffect(() => {
if (injectedService) {
setHydrated(true);
return;
}
if (typeof globalThis.localStorage === "undefined") {
setHydrated(true);
return;
}
const result = restoreSessionsFromStorage(repo, service);
if (result.restored && result.activeSessionId) {
setActiveId(result.activeSessionId);
}
setHydrated(true);
return attachSessionPersister(service, bus);
}, [bus, injectedService, repo, service]);
// Keep the active-id mirror in sync with the bus.
useEffect(() => {
return bus.on("SessionActivated", (e) => {
setActiveId(e.sessionId);
});
}, [bus]);
// Drop byte stores for sessions that get deleted (revoking blob URLs).
useEffect(() => {
return bus.on("SessionDeleted", (e) => {
const store = byteStores.get(e.sessionId);
if (store) {
store.clear();
byteStores.delete(e.sessionId);
}
});
}, [bus, byteStores]);
const value = useMemo<SessionContextValue>(
() => ({
service,
bus,
activeId,
hydrated,
getOrCreateByteStore,
getSessionVersion,
bumpSessionVersion,
}),
[
service,
bus,
activeId,
hydrated,
getOrCreateByteStore,
getSessionVersion,
bumpSessionVersion,
],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSessionService(): SessionService {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionService: missing SessionProvider");
return ctx.service;
}
export function useSessionBus(): EventBus {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionBus: missing SessionProvider");
return ctx.bus;
}
export function useActiveSessionId(): SessionId | null {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useActiveSessionId: missing SessionProvider");
return ctx.activeId;
}
export function useActiveSession(): Session | null {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useActiveSession: missing SessionProvider");
return ctx.activeId ? ctx.service.get(ctx.activeId) : null;
}
export function useSessionsHydrated(): boolean {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionsHydrated: missing SessionProvider");
return ctx.hydrated;
}
export function useSessionByteStore(sessionId: SessionId): PdfByteStore {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionByteStore: missing SessionProvider");
return ctx.getOrCreateByteStore(sessionId);
}
export function useSessionVersionBumper(): (sessionId: SessionId) => void {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionVersionBumper: missing SessionProvider");
return ctx.bumpSessionVersion;
}
export function useSessionVersion(sessionId: SessionId): number {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionVersion: missing SessionProvider");
return ctx.getSessionVersion(sessionId);
}
export function useSessionByteStoreRegistry(): {
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
} {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionByteStoreRegistry: missing SessionProvider");
return { getOrCreateByteStore: ctx.getOrCreateByteStore };
}
/**
* Re-render whenever the session list mutates. Returns a tick counter
* that callers can use as a `useMemo`/`useEffect` dependency to read
* `service.list()` lazily.
*/
export function useSessionListTick(): number {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionListTick: missing SessionProvider");
const [tick, setTick] = useState(0);
useEffect(() => {
const bump = () => setTick((t) => t + 1);
const offs = [
ctx.bus.on("SessionCreated", bump),
ctx.bus.on("SessionRenamed", bump),
ctx.bus.on("SessionDeleted", bump),
];
return () => {
for (const off of offs) off();
};
}, [ctx.bus]);
return tick;
}

View File

@@ -0,0 +1,27 @@
/**
* Internal: the React Context object backing `SessionProvider`.
*
* Lives in its own module so `EngineContext.tsx` can subscribe without
* importing the full `SessionContext.tsx` (which would re-export the
* EngineProvider via the same `@work` barrel and create a circular
* dependency at module-init time).
*/
import { createContext } from "react";
import type { SessionId } from "@shared/ids";
import type { EventBus, SessionService } from "@engine/index";
import type { PdfByteStore } from "@source/index";
export interface SessionInternalContextValue {
readonly service: SessionService;
readonly bus: EventBus;
readonly activeId: SessionId | null;
readonly hydrated: boolean;
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
getSessionVersion(sessionId: SessionId): number;
bumpSessionVersion(sessionId: SessionId): void;
}
export const SessionInternalContext = createContext<SessionInternalContextValue | null>(null);

View File

@@ -1,4 +1,4 @@
export { CollectionList } from "./CollectionList";
export { CollectionList, type CollectionListProps } from "./CollectionList";
export { ViewerShell } from "./ViewerShell";
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
export {
@@ -14,8 +14,23 @@ export {
useActiveDocument,
useActiveDocumentId,
useEngineEventTick,
useEngineRevision,
useLastActivatedEvidence,
usePdfByteStore,
usePendingSelection,
useScrollToAnnotation,
type PendingSelection,
} from "./EngineContext";
export {
SessionProvider,
useActiveSession,
useActiveSessionId,
useSessionBus,
useSessionByteStore,
useSessionByteStoreRegistry,
useSessionListTick,
useSessionService,
useSessionsHydrated,
useSessionVersion,
useSessionVersionBumper,
} from "./SessionContext";

View File

@@ -28,8 +28,6 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Selector } from "@shared/selector";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
@@ -90,42 +88,8 @@ const SYNTHETIC_CANONICAL = [
"Trailing prose that comes after the quote.",
].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
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 document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
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-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: SYNTHETIC_CANONICAL.length,
pageLength: SYNTHETIC_CANONICAL.length,
},
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc, type SeedResult } from "./helpers/seed-session";
// ---------------------------------------------------------------------------
// Helpers
@@ -160,40 +124,40 @@ async function loadApp() {
// ---------------------------------------------------------------------------
describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
let seeded: SeedResult;
beforeEach(() => {
resetViewerSnapshot();
// Each test starts with empty localStorage.
globalThis.localStorage?.clear();
// The fetch isn't reached (ingestPdf is mocked) — but stub it so that
// any accidental call returns gracefully instead of TypeError.
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
seeded = seedSessionWithDoc({
sessionName: "Demo",
documentTitle: FIXTURE.filename,
canonicalText: SYNTHETIC_CANONICAL,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", async () => {
it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", { timeout: 15000 }, async () => {
const user = userEvent.setup();
// Step 1: open the app.
// Step 1: open the app. CE-WP-0005: a pre-seeded session boots
// straight into the active layout (no empty state).
const { unmount } = await loadApp();
expect(screen.getByText("Collection")).toBeTruthy();
expect(screen.queryByTestId("empty-state")).toBeNull();
expect(screen.getByTestId("session-menu-toggle").textContent).toContain("Demo");
// Step 2: pick a fixture.
const fixtureButton = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureButton);
// The mock viewer should have mounted with our test URL.
// Step 2: the fixture doc is pre-seeded into the active session, so
// the viewer mounts automatically — no fixture-button click needed.
await waitFor(() => {
expect(viewerSnapshot.pdfUrl).toBeTruthy();
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
});
expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename);
// Step 3: programmatically inject a selection for the known-good quote.
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
@@ -223,9 +187,11 @@ describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
});
const savedAnnotationId = viewerSnapshot.storedAnnotationIds[0]!;
// Snapshot key from EngineContext.STORAGE_KEY — implementation detail
// Per-session snapshot key from CE-WP-0005 — implementation detail
// but worth asserting once at the integration layer.
const stored = globalThis.localStorage?.getItem("citation-evidence:engine-snapshot:v1");
const stored = globalThis.localStorage?.getItem(
`citation-evidence:session:${seeded.sessionId}:engine-snapshot:v1`,
);
expect(stored).toBeTruthy();
// Step 6: reload — unmount and remount the App. The same localStorage is
@@ -237,9 +203,8 @@ describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
// The viewer should re-mount automatically because the active document
// was persisted.
await waitFor(() => {
expect(viewerSnapshot.pdfUrl).toBeTruthy();
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
});
expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename);
// The sidebar should show the restored item.
const restoredItem = await screen.findByText(/Important deadline clause/);

View File

@@ -20,8 +20,6 @@ import { act, 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 type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
@@ -73,42 +71,8 @@ const SYNTHETIC_CANONICAL = [
"Post quote.",
].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
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 document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
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-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: SYNTHETIC_CANONICAL.length,
pageLength: SYNTHETIC_CANONICAL.length,
},
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc } from "./helpers/seed-session";
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
@@ -136,15 +100,14 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
beforeEach(() => {
resetSnapshot();
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
seedSessionWithDoc({
sessionName: "T06-cycling",
documentTitle: FIXTURE.filename,
canonicalText: SYNTHETIC_CANONICAL,
});
});
afterEach(() => {
@@ -157,8 +120,7 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
await loadApp();
// --- Review mode: create an evidence item via the captured-selection flow.
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
// CE-WP-0005: doc pre-seeded — skip fixture click.
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
await act(async () => {
viewerSnapshot.onSelectionCaptured!(

View File

@@ -22,8 +22,7 @@ import { act, 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 { AnnotationId, DocumentId, RepresentationId } from "@shared/ids";
import type { AnnotationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
@@ -55,38 +54,8 @@ vi.mock("@anchor/index", async (importOriginal) => {
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
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 synthetic = "Synthetic canonical text for the form-link test.";
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
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-fingerprint-for-test",
canonicalText: synthetic,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: synthetic.length, pageLength: synthetic.length },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc } from "./helpers/seed-session";
async function loadApp() {
const { App } = await import("@app/App");
@@ -96,12 +65,6 @@ async function loadApp() {
describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)", () => {
beforeEach(() => {
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
// Forms mode is hash-driven; make sure we start clean.
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
@@ -114,17 +77,19 @@ describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)"
});
it("stages an evidence item then links it to the clicked field", async () => {
seedSessionWithDoc({
sessionName: "T05-link",
documentTitle: FIXTURE.filename,
canonicalText: "Synthetic canonical text for the form-link test.",
});
const user = userEvent.setup();
await loadApp();
// Switch to Forms via the top-bar button.
await user.click(screen.getByRole("button", { name: "Forms" }));
// The collection list is in the Forms layout too.
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
// Wait for the fixture to load and the form to appear.
// CE-WP-0005: doc is pre-seeded into the active session.
// Wait for the form to appear.
await waitFor(() => {
expect(screen.queryByLabelText("Summary of the matter")).not.toBeNull();
});
@@ -170,12 +135,12 @@ describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)"
expect(screen.getByTestId("field-summary-chip").textContent).toMatch(/1 evidence/);
});
it("starts in Review mode by default and switches to Forms via hash", async () => {
it("starts in the empty state when no session is active (CE-WP-0005 default)", async () => {
await loadApp();
expect(screen.getByText("Collection")).toBeTruthy();
// Review pane's no-doc-open hint from EvidenceSidebar:
expect(screen.queryByText(/No document open/)).not.toBeNull();
// No demo form rendered yet
// The empty-state landing is what users see now until they create
// a session.
expect(screen.getByTestId("empty-state")).toBeTruthy();
// No demo form rendered yet.
expect(screen.queryByText("Demo evidence-backed form")).toBeNull();
});
});

View File

@@ -27,8 +27,6 @@ import { act, 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 type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
@@ -82,37 +80,8 @@ vi.mock("@anchor/index", async (importOriginal) => {
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
return {
...original,
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
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 document: Document = {
id: documentId,
mediaType: "application/pdf",
...(options?.filename ? { title: options.filename } : {}),
fingerprint: "synthetic-fingerprint-for-test",
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-fingerprint-for-test",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{ page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length },
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
}),
};
});
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
import { seedSessionWithDoc } from "./helpers/seed-session";
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
@@ -135,15 +104,14 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
viewerSnapshot.scrollToAnnotationId = null;
viewerSnapshot.onSelectionCaptured = null;
globalThis.localStorage?.clear();
globalThis.fetch = vi.fn(async () =>
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
seedSessionWithDoc({
sessionName: "T08-forms",
documentTitle: FIXTURE.filename,
canonicalText: SYNTHETIC_CANONICAL,
});
});
afterEach(() => {
@@ -159,8 +127,7 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
await loadApp();
// Steps 1-4 (CE-WP-0002 setup): create an evidence item in Review mode.
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
await user.click(fixtureBtn);
// CE-WP-0005: doc pre-seeded into the session — skip fixture click.
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
await act(async () => {
viewerSnapshot.onSelectionCaptured!(
@@ -175,9 +142,10 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await screen.findByText(/Overlay E2E evidence/);
// Step 5: navigate to /forms/demo via the top-bar.
// Step 5: navigate to forms via the top-bar.
await user.click(screen.getByRole("button", { name: "Forms" }));
expect(window.location.hash).toBe("#/forms/demo");
// CE-WP-0005: route is now session-scoped.
expect(window.location.hash).toMatch(/^#\/s\/sess_[^/]+\/forms\/demo$/);
// Step 6: stage the evidence in the strip, then click the summary
// field to create the link.

View File

@@ -0,0 +1,116 @@
/**
* Test helper: pre-seed `localStorage` with an active session that holds
* one document + one representation, so an integration test can skip
* the empty-state + upload flow and mount straight into a populated
* Review/Forms layout.
*
* Pre-CE-WP-0005 integration tests clicked a "fixture button" in the
* `CollectionList` to load a sample PDF; after the session refactor
* there is no fixture list directly in the active-session UI. These
* legacy tests now call `seedSessionWithDoc` in `beforeEach` instead.
*
* The seed bypasses `SessionService.create()` (which mints a random
* id) so tests can reference the resulting `documentId` /
* `representationId` synchronously, before the app boots.
*/
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
export interface SeedOptions {
readonly sessionName?: string;
readonly documentTitle?: string;
readonly canonicalText: string;
readonly mode?: "review" | "forms";
}
export interface SeedResult {
readonly sessionId: SessionId;
readonly documentId: DocumentId;
readonly representationId: RepresentationId;
}
function rid(prefix: string): string {
return `${prefix}_seed_${Math.random().toString(36).slice(2, 10)}`;
}
export function seedSessionWithDoc(opts: SeedOptions): SeedResult {
const sessionId = rid("sess") as SessionId;
const documentId = rid("doc") as DocumentId;
const representationId = rid("rep") as RepresentationId;
const now = "2026-05-25T00:00:00.000Z";
const session: Session = {
id: sessionId,
name: opts.sessionName ?? "Test Session",
createdAt: now,
updatedAt: now,
lastOpenedAt: now,
};
const document: Document = {
id: documentId,
mediaType: "application/pdf",
...(opts.documentTitle ? { title: opts.documentTitle } : {}),
fingerprint: "seed-fingerprint",
createdAt: now,
updatedAt: now,
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "seed-fingerprint",
canonicalText: opts.canonicalText,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: opts.canonicalText.length,
pageLength: opts.canonicalText.length,
},
],
generatedAt: now,
};
const snapshot = {
version: 1,
documents: [document],
representations: [representation],
annotations: [],
evidenceItems: [],
};
const sessionsFile = {
version: 1,
sessions: [session],
activeSessionId: sessionId,
};
localStorage.setItem("citation-evidence:sessions:v1", JSON.stringify(sessionsFile));
localStorage.setItem("citation-evidence:active-session-id:v1", sessionId);
localStorage.setItem(
`citation-evidence:session:${sessionId}:engine-snapshot:v1`,
JSON.stringify(snapshot),
);
localStorage.setItem(
`citation-evidence:session:${sessionId}:active-document-id:v1`,
documentId,
);
// Set the hash so the App routes straight into the session.
const hash =
opts.mode === "forms"
? `#/s/${sessionId}/forms/demo`
: `#/s/${sessionId}`;
history.replaceState(
null,
"",
window.location.pathname + window.location.search + hash,
);
return { sessionId, documentId, representationId };
}

View File

@@ -0,0 +1,350 @@
/**
* CE-WP-0005-T08 — full create → annotate → export → reimport E2E.
*
* Walks the demo loop end-to-end:
*
* 1. Load app → empty state.
* 2. Create session "Demo" via the inline name input.
* 3. Upload a synthetic PDF.
* 4. Inject a selection for the manifest's known-good quote → save
* evidence with a commentary.
* 5. (Sanity) Click Export → Copy as Markdown; assert the clipboard
* payload contains the quote + commentary + openContextUrl.
* 6. Click Export ZIP; capture the Blob via a URL.createObjectURL
* spy.
* 7. Click Import ZIP with the captured Blob; assert merge:
* - one document (deduped by fingerprint)
* - two evidence rows (additive merge)
* 8. Click Export → Copy as Markdown on the merged evidence; assert
* the citation card text matches the original.
*/
// @vitest-environment happy-dom
import { act, 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 type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
interface ViewerProps {
pdfUrl: string;
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
scrollToAnnotationId?: string;
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
}
interface ViewerSnapshot {
pdfUrl: string | null;
onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null;
}
const viewerSnapshot: ViewerSnapshot = {
pdfUrl: null,
onSelectionCaptured: null,
};
vi.mock("@anchor/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (props: ViewerProps) => {
viewerSnapshot.pdfUrl = props.pdfUrl;
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
return <div data-testid="mock-pdf-viewer" />;
};
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };
});
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
// Bypass real PDF.js extraction. The mock returns a stable fingerprint
// so the merge-on-fingerprint dedup logic actually fires on re-import.
vi.mock("@source/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@source/index")>();
let counter = 0;
return {
...original,
ingestPdfFromFile: vi.fn(
async (file: File | Blob, store: import("@source/index").PdfByteStore) => {
counter += 1;
const filename =
"name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf";
const documentId = ("doc_e2e_" + counter) as DocumentId;
const representationId = ("rep_e2e_" + counter) 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,
// Stable fingerprint — the importer dedupes by fingerprint, so
// re-importing the export merges into the same doc.
fingerprint: "fp-stable-e2e",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "pdf-text",
contentHash: "fp-stable-e2e",
canonicalText: SYNTHETIC_CANONICAL,
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [
{
page: 1,
globalStart: 0,
globalEnd: SYNTHETIC_CANONICAL.length,
pageLength: SYNTHETIC_CANONICAL.length,
},
],
generatedAt: "2026-05-25T00:00:00.000Z",
};
return { document, representation };
},
),
};
});
// ---------------------------------------------------------------------------
// Test-time intercepts: clipboard, URL.createObjectURL, file-picker input.
// ---------------------------------------------------------------------------
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
return {
kind: "pdf",
text,
page,
rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }],
boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 },
};
}
let writeText: ReturnType<typeof vi.fn>;
function installClipboardSpy() {
writeText = vi.fn(async () => undefined);
const proto = Object.getPrototypeOf(navigator.clipboard);
Object.defineProperty(proto, "writeText", {
configurable: true,
writable: true,
value: writeText,
});
}
interface UrlInterceptor {
blobs: Blob[];
restore(): void;
}
function installUrlInterceptor(): UrlInterceptor {
const blobs: Blob[] = [];
const origCreate = URL.createObjectURL;
const origRevoke = URL.revokeObjectURL;
let counter = 0;
URL.createObjectURL = ((b: Blob) => {
blobs.push(b);
counter += 1;
return `blob:test-${counter}`;
}) as typeof URL.createObjectURL;
URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL;
return {
blobs,
restore() {
URL.createObjectURL = origCreate;
URL.revokeObjectURL = origRevoke;
},
};
}
interface FilePickerInterceptor {
inputs: HTMLInputElement[];
restore(): void;
}
function installFilePickerInterceptor(): FilePickerInterceptor {
const inputs: HTMLInputElement[] = [];
const origCreate = document.createElement.bind(document);
(document.createElement as unknown) = (tag: string) => {
const el = origCreate(tag);
if (tag === "input") inputs.push(el as HTMLInputElement);
return el;
};
return {
inputs,
restore() {
document.createElement = origCreate;
},
};
}
async function loadApp() {
const { App } = await import("@app/App");
return render(<App />);
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
describe("CE-WP-0005-T08 — full create → annotate → export → reimport E2E", () => {
let url: UrlInterceptor;
let picker: FilePickerInterceptor;
beforeEach(() => {
viewerSnapshot.pdfUrl = null;
viewerSnapshot.onSelectionCaptured = null;
globalThis.localStorage?.clear();
if (typeof window !== "undefined") {
history.replaceState(null, "", window.location.pathname);
}
url = installUrlInterceptor();
picker = installFilePickerInterceptor();
});
afterEach(() => {
cleanup();
url.restore();
picker.restore();
vi.restoreAllMocks();
});
it(
"creates session, annotates, exports ZIP, re-imports ZIP, and round-trips the citation card",
{ timeout: 30000 },
async () => {
const user = userEvent.setup();
await loadApp();
// Step 1: empty state.
await screen.findByTestId("empty-state");
// Step 2: create session "Demo".
const nameInput = screen.getByTestId("empty-state-input");
await user.type(nameInput, "Demo");
await user.click(screen.getByTestId("empty-state-create"));
// Wait for the active session UI (collection list + upload dropzone).
await screen.findByTestId("upload-dropzone");
// Step 3: upload a synthetic PDF.
installClipboardSpy();
const fileBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
const pdfFile = new File([fileBytes], "e2e.pdf", { type: "application/pdf" });
const uploadInput = screen.getByTestId("upload-file-input") as HTMLInputElement;
await user.upload(uploadInput, pdfFile);
// Wait for the viewer to mount with the uploaded doc.
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
// Step 4: inject a selection + save evidence with commentary.
await act(async () => {
viewerSnapshot.onSelectionCaptured!(
syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page),
[{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }],
);
});
await user.type(
screen.getByPlaceholderText(/Add a one-line comment/),
"E2E session commentary",
);
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
await screen.findByText(/E2E session commentary/);
// Step 5 (sanity): export the evidence as Markdown via the sidebar.
installClipboardSpy();
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
await waitFor(() => expect(writeText).toHaveBeenCalled());
const firstCard = writeText.mock.calls[0]![0] as string;
expect(firstCard).toContain(FIXTURE.known_good_quote);
expect(firstCard).toContain("E2E session commentary");
expect(firstCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/);
// Step 6: click Export ZIP from the session menu and capture the
// generated Blob via the URL.createObjectURL spy.
const urlCountBeforeExport = url.blobs.length;
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(await screen.findByTestId("session-menu-export"));
await waitFor(() => expect(url.blobs.length).toBeGreaterThan(urlCountBeforeExport));
// The exported ZIP is the most-recently-minted blob URL'd to <a download>.
const zipBlob = url.blobs[url.blobs.length - 1]!;
expect(zipBlob.size).toBeGreaterThan(0);
// Step 7: import the ZIP back via the menu. Intercept the file
// input the App opens, set our captured blob as its file, fire
// change.
const inputsBefore = picker.inputs.length;
await user.click(screen.getByTestId("session-menu-toggle"));
await user.click(await screen.findByTestId("session-menu-import"));
// The handler created a new file input.
await waitFor(() => expect(picker.inputs.length).toBeGreaterThan(inputsBefore));
const importInput = picker.inputs[picker.inputs.length - 1]!;
const zipFile = new File([await zipBlob.arrayBuffer()], "demo.zip", {
type: "application/zip",
});
// happy-dom's `files` is a getter on HTMLInputElement.prototype;
// a value-override on the instance silently doesn't take effect.
// Replace with a property getter that returns our fake FileList.
const fakeFileList = {
0: zipFile,
length: 1,
item: (i: number) => (i === 0 ? zipFile : null),
[Symbol.iterator]: function* () {
yield zipFile;
},
};
Object.defineProperty(importInput, "files", {
configurable: true,
get: () => fakeFileList,
});
await act(async () => {
if (typeof importInput.onchange === "function") {
importInput.onchange(new Event("change"));
}
// Yield so the import promise gets a chance to start.
await new Promise((r) => setTimeout(r, 0));
});
// Wait for the merge to manifest in the DOM. ADR-0008's additive
// policy means we should now see TWO evidence rows for the same
// commentary (the original + the imported copy), and the
// collection should still show ONE document (deduped by
// fingerprint).
await waitFor(
() => {
const matches = screen.queryAllByText(/E2E session commentary/);
expect(matches.length).toBeGreaterThanOrEqual(2);
},
{ timeout: 12000 },
);
await waitFor(() => {
const items = screen
.getByTestId("collection-list-items")
.querySelectorAll("li");
expect(items.length).toBe(1);
});
// Step 8: export the *merged* evidence as Markdown and assert the
// round-trip preserves quote + commentary + URL shape.
installClipboardSpy();
const toggles = screen.getAllByLabelText("Export evidence item");
// Use the last evidence row (the one from the import) just to
// distinguish from the original.
await user.click(toggles[toggles.length - 1]!);
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
await waitFor(() => expect(writeText).toHaveBeenCalled());
const mergedCard = writeText.mock.calls[0]![0] as string;
expect(mergedCard).toContain(FIXTURE.known_good_quote);
expect(mergedCard).toContain("E2E session commentary");
expect(mergedCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/);
},
);
});

View File

@@ -166,6 +166,7 @@ event vocabulary for the MVP is:
```
DocumentImported
DocumentRepresentationGenerated
DocumentRemoved
AnnotationCreated
AnnotationResolved
AnnotationResolutionFailed
@@ -179,8 +180,17 @@ CitationCardRendered
CitationRecoveryStarted
CitationRecoveryCandidateFound
CitationRecoveryConfirmed
SessionCreated
SessionRenamed
SessionDeleted
SessionActivated
```
The `Session*` events live on the cross-session session bus (the
SessionService's own EventBus instance — see CE-WP-0005). The remaining
events live on the per-session engine bus and are scoped to whatever
session is currently active.
Subsystems must emit these events through a shared event bus owned by
`citation-engine`. Subsystems may listen to any event but must not invent
event types without updating this document.

View File

@@ -0,0 +1,507 @@
---
id: CE-WP-0005
type: workplan
title: "Demo app — Named sessions, document uploads, ZIP export/import"
domain: citation_evidence
repo: citation-evidence
repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
topic_slug: citation_evidence_mvp
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
state_hub_workstream_id: ec88caf3-85ad-413c-8ddd-ef7278f6ce57
status: done
owner: Bernd
created: 2026-05-25
updated: 2026-05-26
depends_on_workplan: CE-WP-0004
spec_refs:
- wiki/ProductRequirementsDocument.md
- wiki/ArchitectureOverview.md
- wiki/SharedContracts.md
---
# CE-WP-0005 — Demo App: Sessions + Uploads + ZIP Archive
Turn the MVP into a self-contained demo that a stranger can pick up and use.
After this workplan, a user can:
1. Land on the app and create a **named session** ("Lease 2024", "Klage
Müller", …).
2. Drag-drop or pick **arbitrary PDFs** into that session (no fixtures
required).
3. Annotate, build evidence, link to form fields, and export citation
cards — same flows as CE-WP-0002..0004, now scoped to the active
session.
4. **Export the whole session** as a single `.zip` archive: every PDF
plus a manifest with the engine snapshot.
5. **Import a `.zip` back** — into a new session, or merged into an
existing one (documents deduped by fingerprint; annotations and
evidence added additively).
The demo replaces the current single-bucket app: Review and Forms modes
both become **session-scoped**. The previous fixture-driven workflow
survives as an optional "Sample sessions" quick-start.
## Scoping decisions (locked before drafting)
- **Demo placement:** the demo *replaces* the main app. The MVP Review
and Forms layouts continue to work, but now under a session.
- **PDF byte storage:** in-memory only. PDFs survive within a tab
session; reloading the page loses uploaded bytes unless the ZIP was
exported. Re-importing the ZIP restores them. No IndexedDB tier in
this workplan.
- **Import conflict policy:** if a ZIP carries a session name that
already exists, **merge**. Documents are deduped by SHA-256
fingerprint (incoming references rebound to the existing
`documentId`). Annotations, evidence, and links are added as fresh
ids — additive, never overwriting. Locked in
[`ADR-0008`](../docs/decisions/ADR-0008-session-archive-format.md)
(created in T05).
## Dependency Order
```
T01 (Session model + service + per-session snapshots)
├─ T02 (PdfByteStore + uploaded-document ingest path)
│ └─ T03 (Upload UI + session-scoped CollectionList)
└─ T04 (Session management UI — top-bar menu + hash routing)
T05 (ADR-0008 + SessionArchive schema)
├─ T06 (Export session as ZIP) ───┐
└─ T07 (Import ZIP with merge) │
T08 (E2E test of full flow)
```
T03 and T04 can land in either order once T01+T02 are done. T06 and T07
can be parallelised within T05.
---
## T01 — Session model + service + per-session engine snapshots
```task
id: CE-WP-0005-T01
state_hub_task_id: 5b479bf5-b54a-4fc8-b500-ec49f5d68f6a
priority: high
status: done
```
Under `src/shared/`:
- `src/shared/session.ts``SessionId` branded type added to
`ids.ts`; `Session` interface with `{ id, name, createdAt,
updatedAt, lastOpenedAt? }`. No `documentIds` field — membership is
implicit (a session "owns" the documents in its engine snapshot).
Under `src/engine/`:
- Extend `events/types.ts` with the four new events:
`SessionCreated`, `SessionRenamed`, `SessionDeleted`,
`SessionActivated`. Add to the `EngineEvent` union.
- `src/engine/services/sessions.ts``SessionService` with
`create(name)`, `rename(id, name)`, `delete(id)`, `list()`,
`get(id)`, `setActive(id | null)`, `getActive()`. Backed by a
repo + the event bus.
- `src/engine/repos/in-memory-sessions.ts` — Map-backed
`SessionRepository`.
Per-session **engine snapshot persistence**:
- `STORAGE_KEY` becomes a function of `sessionId`:
`citation-evidence:session:<sessionId>:engine-snapshot:v1`.
- A separate index key
`citation-evidence:sessions:v1` stores the list of all known
sessions.
- The active session id is held in
`citation-evidence:active-session-id:v1`.
- When the active session changes, the old engine snapshot's
persister stops; a new persister is attached against the new
session's key. The engine itself is **recreated** (the cleanest
way to reset every in-memory repo); `EngineProvider` is keyed by
`sessionId` so React unmounts/remounts on switch.
Tests:
- Unit: `SessionService` lifecycle (create/rename/delete/setActive),
event emission, conflict on rename to a duplicate name.
- `restoreFromStorage` round-trip with the new per-session key
scheme — drop in a fixture set of two sessions, restore each,
assert engines hold the right documents.
---
## T02 — PdfByteStore + uploaded-document ingest path
```task
id: CE-WP-0005-T02
state_hub_task_id: 25626309-4cad-44b5-ac44-7e0dc7ea48fa
priority: high
status: done
depends_on: [T01]
```
Under `src/source/pdf/`:
- `byte-store.ts``createPdfByteStore()` returns a
`Map<DocumentId, Uint8Array>` wrapper with `put`, `get`, `delete`,
`list`, `clear`. Scoped per session (one instance per active
session; replaced on switch).
- `upload.ts` — `ingestPdfFromFile(file: File | Blob, store):
Promise<{ document, representation }>`:
1. Read bytes via `file.arrayBuffer()`.
2. Call existing `ingestPdf(bytes, { filename: file.name })`.
3. `store.put(document.id, bytes)`.
4. Mint a `blob:` URL from a fresh `Blob([bytes], { type: "application/pdf" })`
and stash it on `document.uri` so the viewer adapter can mount it.
5. Return the engine inputs ready for
`engine.documents.register(...)`.
- Blob URL **revocation**: when a document is deleted from a session,
`URL.revokeObjectURL(document.uri)` runs before the engine drops the
record. A small helper inside the byte store handles this so the
app layer doesn't have to remember.
The fixture-loading path (current `App.tsx` fetch + `ingestPdf`)
remains as-is for the optional "Sample sessions" quick-start; the
upload path is a parallel branch that ends at the same engine call.
Tests:
- Unit: round-trip a known-bytes PDF through `ingestPdfFromFile`,
assert `store.get(documentId)` returns the same bytes.
- Unit: delete revokes the blob URL exactly once even if called
twice.
---
## T03 — Upload UI + session-scoped Collection list
```task
id: CE-WP-0005-T03
state_hub_task_id: 55275918-e610-4513-ba2d-c05018ecd42d
priority: high
status: done
depends_on: [T02]
```
Under `src/app/sessions/`:
- `UploadDropzone.tsx` — drag-drop region and a file picker that
accepts `application/pdf` (multi-select). On drop:
1. For each `File`: call `ingestPdfFromFile(file, byteStore)` then
`engine.documents.register(...)`. Show a per-file progress chip;
surface a toast on failure.
2. Make the most-recently-uploaded document the active document.
Under `src/work/CollectionList.tsx`:
- Rework to list the **active session's** documents (read from
`engine.documents.list()`), not the fixture manifest.
- Header bar with the session name + an inline "Upload PDF" button
that opens the dropzone.
- Per-item: title (filename), document id, "Open" + "Delete" actions.
Delete confirms via a small inline state, then calls into the byte
store + engine repo.
Fixtures become an optional **Sample sessions** entry inside the
session menu (T04). The current `CollectionList`'s manifest-driven
fixture loader moves into
`src/app/sessions/SampleSessions.tsx`, kept for tests and
demonstration.
Tests:
- DOM: dropping a synthetic File triggers ingest and the new document
appears in the list.
- DOM: per-item delete removes the row and revokes the blob URL.
---
## T04 — Session management UI (top-bar menu, hash routing)
```task
id: CE-WP-0005-T04
state_hub_task_id: e008524c-9cef-448f-b95b-fa524c725bc3
priority: medium
status: done
depends_on: [T01]
```
Under `src/app/sessions/`:
- `SessionMenu.tsx` — top-bar dropdown showing the active session
name. Menu items:
- **Switch to…** (list of all sessions sorted by `lastOpenedAt`)
- **New session…** (opens an inline name-input modal)
- **Rename…**, **Delete…** (with confirmation) for the active
session
- **Export ZIP** (T06)
- **Import ZIP** (T07)
- **Sample sessions ▸** (T03, optional submenu)
- Empty state: if no sessions exist, the app body is replaced by a
centred "Create your first session" call-to-action with an inline
name input.
Hash routing:
- Current routes (`#/forms/demo`, default Review) become
session-scoped: `#/s/<sessionId>`,
`#/s/<sessionId>/forms/demo`. The active-session pointer is the
router's responsibility (single source of truth = the hash); the
`SessionService.setActive(...)` call is a side effect of hash
change.
- A bare `#/` (no session) renders the empty state.
- Deep links into a deleted/unknown session redirect to the empty
state with a toast.
Tests:
- DOM: switching sessions in the menu updates the hash and unmounts +
remounts the engine (verified by checking that the previous
session's documents disappear from the CollectionList).
- DOM: deep-link to a known session loads that session's documents.
---
## T05 — ADR-0008 + SessionArchive manifest schema
```task
id: CE-WP-0005-T05
state_hub_task_id: 50d525b1-ba7d-454e-91b4-34d96bc5ab7b
priority: high
status: done
```
Add `docs/decisions/ADR-0008-session-archive-format.md`. Locks:
- **ZIP layout**:
```
manifest.json
documents/
<documentId>.pdf
```
`<documentId>` is the engine's branded id (`doc_…`), used as the
filename. Future variants (per-representation files,
per-attachment) are intentionally deferred.
- **manifest.json shape** (top-level fields):
- `schemaVersion: 1`
- `exportedAt: string` (ISO-8601)
- `session: { id, name, createdAt, updatedAt }`
- `engine: EngineSnapshot` (the same shape produced by
`captureSnapshot()` — re-used verbatim so the round-trip stays
one-way).
- `documentBindings: Array<{ documentId, filename, fingerprint }>` —
pairs every engine document with its file inside `documents/`.
- **Merge-on-name-collision policy** (T07 spec): documents are
deduped by fingerprint; annotations/evidence/links are imported
with fresh ids and rebound to the deduped `documentId`. Re-importing
*your own* freshly-exported ZIP into the same session therefore
duplicates annotations — documented as a known limitation. A
later workplan can add idempotent imports via an `importBundleId`
field.
Under `src/shared/`:
- `session-archive.ts` — TypeScript interfaces for
`SessionArchiveManifest` matching the ADR. Pure types + a
`parseSessionArchiveManifest(json: unknown):
SessionArchiveManifest` that throws on schema mismatch (used by
the importer in T07).
Tests:
- Unit conformance: round-trip a synthetic manifest object →
`JSON.stringify` → parse → deep-equal.
- Unit failure: a manifest missing required fields, or with the
wrong `schemaVersion`, throws with a useful message.
---
## T06 — Export session as ZIP archive
```task
id: CE-WP-0005-T06
state_hub_task_id: 07546a24-90d8-4b5d-9833-2648d2936ea2
priority: high
status: done
depends_on: [T05]
```
Dependency add: `jszip` (small, MIT, battle-tested). Use the ESM
build to keep the bundle clean.
Under `src/app/sessions/`:
- `exportSessionZip.ts`:
```ts
export async function exportSessionZip(
sessionId: SessionId,
engine: Engine,
byteStore: PdfByteStore,
session: Session,
): Promise<Blob>
```
Steps:
1. Build the manifest from `captureSnapshot(engine)` + session
metadata + per-document `{ filename, fingerprint }` derived
from `engine.documents`.
2. For each `documentBindings[i]`, push `bytes` into
`documents/<documentId>.pdf`.
3. Push `manifest.json` (stringified, pretty-printed).
4. `zip.generateAsync({ type: "blob" })`.
- `triggerSessionDownload(blob, filename)` — creates an `<a download>`
element, clicks it, revokes the URL. Filename:
`<slugified session name>-<ISO date>.zip`.
UI:
- **Export ZIP** menu item in `SessionMenu` calls the above. Show a
brief spinner state on the menu item while the zip generates.
- Surface a success/error toast (re-use the toast pattern from
CE-WP-0004 sidebar, lifted into `src/app/sessions/Toast.tsx`).
Tests:
- DOM: synthesise a one-document session, click Export, capture the
generated Blob, unzip in the test (via JSZip), assert the manifest
matches the engine snapshot and `documents/<id>.pdf` contains the
original bytes.
---
## T07 — Import ZIP with merge-on-name-collision + fingerprint dedup
```task
id: CE-WP-0005-T07
state_hub_task_id: 2fedab8d-6af7-458a-90d3-383241978f4e
priority: high
status: done
depends_on: [T05]
```
Under `src/app/sessions/`:
- `importSessionZip.ts`:
```ts
export interface ImportSessionResult {
readonly sessionId: SessionId;
readonly outcome: "created" | "merged-into";
readonly stats: {
readonly documentsAdded: number;
readonly documentsDeduped: number;
readonly annotationsAdded: number;
readonly evidenceAdded: number;
readonly linksAdded: number;
};
}
export async function importSessionZip(
file: File | Blob,
services: SessionImportServices,
): Promise<ImportSessionResult>;
```
Steps:
1. Read with JSZip; parse `manifest.json` via
`parseSessionArchiveManifest`. Reject on schema mismatch.
2. Find target session: if a session with the manifest's
`session.name` exists → that one (`merged-into`). Else: create a
fresh session (`created`) preserving the imported name.
3. For each `documentBindings[i]`:
- If a document with the same `fingerprint` already lives in the
target session's engine: reuse its `documentId`; record a
remap `incoming.documentId → existing.documentId`. Skip the
bytes (we already have them).
- Else: register a new engine document with a freshly minted
`documentId` (the manifest's id is not preserved — it could
collide with future imports). Push bytes into the byte store.
Remap `incoming.documentId → new.documentId`.
4. Apply remaps:
- Annotations: mint new `annotationId`, rebind `documentId` to
the remapped value, call `engine.annotations.create(...)`.
- Evidence: mint new `evidenceItemId`, remap
`annotationIds`, call `engine.evidence.create(...)`.
- EvidenceLinks: mint new `evidenceLinkId`, remap
`evidenceItemId`, call `bindings.linkEvidenceToTarget(...)`.
5. Switch the active session to the target.
- Errors surface as toasts (corrupt zip, version mismatch, missing
binary file referenced by the manifest, generic IO).
UI:
- **Import ZIP** menu item in `SessionMenu` opens a file picker
(`accept=".zip,application/zip"`).
- After success: show a toast like *"Imported 'Demo' — 1 new
document, 2 annotations, 1 evidence item"*.
Tests:
- Unit: in-process round-trip — export a synthetic session, import
into an empty engine, assert outcome=`created`, document/anno/ev
counts match.
- Unit: same export, but import into a session of the same name that
already holds the document → assert `outcome="merged-into"`,
`documentsDeduped=1`, annotation/evidence counts double (additive
behaviour, per ADR-0008).
- Unit: corrupt manifest (drop a required field) → import rejects
with the parser error.
---
## T08 — E2E test of full create → annotate → export → reimport flow
```task
id: CE-WP-0005-T08
state_hub_task_id: 72d92828-8814-4034-96cc-7da5b6a5e281
priority: high
status: done
depends_on: [T03, T04, T06, T07]
```
`tests/integration/session-export-reimport.dom.test.tsx`. Mocks: the
PDF viewer (same pattern as
`tests/integration/citation-card-export-e2e.dom.test.tsx`); the
ingest path can use the real `ingestPdf` because we hand it real
fixture bytes.
Walk:
1. Load the app — empty state appears.
2. Create session "Demo" via the inline name input.
3. Upload a fixture PDF (read fixture bytes via
`node:fs` and wrap in a `File`).
4. Inject a selection for the manifest's known-good quote → save
evidence with a commentary.
5. (Sanity) Click Export → Copy as Markdown; assert the clipboard
payload contains the quote + commentary + openContextUrl.
6. Click **Export ZIP** in the SessionMenu. Intercept the
`<a download>` invocation; capture the Blob.
7. Click **Import ZIP** with the captured Blob. Assert merge:
- `outcome="merged-into"`, `documentsDeduped=1`,
`annotationsAdded=1`, `evidenceAdded=1`.
- The CollectionList still shows one document (deduped).
- The EvidenceSidebar now shows **two** evidence rows for the
same passage (one original + one from the merge — the known
additive behaviour per ADR-0008).
8. Click Export → Copy as Markdown on the *merged* evidence item;
assert the citation card output matches the original (proves the
round-trip preserves quote + commentary + URL shape).
If T08 passes, the MVP demo loop is complete and the project can ship
as a usable single-page demo.
---
## Out of scope (deferred to later workplans)
- **IndexedDB persistence** of PDF bytes between page reloads.
Currently only the ZIP path persists binaries.
- **Idempotent re-imports** (avoiding annotation duplication when
re-importing your own export). Requires an `importBundleId` field
in the manifest and a dedupe pass during T07. Track as a future
improvement; ADR-0008 already calls it out.
- **Session sharing via URL** (one-click "open this session in a
read-only viewer"). Adjacent to the deep-link URL scheme from
`wiki/ArchitectureOverview.md` §14.3 but not in scope here.
- **Per-document download** (export a single PDF + its annotations
as a `.zip`). The session-level export covers the demo loop; a
per-document variant is a small follow-up if asked for.
- **Polish**: branding, theme, first-run tutorial. Once the loop
works end-to-end, a separate workplan can tackle the look-and-feel.