diff --git a/README.md b/README.md index 1c560f5..a195215 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ cd margin npm run verify ``` -This runs 35 unit and API smoke tests, type-checks the client and server, and produces the Vite production bundle. +This runs 41 unit and API smoke tests, type-checks the client and server, and produces the Vite production bundle. ## Production deployment diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..a6399d9 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,29 @@ +# Architecture overview + +Margin is a local-first Markdown writing application with a React and CodeMirror client and an Express boundary for structured OpenAI operations. + +## Authored and companion state + +Markdown is the authored artifact. Findings, discussions, reviews, sources, corrections, proposals, and generated assets are companion state. Companion state may help interpret or revise a draft, but it never becomes prose without an explicit writer action. + +The durable client architecture is organized around document identity: + +```text +DocumentRecord + ├── Markdown working snapshot + └── DocumentCompanionState + ├── findings and discussions + ├── sources and research + ├── document review and brief + ├── argument corrections + ├── revision proposals + └── portable asset metadata +``` + +IndexedDB stores document records and companion state independently by document ID. A short initialization boundary loads the active session before mounting the editor. Switching documents remounts document-scoped UI state from the selected session, preventing ephemeral state from leaking between drafts. + +## Direction + +Subsequent releases add shared TextAnchors, claims and evidence, a local retrieval library, a controlled Revision Pass, editorial decision memory, and a content-addressed job scheduler. Each domain is extracted when its implementation issue is active rather than through a single large rewrite. + +See [the roadmap](../roadmap.md) and [persistence architecture](persistence.md). diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md new file mode 100644 index 0000000..9d12096 --- /dev/null +++ b/docs/architecture/persistence.md @@ -0,0 +1,54 @@ +# Persistence architecture + +## Goals + +- Every document has stable identity independent of title. +- Companion state is isolated per document and survives switching and reload. +- Legacy `margin.workspace.v1` data migrates without losing prose or editorial state. +- Markdown Save and Export semantics remain unchanged. +- Corrupt companion data degrades to empty safe state without discarding the document. + +## Stores + +The `margin.editorial.v1` IndexedDB database contains: + +| Store | Key | Purpose | +| --- | --- | --- | +| `documents` | `id` | Identity, file metadata, content/fingerprint hashes, timestamps, and a local Markdown working snapshot. | +| `documentState` | `documentId` | Findings, discussions, sources, reviews, corrections, proposals, and assets. | +| `settings` | `key` | Active document identity and migration markers. | + +The persistence domain lives under `margin/src/persistence/`. It exposes document/session operations rather than IndexedDB primitives to React. + +## Why the record contains a working snapshot + +The roadmap’s initial `DocumentRecord` sketch did not include Markdown content. Browser file handles are not reliably recoverable across reloads, and new unsaved documents have no external file at all. Margin therefore stores a local working snapshot on the document record. + +This snapshot is recovery state, not a proprietary authored format. **Save Markdown** and package export continue to produce ordinary Markdown and assets; the companion database never silently writes model output into prose. + +## Identity and recovery + +- New documents receive UUIDs immediately. +- Built-in examples use stable reserved IDs so their companion state can be revisited. +- Open file handles map to document IDs for the current browser session. +- After reload, an exact fingerprint of normalized content plus filename may recover a record. +- A same-named file with different content is a new document. Margin does not guess or merge aggressively. +- Renaming changes metadata and fingerprint but never document ID. + +## Migration + +On first initialization: + +1. Read `margin.workspace.v1` if it is valid. +2. Create one legacy-origin document record and companion record. +3. Mark it active and set `legacyWorkspaceV1Migrated=complete`. +4. Preserve the localStorage value unchanged for rollback during the 0.2 release line. +5. Stop writing legacy localStorage. + +Malformed legacy data falls back to the starter document. Malformed IndexedDB companion fields are individually normalized to safe defaults. + +## Save lifecycle + +Document and companion records are written together in one IndexedDB transaction after a short debounce. Switching first flushes the current session, then activates the target. File Save additionally associates the selected file handle with the current document ID and refreshes record metadata. + +Transient selections, open shelves, hover state, in-flight jobs, and provider cooldown remain ephemeral. diff --git a/docs/build-week-submission.md b/docs/build-week-submission.md index 8ac8eea..4d91450 100644 --- a/docs/build-week-submission.md +++ b/docs/build-week-submission.md @@ -78,7 +78,7 @@ Live research posed a different problem. Web work takes time, and the finished r - In-draft editorial tasks that run in the background and return reviewable diffs with source provenance when needed. - Tables, charts, diagrams, editorial images, and genuinely interactive widgets with portable fallbacks. - Reviewable issue discussions and explicit applied-change diffs. -- A server-only OpenAI boundary, deterministic fallback, 35 automated checks, production build, and public deployment. +- A server-only OpenAI boundary, deterministic fallback, 41 automated checks, production build, and public deployment. ### What we learned diff --git a/docs/findings/2026-08-27-browser-file-handle-recovery.md b/docs/findings/2026-08-27-browser-file-handle-recovery.md new file mode 100644 index 0000000..d196fdc --- /dev/null +++ b/docs/findings/2026-08-27-browser-file-handle-recovery.md @@ -0,0 +1,24 @@ +# Browser file handles are not document persistence + +## Symptom + +A document opened from disk can retain a writable file handle during one browser session, but Margin cannot rely on that object to reconstruct the active draft after reload. New unsaved drafts have no handle at all. + +## Root cause + +The File System Access API exposes useful handles, but their durable availability and permissions vary by browser and session. A `DocumentRecord` containing metadata alone cannot satisfy “reload and recover the active document.” + +## Fix + +Store a normalized Markdown working snapshot on each local `DocumentRecord`. Keep a weak in-session handle-to-document association for identity and direct saves. After reload, use an exact filename-plus-content fingerprint only as conservative recovery; ambiguous or changed files become new records. + +## Regression tests added + +- Reload restores the active document and its independent companion state. +- Same filename with different content does not merge. +- A known in-session file handle preserves identity while accepting newer file content. +- Renaming updates metadata without changing document ID. + +## Broader design implication + +External file access and local editorial continuity are different responsibilities. Margin needs a local working snapshot for continuity while keeping Markdown Save/Export as the portable authored artifact. Future sync work should preserve this separation rather than treating a browser handle as durable identity. diff --git a/docs/releases/0.2-durable-foundation.md b/docs/releases/0.2-durable-foundation.md new file mode 100644 index 0000000..0fe0c8d --- /dev/null +++ b/docs/releases/0.2-durable-foundation.md @@ -0,0 +1,24 @@ +# Margin 0.2 — Durable Foundation + +Status: in progress + +## PR 1: per-document persistence + +- Added IndexedDB stores for documents, companion state, and settings. +- Added durable UUID identity for new/opened documents and stable identities for included examples. +- Added conservative fingerprint recovery for reopened files. +- Added a recent-documents section for returning to locally remembered drafts. +- Migrated the legacy global workspace once while retaining rollback data. +- Added an asynchronous initialization boundary and per-document editor remount. +- Preserved Markdown Save/Export behavior. + +### Compatibility note + +Document records contain a local Markdown working snapshot because browser file handles cannot reliably restore an editor after reload. Markdown remains the portable authored artifact; the snapshot is local recovery state. + +## Remaining release work + +- Shared durable TextAnchor passage identity ([#3](https://github.com/kbaxter/margin-app/issues/3)). +- CodeMirror mapping and migration of findings/argument claims ([#4](https://github.com/kbaxter/margin-app/issues/4)). + +0.2 is not complete until the manual A/B document and anchor durability gate passes. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..56b0e9f --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,39 @@ +# Margin roadmap + +Margin is evolving from a strong single-document writing surface into a durable editorial system whose understanding compounds across sessions and documents. This file is the canonical, versioned roadmap; GitHub issue [#1](https://github.com/kbaxter/margin-app/issues/1) tracks execution. + +## Product invariants + +1. Markdown remains the authored artifact. Companion state never silently becomes prose. +2. The writer remains in control. AI edits require review and acceptance. +3. Margin says fewer, better things. +4. Anchors are the shared passage-identity primitive. +5. Durability precedes intelligence. +6. Sources and evidence retain provenance; URLs, locators, and quotations are never invented. +7. Markdown and normal assets remain useful outside Margin. +8. Interaction stays tied to a document, passage, finding, claim, or review—not a global chat sidebar. +9. Model boundaries use structured outputs and validation. +10. Every pull request leaves `npm run verify` passing. + +## Releases + +| Release | Issues | Outcome | +| --- | --- | --- | +| 0.2 Durable Foundation | [#2](https://github.com/kbaxter/margin-app/issues/2)–[#4](https://github.com/kbaxter/margin-app/issues/4) | Documents retain independent editorial state; passage identity survives normal editing and reopen. | +| 0.3 Claims & Evidence | [#5](https://github.com/kbaxter/margin-app/issues/5)–[#7](https://github.com/kbaxter/margin-app/issues/7) | Margin tracks consequential claims, checks evidence, and notices stale support. | +| 0.4 Margin Library | [#8](https://github.com/kbaxter/margin-app/issues/8)–[#9](https://github.com/kbaxter/margin-app/issues/9) | Project and corpus context is searchable; Connect finds meaningful prior relationships. | +| 0.5 Revision Intelligence | [#10](https://github.com/kbaxter/margin-app/issues/10)–[#11](https://github.com/kbaxter/margin-app/issues/11) | Review becomes a controlled Revision Pass and learns from editorial decisions. | +| 0.6 Execution Layer | [#12](https://github.com/kbaxter/margin-app/issues/12) | Background model work is scheduled, cached, cancelled, and invalidated coherently. | + +## Delivery rules + +- One implementation issue per branch and normally one issue per pull request. +- Architecture documentation changes with the code. +- Domains are extracted incrementally as touched; no preliminary rewrite of `App.tsx` or `server/index.ts`. +- Focused domain tests accompany behavior changes. +- Before merge, run `npm ci` and `npm run verify`, then manually exercise changed UI behavior. +- Meaningful implementation discoveries belong in `docs/findings/`. + +## Definition of success + +A writer’s documents, claims, sources, disagreements, corrections, and accepted or rejected editorial decisions accumulate into a durable local editorial system. Margin remembers what a claim meant, what supported it, when support became stale, where related ideas appeared, and how the writer responds to intervention—while Markdown remains ordinary portable prose and no AI edit becomes authored text without the writer’s choice. diff --git a/margin/package-lock.json b/margin/package-lock.json index f1c4086..3f4ab47 100644 --- a/margin/package-lock.json +++ b/margin/package-lock.json @@ -17,6 +17,7 @@ "@lezer/highlight": "^1.2.1", "dotenv": "^16.6.1", "express": "^5.1.0", + "idb": "^8.0.3", "lucide-react": "^0.468.0", "openai": "^5.20.1", "react": "^19.1.1", @@ -30,6 +31,7 @@ "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", "concurrently": "^9.2.1", + "fake-indexeddb": "^6.2.5", "tsx": "^4.20.5", "typescript": "~5.8.3", "vite": "^6.1.0" @@ -2127,6 +2129,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2347,6 +2359,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", diff --git a/margin/package.json b/margin/package.json index 6708852..2b8f2bc 100644 --- a/margin/package.json +++ b/margin/package.json @@ -24,6 +24,7 @@ "@lezer/highlight": "^1.2.1", "dotenv": "^16.6.1", "express": "^5.1.0", + "idb": "^8.0.3", "lucide-react": "^0.468.0", "openai": "^5.20.1", "react": "^19.1.1", @@ -37,6 +38,7 @@ "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", "concurrently": "^9.2.1", + "fake-indexeddb": "^6.2.5", "tsx": "^4.20.5", "typescript": "~5.8.3", "vite": "^6.1.0" diff --git a/margin/src/App.tsx b/margin/src/App.tsx index b674d3c..f814bae 100644 --- a/margin/src/App.tsx +++ b/margin/src/App.tsx @@ -4,9 +4,9 @@ import { requestAmbient, requestComputedArtifact, requestDocumentReview, request import { applyEditorialTask, documentFileName, extractDocumentSources, parseDirectives, removeDirective, renameMarkdownDocument, replaceDirectiveWithArtifact, researchToMarkdown } from "./document"; import Editor, { type DirectiveControlPhase, type FindingPosition } from "./editor"; import { exportDocumentPackage, openDocumentPackage, openMarkdown, saveMarkdown, type WritableFileHandle } from "./files"; -import { loadWorkspace, saveWorkspace } from "./persistence"; +import { createDocumentPersistence, updateDocumentRecord, type DocumentPersistence } from "./persistence/index"; import { materializeArtifact, reviseArtifactAssets } from "./artifacts"; -import type { ArgumentCorrection, ArgumentSpineClaim, ComputedArtifact, ComputedFormat, Directive, DirectiveResult, DocumentBrief, DocumentReviewResult, EditorialTaskResult, Finding, FindingKind, FindingThread, FindingThreadMessage, ResearchResult, RevisionProposal, Source, WorkspaceState } from "./types"; +import type { ArgumentCorrection, ArgumentSpineClaim, ComputedArtifact, ComputedFormat, Directive, DirectiveResult, DocumentBrief, DocumentCompanionState, DocumentRecord, DocumentReviewResult, DocumentSession, EditorialTaskResult, Finding, FindingKind, FindingThread, FindingThreadMessage, ResearchResult, RevisionProposal, Source } from "./types"; import marginMark from "./assets/margin-mark-v2.svg"; import ternusExample from "../examples/apples-next-ceo-is-a-hardware-engineer.md?raw"; import modelsExample from "../examples/three-models-three-product-philosophies.md?raw"; @@ -87,7 +87,7 @@ interface DirectiveResearchState { } type ExampleDocumentId = keyof typeof EXAMPLE_DOCUMENTS; -type PendingDocumentAction = "new" | "open" | ExampleDocumentId; +type PendingDocumentAction = { kind: "new" | "open" | ExampleDocumentId | "recent"; documentId?: string }; type AmbientStatus = "idle" | "waiting" | "analyzing" | "quiet"; type DocumentReviewStatus = "idle" | "waiting" | "analyzing" | "ready" | "error"; type ActiveShelf = "context" | "review" | null; @@ -126,11 +126,70 @@ function ActionButton({ icon, label, onClick, disabled = false }: { icon: React. ); } +interface MarginDocumentProps { + initialSession: DocumentSession; + initialFileHandle: WritableFileHandle | null; + persistence: DocumentPersistence; + recentDocuments: DocumentRecord[]; + onActivate: (session: DocumentSession, handle?: WritableFileHandle | null) => Promise; + onRefreshDocuments: () => Promise; +} + export default function App() { - const initialWorkspace = useMemo(() => loadWorkspace(STARTER), []); - const [document, setDocument] = useState(initialWorkspace.document); - const [fileName, setFileName] = useState(initialWorkspace.fileName); - const [fileHandle, setFileHandle] = useState(null); + const persistence = useMemo(() => createDocumentPersistence(), []); + const [activeSession, setActiveSession] = useState(null); + const [activeFileHandle, setActiveFileHandle] = useState(null); + const [recentDocuments, setRecentDocuments] = useState([]); + const [initializationError, setInitializationError] = useState(null); + + const refreshDocuments = useCallback(async () => { + setRecentDocuments(await persistence.listDocuments()); + }, [persistence]); + + useEffect(() => { + let cancelled = false; + void persistence.initialize(STARTER).then(async (session) => { + if (cancelled) return; + setActiveSession(session); + await refreshDocuments(); + }).catch((error: unknown) => { + if (!cancelled) setInitializationError(error instanceof Error ? error.message : "Margin could not open its local document library."); + }); + return () => { cancelled = true; }; + }, [persistence, refreshDocuments]); + + const activate = useCallback(async (session: DocumentSession, handle: WritableFileHandle | null = null) => { + setActiveSession(session); + setActiveFileHandle(handle); + await refreshDocuments(); + }, [refreshDocuments]); + + if (initializationError) { + return
Local library unavailable

{initializationError}

; + } + if (!activeSession) { + return
Opening your draft…
; + } + + return ( + + ); +} + +function MarginDocument({ initialSession, initialFileHandle, persistence, recentDocuments, onActivate, onRefreshDocuments }: MarginDocumentProps) { + const initialWorkspace = initialSession.companion; + const recordRef = useRef(initialSession.record); + const [document, setDocument] = useState(initialSession.record.content); + const [fileName, setFileName] = useState(initialSession.record.fileName); + const [fileHandle, setFileHandle] = useState(initialFileHandle); const [fileDirty, setFileDirty] = useState(false); const [findings, setFindings] = useState(initialWorkspace.findings); const [findingThreads, setFindingThreads] = useState>(initialWorkspace.findingThreads); @@ -179,8 +238,8 @@ export default function App() { const documentReviewTimer = useRef(undefined); const documentRef = useRef(document); const reviewedDocumentRef = useRef( - initialWorkspace.documentReview && initialWorkspace.documentReviewSignature === documentSignature(initialWorkspace.document) - ? initialWorkspace.document + initialWorkspace.documentReview && initialWorkspace.documentReviewSignature === documentSignature(initialSession.record.content) + ? initialSession.record.content : "", ); const briefCustomizedRef = useRef(!briefsMatch(initialWorkspace.documentBrief, initialWorkspace.documentReview?.brief ?? null)); @@ -274,33 +333,46 @@ export default function App() { return () => window.removeEventListener("pointerdown", closeOnOutsidePress); }, [documentMenuOpen]); + const companionState = useMemo(() => ({ + documentId: recordRef.current.id, + findings, + findingThreads, + dismissedFindingIds, + sources, + research, + researchMode, + activeDirective, + ambientPaused, + documentReview, + documentReviewMode, + documentReviewSignature: reviewedDocumentRef.current ? documentSignature(reviewedDocumentRef.current) : "", + documentBrief, + argumentCorrections, + revisionProposals, + assets, + }), [activeDirective, ambientPaused, argumentCorrections, assets, dismissedFindingIds, documentBrief, documentReview, documentReviewMode, findingThreads, findings, research, researchMode, revisionProposals, sources]); + + const persistCurrentSession = useCallback(async (lastOpened = false) => { + const record = updateDocumentRecord(recordRef.current, { + fileName, + content: document, + now: new Date().toISOString(), + lastOpened, + }); + recordRef.current = record; + await persistence.saveSession(record, { ...companionState, documentId: record.id }); + return record; + }, [companionState, document, fileName, persistence]); + useEffect(() => { window.clearTimeout(saveTimer.current); saveTimer.current = window.setTimeout(() => { - const workspace: WorkspaceState = { - version: 1, - document, - fileName, - findings, - findingThreads, - dismissedFindingIds, - sources, - research, - researchMode, - activeDirective, - ambientPaused, - documentReview, - documentReviewMode, - documentReviewSignature: reviewedDocumentRef.current ? documentSignature(reviewedDocumentRef.current) : "", - documentBrief, - argumentCorrections, - revisionProposals, - assets, - }; - saveWorkspace(workspace); + void persistCurrentSession().catch(() => { + // A later save retries. Markdown editing must remain available if IndexedDB is temporarily unavailable. + }); }, 350); return () => window.clearTimeout(saveTimer.current); - }, [activeDirective, ambientPaused, argumentCorrections, assets, dismissedFindingIds, document, documentBrief, documentReview, documentReviewMode, fileName, findingThreads, findings, research, researchMode, revisionProposals, sources]); + }, [persistCurrentSession]); useEffect(() => { window.clearTimeout(ambientTimer.current); @@ -880,102 +952,82 @@ export default function App() { } }, [argumentCorrections, document, documentBrief, documentReview, providedContextSignature, revisionStatus]); - const clearDocumentContext = useCallback(() => { - setFindings([]); - setFindingThreads({}); - setFindingPositions({}); - setDismissedFindingIds([]); - setResearch(null); - setResearchMode(null); - setSources([]); - setAssets([]); - setActiveDirective(null); - setActiveShelf(null); - setDocumentReview(null); - setDocumentReviewMode(null); - setDocumentReviewStatus("idle"); - setDocumentBrief(null); - setArgumentCorrections([]); - setSelectedArgumentId(null); - setEditingArgumentId(null); - setRevisionProposals([]); - setRevisionStatus("idle"); - reviewedDocumentRef.current = ""; - setSelection({ from: 0, to: 0, text: "" }); - setObservation(null); - setActiveFinding(null); - setHoveredAppliedChange(null); - setPinnedAppliedChange(null); - setDiscussionFinding(null); - setFindingDrafts({}); - setFindingDiscussionStatus({}); - directivePromisesRef.current.clear(); - directiveResearchRef.current = {}; - setDirectiveResearch({}); - }, []); + const activatePersistedSession = useCallback(async (next: () => Promise) => { + window.clearTimeout(saveTimer.current); + await persistCurrentSession(); + await onActivate(await next()); + }, [onActivate, persistCurrentSession]); const createNewDocument = useCallback(() => { - setDocument(""); - setFileName("untitled.md"); - setFileHandle(null); - setFileDirty(false); setDocumentMenuOpen(false); - setRenaming(false); - clearDocumentContext(); - }, [clearDocumentContext]); + void activatePersistedSession(() => persistence.createDocument({ fileName: "untitled.md", content: "", origin: "new" })); + }, [activatePersistedSession, persistence]); const performOpenDocument = useCallback(async () => { try { const opened = await openMarkdown(); if (!opened) return; - setDocument(opened.text); - setFileName(opened.name); - setFileHandle(opened.handle); - setFileDirty(false); - setDocumentMenuOpen(false); - setRenaming(false); - clearDocumentContext(); + await persistCurrentSession(); + const session = await persistence.openDocument({ fileName: opened.name, content: opened.text, handle: opened.handle }); + if (opened.handle) persistence.associateHandle(opened.handle, session.record.id); + await onActivate(session, opened.handle); } catch (error) { setObservationLabel("Couldn’t open document"); setObservationMode(null); setObservation(error instanceof Error ? error.message : "Margin could not open that file."); } - }, [clearDocumentContext]); + }, [onActivate, persistCurrentSession, persistence]); const performOpenPackage = useCallback(async () => { try { const opened = await openDocumentPackage(); if (!opened) return; - clearDocumentContext(); - setDocument(opened.text); - setFileName(opened.name); - setFileHandle(opened.handle); - setAssets(opened.assets); - setFileDirty(false); - setDocumentMenuOpen(false); - setRenaming(false); + await persistCurrentSession(); + const session = await persistence.openDocument({ fileName: opened.name, content: opened.text, handle: opened.handle }); + const companion = { ...session.companion, assets: opened.assets }; + await persistence.saveSession(session.record, companion); + if (opened.handle) persistence.associateHandle(opened.handle, session.record.id); + await onActivate({ record: session.record, companion }, opened.handle); } catch (error) { setObservationLabel("Couldn’t open package"); setObservationMode(null); setObservation(error instanceof Error ? error.message : "Margin could not open that document package."); } - }, [clearDocumentContext]); + }, [onActivate, persistCurrentSession, persistence]); const loadExampleDocument = useCallback((id: ExampleDocumentId) => { const example = EXAMPLE_DOCUMENTS[id]; - clearDocumentContext(); - setDocument(example.document); - setFileName(example.fileName); - setFileHandle(null); - setFileDirty(false); setDocumentMenuOpen(false); - setRenaming(false); - }, [clearDocumentContext]); + void activatePersistedSession(() => persistence.openExample({ + preferredId: id, + fileName: example.fileName, + content: example.document, + origin: "example", + })); + }, [activatePersistedSession, persistence]); + + const loadRecentDocument = useCallback((documentId: string) => { + setDocumentMenuOpen(false); + void (async () => { + await persistCurrentSession(); + const session = await persistence.loadSession(documentId); + if (session) { + const record = updateDocumentRecord(session.record, { + fileName: session.record.fileName, + content: session.record.content, + now: new Date().toISOString(), + lastOpened: true, + }); + await persistence.saveSession(record, session.companion); + await onActivate({ record, companion: session.companion }); + } + })(); + }, [onActivate, persistCurrentSession, persistence]); const newDocument = useCallback(() => { setDocumentMenuOpen(false); if (fileDirty) { - setPendingDocumentAction("new"); + setPendingDocumentAction({ kind: "new" }); return; } createNewDocument(); @@ -984,7 +1036,7 @@ export default function App() { const openDocument = useCallback(() => { setDocumentMenuOpen(false); if (fileDirty) { - setPendingDocumentAction("open"); + setPendingDocumentAction({ kind: "open" }); return; } void performOpenDocument(); @@ -993,19 +1045,29 @@ export default function App() { const openExampleDocument = useCallback((id: ExampleDocumentId) => { setDocumentMenuOpen(false); if (fileDirty) { - setPendingDocumentAction(id); + setPendingDocumentAction({ kind: id }); return; } loadExampleDocument(id); }, [fileDirty, loadExampleDocument]); + const openRecentDocument = useCallback((documentId: string) => { + setDocumentMenuOpen(false); + if (fileDirty) { + setPendingDocumentAction({ kind: "recent", documentId }); + return; + } + loadRecentDocument(documentId); + }, [fileDirty, loadRecentDocument]); + const continueDocumentAction = useCallback(() => { const action = pendingDocumentAction; setPendingDocumentAction(null); - if (action === "new") createNewDocument(); - if (action === "open") void performOpenDocument(); - if (action === "example-ternus" || action === "example-models") loadExampleDocument(action); - }, [createNewDocument, loadExampleDocument, pendingDocumentAction, performOpenDocument]); + if (action?.kind === "new") createNewDocument(); + if (action?.kind === "open") void performOpenDocument(); + if (action?.kind === "example-ternus" || action?.kind === "example-models") loadExampleDocument(action.kind); + if (action?.kind === "recent" && action.documentId) loadRecentDocument(action.documentId); + }, [createNewDocument, loadExampleDocument, loadRecentDocument, pendingDocumentAction, performOpenDocument]); const beginRename = useCallback(() => { setRenameValue(documentTitle); @@ -1028,14 +1090,24 @@ export default function App() { if (handle) { setFileHandle(handle); setFileName(handle.name); + persistence.associateHandle(handle, recordRef.current.id); } + const nextFileName = handle?.name ?? fileName; + const record = updateDocumentRecord(recordRef.current, { + fileName: nextFileName, + content: document, + now: new Date().toISOString(), + }); + recordRef.current = record; + await persistence.saveSession(record, { ...companionState, documentId: record.id }); + await onRefreshDocuments(); setFileDirty(false); } catch (error) { setObservationLabel("Couldn’t save document"); setObservationMode(null); setObservation(error instanceof Error ? error.message : "Margin could not save that file."); } - }, [document, fileHandle, fileName]); + }, [companionState, document, fileHandle, fileName, onRefreshDocuments, persistence]); const exportPackage = useCallback(async () => { try { @@ -1157,6 +1229,17 @@ export default function App() { {example.title} ))} + {recentDocuments.some((record) => record.id !== recordRef.current.id) && ( + <> +
Recent
+ {recentDocuments.filter((record) => record.id !== recordRef.current.id).slice(0, 6).map((record) => ( + + ))} + + )}
Document
diff --git a/margin/src/persistence.test.ts b/margin/src/persistence.test.ts new file mode 100644 index 0000000..02ebd3b --- /dev/null +++ b/margin/src/persistence.test.ts @@ -0,0 +1,132 @@ +import "fake-indexeddb/auto"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDocumentPersistence, updateDocumentRecord } from "./persistence/index"; +import { LEGACY_WORKSPACE_KEY } from "./persistence"; +import { openMarginDatabase } from "./persistence/db"; + +let databaseOrdinal = 0; + +function databaseName(name: string) { + databaseOrdinal += 1; + return `margin-test-${name}-${databaseOrdinal}`; +} + +function memoryStorage(entries: Record = {}) { + return { + getItem(key: string) { return entries[key] ?? null; }, + }; +} + +function options(name: string, storage = memoryStorage()) { + let id = 0; + let tick = 0; + return { + dbName: databaseName(name), + storage, + createId: () => `document-${++id}`, + now: () => `2026-08-27T00:00:${String(++tick).padStart(2, "0")}.000Z`, + }; +} + +test("migrates one legacy workspace without deleting rollback data", async () => { + const legacy = JSON.stringify({ + version: 1, + document: "# Legacy\n\nA retained draft.", + fileName: "legacy.md", + findings: [{ id: "finding-1", kind: "shift", line: 3, excerpt: "retained", text: "Keep this." }], + sources: [], + }); + const storage = memoryStorage({ [LEGACY_WORKSPACE_KEY]: legacy }); + const persistenceOptions = options("migration", storage); + const persistence = createDocumentPersistence(persistenceOptions); + const migrated = await persistence.initialize("# Fallback"); + + assert.equal(migrated.record.origin, "legacy"); + assert.equal(migrated.record.content, "# Legacy\n\nA retained draft."); + assert.equal(migrated.companion.findings[0]?.id, "finding-1"); + assert.equal(storage.getItem(LEGACY_WORKSPACE_KEY), legacy); + + const reopened = createDocumentPersistence(persistenceOptions); + const restored = await reopened.initialize("# Different fallback"); + assert.equal(restored.record.id, migrated.record.id); + assert.equal((await reopened.listDocuments()).length, 1); +}); + +test("stores independent companion state for two documents", async () => { + const persistenceOptions = options("independent"); + const persistence = createDocumentPersistence(persistenceOptions); + await persistence.initialize("# Starter"); + const first = await persistence.createDocument({ fileName: "a.md", content: "# A", origin: "new" }); + const second = await persistence.createDocument({ fileName: "b.md", content: "# B", origin: "new" }); + + await persistence.saveSession(first.record, { + ...first.companion, + findings: [{ id: "a-finding", kind: "shift", line: 1, excerpt: "A", text: "Only A" }], + }); + await persistence.saveSession(second.record, { + ...second.companion, + sources: [{ id: "b-source", title: "B source", detail: "Only B", kind: "provided" }], + }); + + assert.deepEqual((await persistence.loadSession(first.record.id))?.companion.findings.map((finding) => finding.id), ["a-finding"]); + assert.equal((await persistence.loadSession(first.record.id))?.companion.sources.length, 0); + assert.deepEqual((await persistence.loadSession(second.record.id))?.companion.sources.map((source) => source.id), ["b-source"]); + assert.equal((await persistence.loadSession(second.record.id))?.companion.findings.length, 0); + + const reloaded = createDocumentPersistence(persistenceOptions); + const active = await reloaded.initialize("# Fallback"); + assert.equal(active.record.id, second.record.id); + assert.deepEqual(active.companion.sources.map((source) => source.id), ["b-source"]); +}); + +test("malformed companion state falls back without losing the document", async () => { + const persistenceOptions = options("malformed"); + const persistence = createDocumentPersistence(persistenceOptions); + const created = await persistence.initialize("# Safe draft"); + const database = await openMarginDatabase(persistenceOptions.dbName); + await database.put("documentState", { documentId: created.record.id, findings: "bad" } as never); + + const restored = await persistence.loadSession(created.record.id); + assert.equal(restored?.record.content, "# Safe draft"); + assert.deepEqual(restored?.companion.findings, []); + assert.deepEqual(restored?.companion.sources, []); +}); + +test("same filename with different content never merges silently", async () => { + const persistence = createDocumentPersistence(options("fingerprints")); + await persistence.initialize("# Starter"); + const first = await persistence.openDocument({ fileName: "essay.md", content: "# First" }); + const same = await persistence.openDocument({ fileName: "essay.md", content: "# First" }); + const different = await persistence.openDocument({ fileName: "essay.md", content: "# Second" }); + + assert.equal(same.record.id, first.record.id); + assert.notEqual(different.record.id, first.record.id); +}); + +test("a known file handle preserves identity while accepting newer file content", async () => { + const persistence = createDocumentPersistence(options("handles")); + await persistence.initialize("# Starter"); + const handle = {}; + const first = await persistence.openDocument({ fileName: "essay.md", content: "# First", handle }); + const updated = await persistence.openDocument({ fileName: "essay.md", content: "# Revised", handle }); + + assert.equal(updated.record.id, first.record.id); + assert.equal(updated.record.content, "# Revised"); +}); + +test("renaming updates metadata without changing document identity", async () => { + const persistence = createDocumentPersistence(options("rename")); + const created = await persistence.initialize("# Original"); + const renamed = updateDocumentRecord(created.record, { + fileName: "new-title.md", + content: "# New title\n\nBody", + now: "2026-08-27T01:00:00.000Z", + }); + await persistence.saveSession(renamed, created.companion); + + const restored = await persistence.loadSession(created.record.id); + assert.equal(restored?.record.id, created.record.id); + assert.equal(restored?.record.fileName, "new-title.md"); + assert.equal(restored?.record.title, "New title"); +}); diff --git a/margin/src/persistence.ts b/margin/src/persistence.ts index c17578d..18438f6 100644 --- a/margin/src/persistence.ts +++ b/margin/src/persistence.ts @@ -1,10 +1,9 @@ import type { WorkspaceState } from "./types"; -const WORKSPACE_KEY = "margin.workspace.v1"; +export const LEGACY_WORKSPACE_KEY = "margin.workspace.v1"; -export function loadWorkspace(fallbackDocument: string): WorkspaceState { +export function parseLegacyWorkspace(raw: string | null, fallbackDocument: string, legacyDocument: string | null = null): WorkspaceState { try { - const raw = localStorage.getItem(WORKSPACE_KEY); if (!raw) throw new Error("No saved workspace"); const value = JSON.parse(raw) as Partial; if (value.version !== 1 || typeof value.document !== "string") throw new Error("Unsupported workspace"); @@ -43,7 +42,7 @@ export function loadWorkspace(fallbackDocument: string): WorkspaceState { } catch { return { version: 1, - document: localStorage.getItem("margin.document") ?? fallbackDocument, + document: legacyDocument ?? fallbackDocument, fileName: "the-shape-of-services.md", findings: [], findingThreads: {}, @@ -64,6 +63,15 @@ export function loadWorkspace(fallbackDocument: string): WorkspaceState { } } +export function loadWorkspace(fallbackDocument: string): WorkspaceState { + return parseLegacyWorkspace( + localStorage.getItem(LEGACY_WORKSPACE_KEY), + fallbackDocument, + localStorage.getItem("margin.document"), + ); +} + +/** @deprecated Legacy rollback writer. The active application persists through src/persistence/. */ export function saveWorkspace(state: WorkspaceState) { - localStorage.setItem(WORKSPACE_KEY, JSON.stringify(state)); + localStorage.setItem(LEGACY_WORKSPACE_KEY, JSON.stringify(state)); } diff --git a/margin/src/persistence/db.ts b/margin/src/persistence/db.ts new file mode 100644 index 0000000..14718dc --- /dev/null +++ b/margin/src/persistence/db.ts @@ -0,0 +1,38 @@ +import { openDB, type DBSchema, type IDBPDatabase } from "idb"; +import type { DocumentCompanionState, DocumentRecord } from "../types"; +import type { PersistenceSetting } from "./types"; + +export const DEFAULT_DATABASE_NAME = "margin.editorial.v1"; + +interface MarginDatabase extends DBSchema { + documents: { + key: string; + value: DocumentRecord; + indexes: { + fingerprint: string; + lastOpenedAt: string; + }; + }; + documentState: { + key: string; + value: DocumentCompanionState; + }; + settings: { + key: string; + value: PersistenceSetting; + }; +} + +export function openMarginDatabase(name = DEFAULT_DATABASE_NAME): Promise> { + return openDB(name, 1, { + upgrade(database) { + const documents = database.createObjectStore("documents", { keyPath: "id" }); + documents.createIndex("fingerprint", "fingerprint"); + documents.createIndex("lastOpenedAt", "lastOpenedAt"); + database.createObjectStore("documentState", { keyPath: "documentId" }); + database.createObjectStore("settings", { keyPath: "key" }); + }, + }); +} + +export type MarginDatabaseConnection = IDBPDatabase; diff --git a/margin/src/persistence/documents.ts b/margin/src/persistence/documents.ts new file mode 100644 index 0000000..37fcb21 --- /dev/null +++ b/margin/src/persistence/documents.ts @@ -0,0 +1,127 @@ +import type { DocumentCompanionState, DocumentRecord, WorkspaceState } from "../types"; + +export function normalizeDocumentContent(content: string) { + return content.replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "").trimEnd(); +} + +export function stableHash(value: string) { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `${value.length}-${(hash >>> 0).toString(36)}`; +} + +export function contentHash(content: string) { + return stableHash(normalizeDocumentContent(content)); +} + +export function documentFingerprint(fileName: string, content: string) { + return stableHash(`${fileName.trim().toLocaleLowerCase()}\n${normalizeDocumentContent(content)}`); +} + +export function titleFromDocument(content: string, fileName: string) { + return content.match(/^#\s+(.+)$/m)?.[1]?.trim() + || fileName.replace(/\.(md|markdown|txt)$/i, "").trim() + || "Untitled"; +} + +export function emptyCompanionState(documentId: string): DocumentCompanionState { + return { + documentId, + findings: [], + findingThreads: {}, + dismissedFindingIds: [], + sources: [], + research: null, + researchMode: null, + activeDirective: null, + ambientPaused: false, + documentReview: null, + documentReviewMode: null, + documentReviewSignature: "", + documentBrief: null, + argumentCorrections: [], + revisionProposals: [], + assets: [], + }; +} + +export function companionFromLegacy(documentId: string, workspace: WorkspaceState): DocumentCompanionState { + return { + documentId, + findings: workspace.findings, + findingThreads: workspace.findingThreads, + dismissedFindingIds: workspace.dismissedFindingIds, + sources: workspace.sources, + research: workspace.research, + researchMode: workspace.researchMode, + activeDirective: workspace.activeDirective, + ambientPaused: workspace.ambientPaused, + documentReview: workspace.documentReview, + documentReviewMode: workspace.documentReviewMode, + documentReviewSignature: workspace.documentReviewSignature, + documentBrief: workspace.documentBrief, + argumentCorrections: workspace.argumentCorrections, + revisionProposals: workspace.revisionProposals, + assets: workspace.assets, + }; +} + +export function sanitizeCompanionState(documentId: string, value: unknown): DocumentCompanionState { + const empty = emptyCompanionState(documentId); + if (!value || typeof value !== "object") return empty; + const candidate = value as Partial; + const documentReview = candidate.documentReview?.argumentSpine ? candidate.documentReview : null; + return { + documentId, + findings: Array.isArray(candidate.findings) ? candidate.findings : [], + findingThreads: candidate.findingThreads && typeof candidate.findingThreads === "object" ? candidate.findingThreads : {}, + dismissedFindingIds: Array.isArray(candidate.dismissedFindingIds) ? candidate.dismissedFindingIds : [], + sources: Array.isArray(candidate.sources) ? candidate.sources : [], + research: candidate.research ?? null, + researchMode: candidate.researchMode === "live" || candidate.researchMode === "demo" ? candidate.researchMode : null, + activeDirective: candidate.activeDirective ?? null, + ambientPaused: Boolean(candidate.ambientPaused), + documentReview, + documentReviewMode: documentReview && (candidate.documentReviewMode === "live" || candidate.documentReviewMode === "demo") ? candidate.documentReviewMode : null, + documentReviewSignature: documentReview && typeof candidate.documentReviewSignature === "string" ? candidate.documentReviewSignature : "", + documentBrief: candidate.documentBrief ?? documentReview?.brief ?? null, + argumentCorrections: Array.isArray(candidate.argumentCorrections) ? candidate.argumentCorrections : [], + revisionProposals: Array.isArray(candidate.revisionProposals) ? candidate.revisionProposals : [], + assets: Array.isArray(candidate.assets) ? candidate.assets : [], + }; +} + +export function sanitizeDocumentRecord(value: unknown): DocumentRecord | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if (typeof candidate.id !== "string" || typeof candidate.fileName !== "string" || typeof candidate.content !== "string") return null; + const now = new Date(0).toISOString(); + return { + id: candidate.id, + fileName: candidate.fileName, + title: typeof candidate.title === "string" ? candidate.title : titleFromDocument(candidate.content, candidate.fileName), + content: candidate.content, + contentHash: typeof candidate.contentHash === "string" ? candidate.contentHash : contentHash(candidate.content), + fingerprint: typeof candidate.fingerprint === "string" ? candidate.fingerprint : documentFingerprint(candidate.fileName, candidate.content), + origin: candidate.origin === "file" || candidate.origin === "example" || candidate.origin === "legacy" ? candidate.origin : "new", + createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : now, + updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : now, + lastOpenedAt: typeof candidate.lastOpenedAt === "string" ? candidate.lastOpenedAt : now, + }; +} + +export function updateDocumentRecord(record: DocumentRecord, input: { fileName: string; content: string; now: string; lastOpened?: boolean }): DocumentRecord { + return { + ...record, + fileName: input.fileName, + title: titleFromDocument(input.content, input.fileName), + content: input.content, + contentHash: contentHash(input.content), + fingerprint: documentFingerprint(input.fileName, input.content), + updatedAt: input.now, + lastOpenedAt: input.lastOpened ? input.now : record.lastOpenedAt, + }; +} diff --git a/margin/src/persistence/index.ts b/margin/src/persistence/index.ts new file mode 100644 index 0000000..3b257fd --- /dev/null +++ b/margin/src/persistence/index.ts @@ -0,0 +1,137 @@ +import type { DocumentCompanionState, DocumentRecord, DocumentSession } from "../types"; +import { contentHash, documentFingerprint, emptyCompanionState, sanitizeCompanionState, sanitizeDocumentRecord, titleFromDocument, updateDocumentRecord } from "./documents"; +import { openMarginDatabase } from "./db"; +import { migrateLegacyWorkspace } from "./migrations"; +import type { CreateDocumentInput, DocumentPersistence, DocumentPersistenceOptions, OpenDocumentInput } from "./types"; + +function fallbackId() { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `document-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function createDocumentPersistence(options: DocumentPersistenceOptions = {}): DocumentPersistence { + const now = options.now ?? (() => new Date().toISOString()); + const createId = options.createId ?? fallbackId; + const storage = options.storage ?? localStorage; + const databasePromise = openMarginDatabase(options.dbName); + const handleIds = new WeakMap(); + let initializationPromise: Promise | null = null; + + async function loadSession(documentId: string): Promise { + const database = await databasePromise; + const rawRecord = await database.get("documents", documentId); + const record = sanitizeDocumentRecord(rawRecord); + if (!record) return null; + const rawCompanion = await database.get("documentState", documentId); + return { record, companion: sanitizeCompanionState(documentId, rawCompanion) }; + } + + async function saveSession(record: DocumentRecord, companion: DocumentCompanionState) { + const database = await databasePromise; + const safeRecord = sanitizeDocumentRecord(record); + if (!safeRecord || safeRecord.id !== companion.documentId) throw new Error("Document session identity did not match."); + const transaction = database.transaction(["documents", "documentState", "settings"], "readwrite"); + await transaction.objectStore("documents").put(safeRecord); + await transaction.objectStore("documentState").put(sanitizeCompanionState(safeRecord.id, companion)); + await transaction.objectStore("settings").put({ key: "activeDocumentId", value: safeRecord.id }); + await transaction.done; + } + + async function createDocument(input: CreateDocumentInput): Promise { + const database = await databasePromise; + if (input.preferredId) { + const existing = await loadSession(input.preferredId); + if (existing) { + const record = updateDocumentRecord(existing.record, { fileName: existing.record.fileName, content: existing.record.content, now: now(), lastOpened: true }); + await saveSession(record, existing.companion); + return { record, companion: existing.companion }; + } + } + const timestamp = now(); + const id = input.preferredId ?? createId(); + const record: DocumentRecord = { + id, + fileName: input.fileName, + title: titleFromDocument(input.content, input.fileName), + content: input.content, + contentHash: contentHash(input.content), + fingerprint: documentFingerprint(input.fileName, input.content), + origin: input.origin, + createdAt: timestamp, + updatedAt: timestamp, + lastOpenedAt: timestamp, + }; + const companion = emptyCompanionState(id); + await saveSession(record, companion); + return { record, companion }; + } + + async function openDocument(input: OpenDocumentInput): Promise { + if (input.handle) { + const knownId = handleIds.get(input.handle); + if (knownId) { + const known = await loadSession(knownId); + if (known) { + const record = updateDocumentRecord(known.record, { fileName: input.fileName, content: input.content, now: now(), lastOpened: true }); + await saveSession(record, known.companion); + return { record, companion: known.companion }; + } + } + } + + const database = await databasePromise; + const fingerprint = documentFingerprint(input.fileName, input.content); + const matches = await database.getAllFromIndex("documents", "fingerprint", fingerprint); + const existing = matches.map(sanitizeDocumentRecord).filter((record): record is DocumentRecord => Boolean(record)) + .sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt))[0]; + if (existing) { + const companion = sanitizeCompanionState(existing.id, await database.get("documentState", existing.id)); + const record = updateDocumentRecord(existing, { fileName: input.fileName, content: input.content, now: now(), lastOpened: true }); + await saveSession(record, companion); + if (input.handle) handleIds.set(input.handle, record.id); + return { record, companion }; + } + + const created = await createDocument({ fileName: input.fileName, content: input.content, origin: "file" }); + if (input.handle) handleIds.set(input.handle, created.record.id); + return created; + } + + return { + initialize(fallbackDocument, fallbackFileName = "the-shape-of-services.md") { + initializationPromise ??= (async () => { + const database = await databasePromise; + const migrated = await migrateLegacyWorkspace({ database, storage, fallbackDocument, now: now(), createId }); + if (migrated) return migrated; + const activeId = (await database.get("settings", "activeDocumentId"))?.value; + if (activeId) { + const active = await loadSession(activeId); + if (active) return active; + } + const documents = (await database.getAll("documents")).map(sanitizeDocumentRecord).filter((record): record is DocumentRecord => Boolean(record)); + const mostRecent = documents.sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt))[0]; + if (mostRecent) return (await loadSession(mostRecent.id))!; + return createDocument({ fileName: fallbackFileName, content: fallbackDocument, origin: "new" }); + })(); + return initializationPromise; + }, + createDocument, + openDocument, + openExample: createDocument, + loadSession, + saveSession, + async listDocuments() { + const database = await databasePromise; + return (await database.getAll("documents")) + .map(sanitizeDocumentRecord) + .filter((record): record is DocumentRecord => Boolean(record)) + .sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt)); + }, + associateHandle(handle, documentId) { handleIds.set(handle, documentId); }, + close() { void databasePromise.then((database) => database.close()); }, + }; +} + +export { emptyCompanionState, updateDocumentRecord } from "./documents"; +export type { DocumentPersistence } from "./types"; diff --git a/margin/src/persistence/migrations.ts b/margin/src/persistence/migrations.ts new file mode 100644 index 0000000..c32f293 --- /dev/null +++ b/margin/src/persistence/migrations.ts @@ -0,0 +1,58 @@ +import type { DocumentSession, WorkspaceState } from "../types"; +import { LEGACY_WORKSPACE_KEY, parseLegacyWorkspace as parseLegacyWorkspaceRecord } from "../persistence"; +import { companionFromLegacy, contentHash, documentFingerprint, titleFromDocument } from "./documents"; +import type { MarginDatabaseConnection } from "./db"; + +export const LEGACY_MIGRATION_SETTING = "legacyWorkspaceV1Migrated"; + +function parseLegacyWorkspace(storage: Pick, fallbackDocument: string): WorkspaceState | null { + const raw = storage.getItem(LEGACY_WORKSPACE_KEY); + if (!raw) return null; + try { + const value = JSON.parse(raw) as Partial; + if (value.version !== 1 || typeof value.document !== "string") return null; + } catch { + return null; + } + + return parseLegacyWorkspaceRecord(raw, fallbackDocument, storage.getItem("margin.document")); +} + +export async function migrateLegacyWorkspace(input: { + database: MarginDatabaseConnection; + storage: Pick; + fallbackDocument: string; + now: string; + createId: () => string; +}): Promise { + const { database, storage, fallbackDocument, now, createId } = input; + const migration = await database.get("settings", LEGACY_MIGRATION_SETTING); + if (migration?.value === "complete") return null; + + const workspace = parseLegacyWorkspace(storage, fallbackDocument); + let migrated: DocumentSession | null = null; + const transaction = database.transaction(["documents", "documentState", "settings"], "readwrite"); + if (workspace) { + const id = createId(); + const record = { + id, + fileName: workspace.fileName, + title: titleFromDocument(workspace.document, workspace.fileName), + content: workspace.document, + contentHash: contentHash(workspace.document), + fingerprint: documentFingerprint(workspace.fileName, workspace.document), + origin: "legacy" as const, + createdAt: now, + updatedAt: now, + lastOpenedAt: now, + }; + const companion = companionFromLegacy(id, workspace); + await transaction.objectStore("documents").put(record); + await transaction.objectStore("documentState").put(companion); + await transaction.objectStore("settings").put({ key: "activeDocumentId", value: id }); + migrated = { record, companion }; + } + await transaction.objectStore("settings").put({ key: LEGACY_MIGRATION_SETTING, value: "complete" }); + await transaction.done; + return migrated; +} diff --git a/margin/src/persistence/types.ts b/margin/src/persistence/types.ts new file mode 100644 index 0000000..1dd128f --- /dev/null +++ b/margin/src/persistence/types.ts @@ -0,0 +1,40 @@ +import type { DocumentCompanionState, DocumentRecord, DocumentSession } from "../types"; + +export type PersistenceSettingKey = "activeDocumentId" | "legacyWorkspaceV1Migrated"; + +export interface PersistenceSetting { + key: PersistenceSettingKey; + value: string; +} + +export interface DocumentPersistenceOptions { + dbName?: string; + storage?: Pick; + now?: () => string; + createId?: () => string; +} + +export interface CreateDocumentInput { + fileName: string; + content: string; + origin: DocumentRecord["origin"]; + preferredId?: string; +} + +export interface OpenDocumentInput { + fileName: string; + content: string; + handle?: object | null; +} + +export interface DocumentPersistence { + initialize(fallbackDocument: string, fallbackFileName?: string): Promise; + createDocument(input: CreateDocumentInput): Promise; + openDocument(input: OpenDocumentInput): Promise; + openExample(input: CreateDocumentInput & { preferredId: string }): Promise; + loadSession(documentId: string): Promise; + saveSession(record: DocumentRecord, companion: DocumentCompanionState): Promise; + listDocuments(): Promise; + associateHandle(handle: object, documentId: string): void; + close(): void; +} diff --git a/margin/src/styles.css b/margin/src/styles.css index 4989b08..467f754 100644 --- a/margin/src/styles.css +++ b/margin/src/styles.css @@ -17,6 +17,9 @@ html, body, #root { width: 100%; height: 100%; margin: 0; } button { font: inherit; } button:focus-visible { outline: 2px solid #6f8a92; outline-offset: 2px; } +.initialization-boundary { min-height: 100vh; display: flex; align-items: center; justify-content: center; gap: 10px; color: var(--muted); background: var(--paper); } +.initialization-boundary p { max-width: 34rem; margin: 0; } + .app-shell { height: 100%; min-height: 560px; display: flex; flex-direction: column; background: #ebe9e3; } .topbar { height: 50px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 18px; border-bottom: 1px solid rgba(48, 53, 52, .11); background: rgba(248, 247, 243, .93); backdrop-filter: blur(16px); z-index: 20; } .wordmark { display: flex; align-items: center; gap: 6px; font-family: var(--app-sans); font-size: 15px; font-weight: 600; letter-spacing: -.018em; } diff --git a/margin/src/types.ts b/margin/src/types.ts index cee3821..bfb23a9 100644 --- a/margin/src/types.ts +++ b/margin/src/types.ts @@ -293,3 +293,41 @@ export interface WorkspaceState { revisionProposals: RevisionProposal[]; assets: DocumentAsset[]; } + +export interface DocumentRecord { + id: string; + fileName: string; + title: string; + /** Local working snapshot used to restore the editor after a browser reload. */ + content: string; + contentHash: string; + fingerprint: string; + origin: "new" | "file" | "example" | "legacy"; + createdAt: string; + updatedAt: string; + lastOpenedAt: string; +} + +export interface DocumentCompanionState { + documentId: string; + findings: Finding[]; + findingThreads: Record; + dismissedFindingIds: string[]; + sources: Source[]; + research: ResearchResult | null; + researchMode: "live" | "demo" | null; + activeDirective: Directive | null; + ambientPaused: boolean; + documentReview: DocumentReviewResult | null; + documentReviewMode: "live" | "demo" | null; + documentReviewSignature: string; + documentBrief: DocumentBrief | null; + argumentCorrections: ArgumentCorrection[]; + revisionProposals: RevisionProposal[]; + assets: DocumentAsset[]; +} + +export interface DocumentSession { + record: DocumentRecord; + companion: DocumentCompanionState; +}