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>
723 lines
23 KiB
TypeScript
723 lines
23 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { useState } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type {
|
|
DocumentAnnotationThreadWithComments,
|
|
IssueDocument,
|
|
} from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
DocumentAnnotationsCountChip,
|
|
IssueDocumentAnnotations,
|
|
} from "./IssueDocumentAnnotations";
|
|
|
|
const mockAnnotationsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
get: vi.fn(),
|
|
create: vi.fn(),
|
|
addComment: vi.fn(),
|
|
updateStatus: vi.fn(),
|
|
}));
|
|
|
|
const mockPendingAnchor = vi.hoisted(() => ({
|
|
selector: {
|
|
quote: { exact: "should keep the editor", prefix: "We ", suffix: "." },
|
|
position: { normalizedStart: 10, normalizedEnd: 32, markdownStart: 10, markdownEnd: 32 },
|
|
},
|
|
selectedText: "should keep the editor",
|
|
}));
|
|
|
|
vi.mock("@/api/document-annotations", () => ({
|
|
documentAnnotationsApi: mockAnnotationsApi,
|
|
}));
|
|
|
|
vi.mock("./MarkdownBody", () => ({
|
|
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/sheet", () => ({
|
|
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
|
open ? <div data-slot="sheet">{children}</div> : null,
|
|
SheetContent: ({
|
|
children,
|
|
className,
|
|
side,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
side?: string;
|
|
}) => (
|
|
<div data-slot="sheet-content" data-side={side} className={className}>
|
|
{children}
|
|
</div>
|
|
),
|
|
SheetTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
|
<div data-slot="sheet-title" className={className}>{children}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./DocumentAnnotationLayer", () => ({
|
|
DocumentAnnotationLayer: (props: {
|
|
newCommentDisabled?: boolean;
|
|
onPendingAnchorChange: (anchor: typeof mockPendingAnchor | null) => void;
|
|
onRequestComment: (anchor: typeof mockPendingAnchor) => void;
|
|
}) => (
|
|
<>
|
|
<button
|
|
type="button"
|
|
data-testid="mock-annotation-selection"
|
|
disabled={props.newCommentDisabled}
|
|
onClick={() => {
|
|
props.onPendingAnchorChange(mockPendingAnchor);
|
|
props.onRequestComment(mockPendingAnchor);
|
|
props.onPendingAnchorChange(null);
|
|
}}
|
|
>
|
|
Mock selection
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="mock-annotation-selection-only"
|
|
disabled={props.newCommentDisabled}
|
|
onClick={() => {
|
|
props.onPendingAnchorChange(mockPendingAnchor);
|
|
}}
|
|
>
|
|
Mock captured selection
|
|
</button>
|
|
</>
|
|
),
|
|
}));
|
|
|
|
// 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));
|
|
}
|
|
|
|
async function flush() {
|
|
await act(() => {});
|
|
}
|
|
|
|
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
|
setter?.call(textarea, value);
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
function makeQueryClient() {
|
|
return new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
}
|
|
|
|
function makeDoc(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
|
return {
|
|
id: "doc-1",
|
|
companyId: "co-1",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "# Plan\n\nWe should keep the editor.",
|
|
latestRevisionId: "rev-4",
|
|
latestRevisionNumber: 4,
|
|
createdByAgentId: null,
|
|
createdByUserId: "user-1",
|
|
updatedByAgentId: null,
|
|
updatedByUserId: "user-1",
|
|
lockedAt: null,
|
|
lockedByAgentId: null,
|
|
lockedByUserId: null,
|
|
createdAt: new Date("2026-04-01T00:00:00Z"),
|
|
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeThread(
|
|
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
|
): DocumentAnnotationThreadWithComments {
|
|
const id = overrides.id ?? "thread-1";
|
|
return {
|
|
id,
|
|
companyId: "co-1",
|
|
issueId: "issue-1",
|
|
documentId: "doc-1",
|
|
documentKey: "plan",
|
|
status: "open",
|
|
anchorState: "active",
|
|
anchorConfidence: "exact",
|
|
originalRevisionId: "rev-4",
|
|
originalRevisionNumber: 4,
|
|
currentRevisionId: "rev-4",
|
|
currentRevisionNumber: 4,
|
|
selectedText: "should keep the editor",
|
|
prefixText: "We ",
|
|
suffixText: ".",
|
|
normalizedStart: 0,
|
|
normalizedEnd: 22,
|
|
markdownStart: 0,
|
|
markdownEnd: 22,
|
|
anchorSelector: {
|
|
quote: { exact: "should keep the editor", prefix: "We ", suffix: "." },
|
|
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
|
|
},
|
|
createdByAgentId: null,
|
|
createdByUserId: "user-1",
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: null,
|
|
resolvedAt: null,
|
|
createdAt: new Date("2026-04-01T00:01:00Z"),
|
|
updatedAt: new Date("2026-04-01T00:02:00Z"),
|
|
comments: [
|
|
{
|
|
id: "comment-1",
|
|
companyId: "co-1",
|
|
threadId: id,
|
|
issueId: "issue-1",
|
|
documentId: "doc-1",
|
|
body: "Please clarify this assumption.",
|
|
authorType: "user",
|
|
authorAgentId: null,
|
|
authorUserId: "user-1",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-04-01T00:01:00Z"),
|
|
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function Harness({
|
|
doc,
|
|
draftDirty = false,
|
|
draftConflicted = false,
|
|
historicalPreview = false,
|
|
locationHash = "",
|
|
initialPanelOpen = false,
|
|
}: {
|
|
doc: IssueDocument;
|
|
draftDirty?: boolean;
|
|
draftConflicted?: boolean;
|
|
historicalPreview?: boolean;
|
|
locationHash?: string;
|
|
initialPanelOpen?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(initialPanelOpen);
|
|
return (
|
|
<>
|
|
<DocumentAnnotationsCountChip
|
|
issueId="issue-1"
|
|
docKey={doc.key}
|
|
panelOpen={open}
|
|
onToggle={() => setOpen((current) => !current)}
|
|
/>
|
|
<IssueDocumentAnnotations
|
|
issueId="issue-1"
|
|
doc={doc}
|
|
bodyMarkdown={doc.body}
|
|
draftDirty={draftDirty}
|
|
draftConflicted={draftConflicted}
|
|
historicalPreview={historicalPreview}
|
|
locationHash={locationHash}
|
|
panelOpen={open}
|
|
onPanelOpenChange={setOpen}
|
|
>
|
|
<p>Body content</p>
|
|
</IssueDocumentAnnotations>
|
|
</>
|
|
);
|
|
}
|
|
|
|
describe("IssueDocumentAnnotations", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("renders the open count chip and opens the panel on click", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const chip = container.querySelector('[data-testid="document-annotation-count-plan"]');
|
|
expect(chip).not.toBeNull();
|
|
expect(chip!.textContent).toContain("1");
|
|
expect(mockAnnotationsApi.list).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
(chip as HTMLButtonElement).click();
|
|
});
|
|
await flush();
|
|
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
|
expect(panel).not.toBeNull();
|
|
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]');
|
|
expect(anchor).not.toBeNull();
|
|
expect(anchor?.className).toContain("fixed");
|
|
});
|
|
|
|
it("keeps the desktop annotation panel inside the issue content area when properties are visible", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
|
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
|
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
|
|
x: left,
|
|
y: top,
|
|
left,
|
|
top,
|
|
right,
|
|
bottom,
|
|
width: right - left,
|
|
height: bottom - top,
|
|
toJSON: () => ({}),
|
|
});
|
|
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
|
if (this instanceof HTMLElement && this.id === "main-content") {
|
|
return rectFor(0, 0, 900, 800);
|
|
}
|
|
if (
|
|
this instanceof HTMLElement
|
|
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
|
|
) {
|
|
return rectFor(80, 120, 640, 620);
|
|
}
|
|
return originalGetBoundingClientRect.call(this);
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
try {
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<main id="main-content">
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</main>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
|
|
const panel = container.querySelector('[data-testid="document-annotation-panel"]') as HTMLElement | null;
|
|
expect(anchor).not.toBeNull();
|
|
expect(panel).not.toBeNull();
|
|
expect(anchor!.style.left).toBe("524px");
|
|
expect(anchor!.style.width).toBe("360px");
|
|
expect(panel!.style.width).toBe("360px");
|
|
expect(parseFloat(anchor!.style.left) + parseFloat(anchor!.style.width)).toBeLessThanOrEqual(884);
|
|
} finally {
|
|
rectSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("auto-opens the panel and focuses the thread when deep-linked", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "thread-99" })]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} locationHash="#document-plan&thread=thread-99" />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
|
expect(panel).not.toBeNull();
|
|
const focusedThread = container.querySelector('[data-thread-id="thread-99"][data-focused]');
|
|
expect(focusedThread).not.toBeNull();
|
|
});
|
|
|
|
it("shows a disabled reason in the panel when the draft is dirty", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} draftDirty initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const reason = container.querySelector(
|
|
'[data-testid="document-annotation-disabled-reason"]',
|
|
);
|
|
expect(reason).not.toBeNull();
|
|
expect(reason!.textContent).toMatch(/draft/i);
|
|
});
|
|
|
|
it("filters resolved threads behind their tab", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([
|
|
makeThread({ id: "open-1" }),
|
|
makeThread({ id: "resolved-1", status: "resolved" }),
|
|
]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
// Open filter shows only open
|
|
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
|
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
|
|
|
|
// Switch to Resolved
|
|
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.startsWith("Resolved"),
|
|
);
|
|
expect(resolvedTab).not.toBeUndefined();
|
|
await act(async () => resolvedTab!.click());
|
|
await flush();
|
|
|
|
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
|
});
|
|
|
|
it("renders author name + role from agent and user maps", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([
|
|
makeThread({
|
|
id: "open-1",
|
|
comments: [
|
|
{
|
|
id: "comment-board",
|
|
companyId: "co-1",
|
|
threadId: "open-1",
|
|
issueId: "issue-1",
|
|
documentId: "doc-1",
|
|
body: "From the board.",
|
|
authorType: "user",
|
|
authorAgentId: null,
|
|
authorUserId: "user-1",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-04-01T00:01:00Z"),
|
|
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
|
},
|
|
{
|
|
id: "comment-agent",
|
|
companyId: "co-1",
|
|
threadId: "open-1",
|
|
issueId: "issue-1",
|
|
documentId: "doc-1",
|
|
body: "From the agent.",
|
|
authorType: "agent",
|
|
authorAgentId: "agent-uxdesigner",
|
|
authorUserId: null,
|
|
createdByRunId: "run-1",
|
|
createdAt: new Date("2026-04-01T00:02:00Z"),
|
|
updatedAt: new Date("2026-04-01T00:02:00Z"),
|
|
},
|
|
],
|
|
}),
|
|
]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
const agentMap = new Map([["agent-uxdesigner", { id: "agent-uxdesigner", name: "UXDesigner" }]]);
|
|
const userProfileMap = new Map([["user-1", { label: "Dotta", image: null }]]);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<DocumentAnnotationsCountChip
|
|
issueId="issue-1"
|
|
docKey={doc.key}
|
|
panelOpen
|
|
onToggle={() => {}}
|
|
/>
|
|
<IssueDocumentAnnotations
|
|
issueId="issue-1"
|
|
doc={doc}
|
|
bodyMarkdown={doc.body}
|
|
draftDirty={false}
|
|
draftConflicted={false}
|
|
historicalPreview={false}
|
|
locationHash=""
|
|
panelOpen
|
|
onPanelOpenChange={() => {}}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
>
|
|
<p>Body</p>
|
|
</IssueDocumentAnnotations>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
// Click the open thread to expand it.
|
|
const threadCard = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
|
expect(threadCard).not.toBeNull();
|
|
await act(async () => threadCard!.click());
|
|
await flush();
|
|
|
|
const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? "";
|
|
expect(expandedText).toContain("Dotta");
|
|
expect(expandedText).not.toContain("· board");
|
|
expect(expandedText).toContain("UXDesigner");
|
|
expect(expandedText).toContain("· agent");
|
|
});
|
|
|
|
it("does not render a persistent New comment on selection hint when panel is open", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const cta = container.querySelector('[data-testid="document-annotation-new-comment-cta"]');
|
|
expect(cta).toBeNull();
|
|
expect(container.textContent).not.toMatch(/New comment on selection/i);
|
|
expect(container.textContent).not.toMatch(/⌘⇧M/);
|
|
});
|
|
|
|
it("keeps a captured selection from opening the composer until the layer requests a comment", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const selectOnlyButton = container.querySelector(
|
|
'[data-testid="mock-annotation-selection-only"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(selectOnlyButton).not.toBeNull();
|
|
await act(async () => {
|
|
selectOnlyButton!.click();
|
|
});
|
|
await flush();
|
|
|
|
expect(container.querySelector('[data-testid="document-annotation-composer"]')).toBeNull();
|
|
|
|
expect(container.querySelector('[data-testid="document-annotation-new-comment-cta"]')).toBeNull();
|
|
const directRequestButton = container.querySelector(
|
|
'[data-testid="mock-annotation-selection"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(directRequestButton).not.toBeNull();
|
|
await act(async () => {
|
|
directRequestButton!.click();
|
|
});
|
|
await flush();
|
|
|
|
const composer = container.querySelector(
|
|
'[data-testid="document-annotation-composer"]',
|
|
) as HTMLTextAreaElement | null;
|
|
expect(composer).not.toBeNull();
|
|
expect(container.textContent).toContain(mockPendingAnchor.selectedText);
|
|
});
|
|
|
|
it("creates a thread from a captured selection and refreshes the shared annotations query", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([]);
|
|
mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" }));
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
expect(mockAnnotationsApi.list).toHaveBeenCalledTimes(1);
|
|
|
|
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
|
expect(selectButton).not.toBeNull();
|
|
await act(async () => {
|
|
selectButton!.click();
|
|
});
|
|
await flush();
|
|
|
|
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
|
expect(composer).not.toBeNull();
|
|
await act(async () => {
|
|
setTextareaValue(composer!, "New anchored comment");
|
|
});
|
|
await flush();
|
|
|
|
const submit = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent === "Comment",
|
|
);
|
|
expect(submit).not.toBeUndefined();
|
|
await act(async () => {
|
|
submit!.click();
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", {
|
|
baseRevisionId: "rev-4",
|
|
baseRevisionNumber: 4,
|
|
selector: mockPendingAnchor.selector,
|
|
body: "New anchored comment",
|
|
});
|
|
expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("shows resolve and reopen actions and updates thread status", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([
|
|
makeThread({ id: "open-1" }),
|
|
makeThread({ id: "resolved-1", status: "resolved" }),
|
|
]);
|
|
mockAnnotationsApi.updateStatus.mockResolvedValue(makeThread({ id: "open-1", status: "resolved" }));
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
|
expect(openThread).not.toBeNull();
|
|
await act(async () => openThread!.click());
|
|
await flush();
|
|
|
|
const resolveButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => /\bResolve\b/.test(button.textContent ?? ""),
|
|
);
|
|
expect(resolveButton).not.toBeUndefined();
|
|
await act(async () => resolveButton!.click());
|
|
await flush();
|
|
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "open-1", "resolved");
|
|
|
|
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.startsWith("Resolved"),
|
|
);
|
|
expect(resolvedTab).not.toBeUndefined();
|
|
await act(async () => resolvedTab!.click());
|
|
await flush();
|
|
|
|
const resolvedThread = container.querySelector('[data-thread-id="resolved-1"]') as HTMLElement | null;
|
|
expect(resolvedThread).not.toBeNull();
|
|
await act(async () => resolvedThread!.click());
|
|
await flush();
|
|
|
|
const reopenButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("Reopen"),
|
|
);
|
|
expect(reopenButton).not.toBeUndefined();
|
|
await act(async () => reopenButton!.click());
|
|
await flush();
|
|
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "resolved-1", "open");
|
|
});
|
|
|
|
it("renders the mobile annotation panel through the sheet path", async () => {
|
|
const originalMatchMedia = window.matchMedia;
|
|
Object.defineProperty(window, "matchMedia", {
|
|
configurable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: true,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
try {
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const sheet = container.querySelector('[data-slot="sheet-content"]');
|
|
expect(sheet).not.toBeNull();
|
|
expect(sheet?.getAttribute("data-side")).toBe("bottom");
|
|
expect(sheet?.className).toContain("paperclip-doc-annotation-sheet");
|
|
} finally {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
configurable: true,
|
|
value: originalMatchMedia,
|
|
});
|
|
}
|
|
});
|
|
});
|