From 082dfa7666d44e847702b856bae454d98cc7a320 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:00:01 -0400 Subject: [PATCH 01/49] feat(documents): add HTML-based AI editing Replace Markdown edits with bounded HTML reads, structural edits, and durable Yjs receipts. Keep workspace result counts server-controlled. --- package.json | 1 + pnpm-lock.yaml | 151 +++++- .../workspaces/ai/workspace-citations.test.ts | 12 +- .../ai/workspace-tool-result-adapters.ts | 26 +- .../workspace-content-contract.test.ts | 6 +- .../content/workspace-content-contract.ts | 10 +- .../content/workspace-content-cursor.ts | 2 +- .../content/workspace-content-reader.test.ts | 74 +-- .../content/workspace-content-reader.ts | 55 +- .../content/workspace-read-references.test.ts | 6 +- .../documents/document-ai-edits.test.ts | 109 ++++ .../workspaces/documents/document-ai-edits.ts | 265 ++++++++++ .../documents/document-ai-html.test.ts | 71 +++ .../workspaces/documents/document-ai-html.ts | 220 ++++++++ .../documents/document-ai-html.worker.test.ts | 19 + .../documents/document-edit-receipt.ts | 40 ++ .../document-edit-review-context.tsx | 61 +++ .../document-edit-review-extension.ts | 207 ++++++++ .../document-edit-review-functions.ts | 87 ++++ .../documents/document-edit-review-queries.ts | 41 ++ .../documents/document-html-chunk.ts | 64 +++ .../documents/document-markdown-chunk.test.ts | 41 -- .../documents/document-markdown-chunk.ts | 93 ---- .../documents/document-markdown-edits.ts | 472 ------------------ .../workspaces/documents/document-session.ts | 386 ++++++++++---- .../document-yjs-persistence.test.ts | 66 +++ .../documents/document-yjs-persistence.ts | 106 ++++ .../workspaces/documents/tiptap-schema.ts | 37 +- .../use-document-collaboration-session.ts | 6 +- .../use-document-edit-review-overlay.ts | 106 ++++ .../workspaces/operations/create-items.ts | 12 +- .../workspaces/operations/edit-item.ts | 22 +- .../operations/workspace-tool-definitions.ts | 8 +- .../operations/workspace-tool-schemas.ts | 68 +-- 34 files changed, 2118 insertions(+), 832 deletions(-) create mode 100644 src/features/workspaces/documents/document-ai-edits.test.ts create mode 100644 src/features/workspaces/documents/document-ai-edits.ts create mode 100644 src/features/workspaces/documents/document-ai-html.test.ts create mode 100644 src/features/workspaces/documents/document-ai-html.ts create mode 100644 src/features/workspaces/documents/document-ai-html.worker.test.ts create mode 100644 src/features/workspaces/documents/document-edit-receipt.ts create mode 100644 src/features/workspaces/documents/document-edit-review-context.tsx create mode 100644 src/features/workspaces/documents/document-edit-review-extension.ts create mode 100644 src/features/workspaces/documents/document-edit-review-functions.ts create mode 100644 src/features/workspaces/documents/document-edit-review-queries.ts create mode 100644 src/features/workspaces/documents/document-html-chunk.ts delete mode 100644 src/features/workspaces/documents/document-markdown-chunk.test.ts delete mode 100644 src/features/workspaces/documents/document-markdown-chunk.ts delete mode 100644 src/features/workspaces/documents/document-markdown-edits.ts create mode 100644 src/features/workspaces/documents/document-yjs-persistence.test.ts create mode 100644 src/features/workspaces/documents/document-yjs-persistence.ts create mode 100644 src/features/workspaces/documents/use-document-edit-review-overlay.ts diff --git a/package.json b/package.json index 5a1a56d9..080eeec2 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "drizzle-orm": "^0.45.1", "hast-util-to-string": "^3.0.1", "katex": "^0.17.0", + "linkedom": "0.18.13", "lucide-react": "^1.27.0", "matter-js": "^0.20.0", "mermaid": "11.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fd97e4f..4295166b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: katex: specifier: ^0.17.0 version: 0.17.0 + linkedom: + specifier: 0.18.13 + version: 0.18.13 lucide-react: specifier: ^1.27.0 version: 1.27.0(react@19.2.8) @@ -5183,6 +5186,10 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -5464,15 +5471,26 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + cssstyle@6.2.0: resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} engines: {node: '>=20'} @@ -5753,9 +5771,38 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dot-prop@10.1.0: resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} engines: {node: '>=20'} @@ -5895,6 +5942,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -6428,12 +6479,18 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -6943,6 +7000,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} @@ -7373,6 +7439,10 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8505,6 +8575,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -13004,6 +13077,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@2.0.0: {} + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 @@ -13267,14 +13342,26 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 optional: true + css-what@8.0.0: {} + cssesc@3.0.0: {} + cssom@0.5.0: {} + cssstyle@6.2.0: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -13566,10 +13653,46 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dot-prop@10.1.0: dependencies: type-fest: 5.7.0 @@ -13628,12 +13751,13 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@4.5.0: {} + entities@6.0.1: {} entities@7.0.1: {} - entities@8.0.0: - optional: true + entities@8.0.0: {} env-paths@2.2.1: {} @@ -14346,10 +14470,19 @@ snapshots: - '@noble/hashes' optional: true + html-escaper@3.0.3: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -14788,6 +14921,14 @@ snapshots: lines-and-columns@1.2.4: {} + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + linkifyjs@4.3.3: {} locate-character@3.0.0: {} @@ -15461,6 +15602,10 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -16980,6 +17125,8 @@ snapshots: ufo@1.6.4: {} + uhyphen@0.2.0: {} + uint8array-extras@1.5.0: {} unbash@4.0.1: {} diff --git a/src/features/workspaces/ai/workspace-citations.test.ts b/src/features/workspaces/ai/workspace-citations.test.ts index 4e9e1472..38cc7680 100644 --- a/src/features/workspaces/ai/workspace-citations.test.ts +++ b/src/features/workspaces/ai/workspace-citations.test.ts @@ -68,14 +68,14 @@ describe("workspace citations", () => { references: [first], results: [ { - content: "# Notes", - format: "markdown", + content: '
line ${index + 1}
`).join(""); + const session = createDocumentSession({ html, revision: "revision-1" }); const read = createReader({ bucket: {} as R2Bucket, getDocumentSession: () => session, @@ -36,8 +41,8 @@ describe("WorkspaceContentReader", () => { const [first] = await read([{ mode: "start", path: "/Notes" }]); expect(first).toMatchObject({ - format: "markdown", - location: { kind: "lines", startLine: 1, totalLines: 20_000 }, + format: "html", + location: { kind: "blocks", startBlock: 1, totalBlocks: 20_000 }, path: "/Notes", status: "ready", type: "document", @@ -45,7 +50,7 @@ describe("WorkspaceContentReader", () => { if ( !first || first.status !== "ready" || - first.location.kind !== "lines" || + first.location.kind !== "blocks" || !first.nextCursor ) { throw new Error("Expected the first document chunk to have a continuation cursor."); @@ -53,20 +58,20 @@ describe("WorkspaceContentReader", () => { const [second] = await read([{ cursor: first.nextCursor, mode: "continue", path: "/Notes" }]); expect(second).toMatchObject({ - location: { kind: "lines" }, + location: { kind: "blocks" }, path: "/Notes", status: "ready", type: "document", }); - if (!second || second.status !== "ready" || second.location.kind !== "lines") { + if (!second || second.status !== "ready" || second.location.kind !== "blocks") { throw new Error("Expected a continued document chunk."); } - expect(second.location.startLine).toBeGreaterThan(first.location.startLine); + expect(second.location.startBlock).toBeGreaterThan(first.location.startBlock); }); it("rejects continuation when the live document revision changed", async () => { const session = createDocumentSession({ - markdown: "a\n".repeat(40_000), + html: "a
".repeat(40_000), revision: "revision-1", }); const read = createReader({ @@ -79,17 +84,17 @@ describe("WorkspaceContentReader", () => { throw new Error("Expected a continuation cursor."); } - session.readMarkdownChunk = vi.fn(async () => ({ status: "content_changed" })); + session.readHtmlChunk = vi.fn(async () => ({ status: "content_changed" })); await expect( read([{ cursor: first.nextCursor, mode: "continue", path: "/Notes" }]), ).resolves.toEqual([{ code: "content_changed", path: "/Notes", status: "failed" }]); }); - it("preserves document whitespace across chunk boundaries", async () => { - const markdown = `heading \n\n indented code\n${"x".repeat(64_000)}\n`; + it("keeps document HTML split on top-level block boundaries", async () => { + const html = `${"x".repeat(64_000)}Tail
`; const read = createReader({ bucket: {} as R2Bucket, - getDocumentSession: () => createDocumentSession({ markdown, revision: "revision-1" }), + getDocumentSession: () => createDocumentSession({ html, revision: "revision-1" }), kernel: createKernel(), }); @@ -107,13 +112,19 @@ describe("WorkspaceContentReader", () => { } request = { cursor: result.nextCursor, mode: "continue", path: "/Notes" }; } - expect(contents.join("")).toBe(markdown); + expect(contents).toHaveLength(3); + expect(contents[0]).toMatch( + /^");
+ expect(contents[2]).toContain(">Tail");
});
it("rejects a nonzero continuation offset for an empty document", async () => {
const read = createReader({
bucket: {} as R2Bucket,
- getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }),
+ getDocumentSession: () => createDocumentSession({ html: "", revision: "revision-1" }),
kernel: createKernel(),
});
const cursor = encodeWorkspaceContentCursor({
@@ -121,7 +132,7 @@ describe("WorkspaceContentReader", () => {
offset: 1,
path: "/Notes",
revision: "revision-1",
- version: 2,
+ version: 3,
});
await expect(read([{ cursor, mode: "continue", path: "/Notes" }])).resolves.toEqual([
@@ -132,7 +143,7 @@ describe("WorkspaceContentReader", () => {
it("rejects a continuation cursor issued for another path", async () => {
const read = createReader({
bucket: {} as R2Bucket,
- getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }),
+ getDocumentSession: () => createDocumentSession({ html: "", revision: "revision-1" }),
kernel: createKernel(),
});
const cursor = encodeWorkspaceContentCursor({
@@ -140,7 +151,7 @@ describe("WorkspaceContentReader", () => {
offset: 0,
path: "/Other",
revision: "revision-1",
- version: 2,
+ version: 3,
});
await expect(read([{ cursor, mode: "continue", path: "/Notes" }])).resolves.toEqual([
@@ -152,7 +163,10 @@ describe("WorkspaceContentReader", () => {
const read = createReader({
bucket: {} as R2Bucket,
getDocumentSession: () =>
- createDocumentSession({ markdown: "😀".repeat(40_000), revision: "revision-1" }),
+ createDocumentSession({
+ html: `${"😀".repeat(40_000)}
`,
+ revision: "revision-1",
+ }),
kernel: createKernel(),
});
const requests = Array.from({ length: 20 }, (_, index) => ({
@@ -161,9 +175,9 @@ describe("WorkspaceContentReader", () => {
}));
const results = await read(requests);
- expect(results.filter((result) => result.status === "ready")).toHaveLength(16);
- expect(results.slice(16)).toEqual(
- requests.slice(16).map((request) => ({
+ expect(results.filter((result) => result.status === "ready")).toHaveLength(1);
+ expect(results.slice(1)).toEqual(
+ requests.slice(1).map((request) => ({
code: "read_budget_exceeded",
path: request.path,
status: "failed",
@@ -183,7 +197,7 @@ describe("WorkspaceContentReader", () => {
);
const read = createReader({
bucket: {} as R2Bucket,
- getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }),
+ getDocumentSession: () => createDocumentSession({ html: "", revision: "revision-1" }),
kernel,
});
@@ -201,15 +215,17 @@ describe("WorkspaceContentReader", () => {
});
});
-function createDocumentSession(snapshot: { markdown: string; revision: string }) {
+function createDocumentSession(input: { html: string; revision: string }) {
+ const document = ensureTiptapDocumentAiRefs(parseDocumentAiHtml(input.html)).document;
+ const documentNode = getTiptapDocumentSchema().nodeFromJSON(document);
return {
- readMarkdownChunk: vi.fn(async ({ expectedRevision, offset }) => {
- if (expectedRevision && expectedRevision !== snapshot.revision) {
+ readHtmlChunk: vi.fn(async ({ expectedRevision, offset }) => {
+ if (expectedRevision && expectedRevision !== input.revision) {
return { status: "content_changed" as const };
}
- const chunk = createDocumentMarkdownSnapshot(snapshot.markdown).readChunk(offset);
+ const chunk = await readDocumentHtmlChunk(documentNode, offset);
return chunk
- ? { ...chunk, revision: snapshot.revision, status: "ready" as const }
+ ? { ...chunk, revision: input.revision, status: "ready" as const }
: { status: "invalid_offset" as const };
}),
};
diff --git a/src/features/workspaces/content/workspace-content-reader.ts b/src/features/workspaces/content/workspace-content-reader.ts
index fb0959e3..d8b625f3 100644
--- a/src/features/workspaces/content/workspace-content-reader.ts
+++ b/src/features/workspaces/content/workspace-content-reader.ts
@@ -4,9 +4,9 @@ import type {
WorkspaceContentReadResult,
} from "#/features/workspaces/content/workspace-content-contract";
import type {
- DocumentMarkdownChunkReadInput,
- DocumentMarkdownChunkReadResult,
-} from "#/features/workspaces/documents/document-markdown-chunk";
+ DocumentHtmlChunkReadInput,
+ DocumentHtmlChunkReadResult,
+} from "#/features/workspaces/documents/document-html-chunk";
import { readWorkspacePageProjection } from "#/features/workspaces/extraction/workspace-page-projection";
import {
resolveWorkspaceProjectionReadiness,
@@ -21,18 +21,18 @@ import {
encodeWorkspaceContentCursor,
} from "#/features/workspaces/content/workspace-content-cursor";
-const maxWorkspaceContentBatchBytes = 2 * 1024 * 1024 + 64 * 1024;
+// A tool call may read several paths, but their combined bodies must leave room
+// for the conversation, reasoning, and response in the smallest model window.
+const maxWorkspaceContentBatchBytes = 256 * 1024;
interface DocumentContentReader {
- readMarkdownChunk(
- input: DocumentMarkdownChunkReadInput,
- ): Promise;
+ readHtmlChunk(input: DocumentHtmlChunkReadInput): Promise;
}
interface PendingReadyResult {
item: WorkspaceItemSummary;
read: Extract;
- relations: Awaited>;
+ relations: ReturnType;
}
export async function readWorkspaceContent(input: {
@@ -49,6 +49,7 @@ export async function readWorkspaceContent(input: {
const results: WorkspaceContentReadResult[] = [];
const readyResults: PendingReadyResult[] = [];
let returnedContentBytes = 0;
+ let readBudgetExhausted = false;
// Reads stay ordered so each body is consumed before the shared byte budget advances.
for (const [index, resolution] of resolutions.entries()) {
@@ -72,6 +73,16 @@ export async function readWorkspaceContent(input: {
results.push({ code: "path_is_folder", path: resolution.path, status: "failed" });
continue;
}
+ const readBudgetFailure = {
+ code: "read_budget_exceeded" as const,
+ path: resolution.path,
+ status: "failed" as const,
+ ...(resolution.item.type === "file" ? { type: "file" as const } : {}),
+ };
+ if (readBudgetExhausted) {
+ results.push(readBudgetFailure);
+ continue;
+ }
try {
const read = await readWorkspaceItem({
@@ -86,12 +97,8 @@ export async function readWorkspaceContent(input: {
}
const contentBytes = encoder.encode(read.content).byteLength;
if (returnedContentBytes + contentBytes > maxWorkspaceContentBatchBytes) {
- results.push({
- code: "read_budget_exceeded",
- path: resolution.path,
- status: "failed",
- ...(resolution.item.type === "file" ? { type: "file" as const } : {}),
- });
+ readBudgetExhausted = true;
+ results.push(readBudgetFailure);
continue;
}
returnedContentBytes += contentBytes;
@@ -99,7 +106,7 @@ export async function readWorkspaceContent(input: {
const pending = {
item: resolution.item,
read,
- relations: await input.kernel.listItemRelations({ itemId: resolution.item.id }),
+ relations: input.kernel.listItemRelations({ itemId: resolution.item.id }),
};
readyResults.push(pending);
results.push(read);
@@ -150,7 +157,7 @@ async function readDocument(input: {
}
const documentSession = await input.getDocumentSession(input.item.id);
- const chunk = await documentSession.readMarkdownChunk({
+ const chunk = await documentSession.readHtmlChunk({
expectedRevision: cursor?.kind === "document" ? cursor.revision : undefined,
offset: cursor?.kind === "document" ? cursor.offset : 0,
});
@@ -163,9 +170,9 @@ async function readDocument(input: {
return {
content: chunk.content,
- format: "markdown",
+ format: "html",
itemId: input.item.id,
- location: { kind: "lines", ...chunk.location },
+ location: { kind: "blocks", ...chunk.location },
...(chunk.nextOffset === undefined
? {}
: {
@@ -174,7 +181,7 @@ async function readDocument(input: {
offset: chunk.nextOffset,
path: input.path,
revision: chunk.revision,
- version: 2,
+ version: 3,
}),
}),
path: input.path,
@@ -302,8 +309,14 @@ async function attachRelationPaths(
if (readyResults.length === 0) {
return;
}
+ const resolvedResults = await Promise.all(
+ readyResults.map(async ({ relations, ...result }) => ({
+ ...result,
+ relations: await relations,
+ })),
+ );
const relatedItemIds = new Set();
- for (const result of readyResults) {
+ for (const result of resolvedResults) {
relatedItemIds.add(result.item.id);
for (const relation of result.relations) {
relatedItemIds.add(relation.fromItemId);
@@ -313,7 +326,7 @@ async function attachRelationPaths(
const itemPaths = await kernel.getItemPaths({ itemIds: Array.from(relatedItemIds) });
const pathsByItemId = new Map(itemPaths.map((item) => [item.itemId, item.path]));
- for (const result of readyResults) {
+ for (const result of resolvedResults) {
const relations = serializeWorkspaceRelations({
item: result.item,
pathsByItemId,
diff --git a/src/features/workspaces/content/workspace-read-references.test.ts b/src/features/workspaces/content/workspace-read-references.test.ts
index 185b1fa2..a7311ad9 100644
--- a/src/features/workspaces/content/workspace-read-references.test.ts
+++ b/src/features/workspaces/content/workspace-read-references.test.ts
@@ -175,10 +175,10 @@ function documentResult(): Extract<
{ status: "ready"; type: "document" }
> {
return {
- content: "# Notes",
- format: "markdown",
+ content: 'Notes
',
+ format: "html",
itemId: "document-1",
- location: { endLine: 1, kind: "lines", startLine: 1, totalLines: 1 },
+ location: { endBlock: 1, kind: "blocks", startBlock: 1, totalBlocks: 1 },
path: "/Notes",
status: "ready",
type: "document",
diff --git a/src/features/workspaces/documents/document-ai-edits.test.ts b/src/features/workspaces/documents/document-ai-edits.test.ts
new file mode 100644
index 00000000..7d6bae44
--- /dev/null
+++ b/src/features/workspaces/documents/document-ai-edits.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from "vitest";
+
+import { applyDocumentAiEdits } from "#/features/workspaces/documents/document-ai-edits";
+import {
+ ensureTiptapDocumentAiRefs,
+ parseDocumentAiHtml,
+ serializeTiptapDocumentToAiHtml,
+} from "#/features/workspaces/documents/document-ai-html";
+
+describe("document AI edits", () => {
+ it("applies consecutive structural edits while preserving the target ref", async () => {
+ const document = createDocument("Title
Before
");
+ const paragraphRef = await getRef(document, "p");
+ const result = await applyDocumentAiEdits(document, [
+ { html: "After
", op: "replace", ref: paragraphRef },
+ { html: "More
", op: "insert_after", ref: paragraphRef },
+ ]);
+
+ expect(result).toMatchObject({ applied: 2, failed: 0, status: "applied" });
+ const html = await serializeTiptapDocumentToAiHtml(result.document);
+ expect(html).toContain(`After
{
+ const document = createDocument("One
Two
");
+ const firstRef = await getRef(document, "p");
+ const result = await applyDocumentAiEdits(document, [
+ { html: "Updated
", op: "replace", ref: firstRef },
+ { op: "delete", ref: "b_missingref00.r_0000000000" },
+ ]);
+
+ expect(result).toMatchObject({
+ applied: 1,
+ failed: 1,
+ failures: [{ code: "ref_not_found", index: 1 }],
+ status: "partial",
+ });
+ expect(await serializeTiptapDocumentToAiHtml(result.document)).toContain(">Updated");
+ });
+
+ it("rejects a ref after that block changed in another edit call", async () => {
+ const document = createDocument("Before
");
+ const originalRef = await getRef(document, "p");
+ const first = await applyDocumentAiEdits(document, [
+ { html: "Changed
", op: "replace", ref: originalRef },
+ ]);
+ const stale = await applyDocumentAiEdits(first.document, [
+ { html: "Overwritten
", op: "replace", ref: originalRef },
+ ]);
+
+ expect(stale).toMatchObject({
+ applied: 0,
+ failed: 1,
+ failures: [{ code: "stale_target", index: 0 }],
+ status: "failed",
+ });
+ });
+
+ it("supports whole-document rewrites without carrying old block identities by position", async () => {
+ const document = createDocument("Before
");
+ const originalRef = stableRef(await getRef(document, "p"));
+ const rewritten = await applyDocumentAiEdits(document, [
+ { html: "New
Document
", op: "replace_all" },
+ ]);
+ const noOp = await applyDocumentAiEdits(rewritten.document, [
+ { html: "New
Document
", op: "replace_all" },
+ ]);
+
+ expect(rewritten).toMatchObject({ applied: 1, failed: 0, status: "applied" });
+ expect(noOp).toMatchObject({
+ applied: 0,
+ failed: 1,
+ failures: [{ code: "no_change", index: 0 }],
+ status: "failed",
+ });
+ expect(await serializeTiptapDocumentToAiHtml(rewritten.document)).not.toContain(originalRef);
+ });
+
+ it("matches the editor's trailing paragraph after a final structural block", async () => {
+ const document = createDocument("Before
");
+ const rewritten = await applyDocumentAiEdits(document, [
+ { html: "- After
", op: "replace_all" },
+ ]);
+
+ expect(rewritten.document.content?.at(-1)).toMatchObject({ type: "paragraph" });
+ expect(await serializeTiptapDocumentToAiHtml(rewritten.document)).toMatch(
+ /<\/ul><\/p>$/,
+ );
+ });
+});
+
+function createDocument(html: string) {
+ return ensureTiptapDocumentAiRefs(parseDocumentAiHtml(html)).document;
+}
+
+async function getRef(document: ReturnType, tagName: string) {
+ const match = (await serializeTiptapDocumentToAiHtml(document)).match(
+ new RegExp(`<${tagName} data-ref="([^"]+)"`),
+ );
+ if (!match?.[1]) {
+ throw new Error(`Expected ${tagName} ref.`);
+ }
+ return match[1];
+}
+
+function stableRef(ref: string) {
+ return ref.split(".r_")[0];
+}
diff --git a/src/features/workspaces/documents/document-ai-edits.ts b/src/features/workspaces/documents/document-ai-edits.ts
new file mode 100644
index 00000000..a9707632
--- /dev/null
+++ b/src/features/workspaces/documents/document-ai-edits.ts
@@ -0,0 +1,265 @@
+import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { z } from "zod";
+
+import {
+ createDocumentAiTargetRef,
+ createUnusedDocumentAiRef,
+ DocumentAiHtmlError,
+ parseDocumentAiHtml,
+ parseDocumentAiTargetRef,
+ readTiptapNodeAiRef,
+ withTiptapNodeAiRef,
+} from "#/features/workspaces/documents/document-ai-html";
+import {
+ coerceTiptapDocumentJson,
+ type TiptapDocumentJson,
+} from "#/features/workspaces/documents/tiptap-document";
+import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema";
+
+const documentAiRefSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(64)
+ .describe('Exact data-ref from a recent HTML read. Put it in the "ref" field, never "target".');
+export const documentAiHtmlSchema = z
+ .string()
+ .max(512_000)
+ .describe("Schema-constrained HTML fragment. Model-supplied data-ref attributes are ignored.");
+
+export const documentAiEditSchema = z.union([
+ z.strictObject({
+ html: documentAiHtmlSchema,
+ op: z.literal("replace"),
+ ref: documentAiRefSchema,
+ }),
+ z.strictObject({
+ html: documentAiHtmlSchema,
+ op: z.literal("insert_before"),
+ ref: documentAiRefSchema,
+ }),
+ z.strictObject({
+ html: documentAiHtmlSchema,
+ op: z.literal("insert_after"),
+ ref: documentAiRefSchema,
+ }),
+ z.strictObject({
+ op: z.literal("delete"),
+ ref: documentAiRefSchema,
+ }),
+ z.strictObject({
+ html: documentAiHtmlSchema,
+ op: z.literal("replace_all"),
+ }),
+]);
+
+export const documentAiEditFailureCodes = [
+ "invalid_html",
+ "no_change",
+ "ref_not_found",
+ "stale_target",
+] as const;
+
+export type DocumentAiEdit = z.infer;
+export type DocumentAiEditFailureCode = (typeof documentAiEditFailureCodes)[number];
+export type DocumentAiEditResultStatus = "applied" | "failed" | "partial" | "rejected";
+
+export interface DocumentAiEditResult {
+ applied: number;
+ document: TiptapDocumentJson;
+ failed: number;
+ failures: { code: DocumentAiEditFailureCode; index: number }[];
+ status: Exclude;
+}
+
+export async function applyDocumentAiEdits(
+ document: TiptapDocumentJson,
+ edits: DocumentAiEdit[],
+): Promise {
+ let current = getTiptapDocumentSchema().nodeFromJSON(document);
+ // Validate every targeted edit against the document as it existed when this
+ // tool call began. That lets one ordered batch reuse a read ref while still
+ // rejecting the same ref in a later call after a human or agent changed it.
+ const requestedStableRefs = new Set(
+ edits.flatMap((edit) => {
+ if (edit.op === "replace_all") {
+ return [];
+ }
+ const stableRef = parseDocumentAiTargetRef(edit.ref);
+ return stableRef ? [stableRef] : [];
+ }),
+ );
+ const requestedTargets: Array = [];
+ current.forEach((node) => {
+ const stableRef = readTiptapNodeAiRef(node);
+ if (stableRef && requestedStableRefs.has(stableRef)) {
+ requestedTargets.push([stableRef, node]);
+ }
+ });
+ const targetRefByStableRef = new Map(
+ await Promise.all(
+ requestedTargets.map(
+ async ([stableRef, node]) => [stableRef, await createDocumentAiTargetRef(node)] as const,
+ ),
+ ),
+ );
+ const failures: DocumentAiEditResult["failures"] = [];
+ let applied = 0;
+
+ for (const [index, edit] of edits.entries()) {
+ let stableRef: string | undefined;
+ if (edit.op !== "replace_all") {
+ const parsedRef = parseDocumentAiTargetRef(edit.ref);
+ if (!parsedRef || !targetRefByStableRef.has(parsedRef)) {
+ failures.push({ code: "ref_not_found", index });
+ continue;
+ }
+ if (targetRefByStableRef.get(parsedRef) !== edit.ref) {
+ failures.push({ code: "stale_target", index });
+ continue;
+ }
+ stableRef = parsedRef;
+ }
+
+ const result = applyDocumentAiEdit(current, edit, stableRef);
+ if (result.status === "failed") {
+ failures.push({ code: result.code, index });
+ continue;
+ }
+
+ current = result.document;
+ applied++;
+ }
+
+ return {
+ applied,
+ document: coerceTiptapDocumentJson(current.toJSON()),
+ failed: failures.length,
+ failures,
+ status: applied === 0 ? "failed" : failures.length > 0 ? "partial" : "applied",
+ };
+}
+
+function applyDocumentAiEdit(
+ document: ProseMirrorNode,
+ edit: DocumentAiEdit,
+ stableRef?: string,
+):
+ | { code: DocumentAiEditFailureCode; status: "failed" }
+ | { document: ProseMirrorNode; status: "applied" } {
+ if (edit.op === "replace_all") {
+ const parsed = parseEditHtml(edit.html);
+ if (!parsed) {
+ return { code: "invalid_html", status: "failed" };
+ }
+
+ const next = createDocument(parsed);
+ return documentsHaveSameVisibleContent(document, next)
+ ? { code: "no_change", status: "failed" }
+ : { document: next, status: "applied" };
+ }
+
+ if (!stableRef) {
+ throw new Error("Targeted document edits require a validated stable ref.");
+ }
+ const targetIndex = findTargetIndex(document, stableRef);
+ if (targetIndex === -1) {
+ return { code: "ref_not_found", status: "failed" };
+ }
+
+ const children = getDocumentChildren(document);
+ if (edit.op === "delete") {
+ children.splice(targetIndex, 1);
+ } else {
+ const parsed = parseEditHtml(edit.html);
+ if (!parsed) {
+ return { code: "invalid_html", status: "failed" };
+ }
+ const targetRef = readTiptapNodeAiRef(document.child(targetIndex));
+ const inserted = assignDocumentAiRefs(parsed, edit.op === "replace" ? targetRef : null);
+
+ switch (edit.op) {
+ case "insert_after":
+ children.splice(targetIndex + 1, 0, ...inserted);
+ break;
+ case "insert_before":
+ children.splice(targetIndex, 0, ...inserted);
+ break;
+ case "replace":
+ children.splice(targetIndex, 1, ...inserted);
+ break;
+ }
+ }
+
+ const next = createDocument(children);
+ return documentsHaveSameVisibleContent(document, next)
+ ? { code: "no_change", status: "failed" }
+ : { document: next, status: "applied" };
+}
+
+function parseEditHtml(html: string) {
+ try {
+ return getDocumentChildren(getTiptapDocumentSchema().nodeFromJSON(parseDocumentAiHtml(html)));
+ } catch (error) {
+ if (error instanceof DocumentAiHtmlError) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+function createDocument(children: ProseMirrorNode[]) {
+ const schema = getTiptapDocumentSchema();
+ const editorChildren = children.length > 0 ? [...children] : [schema.nodes.paragraph.create()];
+
+ // StarterKit keeps a paragraph after a final non-paragraph block so users can
+ // place the cursor after lists, tables, and other atom-like content. Apply the
+ // same invariant before persisting an AI edit so opening the editor is not
+ // misclassified as a later human change.
+ if (editorChildren.at(-1)?.type !== schema.nodes.paragraph) {
+ editorChildren.push(schema.nodes.paragraph.create());
+ }
+
+ const content = assignDocumentAiRefs(editorChildren);
+ const document = schema.topNodeType.create(null, Fragment.fromArray(content));
+ document.check();
+ return document;
+}
+
+function assignDocumentAiRefs(children: ProseMirrorNode[], firstRef: string | null = null) {
+ const usedRefs = new Set();
+ return children.map((node, index) => {
+ const existingRef = readTiptapNodeAiRef(node);
+ const candidate = (index === 0 ? firstRef : null) || existingRef;
+ const ref =
+ candidate && !usedRefs.has(candidate) ? candidate : createUnusedDocumentAiRef(usedRefs);
+ usedRefs.add(ref);
+ return withTiptapNodeAiRef(node, ref);
+ });
+}
+
+function documentsHaveSameVisibleContent(left: ProseMirrorNode, right: ProseMirrorNode) {
+ return withoutTopLevelAiRefs(left).eq(withoutTopLevelAiRefs(right));
+}
+
+function withoutTopLevelAiRefs(document: ProseMirrorNode) {
+ return document.type.create(
+ document.attrs,
+ Fragment.fromArray(
+ getDocumentChildren(document).map((node) => withTiptapNodeAiRef(node, null)),
+ ),
+ );
+}
+
+function findTargetIndex(document: ProseMirrorNode, ref: string) {
+ for (let index = 0; index < document.childCount; index++) {
+ if (readTiptapNodeAiRef(document.child(index)) === ref) {
+ return index;
+ }
+ }
+ return -1;
+}
+
+function getDocumentChildren(document: ProseMirrorNode) {
+ return Array.from({ length: document.childCount }, (_, index) => document.child(index));
+}
diff --git a/src/features/workspaces/documents/document-ai-html.test.ts b/src/features/workspaces/documents/document-ai-html.test.ts
new file mode 100644
index 00000000..03d833a9
--- /dev/null
+++ b/src/features/workspaces/documents/document-ai-html.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ DocumentAiHtmlError,
+ ensureTiptapDocumentAiRefs,
+ parseDocumentAiHtml,
+ serializeTiptapDocumentToAiHtml,
+} from "#/features/workspaces/documents/document-ai-html";
+
+describe("document AI HTML", () => {
+ it("round-trips supported rich content through the Tiptap schema", async () => {
+ const document = ensureTiptapDocumentAiRefs(
+ parseDocumentAiHtml(
+ 'Notes
Use bold, links, and .
Done
',
+ ),
+ ).document;
+ const html = await serializeTiptapDocumentToAiHtml(document);
+
+ expect(html).toMatch(/^Notes<\/h1>/);
+ expect(html).toContain("bold");
+ expect(html).toContain('data-type="inline-math"');
+ expect(html).toContain('data-type="taskItem"');
+ expect(parseDocumentAiHtml(html)).toMatchObject({ type: "doc" });
+ });
+
+ it("ignores refs supplied in model-authored HTML", async () => {
+ const html = await serializeTiptapDocumentToAiHtml(
+ ensureTiptapDocumentAiRefs(parseDocumentAiHtml('
Hello
'))
+ .document,
+ );
+
+ expect(html).not.toContain("b_modelchosen1");
+ expect(html).toMatch(/data-ref="b_[A-Za-z0-9_-]{12}\.r_[A-Za-z0-9_-]{10}"/);
+ });
+
+ it("normalizes malformed but recoverable HTML", () => {
+ const document = parseDocumentAiHtml("Hello world
- One
- Two
");
+
+ expect(document).toMatchObject({
+ content: [
+ { type: "paragraph" },
+ { content: [{ type: "listItem" }, { type: "listItem" }], type: "bulletList" },
+ ],
+ type: "doc",
+ });
+ });
+
+ it("keeps only schema-supported attributes and safe links", async () => {
+ const document = ensureTiptapDocumentAiRefs(
+ parseDocumentAiHtml(
+ '',
+ ),
+ ).document;
+
+ const html = await serializeTiptapDocumentToAiHtml(document);
+ expect(html).not.toContain("class=");
+ expect(html).not.toContain("data-extra");
+ expect(html).not.toContain("onclick");
+ expect(html).not.toContain("javascript:");
+ expect(html).toContain(">Unsafe
");
+ });
+
+ it("rejects elements outside the supported document schema", () => {
+ expect(() => parseDocumentAiHtml("Unsupported
")).toThrow(
+ DocumentAiHtmlError,
+ );
+ expect(() => parseDocumentAiHtml("Not a math or task wrapper")).toThrow(
+ DocumentAiHtmlError,
+ );
+ });
+});
diff --git a/src/features/workspaces/documents/document-ai-html.ts b/src/features/workspaces/documents/document-ai-html.ts
new file mode 100644
index 00000000..81ac1b76
--- /dev/null
+++ b/src/features/workspaces/documents/document-ai-html.ts
@@ -0,0 +1,220 @@
+import { DOMParser, DOMSerializer, Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { parseHTML } from "linkedom";
+import { nanoid } from "nanoid";
+
+import {
+ coerceTiptapDocumentJson,
+ type TiptapDocumentJson,
+} from "#/features/workspaces/documents/tiptap-document";
+import {
+ getTiptapDocumentSchema,
+ tiptapDocumentAiRefAttribute,
+} from "#/features/workspaces/documents/tiptap-schema";
+import { sha256Base64UrlText } from "#/lib/binary";
+
+const documentAiRefPattern = /^b_[A-Za-z0-9_-]{12}$/;
+const documentAiTargetRefPattern = /^(b_[A-Za-z0-9_-]{12})\.r_[A-Za-z0-9_-]{10}$/;
+const supportedDocumentAiHtmlTags = new Set([
+ "a",
+ "b",
+ "blockquote",
+ "br",
+ "code",
+ "col",
+ "colgroup",
+ "del",
+ "div",
+ "em",
+ "h1",
+ "h2",
+ "h3",
+ "hr",
+ "i",
+ "input",
+ "label",
+ "li",
+ "mark",
+ "ol",
+ "p",
+ "pre",
+ "s",
+ "span",
+ "strike",
+ "strong",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "u",
+ "ul",
+]);
+
+export class DocumentAiHtmlError extends Error {}
+
+export function parseDocumentAiHtml(html: string): TiptapDocumentJson {
+ const htmlDocument = createHtmlDocument();
+ htmlDocument.body.innerHTML = html;
+ validateDocumentAiHtml(htmlDocument.body);
+
+ for (const element of htmlDocument.body.querySelectorAll("[data-ref]")) {
+ element.removeAttribute("data-ref");
+ }
+
+ try {
+ const document = DOMParser.fromSchema(getTiptapDocumentSchema()).parse(
+ htmlDocument.body as unknown as HTMLElement,
+ );
+ document.check();
+ return coerceTiptapDocumentJson(document.toJSON());
+ } catch (error) {
+ throw new DocumentAiHtmlError("Document HTML does not match the supported schema.", {
+ cause: error,
+ });
+ }
+}
+
+export async function serializeTiptapDocumentToAiHtml(document: TiptapDocumentJson) {
+ const node = getTiptapDocumentSchema().nodeFromJSON(document);
+ return (
+ await Promise.all(
+ Array.from({ length: node.childCount }, (_, index) =>
+ serializeTiptapNodeToAiHtml(node.child(index)),
+ ),
+ )
+ ).join("");
+}
+
+export async function serializeTiptapNodeToAiHtml(node: ProseMirrorNode) {
+ return serializeTiptapFragmentToAiHtml(
+ Fragment.from(withTiptapNodeAiRef(node, await createDocumentAiTargetRef(node))),
+ );
+}
+
+export async function createDocumentAiTargetRef(node: ProseMirrorNode) {
+ const ref = readTiptapNodeAiRef(node);
+ if (!ref) {
+ throw new Error(`Top-level document node ${node.type.name} is missing an AI ref.`);
+ }
+
+ const html = serializeTiptapFragmentToAiHtml(Fragment.from(withTiptapNodeAiRef(node, null)));
+ const revision = (await sha256Base64UrlText(html)).slice(0, 10);
+ return `${ref}.r_${revision}`;
+}
+
+export function parseDocumentAiTargetRef(ref: string) {
+ return documentAiTargetRefPattern.exec(ref)?.[1] ?? null;
+}
+
+export function ensureTiptapDocumentAiRefs(document: TiptapDocumentJson): {
+ changed: boolean;
+ document: TiptapDocumentJson;
+} {
+ const refs = ensureProseMirrorDocumentAiRefs(getTiptapDocumentSchema().nodeFromJSON(document));
+ return refs.changed
+ ? { changed: true, document: coerceTiptapDocumentJson(refs.document.toJSON()) }
+ : { changed: false, document };
+}
+
+export function ensureProseMirrorDocumentAiRefs(document: ProseMirrorNode): {
+ changed: boolean;
+ document: ProseMirrorNode;
+} {
+ const usedRefs = new Set();
+ let changed = false;
+ const children: ProseMirrorNode[] = [];
+
+ document.forEach((node) => {
+ const currentRef = readTiptapNodeAiRef(node);
+ const ref =
+ currentRef && !usedRefs.has(currentRef) ? currentRef : createUnusedDocumentAiRef(usedRefs);
+ usedRefs.add(ref);
+ changed ||= ref !== currentRef;
+ children.push(withTiptapNodeAiRef(node, ref));
+ });
+
+ if (!changed) {
+ return { changed: false, document };
+ }
+
+ return {
+ changed: true,
+ document: document.type.create(document.attrs, Fragment.fromArray(children)),
+ };
+}
+
+export function createDocumentAiRef() {
+ return `b_${nanoid(12)}`;
+}
+
+export function createUnusedDocumentAiRef(usedRefs: ReadonlySet) {
+ let ref = createDocumentAiRef();
+ while (usedRefs.has(ref)) {
+ ref = createDocumentAiRef();
+ }
+ return ref;
+}
+
+export function readTiptapNodeAiRef(node: ProseMirrorNode) {
+ const ref = node.attrs[tiptapDocumentAiRefAttribute];
+ return typeof ref === "string" && documentAiRefPattern.test(ref) ? ref : null;
+}
+
+export function withTiptapNodeAiRef(node: ProseMirrorNode, ref: string | null) {
+ const attributes = node.type.spec.attrs;
+ if (!attributes || !(tiptapDocumentAiRefAttribute in attributes)) {
+ throw new Error(`Top-level document node ${node.type.name} cannot carry an AI ref.`);
+ }
+
+ return node.type.create(
+ { ...node.attrs, [tiptapDocumentAiRefAttribute]: ref },
+ node.content,
+ node.marks,
+ );
+}
+
+function serializeTiptapFragmentToAiHtml(fragment: Fragment) {
+ const htmlDocument = createHtmlDocument();
+ const container = htmlDocument.createElement("div");
+ const serialized = DOMSerializer.fromSchema(getTiptapDocumentSchema()).serializeFragment(
+ fragment,
+ { document: htmlDocument as unknown as Document },
+ );
+ container.appendChild(serialized as unknown as globalThis.Node);
+ return container.innerHTML;
+}
+
+function validateDocumentAiHtml(root: HTMLElement) {
+ for (const element of root.querySelectorAll("*")) {
+ const tagName = element.tagName.toLowerCase();
+ if (!supportedDocumentAiHtmlTags.has(tagName) || !isSupportedSpecialElement(element)) {
+ throw new DocumentAiHtmlError(`Unsupported document HTML element: <${tagName}>.`);
+ }
+ }
+}
+
+function isSupportedSpecialElement(element: Element) {
+ const tagName = element.tagName.toLowerCase();
+ if (tagName === "div") {
+ return (
+ element.getAttribute("data-type") === "block-math" ||
+ element.parentElement?.getAttribute("data-type") === "taskItem"
+ );
+ }
+ if (tagName === "span") {
+ return (
+ element.getAttribute("data-type") === "inline-math" ||
+ element.parentElement?.tagName.toLowerCase() === "label"
+ );
+ }
+ if (tagName === "label" || tagName === "input") {
+ return Boolean(element.closest('li[data-type="taskItem"]'));
+ }
+ return true;
+}
+
+function createHtmlDocument() {
+ return parseHTML("").document;
+}
diff --git a/src/features/workspaces/documents/document-ai-html.worker.test.ts b/src/features/workspaces/documents/document-ai-html.worker.test.ts
new file mode 100644
index 00000000..323509c8
--- /dev/null
+++ b/src/features/workspaces/documents/document-ai-html.worker.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ ensureTiptapDocumentAiRefs,
+ parseDocumentAiHtml,
+ serializeTiptapDocumentToAiHtml,
+} from "#/features/workspaces/documents/document-ai-html";
+
+describe("document AI HTML in Workers", () => {
+ it("parses and serializes with the production runtime DOM adapter", async () => {
+ const document = ensureTiptapDocumentAiRefs(
+ parseDocumentAiHtml("Worker
Schema-safe HTML
"),
+ ).document;
+
+ expect(await serializeTiptapDocumentToAiHtml(document)).toMatch(
+ /^Worker<\/h2>
Schema-safe HTML<\/p>$/,
+ );
+ });
+});
diff --git a/src/features/workspaces/documents/document-edit-receipt.ts b/src/features/workspaces/documents/document-edit-receipt.ts
new file mode 100644
index 00000000..0af0ddbb
--- /dev/null
+++ b/src/features/workspaces/documents/document-edit-receipt.ts
@@ -0,0 +1,40 @@
+import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
+
+export const documentEditReceiptStatuses = [
+ "ready",
+ "reverted",
+ "not_found",
+ "not_latest",
+ "content_changed",
+ "review_unavailable",
+] as const;
+
+export type DocumentEditReceiptStatus = (typeof documentEditReceiptStatuses)[number];
+
+export interface DocumentEditReceiptStatusResult {
+ status: DocumentEditReceiptStatus;
+}
+
+export type DocumentEditReceiptReviewResult =
+ | {
+ afterDocument: TiptapDocumentJson;
+ beforeDocument: TiptapDocumentJson;
+ status: "ready";
+ }
+ | {
+ status: Exclude;
+ };
+
+export type DocumentEditReceiptReviewRpcResult =
+ | {
+ afterContent: string;
+ beforeContent: string;
+ status: "ready";
+ }
+ | {
+ status: Exclude;
+ };
+
+export interface DocumentEditReceiptUndoResult {
+ status: Exclude;
+}
diff --git a/src/features/workspaces/documents/document-edit-review-context.tsx b/src/features/workspaces/documents/document-edit-review-context.tsx
new file mode 100644
index 00000000..df8814cd
--- /dev/null
+++ b/src/features/workspaces/documents/document-edit-review-context.tsx
@@ -0,0 +1,61 @@
+import { createContext, type ReactNode, use, useCallback, useMemo, useState } from "react";
+
+import { useWorkspaceLocationActions } from "#/features/workspaces/locations/workspace-location-context";
+
+export interface ActiveDocumentEditReview {
+ itemId: string;
+ receiptIds: string[];
+ viewInstanceId: string;
+}
+
+interface DocumentEditReviewContextValue {
+ activeReview: ActiveDocumentEditReview | null;
+ hideReview: () => void;
+ showReview: (input: { itemId: string; receiptIds: string[] }) => boolean;
+ workspaceId: string;
+}
+
+const DocumentEditReviewContext = createContext(null);
+
+export function DocumentEditReviewProvider({
+ children,
+ workspaceId,
+}: {
+ children: ReactNode;
+ workspaceId: string;
+}) {
+ const { reveal } = useWorkspaceLocationActions();
+ const [activeReview, setActiveReview] = useState(null);
+ const hideReview = useCallback(() => setActiveReview(null), []);
+ const showReview = useCallback(
+ (input: { itemId: string; receiptIds: string[] }) => {
+ const viewInstanceId = reveal({ itemId: input.itemId, kind: "item", version: 1 });
+ if (!viewInstanceId) {
+ return false;
+ }
+
+ setActiveReview({
+ itemId: input.itemId,
+ receiptIds: input.receiptIds,
+ viewInstanceId,
+ });
+ return true;
+ },
+ [reveal],
+ );
+ const value = useMemo(
+ () => ({ activeReview, hideReview, showReview, workspaceId }),
+ [activeReview, hideReview, showReview, workspaceId],
+ );
+
+ return {children} ;
+}
+
+export function useDocumentEditReview() {
+ const value = use(DocumentEditReviewContext);
+ if (!value) {
+ throw new Error("Document edit review requires a workspace shell.");
+ }
+
+ return value;
+}
diff --git a/src/features/workspaces/documents/document-edit-review-extension.ts b/src/features/workspaces/documents/document-edit-review-extension.ts
new file mode 100644
index 00000000..a555f887
--- /dev/null
+++ b/src/features/workspaces/documents/document-edit-review-extension.ts
@@ -0,0 +1,207 @@
+import { Extension, type Editor } from "@tiptap/core";
+import { ChangeSet, simplifyChanges, type TokenEncoder } from "@tiptap/pm/changeset";
+import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { StepMap } from "@tiptap/pm/transform";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+
+import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
+import {
+ getTiptapDocumentSchema,
+ tiptapDocumentAiRefAttribute,
+} from "#/features/workspaces/documents/tiptap-schema";
+
+const documentEditReviewPluginKey = new PluginKey("documentEditReview");
+const maximumDeletedTextLength = 240;
+
+type DocumentEditReviewMeta =
+ | { beforeDocument: TiptapDocumentJson; type: "show" }
+ | { type: "hide" };
+
+const documentEditTokenEncoder: TokenEncoder = {
+ encodeCharacter(character, marks) {
+ return `c:${character}:${marks.map(encodeMark).sort().join("|")}`;
+ },
+ encodeNodeStart(node) {
+ const attributes = { ...node.attrs };
+ delete attributes[tiptapDocumentAiRefAttribute];
+ return `n:${node.type.name}:${JSON.stringify(attributes)}`;
+ },
+ encodeNodeEnd(node) {
+ return `/n:${node.type.name}`;
+ },
+ compareTokens(left, right) {
+ return left === right;
+ },
+};
+
+export const DocumentEditReviewExtension = Extension.create({
+ name: "documentEditReview",
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: documentEditReviewPluginKey,
+ state: {
+ init: () => DecorationSet.empty,
+ apply(transaction, decorations, _oldState, newState) {
+ const meta = transaction.getMeta(documentEditReviewPluginKey) as
+ | DocumentEditReviewMeta
+ | undefined;
+
+ if (meta?.type === "hide" || (transaction.docChanged && !meta)) {
+ return DecorationSet.empty;
+ }
+ if (meta?.type === "show") {
+ return createDocumentEditReviewDecorations(meta.beforeDocument, newState.doc);
+ }
+
+ return decorations.map(transaction.mapping, transaction.doc);
+ },
+ },
+ props: {
+ decorations(state) {
+ return documentEditReviewPluginKey.getState(state) ?? DecorationSet.empty;
+ },
+ },
+ }),
+ ];
+ },
+});
+
+export function showDocumentEditReview(editor: Editor, beforeDocument: TiptapDocumentJson) {
+ editor.view.dispatch(
+ editor.state.tr
+ .setMeta(documentEditReviewPluginKey, {
+ beforeDocument,
+ type: "show",
+ } satisfies DocumentEditReviewMeta)
+ .setMeta("addToHistory", false),
+ );
+}
+
+export function hideDocumentEditReview(editor: Editor) {
+ editor.view.dispatch(
+ editor.state.tr
+ .setMeta(documentEditReviewPluginKey, {
+ type: "hide",
+ } satisfies DocumentEditReviewMeta)
+ .setMeta("addToHistory", false),
+ );
+}
+
+function createDocumentEditReviewDecorations(
+ beforeDocument: TiptapDocumentJson,
+ afterDocument: ProseMirrorNode,
+) {
+ const beforeNode = getTiptapDocumentSchema().nodeFromJSON(beforeDocument);
+ const changes = simplifyChanges(
+ ChangeSet.create(beforeNode, undefined, documentEditTokenEncoder).addSteps(
+ afterDocument,
+ [new StepMap([0, beforeNode.content.size, afterDocument.content.size])],
+ null,
+ ).changes,
+ afterDocument,
+ );
+ const decorations: Decoration[] = [];
+ const decoratedBlocks = new Set();
+
+ for (const [index, change] of changes.entries()) {
+ if (change.fromB < change.toB) {
+ const isInlineChange = addInsertedContentDecoration(
+ decorations,
+ afterDocument,
+ change.fromB,
+ change.toB,
+ );
+ if (!isInlineChange) {
+ addChangedBlockDecorations(
+ decorations,
+ decoratedBlocks,
+ afterDocument,
+ change.fromB,
+ change.toB,
+ );
+ }
+ }
+
+ if (change.fromA < change.toA) {
+ decorations.push(
+ Decoration.widget(
+ change.fromB,
+ () => createDeletedContentWidget(beforeNode, change.fromA, change.toA),
+ { key: `document-edit-deletion-${index}`, side: -1 },
+ ),
+ );
+ }
+ }
+
+ return DecorationSet.create(afterDocument, decorations);
+}
+
+function addInsertedContentDecoration(
+ decorations: Decoration[],
+ document: ProseMirrorNode,
+ from: number,
+ to: number,
+) {
+ const start = document.resolve(from);
+ const end = document.resolve(to);
+
+ if (start.sameParent(end) && start.parent.inlineContent) {
+ decorations.push(
+ Decoration.inline(from, to, {
+ class: "workspace-document-ai-inserted",
+ }),
+ );
+ return true;
+ }
+
+ return false;
+}
+
+function addChangedBlockDecorations(
+ decorations: Decoration[],
+ decoratedBlocks: Set,
+ document: ProseMirrorNode,
+ from: number,
+ to: number,
+) {
+ document.forEach((node, offset) => {
+ const end = offset + node.nodeSize;
+ if (end <= from || offset >= to) {
+ return;
+ }
+
+ const key = `${offset}:${end}`;
+ if (decoratedBlocks.has(key)) {
+ return;
+ }
+
+ decoratedBlocks.add(key);
+ decorations.push(
+ Decoration.node(offset, end, {
+ class: "workspace-document-ai-changed-block",
+ }),
+ );
+ });
+}
+
+function createDeletedContentWidget(beforeDocument: ProseMirrorNode, from: number, to: number) {
+ const deletedText = beforeDocument.textBetween(from, to, " ").trim();
+ const element = document.createElement("span");
+ const visibleText = deletedText || "Removed block";
+
+ element.className = "workspace-document-ai-deleted";
+ element.contentEditable = "false";
+ element.textContent =
+ visibleText.length > maximumDeletedTextLength
+ ? `${visibleText.slice(0, maximumDeletedTextLength)}…`
+ : visibleText;
+
+ return element;
+}
+
+function encodeMark(mark: Mark) {
+ return `${mark.type.name}:${JSON.stringify(mark.attrs)}`;
+}
diff --git a/src/features/workspaces/documents/document-edit-review-functions.ts b/src/features/workspaces/documents/document-edit-review-functions.ts
new file mode 100644
index 00000000..ea42d756
--- /dev/null
+++ b/src/features/workspaces/documents/document-edit-review-functions.ts
@@ -0,0 +1,87 @@
+import { createServerFn } from "@tanstack/react-start";
+import { z } from "zod";
+
+import { getDocumentSessionFromEnv } from "#/features/workspaces/document-session-access";
+import type {
+ DocumentEditReceiptReviewRpcResult,
+ DocumentEditReceiptReviewResult,
+ DocumentEditReceiptStatusResult,
+ DocumentEditReceiptUndoResult,
+} from "#/features/workspaces/documents/document-edit-receipt";
+import { parseTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
+import { withWorkspaceDb } from "#/features/workspaces/server/workspace-db";
+import {
+ assertCanMutateWorkspace,
+ assertCanReadWorkspace,
+} from "#/features/workspaces/server/permissions";
+
+const documentEditReceiptInputSchema = z.strictObject({
+ itemId: z.string().trim().min(1),
+ receiptIds: z.array(z.string().trim().min(1).max(512)).min(1).max(40),
+ workspaceId: z.string().trim().min(1),
+});
+
+export const getDocumentEditReceiptStatusFn = createServerFn({ method: "GET" })
+ .validator(documentEditReceiptInputSchema)
+ .handler(async ({ data }): Promise => {
+ await authorizeDocumentEditReceipt(data.workspaceId, "read");
+ const session = await getDocumentEditSession(data);
+ return await session.getDocumentEditReceiptStatus({ receiptIds: data.receiptIds });
+ });
+
+export const getDocumentEditReceiptReviewFn = createServerFn({ method: "GET" })
+ .validator(documentEditReceiptInputSchema)
+ .handler(async ({ data }): Promise => {
+ await authorizeDocumentEditReceipt(data.workspaceId, "read");
+ const session = await getDocumentEditSession(data);
+ const result = await session.getDocumentEditReceiptReview({
+ receiptIds: data.receiptIds,
+ });
+
+ return result.status === "ready"
+ ? {
+ afterDocument: parseTiptapDocumentJson(result.afterContent),
+ beforeDocument: parseTiptapDocumentJson(result.beforeContent),
+ status: result.status,
+ }
+ : result;
+ });
+
+export const undoDocumentEditReceiptFn = createServerFn({ method: "POST" })
+ .validator(documentEditReceiptInputSchema)
+ .handler(async ({ data }): Promise => {
+ await authorizeDocumentEditReceipt(data.workspaceId, "mutate");
+ const session = await getDocumentEditSession(data);
+ return await session.undoDocumentEditReceipt({ receiptIds: data.receiptIds });
+ });
+
+async function authorizeDocumentEditReceipt(workspaceId: string, access: "mutate" | "read") {
+ await withWorkspaceDb(async ({ db, userId }) => {
+ if (access === "mutate") {
+ await assertCanMutateWorkspace(db, { userId, workspaceId });
+ } else {
+ await assertCanReadWorkspace(db, { userId, workspaceId });
+ }
+ });
+}
+
+async function getDocumentEditSession(input: {
+ itemId: string;
+ workspaceId: string;
+}): Promise {
+ const { env } = await import("cloudflare:workers");
+ const session: unknown = getDocumentSessionFromEnv(env, input);
+ return session as DocumentEditSession;
+}
+
+// Narrowing the generated stub here avoids recursively expanding every
+// DocumentSession RPC type through createServerFn.
+interface DocumentEditSession {
+ getDocumentEditReceiptReview(input: {
+ receiptIds: string[];
+ }): Promise;
+ getDocumentEditReceiptStatus(input: {
+ receiptIds: string[];
+ }): Promise;
+ undoDocumentEditReceipt(input: { receiptIds: string[] }): Promise;
+}
diff --git a/src/features/workspaces/documents/document-edit-review-queries.ts b/src/features/workspaces/documents/document-edit-review-queries.ts
new file mode 100644
index 00000000..956a13ef
--- /dev/null
+++ b/src/features/workspaces/documents/document-edit-review-queries.ts
@@ -0,0 +1,41 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import {
+ getDocumentEditReceiptReviewFn,
+ getDocumentEditReceiptStatusFn,
+} from "#/features/workspaces/documents/document-edit-review-functions";
+
+export interface DocumentEditReceiptTarget {
+ itemId: string;
+ receiptIds: string[];
+ workspaceId: string;
+}
+
+export function documentEditReceiptStatusQueryOptions(target: DocumentEditReceiptTarget) {
+ return queryOptions({
+ queryKey: documentEditReceiptQueryKey(target, "status"),
+ queryFn: () => getDocumentEditReceiptStatusFn({ data: target }),
+ staleTime: 5_000,
+ });
+}
+
+export function documentEditReceiptReviewQueryOptions(target: DocumentEditReceiptTarget) {
+ return queryOptions({
+ queryKey: documentEditReceiptQueryKey(target, "review"),
+ queryFn: () => getDocumentEditReceiptReviewFn({ data: target }),
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+}
+
+export function documentEditReceiptQueryKey(
+ target: DocumentEditReceiptTarget,
+ kind: "review" | "status",
+) {
+ return [
+ "workspace-document-edit-receipt",
+ target.workspaceId,
+ target.itemId,
+ target.receiptIds.join(":"),
+ kind,
+ ] as const;
+}
diff --git a/src/features/workspaces/documents/document-html-chunk.ts b/src/features/workspaces/documents/document-html-chunk.ts
new file mode 100644
index 00000000..0810bda1
--- /dev/null
+++ b/src/features/workspaces/documents/document-html-chunk.ts
@@ -0,0 +1,64 @@
+import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
+
+import { serializeTiptapNodeToAiHtml } from "#/features/workspaces/documents/document-ai-html";
+
+// Roughly 12k tokens for ordinary prose: useful working context without making
+// one document read dominate the model's turn.
+const targetDocumentChunkCharacters = 48_000;
+
+export interface DocumentHtmlChunk {
+ content: string;
+ location: {
+ endBlock: number;
+ startBlock: number;
+ totalBlocks: number;
+ };
+ nextOffset?: number;
+}
+
+export interface DocumentHtmlChunkReadInput {
+ expectedRevision?: string;
+ offset: number;
+}
+
+export type DocumentHtmlChunkReadResult =
+ | { status: "content_changed" }
+ | { status: "invalid_offset" }
+ | ({ revision: string; status: "ready" } & DocumentHtmlChunk);
+
+export async function readDocumentHtmlChunk(
+ document: ProseMirrorNode,
+ offset: number,
+): Promise {
+ if (offset < 0 || offset >= document.childCount) {
+ return undefined;
+ }
+
+ const content: string[] = [];
+ let characters = 0;
+ let endOffset = offset;
+ while (endOffset < document.childCount) {
+ const block = await serializeTiptapNodeToAiHtml(document.child(endOffset));
+ const separatorCharacters = content.length > 0 ? 1 : 0;
+ if (
+ content.length > 0 &&
+ characters + separatorCharacters + block.length > targetDocumentChunkCharacters
+ ) {
+ break;
+ }
+
+ content.push(block);
+ characters += separatorCharacters + block.length;
+ endOffset++;
+ }
+
+ return {
+ content: content.join("\n"),
+ location: {
+ endBlock: endOffset,
+ startBlock: offset + 1,
+ totalBlocks: document.childCount,
+ },
+ ...(endOffset < document.childCount ? { nextOffset: endOffset } : {}),
+ };
+}
diff --git a/src/features/workspaces/documents/document-markdown-chunk.test.ts b/src/features/workspaces/documents/document-markdown-chunk.test.ts
deleted file mode 100644
index 2da2607b..00000000
--- a/src/features/workspaces/documents/document-markdown-chunk.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { createDocumentMarkdownSnapshot } from "#/features/workspaces/documents/document-markdown-chunk";
-
-describe("document Markdown snapshots", () => {
- it("preserves exact content and indexed line locations across chunks", () => {
- const markdown = `heading \n\n${"x".repeat(64_000)}\ntail\n`;
- const snapshot = createDocumentMarkdownSnapshot(markdown);
- const first = snapshot.readChunk(0);
- if (!first?.nextOffset) {
- throw new Error("Expected a continuation offset.");
- }
- const second = snapshot.readChunk(first.nextOffset);
- if (!second) {
- throw new Error("Expected a second chunk.");
- }
-
- expect(first.content + second.content).toBe(markdown);
- expect(first.location).toEqual({ endLine: 3, startLine: 1, totalLines: 5 });
- expect(second.location).toEqual({ endLine: 5, startLine: 3, totalLines: 5 });
- });
-
- it("rejects nonzero offsets for empty or exhausted snapshots", () => {
- expect(createDocumentMarkdownSnapshot("").readChunk(1)).toBeUndefined();
- expect(createDocumentMarkdownSnapshot("text").readChunk(4)).toBeUndefined();
- });
-
- it("keeps surrogate pairs intact at a hard chunk boundary", () => {
- const markdown = `${"a".repeat(63_999)}😀tail`;
- const snapshot = createDocumentMarkdownSnapshot(markdown);
- const first = snapshot.readChunk(0);
- if (!first?.nextOffset) {
- throw new Error("Expected a continuation offset.");
- }
- const second = snapshot.readChunk(first.nextOffset);
-
- expect(first.content.endsWith("a")).toBe(true);
- expect(second?.content.startsWith("😀")).toBe(true);
- expect(first.content + second?.content).toBe(markdown);
- });
-});
diff --git a/src/features/workspaces/documents/document-markdown-chunk.ts b/src/features/workspaces/documents/document-markdown-chunk.ts
deleted file mode 100644
index 52e9ca75..00000000
--- a/src/features/workspaces/documents/document-markdown-chunk.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-const maxDocumentChunkCharacters = 64_000;
-const minDocumentChunkCharacters = maxDocumentChunkCharacters / 2;
-
-export interface DocumentMarkdownChunk {
- content: string;
- location: {
- endLine: number;
- startLine: number;
- totalLines: number;
- };
- nextOffset?: number;
-}
-
-export interface DocumentMarkdownChunkReadInput {
- expectedRevision?: string;
- offset: number;
-}
-
-export type DocumentMarkdownChunkReadResult =
- | { status: "content_changed" }
- | { status: "invalid_offset" }
- | ({ revision: string; status: "ready" } & DocumentMarkdownChunk);
-
-export interface DocumentMarkdownSnapshot {
- readChunk(offset: number): DocumentMarkdownChunk | undefined;
-}
-
-export function createDocumentMarkdownSnapshot(markdown: string): DocumentMarkdownSnapshot {
- const lineStarts = getLineStarts(markdown);
-
- return {
- readChunk(offset) {
- if (offset < 0 || (offset !== 0 && offset >= markdown.length)) {
- return undefined;
- }
-
- const candidateEnd = Math.min(markdown.length, offset + maxDocumentChunkCharacters);
- const hardEnd = splitsSurrogatePair(markdown, candidateEnd) ? candidateEnd - 1 : candidateEnd;
- const newlineEnd = markdown.lastIndexOf("\n", hardEnd);
- const end =
- hardEnd < markdown.length && newlineEnd > offset + minDocumentChunkCharacters
- ? newlineEnd + 1
- : hardEnd;
- const content = markdown.slice(offset, end);
-
- return {
- content,
- location: {
- endLine: content ? findLineNumber(lineStarts, end) : 0,
- startLine: content ? findLineNumber(lineStarts, offset) : 0,
- totalLines: lineStarts.length,
- },
- ...(end < markdown.length ? { nextOffset: end } : {}),
- };
- },
- };
-}
-
-function splitsSurrogatePair(value: string, offset: number) {
- const previous = value.charCodeAt(offset - 1);
- const next = value.charCodeAt(offset);
- return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff;
-}
-
-function getLineStarts(markdown: string) {
- if (!markdown) {
- return [];
- }
-
- const lineStarts = [0];
- for (
- let index = markdown.indexOf("\n");
- index !== -1;
- index = markdown.indexOf("\n", index + 1)
- ) {
- lineStarts.push(index + 1);
- }
- return lineStarts;
-}
-
-function findLineNumber(lineStarts: number[], offset: number) {
- let low = 0;
- let high = lineStarts.length;
- while (low < high) {
- const middle = Math.floor((low + high) / 2);
- if ((lineStarts[middle] ?? 0) <= offset) {
- low = middle + 1;
- } else {
- high = middle;
- }
- }
- return low;
-}
diff --git a/src/features/workspaces/documents/document-markdown-edits.ts b/src/features/workspaces/documents/document-markdown-edits.ts
deleted file mode 100644
index efd37e2e..00000000
--- a/src/features/workspaces/documents/document-markdown-edits.ts
+++ /dev/null
@@ -1,472 +0,0 @@
-import { z } from "zod";
-
-export const documentMarkdownEditSchema = z.discriminatedUnion("type", [
- z.object({
- type: z.literal("replace"),
- oldText: z.string(),
- newText: z.string(),
- replaceAll: z.boolean().optional(),
- }),
- z.object({
- type: z.literal("append"),
- text: z.string(),
- }),
- z.object({
- type: z.literal("prepend"),
- text: z.string(),
- }),
- z.object({
- type: z.literal("overwrite"),
- content: z.string(),
- }),
-]);
-
-export const documentMarkdownEditResultStatusSchema = z.enum([
- "applied",
- "partial",
- "failed",
- "rejected",
-]);
-
-export type DocumentMarkdownEdit = z.infer;
-export type DocumentMarkdownEditResultStatus = z.infer<
- typeof documentMarkdownEditResultStatusSchema
->;
-
-export interface DocumentMarkdownEditFailure {
- code: DocumentMarkdownEditFailureCode;
- index: number;
-}
-
-export interface DocumentMarkdownEditResult {
- applied: number;
- content: string;
- failed: number;
- failures: DocumentMarkdownEditFailure[];
- status: Exclude;
-}
-
-export const documentMarkdownEditFailureCodes = [
- "empty_old_text",
- "identical_text",
- "multiple_matches",
- "old_text_not_found",
-] as const;
-
-type Replacer = (content: string, find: string) => Generator;
-
-export type DocumentMarkdownEditFailureCode = (typeof documentMarkdownEditFailureCodes)[number];
-
-export function applyDocumentMarkdownEdits(
- content: string,
- edits: DocumentMarkdownEdit[],
-): DocumentMarkdownEditResult {
- let nextContent = content;
- const failures: DocumentMarkdownEditFailure[] = [];
- let applied = 0;
-
- for (const [index, edit] of edits.entries()) {
- const result = applyDocumentMarkdownEdit(nextContent, edit);
-
- if (result.status === "failed") {
- failures.push({ code: result.code, index });
- continue;
- }
-
- nextContent = result.content;
- applied++;
- }
-
- const failed = failures.length;
-
- return {
- applied,
- content: nextContent,
- failed,
- failures,
- status: applied === 0 ? "failed" : failed > 0 ? "partial" : "applied",
- };
-}
-
-function applyDocumentMarkdownEdit(
- content: string,
- edit: DocumentMarkdownEdit,
-):
- | { status: "applied"; content: string }
- | { status: "failed"; code: DocumentMarkdownEditFailureCode } {
- switch (edit.type) {
- case "append":
- return { status: "applied", content: `${content}${edit.text}` };
- case "prepend":
- return { status: "applied", content: `${edit.text}${content}` };
- case "overwrite":
- return { status: "applied", content: edit.content };
- case "replace":
- return replaceDocumentMarkdown(content, edit.oldText, edit.newText, {
- replaceAll: edit.replaceAll ?? false,
- });
- }
-}
-
-function replaceDocumentMarkdown(
- content: string,
- oldText: string,
- newText: string,
- options: { replaceAll: boolean },
-):
- | { status: "applied"; content: string }
- | { status: "failed"; code: DocumentMarkdownEditFailureCode } {
- if (oldText === "") {
- return { status: "failed", code: "empty_old_text" };
- }
-
- if (oldText === newText) {
- return { status: "failed", code: "identical_text" };
- }
-
- const candidates = getDocumentMarkdownReplacementCandidates(content, oldText);
-
- if (candidates.length === 0) {
- return { status: "failed", code: "old_text_not_found" };
- }
-
- if (options.replaceAll) {
- return {
- status: "applied",
- content: content.replaceAll(candidates[0].search, newText),
- };
- }
-
- if (candidates.length > 1 || candidates[0].occurrences > 1) {
- return { status: "failed", code: "multiple_matches" };
- }
-
- const firstIndex = content.indexOf(candidates[0].search);
-
- return {
- status: "applied",
- content:
- content.slice(0, firstIndex) +
- newText +
- content.slice(firstIndex + candidates[0].search.length),
- };
-}
-
-const documentMarkdownReplacers: Replacer[] = [
- simpleReplacer,
- lineTrimmedReplacer,
- blockAnchorReplacer,
- whitespaceNormalizedReplacer,
- indentationFlexibleReplacer,
- escapeNormalizedReplacer,
- trimmedBoundaryReplacer,
-];
-
-function* simpleReplacer(_content: string, find: string) {
- yield find;
-}
-
-function getDocumentMarkdownReplacementCandidates(content: string, oldText: string) {
- const seen = new Set();
- const candidates: { occurrences: number; search: string }[] = [];
-
- for (const replacer of documentMarkdownReplacers) {
- for (const search of replacer(content, oldText)) {
- if (search === "") {
- continue;
- }
-
- if (seen.has(search)) {
- continue;
- }
-
- seen.add(search);
-
- const occurrences = countOccurrences(content, search);
-
- if (occurrences > 0) {
- candidates.push({ occurrences, search });
- }
- }
- }
-
- return candidates;
-}
-
-function countOccurrences(content: string, search: string) {
- let count = 0;
- let startIndex = 0;
-
- while (true) {
- const index = content.indexOf(search, startIndex);
-
- if (index === -1) {
- return count;
- }
-
- count++;
- startIndex = index + search.length;
- }
-}
-
-function* lineTrimmedReplacer(content: string, find: string) {
- const originalLines = content.split("\n");
- const searchLines = trimTrailingEmptyLine(find.split("\n"));
-
- for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
- const matches = searchLines.every((line, index) => {
- return originalLines[i + index].trim() === line.trim();
- });
-
- if (!matches) {
- continue;
- }
-
- yield originalLines.slice(i, i + searchLines.length).join("\n");
- }
-}
-
-function* blockAnchorReplacer(content: string, find: string) {
- const originalLines = content.split("\n");
- const searchLines = trimTrailingEmptyLine(find.split("\n"));
-
- if (searchLines.length < 3) {
- return;
- }
-
- const firstLineSearch = searchLines[0].trim();
- const lastLineSearch = searchLines[searchLines.length - 1].trim();
- const maxLineDelta = Math.max(1, Math.floor(searchLines.length * 0.25));
- const candidates: { endLine: number; startLine: number }[] = [];
-
- for (let startLine = 0; startLine < originalLines.length; startLine++) {
- if (originalLines[startLine].trim() !== firstLineSearch) {
- continue;
- }
-
- for (let endLine = startLine + 2; endLine < originalLines.length; endLine++) {
- if (originalLines[endLine].trim() !== lastLineSearch) {
- continue;
- }
-
- if (Math.abs(endLine - startLine + 1 - searchLines.length) <= maxLineDelta) {
- candidates.push({ endLine, startLine });
- }
- break;
- }
- }
-
- const bestCandidate = candidates
- .map((candidate) => ({
- ...candidate,
- similarity: getMiddleLineSimilarity(originalLines, searchLines, candidate),
- }))
- .filter((candidate) => candidate.similarity >= 0.65)
- .sort((left, right) => right.similarity - left.similarity)[0];
-
- if (bestCandidate) {
- yield originalLines.slice(bestCandidate.startLine, bestCandidate.endLine + 1).join("\n");
- }
-}
-
-function* whitespaceNormalizedReplacer(content: string, find: string) {
- const normalizedFind = normalizeWhitespace(find);
- const lines = content.split("\n");
-
- for (const line of lines) {
- const normalizedLine = normalizeWhitespace(line);
-
- if (normalizedLine === normalizedFind) {
- yield line;
- continue;
- }
-
- if (!normalizedLine.includes(normalizedFind)) {
- continue;
- }
-
- const words = find.trim().split(/\s+/);
- const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+");
- const match = line.match(new RegExp(pattern));
-
- if (match) {
- yield match[0];
- }
- }
-
- const findLines = find.split("\n");
-
- if (findLines.length <= 1) {
- return;
- }
-
- for (let i = 0; i <= lines.length - findLines.length; i++) {
- const block = lines.slice(i, i + findLines.length).join("\n");
-
- if (normalizeWhitespace(block) === normalizedFind) {
- yield block;
- }
- }
-}
-
-function* indentationFlexibleReplacer(content: string, find: string) {
- const normalizedFind = removeCommonIndentation(find);
- const contentLines = content.split("\n");
- const findLineCount = find.split("\n").length;
-
- for (let i = 0; i <= contentLines.length - findLineCount; i++) {
- const block = contentLines.slice(i, i + findLineCount).join("\n");
-
- if (removeCommonIndentation(block) === normalizedFind) {
- yield block;
- }
- }
-}
-
-function* escapeNormalizedReplacer(content: string, find: string) {
- const unescapedFind = unescapeString(find);
-
- if (content.includes(unescapedFind)) {
- yield unescapedFind;
- }
-
- const lines = content.split("\n");
- const findLines = unescapedFind.split("\n");
-
- for (let i = 0; i <= lines.length - findLines.length; i++) {
- const block = lines.slice(i, i + findLines.length).join("\n");
-
- if (unescapeString(block) === unescapedFind) {
- yield block;
- }
- }
-}
-
-function* trimmedBoundaryReplacer(content: string, find: string) {
- const trimmedFind = find.trim();
-
- if (trimmedFind === find) {
- return;
- }
-
- if (content.includes(trimmedFind)) {
- yield trimmedFind;
- }
-
- const lines = content.split("\n");
- const findLines = find.split("\n");
-
- for (let i = 0; i <= lines.length - findLines.length; i++) {
- const block = lines.slice(i, i + findLines.length).join("\n");
-
- if (block.trim() === trimmedFind) {
- yield block;
- }
- }
-}
-
-function getMiddleLineSimilarity(
- originalLines: string[],
- searchLines: string[],
- candidate: { endLine: number; startLine: number },
-) {
- const linesToCheck = Math.min(
- searchLines.length - 2,
- candidate.endLine - candidate.startLine - 1,
- );
-
- if (linesToCheck <= 0) {
- return 1;
- }
-
- let similarity = 0;
-
- for (let i = 1; i <= linesToCheck; i++) {
- const originalLine = originalLines[candidate.startLine + i].trim();
- const searchLine = searchLines[i].trim();
- const maxLength = Math.max(originalLine.length, searchLine.length);
-
- if (maxLength === 0) {
- continue;
- }
-
- similarity += 1 - levenshtein(originalLine, searchLine) / maxLength;
- }
-
- return similarity / linesToCheck;
-}
-
-function levenshtein(left: string, right: string) {
- if (left === "" || right === "") {
- return Math.max(left.length, right.length);
- }
-
- const matrix = Array.from({ length: left.length + 1 }, (_, i) =>
- Array.from({ length: right.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
- );
-
- for (let i = 1; i <= left.length; i++) {
- for (let j = 1; j <= right.length; j++) {
- const cost = left[i - 1] === right[j - 1] ? 0 : 1;
- matrix[i][j] = Math.min(
- matrix[i - 1][j] + 1,
- matrix[i][j - 1] + 1,
- matrix[i - 1][j - 1] + cost,
- );
- }
- }
-
- return matrix[left.length][right.length];
-}
-
-function trimTrailingEmptyLine(lines: string[]) {
- if (lines[lines.length - 1] === "") {
- return lines.slice(0, -1);
- }
-
- return lines;
-}
-
-function normalizeWhitespace(text: string) {
- return text.replace(/\s+/g, " ").trim();
-}
-
-function removeCommonIndentation(text: string) {
- const lines = text.split("\n");
- const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
-
- if (nonEmptyLines.length === 0) {
- return text;
- }
-
- const minIndent = Math.min(...nonEmptyLines.map((line) => line.match(/^(\s*)/)?.[1].length ?? 0));
-
- return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n");
-}
-
-function unescapeString(value: string) {
- return value.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, captured) => {
- switch (captured) {
- case "n":
- return "\n";
- case "t":
- return "\t";
- case "r":
- return "\r";
- case "'":
- return "'";
- case '"':
- return '"';
- case "`":
- return "`";
- case "\\":
- return "\\";
- case "\n":
- return "\n";
- case "$":
- return "$";
- default:
- return match;
- }
- });
-}
diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts
index 0a46ca92..055f209d 100644
--- a/src/features/workspaces/documents/document-session.ts
+++ b/src/features/workspaces/documents/document-session.ts
@@ -1,32 +1,33 @@
import {
prosemirrorJSONToYDoc,
prosemirrorJSONToYXmlFragment,
- yDocToProsemirrorJSON,
+ yXmlFragmentToProseMirrorRootNode,
} from "@tiptap/y-tiptap";
import type { Connection, ConnectionContext } from "partyserver";
import * as Y from "yjs";
import { YServer } from "y-partyserver";
import type { DocumentSessionRouteParams } from "#/features/workspaces/agent-routes";
import {
- parseMarkdownToTiptapDocumentProjection,
- serializeTiptapDocumentToMarkdown,
-} from "#/features/workspaces/documents/document-markdown";
+ applyDocumentAiEdits,
+ type DocumentAiEdit,
+ type DocumentAiEditFailureCode,
+ type DocumentAiEditResultStatus,
+} from "#/features/workspaces/documents/document-ai-edits";
+import { ensureProseMirrorDocumentAiRefs } from "#/features/workspaces/documents/document-ai-html";
import {
- createDocumentMarkdownSnapshot,
- type DocumentMarkdownChunkReadInput,
- type DocumentMarkdownChunkReadResult,
- type DocumentMarkdownSnapshot,
-} from "#/features/workspaces/documents/document-markdown-chunk";
-import {
- applyDocumentMarkdownEdits,
- type DocumentMarkdownEdit,
- type DocumentMarkdownEditFailureCode,
- type DocumentMarkdownEditResultStatus,
-} from "#/features/workspaces/documents/document-markdown-edits";
+ type DocumentHtmlChunkReadInput,
+ type DocumentHtmlChunkReadResult,
+ readDocumentHtmlChunk,
+} from "#/features/workspaces/documents/document-html-chunk";
import {
type DocumentSessionConnectionState,
readForwardedDocumentSessionConnectionAccess,
} from "#/features/workspaces/documents/document-session-connection-access";
+import type {
+ DocumentEditReceiptReviewRpcResult,
+ DocumentEditReceiptStatusResult,
+ DocumentEditReceiptUndoResult,
+} from "#/features/workspaces/documents/document-edit-receipt";
import {
coerceTiptapDocumentJson,
parseTiptapDocumentJson,
@@ -37,41 +38,64 @@ import {
getTiptapDocumentSchema,
tiptapDocumentYjsField,
} from "#/features/workspaces/documents/tiptap-schema";
+import {
+ readPersistedDocumentYjsUpdate,
+ writePersistedDocumentYjsUpdate,
+} from "#/features/workspaces/documents/document-yjs-persistence";
import {
getWorkspaceKernelFromEnv,
type WorkspaceKernelClient,
} from "#/features/workspaces/kernel/workspace-kernel-access";
-import { sha256Base64Url } from "#/lib/binary";
+import { sha256Base64Url, sha256Base64UrlText } from "#/lib/binary";
-const persistedYDocUpdateKey = "document-session:yjs-update";
+const latestDocumentEditReceiptKey = "document-session:ai-edit-receipt:latest";
+const documentEditReceiptKeyPrefix = "document-session:ai-edit-receipt:";
+const maximumDocumentEditReceiptSnapshotBytes = 1_500_000;
const checkpointDelayMs = 1_500;
const checkpointMaxWaitMs = 8_000;
-export interface DocumentSessionApplyMarkdownEditsInput {
- edits: DocumentMarkdownEdit[];
+export interface DocumentSessionApplyEditsInput {
+ edits: DocumentAiEdit[];
+ operationId: string;
}
-export interface DocumentSessionApplyMarkdownEditsResult {
+export interface DocumentSessionApplyEditsResult {
applied: number;
failed: number;
failures: {
- code: DocumentMarkdownEditFailureCode | "invalid_document_projection";
+ code: DocumentAiEditFailureCode | "content_changed" | "operation_id_conflict";
index: number;
}[];
- status: DocumentMarkdownEditResultStatus;
- warnings: string[];
+ status: DocumentAiEditResultStatus;
+}
+
+interface StoredDocumentEditReceipt {
+ afterHash: string;
+ beforeDocument?: TiptapDocumentJson;
+ id: string;
+ inputHash: string;
+ previousReceiptId: string | null;
+ result: DocumentSessionApplyEditsResult;
+ status: "applied" | "reverted";
}
+type ResolvedDocumentEditReceiptGroup =
+ | {
+ beforeDocument: TiptapDocumentJson;
+ lastReceiptId: string;
+ previousReceiptId: string | null;
+ receipts: StoredDocumentEditReceipt[];
+ status: "ready";
+ }
+ | {
+ status: Exclude;
+ };
+
export class DocumentSession extends YServer {
static override options = {
hibernate: true,
};
- private markdownSnapshot?: {
- revision: string;
- snapshot: DocumentMarkdownSnapshot;
- stateVector: Uint8Array;
- };
private deleted = false;
static override callbackOptions = {
@@ -107,7 +131,7 @@ export class DocumentSession extends YServer {
}
override async onLoad() {
- const persistedUpdate = await this.ctx.storage.get(persistedYDocUpdateKey);
+ const persistedUpdate = await readPersistedDocumentYjsUpdate(this.ctx.storage);
if (this.deleted) {
return;
}
@@ -147,13 +171,26 @@ export class DocumentSession extends YServer {
await this.checkpointToKernel();
}
- async applyMarkdownEdits(
- input: DocumentSessionApplyMarkdownEditsInput,
- ): Promise {
+ async applyEdits(
+ input: DocumentSessionApplyEditsInput,
+ ): Promise {
this.assertActive();
- const currentDocument = this.getCurrentTiptapDocument();
- const markdown = serializeTiptapDocumentToMarkdown(currentDocument);
- const editResult = applyDocumentMarkdownEdits(markdown, input.edits);
+ const inputHash = await sha256Base64UrlText(JSON.stringify(input.edits));
+ const existingReceipt = await this.getDocumentEditReceipt(input.operationId);
+
+ if (existingReceipt) {
+ return existingReceipt.inputHash === inputHash
+ ? existingReceipt.result
+ : operationIdConflictResult(input.edits.length);
+ }
+
+ const latestReceiptId = await this.ctx.storage.get(latestDocumentEditReceiptKey);
+ const latestReceipt = latestReceiptId
+ ? await this.getDocumentEditReceipt(latestReceiptId)
+ : undefined;
+ const referencedDocument = await this.getReferencedDocumentSnapshot();
+ const currentDocument = coerceTiptapDocumentJson(referencedDocument.document.toJSON());
+ const editResult = await applyDocumentAiEdits(currentDocument, input.edits);
if (editResult.applied === 0) {
return {
@@ -161,67 +198,138 @@ export class DocumentSession extends YServer {
failed: editResult.failed,
failures: editResult.failures,
status: editResult.status,
- warnings: [],
};
}
- let projection;
+ const beforeDocumentText = stringifyTiptapDocumentJson(currentDocument);
+ const afterDocumentText = stringifyTiptapDocumentJson(editResult.document);
+ const [beforeHash, afterHash] = await Promise.all([
+ sha256Base64UrlText(beforeDocumentText),
+ sha256Base64UrlText(afterDocumentText),
+ ]);
- try {
- projection = parseMarkdownToTiptapDocumentProjection(editResult.content);
- } catch {
+ if (stringifyTiptapDocumentJson(this.getCurrentTiptapDocument()) !== beforeDocumentText) {
return {
applied: 0,
failed: input.edits.length,
- failures: [...editResult.failures, { code: "invalid_document_projection", index: -1 }],
+ failures: input.edits.map((_, index) => ({
+ code: "content_changed",
+ index,
+ })),
status: "rejected",
- warnings: [],
};
}
- this.replaceCurrentDocument(projection.document);
+ const result: DocumentSessionApplyEditsResult = {
+ applied: editResult.applied,
+ failed: editResult.failed,
+ failures: editResult.failures,
+ status: editResult.status,
+ };
+ const receipt: StoredDocumentEditReceipt = {
+ afterHash,
+ ...(fitsDocumentEditReceiptSnapshot(beforeDocumentText)
+ ? { beforeDocument: currentDocument }
+ : {}),
+ id: input.operationId,
+ inputHash,
+ previousReceiptId:
+ latestReceipt?.status === "applied" && latestReceipt.afterHash === beforeHash
+ ? latestReceipt.id
+ : null,
+ result,
+ status: "applied",
+ };
- await this.persistYDoc();
+ this.reconcileCurrentDocument(editResult.document);
+ const persistedUpdate = Y.encodeStateAsUpdate(this.document);
+ await this.ctx.storage.transaction(async (transaction) => {
+ await writePersistedDocumentYjsUpdate(transaction, persistedUpdate);
+ await Promise.all([
+ transaction.put(getDocumentEditReceiptKey(receipt.id), receipt),
+ transaction.put(latestDocumentEditReceiptKey, receipt.id),
+ ]);
+ });
this.assertActive();
- await this.checkpointToKernel();
+ await this.checkpointToKernel(input.operationId);
this.assertActive();
+ return result;
+ }
+
+ async getDocumentEditReceiptStatus(input: {
+ receiptIds: string[];
+ }): Promise {
+ const group = await this.resolveDocumentEditReceiptGroup(input.receiptIds);
+ return { status: group.status };
+ }
+
+ async getDocumentEditReceiptReview(input: {
+ receiptIds: string[];
+ }): Promise {
+ const group = await this.resolveDocumentEditReceiptGroup(input.receiptIds);
+
+ if (group.status !== "ready") {
+ return { status: group.status };
+ }
+
return {
- applied: editResult.applied,
- failed: editResult.failed,
- failures: editResult.failures,
- status: editResult.status,
- warnings: projection.warnings,
+ afterContent: stringifyTiptapDocumentJson(this.getCurrentTiptapDocument()),
+ beforeContent: stringifyTiptapDocumentJson(group.beforeDocument),
+ status: "ready",
};
}
- async readMarkdownChunk(
- input: DocumentMarkdownChunkReadInput,
- ): Promise {
- this.assertActive();
- const stateVector = Uint8Array.from(Y.encodeStateVector(this.document));
- let currentSnapshot = this.markdownSnapshot;
- if (!currentSnapshot || !uint8ArraysEqual(currentSnapshot.stateVector, stateVector)) {
- const markdown = serializeTiptapDocumentToMarkdown(this.getCurrentTiptapDocument());
- currentSnapshot = {
- revision: await sha256Base64Url(stateVector),
- snapshot: createDocumentMarkdownSnapshot(markdown),
- stateVector,
- };
- this.assertActive();
- this.markdownSnapshot = currentSnapshot;
+ async undoDocumentEditReceipt(input: {
+ receiptIds: string[];
+ }): Promise {
+ const group = await this.resolveDocumentEditReceiptGroup(input.receiptIds);
+
+ if (group.status !== "ready") {
+ return { status: group.status };
}
- if (input.expectedRevision && input.expectedRevision !== currentSnapshot.revision) {
+
+ this.reconcileCurrentDocument(group.beforeDocument);
+ const persistedUpdate = Y.encodeStateAsUpdate(this.document);
+
+ await this.ctx.storage.transaction(async (transaction) => {
+ await writePersistedDocumentYjsUpdate(transaction, persistedUpdate);
+ await Promise.all(
+ group.receipts.map((receipt) =>
+ transaction.put(getDocumentEditReceiptKey(receipt.id), {
+ ...receipt,
+ status: "reverted",
+ } satisfies StoredDocumentEditReceipt),
+ ),
+ );
+
+ if (group.previousReceiptId) {
+ await transaction.put(latestDocumentEditReceiptKey, group.previousReceiptId);
+ } else {
+ await transaction.delete(latestDocumentEditReceiptKey);
+ }
+ });
+
+ await this.checkpointToKernel(`undo:${group.lastReceiptId}`);
+
+ return { status: "reverted" };
+ }
+
+ async readHtmlChunk(input: DocumentHtmlChunkReadInput): Promise {
+ this.assertActive();
+ const { document, stateVector } = await this.getReferencedDocumentSnapshot();
+ const revision = await sha256Base64Url(stateVector);
+ if (input.expectedRevision && input.expectedRevision !== revision) {
return { status: "content_changed" };
}
- const chunk = currentSnapshot.snapshot.readChunk(input.offset);
- return chunk
- ? { ...chunk, revision: currentSnapshot.revision, status: "ready" }
- : { status: "invalid_offset" };
+ const chunk = await readDocumentHtmlChunk(document, input.offset);
+ return chunk ? { ...chunk, revision, status: "ready" } : { status: "invalid_offset" };
}
async purgeForDeletion(): Promise {
+ // Deliberately does not hydrate the document: this only wipes durable
+ // storage, and onLoad could otherwise reseed from the deleted item.
this.deleted = true;
for (const connection of this.getConnections()) {
connection.close(1008, "Document deleted");
@@ -230,32 +338,94 @@ export class DocumentSession extends YServer {
await this.ctx.storage.deleteAll();
}
- private async checkpointToKernel() {
+ private async checkpointToKernel(clientMutationId: string | null = null) {
const room = getDocumentSessionRoomNameParts(this.name);
- const document = coerceTiptapDocumentJson(
- yDocToProsemirrorJSON(this.document, tiptapDocumentYjsField),
- );
+ const document = this.getCurrentTiptapDocument();
const kernel = await this.getWorkspaceKernel(room.workspaceId);
await kernel.commitDocumentCheckpoint({
itemId: room.itemId,
content: stringifyTiptapDocumentJson(document),
actorUserId: null,
- clientMutationId: null,
+ clientMutationId,
});
}
+ private async getDocumentEditReceipt(receiptId: string) {
+ return await this.ctx.storage.get(
+ getDocumentEditReceiptKey(receiptId),
+ );
+ }
+
+ private async resolveDocumentEditReceiptGroup(
+ receiptIds: string[],
+ ): Promise {
+ if (receiptIds.length === 0 || new Set(receiptIds).size !== receiptIds.length) {
+ return { status: "not_found" };
+ }
+
+ const receipts = await Promise.all(
+ receiptIds.map((receiptId) => this.getDocumentEditReceipt(receiptId)),
+ );
+ const storedReceipts = receipts.filter(
+ (receipt): receipt is StoredDocumentEditReceipt => receipt !== undefined,
+ );
+ if (storedReceipts.length !== receiptIds.length) {
+ return { status: "not_found" };
+ }
+ if (storedReceipts.some((receipt) => receipt.status === "reverted")) {
+ return { status: "reverted" };
+ }
+ for (let index = 1; index < storedReceipts.length; index += 1) {
+ if (storedReceipts[index]?.previousReceiptId !== storedReceipts[index - 1]?.id) {
+ return { status: "not_latest" };
+ }
+ }
+
+ const firstReceipt = storedReceipts[0];
+ const lastReceipt = storedReceipts.at(-1);
+ if (!firstReceipt || !lastReceipt) {
+ return { status: "not_found" };
+ }
+
+ const latestReceiptId = await this.ctx.storage.get(latestDocumentEditReceiptKey);
+ if (latestReceiptId !== lastReceipt.id) {
+ return { status: "not_latest" };
+ }
+ if (!firstReceipt.beforeDocument) {
+ return { status: "review_unavailable" };
+ }
+
+ this.assertActive();
+ const currentDocumentText = stringifyTiptapDocumentJson(this.getCurrentTiptapDocument());
+ const currentHash = await sha256Base64UrlText(currentDocumentText);
+
+ return currentHash === lastReceipt.afterHash &&
+ stringifyTiptapDocumentJson(this.getCurrentTiptapDocument()) === currentDocumentText
+ ? {
+ beforeDocument: firstReceipt.beforeDocument,
+ lastReceiptId: lastReceipt.id,
+ previousReceiptId: firstReceipt.previousReceiptId,
+ receipts: storedReceipts,
+ status: "ready",
+ }
+ : { status: "content_changed" };
+ }
+
private getCurrentTiptapDocument() {
- return coerceTiptapDocumentJson(yDocToProsemirrorJSON(this.document, tiptapDocumentYjsField));
+ return coerceTiptapDocumentJson(this.getCurrentProseMirrorDocument().toJSON());
}
- private replaceCurrentDocument(document: TiptapDocumentJson) {
- const fragment = this.document.getXmlFragment(tiptapDocumentYjsField);
+ private getCurrentProseMirrorDocument() {
+ return yXmlFragmentToProseMirrorRootNode(
+ this.document.getXmlFragment(tiptapDocumentYjsField),
+ getTiptapDocumentSchema(),
+ );
+ }
- this.document.transact(() => {
- fragment.delete(0, fragment.length);
- prosemirrorJSONToYXmlFragment(getTiptapDocumentSchema(), document, fragment);
- }, this);
+ private reconcileCurrentDocument(document: TiptapDocumentJson) {
+ const fragment = this.document.getXmlFragment(tiptapDocumentYjsField);
+ prosemirrorJSONToYXmlFragment(getTiptapDocumentSchema(), document, fragment);
}
private async persistYDoc() {
@@ -263,7 +433,30 @@ export class DocumentSession extends YServer {
return;
}
- await this.ctx.storage.put(persistedYDocUpdateKey, Y.encodeStateAsUpdate(this.document));
+ const update = Y.encodeStateAsUpdate(this.document);
+ await this.ctx.storage.transaction((transaction) =>
+ writePersistedDocumentYjsUpdate(transaction, update),
+ );
+ }
+
+ private async getReferencedDocumentSnapshot() {
+ const refs = ensureProseMirrorDocumentAiRefs(this.getCurrentProseMirrorDocument());
+ if (refs.changed) {
+ this.reconcileCurrentDocument(coerceTiptapDocumentJson(refs.document.toJSON()));
+ await this.persistYDoc();
+ return this.getDocumentSnapshot();
+ }
+ return {
+ document: refs.document,
+ stateVector: Uint8Array.from(Y.encodeStateVector(this.document)),
+ };
+ }
+
+ private getDocumentSnapshot() {
+ return {
+ document: this.getCurrentProseMirrorDocument(),
+ stateVector: Uint8Array.from(Y.encodeStateVector(this.document)),
+ };
}
private assertActive() {
@@ -277,10 +470,6 @@ export class DocumentSession extends YServer {
}
}
-function uint8ArraysEqual(left: Uint8Array, right: Uint8Array) {
- return left.length === right.length && left.every((value, index) => value === right[index]);
-}
-
function getDocumentSessionRoomNameParts(roomName: string): DocumentSessionRouteParams {
const separatorIndex = roomName.indexOf(":");
@@ -293,3 +482,26 @@ function getDocumentSessionRoomNameParts(roomName: string): DocumentSessionRoute
itemId: roomName.slice(separatorIndex + 1),
};
}
+
+function getDocumentEditReceiptKey(receiptId: string) {
+ return `${documentEditReceiptKeyPrefix}${receiptId}`;
+}
+
+function fitsDocumentEditReceiptSnapshot(documentText: string) {
+ return (
+ documentText.length <= maximumDocumentEditReceiptSnapshotBytes &&
+ new TextEncoder().encode(documentText).byteLength <= maximumDocumentEditReceiptSnapshotBytes
+ );
+}
+
+function operationIdConflictResult(editCount: number): DocumentSessionApplyEditsResult {
+ return {
+ applied: 0,
+ failed: editCount,
+ failures: Array.from({ length: editCount }, (_, index) => ({
+ code: "operation_id_conflict",
+ index,
+ })),
+ status: "rejected",
+ };
+}
diff --git a/src/features/workspaces/documents/document-yjs-persistence.test.ts b/src/features/workspaces/documents/document-yjs-persistence.test.ts
new file mode 100644
index 00000000..e2025d6e
--- /dev/null
+++ b/src/features/workspaces/documents/document-yjs-persistence.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ readPersistedDocumentYjsUpdate,
+ writePersistedDocumentYjsUpdate,
+} from "#/features/workspaces/documents/document-yjs-persistence";
+
+const manifestKey = "document-session:yjs-update";
+const partKeyPrefix = "document-session:yjs-update-part:";
+
+describe("document Yjs persistence", () => {
+ it("reads legacy single-value updates", async () => {
+ const memory = createMemoryStorage();
+ const update = new Uint8Array([1, 2, 3]);
+ memory.values.set(manifestKey, update);
+
+ await expect(readPersistedDocumentYjsUpdate(memory.storage)).resolves.toEqual(update);
+ });
+
+ it("round-trips multipart updates and removes the previous generation", async () => {
+ const memory = createMemoryStorage();
+ const largeUpdate = new Uint8Array(2_500_000).map((_, index) => index % 251);
+
+ await writePersistedDocumentYjsUpdate(memory.transaction, largeUpdate);
+ await expect(readPersistedDocumentYjsUpdate(memory.storage)).resolves.toEqual(largeUpdate);
+ expect(getPartKeys(memory.values)).toHaveLength(3);
+
+ const replacement = new Uint8Array([4, 5, 6]);
+ await writePersistedDocumentYjsUpdate(memory.transaction, replacement);
+ await expect(readPersistedDocumentYjsUpdate(memory.storage)).resolves.toEqual(replacement);
+ expect(getPartKeys(memory.values)).toHaveLength(1);
+ });
+});
+
+function createMemoryStorage() {
+ const values = new Map();
+ const get = async (keyOrKeys: string | string[]) => {
+ if (typeof keyOrKeys === "string") {
+ return values.get(keyOrKeys);
+ }
+ return new Map(
+ keyOrKeys.flatMap((key) => (values.has(key) ? [[key, values.get(key)] as const] : [])),
+ );
+ };
+ const put = async (key: string, value: unknown) => {
+ values.set(key, value);
+ };
+ const remove = async (keyOrKeys: string | string[]) => {
+ const keys = typeof keyOrKeys === "string" ? [keyOrKeys] : keyOrKeys;
+ for (const key of keys) {
+ values.delete(key);
+ }
+ return keys.length;
+ };
+ const methods = { delete: remove, get, put };
+
+ return {
+ storage: methods as unknown as DurableObjectStorage,
+ transaction: methods as unknown as DurableObjectTransaction,
+ values,
+ };
+}
+
+function getPartKeys(values: ReadonlyMap) {
+ return [...values.keys()].filter((key) => key.startsWith(partKeyPrefix));
+}
diff --git a/src/features/workspaces/documents/document-yjs-persistence.ts b/src/features/workspaces/documents/document-yjs-persistence.ts
new file mode 100644
index 00000000..9a36c190
--- /dev/null
+++ b/src/features/workspaces/documents/document-yjs-persistence.ts
@@ -0,0 +1,106 @@
+const persistedDocumentYjsUpdateKey = "document-session:yjs-update";
+const persistedDocumentYjsPartKeyPrefix = "document-session:yjs-update-part:";
+// Keep reconstructed state comfortably below the Worker isolate limit, and
+// each stored value comfortably below Durable Object storage's 2 MB ceiling.
+const maximumPersistedDocumentYjsBytes = 16 * 1024 * 1024;
+const maximumPersistedDocumentYjsPartBytes = 1024 * 1024;
+
+interface PersistedDocumentYjsManifest {
+ generation: string;
+ partCount: number;
+ totalBytes: number;
+ version: 1;
+}
+
+export async function readPersistedDocumentYjsUpdate(storage: DurableObjectStorage) {
+ const stored = await storage.get(
+ persistedDocumentYjsUpdateKey,
+ );
+ if (!stored || stored instanceof Uint8Array) {
+ return stored;
+ }
+ if (!isPersistedDocumentYjsManifest(stored)) {
+ throw new Error("Persisted document state manifest is invalid.");
+ }
+
+ const keys = getPersistedDocumentYjsPartKeys(stored);
+ const parts = await storage.get(keys);
+ const update = new Uint8Array(stored.totalBytes);
+ let offset = 0;
+
+ for (const key of keys) {
+ const part = parts.get(key);
+ if (!(part instanceof Uint8Array) || offset + part.byteLength > update.byteLength) {
+ throw new Error("Persisted document state is incomplete.");
+ }
+ update.set(part, offset);
+ offset += part.byteLength;
+ }
+
+ if (offset !== update.byteLength) {
+ throw new Error("Persisted document state size does not match its manifest.");
+ }
+
+ return update;
+}
+
+export async function writePersistedDocumentYjsUpdate(
+ transaction: DurableObjectTransaction,
+ update: Uint8Array,
+) {
+ if (update.byteLength > maximumPersistedDocumentYjsBytes) {
+ throw new Error("Document is too large to persist safely.");
+ }
+
+ const previous = await transaction.get(
+ persistedDocumentYjsUpdateKey,
+ );
+ const manifest: PersistedDocumentYjsManifest = {
+ generation: crypto.randomUUID(),
+ partCount: Math.max(1, Math.ceil(update.byteLength / maximumPersistedDocumentYjsPartBytes)),
+ totalBytes: update.byteLength,
+ version: 1,
+ };
+ const keys = getPersistedDocumentYjsPartKeys(manifest);
+
+ await Promise.all(
+ keys.map((key, index) => {
+ const start = index * maximumPersistedDocumentYjsPartBytes;
+ return transaction.put(
+ key,
+ update.slice(start, start + maximumPersistedDocumentYjsPartBytes),
+ );
+ }),
+ );
+ await transaction.put(persistedDocumentYjsUpdateKey, manifest);
+
+ if (isPersistedDocumentYjsManifest(previous)) {
+ await transaction.delete(getPersistedDocumentYjsPartKeys(previous));
+ }
+}
+
+function getPersistedDocumentYjsPartKeys(manifest: PersistedDocumentYjsManifest) {
+ return Array.from(
+ { length: manifest.partCount },
+ (_, index) => `${persistedDocumentYjsPartKeyPrefix}${manifest.generation}:${index}`,
+ );
+}
+
+function isPersistedDocumentYjsManifest(
+ value: Uint8Array | PersistedDocumentYjsManifest | undefined,
+): value is PersistedDocumentYjsManifest {
+ return (
+ value !== undefined &&
+ !(value instanceof Uint8Array) &&
+ value.version === 1 &&
+ typeof value.generation === "string" &&
+ value.generation.length > 0 &&
+ value.generation.length <= 64 &&
+ Number.isInteger(value.partCount) &&
+ Number.isInteger(value.totalBytes) &&
+ value.totalBytes >= 0 &&
+ value.totalBytes <= maximumPersistedDocumentYjsBytes &&
+ value.partCount ===
+ Math.max(1, Math.ceil(value.totalBytes / maximumPersistedDocumentYjsPartBytes))
+ );
+}
diff --git a/src/features/workspaces/documents/tiptap-schema.ts b/src/features/workspaces/documents/tiptap-schema.ts
index 2c9e25ca..cf29e06f 100644
--- a/src/features/workspaces/documents/tiptap-schema.ts
+++ b/src/features/workspaces/documents/tiptap-schema.ts
@@ -1,4 +1,4 @@
-import { type AnyExtension, getSchema } from "@tiptap/core";
+import { type AnyExtension, Extension, getSchema } from "@tiptap/core";
import CodeBlock from "@tiptap/extension-code-block";
import Highlight from "@tiptap/extension-highlight";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
@@ -11,6 +11,40 @@ import UnderlineExtension from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
export const tiptapDocumentYjsField = "default";
+export const tiptapDocumentAiRefAttribute = "aiRef";
+
+const DocumentAiRef = Extension.create({
+ name: "documentAiRef",
+
+ addGlobalAttributes() {
+ const blockTypes = this.extensions
+ .filter(
+ (extension) =>
+ extension.type === "node" &&
+ typeof extension.config?.group === "string" &&
+ extension.config.group.split(/\s+/).includes("block"),
+ )
+ .map((extension) => extension.name);
+
+ return [
+ {
+ types: blockTypes,
+ attributes: {
+ [tiptapDocumentAiRefAttribute]: {
+ default: null,
+ // AI refs are generated by the document session. Pasted or model-authored
+ // HTML must not be able to duplicate or choose them.
+ parseHTML: () => null,
+ renderHTML: (attributes: Record) => {
+ const ref = attributes[tiptapDocumentAiRefAttribute];
+ return typeof ref === "string" && ref ? { "data-ref": ref } : {};
+ },
+ },
+ },
+ },
+ ];
+ },
+});
/**
* Server-side code block extension. The editor swaps in `CodeBlockShiki`,
@@ -25,6 +59,7 @@ export function getTiptapDocumentSchemaExtensions({
codeBlock?: AnyExtension;
} = {}) {
return [
+ DocumentAiRef,
StarterKit.configure({
heading: {
levels: [1, 2, 3],
diff --git a/src/features/workspaces/documents/use-document-collaboration-session.ts b/src/features/workspaces/documents/use-document-collaboration-session.ts
index 15db501f..536da2f4 100644
--- a/src/features/workspaces/documents/use-document-collaboration-session.ts
+++ b/src/features/workspaces/documents/use-document-collaboration-session.ts
@@ -4,6 +4,7 @@ import { WebsocketProvider } from "y-partyserver/provider";
import * as Y from "yjs";
import { getDocumentSessionBaseUrl } from "#/features/workspaces/agent-routes";
+import { tiptapDocumentYjsField } from "#/features/workspaces/documents/tiptap-schema";
import { getCollaborationUserColor } from "#/lib/design-system-colors";
const localDocumentReadyKey = "server-synced";
@@ -203,7 +204,10 @@ function createActiveDocumentSession(input: {
void session.persistence.whenSynced
.then(() => session.persistence.get(localDocumentReadyKey))
.then((wasServerSynced) => {
- if (wasServerSynced === localDocumentReadyValue) {
+ if (
+ wasServerSynced === localDocumentReadyValue &&
+ session.ydoc.getXmlFragment(tiptapDocumentYjsField).length > 0
+ ) {
markReady();
}
})
diff --git a/src/features/workspaces/documents/use-document-edit-review-overlay.ts b/src/features/workspaces/documents/use-document-edit-review-overlay.ts
new file mode 100644
index 00000000..db0f2ba8
--- /dev/null
+++ b/src/features/workspaces/documents/use-document-edit-review-overlay.ts
@@ -0,0 +1,106 @@
+import type { Editor } from "@tiptap/core";
+import { useQuery } from "@tanstack/react-query";
+import { useEffect, useRef } from "react";
+import { toast } from "sonner";
+
+import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context";
+import type { DocumentEditReceiptStatus } from "#/features/workspaces/documents/document-edit-receipt";
+import {
+ hideDocumentEditReview,
+ showDocumentEditReview,
+} from "#/features/workspaces/documents/document-edit-review-extension";
+import { documentEditReceiptReviewQueryOptions } from "#/features/workspaces/documents/document-edit-review-queries";
+import {
+ coerceTiptapDocumentJson,
+ stringifyTiptapDocumentJson,
+} from "#/features/workspaces/documents/tiptap-document";
+
+export function useDocumentEditReviewOverlay({
+ editor,
+ itemId,
+ viewInstanceId,
+ workspaceId,
+}: {
+ editor: Editor | null;
+ itemId: string;
+ viewInstanceId: string;
+ workspaceId: string;
+}) {
+ const { activeReview, hideReview } = useDocumentEditReview();
+ const target =
+ activeReview?.itemId === itemId && activeReview.viewInstanceId === viewInstanceId
+ ? activeReview
+ : null;
+ const reviewQuery = useQuery({
+ ...documentEditReceiptReviewQueryOptions({
+ itemId,
+ receiptIds: target?.receiptIds ?? ["inactive"],
+ workspaceId,
+ }),
+ enabled: Boolean(editor && target),
+ });
+ const hasShownReview = useRef(false);
+
+ useEffect(() => {
+ if (!editor || !target) {
+ hasShownReview.current = false;
+ return;
+ }
+
+ if (reviewQuery.isError) {
+ toast.error("Could not load these changes.");
+ hideReview();
+ return;
+ }
+
+ const review = reviewQuery.data;
+ if (!review) {
+ return;
+ }
+ if (review.status !== "ready") {
+ toast.error(getUnavailableReviewMessage(review.status));
+ hideReview();
+ return;
+ }
+
+ const expectedDocument = stringifyTiptapDocumentJson(review.afterDocument);
+ const syncReview = () => {
+ const currentDocument = stringifyTiptapDocumentJson(
+ coerceTiptapDocumentJson(editor.getJSON()),
+ );
+
+ if (currentDocument === expectedDocument) {
+ showDocumentEditReview(editor, review.beforeDocument);
+ hasShownReview.current = true;
+ } else if (hasShownReview.current) {
+ hideReview();
+ }
+ };
+
+ syncReview();
+ editor.on("update", syncReview);
+
+ return () => {
+ editor.off("update", syncReview);
+ hideDocumentEditReview(editor);
+ hasShownReview.current = false;
+ };
+ }, [editor, hideReview, reviewQuery.data, reviewQuery.isError, target]);
+}
+
+function getUnavailableReviewMessage(status: DocumentEditReceiptStatus) {
+ switch (status) {
+ case "content_changed":
+ return "The document changed after this AI edit.";
+ case "review_unavailable":
+ return "Change review is unavailable for this large document.";
+ case "not_latest":
+ return "Only the latest unchanged AI edit can be reviewed.";
+ case "not_found":
+ return "These changes are no longer available.";
+ case "reverted":
+ return "These changes were already undone.";
+ case "ready":
+ return "Changes are ready.";
+ }
+}
diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts
index 5f4937e8..e3500482 100644
--- a/src/features/workspaces/operations/create-items.ts
+++ b/src/features/workspaces/operations/create-items.ts
@@ -6,7 +6,7 @@ import {
} from "#/features/workspaces/operations/relations";
import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context";
import type { WorkspaceKernelPathResolution } from "#/features/workspaces/kernel/workspace-kernel-types";
-import { parseMarkdownToTiptapDocumentProjection } from "#/features/workspaces/documents/document-markdown";
+import { parseDocumentAiHtml } from "#/features/workspaces/documents/document-ai-html";
import { stringifyTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
import {
createWorkspaceReferenceRecords,
@@ -54,7 +54,6 @@ export interface CreatedWorkspaceItem {
itemId: string;
path: string;
type: "document" | "folder";
- warnings?: string[];
}
export interface CreateWorkspaceItemsOperationResult {
@@ -177,9 +176,6 @@ export async function createWorkspaceItemsOperation(
itemId: id,
path: createdPath,
type: itemInput.type,
- ...(initialContent.warnings && initialContent.warnings.length > 0
- ? { warnings: initialContent.warnings }
- : {}),
});
}
@@ -282,7 +278,6 @@ function getCreateWorkspaceItemInitialContent(input: CreateWorkspaceItemOperatio
| {
content?: string;
status: "ready";
- warnings?: string[];
}
| {
code: "invalid_initial_content";
@@ -293,12 +288,9 @@ function getCreateWorkspaceItemInitialContent(input: CreateWorkspaceItemOperatio
}
try {
- const projection = parseMarkdownToTiptapDocumentProjection(input.initialContent);
-
return {
- content: stringifyTiptapDocumentJson(projection.document),
+ content: stringifyTiptapDocumentJson(parseDocumentAiHtml(input.initialContent)),
status: "ready",
- ...(projection.warnings.length > 0 ? { warnings: projection.warnings } : {}),
};
} catch {
return {
diff --git a/src/features/workspaces/operations/edit-item.ts b/src/features/workspaces/operations/edit-item.ts
index c0583e74..4e74c4c3 100644
--- a/src/features/workspaces/operations/edit-item.ts
+++ b/src/features/workspaces/operations/edit-item.ts
@@ -5,23 +5,24 @@ import {
} from "#/features/workspaces/operations/workspace-operation-context";
import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context";
import {
- type DocumentMarkdownEdit,
- documentMarkdownEditFailureCodes,
-} from "#/features/workspaces/documents/document-markdown-edits";
+ type DocumentAiEdit,
+ documentAiEditFailureCodes,
+} from "#/features/workspaces/documents/document-ai-edits";
export const editWorkspaceItemFailureCodes = [
"cannot_edit_root",
"path_not_absolute",
"path_not_found",
"unsupported_item_type",
- ...documentMarkdownEditFailureCodes,
- "invalid_document_projection",
+ ...documentAiEditFailureCodes,
+ "content_changed",
+ "operation_id_conflict",
] as const;
type EditWorkspaceItemFailureCode = (typeof editWorkspaceItemFailureCodes)[number];
export interface EditWorkspaceItemOperationInput {
- edits: DocumentMarkdownEdit[];
+ edits: DocumentAiEdit[];
path: string;
}
@@ -33,8 +34,8 @@ interface EditWorkspaceItemFailure {
export interface EditWorkspaceItemOperationResult {
applied: number;
failed: EditWorkspaceItemFailure[];
+ itemId?: string;
path: string;
- warnings: string[];
}
export async function editWorkspaceItemOperation(
@@ -59,7 +60,6 @@ export async function editWorkspaceItemOperation(
if (resolution.status === "failed") {
return {
path: resolution.failure.path,
- warnings: [],
...failedWorkspaceEditResult(resolution.failure.code, failureCount),
};
}
@@ -67,7 +67,6 @@ export async function editWorkspaceItemOperation(
if (resolution.item.type !== "document") {
return {
path: resolution.path,
- warnings: [],
...failedWorkspaceEditResult("unsupported_item_type", edits.length),
};
}
@@ -77,15 +76,16 @@ export async function editWorkspaceItemOperation(
workspaceId: accessContext.workspaceId,
});
- const result = await documentSession.applyMarkdownEdits({
+ const result = await documentSession.applyEdits({
edits,
+ operationId: accessContext.operationId,
});
return {
applied: result.applied,
failed: result.failures,
+ itemId: resolution.item.id,
path: resolution.path,
- warnings: result.warnings,
};
}
diff --git a/src/features/workspaces/operations/workspace-tool-definitions.ts b/src/features/workspaces/operations/workspace-tool-definitions.ts
index 9803ad05..a3427b1f 100644
--- a/src/features/workspaces/operations/workspace-tool-definitions.ts
+++ b/src/features/workspaces/operations/workspace-tool-definitions.ts
@@ -16,7 +16,7 @@ import {
workspaceDeleteItemsInputExamples,
workspaceDeleteItemsInputSchema,
workspaceDeleteItemsOutputSchema,
- workspaceDocumentMarkdownMathInstruction,
+ workspaceDocumentHtmlInstruction,
workspaceEditItemInputExamples,
workspaceEditItemInputSchema,
workspaceEditItemOutputSchema,
@@ -149,7 +149,7 @@ export const workspaceToolDefinitions = [
name: "workspace_read_items",
access: "read",
description:
- "Read ThinkEx documents and extracted files by absolute path. Documents return bounded line chunks; files support explicit physical-page selections. Continue either kind with the returned nextCursor. Uploaded files extract in the background, so a read can come back pending or report that extraction failed; each result carries the guidance for handling it.",
+ "Read ThinkEx documents and extracted files by absolute path. Documents return bounded HTML block chunks; each top-level data-ref is an item-local edit target, not a citation ref. Files support explicit physical-page selections. Continue either kind with nextCursor. Uploaded files may still be extracting; each result carries any needed handling guidance.",
inputSchema: workspaceReadItemsInputSchema,
inputExamples: workspaceReadItemsInputExamples,
outputSchema: workspaceReadItemsOutputSchema,
@@ -214,7 +214,7 @@ export const workspaceToolDefinitions = [
defineWorkspaceTool({
name: "workspace_create_items",
access: "write",
- description: `Create one or more folders or documents at exact absolute paths. If a path already exists, creation fails instead of renaming. ${workspaceDocumentMarkdownMathInstruction}`,
+ description: `Create one or more folders or documents at exact absolute paths. If a path already exists, creation fails instead of renaming. ${workspaceDocumentHtmlInstruction}`,
inputSchema: workspaceCreateItemsInputSchema,
inputExamples: workspaceCreateItemsInputExamples,
outputSchema: workspaceCreateItemsOutputSchema,
@@ -244,7 +244,7 @@ export const workspaceToolDefinitions = [
defineWorkspaceTool({
name: "workspace_edit_item",
access: "write",
- description: `Edit one actual ThinkEx workspace document by absolute path. Use workspace_link_items to add relationships. Read before editing unless the user requested a simple append or prepend. ${workspaceDocumentMarkdownMathInstruction}`,
+ description: `Edit one actual ThinkEx workspace document by absolute path using structural HTML operations. Read first for targeted edits; replace_all can rewrite the whole document without a read. Use workspace_link_items to add relationships. ${workspaceDocumentHtmlInstruction}`,
inputSchema: workspaceEditItemInputSchema,
inputExamples: workspaceEditItemInputExamples,
outputSchema: workspaceEditItemOutputSchema,
diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts
index a8533c38..bd57007d 100644
--- a/src/features/workspaces/operations/workspace-tool-schemas.ts
+++ b/src/features/workspaces/operations/workspace-tool-schemas.ts
@@ -14,8 +14,11 @@ import {
workspaceItemTypeSchema,
workspaceRelationKindSchema,
} from "#/features/workspaces/contracts";
-import { documentMarkdownEditSchema } from "#/features/workspaces/documents/document-markdown-edits";
import { workspaceReferenceRecordSchema } from "#/features/workspaces/locations/workspace-location";
+import {
+ documentAiEditSchema,
+ documentAiHtmlSchema,
+} from "#/features/workspaces/documents/document-ai-edits";
import { workspaceFileAssetKindSchema } from "#/features/workspaces/model/workspace-file";
import {
workspaceSearchInputSchema,
@@ -29,8 +32,8 @@ export {
workspaceSearchOutputSchema,
};
-export const workspaceDocumentMarkdownMathInstruction =
- "For document Markdown math, use `$...$` for inline math and `$$...$$` on separate lines for block math. Escape literal currency dollar signs as `\\$`.";
+export const workspaceDocumentHtmlInstruction =
+ 'Use semantic HTML with paragraphs, h1-h3, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. For math, use or .';
const workspacePathSchema = z.string().min(1);
const workspaceIndexSchema = z.number().int().nonnegative();
@@ -115,11 +118,11 @@ export const workspaceListItemsInputSchema = z.object({
export const workspaceEditItemInputSchema = z.object({
path: z.string().min(1).describe("Absolute path of one actual ThinkEx workspace item to edit."),
edits: z
- .array(documentMarkdownEditSchema)
+ .array(documentAiEditSchema)
.min(1)
.max(40)
.describe(
- `Ordered text edits to apply to a document projection, at most 40. ${workspaceDocumentMarkdownMathInstruction}`,
+ 'Ordered structural HTML edits, at most 40. For targeted operations, copy data-ref into the "ref" field; there is no "target" field. These refs are local to this document and are not workspace citation refs.',
),
});
@@ -152,7 +155,7 @@ export const workspaceMoveItemsInputSchema = z.object({
export const workspaceCreateItemsInputSchema = z.object({
items: z
.array(
- z.discriminatedUnion("type", [
+ z.union([
z.object({
type: z.literal("folder"),
path: z.string().min(1).describe("Final absolute path for the folder to create."),
@@ -174,10 +177,9 @@ export const workspaceCreateItemsInputSchema = z.object({
.describe(
"Optional relationships from this new document to other workspace items, at most 20.",
),
- initialContent: z
- .string()
+ initialContent: documentAiHtmlSchema
.describe(
- `Optional initial Markdown content for the document. ${workspaceDocumentMarkdownMathInstruction}`,
+ `Optional initial HTML content for the document. ${workspaceDocumentHtmlInstruction}`,
)
.optional(),
}),
@@ -262,7 +264,8 @@ export const workspaceCreateItemsInputExamples = createInputExamples<
{
type: "document",
path: "/Demo Folder/Demo Document",
- initialContent: "# Demo Document\nThis document was created as part of a tool demo.",
+ initialContent:
+ "Demo Document
This document was created as part of a tool demo.
",
relations: [
{
kind: "derived_from",
@@ -282,15 +285,27 @@ export const workspaceDeleteItemsInputExamples = createInputExamples<
export const workspaceEditItemInputExamples = createInputExamples<
z.input
->({
- path: "/Demo Folder/Demo Document",
- edits: [
- {
- type: "overwrite",
- content: "# Demo Document\nThis document was updated as part of the demo.",
- },
- ],
-});
+>(
+ {
+ path: "/Demo Folder/Demo Document",
+ edits: [
+ {
+ op: "replace",
+ ref: "b_JQrkL4Neurv2.r_6sNqkQxDdy",
+ html: "Updated paragraph.
",
+ },
+ ],
+ },
+ {
+ path: "/Demo Folder/Demo Document",
+ edits: [
+ {
+ op: "replace_all",
+ html: "Demo Document
This document was updated as part of the demo.
",
+ },
+ ],
+ },
+);
export const workspaceLinkItemsInputExamples = createInputExamples<
z.input
@@ -318,15 +333,7 @@ export const workspaceListItemsOutputSchema = z.object({
});
export const workspaceCreateItemsOutputSchema = createWorkspaceItemsResultSchema({
- itemSchema: z.object({
- itemId: z.string().min(1),
- path: workspacePathSchema,
- type: z.enum(["document", "folder"]),
- warnings: z
- .array(z.string())
- .optional()
- .describe("Content projection warnings for created documents."),
- }),
+ itemSchema: workspacePathItemSchema.extend({ itemId: z.string().min(1) }),
failureSchema: createFailureSchema(createWorkspaceItemsFailureCodes),
}).extend({
references: z.array(workspaceReferenceRecordSchema),
@@ -354,16 +361,13 @@ export const workspaceRenameItemOutputSchema = z.object({
export const workspaceEditItemOutputSchema = z.object({
path: workspacePathSchema,
applied: z.number().int().min(0),
+ itemId: z.string().optional(),
failed: z.array(
z.object({
code: z.enum(editWorkspaceItemFailureCodes),
index: workspaceIndexSchema,
}),
),
- warnings: z
- .array(z.string())
- .describe("Content projection warnings after applying edits.")
- .optional(),
});
export const workspaceLinkItemsOutputSchema = z.object({
From cb093c5127ff4661dd9a7907ff72f3adc8691f4a Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 21:00:11 -0400
Subject: [PATCH 02/49] feat(ai-chat): group document edit review actions
Show one review and undo surface per assistant turn while keeping edit receipts out of model output.
---
.../ai/ai-thread-orchestration-contract.ts | 58 +++++-
.../workspaces/ai/ai-thread-orchestration.ts | 20 ++-
.../ai/ai-thread-orchestration.worker.test.ts | 49 +++++
.../ai/ai-thread-tool-ui-metadata.ts | 57 ++++++
.../workspaces/components/WorkspaceLayout.tsx | 13 +-
.../ai-chat/AiChatDocumentEditActions.tsx | 170 ++++++++++++++++++
.../components/ai-chat/AiChatMessageRow.tsx | 24 ++-
.../ai-chat/AiChatToolActivityRow.tsx | 2 +-
.../ai-chat/ai-chat-codemode-activity.ts | 26 ++-
.../ai-chat/ai-chat-display-state.ts | 2 +-
.../ai-chat/ai-chat-document-edit-actions.ts | 82 +++++++++
.../document-editor/DocumentEditReviewBar.tsx | 32 ++++
.../document-editor/DocumentEditorSurface.tsx | 11 ++
.../locations/workspace-location-context.tsx | 4 +-
src/styles.css | 21 +++
15 files changed, 548 insertions(+), 23 deletions(-)
create mode 100644 src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts
create mode 100644 src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
create mode 100644 src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts
create mode 100644 src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx
diff --git a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts
index a3c694f9..dfebc732 100644
--- a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts
+++ b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts
@@ -8,6 +8,10 @@ import {
type AIToolOutcome,
} from "#/features/workspaces/ai/ai-tool-outcome";
import { summarizeAIThreadBrowserActivity } from "#/features/workspaces/ai/ai-thread-browser-activity";
+import {
+ getDocumentEditReceiptMetadata,
+ stripAIThreadToolUiMetadata,
+} from "#/features/workspaces/ai/ai-thread-tool-ui-metadata";
const orchestrationCallStateSchema = z.enum([
"executing",
@@ -76,6 +80,14 @@ const rawOrchestrationOutputSchema = z
});
const orchestrationCallSchema = z.object({
+ action: z
+ .object({
+ kind: z.literal("document-edit"),
+ itemId: z.string(),
+ path: z.string(),
+ receiptId: z.string(),
+ })
+ .optional(),
id: z.string(),
outcome: aiToolOutcomeSchema,
requiresApproval: z.boolean(),
@@ -130,7 +142,7 @@ export function normalizeAIThreadOrchestrationOutput(output: unknown): AIThreadO
return {
status: parsed.data.status,
executionId: parsed.data.executionId,
- result: parsed.data.result,
+ result: stripAIThreadToolUiMetadata(parsed.data.result),
calls,
outcome: childOutcome,
};
@@ -180,11 +192,23 @@ export function getAIThreadOrchestrationTelemetryOutput(output: unknown) {
return {
status: parsed.data.status,
outcome: parsed.data.outcome,
- calls: parsed.data.calls,
+ calls: parsed.data.calls.map(({ action: _action, ...call }) => call),
...(parsed.data.status === "paused" ? { pendingCount: parsed.data.pending.length } : {}),
};
}
+export function getAIThreadOrchestrationModelOutput(output: unknown) {
+ const parsed = aiThreadOrchestrationOutputSchema.safeParse(output);
+ if (!parsed.success) {
+ return output;
+ }
+
+ return {
+ ...parsed.data,
+ calls: parsed.data.calls.map(({ action: _action, ...call }) => call),
+ };
+}
+
function invalidOrchestrationOutput(output: unknown): AIThreadOrchestrationOutput {
return {
status: "error",
@@ -207,8 +231,10 @@ function getExecutionId(output: unknown) {
function normalizeCall(call: z.output) {
const outcome = getOrchestrationCallOutcome(call.method, call.state, call.result);
+ const action = getDocumentEditAction(call);
return {
+ ...(action ? { action } : {}),
id: `${call.seq}:${call.connector}:${call.method}`,
toolName: call.method,
state: call.state,
@@ -219,6 +245,28 @@ function normalizeCall(call: z.output) {
};
}
+function getDocumentEditAction(call: z.output) {
+ if (call.method !== "workspace_edit_item" || call.state !== "applied") {
+ return undefined;
+ }
+
+ const args = asRecord(call.args);
+ const result = asRecord(call.result);
+ const path =
+ typeof result.path === "string"
+ ? result.path
+ : typeof args.path === "string"
+ ? args.path
+ : undefined;
+ const applied = typeof result.applied === "number" ? result.applied : 0;
+ const itemId = typeof result.itemId === "string" ? result.itemId : undefined;
+ const receiptId = getDocumentEditReceiptMetadata(call.result);
+
+ return itemId && path && applied > 0 && receiptId
+ ? { itemId, kind: "document-edit" as const, path, receiptId }
+ : undefined;
+}
+
/**
* Collapse each *contiguous* run of CDP traffic into one browser receipt.
*
@@ -349,3 +397,9 @@ function summarizeOrchestrationCall(outcome: AIToolOutcome) {
return "Completed";
}
+
+function asRecord(value: unknown): Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
diff --git a/src/features/workspaces/ai/ai-thread-orchestration.ts b/src/features/workspaces/ai/ai-thread-orchestration.ts
index 3f88ae4b..70594c73 100644
--- a/src/features/workspaces/ai/ai-thread-orchestration.ts
+++ b/src/features/workspaces/ai/ai-thread-orchestration.ts
@@ -7,12 +7,16 @@ import { z } from "zod";
import { generateAIThreadCodemodeTypes } from "#/features/workspaces/ai/ai-codemode-types";
import { createAIThreadBrowserConnector } from "#/features/workspaces/ai/ai-thread-browser";
-import { normalizeAIThreadOrchestrationOutput } from "#/features/workspaces/ai/ai-thread-orchestration-contract";
+import {
+ getAIThreadOrchestrationModelOutput,
+ normalizeAIThreadOrchestrationOutput,
+} from "#/features/workspaces/ai/ai-thread-orchestration-contract";
import {
aiThreadActivityTitleSchema,
getModelToolDefinition,
requireAIThreadToolRuntime,
} from "#/features/workspaces/ai/ai-thread-tool";
+import { attachDocumentEditReceiptMetadata } from "#/features/workspaces/ai/ai-thread-tool-ui-metadata";
type AIThreadOrchestrationRuntime = ReturnType["runtime"];
@@ -30,6 +34,7 @@ const aiThreadOrchestrationInputSchema = z.object({
export {
getAIThreadOrchestrationTelemetryOutput,
+ getAIThreadOrchestrationModelOutput,
normalizeAIThreadOrchestrationOutput,
} from "#/features/workspaces/ai/ai-thread-orchestration-contract";
export type { AIThreadOrchestrationOutput } from "#/features/workspaces/ai/ai-thread-orchestration-contract";
@@ -72,6 +77,10 @@ export function createAIThreadOrchestrationTool(input: CreateAIThreadOrchestrati
return {
...getModelToolDefinition(runtime.tool),
inputSchema: aiThreadOrchestrationInputSchema,
+ toModelOutput: ({ output }) => ({
+ type: "json" as const,
+ value: getAIThreadOrchestrationModelOutput(output),
+ }),
execute: async (...args: Parameters) => {
return normalizeAIThreadOrchestrationOutput(await execute(...args));
},
@@ -114,11 +123,16 @@ class AIThreadToolSetConnector extends CodemodeConnector {
? { requiresApproval: true }
: {}),
execute: async (args, context) => {
- return runtime.execute(args, {
+ const invocationId = crypto.randomUUID();
+ const output = await runtime.execute(args, {
codemodeExecutionId: context?.executionId,
- invocationId: crypto.randomUUID(),
+ invocationId,
source: "codemode",
});
+
+ return toolName === "workspace_edit_item"
+ ? attachDocumentEditReceiptMetadata(output, invocationId)
+ : output;
},
};
diff --git a/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts b/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts
index f6a4f26c..c6749f8c 100644
--- a/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts
+++ b/src/features/workspaces/ai/ai-thread-orchestration.worker.test.ts
@@ -25,6 +25,7 @@ vi.mock("#/features/workspaces/operations/workspace-tool-definitions", () => ({
import {
createAIThreadOrchestrationTool,
+ getAIThreadOrchestrationModelOutput,
getAIThreadOrchestrationTelemetryOutput,
normalizeAIThreadOrchestrationOutput,
} from "#/features/workspaces/ai/ai-thread-orchestration";
@@ -121,6 +122,54 @@ describe("AI thread orchestration", () => {
expect(JSON.stringify(output.calls)).not.toContain("example.com");
});
+ it("keeps document edit controls app-only", () => {
+ const output = normalizeAIThreadOrchestrationOutput({
+ status: "completed",
+ executionId: "execution-edit",
+ result: {
+ nested: {
+ __thinkexUi: { documentEditReceiptId: "receipt-secret" },
+ },
+ },
+ calls: [
+ {
+ seq: 1,
+ connector: "tools",
+ method: "workspace_edit_item",
+ state: "applied",
+ requiresApproval: false,
+ args: { path: "/Notes" },
+ result: {
+ applied: 1,
+ failed: [],
+ itemId: "document-1",
+ path: "/Notes",
+ __thinkexUi: { documentEditReceiptId: "receipt-secret" },
+ },
+ },
+ ],
+ });
+
+ expect(output.calls[0]).toMatchObject({
+ action: {
+ itemId: "document-1",
+ kind: "document-edit",
+ path: "/Notes",
+ receiptId: "receipt-secret",
+ },
+ });
+ if (output.status !== "completed") {
+ throw new Error("Expected completed orchestration output.");
+ }
+ expect(JSON.stringify(output.result)).not.toContain("__thinkexUi");
+
+ const modelOutput = getAIThreadOrchestrationModelOutput(output);
+ const telemetryOutput = getAIThreadOrchestrationTelemetryOutput(output);
+ expect(JSON.stringify(modelOutput)).not.toContain("receipt-secret");
+ expect(JSON.stringify(modelOutput)).not.toContain('"action"');
+ expect(JSON.stringify(telemetryOutput)).not.toContain("receipt-secret");
+ });
+
it("fails closed when a completed runtime result contains a malformed child call", () => {
const output = normalizeAIThreadOrchestrationOutput({
status: "completed",
diff --git a/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts
new file mode 100644
index 00000000..30b8ee31
--- /dev/null
+++ b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts
@@ -0,0 +1,57 @@
+const aiThreadToolUiMetadataKey = "__thinkexUi";
+
+interface AIThreadToolUiMetadata {
+ documentEditReceiptId?: string;
+}
+
+export function attachDocumentEditReceiptMetadata(output: unknown, receiptId: string) {
+ if (!isPlainRecord(output)) {
+ return output;
+ }
+
+ return {
+ ...output,
+ [aiThreadToolUiMetadataKey]: {
+ documentEditReceiptId: receiptId,
+ } satisfies AIThreadToolUiMetadata,
+ };
+}
+
+export function getDocumentEditReceiptMetadata(output: unknown) {
+ if (!isPlainRecord(output)) {
+ return undefined;
+ }
+
+ const metadata = output[aiThreadToolUiMetadataKey];
+ if (!isPlainRecord(metadata)) {
+ return undefined;
+ }
+
+ return typeof metadata.documentEditReceiptId === "string"
+ ? metadata.documentEditReceiptId
+ : undefined;
+}
+
+export function stripAIThreadToolUiMetadata(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(stripAIThreadToolUiMetadata);
+ }
+ if (!isPlainRecord(value)) {
+ return value;
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).flatMap(([key, entry]) =>
+ key === aiThreadToolUiMetadataKey ? [] : [[key, stripAIThreadToolUiMetadata(entry)]],
+ ),
+ );
+}
+
+function isPlainRecord(value: unknown): value is Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ return false;
+ }
+
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
diff --git a/src/features/workspaces/components/WorkspaceLayout.tsx b/src/features/workspaces/components/WorkspaceLayout.tsx
index 9f9c5582..bab779c8 100644
--- a/src/features/workspaces/components/WorkspaceLayout.tsx
+++ b/src/features/workspaces/components/WorkspaceLayout.tsx
@@ -29,6 +29,7 @@ import type {
} from "#/features/workspaces/contracts";
import type { WorkspaceLocation } from "#/features/workspaces/locations/workspace-location";
import { WorkspaceLocationProvider } from "#/features/workspaces/locations/workspace-location-context";
+import { DocumentEditReviewProvider } from "#/features/workspaces/documents/document-edit-review-context";
import type { WorkspaceItem } from "#/features/workspaces/model/types";
import { isWorkspaceItemView } from "#/features/workspaces/model/view";
import { workspaceItemRequiresHeavyViewerRuntime } from "#/features/workspaces/model/workspace-file";
@@ -305,11 +306,13 @@ export function WorkspaceShell({
return (
- {hasHeavyViewerRuntimeItems ? (
- {workspaceInteractionContent}
- ) : (
- workspaceInteractionContent
- )}
+
+ {hasHeavyViewerRuntimeItems ? (
+ {workspaceInteractionContent}
+ ) : (
+ workspaceInteractionContent
+ )}
+
);
diff --git a/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
new file mode 100644
index 00000000..164b702b
--- /dev/null
+++ b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
@@ -0,0 +1,170 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Eye, EyeOff, FileText, LoaderCircle, Undo2 } from "lucide-react";
+import { useEffect } from "react";
+import { toast } from "sonner";
+
+import { Button } from "#/components/ui/button";
+import type { AiChatDocumentEditGroup } from "#/features/workspaces/components/ai-chat/ai-chat-document-edit-actions";
+import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access";
+import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context";
+import type { DocumentEditReceiptUndoResult } from "#/features/workspaces/documents/document-edit-receipt";
+import { undoDocumentEditReceiptFn } from "#/features/workspaces/documents/document-edit-review-functions";
+import {
+ documentEditReceiptQueryKey,
+ documentEditReceiptStatusQueryOptions,
+} from "#/features/workspaces/documents/document-edit-review-queries";
+import { getWorkspacePathName } from "#/features/workspaces/kernel/workspace-kernel-paths";
+
+export function AiChatDocumentEditActions({
+ groups,
+}: {
+ groups: readonly AiChatDocumentEditGroup[];
+}) {
+ return (
+
+ {groups.map((group) => (
+
+ ))}
+
+ );
+}
+
+function DocumentEditActionRow({ group }: { group: AiChatDocumentEditGroup }) {
+ const queryClient = useQueryClient();
+ const { capabilities } = useWorkspaceMutationAccess();
+ const { activeReview, hideReview, showReview, workspaceId } = useDocumentEditReview();
+ const { itemId } = group;
+ const receiptKey = group.receiptIds.join(":");
+ const target = itemId ? { itemId, receiptIds: group.receiptIds, workspaceId } : null;
+ const statusQuery = useQuery({
+ ...documentEditReceiptStatusQueryOptions(
+ target ?? { itemId: "unavailable", receiptIds: group.receiptIds, workspaceId },
+ ),
+ enabled: Boolean(target),
+ });
+ const undoMutation = useMutation({
+ mutationFn: async () => {
+ if (!target) {
+ throw new Error("Document is no longer available.");
+ }
+ return await undoDocumentEditReceiptFn({ data: target });
+ },
+ onSuccess: async (result) => {
+ if (!target) {
+ return;
+ }
+ if (result.status === "reverted") {
+ hideReview();
+ toast.success("AI changes undone.");
+ } else {
+ toast.error(getUndoUnavailableMessage(result.status));
+ }
+
+ queryClient.setQueryData(documentEditReceiptQueryKey(target, "status"), result);
+ await queryClient.invalidateQueries({
+ queryKey: ["workspace-document-edit-receipt", workspaceId, itemId],
+ });
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : "Could not undo these changes.");
+ },
+ });
+ useEffect(() => {
+ if (!itemId || statusQuery.data?.status !== "ready") {
+ return;
+ }
+
+ for (const query of queryClient
+ .getQueryCache()
+ .findAll({ queryKey: ["workspace-document-edit-receipt", workspaceId, itemId] })) {
+ if (query.queryKey[3] !== receiptKey && query.queryKey[4] === "status") {
+ queryClient.setQueryData(query.queryKey, { status: "not_latest" });
+ }
+ }
+ }, [itemId, queryClient, receiptKey, statusQuery.data?.status, workspaceId]);
+
+ if (!target || !statusQuery.data) {
+ return null;
+ }
+
+ const isReviewActive = Boolean(
+ activeReview &&
+ activeReview.itemId === itemId &&
+ activeReview.receiptIds.join(":") === receiptKey,
+ );
+ const name = getWorkspacePathName(group.path);
+
+ if (statusQuery.data.status === "reverted") {
+ return (
+
+
+
+ {name}
+
+ · Undone
+
+ );
+ }
+ if (statusQuery.data.status !== "ready") {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {name}
+
+
+
+ {capabilities.canMutateContent ? (
+
+ ) : null}
+
+ );
+}
+
+function getUndoUnavailableMessage(status: DocumentEditReceiptUndoResult["status"]) {
+ switch (status) {
+ case "content_changed":
+ return "The document changed after these AI changes, so they were not undone.";
+ case "not_latest":
+ return "Undo newer AI changes first.";
+ case "review_unavailable":
+ return "Undo is unavailable for this large document.";
+ case "not_found":
+ return "These AI changes are no longer available.";
+ case "reverted":
+ return "These AI changes were already undone.";
+ }
+}
diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx
index 69fd3d2f..331f7df0 100644
--- a/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx
@@ -9,6 +9,8 @@ import { Collapsible, CollapsibleTrigger } from "#/components/ui/collapsible";
import { Message, MessageContent, MessageFooter } from "#/components/ui/message";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "#/components/ui/tooltip";
import { AiChatMessagePartView } from "#/features/workspaces/components/ai-chat/AiChatMessagePartView";
+import { AiChatDocumentEditActions } from "#/features/workspaces/components/ai-chat/AiChatDocumentEditActions";
+import { getAiChatDocumentEditGroups } from "#/features/workspaces/components/ai-chat/ai-chat-document-edit-actions";
import {
getWorkspaceCitationLocations,
stripWorkspaceCitationTags,
@@ -204,14 +206,20 @@ function AssistantMessageBody({
workspaceCitationLocations: ReturnType;
}) {
if (display.kind === "content") {
- return display.parts.map((part, index) => (
-
- ));
+ const editGroups = isStreaming ? [] : getAiChatDocumentEditGroups(display.parts);
+ return (
+ <>
+ {display.parts.map((part, index) => (
+
+ ))}
+ {editGroups.length > 0 ? : null}
+ >
+ );
}
if (display.kind === "empty-terminal") {
diff --git a/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx b/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx
index 868d9b9b..3903a0df 100644
--- a/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx
@@ -60,7 +60,7 @@ export function AiChatToolActivityRow({
-
+
{details}
{sourcePreviews.length > 0 ? : null}
{nestedChildren.length > 0 ? (
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-codemode-activity.ts b/src/features/workspaces/components/ai-chat/ai-chat-codemode-activity.ts
index d94ae6e8..3f1c83d6 100644
--- a/src/features/workspaces/components/ai-chat/ai-chat-codemode-activity.ts
+++ b/src/features/workspaces/components/ai-chat/ai-chat-codemode-activity.ts
@@ -13,6 +13,12 @@ import {
} from "#/features/workspaces/components/ai-chat/ai-chat-tool-receipts";
export interface AiChatToolChildActivity {
+ action?: {
+ kind: "document-edit";
+ itemId: string;
+ path: string;
+ receiptId: string;
+ };
id: string;
presentation: AiToolPresentation;
status: AiChatToolReceiptStatus;
@@ -77,12 +83,14 @@ function isCompactToolActivity(
return false;
}
const record = value as Record;
+ const action = record.action;
return (
typeof record.id === "string" &&
isReceiptStatus(record.status) &&
typeof record.summary === "string" &&
- typeof record.toolName === "string"
+ typeof record.toolName === "string" &&
+ (action === undefined || isDocumentEditAction(action))
);
}
@@ -179,3 +187,19 @@ function isCallState(value: unknown): value is ToolLogEntry["state"] {
function isReceiptStatus(value: unknown): value is AiChatToolReceiptStatus {
return value === "completed" || value === "failed" || value === "running";
}
+
+function isDocumentEditAction(
+ value: unknown,
+): value is NonNullable {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+ const record = value as Record;
+
+ return (
+ record.kind === "document-edit" &&
+ typeof record.itemId === "string" &&
+ typeof record.path === "string" &&
+ typeof record.receiptId === "string"
+ );
+}
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts
index bf16e433..bcf4d055 100644
--- a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts
+++ b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts
@@ -294,7 +294,7 @@ export function isVisibleToolPart(part: AiChatToolPart) {
return getAiToolPresentation(toolName).visibility === "visible";
}
-function getToolPartName(part: AiChatToolPart) {
+export function getToolPartName(part: AiChatToolPart) {
return part.type === "dynamic-tool" ? part.toolName : part.type.split("-").slice(1).join("-");
}
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts
new file mode 100644
index 00000000..0d0249c6
--- /dev/null
+++ b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts
@@ -0,0 +1,82 @@
+import { isToolUIPart } from "ai";
+
+import {
+ getToolPartName,
+ isAiChatToolGroupPart,
+ type AiChatRenderablePart,
+} from "#/features/workspaces/components/ai-chat/ai-chat-display-state";
+
+export interface AiChatDocumentEditGroup {
+ itemId: string;
+ path: string;
+ receiptIds: string[];
+}
+
+export function getAiChatDocumentEditGroups(
+ parts: readonly AiChatRenderablePart[],
+): AiChatDocumentEditGroup[] {
+ const groupsByItemId = new Map();
+ const seenReceiptIds = new Set();
+
+ for (const part of parts) {
+ if (isAiChatToolGroupPart(part)) {
+ for (const child of part.children) {
+ if (child.status === "completed" && child.action?.kind === "document-edit") {
+ addToGroup(groupsByItemId, seenReceiptIds, child.action);
+ }
+ }
+ continue;
+ }
+
+ if (
+ !isToolUIPart(part) ||
+ getToolPartName(part) !== "workspace_edit_item" ||
+ part.state !== "output-available"
+ ) {
+ continue;
+ }
+
+ const output = asRecord(part.output);
+ const path = typeof output.path === "string" ? output.path : null;
+ const itemId = typeof output.itemId === "string" ? output.itemId : null;
+ const applied = typeof output.applied === "number" ? output.applied : 0;
+ if (itemId && path && applied > 0) {
+ addToGroup(groupsByItemId, seenReceiptIds, {
+ itemId,
+ path,
+ receiptId: part.toolCallId,
+ });
+ }
+ }
+
+ return [...groupsByItemId.values()];
+}
+
+function addToGroup(
+ groupsByItemId: Map,
+ seenReceiptIds: Set,
+ action: { itemId: string; path: string; receiptId: string },
+) {
+ if (seenReceiptIds.has(action.receiptId)) {
+ return;
+ }
+ seenReceiptIds.add(action.receiptId);
+
+ const group = groupsByItemId.get(action.itemId);
+ if (group) {
+ group.path = action.path;
+ group.receiptIds.push(action.receiptId);
+ } else {
+ groupsByItemId.set(action.itemId, {
+ itemId: action.itemId,
+ path: action.path,
+ receiptIds: [action.receiptId],
+ });
+ }
+}
+
+function asRecord(value: unknown): Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
diff --git a/src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx b/src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx
new file mode 100644
index 00000000..7cd84e1e
--- /dev/null
+++ b/src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx
@@ -0,0 +1,32 @@
+import { Sparkles } from "lucide-react";
+
+import { Button } from "#/components/ui/button";
+import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context";
+
+export function DocumentEditReviewBar({
+ itemId,
+ viewInstanceId,
+}: {
+ itemId: string;
+ viewInstanceId: string;
+}) {
+ const { activeReview, hideReview } = useDocumentEditReview();
+
+ if (activeReview?.itemId !== itemId || activeReview.viewInstanceId !== viewInstanceId) {
+ return null;
+ }
+
+ return (
+
+
+ AI changes
+
+
+ );
+}
diff --git a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
index dfb55d6b..0dfeb378 100644
--- a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
+++ b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
@@ -6,10 +6,12 @@ import { useState } from "react";
import { Skeleton } from "#/components/ui/skeleton";
import { DocumentAskSelectionMenu } from "#/features/workspaces/components/document-editor/DocumentAskSelectionMenu";
+import { DocumentEditReviewBar } from "#/features/workspaces/components/document-editor/DocumentEditReviewBar";
import { DocumentWordCount } from "#/features/workspaces/components/document-editor/DocumentWordCount";
import { useDocumentEditorToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot";
import { useWorkspacePaneRuntime } from "#/features/workspaces/components/WorkspacePaneRuntime";
import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access";
+import { DocumentEditReviewExtension } from "#/features/workspaces/documents/document-edit-review-extension";
import {
getTiptapDocumentBaseExtensions,
tiptapDocumentYjsField,
@@ -18,6 +20,7 @@ import {
type DocumentCollaborationSession,
useDocumentCollaborationSession,
} from "#/features/workspaces/documents/use-document-collaboration-session";
+import { useDocumentEditReviewOverlay } from "#/features/workspaces/documents/use-document-edit-review-overlay";
import type { WorkspaceItem } from "#/features/workspaces/model/types";
import { DEFAULT_COLLABORATION_COLOR } from "#/lib/design-system-colors";
import { getAuthSessionQueryOptions } from "#/lib/session-query";
@@ -99,9 +102,16 @@ function DocumentEditorInstance({
});
useDocumentEditorToolbar(viewInstanceId, capabilities.canMutateContent ? editor : null);
+ useDocumentEditReviewOverlay({
+ editor,
+ itemId: item.id,
+ viewInstanceId,
+ workspaceId,
+ });
return (
+
void;
getPresentation: (location: WorkspaceLocation) => WorkspaceLocationPresentation;
- reveal: (location: WorkspaceLocation) => boolean;
+ reveal: (location: WorkspaceLocation) => string | undefined;
revealRequest: WorkspacePdfPageRevealRequest | null;
};
@@ -69,7 +69,7 @@ export function WorkspaceLocationProvider({
setRevealRequest(
viewInstanceId && location.kind === "pdf-page" ? { location, viewInstanceId } : null,
);
- return Boolean(viewInstanceId);
+ return viewInstanceId;
},
revealRequest,
};
diff --git a/src/styles.css b/src/styles.css
index 6407bf5a..fb980c6a 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -504,6 +504,27 @@
color: var(--foreground);
}
+ .workspace-document-prose .workspace-document-ai-inserted {
+ border-radius: 0.2rem;
+ background: color-mix(in oklab, var(--color-blue-500) 18%, transparent);
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+ }
+
+ .workspace-document-prose .workspace-document-ai-deleted {
+ margin-inline: 0.15rem;
+ border-radius: 0.2rem;
+ background: color-mix(in oklab, var(--color-muted) 82%, transparent);
+ color: var(--color-muted-foreground);
+ text-decoration: line-through;
+ }
+
+ .workspace-document-prose .workspace-document-ai-changed-block {
+ border-radius: 0.35rem;
+ box-shadow: inset 2px 0 color-mix(in oklab, var(--color-blue-500) 60%, transparent);
+ background: color-mix(in oklab, var(--color-blue-500) 7%, transparent);
+ }
+
.dark .workspace-document-prose {
--workspace-document-highlight: oklch(0.82 0.12 88 / 82%);
--workspace-document-highlight-foreground: var(--foreground);
From d2591d6221444d0de1f59b5102b2ec90f6da95f6 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 22:21:25 -0400
Subject: [PATCH 03/49] refactor(documents): persist Yjs state with a single
put
Documents hold text only; the schema has no image node and pasted media is
stripped, so a Yjs update cannot approach the per-value storage ceiling the
chunked manifest guarded against.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../workspaces/documents/document-session.ts | 33 ++----
.../document-yjs-persistence.test.ts | 66 -----------
.../documents/document-yjs-persistence.ts | 106 ------------------
3 files changed, 11 insertions(+), 194 deletions(-)
delete mode 100644 src/features/workspaces/documents/document-yjs-persistence.test.ts
delete mode 100644 src/features/workspaces/documents/document-yjs-persistence.ts
diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts
index 055f209d..310bb0f0 100644
--- a/src/features/workspaces/documents/document-session.ts
+++ b/src/features/workspaces/documents/document-session.ts
@@ -38,16 +38,13 @@ import {
getTiptapDocumentSchema,
tiptapDocumentYjsField,
} from "#/features/workspaces/documents/tiptap-schema";
-import {
- readPersistedDocumentYjsUpdate,
- writePersistedDocumentYjsUpdate,
-} from "#/features/workspaces/documents/document-yjs-persistence";
import {
getWorkspaceKernelFromEnv,
type WorkspaceKernelClient,
} from "#/features/workspaces/kernel/workspace-kernel-access";
import { sha256Base64Url, sha256Base64UrlText } from "#/lib/binary";
+const persistedYDocUpdateKey = "document-session:yjs-update";
const latestDocumentEditReceiptKey = "document-session:ai-edit-receipt:latest";
const documentEditReceiptKeyPrefix = "document-session:ai-edit-receipt:";
const maximumDocumentEditReceiptSnapshotBytes = 1_500_000;
@@ -131,7 +128,7 @@ export class DocumentSession extends YServer {
}
override async onLoad() {
- const persistedUpdate = await readPersistedDocumentYjsUpdate(this.ctx.storage);
+ const persistedUpdate = await this.ctx.storage.get(persistedYDocUpdateKey);
if (this.deleted) {
return;
}
@@ -244,8 +241,8 @@ export class DocumentSession extends YServer {
this.reconcileCurrentDocument(editResult.document);
const persistedUpdate = Y.encodeStateAsUpdate(this.document);
await this.ctx.storage.transaction(async (transaction) => {
- await writePersistedDocumentYjsUpdate(transaction, persistedUpdate);
await Promise.all([
+ transaction.put(persistedYDocUpdateKey, persistedUpdate),
transaction.put(getDocumentEditReceiptKey(receipt.id), receipt),
transaction.put(latestDocumentEditReceiptKey, receipt.id),
]);
@@ -293,15 +290,15 @@ export class DocumentSession extends YServer {
const persistedUpdate = Y.encodeStateAsUpdate(this.document);
await this.ctx.storage.transaction(async (transaction) => {
- await writePersistedDocumentYjsUpdate(transaction, persistedUpdate);
- await Promise.all(
- group.receipts.map((receipt) =>
+ await Promise.all([
+ transaction.put(persistedYDocUpdateKey, persistedUpdate),
+ ...group.receipts.map((receipt) =>
transaction.put(getDocumentEditReceiptKey(receipt.id), {
...receipt,
status: "reverted",
} satisfies StoredDocumentEditReceipt),
),
- );
+ ]);
if (group.previousReceiptId) {
await transaction.put(latestDocumentEditReceiptKey, group.previousReceiptId);
@@ -433,10 +430,7 @@ export class DocumentSession extends YServer {
return;
}
- const update = Y.encodeStateAsUpdate(this.document);
- await this.ctx.storage.transaction((transaction) =>
- writePersistedDocumentYjsUpdate(transaction, update),
- );
+ await this.ctx.storage.put(persistedYDocUpdateKey, Y.encodeStateAsUpdate(this.document));
}
private async getReferencedDocumentSnapshot() {
@@ -444,17 +438,12 @@ export class DocumentSession extends YServer {
if (refs.changed) {
this.reconcileCurrentDocument(coerceTiptapDocumentJson(refs.document.toJSON()));
await this.persistYDoc();
- return this.getDocumentSnapshot();
}
- return {
- document: refs.document,
- stateVector: Uint8Array.from(Y.encodeStateVector(this.document)),
- };
- }
- private getDocumentSnapshot() {
return {
- document: this.getCurrentProseMirrorDocument(),
+ // Re-read through Yjs after reconciling so the snapshot matches what
+ // collaborators see, not the detached node the refs pass produced.
+ document: refs.changed ? this.getCurrentProseMirrorDocument() : refs.document,
stateVector: Uint8Array.from(Y.encodeStateVector(this.document)),
};
}
diff --git a/src/features/workspaces/documents/document-yjs-persistence.test.ts b/src/features/workspaces/documents/document-yjs-persistence.test.ts
deleted file mode 100644
index e2025d6e..00000000
--- a/src/features/workspaces/documents/document-yjs-persistence.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- readPersistedDocumentYjsUpdate,
- writePersistedDocumentYjsUpdate,
-} from "#/features/workspaces/documents/document-yjs-persistence";
-
-const manifestKey = "document-session:yjs-update";
-const partKeyPrefix = "document-session:yjs-update-part:";
-
-describe("document Yjs persistence", () => {
- it("reads legacy single-value updates", async () => {
- const memory = createMemoryStorage();
- const update = new Uint8Array([1, 2, 3]);
- memory.values.set(manifestKey, update);
-
- await expect(readPersistedDocumentYjsUpdate(memory.storage)).resolves.toEqual(update);
- });
-
- it("round-trips multipart updates and removes the previous generation", async () => {
- const memory = createMemoryStorage();
- const largeUpdate = new Uint8Array(2_500_000).map((_, index) => index % 251);
-
- await writePersistedDocumentYjsUpdate(memory.transaction, largeUpdate);
- await expect(readPersistedDocumentYjsUpdate(memory.storage)).resolves.toEqual(largeUpdate);
- expect(getPartKeys(memory.values)).toHaveLength(3);
-
- const replacement = new Uint8Array([4, 5, 6]);
- await writePersistedDocumentYjsUpdate(memory.transaction, replacement);
- await expect(readPersistedDocumentYjsUpdate(memory.storage)).resolves.toEqual(replacement);
- expect(getPartKeys(memory.values)).toHaveLength(1);
- });
-});
-
-function createMemoryStorage() {
- const values = new Map();
- const get = async (keyOrKeys: string | string[]) => {
- if (typeof keyOrKeys === "string") {
- return values.get(keyOrKeys);
- }
- return new Map(
- keyOrKeys.flatMap((key) => (values.has(key) ? [[key, values.get(key)] as const] : [])),
- );
- };
- const put = async (key: string, value: unknown) => {
- values.set(key, value);
- };
- const remove = async (keyOrKeys: string | string[]) => {
- const keys = typeof keyOrKeys === "string" ? [keyOrKeys] : keyOrKeys;
- for (const key of keys) {
- values.delete(key);
- }
- return keys.length;
- };
- const methods = { delete: remove, get, put };
-
- return {
- storage: methods as unknown as DurableObjectStorage,
- transaction: methods as unknown as DurableObjectTransaction,
- values,
- };
-}
-
-function getPartKeys(values: ReadonlyMap) {
- return [...values.keys()].filter((key) => key.startsWith(partKeyPrefix));
-}
diff --git a/src/features/workspaces/documents/document-yjs-persistence.ts b/src/features/workspaces/documents/document-yjs-persistence.ts
deleted file mode 100644
index 9a36c190..00000000
--- a/src/features/workspaces/documents/document-yjs-persistence.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-const persistedDocumentYjsUpdateKey = "document-session:yjs-update";
-const persistedDocumentYjsPartKeyPrefix = "document-session:yjs-update-part:";
-// Keep reconstructed state comfortably below the Worker isolate limit, and
-// each stored value comfortably below Durable Object storage's 2 MB ceiling.
-const maximumPersistedDocumentYjsBytes = 16 * 1024 * 1024;
-const maximumPersistedDocumentYjsPartBytes = 1024 * 1024;
-
-interface PersistedDocumentYjsManifest {
- generation: string;
- partCount: number;
- totalBytes: number;
- version: 1;
-}
-
-export async function readPersistedDocumentYjsUpdate(storage: DurableObjectStorage) {
- const stored = await storage.get(
- persistedDocumentYjsUpdateKey,
- );
- if (!stored || stored instanceof Uint8Array) {
- return stored;
- }
- if (!isPersistedDocumentYjsManifest(stored)) {
- throw new Error("Persisted document state manifest is invalid.");
- }
-
- const keys = getPersistedDocumentYjsPartKeys(stored);
- const parts = await storage.get(keys);
- const update = new Uint8Array(stored.totalBytes);
- let offset = 0;
-
- for (const key of keys) {
- const part = parts.get(key);
- if (!(part instanceof Uint8Array) || offset + part.byteLength > update.byteLength) {
- throw new Error("Persisted document state is incomplete.");
- }
- update.set(part, offset);
- offset += part.byteLength;
- }
-
- if (offset !== update.byteLength) {
- throw new Error("Persisted document state size does not match its manifest.");
- }
-
- return update;
-}
-
-export async function writePersistedDocumentYjsUpdate(
- transaction: DurableObjectTransaction,
- update: Uint8Array,
-) {
- if (update.byteLength > maximumPersistedDocumentYjsBytes) {
- throw new Error("Document is too large to persist safely.");
- }
-
- const previous = await transaction.get(
- persistedDocumentYjsUpdateKey,
- );
- const manifest: PersistedDocumentYjsManifest = {
- generation: crypto.randomUUID(),
- partCount: Math.max(1, Math.ceil(update.byteLength / maximumPersistedDocumentYjsPartBytes)),
- totalBytes: update.byteLength,
- version: 1,
- };
- const keys = getPersistedDocumentYjsPartKeys(manifest);
-
- await Promise.all(
- keys.map((key, index) => {
- const start = index * maximumPersistedDocumentYjsPartBytes;
- return transaction.put(
- key,
- update.slice(start, start + maximumPersistedDocumentYjsPartBytes),
- );
- }),
- );
- await transaction.put(persistedDocumentYjsUpdateKey, manifest);
-
- if (isPersistedDocumentYjsManifest(previous)) {
- await transaction.delete(getPersistedDocumentYjsPartKeys(previous));
- }
-}
-
-function getPersistedDocumentYjsPartKeys(manifest: PersistedDocumentYjsManifest) {
- return Array.from(
- { length: manifest.partCount },
- (_, index) => `${persistedDocumentYjsPartKeyPrefix}${manifest.generation}:${index}`,
- );
-}
-
-function isPersistedDocumentYjsManifest(
- value: Uint8Array | PersistedDocumentYjsManifest | undefined,
-): value is PersistedDocumentYjsManifest {
- return (
- value !== undefined &&
- !(value instanceof Uint8Array) &&
- value.version === 1 &&
- typeof value.generation === "string" &&
- value.generation.length > 0 &&
- value.generation.length <= 64 &&
- Number.isInteger(value.partCount) &&
- Number.isInteger(value.totalBytes) &&
- value.totalBytes >= 0 &&
- value.totalBytes <= maximumPersistedDocumentYjsBytes &&
- value.partCount ===
- Math.max(1, Math.ceil(value.totalBytes / maximumPersistedDocumentYjsPartBytes))
- );
-}
From 760cd8a09ec88f1e3b099811f7a4ab39ee116c30 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 22:24:19 -0400
Subject: [PATCH 04/49] refactor(documents): trim document edit review plumbing
Drop the unreachable null-target branch and the sibling-receipt cache writes in
the chat actions, collapse the status message switches into records, and reuse
the shared record helpers instead of new copies.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../ai/ai-thread-orchestration-contract.ts | 19 +++---
.../ai/ai-thread-tool-ui-metadata.ts | 7 +--
.../ai-chat/AiChatDocumentEditActions.tsx | 61 ++++---------------
.../ai-chat/ai-chat-document-edit-actions.ts | 8 +--
.../workspaces/documents/document-ai-edits.ts | 17 +-----
.../workspaces/documents/document-ai-html.ts | 11 +---
.../documents/document-edit-receipt.ts | 24 ++++----
.../use-document-edit-review-overlay.ts | 27 +++-----
8 files changed, 49 insertions(+), 125 deletions(-)
diff --git a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts
index dfebc732..90367938 100644
--- a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts
+++ b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts
@@ -8,6 +8,7 @@ import {
type AIToolOutcome,
} from "#/features/workspaces/ai/ai-tool-outcome";
import { summarizeAIThreadBrowserActivity } from "#/features/workspaces/ai/ai-thread-browser-activity";
+import { asRecord } from "#/features/workspaces/ai/ai-inspector-view-parsing";
import {
getDocumentEditReceiptMetadata,
stripAIThreadToolUiMetadata,
@@ -192,7 +193,7 @@ export function getAIThreadOrchestrationTelemetryOutput(output: unknown) {
return {
status: parsed.data.status,
outcome: parsed.data.outcome,
- calls: parsed.data.calls.map(({ action: _action, ...call }) => call),
+ calls: withoutCallActions(parsed.data.calls),
...(parsed.data.status === "paused" ? { pendingCount: parsed.data.pending.length } : {}),
};
}
@@ -203,10 +204,12 @@ export function getAIThreadOrchestrationModelOutput(output: unknown) {
return output;
}
- return {
- ...parsed.data,
- calls: parsed.data.calls.map(({ action: _action, ...call }) => call),
- };
+ return { ...parsed.data, calls: withoutCallActions(parsed.data.calls) };
+}
+
+/** `action` drives the app's review controls; neither the model nor telemetry sees it. */
+function withoutCallActions(calls: z.output[]) {
+ return calls.map(({ action: _action, ...call }) => call);
}
function invalidOrchestrationOutput(output: unknown): AIThreadOrchestrationOutput {
@@ -397,9 +400,3 @@ function summarizeOrchestrationCall(outcome: AIToolOutcome) {
return "Completed";
}
-
-function asRecord(value: unknown): Record {
- return typeof value === "object" && value !== null && !Array.isArray(value)
- ? (value as Record)
- : {};
-}
diff --git a/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts
index 30b8ee31..ac0db06b 100644
--- a/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts
+++ b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts
@@ -48,10 +48,5 @@ export function stripAIThreadToolUiMetadata(value: unknown): unknown {
}
function isPlainRecord(value: unknown): value is Record {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- return false;
- }
-
- const prototype = Object.getPrototypeOf(value);
- return prototype === Object.prototype || prototype === null;
+ return typeof value === "object" && value !== null && !Array.isArray(value);
}
diff --git a/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
index 164b702b..af07f303 100644
--- a/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
@@ -1,13 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff, FileText, LoaderCircle, Undo2 } from "lucide-react";
-import { useEffect } from "react";
import { toast } from "sonner";
import { Button } from "#/components/ui/button";
import type { AiChatDocumentEditGroup } from "#/features/workspaces/components/ai-chat/ai-chat-document-edit-actions";
import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access";
import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context";
-import type { DocumentEditReceiptUndoResult } from "#/features/workspaces/documents/document-edit-receipt";
+import type { DocumentEditReceiptUnavailableStatus } from "#/features/workspaces/documents/document-edit-receipt";
import { undoDocumentEditReceiptFn } from "#/features/workspaces/documents/document-edit-review-functions";
import {
documentEditReceiptQueryKey,
@@ -35,29 +34,16 @@ function DocumentEditActionRow({ group }: { group: AiChatDocumentEditGroup }) {
const { activeReview, hideReview, showReview, workspaceId } = useDocumentEditReview();
const { itemId } = group;
const receiptKey = group.receiptIds.join(":");
- const target = itemId ? { itemId, receiptIds: group.receiptIds, workspaceId } : null;
- const statusQuery = useQuery({
- ...documentEditReceiptStatusQueryOptions(
- target ?? { itemId: "unavailable", receiptIds: group.receiptIds, workspaceId },
- ),
- enabled: Boolean(target),
- });
+ const target = { itemId, receiptIds: group.receiptIds, workspaceId };
+ const statusQuery = useQuery(documentEditReceiptStatusQueryOptions(target));
const undoMutation = useMutation({
- mutationFn: async () => {
- if (!target) {
- throw new Error("Document is no longer available.");
- }
- return await undoDocumentEditReceiptFn({ data: target });
- },
+ mutationFn: () => undoDocumentEditReceiptFn({ data: target }),
onSuccess: async (result) => {
- if (!target) {
- return;
- }
if (result.status === "reverted") {
hideReview();
toast.success("AI changes undone.");
} else {
- toast.error(getUndoUnavailableMessage(result.status));
+ toast.error(undoUnavailableMessages[result.status]);
}
queryClient.setQueryData(documentEditReceiptQueryKey(target, "status"), result);
@@ -69,21 +55,7 @@ function DocumentEditActionRow({ group }: { group: AiChatDocumentEditGroup }) {
toast.error(error instanceof Error ? error.message : "Could not undo these changes.");
},
});
- useEffect(() => {
- if (!itemId || statusQuery.data?.status !== "ready") {
- return;
- }
-
- for (const query of queryClient
- .getQueryCache()
- .findAll({ queryKey: ["workspace-document-edit-receipt", workspaceId, itemId] })) {
- if (query.queryKey[3] !== receiptKey && query.queryKey[4] === "status") {
- queryClient.setQueryData(query.queryKey, { status: "not_latest" });
- }
- }
- }, [itemId, queryClient, receiptKey, statusQuery.data?.status, workspaceId]);
-
- if (!target || !statusQuery.data) {
+ if (!statusQuery.data) {
return null;
}
@@ -154,17 +126,10 @@ function DocumentEditActionRow({ group }: { group: AiChatDocumentEditGroup }) {
);
}
-function getUndoUnavailableMessage(status: DocumentEditReceiptUndoResult["status"]) {
- switch (status) {
- case "content_changed":
- return "The document changed after these AI changes, so they were not undone.";
- case "not_latest":
- return "Undo newer AI changes first.";
- case "review_unavailable":
- return "Undo is unavailable for this large document.";
- case "not_found":
- return "These AI changes are no longer available.";
- case "reverted":
- return "These AI changes were already undone.";
- }
-}
+const undoUnavailableMessages: Record = {
+ content_changed: "The document changed after these AI changes, so they were not undone.",
+ not_found: "These AI changes are no longer available.",
+ not_latest: "Undo newer AI changes first.",
+ reverted: "These AI changes were already undone.",
+ review_unavailable: "Undo is unavailable for this large document.",
+};
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts
index 0d0249c6..eef95d2b 100644
--- a/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts
+++ b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts
@@ -1,5 +1,7 @@
import { isToolUIPart } from "ai";
+import { asRecord } from "#/features/workspaces/ai/ai-inspector-view-parsing";
+
import {
getToolPartName,
isAiChatToolGroupPart,
@@ -74,9 +76,3 @@ function addToGroup(
});
}
}
-
-function asRecord(value: unknown): Record {
- return typeof value === "object" && value !== null && !Array.isArray(value)
- ? (value as Record)
- : {};
-}
diff --git a/src/features/workspaces/documents/document-ai-edits.ts b/src/features/workspaces/documents/document-ai-edits.ts
index a9707632..f6fcdb8b 100644
--- a/src/features/workspaces/documents/document-ai-edits.ts
+++ b/src/features/workspaces/documents/document-ai-edits.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
import {
createDocumentAiTargetRef,
- createUnusedDocumentAiRef,
+ createDocumentAiRef,
DocumentAiHtmlError,
parseDocumentAiHtml,
parseDocumentAiTargetRef,
@@ -30,17 +30,7 @@ export const documentAiHtmlSchema = z
export const documentAiEditSchema = z.union([
z.strictObject({
html: documentAiHtmlSchema,
- op: z.literal("replace"),
- ref: documentAiRefSchema,
- }),
- z.strictObject({
- html: documentAiHtmlSchema,
- op: z.literal("insert_before"),
- ref: documentAiRefSchema,
- }),
- z.strictObject({
- html: documentAiHtmlSchema,
- op: z.literal("insert_after"),
+ op: z.enum(["insert_after", "insert_before", "replace"]),
ref: documentAiRefSchema,
}),
z.strictObject({
@@ -231,8 +221,7 @@ function assignDocumentAiRefs(children: ProseMirrorNode[], firstRef: string | nu
return children.map((node, index) => {
const existingRef = readTiptapNodeAiRef(node);
const candidate = (index === 0 ? firstRef : null) || existingRef;
- const ref =
- candidate && !usedRefs.has(candidate) ? candidate : createUnusedDocumentAiRef(usedRefs);
+ const ref = candidate && !usedRefs.has(candidate) ? candidate : createDocumentAiRef();
usedRefs.add(ref);
return withTiptapNodeAiRef(node, ref);
});
diff --git a/src/features/workspaces/documents/document-ai-html.ts b/src/features/workspaces/documents/document-ai-html.ts
index 81ac1b76..02c8de34 100644
--- a/src/features/workspaces/documents/document-ai-html.ts
+++ b/src/features/workspaces/documents/document-ai-html.ts
@@ -128,8 +128,7 @@ export function ensureProseMirrorDocumentAiRefs(document: ProseMirrorNode): {
document.forEach((node) => {
const currentRef = readTiptapNodeAiRef(node);
- const ref =
- currentRef && !usedRefs.has(currentRef) ? currentRef : createUnusedDocumentAiRef(usedRefs);
+ const ref = currentRef && !usedRefs.has(currentRef) ? currentRef : createDocumentAiRef();
usedRefs.add(ref);
changed ||= ref !== currentRef;
children.push(withTiptapNodeAiRef(node, ref));
@@ -149,14 +148,6 @@ export function createDocumentAiRef() {
return `b_${nanoid(12)}`;
}
-export function createUnusedDocumentAiRef(usedRefs: ReadonlySet) {
- let ref = createDocumentAiRef();
- while (usedRefs.has(ref)) {
- ref = createDocumentAiRef();
- }
- return ref;
-}
-
export function readTiptapNodeAiRef(node: ProseMirrorNode) {
const ref = node.attrs[tiptapDocumentAiRefAttribute];
return typeof ref === "string" && documentAiRefPattern.test(ref) ? ref : null;
diff --git a/src/features/workspaces/documents/document-edit-receipt.ts b/src/features/workspaces/documents/document-edit-receipt.ts
index 0af0ddbb..c05debbc 100644
--- a/src/features/workspaces/documents/document-edit-receipt.ts
+++ b/src/features/workspaces/documents/document-edit-receipt.ts
@@ -1,15 +1,15 @@
import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
-export const documentEditReceiptStatuses = [
- "ready",
- "reverted",
- "not_found",
- "not_latest",
- "content_changed",
- "review_unavailable",
-] as const;
+export type DocumentEditReceiptStatus =
+ | "content_changed"
+ | "not_found"
+ | "not_latest"
+ | "ready"
+ | "reverted"
+ | "review_unavailable";
-export type DocumentEditReceiptStatus = (typeof documentEditReceiptStatuses)[number];
+/** Every status a receipt can report once it is known not to be reviewable. */
+export type DocumentEditReceiptUnavailableStatus = Exclude;
export interface DocumentEditReceiptStatusResult {
status: DocumentEditReceiptStatus;
@@ -22,7 +22,7 @@ export type DocumentEditReceiptReviewResult =
status: "ready";
}
| {
- status: Exclude;
+ status: DocumentEditReceiptUnavailableStatus;
};
export type DocumentEditReceiptReviewRpcResult =
@@ -32,9 +32,9 @@ export type DocumentEditReceiptReviewRpcResult =
status: "ready";
}
| {
- status: Exclude;
+ status: DocumentEditReceiptUnavailableStatus;
};
export interface DocumentEditReceiptUndoResult {
- status: Exclude;
+ status: DocumentEditReceiptUnavailableStatus;
}
diff --git a/src/features/workspaces/documents/use-document-edit-review-overlay.ts b/src/features/workspaces/documents/use-document-edit-review-overlay.ts
index db0f2ba8..7b2df6f7 100644
--- a/src/features/workspaces/documents/use-document-edit-review-overlay.ts
+++ b/src/features/workspaces/documents/use-document-edit-review-overlay.ts
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context";
-import type { DocumentEditReceiptStatus } from "#/features/workspaces/documents/document-edit-receipt";
+import type { DocumentEditReceiptUnavailableStatus } from "#/features/workspaces/documents/document-edit-receipt";
import {
hideDocumentEditReview,
showDocumentEditReview,
@@ -58,7 +58,7 @@ export function useDocumentEditReviewOverlay({
return;
}
if (review.status !== "ready") {
- toast.error(getUnavailableReviewMessage(review.status));
+ toast.error(unavailableReviewMessages[review.status]);
hideReview();
return;
}
@@ -88,19 +88,10 @@ export function useDocumentEditReviewOverlay({
}, [editor, hideReview, reviewQuery.data, reviewQuery.isError, target]);
}
-function getUnavailableReviewMessage(status: DocumentEditReceiptStatus) {
- switch (status) {
- case "content_changed":
- return "The document changed after this AI edit.";
- case "review_unavailable":
- return "Change review is unavailable for this large document.";
- case "not_latest":
- return "Only the latest unchanged AI edit can be reviewed.";
- case "not_found":
- return "These changes are no longer available.";
- case "reverted":
- return "These changes were already undone.";
- case "ready":
- return "Changes are ready.";
- }
-}
+const unavailableReviewMessages: Record = {
+ content_changed: "The document changed after this AI edit.",
+ not_found: "These changes are no longer available.",
+ not_latest: "Only the latest unchanged AI edit can be reviewed.",
+ reverted: "These changes were already undone.",
+ review_unavailable: "Change review is unavailable for this large document.",
+};
From b3db075e509504477c2f0e2a961ed8958dcf20c2 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 22:29:59 -0400
Subject: [PATCH 05/49] perf(documents): fingerprint AI block refs from JSON
Hashing the rendered HTML meant serializing every block twice on every read.
Co-Authored-By: Claude Opus 5 (1M context)
---
src/features/workspaces/documents/document-ai-html.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/features/workspaces/documents/document-ai-html.ts b/src/features/workspaces/documents/document-ai-html.ts
index 02c8de34..12d2548f 100644
--- a/src/features/workspaces/documents/document-ai-html.ts
+++ b/src/features/workspaces/documents/document-ai-html.ts
@@ -99,8 +99,11 @@ export async function createDocumentAiTargetRef(node: ProseMirrorNode) {
throw new Error(`Top-level document node ${node.type.name} is missing an AI ref.`);
}
- const html = serializeTiptapFragmentToAiHtml(Fragment.from(withTiptapNodeAiRef(node, null)));
- const revision = (await sha256Base64UrlText(html)).slice(0, 10);
+ // Fingerprint the block's JSON rather than its HTML: rendering costs a whole
+ // second DOM pass per block per read, and the ref only has to change whenever
+ // the block's content does.
+ const content = JSON.stringify(withTiptapNodeAiRef(node, null).toJSON());
+ const revision = (await sha256Base64UrlText(content)).slice(0, 10);
return `${ref}.r_${revision}`;
}
From 5099b0e521b740ab93ce0154129231596d5d9263 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 22:31:26 -0400
Subject: [PATCH 06/49] revert(workspaces): restore main's content read budget
The HTML switch cut the per-call batch budget to 256 KB. Keep main's limit;
the per-document chunk cap already bounds an ordinary read.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../workspaces/content/workspace-content-reader.test.ts | 2 +-
src/features/workspaces/content/workspace-content-reader.ts | 4 +---
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/src/features/workspaces/content/workspace-content-reader.test.ts b/src/features/workspaces/content/workspace-content-reader.test.ts
index efdf20f3..17c1eaf0 100644
--- a/src/features/workspaces/content/workspace-content-reader.test.ts
+++ b/src/features/workspaces/content/workspace-content-reader.test.ts
@@ -164,7 +164,7 @@ describe("WorkspaceContentReader", () => {
bucket: {} as R2Bucket,
getDocumentSession: () =>
createDocumentSession({
- html: `${"😀".repeat(40_000)}
`,
+ html: `${"😀".repeat(300_000)}
`,
revision: "revision-1",
}),
kernel: createKernel(),
diff --git a/src/features/workspaces/content/workspace-content-reader.ts b/src/features/workspaces/content/workspace-content-reader.ts
index d8b625f3..4d172c8c 100644
--- a/src/features/workspaces/content/workspace-content-reader.ts
+++ b/src/features/workspaces/content/workspace-content-reader.ts
@@ -21,9 +21,7 @@ import {
encodeWorkspaceContentCursor,
} from "#/features/workspaces/content/workspace-content-cursor";
-// A tool call may read several paths, but their combined bodies must leave room
-// for the conversation, reasoning, and response in the smallest model window.
-const maxWorkspaceContentBatchBytes = 256 * 1024;
+const maxWorkspaceContentBatchBytes = 2 * 1024 * 1024 + 64 * 1024;
interface DocumentContentReader {
readHtmlChunk(input: DocumentHtmlChunkReadInput): Promise;
From b29514af14f4fd7e9f446cbdaeef9b8c5633d831 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 22:54:48 -0400
Subject: [PATCH 07/49] feat(documents): make AI change review calmer to read
Group a turn's document edits into one card with plain Review and Undo
actions, move the in-document review controls into the toolbar in place of
formatting buttons that do nothing mid-review, and restyle the marks as
tracked changes rather than tinted diff blocks.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/WorkspaceItemToolbarSlot.tsx | 27 ++-
.../ai-chat/AiChatDocumentEditActions.tsx | 202 +++++++++++-------
.../document-editor/DocumentEditReviewBar.tsx | 32 ---
.../document-editor/DocumentEditorSurface.tsx | 9 +-
.../document-editor/DocumentToolbar.tsx | 50 ++++-
src/styles.css | 37 +++-
6 files changed, 235 insertions(+), 122 deletions(-)
delete mode 100644 src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx
diff --git a/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx b/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx
index 319a48e6..f4611e4b 100644
--- a/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx
+++ b/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx
@@ -15,7 +15,9 @@ import { WorkspaceFileToolbar } from "#/features/workspaces/components/Workspace
type WorkspaceItemToolbarRegistration =
| {
+ canEdit: boolean;
editor: Editor | null;
+ itemId: string;
kind: "document";
slotId: string;
}
@@ -49,7 +51,17 @@ export function WorkspaceItemToolbarProvider({ children }: { children: ReactNode
);
}
-export function useDocumentEditorToolbar(slotId: string, editor: Editor | null) {
+export function useDocumentEditorToolbar({
+ canEdit,
+ editor,
+ itemId,
+ slotId,
+}: {
+ canEdit: boolean;
+ editor: Editor | null;
+ itemId: string;
+ slotId: string;
+}) {
const context = use(WorkspaceItemToolbarContext);
const setRegistration = context?.setRegistration;
@@ -58,12 +70,14 @@ export function useDocumentEditorToolbar(slotId: string, editor: Editor | null)
return;
}
- const registration = { editor, kind: "document" as const, slotId };
+ const registration = { canEdit, editor, itemId, kind: "document" as const, slotId };
setRegistration((current) => {
const existing = current[slotId];
if (
existing?.kind === "document" &&
+ existing.canEdit === canEdit &&
existing.editor === editor &&
+ existing.itemId === itemId &&
existing.slotId === slotId
) {
return current;
@@ -87,7 +101,7 @@ export function useDocumentEditorToolbar(slotId: string, editor: Editor | null)
return next;
});
};
- }, [editor, slotId, setRegistration]);
+ }, [canEdit, editor, itemId, slotId, setRegistration]);
}
export function useFileItemToolbar({
@@ -178,7 +192,12 @@ export function WorkspaceItemToolbarSlot({
{registration.kind === "document" ? (
-
+
) : (
+ documentEditReceiptStatusQueryOptions({
+ itemId: group.itemId,
+ receiptIds: group.receiptIds,
+ workspaceId,
+ }),
+ ),
+ });
+ const settledGroups = groups.flatMap((group, index) => {
+ const status = statusQueries[index]?.data?.status;
+ return status === "ready" || status === "reverted"
+ ? [{ group, reverted: status === "reverted" }]
+ : [];
+ });
+
+ if (settledGroups.length === 0) {
+ return null;
+ }
+
+ const firstGroup = settledGroups[0];
+
return (
-
- {groups.map((group) => (
-
- ))}
+
+ {settledGroups.length === 1 && firstGroup ? (
+ }
+ reverted={firstGroup.reverted}
+ />
+ ) : (
+ <>
+
+
+ Updated {settledGroups.length} documents
+
+
+ {settledGroups.map(({ group, reverted }) => (
+
+ ))}
+
+ >
+ )}
);
}
-function DocumentEditActionRow({ group }: { group: AiChatDocumentEditGroup }) {
+function CardIcon() {
+ return (
+
+
+
+ );
+}
+
+function DocumentEditCardRow({
+ group,
+ icon,
+ reverted,
+}: {
+ group: AiChatDocumentEditGroup;
+ icon?: ReactNode;
+ reverted: boolean;
+}) {
const queryClient = useQueryClient();
const { capabilities } = useWorkspaceMutationAccess();
const { activeReview, hideReview, showReview, workspaceId } = useDocumentEditReview();
const { itemId } = group;
const receiptKey = group.receiptIds.join(":");
const target = { itemId, receiptIds: group.receiptIds, workspaceId };
- const statusQuery = useQuery(documentEditReceiptStatusQueryOptions(target));
const undoMutation = useMutation({
mutationFn: () => undoDocumentEditReceiptFn({ data: target }),
onSuccess: async (result) => {
if (result.status === "reverted") {
hideReview();
- toast.success("AI changes undone.");
+ toast.success("Changes undone.");
} else {
toast.error(undoUnavailableMessages[result.status]);
}
@@ -55,81 +118,74 @@ function DocumentEditActionRow({ group }: { group: AiChatDocumentEditGroup }) {
toast.error(error instanceof Error ? error.message : "Could not undo these changes.");
},
});
- if (!statusQuery.data) {
- return null;
- }
-
const isReviewActive = Boolean(
activeReview &&
activeReview.itemId === itemId &&
activeReview.receiptIds.join(":") === receiptKey,
);
- const name = getWorkspacePathName(group.path);
-
- if (statusQuery.data.status === "reverted") {
- return (
-
-
-
- {name}
-
- · Undone
-
- );
- }
- if (statusQuery.data.status !== "ready") {
- return null;
- }
return (
-
-
-
-
- {name}
-
-
-
- {capabilities.canMutateContent ? (
-
- ) : null}
+
+ {icon ?? }
+
+ {reverted ? (
+ Undone
+ ) : (
+
+
+ {capabilities.canMutateContent ? (
+
+ ) : null}
+
+ )}
);
}
+function DocumentName({ muted, path }: { muted: boolean; path: string }) {
+ const name = getWorkspacePathName(path);
+ const separatorIndex = path.lastIndexOf("/");
+ const folder = separatorIndex > 0 ? path.slice(0, separatorIndex + 1) : "";
+
+ return (
+
+ {folder ? {folder} : null}
+ {name}
+
+ );
+}
+
const undoUnavailableMessages: Record = {
- content_changed: "The document changed after these AI changes, so they were not undone.",
- not_found: "These AI changes are no longer available.",
- not_latest: "Undo newer AI changes first.",
- reverted: "These AI changes were already undone.",
+ content_changed: "This document changed after these edits, so they were not undone.",
+ not_found: "These changes are no longer available.",
+ not_latest: "Undo the newer changes first.",
+ reverted: "These changes were already undone.",
review_unavailable: "Undo is unavailable for this large document.",
};
diff --git a/src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx b/src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx
deleted file mode 100644
index 7cd84e1e..00000000
--- a/src/features/workspaces/components/document-editor/DocumentEditReviewBar.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Sparkles } from "lucide-react";
-
-import { Button } from "#/components/ui/button";
-import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context";
-
-export function DocumentEditReviewBar({
- itemId,
- viewInstanceId,
-}: {
- itemId: string;
- viewInstanceId: string;
-}) {
- const { activeReview, hideReview } = useDocumentEditReview();
-
- if (activeReview?.itemId !== itemId || activeReview.viewInstanceId !== viewInstanceId) {
- return null;
- }
-
- return (
-
-
- AI changes
-
-
- );
-}
diff --git a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
index 0dfeb378..3c3e8fbf 100644
--- a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
+++ b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx
@@ -6,7 +6,6 @@ import { useState } from "react";
import { Skeleton } from "#/components/ui/skeleton";
import { DocumentAskSelectionMenu } from "#/features/workspaces/components/document-editor/DocumentAskSelectionMenu";
-import { DocumentEditReviewBar } from "#/features/workspaces/components/document-editor/DocumentEditReviewBar";
import { DocumentWordCount } from "#/features/workspaces/components/document-editor/DocumentWordCount";
import { useDocumentEditorToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot";
import { useWorkspacePaneRuntime } from "#/features/workspaces/components/WorkspacePaneRuntime";
@@ -101,7 +100,12 @@ function DocumentEditorInstance({
},
});
- useDocumentEditorToolbar(viewInstanceId, capabilities.canMutateContent ? editor : null);
+ useDocumentEditorToolbar({
+ canEdit: capabilities.canMutateContent,
+ editor: capabilities.canMutateContent ? editor : null,
+ itemId: item.id,
+ slotId: viewInstanceId,
+ });
useDocumentEditReviewOverlay({
editor,
itemId: item.id,
@@ -111,7 +115,6 @@ function DocumentEditorInstance({
return (
-
;
+ }
+
+ // Read-only viewers get every formatting control disabled, which reads as a
+ // broken toolbar. Show only what still works.
+ if (!canEdit) {
+ return (
+ }
+ >
+
+
+ );
+ }
return (
void }) {
+ return (
+
+
+
+ Reviewing changes
+
+
+
+ );
+}
+
function DocumentMobileMenuContent({
editor,
editorState,
diff --git a/src/styles.css b/src/styles.css
index fb980c6a..10f3f702 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -504,25 +504,46 @@
color: var(--foreground);
}
+ /*
+ * Review marks follow word-processor tracked changes rather than a code diff:
+ * new text is underlined in the app's accent, removed text is struck through
+ * and faded. Retune every mark from this one variable.
+ */
+ .workspace-document-prose {
+ --workspace-document-ai-accent: var(--success);
+ }
+
.workspace-document-prose .workspace-document-ai-inserted {
border-radius: 0.2rem;
- background: color-mix(in oklab, var(--color-blue-500) 18%, transparent);
+ background: color-mix(in oklab, var(--workspace-document-ai-accent) 8%, transparent);
+ text-decoration: underline;
+ text-decoration-color: color-mix(
+ in oklab,
+ var(--workspace-document-ai-accent) 60%,
+ transparent
+ );
+ text-decoration-thickness: 2px;
+ text-underline-offset: 3px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.workspace-document-prose .workspace-document-ai-deleted {
- margin-inline: 0.15rem;
- border-radius: 0.2rem;
- background: color-mix(in oklab, var(--color-muted) 82%, transparent);
- color: var(--color-muted-foreground);
+ color: var(--muted-foreground);
text-decoration: line-through;
+ text-decoration-color: color-mix(in oklab, var(--muted-foreground) 55%, transparent);
+ opacity: 0.75;
}
+ /*
+ * Whole-block changes cannot be underlined inline, so they get a hairline
+ * outline. Deliberately not a fill: tinting a long list or table is the
+ * loudest thing on the page and says the least.
+ */
.workspace-document-prose .workspace-document-ai-changed-block {
- border-radius: 0.35rem;
- box-shadow: inset 2px 0 color-mix(in oklab, var(--color-blue-500) 60%, transparent);
- background: color-mix(in oklab, var(--color-blue-500) 7%, transparent);
+ border-radius: 0.375rem;
+ box-shadow: inset 0 0 0 1px
+ color-mix(in oklab, var(--workspace-document-ai-accent) 20%, transparent);
}
.dark .workspace-document-prose {
From b608473be6555b62b85ef5d24b967f4db0952dd6 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 22:58:22 -0400
Subject: [PATCH 08/49] feat(documents): report what each AI edit changed
Return a block-level added/rewritten/removed tally with receipt status and
show it per document, so the card says what happened instead of only offering
actions. Make Review the primary action.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../ai-chat/AiChatDocumentEditActions.tsx | 214 +++++++++++-------
.../documents/document-ai-edits.test.ts | 52 ++++-
.../workspaces/documents/document-ai-edits.ts | 50 ++++
.../documents/document-edit-receipt.ts | 12 +
.../workspaces/documents/document-session.ts | 14 +-
5 files changed, 253 insertions(+), 89 deletions(-)
diff --git a/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
index dbbff21a..f0ce07d8 100644
--- a/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
@@ -1,13 +1,15 @@
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
-import { Eye, EyeOff, FileText, LoaderCircle, PencilLine, Undo2 } from "lucide-react";
-import type { ReactNode } from "react";
+import { Eye, EyeOff, FileText, LoaderCircle, Undo2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "#/components/ui/button";
import type { AiChatDocumentEditGroup } from "#/features/workspaces/components/ai-chat/ai-chat-document-edit-actions";
import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access";
import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context";
-import type { DocumentEditReceiptUnavailableStatus } from "#/features/workspaces/documents/document-edit-receipt";
+import type {
+ DocumentEditBlockChanges,
+ DocumentEditReceiptUnavailableStatus,
+} from "#/features/workspaces/documents/document-edit-receipt";
import { undoDocumentEditReceiptFn } from "#/features/workspaces/documents/document-edit-review-functions";
import {
documentEditReceiptQueryKey,
@@ -15,10 +17,16 @@ import {
} from "#/features/workspaces/documents/document-edit-review-queries";
import { getWorkspacePathName } from "#/features/workspaces/kernel/workspace-kernel-paths";
+interface SettledDocumentEditGroup {
+ changes?: DocumentEditBlockChanges;
+ group: AiChatDocumentEditGroup;
+ reverted: boolean;
+}
+
/**
- * Card summarising the documents the assistant changed in one turn. Everything
- * here is read by people who did not ask for a diff, so it names documents and
- * offers two plain actions rather than reporting counts of applied operations.
+ * Everything the assistant changed in one turn, in one card. Counts are stated
+ * in words rather than +/- diff stats: this is read by people who did not ask
+ * for a diff, and "21 deletions" reads as damage when it was a rewritten line.
*/
export function AiChatDocumentEditActions({
groups,
@@ -35,67 +43,64 @@ export function AiChatDocumentEditActions({
}),
),
});
- const settledGroups = groups.flatMap((group, index) => {
- const status = statusQueries[index]?.data?.status;
- return status === "ready" || status === "reverted"
- ? [{ group, reverted: status === "reverted" }]
- : [];
+ const settledGroups = groups.flatMap((group, index) => {
+ const result = statusQueries[index]?.data;
+
+ if (result?.status !== "ready" && result?.status !== "reverted") {
+ return [];
+ }
+
+ return [
+ {
+ ...(result.changes ? { changes: result.changes } : {}),
+ group,
+ reverted: result.status === "reverted",
+ },
+ ];
});
if (settledGroups.length === 0) {
return null;
}
- const firstGroup = settledGroups[0];
-
return (
- {settledGroups.length === 1 && firstGroup ? (
- }
- reverted={firstGroup.reverted}
- />
- ) : (
- <>
-
-
- Updated {settledGroups.length} documents
-
-
- {settledGroups.map(({ group, reverted }) => (
-
- ))}
-
- >
- )}
+ {settledGroups.length > 1 ? (
+
+
+
+ Edited {settledGroups.length} documents
+
+ {summarizeSettledGroups(settledGroups)}
+
+ ) : null}
+
+ {settledGroups.map((settled) => (
+
+ ))}
+
);
}
-function CardIcon() {
- return (
-
-
-
- );
-}
-
-function DocumentEditCardRow({
- group,
- icon,
- reverted,
+function DocumentEditRow({
+ settled,
+ showIcon,
}: {
- group: AiChatDocumentEditGroup;
- icon?: ReactNode;
- reverted: boolean;
+ settled: SettledDocumentEditGroup;
+ showIcon: boolean;
}) {
const queryClient = useQueryClient();
const { capabilities } = useWorkspaceMutationAccess();
const { activeReview, hideReview, showReview, workspaceId } = useDocumentEditReview();
+ const { group, reverted } = settled;
const { itemId } = group;
const receiptKey = group.receiptIds.join(":");
const target = { itemId, receiptIds: group.receiptIds, workspaceId };
@@ -123,65 +128,100 @@ function DocumentEditCardRow({
activeReview.itemId === itemId &&
activeReview.receiptIds.join(":") === receiptKey,
);
+ const summary = settled.changes ? formatBlockChanges(settled.changes) : "";
return (
-
- {icon ?? }
-
- {reverted ? (
- Undone
- ) : (
-
-
- {capabilities.canMutateContent ? (
+
+ {showIcon ? (
+
+ ) : null}
+
+
+
+ {summary && !reverted ? (
+ {summary}
+ ) : null}
+
+ {reverted ? (
+ Undone
+ ) : (
+ <>
+ {capabilities.canMutateContent ? (
+
+ ) : null}
- ) : null}
-
- )}
+ >
+ )}
+
);
}
-function DocumentName({ muted, path }: { muted: boolean; path: string }) {
- const name = getWorkspacePathName(path);
+function DocumentPathLabel({ path }: { path: string }) {
const separatorIndex = path.lastIndexOf("/");
const folder = separatorIndex > 0 ? path.slice(0, separatorIndex + 1) : "";
return (
-
+ <>
{folder ? {folder} : null}
- {name}
-
+ {getWorkspacePathName(path)}
+ >
);
}
+function summarizeSettledGroups(settledGroups: SettledDocumentEditGroup[]) {
+ return formatBlockChanges(
+ settledGroups.reduce(
+ (total, settled) => ({
+ added: total.added + (settled.changes?.added ?? 0),
+ edited: total.edited + (settled.changes?.edited ?? 0),
+ removed: total.removed + (settled.changes?.removed ?? 0),
+ }),
+ { added: 0, edited: 0, removed: 0 },
+ ),
+ );
+}
+
+function formatBlockChanges(changes: DocumentEditBlockChanges) {
+ return [
+ changes.added > 0 ? `${changes.added} added` : "",
+ changes.edited > 0 ? `${changes.edited} rewritten` : "",
+ changes.removed > 0 ? `${changes.removed} removed` : "",
+ ]
+ .filter(Boolean)
+ .join(" · ");
+}
+
const undoUnavailableMessages: Record = {
content_changed: "This document changed after these edits, so they were not undone.",
not_found: "These changes are no longer available.",
diff --git a/src/features/workspaces/documents/document-ai-edits.test.ts b/src/features/workspaces/documents/document-ai-edits.test.ts
index 7d6bae44..1b3a2c9d 100644
--- a/src/features/workspaces/documents/document-ai-edits.test.ts
+++ b/src/features/workspaces/documents/document-ai-edits.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
-import { applyDocumentAiEdits } from "#/features/workspaces/documents/document-ai-edits";
+import {
+ applyDocumentAiEdits,
+ summarizeDocumentAiBlockChanges,
+} from "#/features/workspaces/documents/document-ai-edits";
import {
ensureTiptapDocumentAiRefs,
parseDocumentAiHtml,
@@ -88,8 +91,55 @@ describe("document AI edits", () => {
/<\/ul><\/p>$/,
);
});
+
+ it("counts block changes by ref so a moved block is not an add plus a remove", async () => {
+ const document = createDocument("
Title
Keep
Rewrite
Drop
");
+ const rewriteRef = await getNthRef(document, "p", 1);
+ const dropRef = await getNthRef(document, "p", 2);
+ const edited = await applyDocumentAiEdits(document, [
+ { html: "Rewritten
", op: "replace", ref: rewriteRef },
+ { html: "Brand new
", op: "insert_after", ref: rewriteRef },
+ { op: "delete", ref: dropRef },
+ ]);
+
+ expect(edited.applied).toBe(3);
+ // The replaced paragraph keeps its ref, so it counts as rewritten rather
+ // than as a removal plus an addition.
+ expect(summarizeDocumentAiBlockChanges(document, edited.document)).toEqual({
+ added: 1,
+ edited: 1,
+ removed: 1,
+ });
+ });
+
+ it("reports nothing changed when the document is untouched", () => {
+ const document = createDocument("Title
Body
");
+
+ expect(summarizeDocumentAiBlockChanges(document, document)).toEqual({
+ added: 0,
+ edited: 0,
+ removed: 0,
+ });
+ });
});
+async function getNthRef(
+ document: ReturnType,
+ tagName: string,
+ index: number,
+) {
+ const refs = [
+ ...(await serializeTiptapDocumentToAiHtml(document)).matchAll(
+ new RegExp(`<${tagName} data-ref="([^"]+)"`, "g"),
+ ),
+ ];
+ const ref = refs[index]?.[1];
+ if (!ref) {
+ throw new Error(`Expected ${tagName} ref at ${index}.`);
+ }
+ return ref;
+}
+
function createDocument(html: string) {
return ensureTiptapDocumentAiRefs(parseDocumentAiHtml(html)).document;
}
diff --git a/src/features/workspaces/documents/document-ai-edits.ts b/src/features/workspaces/documents/document-ai-edits.ts
index f6fcdb8b..c15bc509 100644
--- a/src/features/workspaces/documents/document-ai-edits.ts
+++ b/src/features/workspaces/documents/document-ai-edits.ts
@@ -10,6 +10,7 @@ import {
readTiptapNodeAiRef,
withTiptapNodeAiRef,
} from "#/features/workspaces/documents/document-ai-html";
+import type { DocumentEditBlockChanges } from "#/features/workspaces/documents/document-edit-receipt";
import {
coerceTiptapDocumentJson,
type TiptapDocumentJson,
@@ -130,6 +131,55 @@ export async function applyDocumentAiEdits(
};
}
+/**
+ * Compare two versions of a document by top-level block, matching blocks on
+ * their stable AI ref so a block that moved is not counted as a delete plus an
+ * add. Blocks without a ref fall back to their position.
+ */
+export function summarizeDocumentAiBlockChanges(
+ before: TiptapDocumentJson,
+ after: TiptapDocumentJson,
+): DocumentEditBlockChanges {
+ const beforeBlocks = getDocumentBlockFingerprints(before);
+ const afterBlocks = getDocumentBlockFingerprints(after);
+ let added = 0;
+ let edited = 0;
+ let removed = 0;
+
+ for (const [ref, fingerprint] of afterBlocks) {
+ const previous = beforeBlocks.get(ref);
+ if (previous === undefined) {
+ added++;
+ } else if (previous !== fingerprint) {
+ edited++;
+ }
+ }
+
+ for (const ref of beforeBlocks.keys()) {
+ if (!afterBlocks.has(ref)) {
+ removed++;
+ }
+ }
+
+ return { added, edited, removed };
+}
+
+function getDocumentBlockFingerprints(document: TiptapDocumentJson) {
+ const fingerprints = new Map();
+
+ getTiptapDocumentSchema()
+ .nodeFromJSON(document)
+ .forEach((node, _offset, index) => {
+ const ref = readTiptapNodeAiRef(node);
+ fingerprints.set(
+ ref ?? `position:${index}`,
+ JSON.stringify(ref === null ? node.toJSON() : withTiptapNodeAiRef(node, null).toJSON()),
+ );
+ });
+
+ return fingerprints;
+}
+
function applyDocumentAiEdit(
document: ProseMirrorNode,
edit: DocumentAiEdit,
diff --git a/src/features/workspaces/documents/document-edit-receipt.ts b/src/features/workspaces/documents/document-edit-receipt.ts
index c05debbc..5e07ebda 100644
--- a/src/features/workspaces/documents/document-edit-receipt.ts
+++ b/src/features/workspaces/documents/document-edit-receipt.ts
@@ -11,7 +11,19 @@ export type DocumentEditReceiptStatus =
/** Every status a receipt can report once it is known not to be reviewable. */
export type DocumentEditReceiptUnavailableStatus = Exclude;
+/**
+ * Block-level tally of what an AI edit did, counted against the document as it
+ * stands now. Blocks, not lines: a document has no lines, and a block is the
+ * same unit the model targets, so the count matches what review highlights.
+ */
+export interface DocumentEditBlockChanges {
+ added: number;
+ edited: number;
+ removed: number;
+}
+
export interface DocumentEditReceiptStatusResult {
+ changes?: DocumentEditBlockChanges;
status: DocumentEditReceiptStatus;
}
diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts
index 310bb0f0..5f4084cc 100644
--- a/src/features/workspaces/documents/document-session.ts
+++ b/src/features/workspaces/documents/document-session.ts
@@ -9,6 +9,7 @@ import { YServer } from "y-partyserver";
import type { DocumentSessionRouteParams } from "#/features/workspaces/agent-routes";
import {
applyDocumentAiEdits,
+ summarizeDocumentAiBlockChanges,
type DocumentAiEdit,
type DocumentAiEditFailureCode,
type DocumentAiEditResultStatus,
@@ -258,7 +259,18 @@ export class DocumentSession extends YServer {
receiptIds: string[];
}): Promise {
const group = await this.resolveDocumentEditReceiptGroup(input.receiptIds);
- return { status: group.status };
+
+ if (group.status !== "ready") {
+ return { status: group.status };
+ }
+
+ return {
+ changes: summarizeDocumentAiBlockChanges(
+ group.beforeDocument,
+ this.getCurrentTiptapDocument(),
+ ),
+ status: "ready",
+ };
}
async getDocumentEditReceiptReview(input: {
From 39c84b755f22bf5bfb6e8963a9563d837b629f2e Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 23:00:30 -0400
Subject: [PATCH 09/49] fix(documents): mark reviewed changes inline instead of
boxing blocks
Inline decorations already span block boundaries, so marking whole blocks drew
hairline rules across the page - including around blank paragraphs that had
nothing to show. Keep block decorations for text-less atoms only, and give the
inline mark enough weight and padding to read.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../document-edit-review-extension.ts | 45 +++++--------------
src/styles.css | 25 ++++++-----
2 files changed, 27 insertions(+), 43 deletions(-)
diff --git a/src/features/workspaces/documents/document-edit-review-extension.ts b/src/features/workspaces/documents/document-edit-review-extension.ts
index a555f887..5c8b6b15 100644
--- a/src/features/workspaces/documents/document-edit-review-extension.ts
+++ b/src/features/workspaces/documents/document-edit-review-extension.ts
@@ -108,21 +108,21 @@ function createDocumentEditReviewDecorations(
for (const [index, change] of changes.entries()) {
if (change.fromB < change.toB) {
- const isInlineChange = addInsertedContentDecoration(
+ // Inline decorations already span block boundaries, marking the text
+ // inside each one. Only blocks that carry no text of their own — a rule,
+ // a rendered formula — need a decoration of their own to be visible.
+ decorations.push(
+ Decoration.inline(change.fromB, change.toB, {
+ class: "workspace-document-ai-inserted",
+ }),
+ );
+ addChangedAtomDecorations(
decorations,
+ decoratedBlocks,
afterDocument,
change.fromB,
change.toB,
);
- if (!isInlineChange) {
- addChangedBlockDecorations(
- decorations,
- decoratedBlocks,
- afterDocument,
- change.fromB,
- change.toB,
- );
- }
}
if (change.fromA < change.toA) {
@@ -139,28 +139,7 @@ function createDocumentEditReviewDecorations(
return DecorationSet.create(afterDocument, decorations);
}
-function addInsertedContentDecoration(
- decorations: Decoration[],
- document: ProseMirrorNode,
- from: number,
- to: number,
-) {
- const start = document.resolve(from);
- const end = document.resolve(to);
-
- if (start.sameParent(end) && start.parent.inlineContent) {
- decorations.push(
- Decoration.inline(from, to, {
- class: "workspace-document-ai-inserted",
- }),
- );
- return true;
- }
-
- return false;
-}
-
-function addChangedBlockDecorations(
+function addChangedAtomDecorations(
decorations: Decoration[],
decoratedBlocks: Set,
document: ProseMirrorNode,
@@ -169,7 +148,7 @@ function addChangedBlockDecorations(
) {
document.forEach((node, offset) => {
const end = offset + node.nodeSize;
- if (end <= from || offset >= to) {
+ if (end <= from || offset >= to || !node.isAtom) {
return;
}
diff --git a/src/styles.css b/src/styles.css
index 10f3f702..bc74945b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -514,12 +514,17 @@
}
.workspace-document-prose .workspace-document-ai-inserted {
- border-radius: 0.2rem;
- background: color-mix(in oklab, var(--workspace-document-ai-accent) 8%, transparent);
+ /* The negative margin cancels the padding, so the tint gets breathing
+ * room without nudging the surrounding text. */
+ margin-inline: -0.15em;
+ padding-block: 0.06em;
+ padding-inline: 0.15em;
+ border-radius: 0.25rem;
+ background: color-mix(in oklab, var(--workspace-document-ai-accent) 15%, transparent);
text-decoration: underline;
text-decoration-color: color-mix(
in oklab,
- var(--workspace-document-ai-accent) 60%,
+ var(--workspace-document-ai-accent) 80%,
transparent
);
text-decoration-thickness: 2px;
@@ -531,19 +536,19 @@
.workspace-document-prose .workspace-document-ai-deleted {
color: var(--muted-foreground);
text-decoration: line-through;
- text-decoration-color: color-mix(in oklab, var(--muted-foreground) 55%, transparent);
- opacity: 0.75;
+ text-decoration-color: color-mix(in oklab, var(--muted-foreground) 65%, transparent);
+ opacity: 0.8;
}
/*
- * Whole-block changes cannot be underlined inline, so they get a hairline
- * outline. Deliberately not a fill: tinting a long list or table is the
- * loudest thing on the page and says the least.
+ * Only reached by blocks holding no text of their own — a horizontal rule, a
+ * rendered formula. Anything containing text is marked inline instead, so
+ * this never draws a box around a paragraph, a list, or a blank line.
*/
.workspace-document-prose .workspace-document-ai-changed-block {
+ padding: 0.35rem 0.5rem;
border-radius: 0.375rem;
- box-shadow: inset 0 0 0 1px
- color-mix(in oklab, var(--workspace-document-ai-accent) 20%, transparent);
+ background: color-mix(in oklab, var(--workspace-document-ai-accent) 12%, transparent);
}
.dark .workspace-document-prose {
From 28b4da956dc38ae0153df285a1325bd2a305a5f8 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Fri, 31 Jul 2026 23:04:09 -0400
Subject: [PATCH 10/49] feat(documents): end AI change review only when the
reader says so
Map review marks through edits instead of clearing them, so typing no longer
throws the reader out of review. Move the chat receipt's tally under each
document with colour-coded counts, put the icon in the header, and drop the
card border that was clipping against the message column.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../ai-chat/AiChatDocumentEditActions.tsx | 180 +++++++++---------
.../document-editor/DocumentToolbar.tsx | 11 +-
.../document-edit-review-extension.ts | 4 +-
.../use-document-edit-review-overlay.ts | 32 ++--
4 files changed, 103 insertions(+), 124 deletions(-)
diff --git a/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
index f0ce07d8..b099450e 100644
--- a/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx
@@ -1,5 +1,5 @@
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
-import { Eye, EyeOff, FileText, LoaderCircle, Undo2 } from "lucide-react";
+import { FilePen, LoaderCircle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "#/components/ui/button";
@@ -24,9 +24,10 @@ interface SettledDocumentEditGroup {
}
/**
- * Everything the assistant changed in one turn, in one card. Counts are stated
- * in words rather than +/- diff stats: this is read by people who did not ask
- * for a diff, and "21 deletions" reads as damage when it was a rewritten line.
+ * Receipt for the documents the assistant changed in one turn: a header saying
+ * how much happened, then one row per document with its own tally and actions.
+ * Counts are worded rather than shown as +/- diff stats, because this is read
+ * by people who did not ask for a diff.
*/
export function AiChatDocumentEditActions({
groups,
@@ -66,37 +67,24 @@ export function AiChatDocumentEditActions({
return (
- {settledGroups.length > 1 ? (
-
-
-
- Edited {settledGroups.length} documents
-
- {summarizeSettledGroups(settledGroups)}
-
- ) : null}
-
+
+
+
+ Edited {settledGroups.length} {settledGroups.length === 1 ? "document" : "documents"}
+
+
+
{settledGroups.map((settled) => (
-
+
))}
);
}
-function DocumentEditRow({
- settled,
- showIcon,
-}: {
- settled: SettledDocumentEditGroup;
- showIcon: boolean;
-}) {
+function DocumentEditRow({ settled }: { settled: SettledDocumentEditGroup }) {
const queryClient = useQueryClient();
const { capabilities } = useWorkspaceMutationAccess();
const { activeReview, hideReview, showReview, workspaceId } = useDocumentEditReview();
@@ -128,61 +116,47 @@ function DocumentEditRow({
activeReview.itemId === itemId &&
activeReview.receiptIds.join(":") === receiptKey,
);
- const summary = settled.changes ? formatBlockChanges(settled.changes) : "";
return (
-
- {showIcon ? (
-
- ) : null}
-
-
-
- {summary && !reverted ? (
- {summary}
- ) : null}
-
- {reverted ? (
- Undone
- ) : (
- <>
- {capabilities.canMutateContent ? (
-
- ) : null}
+
+
+
+
+
+
+
+ {reverted ? null : (
+
+ {capabilities.canMutateContent ? (
- >
- )}
-
+ ) : null}
+
+
+ )}
);
}
@@ -199,27 +173,43 @@ function DocumentPathLabel({ path }: { path: string }) {
);
}
-function summarizeSettledGroups(settledGroups: SettledDocumentEditGroup[]) {
- return formatBlockChanges(
- settledGroups.reduce(
- (total, settled) => ({
- added: total.added + (settled.changes?.added ?? 0),
- edited: total.edited + (settled.changes?.edited ?? 0),
- removed: total.removed + (settled.changes?.removed ?? 0),
- }),
- { added: 0, edited: 0, removed: 0 },
- ),
- );
-}
+function ChangeSummary({
+ changes,
+ reverted,
+}: {
+ changes?: DocumentEditBlockChanges;
+ reverted: boolean;
+}) {
+ if (reverted) {
+ return Undone;
+ }
+
+ // Colour carries the meaning at a glance, muted so a removal reads as a fact
+ // rather than an alarm.
+ const parts = [
+ changes?.added ? { className: "text-success/90", label: `${changes.added} added` } : null,
+ changes?.edited
+ ? { className: "text-muted-foreground", label: `${changes.edited} rewritten` }
+ : null,
+ changes?.removed
+ ? { className: "text-destructive/80", label: `${changes.removed} removed` }
+ : null,
+ ].filter((part) => part !== null);
-function formatBlockChanges(changes: DocumentEditBlockChanges) {
- return [
- changes.added > 0 ? `${changes.added} added` : "",
- changes.edited > 0 ? `${changes.edited} rewritten` : "",
- changes.removed > 0 ? `${changes.removed} removed` : "",
- ]
- .filter(Boolean)
- .join(" · ");
+ if (parts.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {parts.map((part, index) => (
+
+ {index > 0 ? · : null}
+ {part.label}
+
+ ))}
+
+ );
}
const undoUnavailableMessages: Record = {
diff --git a/src/features/workspaces/components/document-editor/DocumentToolbar.tsx b/src/features/workspaces/components/document-editor/DocumentToolbar.tsx
index 8b5cfb12..26ac324a 100644
--- a/src/features/workspaces/components/document-editor/DocumentToolbar.tsx
+++ b/src/features/workspaces/components/document-editor/DocumentToolbar.tsx
@@ -1,5 +1,5 @@
import type { Editor } from "@tiptap/react";
-import { Check, Download, EllipsisVertical, FileText, Redo2, Sparkles, Undo2 } from "lucide-react";
+import { Check, Download, EllipsisVertical, FileText, Redo2, Undo2 } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "#/components/ui/button";
@@ -100,12 +100,9 @@ export function DocumentToolbar({
function DocumentEditReviewControls({ onDone }: { onDone: () => void }) {
return (
-
-
-
- Reviewing changes
-
-