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: '

Notes

', + format: "html", itemId: "item-1", location: { - endLine: 1, - kind: "lines", - startLine: 1, - totalLines: 1, + endBlock: 1, + kind: "blocks", + startBlock: 1, + totalBlocks: 1, }, path: "/Notes", status: "ready", diff --git a/src/features/workspaces/ai/workspace-tool-result-adapters.ts b/src/features/workspaces/ai/workspace-tool-result-adapters.ts index 99e9a493..0819110c 100644 --- a/src/features/workspaces/ai/workspace-tool-result-adapters.ts +++ b/src/features/workspaces/ai/workspace-tool-result-adapters.ts @@ -11,14 +11,14 @@ import { workspaceSearchOutputSchema } from "#/features/workspaces/search/worksp import { createWorkspaceSearchModelOutput } from "#/features/workspaces/search/workspace-search-references"; function defineWorkspaceToolResultAdapter(input: { - collectReferences: (output: z.output) => readonly WorkspaceReferenceRecord[]; + collectReferences?: (output: z.output) => readonly WorkspaceReferenceRecord[]; outputSchema: TSchema; projectOutput: (output: z.output) => unknown; }) { return { collectReferences: (output: unknown) => { const parsed = input.outputSchema.safeParse(output); - return parsed.success ? input.collectReferences(parsed.data) : []; + return parsed.success ? (input.collectReferences?.(parsed.data) ?? []) : []; }, // Also runs when history is replayed, on results shaped by older versions // of this schema. Execution already validated them, so projection is @@ -51,7 +51,6 @@ const workspaceCreateItemsResultAdapter = defineWorkspaceToolResultAdapter({ itemId: z.string(), path: z.string(), type: z.enum(["document", "folder"]), - warnings: z.array(z.string()).optional(), }), ), references: z.array(workspaceReferenceRecordSchema), @@ -65,22 +64,29 @@ const workspaceCreateItemsResultAdapter = defineWorkspaceToolResultAdapter({ return { failed: output.failed, - items: output.items.map(({ itemId, path, type, warnings }) => { + items: output.items.map(({ itemId, path, type }) => { const reference = refsByItemId.get(itemId); - return { - path, - ...(reference ? { reference } : {}), - type, - ...(warnings ? { warnings } : {}), - }; + return { path, ...(reference ? { reference } : {}), type }; }), }; }, }); +// The operation output also carries the durable item id used by the app's +// review controls. Keep the model-facing receipt limited to actionable counts. +const workspaceEditItemResultAdapter = defineWorkspaceToolResultAdapter({ + outputSchema: z.object({ + applied: z.number(), + failed: z.array(z.object({ code: z.string(), index: z.number() })), + path: z.string(), + }), + projectOutput: (output) => output, +}); + const workspaceToolResultAdapters = { workspace_create_items: workspaceCreateItemsResultAdapter, + workspace_edit_item: workspaceEditItemResultAdapter, workspace_read_items: workspaceReadItemsResultAdapter, workspace_search: workspaceSearchResultAdapter, } as const; diff --git a/src/features/workspaces/content/workspace-content-contract.test.ts b/src/features/workspaces/content/workspace-content-contract.test.ts index dd0dec1b..ea4918c7 100644 --- a/src/features/workspaces/content/workspace-content-contract.test.ts +++ b/src/features/workspaces/content/workspace-content-contract.test.ts @@ -54,10 +54,10 @@ describe("workspace read tool schemas", () => { references: [], results: [ { - content: "# Notes", - format: "markdown", + content: '

Notes

', + format: "html", itemId: "notes", - 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/content/workspace-content-contract.ts b/src/features/workspaces/content/workspace-content-contract.ts index c51046ac..2ae8a4d6 100644 --- a/src/features/workspaces/content/workspace-content-contract.ts +++ b/src/features/workspaces/content/workspace-content-contract.ts @@ -78,13 +78,13 @@ const workspaceReadRelationsSchema = z.array( const workspaceContentReadResultSchema = z.union([ z.object({ content: z.string(), - format: z.literal("markdown"), + format: z.literal("html"), itemId: z.string().min(1), location: z.object({ - endLine: z.number().int().nonnegative(), - kind: z.literal("lines"), - startLine: z.number().int().nonnegative(), - totalLines: z.number().int().nonnegative(), + endBlock: z.number().int().positive(), + kind: z.literal("blocks"), + startBlock: z.number().int().positive(), + totalBlocks: z.number().int().positive(), }), nextCursor: z.string().optional(), path: workspacePathSchema, diff --git a/src/features/workspaces/content/workspace-content-cursor.ts b/src/features/workspaces/content/workspace-content-cursor.ts index a6ae381e..2f1714b2 100644 --- a/src/features/workspaces/content/workspace-content-cursor.ts +++ b/src/features/workspaces/content/workspace-content-cursor.ts @@ -8,7 +8,7 @@ const workspaceContentCursorSchema = z.discriminatedUnion("kind", [ offset: z.number().int().nonnegative(), path: z.string().min(1), revision: z.string().min(1), - version: z.literal(2), + version: z.literal(3), }), z.object({ kind: z.literal("file"), diff --git a/src/features/workspaces/content/workspace-content-reader.test.ts b/src/features/workspaces/content/workspace-content-reader.test.ts index ac3108f0..efdf20f3 100644 --- a/src/features/workspaces/content/workspace-content-reader.test.ts +++ b/src/features/workspaces/content/workspace-content-reader.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; import type { WorkspaceContentReadRequest } from "#/features/workspaces/content/workspace-content-contract"; -import { createDocumentMarkdownSnapshot } from "#/features/workspaces/documents/document-markdown-chunk"; +import { + ensureTiptapDocumentAiRefs, + parseDocumentAiHtml, +} from "#/features/workspaces/documents/document-ai-html"; +import { readDocumentHtmlChunk } from "#/features/workspaces/documents/document-html-chunk"; +import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema"; import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; import type { WorkspaceKernelPathResolution } from "#/features/workspaces/kernel/workspace-kernel-types"; import { readWorkspaceContent } from "#/features/workspaces/content/workspace-content-reader"; @@ -26,8 +31,8 @@ const documentItem: WorkspaceItemSummary = { describe("WorkspaceContentReader", () => { it("continues a large live document with a revision-guarded cursor", async () => { - const markdown = Array.from({ length: 20_000 }, (_, index) => `line ${index + 1}`).join("\n"); - const session = createDocumentSession({ markdown, revision: "revision-1" }); + const html = Array.from({ length: 20_000 }, (_, index) => `

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 = `

Heading

${"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( + /^

Heading<\/h1>$/, + ); + expect(contents[1]).toContain("
");
+		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( + '

Unsafe

', + ), + ).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 ( +
+
+ ); + } + if (statusQuery.data.status !== "ready") { + return null; + } + + return ( +
+
+
+ + {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 ( +
+
+ ); +} 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 ( -
-
- ); - } - if (statusQuery.data.status !== "ready") { - return null; - } return ( -
-
-
- - {capabilities.canMutateContent ? ( - - ) : null} +
+ {icon ??
); } +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 ( -
-
- ); -} 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 ( +
+ + + +
+ ); +} + 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 ? ( +
+
+ ) : 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 ??