diff --git a/.hermes/plans/2026-09-20_131317-debate-workspace.md b/.hermes/plans/2026-09-20_131317-debate-workspace.md
index 40d1c8713..cfab64de7 100644
--- a/.hermes/plans/2026-09-20_131317-debate-workspace.md
+++ b/.hermes/plans/2026-09-20_131317-debate-workspace.md
@@ -264,10 +264,10 @@ Each numbered slice is independently reviewable. Before implementation, write it
**P0.2 Suggestions and import/export fidelity spike.** Proposed new paths: `src/test/workspace/review.test.ts`, `src/test/workspace/document-conversion.test.ts`, and small fixture files under `src/test/workspace/fixtures/` during implementation.
-- [ ] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits.
-- [ ] Test human suggested insert/delete/format, overlapping real edits, accepting one hunk, rejecting all, and undo/redo. Prove quote/comment anchors survive unrelated insertions and detach on deletion.
-- [ ] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality.
-- [ ] Decide whether the open-source suggestion implementation is maintainable before committing the UI architecture. Record the tested ceiling and package choices in the implementation PR.
+- [x] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits.
+- [x] Test human suggested insert/delete/format, overlapping real edits, accepting one hunk, rejecting all, and undo/redo. Prove quote/comment anchors survive unrelated insertions and detach on deletion.
+- [x] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality.
+- [x] Decide whether the open-source suggestion implementation is maintainable before committing the UI architecture. Record the tested ceiling and package choices in the implementation PR.
**Exit:** Architecture is selected using runnable evidence. A failed gate stops dependent work, not the entire vision. No live rollout yet.
diff --git a/e2e/fixtures/workspace-review-ime.html b/e2e/fixtures/workspace-review-ime.html
new file mode 100644
index 000000000..c16d77f29
--- /dev/null
+++ b/e2e/fixtures/workspace-review-ime.html
@@ -0,0 +1,102 @@
+
+
+
+
+
+ Workspace IME review gate
+
+
+
+
+
+
diff --git a/e2e/workspace-review-ime.spec.ts b/e2e/workspace-review-ime.spec.ts
new file mode 100644
index 000000000..2caac968e
--- /dev/null
+++ b/e2e/workspace-review-ime.spec.ts
@@ -0,0 +1,114 @@
+import { expect, test, type CDPSession, type Page } from "@playwright/test";
+
+interface ImeSnapshot {
+ canonical: string;
+ canonicalUpdates: number;
+ suggestion: string;
+ editorComposing: boolean;
+ phase: "idle" | "composing" | "settled";
+ events: Array<{
+ type: string;
+ data: string | null;
+ inputType: string | null;
+ isComposing: boolean | null;
+ }>;
+ proposal: null | {
+ status: string;
+ replacement: Array<{ type: string; text?: string }>;
+ };
+}
+
+interface WorkspaceImeHarness {
+ snapshot(): ImeSnapshot;
+ capture(id: string): ImeSnapshot;
+ accept(): ImeSnapshot;
+ reject(): ImeSnapshot;
+ resetSuggestion(): ImeSnapshot;
+}
+
+declare global {
+ interface Window {
+ workspaceIme: WorkspaceImeHarness;
+ }
+}
+
+async function snapshot(page: Page) {
+ return page.evaluate(() => window.workspaceIme.snapshot());
+}
+
+async function compose(
+ page: Page,
+ cdp: CDPSession,
+ id: string,
+ steps: [string, ...string[]],
+ accepted: string,
+ acceptedUpdates: number,
+) {
+ await page.locator(".ProseMirror").focus();
+ for (const text of steps) {
+ await cdp.send("Input.imeSetComposition", {
+ text,
+ selectionStart: text.length,
+ selectionEnd: text.length,
+ });
+ const during = await page.evaluate((proposalId) =>
+ window.workspaceIme.capture(proposalId), id,
+ );
+ expect(during.phase).toBe("composing");
+ expect(during.proposal).toBeNull();
+ expect(during.canonical).toBe(accepted);
+ expect(during.canonicalUpdates).toBe(acceptedUpdates);
+ }
+ await cdp.send("Input.insertText", { text: steps[steps.length - 1] });
+ await expect.poll(async () => (await snapshot(page)).phase).toBe("settled");
+ await expect.poll(async () => (await snapshot(page)).editorComposing).toBe(false);
+}
+
+test("captures Spanish and Hungarian IME text only after composition settles", async ({
+ page,
+ context,
+}, testInfo) => {
+ test.skip(testInfo.project.name !== "desktop-chromium", "Chromium CDP provides native IME composition");
+
+ await page.goto("/e2e/fixtures/workspace-review-ime.html");
+ await expect.poll(() => page.evaluate(() => Boolean(window.workspaceIme))).toBe(true);
+ const cdp = await context.newCDPSession(page);
+
+ await compose(page, cdp, "spanish", ["n", "ni", "niñ", "niño"], "Accepted:", 0);
+ const spanishSettled = await page.evaluate(() => window.workspaceIme.capture("spanish"));
+ expect(spanishSettled.events.map((event) => event.type)).toEqual(expect.arrayContaining([
+ "compositionstart",
+ "beforeinput",
+ "input",
+ "compositionend",
+ ]));
+ expect(spanishSettled.events.filter((event) => event.type === "beforeinput").every((event) =>
+ event.inputType === "insertCompositionText" && event.isComposing,
+ )).toBe(true);
+ expect(spanishSettled.proposal).toEqual({
+ status: "pending",
+ replacement: [{ type: "text", text: "niño" }],
+ });
+ expect(spanishSettled.canonical).toBe("Accepted:");
+ expect(spanishSettled.canonicalUpdates).toBe(0);
+ expect((await page.evaluate(() => window.workspaceIme.accept())).canonical).toBe("Accepted:niño");
+
+ await page.evaluate(() => window.workspaceIme.resetSuggestion());
+ await compose(
+ page,
+ cdp,
+ "hungarian",
+ ["t", "tű", "tűr", "tűrő"],
+ "Accepted:niño",
+ 1,
+ );
+ const hungarianSettled = await page.evaluate(() => window.workspaceIme.capture("hungarian"));
+ expect(hungarianSettled.proposal).toEqual({
+ status: "pending",
+ replacement: [{ type: "text", text: "tűrő" }],
+ });
+ expect(hungarianSettled.canonical).toBe("Accepted:niño");
+ const rejected = await page.evaluate(() => window.workspaceIme.reject());
+ expect(rejected.proposal?.status).toBe("rejected");
+ expect(rejected.canonical).toBe("Accepted:niño");
+});
diff --git a/src/lib/workspace/document-conversion.ts b/src/lib/workspace/document-conversion.ts
new file mode 100644
index 000000000..cd838d4f8
--- /dev/null
+++ b/src/lib/workspace/document-conversion.ts
@@ -0,0 +1,530 @@
+import type { JSONContent } from "@tiptap/core";
+import { validateUrl } from "@/lib/validation/url";
+import liberationSansUrl from "pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf?url";
+import { workspaceSchema } from "./schema";
+
+function escapeMarkdown(text: string) {
+ return text.replace(/([\\`*_[\]()|])/g, "\\$1");
+}
+
+function unescapeMarkdown(text: string) {
+ return text.replace(/\\([\\`*_[\]()|])/g, "$1");
+}
+
+function citationLabel(attrs: Record) {
+ const title = String(attrs.title || "Citation");
+ const locator = attrs.locator ? `, ${String(attrs.locator)}` : "";
+ return `[${title}${locator}]`;
+}
+
+function citationUri(attrs: Record) {
+ const query = new URLSearchParams();
+ for (const key of ["title", "locator", "quote", "href"] as const) {
+ if (attrs[key]) query.set(key, String(attrs[key]));
+ }
+ return `debatelab-citation:${encodeURIComponent(String(attrs.sourceId || ""))}?${query}`;
+}
+
+function inlineMarkdown(content: JSONContent[] = []) {
+ return content.map((node) => {
+ if (node.type === "citation") {
+ return `${citationLabel(node.attrs ?? {})}(${citationUri(node.attrs ?? {})})`;
+ }
+ if (node.type !== "text") return "";
+
+ let text = escapeMarkdown(node.text ?? "");
+ for (const mark of node.marks ?? []) {
+ if (mark.type === "bold") text = `**${text}**`;
+ if (mark.type === "italic") text = `*${text}*`;
+ if (mark.type === "strike") text = `~~${text}~~`;
+ }
+ const link = node.marks?.find((mark) => mark.type === "link");
+ return link?.attrs?.href
+ ? `[${text}](${String(link.attrs.href)})`
+ : text;
+ }).join("");
+}
+
+function serializeList(node: JSONContent, depth = 0): string {
+ const lines: string[] = [];
+ for (const item of node.content ?? []) {
+ const blocks = item.content ?? [];
+ const paragraph = blocks.find((block) => block.type === "paragraph");
+ lines.push(`${" ".repeat(depth)}- ${inlineMarkdown(paragraph?.content)}`);
+ for (const child of blocks) {
+ if (child.type === "bulletList") lines.push(serializeList(child, depth + 1));
+ }
+ }
+ return lines.join("\n");
+}
+
+function tableCellMarkdown(cell: JSONContent) {
+ return escapeMarkdown((cell.content ?? []).map((block) => inlineMarkdown(block.content)).join(" "));
+}
+
+function serializeTable(node: JSONContent) {
+ const rows = (node.content ?? []).map((row) =>
+ `| ${(row.content ?? []).map(tableCellMarkdown).join(" | ")} |`,
+ );
+ if (rows.length === 0) return "";
+ const columns = node.content?.[0]?.content?.length ?? 1;
+ rows.splice(1, 0, `| ${Array.from({ length: columns }, () => "---").join(" | ")} |`);
+ return rows.join("\n");
+}
+
+export function toWorkspaceMarkdown(document: JSONContent): string {
+ workspaceSchema.nodeFromJSON(document).check();
+ return (document.content ?? []).map((node) => {
+ if (node.type === "heading") {
+ return `${"#".repeat(Number(node.attrs?.level) || 1)} ${inlineMarkdown(node.content)}`;
+ }
+ if (node.type === "paragraph") return inlineMarkdown(node.content);
+ if (node.type === "bulletList") return serializeList(node);
+ if (node.type === "table") return serializeTable(node);
+ return "";
+ }).filter(Boolean).join("\n\n");
+}
+
+function parseCitation(href: string): JSONContent | null {
+ if (!href.startsWith("debatelab-citation:")) return null;
+ const [source, query = ""] = href.slice("debatelab-citation:".length).split("?", 2);
+ let sourceId: string;
+ try {
+ sourceId = decodeURIComponent(source);
+ } catch {
+ return null;
+ }
+ if (!sourceId) return null;
+ const params = new URLSearchParams(query);
+ const citationHref = validateUrl(params.get("href") ?? undefined) ?? null;
+ return {
+ type: "citation",
+ attrs: {
+ sourceId,
+ title: params.get("title"),
+ locator: params.get("locator"),
+ quote: params.get("quote"),
+ href: citationHref,
+ },
+ };
+}
+
+function parseMarkedText(text: string, extraMarks: JSONContent["marks"] = []): JSONContent[] {
+ const nodes: JSONContent[] = [];
+ const pattern = /(\*\*([^*]+)\*\*|\*([^*]+)\*|~~([^~]+)~~)/g;
+ let start = 0;
+ for (const match of text.matchAll(pattern)) {
+ if (match.index > start) {
+ nodes.push({ type: "text", text: unescapeMarkdown(text.slice(start, match.index)), marks: extraMarks });
+ }
+ const mark = match[2] ? "bold" : match[3] ? "italic" : "strike";
+ nodes.push({
+ type: "text",
+ text: unescapeMarkdown(match[2] ?? match[3] ?? match[4]),
+ marks: [...extraMarks, { type: mark }],
+ });
+ start = match.index + match[0].length;
+ }
+ if (start < text.length) {
+ nodes.push({ type: "text", text: unescapeMarkdown(text.slice(start)), marks: extraMarks });
+ }
+ return nodes.map((node) => node.marks?.length ? node : { ...node, marks: undefined });
+}
+
+function parseInline(text: string): JSONContent[] {
+ const nodes: JSONContent[] = [];
+ const linkPattern = /\[([^\]]*)\]\(([^)]+)\)/g;
+ let start = 0;
+ for (const match of text.matchAll(linkPattern)) {
+ if (match.index > start) nodes.push(...parseMarkedText(text.slice(start, match.index)));
+ const citation = parseCitation(match[2]);
+ if (citation) {
+ nodes.push(citation);
+ } else {
+ const href = validateUrl(match[2]);
+ nodes.push(...parseMarkedText(match[1], href ? [{ type: "link", attrs: { href } }] : []));
+ }
+ start = match.index + match[0].length;
+ }
+ if (start < text.length) nodes.push(...parseMarkedText(text.slice(start)));
+ return nodes;
+}
+
+function splitTableRow(line: string) {
+ const cells: string[] = [];
+ let cell = "";
+ let escaped = false;
+ for (const character of line.trim().replace(/^\||\|$/g, "")) {
+ if (character === "|" && !escaped) {
+ cells.push(cell.trim());
+ cell = "";
+ } else {
+ cell += character;
+ }
+ escaped = character === "\\" && !escaped;
+ if (character !== "\\") escaped = false;
+ }
+ cells.push(cell.trim());
+ return cells;
+}
+
+function parseTable(lines: string[], start: number) {
+ const rows: JSONContent[] = [];
+ let index = start;
+ let header = true;
+ while (index < lines.length && /^\s*\|/.test(lines[index])) {
+ if (!/^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(lines[index])) {
+ rows.push({
+ type: "tableRow",
+ content: splitTableRow(lines[index]).map((cell) => ({
+ type: header ? "tableHeader" : "tableCell",
+ content: [{ type: "paragraph", content: parseInline(unescapeMarkdown(cell)) }],
+ })),
+ });
+ header = false;
+ }
+ index += 1;
+ }
+ return { node: { type: "table", content: rows } satisfies JSONContent, index };
+}
+
+function parseList(lines: string[], start: number, indent: number) {
+ const items: JSONContent[] = [];
+ let index = start;
+ while (index < lines.length) {
+ const match = /^(\s*)-\s+(.*)$/.exec(lines[index]);
+ if (!match || match[1].length < indent) break;
+ if (match[1].length > indent) {
+ const nested = parseList(lines, index, match[1].length);
+ items.at(-1)?.content?.push(nested.node);
+ index = nested.index;
+ continue;
+ }
+ items.push({
+ type: "listItem",
+ content: [{ type: "paragraph", content: parseInline(match[2]) }],
+ });
+ index += 1;
+ }
+ return { node: { type: "bulletList", content: items } satisfies JSONContent, index };
+}
+
+export function fromWorkspaceMarkdown(markdown: string): JSONContent {
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
+ const content: JSONContent[] = [];
+ let index = 0;
+ while (index < lines.length) {
+ const line = lines[index];
+ if (!line.trim()) {
+ index += 1;
+ continue;
+ }
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
+ if (heading) {
+ content.push({
+ type: "heading",
+ attrs: { level: heading[1].length },
+ content: parseInline(heading[2]),
+ });
+ index += 1;
+ continue;
+ }
+ if (/^\s*-\s+/.test(line)) {
+ const list = parseList(lines, index, /^\s*/.exec(line)?.[0].length ?? 0);
+ content.push(list.node);
+ index = list.index;
+ continue;
+ }
+ if (/^\s*\|/.test(line) && /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(lines[index + 1] ?? "")) {
+ const table = parseTable(lines, index);
+ content.push(table.node, { type: "paragraph" });
+ index = table.index;
+ continue;
+ }
+
+ content.push({ type: "paragraph", content: parseInline(line) });
+ index += 1;
+ }
+
+ const parsed = workspaceSchema.nodeFromJSON({ type: "doc", content });
+ parsed.check();
+ return parsed.toJSON();
+}
+
+function docxInline(content: JSONContent[] = [], docx: typeof import("docx")) {
+ return content.map((node) => {
+ if (node.type === "citation") {
+ return new docx.ExternalHyperlink({
+ link: citationUri(node.attrs ?? {}),
+ children: [new docx.TextRun(citationLabel(node.attrs ?? {}))],
+ });
+ }
+
+ const options = {
+ text: node.text ?? "",
+ bold: node.marks?.some((mark) => mark.type === "bold"),
+ italics: node.marks?.some((mark) => mark.type === "italic"),
+ strike: node.marks?.some((mark) => mark.type === "strike"),
+ };
+ const link = node.marks?.find((mark) => mark.type === "link")?.attrs?.href;
+ return link
+ ? new docx.ExternalHyperlink({
+ link: String(link),
+ children: [new docx.TextRun(options)],
+ })
+ : new docx.TextRun(options);
+ });
+}
+
+function docxList(node: JSONContent, docx: typeof import("docx"), depth = 0): import("docx").Paragraph[] {
+ const paragraphs: import("docx").Paragraph[] = [];
+ for (const item of node.content ?? []) {
+ const blocks = item.content ?? [];
+ const paragraph = blocks.find((block) => block.type === "paragraph");
+ paragraphs.push(new docx.Paragraph({
+ children: docxInline(paragraph?.content, docx),
+ bullet: { level: depth },
+ }));
+ for (const child of blocks) {
+ if (child.type === "bulletList") paragraphs.push(...docxList(child, docx, depth + 1));
+ }
+ }
+ return paragraphs;
+}
+
+function docxTable(node: JSONContent, docx: typeof import("docx")) {
+ return new docx.Table({
+ rows: (node.content ?? []).map((row, rowIndex) => new docx.TableRow({
+ tableHeader: rowIndex === 0,
+ children: (row.content ?? []).map((cell) => new docx.TableCell({
+ children: (cell.content ?? []).map((block) => new docx.Paragraph({
+ children: docxInline(block.content, docx),
+ })),
+ })),
+ })),
+ });
+}
+
+export async function toWorkspaceDocx(document: JSONContent): Promise {
+ workspaceSchema.nodeFromJSON(document).check();
+ const docx = await import("docx");
+ const children = (document.content ?? []).flatMap((node) => {
+ if (node.type === "heading") {
+ const levels = [
+ docx.HeadingLevel.HEADING_1,
+ docx.HeadingLevel.HEADING_2,
+ docx.HeadingLevel.HEADING_3,
+ docx.HeadingLevel.HEADING_4,
+ docx.HeadingLevel.HEADING_5,
+ docx.HeadingLevel.HEADING_6,
+ ];
+ return [new docx.Paragraph({
+ heading: levels[Math.max(0, Math.min(5, Number(node.attrs?.level) - 1))],
+ children: docxInline(node.content, docx),
+ })];
+ }
+ if (node.type === "paragraph") {
+ return [new docx.Paragraph({ children: docxInline(node.content, docx) })];
+ }
+ if (node.type === "bulletList") return docxList(node, docx);
+ if (node.type === "table") return [docxTable(node, docx)];
+ return [];
+ });
+ const file = new docx.Document({ sections: [{ children }] });
+ return new Uint8Array(await docx.Packer.toBuffer(file));
+}
+
+function htmlInline(node: ChildNode, marks: JSONContent["marks"] = []): JSONContent[] {
+ if (node.nodeType === 3) {
+ return node.textContent ? [{ type: "text", text: node.textContent, marks }] : [];
+ }
+ if (!(node instanceof Element)) return [];
+
+ const tag = node.tagName.toLowerCase();
+ if (tag === "script" || tag === "style") return [];
+ if (tag === "a") {
+ const href = node.getAttribute("href") ?? "";
+ const citation = parseCitation(href);
+ if (citation) return [citation];
+ const safeHref = validateUrl(href);
+ const linkMarks = safeHref ? [...marks, { type: "link", attrs: { href: safeHref } }] : marks;
+ return Array.from(node.childNodes).flatMap((child) => htmlInline(child, linkMarks));
+ }
+
+ const mark = tag === "strong" || tag === "b"
+ ? "bold"
+ : tag === "em" || tag === "i"
+ ? "italic"
+ : tag === "s" || tag === "del"
+ ? "strike"
+ : null;
+ const nextMarks = mark ? [...marks, { type: mark }] : marks;
+ return Array.from(node.childNodes).flatMap((child) => htmlInline(child, nextMarks));
+}
+
+function htmlParagraph(element: Element): JSONContent {
+ return {
+ type: "paragraph",
+ content: Array.from(element.childNodes).flatMap((child) => htmlInline(child)),
+ };
+}
+
+function htmlList(element: Element): JSONContent {
+ return {
+ type: "bulletList",
+ content: Array.from(element.children)
+ .filter((child) => child.tagName.toLowerCase() === "li")
+ .map((item) => {
+ const content: JSONContent[] = [];
+ const paragraph = Array.from(item.children).find((child) =>
+ child.tagName.toLowerCase() === "p",
+ );
+ content.push(paragraph
+ ? htmlParagraph(paragraph)
+ : {
+ type: "paragraph",
+ content: Array.from(item.childNodes)
+ .filter((child) => !(child instanceof Element) || !["ul", "ol"].includes(child.tagName.toLowerCase()))
+ .flatMap((child) => htmlInline(child)),
+ });
+ for (const child of Array.from(item.children)) {
+ if (["ul", "ol"].includes(child.tagName.toLowerCase())) content.push(htmlList(child));
+ }
+ return { type: "listItem", content };
+ }),
+ };
+}
+
+function htmlTable(element: Element): JSONContent {
+ const rows = Array.from(element.querySelectorAll("tr")).map((row, rowIndex) => ({
+ type: "tableRow",
+ content: Array.from(row.children)
+ .filter((cell) => ["td", "th"].includes(cell.tagName.toLowerCase()))
+ .map((cell) => ({
+ type: rowIndex === 0 || cell.tagName.toLowerCase() === "th" ? "tableHeader" : "tableCell",
+ content: [htmlParagraph(cell)],
+ })),
+ }));
+ return { type: "table", content: rows };
+}
+
+export function fromWorkspaceHtml(html: string): JSONContent {
+ const body = new DOMParser().parseFromString(html, "text/html").body;
+ const content: JSONContent[] = [];
+ for (const element of Array.from(body.children)) {
+ const tag = element.tagName.toLowerCase();
+ if (/^h[1-6]$/.test(tag)) {
+ content.push({
+ type: "heading",
+ attrs: { level: Number(tag[1]) },
+ content: Array.from(element.childNodes).flatMap((child) => htmlInline(child)),
+ });
+ } else if (tag === "p") {
+ content.push(htmlParagraph(element));
+ } else if (tag === "ul" || tag === "ol") {
+ content.push(htmlList(element));
+ } else if (tag === "table") {
+ content.push(htmlTable(element), { type: "paragraph" });
+ }
+ }
+ const parsed = workspaceSchema.nodeFromJSON({ type: "doc", content });
+ parsed.check();
+ return parsed.toJSON();
+}
+
+export async function fromWorkspaceDocx(bytes: Uint8Array): Promise {
+ const mammoth = await import("mammoth");
+ const arrayBuffer = new Uint8Array(bytes).buffer;
+ const input = { arrayBuffer, buffer: bytes };
+ const result = await mammoth.convertToHtml(input);
+ return fromWorkspaceHtml(result.value);
+}
+
+function visibleInlineText(content: JSONContent[] = []) {
+ return content.map((node) =>
+ node.type === "citation"
+ ? citationLabel(node.attrs ?? {})
+ : node.text ?? "",
+ ).join("");
+}
+
+function pdfLines(document: JSONContent) {
+ const lines: Array<{ text: string; fontSize: number; indent: number }> = [];
+ const addList = (node: JSONContent, depth = 0) => {
+ for (const item of node.content ?? []) {
+ const blocks = item.content ?? [];
+ const paragraph = blocks.find((block) => block.type === "paragraph");
+ lines.push({ text: `• ${visibleInlineText(paragraph?.content)}`, fontSize: 11, indent: depth * 6 });
+ for (const child of blocks) {
+ if (child.type === "bulletList") addList(child, depth + 1);
+ }
+ }
+ };
+
+ for (const node of document.content ?? []) {
+ if (node.type === "heading") {
+ lines.push({
+ text: visibleInlineText(node.content),
+ fontSize: Math.max(12, 18 - Number(node.attrs?.level || 1) * 2),
+ indent: 0,
+ });
+ } else if (node.type === "paragraph") {
+ const text = visibleInlineText(node.content);
+ if (text) lines.push({ text, fontSize: 11, indent: 0 });
+ } else if (node.type === "bulletList") {
+ addList(node);
+ } else if (node.type === "table") {
+ for (const row of node.content ?? []) {
+ lines.push({
+ text: (row.content ?? []).map((cell) =>
+ (cell.content ?? []).map((block) => visibleInlineText(block.content)).join(" "),
+ ).join(" | "),
+ fontSize: 10,
+ indent: 0,
+ });
+ }
+ }
+ }
+ return lines;
+}
+
+function binaryString(bytes: Uint8Array) {
+ let value = "";
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
+ value += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
+ }
+ return value;
+}
+
+export async function toWorkspacePdf(
+ document: JSONContent,
+ options: { fontData?: Uint8Array } = {},
+): Promise {
+ workspaceSchema.nodeFromJSON(document).check();
+ const { jsPDF } = await import("jspdf");
+ const fontData = options.fontData ?? new Uint8Array(
+ await (await fetch(liberationSansUrl)).arrayBuffer(),
+ );
+ const pdf = new jsPDF({ unit: "mm", format: "a4" });
+ pdf.addFileToVFS("LiberationSans-Regular.ttf", binaryString(fontData));
+ pdf.addFont("LiberationSans-Regular.ttf", "LiberationSans", "normal");
+ pdf.setFont("LiberationSans", "normal");
+
+ const margin = 20;
+ const width = pdf.internal.pageSize.getWidth() - margin * 2;
+ const pageBottom = pdf.internal.pageSize.getHeight() - margin;
+ let y = margin;
+ for (const line of pdfLines(document)) {
+ pdf.setFontSize(line.fontSize);
+ const wrapped = Array.from(pdf.splitTextToSize(line.text, width - line.indent), String);
+ const lineHeight = line.fontSize * 0.4;
+ if (y + wrapped.length * lineHeight > pageBottom) {
+ pdf.addPage();
+ pdf.setFont("LiberationSans", "normal");
+ y = margin;
+ }
+ pdf.text(wrapped, margin + line.indent, y);
+ y += wrapped.length * lineHeight + 3;
+ }
+ return new Uint8Array(pdf.output("arraybuffer"));
+}
diff --git a/src/lib/workspace/review.ts b/src/lib/workspace/review.ts
new file mode 100644
index 000000000..666c709f7
--- /dev/null
+++ b/src/lib/workspace/review.ts
@@ -0,0 +1,333 @@
+import type { Editor, JSONContent } from "@tiptap/core";
+import { createMappablePosition } from "@tiptap/extension-collaboration";
+import { Fragment, Slice } from "@tiptap/pm/model";
+import {
+ relativePositionToAbsolutePosition,
+ ySyncPluginKey,
+} from "@tiptap/y-tiptap";
+import * as Y from "yjs";
+
+export type ProposalHunkStatus = "pending" | "accepted" | "rejected" | "stale";
+
+type ProposalHunkInput =
+ | { id: string; kind: "insert"; at: number; content: JSONContent[] }
+ | { id: string; kind: "delete"; from: number; to: number }
+ | {
+ id: string;
+ kind: "format";
+ from: number;
+ to: number;
+ content: JSONContent[];
+ };
+
+export interface ProposalHunk {
+ id: string;
+ kind: ProposalHunkInput["kind"];
+ status: ProposalHunkStatus;
+ from: Uint8Array;
+ to: Uint8Array;
+ original: JSONContent[];
+ replacement: JSONContent[];
+ contextBefore: JSONContent[];
+ contextAfter: JSONContent[];
+}
+
+export interface WorkspaceProposal {
+ id: string;
+ authorId: string;
+ documentEpoch: number;
+ baseStateVector: Uint8Array;
+ hunks: ProposalHunk[];
+}
+
+export interface QuoteAnchor {
+ id: string;
+ documentEpoch: number;
+ from: Uint8Array;
+ to: Uint8Array;
+ quote: string;
+}
+
+function normalizedContent(editor: Editor, content: JSONContent[]) {
+ return Fragment.fromJSON(editor.schema, content).toJSON();
+}
+
+function rangeFor(input: ProposalHunkInput) {
+ return input.kind === "insert"
+ ? { from: input.at, to: input.at }
+ : { from: input.from, to: input.to };
+}
+
+function documentFor(editor: Editor): Y.Doc {
+ const state = ySyncPluginKey.getState(editor.state);
+ if (!state?.doc) throw new Error("Workspace editor is not bound to Yjs");
+ return state.doc;
+}
+
+function sameContent(left: JSONContent[], right: JSONContent[]) {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+function resolveAnchors(editor: Editor, fromAnchor: Uint8Array, toAnchor: Uint8Array) {
+ const state = ySyncPluginKey.getState(editor.state);
+ if (!state?.doc || !state.type || !state.binding?.mapping) return null;
+
+ const from = relativePositionToAbsolutePosition(
+ state.doc,
+ state.type,
+ Y.decodeRelativePosition(fromAnchor),
+ state.binding.mapping,
+ );
+ const to = relativePositionToAbsolutePosition(
+ state.doc,
+ state.type,
+ Y.decodeRelativePosition(toAnchor),
+ state.binding.mapping,
+ );
+ return from === null || to === null || from > to ? null : { from, to };
+}
+
+function resolvedRange(editor: Editor, hunk: ProposalHunk) {
+ const range = resolveAnchors(editor, hunk.from, hunk.to);
+ if (!range) return null;
+ const { from, to } = range;
+
+ const documentEnd = editor.state.doc.content.size;
+ const original = editor.state.doc.slice(from, to).content.toJSON();
+ const contextBefore = editor.state.doc
+ .slice(Math.max(0, from - 1), from)
+ .content.toJSON();
+ const contextAfter = editor.state.doc
+ .slice(to, Math.min(documentEnd, to + 1))
+ .content.toJSON();
+
+ return sameContent(original, hunk.original) &&
+ sameContent(contextBefore, hunk.contextBefore) &&
+ sameContent(contextAfter, hunk.contextAfter)
+ ? { from, to }
+ : null;
+}
+
+function withStatuses(
+ proposal: WorkspaceProposal,
+ hunkIds: Set,
+ status: ProposalHunkStatus,
+): WorkspaceProposal {
+ return {
+ ...proposal,
+ hunks: proposal.hunks.map((hunk) =>
+ hunkIds.has(hunk.id) ? { ...hunk, status } : hunk,
+ ),
+ };
+}
+
+export class StaleProposalError extends Error {
+ constructor(
+ public readonly hunkIds: string[],
+ public readonly proposal: WorkspaceProposal,
+ ) {
+ super(`Stale proposal hunks: ${hunkIds.join(", ")}`);
+ this.name = "StaleProposalError";
+ }
+}
+
+export function createProposal({
+ id,
+ authorId,
+ documentEpoch,
+ editor,
+ hunks,
+}: {
+ id: string;
+ authorId: string;
+ documentEpoch: number;
+ editor: Editor;
+ hunks: ProposalHunkInput[];
+}): WorkspaceProposal {
+ const document = documentFor(editor);
+ const documentEnd = editor.state.doc.content.size;
+
+ return {
+ id,
+ authorId,
+ documentEpoch,
+ baseStateVector: Y.encodeStateVector(document),
+ hunks: hunks.map((input) => {
+ const { from, to } = rangeFor(input);
+ if (from < 0 || to < from || to > documentEnd) {
+ throw new RangeError(`Invalid proposal range ${from}..${to}`);
+ }
+
+ const replacement =
+ input.kind === "delete" ? [] : normalizedContent(editor, input.content);
+
+ return {
+ id: input.id,
+ kind: input.kind,
+ status: "pending",
+ from: Y.encodeRelativePosition(
+ createMappablePosition(from, editor.state).yRelativePosition,
+ ),
+ to: Y.encodeRelativePosition(
+ createMappablePosition(to, editor.state).yRelativePosition,
+ ),
+ original: editor.state.doc.slice(from, to).content.toJSON(),
+ replacement,
+ contextBefore: editor.state.doc
+ .slice(Math.max(0, from - 1), from)
+ .content.toJSON(),
+ contextAfter: editor.state.doc
+ .slice(to, Math.min(documentEnd, to + 1))
+ .content.toJSON(),
+ };
+ }),
+ };
+}
+
+export function createProposalAfterComposition({
+ compositionState,
+ proposal,
+}: {
+ compositionState: "idle" | "composing" | "settled";
+ proposal: Parameters[0];
+}): WorkspaceProposal | null {
+ return compositionState === "settled" ? createProposal(proposal) : null;
+}
+
+export function acceptProposalHunks({
+ editor,
+ documentEpoch,
+ proposal,
+ hunkIds,
+}: {
+ editor: Editor;
+ documentEpoch: number;
+ proposal: WorkspaceProposal;
+ hunkIds: string[];
+}): WorkspaceProposal {
+ const selectedIds = new Set(hunkIds);
+ if (selectedIds.size !== hunkIds.length) {
+ throw new Error("Duplicate proposal hunk ID");
+ }
+
+ const selected = proposal.hunks.filter((hunk) => selectedIds.has(hunk.id));
+ if (selected.length !== selectedIds.size) throw new Error("Unknown proposal hunk ID");
+ if (selected.some((hunk) => hunk.status !== "pending")) {
+ throw new Error("Only pending proposal hunks can be accepted");
+ }
+
+ const resolved = selected.map((hunk) => ({ hunk, range: resolvedRange(editor, hunk) }));
+ const stale = resolved
+ .filter(({ range }) => range === null)
+ .map(({ hunk }) => hunk.id);
+ if (documentEpoch !== proposal.documentEpoch) stale.push(...selected.map((hunk) => hunk.id));
+
+ const valid = resolved
+ .filter((item): item is { hunk: ProposalHunk; range: { from: number; to: number } } =>
+ item.range !== null,
+ )
+ .sort((left, right) => left.range.from - right.range.from);
+ for (let index = 1; index < valid.length; index += 1) {
+ const previous = valid[index - 1].range;
+ const current = valid[index].range;
+ if (current.from < previous.to ||
+ (current.from === previous.from && current.to === previous.to)) {
+ stale.push(valid[index - 1].hunk.id, valid[index].hunk.id);
+ }
+ }
+ if (stale.length > 0) {
+ const staleIds = [...new Set(stale)];
+ throw new StaleProposalError(
+ staleIds,
+ withStatuses(proposal, new Set(staleIds), "stale"),
+ );
+ }
+
+ const transaction = valid
+ .sort((left, right) => right.range.from - left.range.from)
+ .reduce(
+ (next, { hunk, range }) =>
+ next.replace(
+ range.from,
+ range.to,
+ new Slice(Fragment.fromJSON(editor.schema, hunk.replacement), 0, 0),
+ ),
+ editor.state.tr,
+ )
+ .setMeta("addToHistory", false);
+ editor.view.dispatch(transaction);
+
+ return withStatuses(proposal, selectedIds, "accepted");
+}
+
+export function rejectProposalHunks({
+ proposal,
+ hunkIds,
+}: {
+ proposal: WorkspaceProposal;
+ hunkIds: string[];
+}): WorkspaceProposal {
+ const selectedIds = new Set(hunkIds);
+ if (selectedIds.size !== hunkIds.length) throw new Error("Duplicate proposal hunk ID");
+ const selected = proposal.hunks.filter((hunk) => selectedIds.has(hunk.id));
+ if (selected.length !== selectedIds.size) throw new Error("Unknown proposal hunk ID");
+ if (selected.some((hunk) => hunk.status !== "pending")) {
+ throw new Error("Only pending proposal hunks can be rejected");
+ }
+ return withStatuses(proposal, selectedIds, "rejected");
+}
+
+export function createQuoteAnchor({
+ id,
+ documentEpoch,
+ editor,
+ from,
+ to,
+ quote,
+}: {
+ id: string;
+ documentEpoch: number;
+ editor: Editor;
+ from: number;
+ to: number;
+ quote: string;
+}): QuoteAnchor {
+ if (from < 0 || to <= from || to > editor.state.doc.content.size) {
+ throw new RangeError(`Invalid quote range ${from}..${to}`);
+ }
+ if (editor.state.doc.textBetween(from, to) !== quote) {
+ throw new Error("Quote does not match the anchored text");
+ }
+ return {
+ id,
+ documentEpoch,
+ from: Y.encodeRelativePosition(
+ createMappablePosition(from, editor.state).yRelativePosition,
+ ),
+ to: Y.encodeRelativePosition(
+ createMappablePosition(to, editor.state).yRelativePosition,
+ ),
+ quote,
+ };
+}
+
+export function resolveQuoteAnchor({
+ anchor,
+ documentEpoch,
+ editor,
+}: {
+ anchor: QuoteAnchor;
+ documentEpoch: number;
+ editor: Editor;
+}):
+ | { status: "attached"; from: number; to: number; quote: string }
+ | { status: "detached"; quote: string } {
+ if (anchor.documentEpoch !== documentEpoch) {
+ return { status: "detached", quote: anchor.quote };
+ }
+ const range = resolveAnchors(editor, anchor.from, anchor.to);
+ if (!range || editor.state.doc.textBetween(range.from, range.to) !== anchor.quote) {
+ return { status: "detached", quote: anchor.quote };
+ }
+ return { status: "attached", ...range, quote: anchor.quote };
+}
diff --git a/src/lib/workspace/schema.ts b/src/lib/workspace/schema.ts
index 0ff27a41b..78d5f3859 100644
--- a/src/lib/workspace/schema.ts
+++ b/src/lib/workspace/schema.ts
@@ -1,4 +1,4 @@
-import { getSchema } from "@tiptap/core";
+import { getSchema, Node } from "@tiptap/core";
import { TableKit } from "@tiptap/extension-table";
import StarterKit from "@tiptap/starter-kit";
import { yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap";
@@ -6,11 +6,41 @@ import type * as Y from "yjs";
export const WORKSPACE_CONTENT_FIELD = "content";
+export const InlineCitation = Node.create({
+ name: "citation",
+ group: "inline",
+ inline: true,
+ atom: true,
+ selectable: false,
+ addAttributes() {
+ return {
+ sourceId: { default: null },
+ title: { default: null },
+ locator: { default: null },
+ quote: { default: null },
+ href: { default: null },
+ };
+ },
+ parseHTML() {
+ return [{ tag: "span[data-citation-source-id]" }];
+ },
+ renderHTML({ node }) {
+ return [
+ "span",
+ { "data-citation-source-id": node.attrs.sourceId },
+ node.attrs.locator ? `[${node.attrs.locator}]` : "[citation]",
+ ];
+ },
+ renderText({ node }) {
+ return node.attrs.locator ? `[${node.attrs.locator}]` : "[citation]";
+ },
+});
+
export function workspaceSchemaExtensions() {
- return [StarterKit.configure({ undoRedo: false }), TableKit];
+ return [StarterKit.configure({ undoRedo: false }), TableKit, InlineCitation];
}
-const workspaceSchema = getSchema(workspaceSchemaExtensions());
+export const workspaceSchema = getSchema(workspaceSchemaExtensions());
export function validateWorkspaceDocument(document: Y.Doc) {
for (const name of document.share.keys()) {
diff --git a/src/test/workspace/document-conversion.test.ts b/src/test/workspace/document-conversion.test.ts
new file mode 100644
index 000000000..47648770a
--- /dev/null
+++ b/src/test/workspace/document-conversion.test.ts
@@ -0,0 +1,147 @@
+import type { JSONContent } from "@tiptap/core";
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
+import { describe, expect, it, vi } from "vitest";
+import {
+ fromWorkspaceDocx,
+ fromWorkspaceHtml,
+ fromWorkspaceMarkdown,
+ toWorkspaceDocx,
+ toWorkspaceMarkdown,
+ toWorkspacePdf,
+} from "@/lib/workspace/document-conversion";
+import {
+ STRUCTURED_SPEECH,
+ STRUCTURED_SPEECH_TEXT,
+} from "./fixtures/structured-speech";
+
+const EXPECTED_SEMANTICS = {
+ headings: [{ level: 1, text: "A fair transition" }],
+ bulletListDepths: [0, 1],
+ links: [{ href: "https://example.com/evidence", text: "source" }],
+ table: [
+ ["Metric", "Value"],
+ ["Access", "Equal"],
+ ],
+ citations: [
+ {
+ sourceId: "source-1",
+ title: "Public evidence",
+ locator: "p. 7",
+ quote: "Equal access",
+ href: "https://example.com/evidence",
+ },
+ ],
+};
+
+function nodeText(node: JSONContent): string {
+ if (node.type === "citation") return "";
+ return node.text ?? node.content?.map(nodeText).join("") ?? "";
+}
+
+function documentSemantics(document: JSONContent) {
+ const headings: Array<{ level: number; text: string }> = [];
+ const bulletListDepths: number[] = [];
+ const links: Array<{ href: string; text: string }> = [];
+ const table: string[][] = [];
+ const citations: Array> = [];
+
+ function visit(node: JSONContent, listDepth = 0) {
+ if (node.type === "heading") {
+ headings.push({ level: Number(node.attrs?.level), text: nodeText(node) });
+ }
+ if (node.type === "bulletList") {
+ bulletListDepths.push(listDepth);
+ listDepth += 1;
+ }
+ if (node.type === "text") {
+ const link = node.marks?.find((mark) => mark.type === "link");
+ if (link) links.push({ href: String(link.attrs?.href), text: node.text ?? "" });
+ }
+ if (node.type === "table") {
+ for (const row of node.content ?? []) {
+ table.push((row.content ?? []).map(nodeText));
+ }
+ }
+ if (node.type === "citation") citations.push(node.attrs ?? {});
+ node.content?.forEach((child) => visit(child, listDepth));
+ }
+
+ visit(document);
+ return { headings, bulletListDepths, links, table, citations };
+}
+
+describe("workspace document conversion", () => {
+ it("round-trips supported semantics and multilingual text through Markdown", () => {
+ expect(documentSemantics(STRUCTURED_SPEECH)).toEqual(EXPECTED_SEMANTICS);
+
+ const markdown = toWorkspaceMarkdown(STRUCTURED_SPEECH);
+ const roundTrip = fromWorkspaceMarkdown(markdown);
+
+ expect(documentSemantics(roundTrip)).toEqual(EXPECTED_SEMANTICS);
+ const visibleText = nodeText(roundTrip);
+ for (const text of STRUCTURED_SPEECH_TEXT) {
+ expect(visibleText).toContain(text);
+ }
+ });
+
+ it("round-trips supported semantics and multilingual text through DOCX", async () => {
+ expect(toWorkspaceDocx).toBeTypeOf("function");
+ expect(fromWorkspaceDocx).toBeTypeOf("function");
+
+ const docx = await toWorkspaceDocx(STRUCTURED_SPEECH);
+ expect(Array.from(docx.slice(0, 2))).toEqual([0x50, 0x4b]);
+ const roundTrip = await fromWorkspaceDocx(docx);
+
+ expect(documentSemantics(roundTrip)).toEqual(EXPECTED_SEMANTICS);
+ const visibleText = nodeText(roundTrip);
+ for (const text of STRUCTURED_SPEECH_TEXT) {
+ expect(visibleText).toContain(text);
+ }
+ });
+
+ it("renders selectable multilingual PDF text with intact glyphs", async () => {
+ const fontData = await readFile(resolve(
+ process.cwd(),
+ "node_modules/pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf",
+ ));
+ const bytes = await toWorkspacePdf(STRUCTURED_SPEECH, { fontData });
+ const loadingTask = getDocument({ data: bytes, useWorkerFetch: false });
+ const pdf = await loadingTask.promise;
+ const pages: string[] = [];
+ for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
+ const page = await pdf.getPage(pageNumber);
+ const content = await page.getTextContent();
+ pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" "));
+ page.cleanup();
+ }
+ await loadingTask.destroy();
+
+ const extracted = pages.join(" ").replace(/\s+/g, " ");
+ for (const text of STRUCTURED_SPEECH_TEXT) {
+ expect(extracted).toContain(text);
+ }
+ expect(extracted).toContain("[Public evidence, p. 7]");
+ });
+
+ it("keeps malformed citations, scripts, and unsafe links outside imported schema", () => {
+ const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const imported = fromWorkspaceHtml(`
+ Safe heading
+
+ plain unsafe label
+
+ broken citation label
+
+ `);
+ const serialized = JSON.stringify(imported);
+
+ expect(nodeText(imported)).toContain("plain unsafe label");
+ expect(nodeText(imported)).toContain("broken citation label");
+ expect(serialized).not.toContain("hidden payload");
+ expect(serialized).not.toContain('"type":"link"');
+ expect(serialized).not.toContain('"type":"citation"');
+ warning.mockRestore();
+ });
+});
diff --git a/src/test/workspace/fixtures/structured-speech.ts b/src/test/workspace/fixtures/structured-speech.ts
new file mode 100644
index 000000000..cb13205f6
--- /dev/null
+++ b/src/test/workspace/fixtures/structured-speech.ts
@@ -0,0 +1,152 @@
+import type { JSONContent } from "@tiptap/core";
+
+export const STRUCTURED_SPEECH_TEXT = [
+ "A fair transition",
+ "English opening with café evidence",
+ "Español: la niñez y la acción pública",
+ "Magyar: árvíztűrő tükörfúrógép",
+ "First response",
+ "Nested reply",
+ "Metric",
+ "Value",
+ "Access",
+ "Equal",
+] as const;
+
+export const STRUCTURED_SPEECH: JSONContent = {
+ type: "doc",
+ content: [
+ {
+ type: "heading",
+ attrs: { level: 1 },
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[0] }],
+ },
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: `${STRUCTURED_SPEECH_TEXT[1]} ` },
+ {
+ type: "text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://example.com/evidence",
+ target: "_blank",
+ rel: "noopener noreferrer nofollow",
+ class: null,
+ title: null,
+ },
+ },
+ ],
+ text: "source",
+ },
+ { type: "text", text: ". " },
+ {
+ type: "citation",
+ attrs: {
+ sourceId: "source-1",
+ title: "Public evidence",
+ locator: "p. 7",
+ quote: "Equal access",
+ href: "https://example.com/evidence",
+ },
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[2] }],
+ },
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[3] }],
+ },
+ {
+ type: "bulletList",
+ content: [
+ {
+ type: "listItem",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[4] }],
+ },
+ {
+ type: "bulletList",
+ content: [
+ {
+ type: "listItem",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: STRUCTURED_SPEECH_TEXT[5] },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "table",
+ content: [
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableHeader",
+ attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[6] }],
+ },
+ ],
+ },
+ {
+ type: "tableHeader",
+ attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[7] }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableCell",
+ attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[8] }],
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[9] }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ { type: "paragraph" },
+ ],
+};
diff --git a/src/test/workspace/review.test.ts b/src/test/workspace/review.test.ts
new file mode 100644
index 000000000..a94b07e98
--- /dev/null
+++ b/src/test/workspace/review.test.ts
@@ -0,0 +1,395 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { yUndoPluginKey } from "@tiptap/y-tiptap";
+import * as Y from "yjs";
+import { createWorkspaceEditor } from "@/lib/workspace/editor-schema";
+import * as review from "@/lib/workspace/review";
+import {
+ STRUCTURED_SPEECH,
+ STRUCTURED_SPEECH_TEXT,
+} from "./fixtures/structured-speech";
+
+describe("workspace review model", () => {
+ const cleanup: Array<() => void> = [];
+
+ afterEach(() => {
+ cleanup.splice(0).forEach((dispose) => dispose());
+ });
+
+ function createEditor(document = new Y.Doc()) {
+ const editor = createWorkspaceEditor(document);
+ cleanup.push(() => editor.destroy());
+ if (arguments.length === 0) cleanup.push(() => document.destroy());
+ return editor;
+ }
+
+ function createSyncedEditors() {
+ const leftDocument = new Y.Doc();
+ const rightDocument = new Y.Doc();
+ const fromLeft = Symbol("from-left");
+ const fromRight = Symbol("from-right");
+ const syncLeft = (update: Uint8Array, origin: unknown) => {
+ if (origin !== fromRight) Y.applyUpdate(rightDocument, update, fromLeft);
+ };
+ const syncRight = (update: Uint8Array, origin: unknown) => {
+ if (origin !== fromLeft) Y.applyUpdate(leftDocument, update, fromRight);
+ };
+ leftDocument.on("update", syncLeft);
+ rightDocument.on("update", syncRight);
+ cleanup.push(
+ () => leftDocument.off("update", syncLeft),
+ () => rightDocument.off("update", syncRight),
+ () => leftDocument.destroy(),
+ () => rightDocument.destroy(),
+ );
+ return {
+ left: createEditor(leftDocument),
+ leftDocument,
+ right: createEditor(rightDocument),
+ rightDocument,
+ };
+ }
+
+ it("keeps the representative multilingual document structured", () => {
+ const editor = createEditor();
+
+ expect(editor.commands.setContent(STRUCTURED_SPEECH)).toBe(true);
+
+ expect(editor.getJSON()).toEqual(STRUCTURED_SPEECH);
+ expect(editor.getJSON()).toMatchObject({
+ content: expect.arrayContaining([
+ expect.objectContaining({ type: "heading" }),
+ expect.objectContaining({ type: "bulletList" }),
+ expect.objectContaining({ type: "table" }),
+ ]),
+ });
+ expect(JSON.stringify(editor.getJSON())).toContain('"type":"citation"');
+ for (const text of STRUCTURED_SPEECH_TEXT) {
+ expect(editor.getText()).toContain(text);
+ }
+ });
+
+ it("keeps suggested insert, delete, and formatting outside accepted content", async () => {
+ const editor = createEditor();
+ editor.commands.setContent("Hello world
");
+ const acceptedBefore = editor.getJSON();
+ const composed = " tűrő".normalize("NFC");
+
+ const proposal = review.createProposal({
+ id: "proposal-1",
+ authorId: "member-1",
+ documentEpoch: 1,
+ editor,
+ hunks: [
+ { id: "insert", kind: "insert", at: 6, content: [{ type: "text", text: composed }] },
+ { id: "delete", kind: "delete", from: 7, to: 12 },
+ {
+ id: "format",
+ kind: "format",
+ from: 1,
+ to: 6,
+ content: [
+ { type: "text", text: "Hello", marks: [{ type: "bold" }] },
+ ],
+ },
+ ],
+ });
+
+ expect(editor.getJSON()).toEqual(acceptedBefore);
+ expect(proposal.hunks.map((hunk) => [hunk.kind, hunk.status])).toEqual([
+ ["insert", "pending"],
+ ["delete", "pending"],
+ ["format", "pending"],
+ ]);
+ expect(proposal.hunks[0].replacement).toEqual([
+ { type: "text", text: composed },
+ ]);
+ expect(proposal.hunks[2].replacement).toEqual([
+ { type: "text", text: "Hello", marks: [{ type: "bold" }] },
+ ]);
+ expect(proposal.hunks[1].original).toEqual([
+ { type: "text", text: "world" },
+ ]);
+ });
+
+ it("accepts one independent hunk and keeps teammate text outside local undo", async () => {
+ expect(review.acceptProposalHunks).toBeTypeOf("function");
+ expect(review.rejectProposalHunks).toBeTypeOf("function");
+
+ const { left, right } = createSyncedEditors();
+ left.commands.setContent("Alpha Beta Gamma
");
+ expect(right.getJSON()).toEqual(left.getJSON());
+
+ const proposal = review.createProposal({
+ id: "proposal-2",
+ authorId: "member-1",
+ documentEpoch: 1,
+ editor: left,
+ hunks: [
+ {
+ id: "format-beta",
+ kind: "format",
+ from: 7,
+ to: 11,
+ content: [
+ { type: "text", text: "Beta", marks: [{ type: "bold" }] },
+ ],
+ },
+ { id: "delete-gamma", kind: "delete", from: 12, to: 17 },
+ ],
+ });
+
+ yUndoPluginKey.getState(left.state).undoManager.clear();
+ yUndoPluginKey.getState(right.state).undoManager.clear();
+ right.commands.insertContentAt(1, "Remote ");
+ left.commands.insertContentAt(left.state.doc.content.size - 1, " Local");
+
+ const accepted = review.acceptProposalHunks({
+ editor: left,
+ documentEpoch: 1,
+ proposal,
+ hunkIds: ["format-beta"],
+ });
+ const reviewed = review.rejectProposalHunks({
+ proposal: accepted,
+ hunkIds: ["delete-gamma"],
+ });
+
+ expect(reviewed.hunks.map((hunk) => [hunk.id, hunk.status])).toEqual([
+ ["format-beta", "accepted"],
+ ["delete-gamma", "rejected"],
+ ]);
+ expect(left.getText()).toBe("Remote Alpha Beta Gamma Local");
+ expect(right.getJSON()).toEqual(left.getJSON());
+ expect(left.getJSON()).toEqual(
+ expect.objectContaining({
+ content: expect.arrayContaining([
+ expect.objectContaining({
+ content: expect.arrayContaining([
+ expect.objectContaining({
+ text: "Beta",
+ marks: [{ type: "bold" }],
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ );
+
+ expect(left.commands.undo()).toBe(true);
+ expect(left.getText()).toBe("Remote Alpha Beta Gamma");
+ expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"');
+ expect(left.commands.redo()).toBe(true);
+ expect(left.getText()).toBe("Remote Alpha Beta Gamma Local");
+ expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"');
+ });
+
+ it("stales a remotely deleted and reinserted identical target", () => {
+ const { left, right, rightDocument } = createSyncedEditors();
+ left.commands.setContent("Alpha Beta Gamma
");
+ const proposal = review.createProposal({
+ id: "proposal-identity",
+ authorId: "member-1",
+ documentEpoch: 1,
+ editor: left,
+ hunks: [
+ {
+ id: "format-beta",
+ kind: "format",
+ from: 7,
+ to: 11,
+ content: [
+ { type: "text", text: "Beta", marks: [{ type: "bold" }] },
+ ],
+ },
+ ],
+ });
+
+ right.commands.insertContentAt(1, "Remote ");
+ const paragraph = rightDocument.getXmlFragment("content").get(0);
+ if (!(paragraph instanceof Y.XmlElement)) throw new Error("Expected paragraph element");
+ const text = paragraph.get(0);
+ if (!(text instanceof Y.XmlText)) throw new Error("Expected paragraph text");
+ rightDocument.transact(() => {
+ text.delete(13, 4);
+ text.insert(13, "Beta");
+ });
+ const beforeAcceptance = left.getJSON();
+
+ let stale: unknown;
+ try {
+ review.acceptProposalHunks({
+ editor: left,
+ documentEpoch: 1,
+ proposal,
+ hunkIds: ["format-beta"],
+ });
+ } catch (error) {
+ stale = error;
+ }
+
+ expect(stale).toBeInstanceOf(review.StaleProposalError);
+ if (!(stale instanceof review.StaleProposalError)) throw stale;
+ expect(stale.proposal.hunks[0].status).toBe("stale");
+ expect(left.getJSON()).toEqual(beforeAcceptance);
+ expect(right.getJSON()).toEqual(beforeAcceptance);
+ expect(left.getText()).toBe("Remote Alpha Beta Gamma");
+ expect(JSON.stringify(left.getJSON())).not.toContain('"type":"bold"');
+ });
+
+ it("stales a remotely overlapped target and preserves unrelated remote text", () => {
+ const { left, right } = createSyncedEditors();
+ left.commands.setContent("Alpha Beta Gamma
");
+ const proposal = review.createProposal({
+ id: "proposal-overlap",
+ authorId: "member-1",
+ documentEpoch: 1,
+ editor: left,
+ hunks: [
+ {
+ id: "format-beta",
+ kind: "format",
+ from: 7,
+ to: 11,
+ content: [
+ { type: "text", text: "Beta", marks: [{ type: "bold" }] },
+ ],
+ },
+ ],
+ });
+
+ right.commands.insertContentAt(1, "Remote ");
+ right.commands.insertContentAt(16, "X");
+ const beforeAcceptance = left.getJSON();
+
+ let stale: unknown;
+ try {
+ review.acceptProposalHunks({
+ editor: left,
+ documentEpoch: 1,
+ proposal,
+ hunkIds: ["format-beta"],
+ });
+ } catch (error) {
+ stale = error;
+ }
+
+ expect(stale).toBeInstanceOf(review.StaleProposalError);
+ if (!(stale instanceof review.StaleProposalError)) throw stale;
+ expect(stale.proposal.hunks[0].status).toBe("stale");
+ expect(left.getJSON()).toEqual(beforeAcceptance);
+ expect(right.getJSON()).toEqual(beforeAcceptance);
+ expect(left.getText()).toBe("Remote Alpha BeXta Gamma");
+ expect(JSON.stringify(left.getJSON())).not.toContain('"type":"bold"');
+ });
+
+ it("marks changed and overlapping exact targets stale without fuzzy replacement", async () => {
+ const changed = createEditor();
+ changed.commands.setContent("alpha beta alpha
");
+ const changedProposal = review.createProposal({
+ id: "proposal-3",
+ authorId: "member-1",
+ documentEpoch: 1,
+ editor: changed,
+ hunks: [
+ {
+ id: "changed-target",
+ kind: "format",
+ from: 7,
+ to: 11,
+ content: [
+ { type: "text", text: "beta", marks: [{ type: "bold" }] },
+ ],
+ },
+ ],
+ });
+ changed.commands.insertContentAt({ from: 7, to: 11 }, "alpha");
+
+ let changedError: unknown;
+ try {
+ review.acceptProposalHunks({
+ editor: changed,
+ documentEpoch: 1,
+ proposal: changedProposal,
+ hunkIds: ["changed-target"],
+ });
+ } catch (error) {
+ changedError = error;
+ }
+ expect(changedError).toBeInstanceOf(review.StaleProposalError);
+ if (!(changedError instanceof review.StaleProposalError)) throw changedError;
+ expect(changedError.proposal.hunks[0].status).toBe("stale");
+ expect(changed.getText()).toBe("alpha alpha alpha");
+ expect(JSON.stringify(changed.getJSON())).not.toContain('"type":"bold"');
+
+ const overlapping = createEditor();
+ overlapping.commands.setContent("abcdefghij
");
+ const overlappingProposal = review.createProposal({
+ id: "proposal-4",
+ authorId: "member-1",
+ documentEpoch: 1,
+ editor: overlapping,
+ hunks: [
+ {
+ id: "left-range",
+ kind: "format",
+ from: 2,
+ to: 6,
+ content: [{ type: "text", text: "bcde", marks: [{ type: "bold" }] }],
+ },
+ { id: "right-range", kind: "delete", from: 4, to: 8 },
+ ],
+ });
+
+ let overlapError: unknown;
+ try {
+ review.acceptProposalHunks({
+ editor: overlapping,
+ documentEpoch: 1,
+ proposal: overlappingProposal,
+ hunkIds: ["left-range", "right-range"],
+ });
+ } catch (error) {
+ overlapError = error;
+ }
+ expect(overlapError).toBeInstanceOf(review.StaleProposalError);
+ if (!(overlapError instanceof review.StaleProposalError)) throw overlapError;
+ expect(overlapError.proposal.hunks.map((hunk) => hunk.status)).toEqual([
+ "stale",
+ "stale",
+ ]);
+ expect(overlapping.getText()).toBe("abcdefghij");
+ });
+
+ it("keeps quote anchors through unrelated insertion and detaches on target deletion", async () => {
+ expect(review.createQuoteAnchor).toBeTypeOf("function");
+ expect(review.resolveQuoteAnchor).toBeTypeOf("function");
+
+ const editor = createEditor();
+ editor.commands.setContent("Claim target context
");
+ const anchor = review.createQuoteAnchor({
+ id: "comment-1",
+ documentEpoch: 1,
+ editor,
+ from: 7,
+ to: 13,
+ quote: "target",
+ });
+
+ editor.commands.insertContentAt(1, "Unrelated ");
+ const moved = review.resolveQuoteAnchor({
+ anchor,
+ documentEpoch: 1,
+ editor,
+ });
+ expect(moved).toMatchObject({ status: "attached", quote: "target" });
+ if (moved.status !== "attached") return;
+ expect(editor.state.doc.textBetween(moved.from, moved.to)).toBe("target");
+
+ editor.commands.deleteRange({ from: moved.from, to: moved.to });
+ expect(review.resolveQuoteAnchor({
+ anchor,
+ documentEpoch: 1,
+ editor,
+ })).toEqual({ status: "detached", quote: "target" });
+ });
+});