generated from coulomb/repo-seed
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/app/forms/CaptureLinkPersister.tsx
Normal file
29
src/app/forms/CaptureLinkPersister.tsx
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
95
src/app/forms/capture-persistence.test.ts
Normal file
95
src/app/forms/capture-persistence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
129
src/app/forms/capture-persistence.ts
Normal file
129
src/app/forms/capture-persistence.ts
Normal 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));
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
109
tests/integration/capture-session-persist.dom.test.tsx
Normal file
109
tests/integration/capture-session-persist.dom.test.tsx
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user