{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 (
+
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:
+ "