diff --git a/knip.json b/knip.json index 3817ff7c..68af3c01 100644 --- a/knip.json +++ b/knip.json @@ -18,7 +18,6 @@ "ignoreBinaries": ["infisical"], "ignoreDependencies": [ "@babel/plugin-proposal-decorators", - "@cloudflare/vitest-pool-workers", "@tailwindcss/typography", "@tanstack/devtools-event-client", "@tanstack/router-plugin", diff --git a/package.json b/package.json index 5a1a56d9..18363585 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "knip": "knip", "prepare": "vp config --no-agent", "test": "vp test --run", - "test:workers": "vp test --run --passWithNoTests --config vitest.cloudflare.config.ts", + "test:workers": "vp test --run --project=workers", "deploy:worker": "CLOUDFLARE_ENV=production wrangler deploy", "deploy:worker:staging": "CLOUDFLARE_ENV=staging wrangler deploy --containers-rollout=none", "deploy": "vp run build:production && vp run deploy:worker", @@ -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/durable-objects.ts b/src/durable-objects.ts new file mode 100644 index 00000000..5eee4944 --- /dev/null +++ b/src/durable-objects.ts @@ -0,0 +1,11 @@ +// Single source of truth for the Worker's stateful exports. The app entry and +// the test entry both re-export this list so a new Durable Object cannot reach +// production while staying invisible to the worker test project. +export { CodemodeRuntime } from "@cloudflare/codemode"; +export { Sandbox } from "@cloudflare/sandbox"; +export { AIThread, UserAIStore } from "#/features/workspaces/ai/user-ai-agents"; +export { OfficePdfConverter } from "#/features/workspaces/conversion/office-pdf-converter"; +export { DocumentSession } from "#/features/workspaces/documents/document-session"; +export { WorkspaceFileExtractionWorkflow } from "#/features/workspaces/extraction/workspace-file-extraction-workflow"; +export { WorkspaceFileProcessor } from "#/features/workspaces/files/workspace-file-processor"; +export { WorkspaceKernel } from "#/features/workspaces/kernel/workspace-kernel"; diff --git a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts index a3c694f9..13426c27 100644 --- a/src/features/workspaces/ai/ai-thread-orchestration-contract.ts +++ b/src/features/workspaces/ai/ai-thread-orchestration-contract.ts @@ -8,6 +8,11 @@ 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, +} from "#/features/workspaces/ai/ai-thread-tool-ui-metadata"; const orchestrationCallStateSchema = z.enum([ "executing", @@ -76,6 +81,15 @@ const rawOrchestrationOutputSchema = z }); const orchestrationCallSchema = z.object({ + action: z + .object({ + kind: z.literal("document-edit"), + itemId: z.string(), + lineChanges: z.object({ added: z.number(), removed: z.number() }).optional(), + path: z.string(), + receiptId: z.string(), + }) + .optional(), id: z.string(), outcome: aiToolOutcomeSchema, requiresApproval: z.boolean(), @@ -130,7 +144,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 +194,25 @@ export function getAIThreadOrchestrationTelemetryOutput(output: unknown) { return { status: parsed.data.status, outcome: parsed.data.outcome, - calls: parsed.data.calls, + calls: withoutCallActions(parsed.data.calls), ...(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: 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 { return { status: "error", @@ -207,8 +235,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 +249,37 @@ 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); + + const lineChanges = asRecord(result.lineChanges); + return itemId && path && applied > 0 && receiptId + ? { + itemId, + kind: "document-edit" as const, + ...(typeof lineChanges.added === "number" && typeof lineChanges.removed === "number" + ? { lineChanges: { added: lineChanges.added, removed: lineChanges.removed } } + : {}), + path, + receiptId, + } + : undefined; +} + /** * Collapse each *contiguous* run of CDP traffic into one browser receipt. * 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-runtime.ts b/src/features/workspaces/ai/ai-thread-runtime.ts index 410a3afa..25cacbfb 100644 --- a/src/features/workspaces/ai/ai-thread-runtime.ts +++ b/src/features/workspaces/ai/ai-thread-runtime.ts @@ -90,6 +90,7 @@ export function createAIThreadTools(input: { workspace: WorkspaceLike; getThreadContext: () => Promise; onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; timeZone?: string; }): ToolSet { return createAIThreadToolCatalog(input).tools; @@ -144,6 +145,7 @@ function createAIThreadToolCatalog(input: { workspace: WorkspaceLike; getThreadContext: () => Promise; onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; timeZone?: string; }) { const sandboxTools = createSandboxTools(input.workspace); @@ -159,6 +161,7 @@ function createAIThreadToolCatalog(input: { const workspaceTools = createAIThreadWorkspaceTools({ getThreadContext: input.getThreadContext, onWorkspaceReferences: input.onWorkspaceReferences, + resolveWorkspaceReferences: input.resolveWorkspaceReferences, }); const entries: AIThreadToolEntry[] = []; 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..ac0db06b --- /dev/null +++ b/src/features/workspaces/ai/ai-thread-tool-ui-metadata.ts @@ -0,0 +1,52 @@ +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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/features/workspaces/ai/ai-thread.ts b/src/features/workspaces/ai/ai-thread.ts index 77c60b16..6c46ef44 100644 --- a/src/features/workspaces/ai/ai-thread.ts +++ b/src/features/workspaces/ai/ai-thread.ts @@ -172,6 +172,7 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { onWorkspaceReferences: (records) => { this._recordWorkspaceReferences(records); }, + resolveWorkspaceReferences: (refs) => this._resolveWorkspaceReferences(refs), }); } @@ -517,6 +518,21 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { this.activeWorkspaceReferences.push(...records); } + /** + * Looks up the refs a read handed the assistant, so a tool can turn one + * into the location it stands for. Reads from this turn are not in the + * transcript yet, which is exactly when a document usually cites them. + */ + private async _resolveWorkspaceReferences(refs: readonly string[]) { + const wanted = new Set(refs); + const records = [ + ...collectWorkspaceReferenceRecords(await this.getMessages()), + ...this.activeWorkspaceReferences, + ]; + + return records.filter((record) => wanted.has(record.ref)); + } + private async _reconcileWorkspaceCitations(message: ChatResponseResult["message"]) { try { const transcriptReferences = collectWorkspaceReferenceRecords(await this.getMessages()); diff --git a/src/features/workspaces/ai/user-ai-agents.ts b/src/features/workspaces/ai/user-ai-agents.ts index f5409bba..99946561 100644 --- a/src/features/workspaces/ai/user-ai-agents.ts +++ b/src/features/workspaces/ai/user-ai-agents.ts @@ -96,6 +96,21 @@ export class UserAIStore extends Agent { initialState: UserAIStoreState = { isLoaded: false, threads: [] }; + // A purge empties storage without evicting this instance, so every query + // routes through here to stop reading a schema that no longer exists. + private purged = false; + + override sql>( + strings: TemplateStringsArray, + ...values: (string | number | boolean | null)[] + ): T[] { + if (this.purged) { + throw new Error("Account deleted."); + } + + return super.sql(strings, ...values); + } + onStart() { const startedAt = performance.now(); let stage: "schema" | "state_refresh" = "schema"; @@ -334,6 +349,7 @@ export class UserAIStore extends Agent { connection.close(1008, "Account deleted"); } await this.ctx.storage.deleteAll(); + this.purged = true; } return { attempted: threads.length + 1, failed }; } diff --git a/src/features/workspaces/ai/user-ai-store-purge.worker.test.ts b/src/features/workspaces/ai/user-ai-store-purge.worker.test.ts new file mode 100644 index 00000000..117a8f1f --- /dev/null +++ b/src/features/workspaces/ai/user-ai-store-purge.worker.test.ts @@ -0,0 +1,33 @@ +import { env } from "cloudflare:test"; +import { runInDurableObject } from "cloudflare:test"; +import { getAgentByName } from "agents"; +import { describe, expect, it } from "vitest"; + +import type { UserAIStore } from "#/features/workspaces/ai/user-ai-agents"; +import type { ResourcePurgeResult } from "#/features/workspaces/resource-purge-result"; + +// The generated recursive Agent stub exceeds TypeScript's instantiation depth, +// so narrow the binding the way the lifecycle helpers do. +interface TestUserAIStore { + purgeForDeletion(): Promise; +} + +describe("user AI store purge", () => { + it("refuses queries on the live instance instead of reading a dropped schema", async () => { + const namespace = Reflect.get(env as object, "UserAIStore") as DurableObjectNamespace; + const userId = "purge-live-instance"; + + // This DO builds its schema in onStart rather than its constructor, so it + // has to be reached through the agent bootstrap. A raw stub skips it. + const agent = (await getAgentByName(namespace as never, userId)) as unknown as TestUserAIStore; + const purge = await agent.purgeForDeletion(); + expect(purge.failed).toBe(0); + + // The purge empties storage without evicting the instance, so this runs + // against the same object whose schema setup already ran. + const stub = namespace.get(namespace.idFromName(userId)) as DurableObjectStub; + await runInDurableObject(stub, async (store: UserAIStore) => { + await expect(store.purgeForDeletion()).rejects.toThrow("Account deleted."); + }); + }); +}); 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..d1d3193c 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,31 @@ 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(), detail: z.string().optional(), 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/ai/workspace-tools.ts b/src/features/workspaces/ai/workspace-tools.ts index 5c35e716..5fc774ea 100644 --- a/src/features/workspaces/ai/workspace-tools.ts +++ b/src/features/workspaces/ai/workspace-tools.ts @@ -18,6 +18,7 @@ type WorkspaceThreadToolConfig = { definition: (typeof workspaceToolDefinitions)[number]; getThreadContext: () => Promise; onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; }; function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { @@ -46,6 +47,7 @@ function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { thread, getWorkspaceToolScopes(definition.access), context.invocationId, + input.resolveWorkspaceReferences, ), ); @@ -62,6 +64,7 @@ function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { export function createAIThreadWorkspaceTools(input: { getThreadContext: () => Promise; onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; }): ToolSet { return Object.fromEntries( workspaceToolDefinitions.map((definition) => [ @@ -70,6 +73,7 @@ export function createAIThreadWorkspaceTools(input: { definition, getThreadContext: input.getThreadContext, onWorkspaceReferences: input.onWorkspaceReferences, + resolveWorkspaceReferences: input.resolveWorkspaceReferences, }), ]), ) as ToolSet; @@ -89,9 +93,11 @@ function createThreadWorkspaceAccessContext( thread: AIThreadContext, scopes: readonly WorkspaceAccessScope[], operationId: string, + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise, ): WorkspaceAccessContext { return createWorkspaceAccessContext({ operationId, + ...(resolveWorkspaceReferences ? { resolveWorkspaceReferences } : {}), scopes, userId: thread.userId, workspaceId: thread.workspaceId, diff --git a/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx b/src/features/workspaces/components/WorkspaceItemToolbarSlot.tsx index 319a48e6..73d88344 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,11 @@ export function WorkspaceItemToolbarSlot({
{registration.kind === "document" ? ( - + ) : ( - {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..3022df76 --- /dev/null +++ b/src/features/workspaces/components/ai-chat/AiChatDocumentEditActions.tsx @@ -0,0 +1,104 @@ +import { FilePen } from "lucide-react"; + +import type { AiChatDocumentEditGroup } from "#/features/workspaces/components/ai-chat/ai-chat-document-edit-actions"; +import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context"; +import { useWorkspaceLocationActions } from "#/features/workspaces/locations/workspace-location-context"; +import { getWorkspacePathName } from "#/features/workspaces/kernel/workspace-kernel-paths"; + +/** + * Receipt for the documents the assistant changed in one turn: what it touched + * and how much it changed, read straight from the turn itself. + * + * Deliberately asks the server nothing. Whether those changes can still be + * reviewed or undone is a live question, and answering it up front would cost a + * request per row on every chat load just to render a label. Clicking finds out. + */ +export function AiChatDocumentEditActions({ + groups, +}: { + groups: readonly AiChatDocumentEditGroup[]; +}) { + // A deleted document has nothing left to open. + const { hasItem } = useWorkspaceLocationActions(); + const knownGroups = groups.filter((group) => hasItem(group.itemId)); + + if (knownGroups.length === 0) { + return null; + } + + return ( +
+
+
+
+ {knownGroups.map((group) => ( + + ))} +
+
+ ); +} + +function DocumentEditRow({ group }: { group: AiChatDocumentEditGroup }) { + const { showReview } = useDocumentEditReview(); + + // The row is a destination: clicking takes you to the changes, opening the + // document or switching tabs as needed. If they are no longer reviewable the + // document still opens and the overlay says why. + return ( + + ); +} + +function DocumentPathLabel({ path }: { path: string }) { + const separatorIndex = path.lastIndexOf("/"); + const folder = separatorIndex > 0 ? path.slice(0, separatorIndex + 1) : ""; + + return ( + <> + {folder ? {folder} : null} + {getWorkspacePathName(path)} + + ); +} + +function ChangeSummary({ lineChanges }: { lineChanges: { added: number; removed: number } }) { + if (!lineChanges.added && !lineChanges.removed) { + return null; + } + + // Colour carries the meaning at a glance, muted so a removal reads as a fact + // rather than an alarm. + return ( +
+ Lines: + {lineChanges.added > 0 ? +{lineChanges.added} : null} + {lineChanges.added > 0 && lineChanges.removed > 0 ? ( + · + ) : null} + {lineChanges.removed > 0 ? ( + −{lineChanges.removed} + ) : null} +
+ ); +} diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx index 69fd3d2f..3e360dfa 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, @@ -57,6 +59,10 @@ export default function AiChatMessageRow({ ? [] : displayableParts.filter((part) => !isAttachmentPart(part)); const copyableText = !isStreaming ? getCopyableMessageText(message) : ""; + const documentEditGroups = + isAssistant && display?.kind === "content" && !isStreaming + ? getAiChatDocumentEditGroups(display.parts) + : []; return ( @@ -85,6 +91,11 @@ export default function AiChatMessageRow({ ) : null} + {/* Outside the bubble on purpose: the bubble is w-fit and clips its + overflow, so a receipt inside it is only as wide as the reply. */} + {documentEditGroups.length > 0 ? ( + + ) : null} {isAssistant && display?.kind === "content" && display.parts.length > 0 && !isStreaming ? (
- + {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..8d77b00b --- /dev/null +++ b/src/features/workspaces/components/ai-chat/ai-chat-document-edit-actions.ts @@ -0,0 +1,100 @@ +import { isToolUIPart } from "ai"; + +import { asRecord } from "#/features/workspaces/ai/ai-inspector-view-parsing"; + +import { + getToolPartName, + isAiChatToolGroupPart, + type AiChatRenderablePart, +} from "#/features/workspaces/components/ai-chat/ai-chat-display-state"; + +export interface AiChatDocumentEditGroup { + itemId: string; + /** Summed from the edits themselves: a record of what this turn did, not a + * running estimate of how much of it survives. */ + lineChanges: { added: number; removed: number }; + 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, + lineChanges: readLineChanges(output.lineChanges), + path, + receiptId: part.toolCallId, + }); + } + } + + return [...groupsByItemId.values()]; +} + +function addToGroup( + groupsByItemId: Map, + seenReceiptIds: Set, + action: { + itemId: string; + lineChanges?: { added: number; removed: number }; + path: string; + receiptId: string; + }, +) { + if (seenReceiptIds.has(action.receiptId)) { + return; + } + seenReceiptIds.add(action.receiptId); + + const lineChanges = action.lineChanges ?? { added: 0, removed: 0 }; + const group = groupsByItemId.get(action.itemId); + if (group) { + group.lineChanges = { + added: group.lineChanges.added + lineChanges.added, + removed: group.lineChanges.removed + lineChanges.removed, + }; + group.path = action.path; + group.receiptIds.push(action.receiptId); + } else { + groupsByItemId.set(action.itemId, { + itemId: action.itemId, + lineChanges, + path: action.path, + receiptIds: [action.receiptId], + }); + } +} + +function readLineChanges(value: unknown) { + const changes = asRecord(value); + return typeof changes.added === "number" && typeof changes.removed === "number" + ? { added: changes.added, removed: changes.removed } + : undefined; +} diff --git a/src/features/workspaces/components/document-editor/DocumentEditUndoButton.tsx b/src/features/workspaces/components/document-editor/DocumentEditUndoButton.tsx new file mode 100644 index 00000000..0ac49985 --- /dev/null +++ b/src/features/workspaces/components/document-editor/DocumentEditUndoButton.tsx @@ -0,0 +1,104 @@ +import { useMutation } from "@tanstack/react-query"; +import { LoaderCircle } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "#/components/ui/alert-dialog"; +import { Button } from "#/components/ui/button"; +import type { DocumentEditReceiptUnavailableStatus } from "#/features/workspaces/documents/document-edit-receipt"; +import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context"; +import { undoDocumentEditReceiptFn } from "#/features/workspaces/documents/document-edit-review-functions"; + +/** + * Undo, with a confirmation step because it throws away work the reader may + * have asked for and only skimmed. + */ +export function DocumentEditUndoButton({ + className, + itemId, + receiptIds, + workspaceId, +}: { + className?: string; + itemId: string; + receiptIds: string[]; + workspaceId: string; +}) { + const [isConfirming, setIsConfirming] = useState(false); + const { hideReview } = useDocumentEditReview(); + const undoMutation = useMutation({ + mutationFn: () => undoDocumentEditReceiptFn({ data: { itemId, receiptIds, workspaceId } }), + onSuccess: (result) => { + // Every outcome ends the review: it either just undid the changes, or + // told us they no longer describe the document. Leaving the marks up + // after that would be showing a diff we have just been told is wrong. + hideReview(); + + if (result.status === "undone") { + toast.success("Changes undone."); + } else { + toast.error(undoUnavailableMessages[result.status]); + } + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Could not undo these changes."); + }, + }); + + return ( + <> + + + + + Undo these changes? + + The document goes back to how it was before the assistant edited it. + + + + Keep changes + { + setIsConfirming(false); + undoMutation.mutate(); + }} + > + Undo + + + + + + ); +} + +const undoUnavailableMessages: Record = { + 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/DocumentEditorSurface.tsx b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx index dfb55d6b..017fe3db 100644 --- a/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx +++ b/src/features/workspaces/components/document-editor/DocumentEditorSurface.tsx @@ -10,6 +10,7 @@ import { DocumentWordCount } from "#/features/workspaces/components/document-edi 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 +19,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"; @@ -98,7 +100,17 @@ function DocumentEditorInstance({ }, }); - useDocumentEditorToolbar(viewInstanceId, capabilities.canMutateContent ? editor : null); + useDocumentEditorToolbar({ + canEdit: capabilities.canMutateContent, + editor: capabilities.canMutateContent ? editor : null, + itemId: item.id, + slotId: viewInstanceId, + }); + useDocumentEditReviewOverlay({ + canEdit: capabilities.canMutateContent, + editor, + itemId: item.id, + }); return (
@@ -123,6 +135,7 @@ function getDocumentEditorExtensions(collaborationSession: DocumentCollaboration return [ ...baseExtensions, + DocumentEditReviewExtension, Collaboration.configure({ document: collaborationSession.ydoc, field: tiptapDocumentYjsField, diff --git a/src/features/workspaces/components/document-editor/DocumentToolbar.tsx b/src/features/workspaces/components/document-editor/DocumentToolbar.tsx index 75133009..de1f5eb1 100644 --- a/src/features/workspaces/components/document-editor/DocumentToolbar.tsx +++ b/src/features/workspaces/components/document-editor/DocumentToolbar.tsx @@ -2,6 +2,9 @@ import type { Editor } from "@tiptap/react"; import { Check, Download, EllipsisVertical, FileText, Redo2, Undo2 } from "lucide-react"; import type { ReactNode } from "react"; +import { Button } from "#/components/ui/button"; +import { DocumentEditUndoButton } from "#/features/workspaces/components/document-editor/DocumentEditUndoButton"; +import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context"; import { DropdownMenu, DropdownMenuContent, @@ -20,10 +23,10 @@ import { import { type DocumentToolbarAction, documentBlockActions, - documentFontSizeActions, + documentTextStyleActions, documentInlineActions, documentTextAlignActions, - getFontSizeIcon, + getTextStyleIcon, getInlineMarkIcon, getStructureBlockIcon, getTextAlignIcon, @@ -33,9 +36,45 @@ import { WorkspaceResponsiveToolbar, WorkspaceToolbarIconButton, } from "#/features/workspaces/components/WorkspaceToolbar"; +import { workspaceToolbarTextButtonSizeClass } from "#/features/workspaces/components/workspace-toolbar-styles"; -export function DocumentToolbar({ editor }: { editor: Editor | null }) { +export function DocumentToolbar({ + canEdit, + editor, + itemId, +}: { + canEdit: boolean; + editor: Editor | null; + itemId: string; +}) { const editorState = useDocumentEditorUiState(editor); + const { activeReview, hideReview } = useDocumentEditReview(); + + // Reviewing borrows the toolbar rather than floating over the page: the + // formatting controls are unusable mid-review anyway, so the space is free. + if (activeReview?.itemId === itemId) { + 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; + receiptIds: string[]; +}) { + const { workspaceId } = useDocumentEditReview(); + + // Wider than the toolbar's icon-button gap: these two carry a border and a + // fill, so flush spacing would read as one control. + return ( +
+ + Reviewing changes: + + {canUndo ? ( + + ) : null} + +
+ ); +} + function DocumentMobileMenuContent({ editor, editorState, @@ -76,7 +155,7 @@ function DocumentMobileMenuContent({ return ( <> {getFontSizeIcon(editorState.block.size)} + {getTextStyleIcon(editorState.block.style)} )} Text style - {documentFontSizeActions.map((action) => ( + {documentTextStyleActions.map((action) => ( void; } -export const documentFontSizeActions: DocumentToolbarAction[] = [ +export const documentTextStyleActions: DocumentToolbarAction[] = [ { - id: "font-size-16", + id: "text-style-paragraph", icon: , - label: "Paragraph", - active: (editorState) => isFontSize(editorState, "16"), + label: "Text", + active: (editorState) => isTextStyle(editorState, "paragraph"), disabled: isCodeBlock, run: (editor) => editor.chain().focus().setParagraph().run(), }, { - id: "font-size-32", + id: "text-style-heading1", icon: , label: "Heading 1", - active: (editorState) => isFontSize(editorState, "32"), + active: (editorState) => isTextStyle(editorState, "heading1"), disabled: isCodeBlock, run: (editor) => editor.chain().focus().setHeading({ level: 1 }).run(), }, { - id: "font-size-24", + id: "text-style-heading2", icon: , label: "Heading 2", - active: (editorState) => isFontSize(editorState, "24"), + active: (editorState) => isTextStyle(editorState, "heading2"), disabled: isCodeBlock, run: (editor) => editor.chain().focus().setHeading({ level: 2 }).run(), }, { - id: "font-size-18", + id: "text-style-heading3", icon: , label: "Heading 3", - active: (editorState) => isFontSize(editorState, "18"), + active: (editorState) => isTextStyle(editorState, "heading3"), disabled: isCodeBlock, run: (editor) => editor.chain().focus().setHeading({ level: 3 }).run(), }, + { + id: "text-style-heading4", + icon: , + label: "Heading 4", + active: (editorState) => isTextStyle(editorState, "heading4"), + disabled: isCodeBlock, + run: (editor) => editor.chain().focus().setHeading({ level: 4 }).run(), + }, ]; export const documentTextAlignActions: DocumentToolbarAction[] = [ @@ -254,15 +263,17 @@ export function getStructureBlockIcon(type: DocumentStructureBlock) { } } -export function getFontSizeIcon(size: DocumentFontSize) { - switch (size) { - case "16": +export function getTextStyleIcon(style: DocumentTextStyle) { + switch (style) { + case "paragraph": return ; - case "18": + case "heading4": + return ; + case "heading3": return ; - case "24": + case "heading2": return ; - case "32": + case "heading1": return ; } } @@ -301,8 +312,8 @@ export function getInlineMarkIcon(mark: DocumentInlineMark) { } } -function isFontSize(editorState: DocumentEditorUiState, size: DocumentFontSize) { - return editorState.block.kind === "fontSize" && editorState.block.size === size; +function isTextStyle(editorState: DocumentEditorUiState, style: DocumentTextStyle) { + return editorState.block.kind === "textStyle" && editorState.block.style === style; } function isStructureBlock(editorState: DocumentEditorUiState, type: DocumentStructureBlock) { diff --git a/src/features/workspaces/components/workspace-toolbar-styles.ts b/src/features/workspaces/components/workspace-toolbar-styles.ts index f645f5ad..10368e7e 100644 --- a/src/features/workspaces/components/workspace-toolbar-styles.ts +++ b/src/features/workspaces/components/workspace-toolbar-styles.ts @@ -2,8 +2,12 @@ const workspaceToolbarButtonSizeClass = "size-10 sm:size-8.5"; const workspaceToolbarIconButtonClass = `${workspaceToolbarButtonSizeClass} justify-center px-0 text-muted-foreground hover:text-foreground aria-expanded:text-foreground [&_svg:not([class*='size-'])]:size-4`; -const workspaceToolbarTextButtonClass = - "h-10 gap-1.5 px-3 text-sm text-muted-foreground hover:text-foreground sm:h-8.5 sm:px-2.5 [&_svg:not([class*='size-'])]:size-4"; +// Geometry only, so a toolbar button that is not ghost-styled can still match +// the row exactly without inheriting its muted colours. +const workspaceToolbarTextButtonSizeClass = + "h-10 gap-1.5 px-3 text-sm sm:h-8.5 sm:px-2.5 [&_svg:not([class*='size-'])]:size-4"; + +const workspaceToolbarTextButtonClass = `${workspaceToolbarTextButtonSizeClass} text-muted-foreground hover:text-foreground`; const workspaceToolbarGroupClassName = "flex items-center gap-1 sm:gap-0.5"; @@ -16,4 +20,5 @@ export { workspaceToolbarIconButtonClass, workspaceToolbarScrollGroupClassName, workspaceToolbarTextButtonClass, + workspaceToolbarTextButtonSizeClass, }; 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..17c1eaf0 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(300_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..4d172c8c 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, @@ -24,15 +24,13 @@ import { const maxWorkspaceContentBatchBytes = 2 * 1024 * 1024 + 64 * 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 +47,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 +71,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 +95,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 +104,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 +155,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 +168,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 +179,7 @@ async function readDocument(input: { offset: chunk.nextOffset, path: input.path, revision: chunk.revision, - version: 2, + version: 3, }), }), path: input.path, @@ -302,8 +307,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 +324,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..7202094a --- /dev/null +++ b/src/features/workspaces/documents/document-ai-edits.ts @@ -0,0 +1,312 @@ +import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { z } from "zod"; + +import { + createDocumentAiTargetRef, + DocumentAiHtmlError, + ensureProseMirrorDocumentAiRefs, + parseDocumentAiHtml, + parseDocumentAiTargetRef, + readTiptapNodeAiRef, + withTiptapNodeAiRef, +} from "#/features/workspaces/documents/document-ai-html"; +import type { DocumentEditLineChanges } from "#/features/workspaces/documents/document-edit-receipt"; +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.enum(["insert_after", "insert_before", "replace"]), + 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; + /** `detail` explains a rejection in the model's own terms, so a retry can fix + * the markup instead of repeating it. */ + failures: { code: DocumentAiEditFailureCode; detail?: string; 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, + ...(result.detail ? { detail: result.detail } : {}), + 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", + }; +} + +/** + * Count the lines an AI edit added and removed. Lines are compared as a bag of + * contents rather than by position, so moving a paragraph counts as nothing + * while rewriting one counts as a line out and a line in. + */ +export function summarizeDocumentAiLineChanges( + before: TiptapDocumentJson, + after: TiptapDocumentJson, +): DocumentEditLineChanges { + const beforeLines = countDocumentLines(before); + const afterLines = countDocumentLines(after); + let added = 0; + let removed = 0; + + for (const [line, count] of afterLines) { + added += Math.max(0, count - (beforeLines.get(line) ?? 0)); + } + + for (const [line, count] of beforeLines) { + removed += Math.max(0, count - (afterLines.get(line) ?? 0)); + } + + return { added, removed }; +} + +function countDocumentLines(document: TiptapDocumentJson) { + const counts = new Map(); + const countLine = (line: string) => counts.set(line, (counts.get(line) ?? 0) + 1); + + getTiptapDocumentSchema() + .nodeFromJSON(document) + .descendants((node) => { + if (node.isTextblock) { + const text = node.textContent.trim(); + if (text) { + countLine(`${node.type.name}:${text}`); + } + return false; + } + // A rule or a formula holds no text but still occupies a line. + if (node.isAtom) { + countLine(JSON.stringify(node.toJSON())); + return false; + } + + return true; + }); + + return counts; +} + +function applyDocumentAiEdit( + document: ProseMirrorNode, + edit: DocumentAiEdit, + stableRef?: string, +): + | { code: DocumentAiEditFailureCode; detail?: string; status: "failed" } + | { document: ProseMirrorNode; status: "applied" } { + if (edit.op === "replace_all") { + const parsed = parseEditHtml(edit.html); + if (!parsed.children) { + return { code: "invalid_html", detail: parsed.detail, status: "failed" }; + } + + const next = createDocument(parsed.children); + 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.children) { + return { code: "invalid_html", detail: parsed.detail, status: "failed" }; + } + const targetRef = readTiptapNodeAiRef(document.child(targetIndex)); + const firstNode = parsed.children[0]; + // A replacement inherits the ref of the block it stands in for, so a model + // holding that ref can keep editing it. + const inserted = + edit.op === "replace" && targetRef && firstNode + ? [withTiptapNodeAiRef(firstNode, targetRef), ...parsed.children.slice(1)] + : parsed.children; + + 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 { + children: getDocumentChildren( + getTiptapDocumentSchema().nodeFromJSON(parseDocumentAiHtml(html)), + ), + }; + } catch (error) { + if (error instanceof DocumentAiHtmlError) { + return { detail: error.message }; + } + 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 document = ensureProseMirrorDocumentAiRefs( + schema.topNodeType.create(null, Fragment.fromArray(editorChildren)), + ).document; + document.check(); + return document; +} + +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..f6734e02 --- /dev/null +++ b/src/features/workspaces/documents/document-ai-html.ts @@ -0,0 +1,278 @@ +import { DOMParser, DOMSerializer, Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { parseHTML } from "linkedom"; +import { nanoid } from "nanoid"; + +import type { WorkspaceLocation } from "#/features/workspaces/locations/workspace-location"; +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 TEXT_NODE = 3; +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", + "citation", + "code", + "col", + "colgroup", + "del", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "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; + + // A citation the operation could not resolve to a real item cannot navigate + // anywhere, so it becomes its own label rather than failing the write. + for (const element of htmlDocument.body.querySelectorAll("citation:not([data-item-id])")) { + element.replaceWith(htmlDocument.createTextNode(element.textContent ?? "")); + } + + 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.`); + } + + // 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}`; +} + +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 : createDocumentAiRef(); + 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 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) { + // Text sitting at the top level means this is not HTML at all — Markdown, + // most often, which a model reaches for by habit. ProseMirror would take it + // without complaint and flatten the whole thing into one paragraph of + // literal source, so refuse it while the edit can still be reported failed. + for (const node of root.childNodes) { + if (node.nodeType === TEXT_NODE && node.textContent?.trim()) { + throw new DocumentAiHtmlError( + "Document content must be HTML elements. Plain text and Markdown are not accepted.", + ); + } + } + + 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; +} + +/** Short refs the assistant cited, for the caller to resolve to locations. */ +export function readDocumentCitationRefs(html: string) { + const htmlDocument = createHtmlDocument(); + htmlDocument.body.innerHTML = html; + + return [ + ...new Set( + [...htmlDocument.body.querySelectorAll("citation[ref]")].flatMap( + (element) => element.getAttribute("ref") ?? [], + ), + ), + ]; +} + +/** + * Rewrite cited refs to the locations they stand for. A ref belongs to one chat + * turn; the location outlives it, so that is what the document keeps. + */ +export function applyDocumentCitationLocations( + html: string, + locationsByRef: Map, +) { + const htmlDocument = createHtmlDocument(); + htmlDocument.body.innerHTML = html; + + for (const element of htmlDocument.body.querySelectorAll("citation[ref]")) { + const location = locationsByRef.get(element.getAttribute("ref") ?? ""); + element.removeAttribute("ref"); + + if (!location) { + continue; + } + element.setAttribute("data-item-id", location.itemId); + if (location.kind === "pdf-page") { + element.setAttribute("data-page", String(location.pageNumber)); + } + } + + return htmlDocument.body.innerHTML; +} + +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-citation-node.tsx b/src/features/workspaces/documents/document-citation-node.tsx new file mode 100644 index 00000000..baeacbef --- /dev/null +++ b/src/features/workspaces/documents/document-citation-node.tsx @@ -0,0 +1,34 @@ +import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; + +import { WorkspaceCitation } from "#/features/workspaces/components/ai-chat/WorkspaceCitation"; +import { Citation } from "#/features/workspaces/documents/tiptap-schema"; + +/** + * The editor's citation: the same chip a chat reply shows, over the same + * location. Names are read from the workspace as it stands, so renaming a + * source renames every citation of it, and a deleted one says so. + */ +export const DocumentCitation = Citation.extend({ + addNodeView() { + return ReactNodeViewRenderer(DocumentCitationView, { as: "span" }); + }, +}); + +function DocumentCitationView({ node }: { node: { attrs: Record } }) { + const itemId = typeof node.attrs.itemId === "string" ? node.attrs.itemId : null; + const pageNumber = typeof node.attrs.pageNumber === "number" ? node.attrs.pageNumber : null; + + return ( + + {itemId ? ( + + ) : null} + + ); +} 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..4ffa3581 --- /dev/null +++ b/src/features/workspaces/documents/document-edit-receipt.ts @@ -0,0 +1,50 @@ +import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; + +export type DocumentEditReceiptStatus = + | "content_changed" + | "not_found" + | "not_latest" + | "ready" + | "reverted" + | "review_unavailable"; + +/** Every status a receipt can report once it is known not to be reviewable. */ +export type DocumentEditReceiptUnavailableStatus = Exclude; + +/** + * Line tally for an AI edit, counted once against the two versions of the + * document that edit sat between. A line is one text block — a paragraph, a + * heading, a single list item — so a rewritten paragraph reads as one line out + * and one line in. + */ +export interface DocumentEditLineChanges { + added: number; + removed: number; +} + +export type DocumentEditReceiptReviewResult = + | { + beforeDocument: TiptapDocumentJson; + status: "ready"; + } + | { + status: DocumentEditReceiptUnavailableStatus; + }; + +export type DocumentEditReceiptReviewRpcResult = + | { + beforeContent: string; + status: "ready"; + } + | { + status: DocumentEditReceiptUnavailableStatus; + }; + +/** + * `undone` is the only outcome that changed the document. Everything else + * names why it did not — including `reverted`, which means an earlier undo + * already did the work this one was asked to do. + */ +export interface DocumentEditReceiptUndoResult { + status: "undone" | DocumentEditReceiptUnavailableStatus; +} 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..f24c1cb2 --- /dev/null +++ b/src/features/workspaces/documents/document-edit-review-context.tsx @@ -0,0 +1,96 @@ +import { createContext, type ReactNode, use, useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import type { DocumentEditReceiptUnavailableStatus } from "#/features/workspaces/documents/document-edit-receipt"; +import { getDocumentEditReceiptReviewFn } from "#/features/workspaces/documents/document-edit-review-functions"; +import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; +import { useWorkspaceLocationActions } from "#/features/workspaces/locations/workspace-location-context"; + +/** + * Review belongs to the document, not to the view showing it. Tying it to a + * view instance meant the review had to be opened after that view existed, and + * every wherever-it-is-now question became a lifecycle problem. + * + * The document it was computed against travels with it. Whether an edit can + * still be reviewed is a judgement about a moment, so it is asked once, when + * the reader asks to see it, and never revisited behind their back. + */ +export interface ActiveDocumentEditReview { + beforeDocument: TiptapDocumentJson; + itemId: string; + receiptIds: string[]; +} + +interface DocumentEditReviewContextValue { + activeReview: ActiveDocumentEditReview | null; + hideReview: () => void; + showReview: (input: { itemId: string; receiptIds: string[] }) => Promise; + 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( + async (input: { itemId: string; receiptIds: string[] }) => { + // reveal opens the document, or focuses the tab already holding it, and + // only fails when the item is gone. + if (!reveal({ itemId: input.itemId, kind: "item", version: 1 })) { + toast.error("This document no longer exists."); + return; + } + + const review = await getDocumentEditReceiptReviewFn({ + data: { itemId: input.itemId, receiptIds: input.receiptIds, workspaceId }, + }).catch(() => null); + + if (!review) { + toast.error("Could not load these changes."); + return; + } + if (review.status !== "ready") { + toast.error(unavailableReviewMessages[review.status]); + return; + } + + setActiveReview({ + beforeDocument: review.beforeDocument, + itemId: input.itemId, + receiptIds: input.receiptIds, + }); + }, + [reveal, workspaceId], + ); + 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; +} + +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.", +}; 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..fe1ec45c --- /dev/null +++ b/src/features/workspaces/documents/document-edit-review-extension.ts @@ -0,0 +1,204 @@ +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"; + +/** + * Review state holds the document as it was before the edit, not the marks it + * produced. Marks are derived from it against whatever is on screen right now, + * so they are correct no matter when the document arrives — a reopened tab + * syncing its content, or a collaborator typing mid-review. + */ +interface DocumentEditReviewState { + beforeDocument: TiptapDocumentJson; + decorations: DecorationSet; +} + +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: () => null, + apply(transaction, review, _oldState, newState) { + const meta = transaction.getMeta(documentEditReviewPluginKey) as + | DocumentEditReviewMeta + | undefined; + const beforeDocument = + meta?.type === "show" ? meta.beforeDocument : review?.beforeDocument; + + if (meta?.type === "hide" || !beforeDocument) { + return null; + } + if (meta?.type !== "show" && !transaction.docChanged) { + return review; + } + + return { + beforeDocument, + decorations: createDocumentEditReviewDecorations(beforeDocument, newState.doc), + }; + }, + }, + props: { + decorations(state) { + return documentEditReviewPluginKey.getState(state)?.decorations ?? 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) { + // Inline decorations already span block boundaries, marking the text + // inside each one. Only blocks that carry no text of their own — a rule, + // a rendered formula — need a decoration of their own to be visible. + decorations.push( + Decoration.inline(change.fromB, change.toB, { + class: "workspace-document-ai-inserted", + }), + ); + addChangedAtomDecorations( + decorations, + decoratedBlocks, + afterDocument, + change.fromB, + change.toB, + ); + } + + if (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 addChangedAtomDecorations( + decorations: Decoration[], + decoratedBlocks: Set, + document: ProseMirrorNode, + from: number, + to: number, +) { + document.forEach((node, offset) => { + const end = offset + node.nodeSize; + if (end <= from || offset >= to || !node.isAtom) { + 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..f4d7d9ae --- /dev/null +++ b/src/features/workspaces/documents/document-edit-review-functions.ts @@ -0,0 +1,68 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + +import { getDocumentSessionFromEnv } from "#/features/workspaces/document-session-access"; +import type { + DocumentEditReceiptReviewRpcResult, + DocumentEditReceiptReviewResult, + 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 getDocumentEditReceiptReviewFn = createServerFn({ method: "GET" }) + .validator(documentEditReceiptInputSchema) + .handler(async ({ data }): Promise => { + await withWorkspaceDb(({ db, userId }) => + assertCanReadWorkspace(db, { userId, workspaceId: data.workspaceId }), + ); + const session = await getDocumentEditSession(data); + const result = await session.getDocumentEditReceiptReview({ + receiptIds: data.receiptIds, + }); + + return result.status === "ready" + ? { + beforeDocument: parseTiptapDocumentJson(result.beforeContent), + status: result.status, + } + : result; + }); + +export const undoDocumentEditReceiptFn = createServerFn({ method: "POST" }) + .validator(documentEditReceiptInputSchema) + .handler(async ({ data }): Promise => { + await withWorkspaceDb(({ db, userId }) => + assertCanMutateWorkspace(db, { userId, workspaceId: data.workspaceId }), + ); + const session = await getDocumentEditSession(data); + return await session.undoDocumentEditReceipt({ receiptIds: data.receiptIds }); + }); + +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; + undoDocumentEditReceipt(input: { receiptIds: string[] }): Promise; +} 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..88ae52c0 100644 --- a/src/features/workspaces/documents/document-session.ts +++ b/src/features/workspaces/documents/document-session.ts @@ -1,32 +1,35 @@ 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, + summarizeDocumentAiLineChanges, + 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 { + DocumentEditLineChanges, + DocumentEditReceiptReviewRpcResult, + DocumentEditReceiptStatus, + DocumentEditReceiptUndoResult, +} from "#/features/workspaces/documents/document-edit-receipt"; import { coerceTiptapDocumentJson, parseTiptapDocumentJson, @@ -41,37 +44,63 @@ 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 documentEditReceiptIndexKey = "document-session:ai-edit-receipt:index"; +const documentEditReceiptKeyPrefix = "document-session:ai-edit-receipt:"; +const maximumRetainedDocumentEditReceipts = 8; +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; + /** Counted here, against the two documents this edit sat between, so the + * receipt states what the edit did rather than what is left of it later. */ + lineChanges?: DocumentEditLineChanges; failures: { - code: DocumentMarkdownEditFailureCode | "invalid_document_projection"; + code: DocumentAiEditFailureCode | "content_changed" | "operation_id_conflict"; + detail?: string; 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 = { @@ -147,13 +176,28 @@ 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, existingReceipt] = await Promise.all([ + sha256Base64UrlText(JSON.stringify(input.edits)), + 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 +205,147 @@ 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, + lineChanges: summarizeDocumentAiLineChanges(currentDocument, editResult.document), + 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", + }; + + // Only the newest edit can still be undone, so older receipts are dead + // weight — and each one holds a whole copy of the document. Keep enough for + // a turn that edited this document several times, and drop the rest. + const recentReceiptIds = [ + ...((await this.ctx.storage.get(documentEditReceiptIndexKey)) ?? []), + receipt.id, + ]; + const expiredReceiptIds = recentReceiptIds.splice( + 0, + Math.max(0, recentReceiptIds.length - maximumRetainedDocumentEditReceipts), + ); - await this.persistYDoc(); + this.reconcileCurrentDocument(editResult.document); + const persistedUpdate = Y.encodeStateAsUpdate(this.document); + await this.ctx.storage.transaction(async (transaction) => { + await Promise.all([ + transaction.put(persistedYDocUpdateKey, persistedUpdate), + transaction.put(getDocumentEditReceiptKey(receipt.id), receipt), + transaction.put(latestDocumentEditReceiptKey, receipt.id), + transaction.put(documentEditReceiptIndexKey, recentReceiptIds), + ...(expiredReceiptIds.length > 0 + ? [transaction.delete(expiredReceiptIds.map(getDocumentEditReceiptKey))] + : []), + ]); + }); this.assertActive(); - await this.checkpointToKernel(); + await this.checkpointToKernel(input.operationId); this.assertActive(); + return result; + } + + 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, + 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 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); + } else { + await transaction.delete(latestDocumentEditReceiptKey); + } + }); + + await this.checkpointToKernel(`undo:${group.lastReceiptId}`); + + return { status: "undone" }; + } + + 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 +354,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() { @@ -266,6 +452,21 @@ export class DocumentSession extends YServer { await this.ctx.storage.put(persistedYDocUpdateKey, Y.encodeStateAsUpdate(this.document)); } + private async getReferencedDocumentSnapshot() { + const refs = ensureProseMirrorDocumentAiRefs(this.getCurrentProseMirrorDocument()); + if (refs.changed) { + this.reconcileCurrentDocument(coerceTiptapDocumentJson(refs.document.toJSON())); + await this.persistYDoc(); + } + + return { + // 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)), + }; + } + private assertActive() { if (this.deleted) { throw new Error("Document session has been deleted."); @@ -277,10 +478,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 +490,25 @@ function getDocumentSessionRoomNameParts(roomName: string): DocumentSessionRoute itemId: roomName.slice(separatorIndex + 1), }; } + +function getDocumentEditReceiptKey(receiptId: string) { + return `${documentEditReceiptKeyPrefix}${receiptId}`; +} + +function fitsDocumentEditReceiptSnapshot(documentText: string) { + return ( + 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/tiptap-extensions.ts b/src/features/workspaces/documents/tiptap-extensions.ts index f291b562..ee7ed9f6 100644 --- a/src/features/workspaces/documents/tiptap-extensions.ts +++ b/src/features/workspaces/documents/tiptap-extensions.ts @@ -3,6 +3,7 @@ import Placeholder from "@tiptap/extension-placeholder"; import "katex/dist/katex.min.css"; import { CodeBlockShiki } from "#/features/workspaces/documents/code-block-shiki"; +import { DocumentCitation } from "#/features/workspaces/documents/document-citation-node"; import { getTiptapDocumentSchemaExtensions, tiptapDocumentYjsField, @@ -13,7 +14,8 @@ export { tiptapDocumentYjsField }; export function getTiptapDocumentBaseExtensions() { return [ ...getTiptapDocumentSchemaExtensions({ - // Extends the same codeBlock node spec used by tiptapDocumentKernelCodeBlock. + // Both extend the node spec the server uses, adding only how it draws. + citation: DocumentCitation, codeBlock: CodeBlockShiki, }), Placeholder.configure({ diff --git a/src/features/workspaces/documents/tiptap-schema.ts b/src/features/workspaces/documents/tiptap-schema.ts index 2c9e25ca..523e695d 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, Node } from "@tiptap/core"; import CodeBlock from "@tiptap/extension-code-block"; import Highlight from "@tiptap/extension-highlight"; import HorizontalRule from "@tiptap/extension-horizontal-rule"; @@ -12,6 +12,80 @@ import StarterKit from "@tiptap/starter-kit"; export const tiptapDocumentYjsField = "default"; +/** + * A source reference inside a document. Holds the workspace item id rather than + * the ref the assistant cited with: refs belong to one chat turn, and documents + * outlive them. What the source is called is looked up when it is drawn, the + * same way a chat citation does it, so a renamed source stays right. + */ +export const Citation = Node.create({ + name: "citation", + group: "inline", + inline: true, + atom: true, + + addAttributes() { + return { + itemId: { default: null, parseHTML: (el) => el.getAttribute("data-item-id") }, + pageNumber: { + default: null, + parseHTML: (el) => { + const page = Number(el.getAttribute("data-page")); + return Number.isInteger(page) && page > 0 ? page : null; + }, + }, + }; + }, + + parseHTML() { + return [{ tag: "citation[data-item-id]" }]; + }, + + renderHTML({ node }) { + return [ + "citation", + { + "data-item-id": node.attrs.itemId, + ...(node.attrs.pageNumber ? { "data-page": String(node.attrs.pageNumber) } : {}), + }, + ]; + }, +}); +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`, * which extends the same `codeBlock` node spec, so JSON snapshots stay @@ -20,14 +94,18 @@ export const tiptapDocumentYjsField = "default"; export const tiptapDocumentKernelCodeBlock = CodeBlock; export function getTiptapDocumentSchemaExtensions({ + citation = Citation, codeBlock = tiptapDocumentKernelCodeBlock, }: { + citation?: AnyExtension; codeBlock?: AnyExtension; } = {}) { return [ + DocumentAiRef, + citation, StarterKit.configure({ heading: { - levels: [1, 2, 3], + levels: [1, 2, 3, 4], }, codeBlock: false, horizontalRule: false, @@ -40,7 +118,7 @@ export function getTiptapDocumentSchemaExtensions({ UnderlineExtension, Highlight, Link.configure({ - openOnClick: false, + openOnClick: true, autolink: true, defaultProtocol: "https", }), 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..0ab7be0e --- /dev/null +++ b/src/features/workspaces/documents/use-document-edit-review-overlay.ts @@ -0,0 +1,39 @@ +import type { Editor } from "@tiptap/core"; +import { useEffect } from "react"; + +import { useDocumentEditReview } from "#/features/workspaces/documents/document-edit-review-context"; +import { + hideDocumentEditReview, + showDocumentEditReview, +} from "#/features/workspaces/documents/document-edit-review-extension"; + +export function useDocumentEditReviewOverlay({ + canEdit, + editor, + itemId, +}: { + canEdit: boolean; + editor: Editor | null; + itemId: string; +}) { + const { activeReview } = useDocumentEditReview(); + // Any mounted view of this document shows the marks, so a review opened + // before the document was on screen still applies once it mounts. + const target = activeReview?.itemId === itemId ? activeReview : null; + + useEffect(() => { + if (!editor || !target) { + return; + } + + // Reviewing is a reading mode: hold the document still until Done rather + // than deciding what a keystroke mid-review was supposed to mean. + showDocumentEditReview(editor, target.beforeDocument); + editor.setEditable(false); + + return () => { + hideDocumentEditReview(editor); + editor.setEditable(canEdit); + }; + }, [canEdit, editor, target]); +} diff --git a/src/features/workspaces/kernel/workspace-kernel-purge.worker.test.ts b/src/features/workspaces/kernel/workspace-kernel-purge.worker.test.ts new file mode 100644 index 00000000..19b4cec2 --- /dev/null +++ b/src/features/workspaces/kernel/workspace-kernel-purge.worker.test.ts @@ -0,0 +1,50 @@ +import { env } from "cloudflare:test"; +import { evictDurableObject, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import type { WorkspaceKernel } from "#/features/workspaces/kernel/workspace-kernel"; + +// The generated recursive Agent stub exceeds TypeScript's instantiation depth, +// so reach the binding the way workspace-kernel-access does. +function getKernelStub(workspaceId: string) { + const namespace = Reflect.get(env as object, "WorkspaceKernel") as DurableObjectNamespace; + + return namespace.get(namespace.idFromName(workspaceId)) as DurableObjectStub; +} + +async function seedAndPurge(workspaceId: string) { + const stub = getKernelStub(workspaceId); + + await runInDurableObject(stub, async (kernel: WorkspaceKernel) => { + await kernel.createItem({ id: crypto.randomUUID(), type: "folder", name: "Notes" }); + const purge = await kernel.purgeForDeletion(); + + expect(purge.failed).toBe(0); + }); + + return stub; +} + +describe("workspace kernel purge", () => { + it("refuses queries on the live instance instead of reading a dropped schema", async () => { + const stub = await seedAndPurge("purge-live-instance"); + + // The purge empties storage without evicting the instance, so this runs + // against the same object whose constructor already created the schema. + await runInDurableObject(stub, async (kernel: WorkspaceKernel) => { + await expect(kernel.getPage()).rejects.toThrow("Workspace deleted."); + }); + }); + + it("rebuilds an empty schema once the purged instance is evicted", async () => { + const stub = await seedAndPurge("purge-after-eviction"); + + await evictDurableObject(stub); + + // A later request reconstructs the object, so queries see an empty schema + // rather than failing on a missing table. + await runInDurableObject(stub, async (kernel: WorkspaceKernel) => { + await expect(kernel.getPage()).resolves.toMatchObject({ items: [] }); + }); + }); +}); diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index e383e09a..c61f0a7b 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -76,8 +76,19 @@ export { setWorkspaceKernelUserHeaders }; export class WorkspaceKernel extends Agent { private lastExtractionHealingRequestAt = 0; - private readonly kernelSql: WorkspaceKernelSql = (strings, ...values) => - this.sql(strings, ...values); + // A purge empties storage without evicting this instance, so kernel queries + // route through here rather than read a schema that no longer exists. The + // file store holds its own handle on `ctx.storage.sql` and is not covered: + // after a purge it fails on the missing table instead, which is the same + // outcome with a worse message on an object that is being deleted anyway. + private purged = false; + private readonly kernelSql: WorkspaceKernelSql = (strings, ...values) => { + if (this.purged) { + throw new Error("Workspace deleted."); + } + + return this.sql(strings, ...values); + }; private readonly workspace = new ShellWorkspace({ sql: this.ctx.storage.sql, r2: this.env.WORKSPACE_KERNEL_FILES, @@ -138,8 +149,12 @@ export class WorkspaceKernel extends Agent { constructor(ctx: DurableObjectState, env: Cloudflare.Env) { super(ctx, env); - initializeWorkspaceKernelStorage(this.kernelSql); - this.search.initialize(); + // Constructor writes commit with whichever invocation constructed the + // instance, so a canceled one rolls the schema back under a live object. + void ctx.blockConcurrencyWhile(async () => { + initializeWorkspaceKernelStorage(this.kernelSql); + this.search.initialize(); + }); } async onStart() { @@ -486,6 +501,7 @@ export class WorkspaceKernel extends Agent { connection.close(1008, "Workspace deleted"); } await this.ctx.storage.deleteAll(); + this.purged = true; } else { const attempt = input.attempt ?? 1; if (attempt < workspacePurgeMaximumAttempts) { diff --git a/src/features/workspaces/locations/workspace-location-context.tsx b/src/features/workspaces/locations/workspace-location-context.tsx index 01db9890..9eb7665b 100644 --- a/src/features/workspaces/locations/workspace-location-context.tsx +++ b/src/features/workspaces/locations/workspace-location-context.tsx @@ -22,6 +22,7 @@ type WorkspacePdfPageRevealRequest = { type WorkspaceLocationContextValue = { consumeRevealRequest: (request: WorkspacePdfPageRevealRequest) => void; getPresentation: (location: WorkspaceLocation) => WorkspaceLocationPresentation; + hasItem: (itemId: string) => boolean; reveal: (location: WorkspaceLocation) => boolean; revealRequest: WorkspacePdfPageRevealRequest | null; }; @@ -63,6 +64,9 @@ export function WorkspaceLocationProvider({ const { Icon, iconClassName } = getWorkspaceItemDisplay(item); return { Icon, iconClassName, label: itemName, locatorLabel }; }, + hasItem(itemId) { + return itemsById.has(itemId); + }, reveal(location) { const viewInstanceId = navigate(location); diff --git a/src/features/workspaces/model/workspace-ai-context-prompt.ts b/src/features/workspaces/model/workspace-ai-context-prompt.ts index 10806206..f6fc1d40 100644 --- a/src/features/workspaces/model/workspace-ai-context-prompt.ts +++ b/src/features/workspaces/model/workspace-ai-context-prompt.ts @@ -73,6 +73,12 @@ export function formatWorkspaceAiContextForPrompt(value: unknown) { } function formatWorkspaceAiContextOutline(outline: WorkspaceAiContextOutline) { + // Said plainly. Reporting "0 items complete" buries an empty workspace in a + // sentence whose grammar reads as a truncation notice. + if (outline.totalItems === 0) { + return ["- Workspace outline: this workspace is empty. It has no items yet."]; + } + const itemLines = limitWorkspaceAiContextOutlineLines( outline.items.map(formatWorkspaceAiContextOutlineItem), ); diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index 5f4937e8..9c87ea7d 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -6,7 +6,8 @@ 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 { resolveDocumentCitations } from "#/features/workspaces/operations/document-citations"; import { stringifyTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; import { createWorkspaceReferenceRecords, @@ -54,7 +55,6 @@ export interface CreatedWorkspaceItem { itemId: string; path: string; type: "document" | "folder"; - warnings?: string[]; } export interface CreateWorkspaceItemsOperationResult { @@ -118,7 +118,17 @@ export async function createWorkspaceItemsOperation( continue; } - const initialContent = getCreateWorkspaceItemInitialContent(itemInput); + const initialContent = getCreateWorkspaceItemInitialContent( + itemInput.type === "document" && itemInput.initialContent !== undefined + ? { + ...itemInput, + initialContent: await resolveDocumentCitations({ + context: accessContext, + html: itemInput.initialContent, + }), + } + : itemInput, + ); if (initialContent.status === "failed") { failed.push({ @@ -177,9 +187,6 @@ export async function createWorkspaceItemsOperation( itemId: id, path: createdPath, type: itemInput.type, - ...(initialContent.warnings && initialContent.warnings.length > 0 - ? { warnings: initialContent.warnings } - : {}), }); } @@ -282,7 +289,6 @@ function getCreateWorkspaceItemInitialContent(input: CreateWorkspaceItemOperatio | { content?: string; status: "ready"; - warnings?: string[]; } | { code: "invalid_initial_content"; @@ -293,12 +299,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/document-citations.ts b/src/features/workspaces/operations/document-citations.ts new file mode 100644 index 00000000..6b12fc4e --- /dev/null +++ b/src/features/workspaces/operations/document-citations.ts @@ -0,0 +1,31 @@ +import { + applyDocumentCitationLocations, + readDocumentCitationRefs, +} from "#/features/workspaces/documents/document-ai-html"; +import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; + +/** + * Turn the refs an assistant cited into the locations a document can keep. + * + * The assistant cites `wr_` refs, the same way it cites in a chat reply, but a + * ref only means something inside the turn that produced it. Resolving here + * lets the document store the item and page it points at; what that source is + * called is read from the workspace when the citation is drawn. + */ +export async function resolveDocumentCitations(input: { + context: WorkspaceAccessContext; + html: string; +}): Promise { + const refs = readDocumentCitationRefs(input.html); + + if (refs.length === 0 || !input.context.resolveWorkspaceReferences) { + return input.html; + } + + const records = await input.context.resolveWorkspaceReferences(refs); + + return applyDocumentCitationLocations( + input.html, + new Map(records.map((record) => [record.ref, record.location])), + ); +} diff --git a/src/features/workspaces/operations/edit-item.ts b/src/features/workspaces/operations/edit-item.ts index c0583e74..b3029eca 100644 --- a/src/features/workspaces/operations/edit-item.ts +++ b/src/features/workspaces/operations/edit-item.ts @@ -5,36 +5,41 @@ 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"; +import type { DocumentEditLineChanges } from "#/features/workspaces/documents/document-edit-receipt"; +import { resolveDocumentCitations } from "#/features/workspaces/operations/document-citations"; 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; } interface EditWorkspaceItemFailure { code: EditWorkspaceItemFailureCode; + detail?: string; index: number; } export interface EditWorkspaceItemOperationResult { applied: number; failed: EditWorkspaceItemFailure[]; + itemId?: string; + lineChanges?: DocumentEditLineChanges; path: string; - warnings: string[]; } export async function editWorkspaceItemOperation( @@ -59,7 +64,6 @@ export async function editWorkspaceItemOperation( if (resolution.status === "failed") { return { path: resolution.failure.path, - warnings: [], ...failedWorkspaceEditResult(resolution.failure.code, failureCount), }; } @@ -67,7 +71,6 @@ export async function editWorkspaceItemOperation( if (resolution.item.type !== "document") { return { path: resolution.path, - warnings: [], ...failedWorkspaceEditResult("unsupported_item_type", edits.length), }; } @@ -77,15 +80,29 @@ export async function editWorkspaceItemOperation( workspaceId: accessContext.workspaceId, }); - const result = await documentSession.applyMarkdownEdits({ - edits, + const result = await documentSession.applyEdits({ + edits: await Promise.all( + edits.map(async (edit) => + "html" in edit + ? { + ...edit, + html: await resolveDocumentCitations({ + context: accessContext, + html: edit.html, + }), + } + : edit, + ), + ), + operationId: accessContext.operationId, }); return { applied: result.applied, failed: result.failures, + itemId: resolution.item.id, + ...(result.lineChanges ? { lineChanges: result.lineChanges } : {}), path: resolution.path, - warnings: result.warnings, }; } diff --git a/src/features/workspaces/operations/workspace-access-context.ts b/src/features/workspaces/operations/workspace-access-context.ts index ad3970f3..2ccad3c6 100644 --- a/src/features/workspaces/operations/workspace-access-context.ts +++ b/src/features/workspaces/operations/workspace-access-context.ts @@ -1,3 +1,4 @@ +import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; import { assertAccessScope, createAccessActor, @@ -10,18 +11,27 @@ export type WorkspaceAccessScope = (typeof workspaceAccessScopes)[number]; export interface WorkspaceAccessContext extends ScopedAccessContext { operationId: string; + /** + * Resolves the short refs a read handed the assistant, so a document can cite + * with the same `wr_` ref it cites with in chat. Absent outside a chat turn. + */ + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; workspaceId: string; } export function createWorkspaceAccessContext(input: { scopes: readonly WorkspaceAccessScope[]; operationId: string; + resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; userId: string; workspaceId: string; }): WorkspaceAccessContext { return { actor: createAccessActor(input), operationId: input.operationId, + ...(input.resolveWorkspaceReferences + ? { resolveWorkspaceReferences: input.resolveWorkspaceReferences } + : {}), workspaceId: input.workspaceId, }; } 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..1cc79ffa 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-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. For math, use or

. For checkboxes, use
  • Item

. Documents cannot hold images: never use or
, and describe the visual in words instead. Cite workspace sources in documents exactly as in a chat reply, with placed after the claim it supports.'; 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,14 +333,11 @@ export const workspaceListItemsOutputSchema = z.object({ }); export const workspaceCreateItemsOutputSchema = createWorkspaceItemsResultSchema({ - itemSchema: z.object({ + itemSchema: workspacePathItemSchema.extend({ itemId: z.string().min(1), - path: workspacePathSchema, + // Creation makes these two and nothing else; the shared item type covers + // files and study items this tool cannot produce. type: z.enum(["document", "folder"]), - warnings: z - .array(z.string()) - .optional() - .describe("Content projection warnings for created documents."), }), failureSchema: createFailureSchema(createWorkspaceItemsFailureCodes), }).extend({ @@ -354,16 +366,20 @@ export const workspaceRenameItemOutputSchema = z.object({ export const workspaceEditItemOutputSchema = z.object({ path: workspacePathSchema, applied: z.number().int().min(0), + itemId: z.string().optional(), + lineChanges: z + .object({ added: z.number().int().min(0), removed: z.number().int().min(0) }) + .optional(), failed: z.array( z.object({ code: z.enum(editWorkspaceItemFailureCodes), + detail: z + .string() + .optional() + .describe("Why this edit was refused, when the reason is known."), index: workspaceIndexSchema, }), ), - warnings: z - .array(z.string()) - .describe("Content projection warnings after applying edits.") - .optional(), }); export const workspaceLinkItemsOutputSchema = z.object({ diff --git a/src/server.ts b/src/server.ts index 039b2143..8b2322f1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,14 +9,17 @@ import { posthogHost, posthogHostOrigin, posthogProjectToken } from "#/integrati import { getTelemetryRequestDetails } from "#/integrations/posthog/server-context"; import { buildContentSecurityPolicy } from "#/lib/http/content-security-policy"; -export { CodemodeRuntime } from "@cloudflare/codemode"; -export { Sandbox } from "@cloudflare/sandbox"; -export { AIThread, UserAIStore } from "#/features/workspaces/ai/user-ai-agents"; -export { OfficePdfConverter } from "#/features/workspaces/conversion/office-pdf-converter"; -export { DocumentSession } from "#/features/workspaces/documents/document-session"; -export { WorkspaceFileExtractionWorkflow } from "#/features/workspaces/extraction/workspace-file-extraction-workflow"; -export { WorkspaceFileProcessor } from "#/features/workspaces/files/workspace-file-processor"; -export { WorkspaceKernel } from "#/features/workspaces/kernel/workspace-kernel"; +export { + AIThread, + CodemodeRuntime, + DocumentSession, + OfficePdfConverter, + Sandbox, + UserAIStore, + WorkspaceFileExtractionWorkflow, + WorkspaceFileProcessor, + WorkspaceKernel, +} from "#/durable-objects"; const isProduction = import.meta.env.PROD; diff --git a/src/styles.css b/src/styles.css index 6407bf5a..ad2ec5a4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -504,6 +504,53 @@ 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 { + /* The negative margin cancels the padding, so the tint gets breathing + * room without nudging the surrounding text. */ + margin-inline: -0.15em; + padding-block: 0.06em; + padding-inline: 0.15em; + border-radius: 0.25rem; + background: color-mix(in oklab, var(--workspace-document-ai-accent) 15%, transparent); + text-decoration: underline; + text-decoration-color: color-mix( + in oklab, + var(--workspace-document-ai-accent) 80%, + 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 { + color: var(--muted-foreground); + text-decoration: line-through; + text-decoration-color: color-mix(in oklab, var(--muted-foreground) 65%, transparent); + opacity: 0.8; + } + + /* + * Only reached by blocks holding no text of their own — a horizontal rule, a + * rendered formula. Anything containing text is marked inline instead, so + * this never draws a box around a paragraph, a list, or a blank line. + */ + .workspace-document-prose .workspace-document-ai-changed-block { + padding: 0.35rem 0.5rem; + border-radius: 0.375rem; + background: color-mix(in oklab, var(--workspace-document-ai-accent) 12%, transparent); + } + .dark .workspace-document-prose { --workspace-document-highlight: oklch(0.82 0.12 88 / 82%); --workspace-document-highlight-foreground: var(--foreground); @@ -515,7 +562,8 @@ .workspace-document-prose h1, .workspace-document-prose h2, - .workspace-document-prose h3 { + .workspace-document-prose h3, + .workspace-document-prose h4 { font-weight: 650; line-height: 1.15; text-wrap: balance; @@ -536,6 +584,14 @@ font-size: 1.2rem; } + /* The size ladder has no room below h3, so this level is set apart by weight + and spacing instead — the usual answer for a fourth heading. */ + .workspace-document-prose h4 { + margin-top: 1.3rem; + font-size: 1rem; + letter-spacing: 0.01em; + } + .workspace-document-prose p { min-height: 1.65rem; line-height: 1.7; @@ -611,6 +667,7 @@ .workspace-document-prose a { color: var(--primary); + cursor: pointer; text-decoration-line: underline; text-decoration-thickness: 0.08em; text-underline-offset: 0.18em; diff --git a/test/stubs/tanstack-start-server.ts b/test/stubs/tanstack-start-server.ts new file mode 100644 index 00000000..fdcfd92d --- /dev/null +++ b/test/stubs/tanstack-start-server.ts @@ -0,0 +1,13 @@ +// Worker tests drive Durable Objects and server modules directly, with no +// TanStack request in scope. Importing the real module would pull the Start +// server handler and its build-time virtual entries into the test graph. +// +// This throws rather than returning empty headers: a test that reaches the +// ambient request context has found a code path that cannot run inside a +// Durable Object, and should say so instead of quietly passing on blank values. +export function getRequestHeaders(): Headers { + throw new Error( + "getRequestHeaders() is unavailable in worker tests: there is no TanStack request. " + + "Pass explicit headers, or move this call behind a request-scoped entry point.", + ); +} diff --git a/test/worker-entry.ts b/test/worker-entry.ts new file mode 100644 index 00000000..4e24f550 --- /dev/null +++ b/test/worker-entry.ts @@ -0,0 +1,20 @@ +// Worker tests drive Durable Objects and server modules directly. The app entry +// serves the TanStack router, whose build-time virtual specifiers do not resolve +// under Vitest, so the test project runs against this handler-free entry instead. +export { + AIThread, + CodemodeRuntime, + DocumentSession, + OfficePdfConverter, + Sandbox, + UserAIStore, + WorkspaceFileExtractionWorkflow, + WorkspaceFileProcessor, + WorkspaceKernel, +} from "#/durable-objects"; + +export default { + fetch() { + return new Response("Test worker entry", { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/tsconfig.json b/tsconfig.json index a8278324..36eb548e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,9 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", + "test/**/*.ts", "drizzle.config.ts", "vite.config.ts", - "vitest.cloudflare.config.ts", "vitest.config.ts" ], "compilerOptions": { @@ -16,7 +16,12 @@ "@/*": ["./src/*"] }, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["./worker-configuration.d.ts", "node", "vite/client"], + "types": [ + "./worker-configuration.d.ts", + "node", + "vite/client", + "@cloudflare/vitest-pool-workers/types" + ], /* Bundler mode */ "moduleResolution": "bundler", diff --git a/vitest.cloudflare.config.ts b/vitest.cloudflare.config.ts deleted file mode 100644 index 7bd60a4c..00000000 --- a/vitest.cloudflare.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; -import { defineConfig } from "vite-plus/test/config"; - -export default defineConfig({ - plugins: [ - cloudflareTest({ - remoteBindings: false, - wrangler: { - configPath: "./wrangler.jsonc", - }, - }), - ], - test: { - include: ["src/**/*.worker.test.ts"], - }, -}); diff --git a/vitest.config.ts b/vitest.config.ts index 2679b340..59d3f528 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,50 @@ +import { fileURLToPath } from "node:url"; + +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import agents from "agents/vite"; import { defineConfig } from "vite-plus/test/config"; export default defineConfig({ - resolve: { tsconfigPaths: true }, test: { - include: ["src/**/*.test.{ts,tsx}"], - exclude: ["src/**/*.worker.test.ts"], - environment: "node", + projects: [ + { + resolve: { tsconfigPaths: true }, + test: { + name: "node", + include: ["src/**/*.test.{ts,tsx}"], + exclude: ["src/**/*.worker.test.ts"], + environment: "node", + }, + }, + { + resolve: { + tsconfigPaths: true, + alias: { + // Worker tests drive Durable Objects and server modules directly, + // with no TanStack request in scope. The real module would drag the + // Start server handler and its build-time virtual entries in with it. + "@tanstack/react-start/server": fileURLToPath( + new URL("./test/stubs/tanstack-start-server.ts", import.meta.url), + ), + }, + }, + plugins: [ + // Agent classes use TC39 decorators, which Oxc cannot transform yet. + // The app build leans on this same plugin to lower them. + agents(), + cloudflareTest({ + // The app entry serves the TanStack router, which tests never + // exercise. A handler-free entry keeps that build out of the graph. + main: "./test/worker-entry.ts", + remoteBindings: false, + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + test: { + name: "workers", + include: ["src/**/*.worker.test.ts"], + }, + }, + ], }, });