diff --git a/src/app/App.tsx b/src/app/App.tsx index 7a5876e..b6d54db 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -24,6 +24,7 @@ import { EngineProvider, SessionProvider, useActiveSession, + useActiveSessionId, useEngine, usePdfByteStore, useSessionByteStoreRegistry, @@ -33,6 +34,8 @@ import { useSessionVersionBumper, } from "@work/index"; +import { CaptureLinkPersister } from "./forms/CaptureLinkPersister"; +import { loadCaptureState } from "./forms/capture-persistence"; import { FormsApp } from "./forms/FormsApp"; import { ReviewLayout } from "./ReviewLayout"; @@ -158,9 +161,29 @@ function ActiveAppFrame({ function SessionScopedTree({ mode }: { mode: AppMode }) { const engine = useEngine(); + const sessionId = useActiveSessionId(); + const restoredCapture = useMemo( + () => (sessionId ? loadCaptureState(sessionId) : null), + [sessionId], + ); + + if (!sessionId) return null; + return ( - - {mode === "forms" ? : } />} + + + {mode === "forms" ? ( + + ) : ( + } /> + )} ); } diff --git a/src/app/forms/CaptureLinkPersister.tsx b/src/app/forms/CaptureLinkPersister.tsx new file mode 100644 index 0000000..777a582 --- /dev/null +++ b/src/app/forms/CaptureLinkPersister.tsx @@ -0,0 +1,29 @@ +/** + * Writes evidence links to per-session capture storage whenever the + * binder mutates links. + */ + +import { useEffect } from "react"; + +import type { SessionId } from "@shared/ids"; + +import { useBinder } from "@binder/index"; +import { useEngineEventTick } from "@work/index"; + +import { persistCapturePatch } from "./capture-persistence"; + +export function CaptureLinkPersister({ sessionId }: { sessionId: SessionId }) { + const { links } = useBinder(); + const linkTick = useEngineEventTick("EvidenceLinkCreated"); + const unlinkTick = useEngineEventTick("EvidenceLinkRemoved"); + const updateTick = useEngineEventTick("EvidenceLinkUpdated"); + + useEffect(() => { + void linkTick; + void unlinkTick; + void updateTick; + persistCapturePatch(sessionId, { evidenceLinks: links.list() }); + }, [sessionId, links, linkTick, unlinkTick, updateTick]); + + return null; +} \ No newline at end of file diff --git a/src/app/forms/FormsApp.tsx b/src/app/forms/FormsApp.tsx index 53be648..0157ace 100644 --- a/src/app/forms/FormsApp.tsx +++ b/src/app/forms/FormsApp.tsx @@ -24,6 +24,8 @@ import { useBinder, useRegisterRect, } from "@binder/index"; +import type { SessionId } from "@shared/ids"; + import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer"; import { CollectionList, @@ -36,6 +38,7 @@ import { import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer"; +import { persistCapturePatch } from "./capture-persistence"; import { DEMO_SCHEMA } from "./demo-schema"; import { HighlightRectBridge } from "./HighlightRectBridge"; @@ -53,18 +56,31 @@ function quotePreview(text: string, max = 80): string { return t.length > max ? `${t.slice(0, max)}…` : t; } -export function FormsApp() { - const [schema, setSchema] = useState(() => ({ - ...DEMO_SCHEMA, - fields: [...DEMO_SCHEMA.fields], - })); +export interface FormsAppProps { + readonly sessionId: SessionId; + readonly initialSchema?: FormSchema; + readonly initialFieldValues?: Readonly>; +} + +export function FormsApp({ + sessionId, + initialSchema, + initialFieldValues, +}: FormsAppProps) { + const [schema, setSchema] = useState(() => + initialSchema + ? { ...initialSchema, fields: [...initialSchema.fields] } + : { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] }, + ); const fieldLabels = useMemo( () => new Map(schema.fields.map((f) => [f.id, f.label] as const)), [schema], ); - const [fieldValues, setFieldValues] = useState>({}); + const [fieldValues, setFieldValues] = useState>( + () => ({ ...(initialFieldValues ?? {}) }), + ); const [showAddFieldForm, setShowAddFieldForm] = useState(false); const [editingFieldId, setEditingFieldId] = useState(null); @@ -72,6 +88,10 @@ export function FormsApp() { setFieldValues((prev) => ({ ...prev, [fieldId]: value })); }, []); + useEffect(() => { + persistCapturePatch(sessionId, { formSchema: schema, fieldValues }); + }, [sessionId, schema, fieldValues]); + const nextFieldId = useCallback((fields: readonly FormFieldSchema[]): string => { let max = 0; for (const f of fields) { diff --git a/src/app/forms/capture-persistence.test.ts b/src/app/forms/capture-persistence.test.ts new file mode 100644 index 0000000..e9ab808 --- /dev/null +++ b/src/app/forms/capture-persistence.test.ts @@ -0,0 +1,95 @@ +/** + * CE-WP-0008 — per-session capture state round-trip. + */ + +import { describe, expect, it } from "vitest"; + +import type { SessionId } from "@shared/ids"; + +import { + CAPTURE_STATE_VERSION, + captureStateKey, + defaultCaptureState, + loadCaptureState, + persistCapturePatch, + removeCaptureState, + saveCaptureState, +} from "./capture-persistence"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (k) => map.get(k) ?? null, + key: (i) => [...map.keys()][i] ?? null, + removeItem: (k) => void map.delete(k), + setItem: (k, v) => void map.set(k, v), + }; +} + +const SESSION = "sess_capture" as SessionId; + +describe("capture-persistence", () => { + it("uses a per-session storage key", () => { + expect(captureStateKey(SESSION)).toBe( + "citation-evidence:session:sess_capture:capture-state:v1", + ); + }); + + it("round-trips schema, field values, and links", () => { + const storage = memoryStorage(); + const state = defaultCaptureState(); + const withData = { + ...state, + fieldValues: { summary: "Tenant owes arrears", deadline: "2026-12-15" }, + evidenceLinks: [ + { + id: "evlink_1", + evidenceItemId: "evi_1", + targetType: "form-field", + targetId: "summary", + relation: "supports", + status: "candidate", + createdAt: "2026-06-08T00:00:00.000Z", + updatedAt: "2026-06-08T00:00:00.000Z", + }, + ], + } as const; + + saveCaptureState(SESSION, withData, storage); + const loaded = loadCaptureState(SESSION, storage); + + expect(loaded?.version).toBe(CAPTURE_STATE_VERSION); + expect(loaded?.fieldValues).toEqual(withData.fieldValues); + expect(loaded?.evidenceLinks).toHaveLength(1); + expect(loaded?.formSchema.id).toBe("demo-form"); + }); + + it("persistCapturePatch merges without dropping other fields", () => { + const storage = memoryStorage(); + saveCaptureState( + SESSION, + { + ...defaultCaptureState(), + fieldValues: { amount: "1200" }, + evidenceLinks: [], + }, + storage, + ); + + persistCapturePatch(SESSION, { fieldValues: { amount: "1500", summary: "Updated" } }, storage); + + const loaded = loadCaptureState(SESSION, storage); + expect(loaded?.fieldValues).toEqual({ amount: "1500", summary: "Updated" }); + }); + + it("removeCaptureState clears the key", () => { + const storage = memoryStorage(); + saveCaptureState(SESSION, defaultCaptureState(), storage); + removeCaptureState(SESSION, storage); + expect(loadCaptureState(SESSION, storage)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/app/forms/capture-persistence.ts b/src/app/forms/capture-persistence.ts new file mode 100644 index 0000000..1716826 --- /dev/null +++ b/src/app/forms/capture-persistence.ts @@ -0,0 +1,129 @@ +/** + * Per-session Capture mode persistence (form schema, field values, links). + * + * Engine snapshots intentionally omit binder/app UI state. This module + * stores capture data beside the engine snapshot under a per-session + * localStorage key. + */ + +import type { EvidenceLink } from "@shared/evidence-link"; +import type { SessionId } from "@shared/ids"; + +import type { FormSchema } from "@binder/FormRenderer"; + +import { DEMO_SCHEMA } from "./demo-schema"; + +export const CAPTURE_STATE_VERSION = 1; + +export interface CaptureStateSnapshot { + readonly version: number; + readonly formSchema: FormSchema; + readonly fieldValues: Readonly>; + readonly evidenceLinks: readonly EvidenceLink[]; +} + +export function captureStateKey(sessionId: SessionId): string { + return `citation-evidence:session:${sessionId}:capture-state:v1`; +} + +export function defaultCaptureState(): CaptureStateSnapshot { + return { + version: CAPTURE_STATE_VERSION, + formSchema: { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] }, + fieldValues: {}, + evidenceLinks: [], + }; +} + +function isFormSchema(value: unknown): value is FormSchema { + if (typeof value !== "object" || value === null) return false; + const o = value as Record; + if (typeof o.id !== "string" || typeof o.title !== "string") return false; + if (!Array.isArray(o.fields)) return false; + return o.fields.every((f) => { + if (typeof f !== "object" || f === null) return false; + const field = f as Record; + return ( + typeof field.id === "string" && + typeof field.label === "string" && + (field.type === "text" || field.type === "textarea" || field.type === "date") + ); + }); +} + +function parseCaptureState(raw: unknown): CaptureStateSnapshot | null { + if (typeof raw !== "object" || raw === null) return null; + const o = raw as Record; + if (o.version !== CAPTURE_STATE_VERSION) return null; + if (!isFormSchema(o.formSchema)) return null; + if (typeof o.fieldValues !== "object" || o.fieldValues === null || Array.isArray(o.fieldValues)) { + return null; + } + const fieldValues: Record = {}; + for (const [k, v] of Object.entries(o.fieldValues as Record)) { + if (typeof v === "string") fieldValues[k] = v; + } + if (!Array.isArray(o.evidenceLinks)) return null; + const evidenceLinks = o.evidenceLinks as EvidenceLink[]; + return { + version: CAPTURE_STATE_VERSION, + formSchema: o.formSchema, + fieldValues, + evidenceLinks, + }; +} + +export function loadCaptureState( + sessionId: SessionId, + storage: Pick = globalThis.localStorage, +): CaptureStateSnapshot | null { + if (typeof storage?.getItem !== "function") return null; + const raw = storage.getItem(captureStateKey(sessionId)); + if (!raw) return null; + try { + return parseCaptureState(JSON.parse(raw) as unknown); + } catch { + return null; + } +} + +export function saveCaptureState( + sessionId: SessionId, + state: CaptureStateSnapshot, + storage: Pick = globalThis.localStorage, +): void { + if (typeof storage?.setItem !== "function") return; + try { + storage.setItem(captureStateKey(sessionId), JSON.stringify(state)); + } catch (err) { + console.warn("saveCaptureState: write failed", err); + } +} + +export function persistCapturePatch( + sessionId: SessionId, + patch: Partial< + Pick + >, + storage: Pick = globalThis.localStorage, +): void { + const current = loadCaptureState(sessionId, storage) ?? defaultCaptureState(); + saveCaptureState( + sessionId, + { + version: CAPTURE_STATE_VERSION, + formSchema: patch.formSchema ?? current.formSchema, + fieldValues: patch.fieldValues ?? current.fieldValues, + evidenceLinks: patch.evidenceLinks ?? current.evidenceLinks, + }, + storage, + ); +} + +export function removeCaptureState( + sessionId: SessionId, + storage: Pick = globalThis.localStorage, +): void { + if (typeof storage?.removeItem !== "function") return; + storage.removeItem(captureStateKey(sessionId)); +} \ No newline at end of file diff --git a/src/binder/BinderProvider.tsx b/src/binder/BinderProvider.tsx index 633cfb7..668f411 100644 --- a/src/binder/BinderProvider.tsx +++ b/src/binder/BinderProvider.tsx @@ -20,6 +20,8 @@ import { type ReactNode, } from "react"; +import type { EvidenceLink } from "@shared/evidence-link"; + import type { EventBus } from "@engine/events"; import { @@ -68,9 +70,20 @@ export interface BinderProviderProps { * because its observers attach to the current `window`. */ readonly services?: Omit; + /** + * Restored evidence links for this session. Seeded directly into the + * repo (no bus events) so reload does not spuriously re-emit + * `EvidenceLinkCreated`. + */ + readonly initialLinks?: readonly EvidenceLink[]; } -export function BinderProvider({ children, bus, services }: BinderProviderProps) { +export function BinderProvider({ + children, + bus, + services, + initialLinks, +}: BinderProviderProps) { const built = useMemo(() => { const links = services?.links ?? createInMemoryLinkRepo(); const bindings = services?.bindings ?? createBindingService(links, bus); @@ -78,6 +91,15 @@ export function BinderProvider({ children, bus, services }: BinderProviderProps) return { links, bindings, rect }; }, [bus, services]); + useEffect(() => { + if (!initialLinks?.length || services?.links) return; + for (const link of initialLinks) { + if (!built.links.get(link.id)) { + built.links.create(link); + } + } + }, [built.links, initialLinks, services?.links]); + // Disconnect rect observers + listeners on unmount. useEffect(() => { return () => { diff --git a/src/engine/services/sessions.ts b/src/engine/services/sessions.ts index 929608c..ddd26f3 100644 --- a/src/engine/services/sessions.ts +++ b/src/engine/services/sessions.ts @@ -277,6 +277,7 @@ export function attachSessionPersister( // Also drop the per-session active-document-id key — otherwise // it gets orphaned and accumulates in localStorage forever. storage.removeItem(`citation-evidence:session:${sessionId}:active-document-id:v1`); + storage.removeItem(`citation-evidence:session:${sessionId}:capture-state:v1`); } catch (err) { console.warn("attachSessionPersister: snapshot cleanup failed", err); } diff --git a/tests/integration/capture-session-persist.dom.test.tsx b/tests/integration/capture-session-persist.dom.test.tsx new file mode 100644 index 0000000..4372ad1 --- /dev/null +++ b/tests/integration/capture-session-persist.dom.test.tsx @@ -0,0 +1,109 @@ +/** + * Capture state survives switching away from and back to a session. + */ + +// @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 { Selector } from "@shared/selector"; + +import type { PdfSelectionCapture } from "@anchor/index"; +import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; + +import { captureStateKey, loadCaptureState } from "@app/forms/capture-persistence"; + +interface ViewerProps { + pdfUrl: string; + storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[]; + onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void; +} + +vi.mock("@anchor/index", async (importOriginal) => { + const original = await importOriginal(); + const MockPdfSpikeViewer = (_props: ViewerProps) => ( +
+ ); + return { ...original, PdfSpikeViewer: MockPdfSpikeViewer }; +}); + +const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!; +const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" "); +const SESSION_NAME = "capture-persist-session"; + +import { seedSessionWithDoc } from "./helpers/seed-session"; + +async function loadApp() { + const { App } = await import("@app/App"); + return render(); +} + +describe("Capture session persistence", () => { + beforeEach(() => { + globalThis.localStorage?.clear(); + if (typeof window !== "undefined") { + history.replaceState(null, "", window.location.pathname); + } + seedSessionWithDoc({ + sessionName: SESSION_NAME, + documentTitle: FIXTURE.filename, + canonicalText: SYNTHETIC_CANONICAL, + mode: "forms", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + it( + "restores typed field values after leaving and reopening the session", + { timeout: 20000 }, + async () => { + const user = userEvent.setup(); + const first = await loadApp(); + + const summary = screen.getByLabelText("Summary of the matter"); + await user.type(summary, "Persisted summary text"); + await user.click(screen.getByLabelText("Disputed amount")); + + await waitFor(() => { + const stored = Object.values(globalThis.localStorage ?? {}).join(""); + expect(stored).toContain("Persisted summary text"); + }); + + first.unmount(); + + await loadApp(); + + const restored = screen.getByLabelText("Summary of the matter") as HTMLTextAreaElement; + await waitFor(() => { + expect(restored.value).toBe("Persisted summary text"); + }); + }, + ); + + it( + "writes capture state under the per-session storage key", + { timeout: 15000 }, + async () => { + const user = userEvent.setup(); + await loadApp(); + + await user.type(screen.getByLabelText("Disputed amount"), "EUR 500"); + + await waitFor(() => { + const keys = Object.keys(globalThis.localStorage ?? {}); + const captureKey = keys.find((k) => k.includes(":capture-state:v1")); + expect(captureKey).toBeTruthy(); + const sessionId = captureKey!.split(":")[2]; + const state = loadCaptureState(sessionId as never); + expect(state?.fieldValues.amount).toBe("EUR 500"); + expect(captureStateKey(sessionId as never)).toBe(captureKey); + }); + }, + ); +}); \ No newline at end of file