paperclip/packages/shared/src/document-anchors.test.ts

184 lines
7.2 KiB
TypeScript
Raw Normal View History

[codex] Add document annotations and comments (#6733) ## Thinking Path > - Paperclip orchestrates AI-agent companies through issues, documents, runs, and durable company-scoped state. > - Issue documents are where agents and operators capture plans, handoffs, and work products. > - Before this change, document collaboration could only happen through whole-document edits and detached issue comments. > - Inline document annotations need stable anchors, revision-aware persistence, and UI affordances that do not break existing document editing. > - This pull request adds company-scoped document annotation threads, comments, anchor snapshots, API routes, and board UI. > - The benefit is that operators and agents can discuss specific document passages without losing context as documents evolve. ## What Changed - Added document annotation tables, schema exports, shared types, validators, anchor hashing, and text-anchor helpers. - Added server-side document annotation services and issue routes for listing, creating, commenting, resolving, and reopening annotation threads. - Included annotation summaries in relevant issue document reads and backup/recovery document workspace behavior. - Added React UI for inline document highlights, comment panels, mobile sheet behavior, deep-link focus, and resolved/open filtering. - Added annotation design artifacts, Storybook coverage, screenshots, and a screenshot helper script. - Rebased the branch onto current `paperclipai/paperclip` `master` and renumbered the annotation migration from `0085_old_swarm` to `0091_old_swarm`; the SQL uses `IF NOT EXISTS` guards so environments that previously applied the old migration number can safely apply the new one. - Adjusted the new annotation UI tests to use a local async flush helper because this workspace's React 19.2.4 export does not expose `React.act`. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/document-anchors.test.ts server/src/__tests__/document-annotation-routes.test.ts server/src/__tests__/document-annotations-service.test.ts ui/src/components/DocumentAnnotationLayer.test.tsx ui/src/components/IssueDocumentAnnotations.test.tsx ui/src/lib/document-annotation-hash.test.ts ui/src/lib/document-annotation-selection.test.ts` - Confirmed `git diff --check` passes. - Confirmed no `pnpm-lock.yaml` or `.github/workflows/*` files are included in the PR diff. ## Risks - Medium risk: this adds new persisted annotation tables and routes across db/shared/server/ui. - Migration risk is reduced by moving the branch migration to `0091_old_swarm` after upstream `0090_resource_memberships` and keeping the SQL idempotent for old `0085_old_swarm` adopters. - UI risk is mostly around text range anchoring and panel positioning across long documents, folded content, and mobile layouts; the PR includes focused unit coverage and design screenshots. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-using software engineering mode. Context window size is not exposed in this Paperclip runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-26 08:41:23 -05:00
import { describe, expect, it } from "vitest";
import {
createDocumentAnchorSelector,
projectMarkdownToText,
remapDocumentAnchor,
resolveProjectionRange,
verifyDocumentAnchorSelector,
} from "./document-anchors.js";
function selectorFor(markdown: string, quote: string) {
const projection = projectMarkdownToText(markdown);
const start = projection.text.indexOf(quote);
expect(start).toBeGreaterThanOrEqual(0);
const range = resolveProjectionRange(projection, start, start + quote.length);
expect(range).not.toBeNull();
return createDocumentAnchorSelector(projection, range!);
}
describe("document text projection", () => {
it("projects markdown into normalized rendered text with source ranges", () => {
const markdown = [
"# Heading",
"",
"- Ship **bold** [link text](https://example.com) and `code span`.",
"| Name | Value |",
"| --- | --- |",
"| Alpha | Beta |",
].join("\n");
const projection = projectMarkdownToText(markdown);
expect(projection.text).toContain("Heading");
expect(projection.text).toContain("Ship bold link text and code span.");
expect(projection.text).toContain("Name Value");
expect(projection.text).toContain("Alpha Beta");
expect(projection.text).not.toContain("https://example.com");
expect(projection.positions).toHaveLength(projection.text.length);
const linkStart = projection.text.indexOf("link text");
const range = resolveProjectionRange(projection, linkStart, linkStart + "link text".length);
expect(range?.markdownStart).toBe(markdown.indexOf("link text"));
expect(range?.markdownEnd).toBe(markdown.indexOf("link text") + "link text".length);
});
it("normalizes whitespace while retaining markdown offsets", () => {
const markdown = "First line\n\nSecond\t\tline";
const projection = projectMarkdownToText(markdown);
expect(projection.text).toBe("First line Second line");
const range = resolveProjectionRange(projection, projection.text.indexOf("Second"), projection.text.length);
expect(range?.markdownStart).toBe(markdown.indexOf("Second"));
expect(range?.markdownEnd).toBe(markdown.length);
});
it("preserves non-link punctuation", () => {
const markdown = "Keep (parenthetical) [plain brackets] visible.";
const projection = projectMarkdownToText(markdown);
expect(projection.text).toBe("Keep (parenthetical) [plain brackets] visible.");
});
});
describe("document anchor verification and remapping", () => {
it("verifies a selector against its base revision", () => {
const markdown = "Intro text with **selected text** inside.";
const selector = selectorFor(markdown, "selected text");
const result = verifyDocumentAnchorSelector({ markdown, selector });
expect(result.ok).toBe(true);
expect(result.anchor?.selectedText).toBe("selected text");
expect(result.anchor?.markdownStart).toBe(markdown.indexOf("selected text"));
});
it("remaps exact anchors after surrounding text moves", () => {
const selector = selectorFor("Alpha paragraph.\n\nTarget sentence here.\n\nOmega paragraph.", "Target sentence here.");
const previousAnchor = {
selectedText: selector.quote.exact,
prefixText: selector.quote.prefix,
suffixText: selector.quote.suffix,
normalizedStart: selector.position.normalizedStart,
normalizedEnd: selector.position.normalizedEnd,
markdownStart: selector.position.markdownStart,
markdownEnd: selector.position.markdownEnd,
};
const result = remapDocumentAnchor({
previousAnchor,
nextMarkdown: "Omega paragraph.\n\nAlpha paragraph.\n\nTarget sentence here.",
});
expect(result.anchorState).toBe("active");
expect(result.confidence).toBe("exact");
expect(result.anchor?.selectedText).toBe("Target sentence here.");
});
it("uses context and proximity to disambiguate duplicate quotes", () => {
const selector = selectorFor("One apple near the start.\n\nTwo apple near the end.", "apple");
const previousAnchor = {
selectedText: selector.quote.exact,
prefixText: selector.quote.prefix,
suffixText: selector.quote.suffix,
normalizedStart: selector.position.normalizedStart,
normalizedEnd: selector.position.normalizedEnd,
markdownStart: selector.position.markdownStart,
markdownEnd: selector.position.markdownEnd,
};
const result = remapDocumentAnchor({
previousAnchor,
nextMarkdown: "Zero apple elsewhere.\n\nOne apple near the start.\n\nTwo apple near the end.",
});
expect(result.anchorState).toBe("active");
expect(result.confidence).toBe("duplicate");
expect(result.anchor?.prefixText).toContain("One");
});
it("marks duplicate anchors ambiguous when context cannot distinguish them", () => {
const selector = selectorFor("apple apple", "apple");
const previousAnchor = {
selectedText: selector.quote.exact,
prefixText: "",
suffixText: "",
normalizedStart: selector.position.normalizedStart,
normalizedEnd: selector.position.normalizedEnd,
markdownStart: selector.position.markdownStart,
markdownEnd: selector.position.markdownEnd,
};
const result = remapDocumentAnchor({ previousAnchor, nextMarkdown: "apple apple" });
expect(result.anchorState).toBe("stale");
expect(result.confidence).toBe("ambiguous");
});
it("keeps edited anchors as stale fuzzy matches", () => {
const selector = selectorFor("We rely on an important launch assumption for scope.", "important launch assumption");
const previousAnchor = {
selectedText: selector.quote.exact,
prefixText: selector.quote.prefix,
suffixText: selector.quote.suffix,
normalizedStart: selector.position.normalizedStart,
normalizedEnd: selector.position.normalizedEnd,
markdownStart: selector.position.markdownStart,
markdownEnd: selector.position.markdownEnd,
};
const result = remapDocumentAnchor({
previousAnchor,
nextMarkdown: "We rely on an important product launch assumption for scope.",
});
expect(result.anchorState).toBe("stale");
expect(result.confidence).toBe("fuzzy");
expect(result.anchor?.selectedText).toBe("important product launch assumption");
});
it("marks deleted anchors orphaned and allows future remapping from the latest known anchor", () => {
const selector = selectorFor("Keep this reviewed phrase in mind.", "reviewed phrase");
const previousAnchor = {
selectedText: selector.quote.exact,
prefixText: selector.quote.prefix,
suffixText: selector.quote.suffix,
normalizedStart: selector.position.normalizedStart,
normalizedEnd: selector.position.normalizedEnd,
markdownStart: selector.position.markdownStart,
markdownEnd: selector.position.markdownEnd,
};
const missing = remapDocumentAnchor({ previousAnchor, nextMarkdown: "The target disappeared." });
const recovered = remapDocumentAnchor({
previousAnchor,
nextMarkdown: "The target came back: reviewed phrase.",
});
expect(missing.anchorState).toBe("orphaned");
expect(missing.confidence).toBe("missing");
expect(missing.anchor).toBeNull();
expect(recovered.anchorState).toBe("active");
expect(recovered.anchor?.selectedText).toBe("reviewed phrase");
});
});