Persist capture data per session (field values, schema, links)

Capture mode state lived only in React memory and was lost when
reopening a session or remounting EngineProvider.

- Add per-session localStorage capture snapshot (schema, values, links)
- Restore on session mount; persist on field/schema/link changes
- Seed binder links from storage without spurious bus events
- Clean up capture key when session is deleted
- Integration test for reload persistence
This commit is contained in:
2026-06-08 01:23:48 +02:00
parent ba34ba868f
commit b28feaad42
8 changed files with 437 additions and 9 deletions

View File

@@ -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 (
<BinderProvider bus={engine.bus}>
{mode === "forms" ? <FormsApp /> : <ReviewLayout upload={<UploadDropzone />} />}
<BinderProvider
bus={engine.bus}
initialLinks={restoredCapture?.evidenceLinks}
>
<CaptureLinkPersister sessionId={sessionId} />
{mode === "forms" ? (
<FormsApp
sessionId={sessionId}
initialSchema={restoredCapture?.formSchema}
initialFieldValues={restoredCapture?.fieldValues}
/>
) : (
<ReviewLayout upload={<UploadDropzone />} />
)}
</BinderProvider>
);
}

View File

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

View File

@@ -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<FormSchema>(() => ({
...DEMO_SCHEMA,
fields: [...DEMO_SCHEMA.fields],
}));
export interface FormsAppProps {
readonly sessionId: SessionId;
readonly initialSchema?: FormSchema;
readonly initialFieldValues?: Readonly<Record<string, string>>;
}
export function FormsApp({
sessionId,
initialSchema,
initialFieldValues,
}: FormsAppProps) {
const [schema, setSchema] = useState<FormSchema>(() =>
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<Record<string, string>>({});
const [fieldValues, setFieldValues] = useState<Record<string, string>>(
() => ({ ...(initialFieldValues ?? {}) }),
);
const [showAddFieldForm, setShowAddFieldForm] = useState(false);
const [editingFieldId, setEditingFieldId] = useState<string | null>(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) {

View File

@@ -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<string, string>();
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();
});
});

View File

@@ -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<Record<string, string>>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, string> = {};
for (const [k, v] of Object.entries(o.fieldValues as Record<string, unknown>)) {
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<Storage, "getItem"> = 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<Storage, "setItem"> = 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<CaptureStateSnapshot, "formSchema" | "fieldValues" | "evidenceLinks">
>,
storage: Pick<Storage, "getItem" | "setItem"> = 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<Storage, "removeItem"> = globalThis.localStorage,
): void {
if (typeof storage?.removeItem !== "function") return;
storage.removeItem(captureStateKey(sessionId));
}

View File

@@ -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<BinderServices, "rect">;
/**
* 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<BinderServices>(() => {
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 () => {

View File

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

View File

@@ -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<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (_props: ViewerProps) => (
<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(" ");
const SESSION_NAME = "capture-persist-session";
import { seedSessionWithDoc } from "./helpers/seed-session";
async function loadApp() {
const { App } = await import("@app/App");
return render(<App />);
}
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);
});
},
);
});