b7545823be
## 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>
184 lines
7.2 KiB
TypeScript
184 lines
7.2 KiB
TypeScript
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");
|
|
});
|
|
});
|