Files
PaperClipAI/ui/src/components/DocumentAnnotationLayer.test.tsx
T
DottaandPaperclip b7545823be [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 06:41:23 -07:00

201 lines
7.2 KiB
TypeScript

// @vitest-environment jsdom
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DocumentAnnotationLayer } from "./DocumentAnnotationLayer";
const mockRangesForNormalizedSpan = vi.hoisted(() => vi.fn());
vi.mock("@/lib/document-annotation-selection", () => ({
buildAnchorFromContainerSelection: vi.fn(),
getContainerTextOffset: vi.fn(),
rangesForNormalizedSpan: mockRangesForNormalizedSpan,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
await callback();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function makeRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
right: left + width,
bottom: top + height,
width,
height,
toJSON: () => ({}),
} as DOMRect;
}
function makeRange(rects: DOMRect[], commonAncestorContainer: Node = document.createTextNode("")): Range {
return {
commonAncestorContainer,
getClientRects: () => rects,
} as unknown as Range;
}
describe("DocumentAnnotationLayer", () => {
let container: HTMLDivElement;
let rectSpy: ReturnType<typeof vi.spyOn>;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 12, 80, 18)])]);
rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(makeRect(0, 0, 400, 300));
});
afterEach(async () => {
if (root) {
await act(() => root?.unmount());
root = null;
}
rectSpy.mockRestore();
container.remove();
vi.clearAllMocks();
});
it("uses solid yellow backgrounds for annotation highlights in light and dark themes", async () => {
const body = document.createElement("div");
body.textContent = "Annotated body text.";
root = createRoot(container);
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Annotated body text."
threads={[
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
{ id: "focused", selectedText: "body", status: "open", anchorState: "active" },
{ id: "stale", selectedText: "text", status: "open", anchorState: "stale" },
{ id: "resolved", selectedText: "body text", status: "resolved", anchorState: "active" },
]}
focusedThreadId="focused"
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={vi.fn()}
onRequestComment={vi.fn()}
hideResolved={false}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
const highlights = Array.from(container.querySelectorAll(".paperclip-doc-annotation-highlight"));
expect(highlights).toHaveLength(4);
for (const highlight of highlights) {
const backgroundClasses = Array.from(highlight.classList).filter((className) =>
/^(dark:|hover:|dark:hover:)?bg-yellow-\d+$/.test(className)
|| /^(dark:|hover:|dark:hover:)?bg-yellow-\d+\//.test(className),
);
expect(backgroundClasses.some((className) => className.includes("/"))).toBe(false);
expect(backgroundClasses.some((className) => className.startsWith("bg-yellow-"))).toBe(true);
expect(backgroundClasses.some((className) => className.startsWith("dark:bg-yellow-"))).toBe(true);
}
});
it("does not render highlights for text clipped by folded document content", async () => {
const body = document.createElement("div");
const clippedContent = document.createElement("div");
clippedContent.className = "fold-curtain__content";
const hiddenText = document.createTextNode("Hidden folded text");
clippedContent.appendChild(hiddenText);
body.appendChild(clippedContent);
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 60, 80, 18)], hiddenText)]);
rectSpy.mockImplementation(function (this: HTMLElement) {
if (this === clippedContent) return makeRect(0, 0, 400, 40);
return makeRect(0, 0, 400, 120);
});
root = createRoot(container);
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Hidden folded text"
threads={[
{ id: "hidden", selectedText: "Hidden folded text", status: "open", anchorState: "active" },
]}
focusedThreadId={null}
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={vi.fn()}
onRequestComment={vi.fn()}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull();
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).toBeNull();
});
it("uses native CSS highlights for visual paint when the browser supports them", async () => {
const originalCss = globalThis.CSS;
const originalHighlight = (globalThis as { Highlight?: unknown }).Highlight;
const setHighlight = vi.fn();
const deleteHighlight = vi.fn();
class MockHighlight {
ranges: Range[];
constructor(...ranges: Range[]) {
this.ranges = ranges;
}
}
(globalThis as { CSS?: unknown }).CSS = {
...(originalCss ?? {}),
highlights: {
set: setHighlight,
delete: deleteHighlight,
},
};
(globalThis as { Highlight?: unknown }).Highlight = MockHighlight;
const body = document.createElement("div");
body.textContent = "Annotated body text.";
root = createRoot(container);
await act(async () => {
root?.render(
<DocumentAnnotationLayer
containerRef={{ current: body }}
markdown="Annotated body text."
threads={[
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
]}
focusedThreadId={null}
onThreadFocus={vi.fn()}
pendingAnchor={null}
onPendingAnchorChange={vi.fn()}
onRequestComment={vi.fn()}
/>,
);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull();
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).not.toBeNull();
const openHighlightCall = setHighlight.mock.calls.find(([name]) => name === "paperclip-doc-annotation-open");
expect(openHighlightCall).toBeTruthy();
expect((openHighlightCall?.[1] as MockHighlight).ranges).toHaveLength(1);
await act(async () => root?.unmount());
root = null;
expect(deleteHighlight).toHaveBeenCalledWith("paperclip-doc-annotation-open");
(globalThis as { CSS?: unknown }).CSS = originalCss;
(globalThis as { Highlight?: unknown }).Highlight = originalHighlight;
});
});