mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## 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>
201 lines
7.2 KiB
TypeScript
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;
|
|
});
|
|
});
|