mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The issue detail view is the main place where people read work and guide agents. > - The chat-style task view needs clear messages, controls, properties, and document feedback. > - Dense metadata and disconnected controls make active work harder to scan. > - This pull request refines the existing chat-style task workflow across desktop and mobile layouts. > - The benefit is a clearer issue thread with faster access to the controls that guide work. ## Linked Issues or Issue Description **What existing behavior does this improve?** The change improves the chat-style issue detail view that was introduced in [#10606](https://github.com/paperclipai/paperclip/pull/10606) and expanded in [#10707](https://github.com/paperclipai/paperclip/pull/10707). **Current behavior** The issue thread spreads task controls across the page. Agent-turn metadata competes with the message content. Document annotation comments use inline placement that limits the document reading area. The mobile composer can overlap the bottom navigation. **Proposed behavior** The issue view keeps the thread focused on message content. It moves supporting controls into the properties area, adds searchable assignment, restores the sub-task tree, docks document comments in a side gutter, and keeps the mobile composer clear of navigation. **Reason and benefit** People can scan active work faster and find task controls without leaving the issue. The layout also gives documents and mobile conversations more usable space. **Breaking changes** None. The change updates presentation and interaction behavior in the existing issue UI. ## What Changed - Refined task-chat message spacing, metadata, agent bubbles, and composer alignment. - Added searchable assignment and restored sub-task navigation in the properties pane. - Moved document annotation comments into a right-side gutter. - Kept the mobile composer above the auto-hiding bottom navigation. - Added and updated focused component tests for the changed interactions. ## Verification - `pnpm check:token-gates` - `TZ=UTC pnpm --filter @paperclipai/ui exec vitest run src/components/InlineEntitySelector.test.tsx src/components/IssueDocumentAnnotations.test.tsx src/components/IssueProperties.test.tsx src/components/TaskChatThread.test.tsx src/pages/IssueDetail.test.tsx` - The token gates report 3/3 clean. - The focused test run passes 124 tests in 5 files. - Visual snapshot baselines were not updated. This follows the `doc/design/DECISION-SHEET.md` entry "Per-change snapshot verification demoted to dormant (Jul 13 2026)." ## Risks - The changes affect several related issue-detail layouts. A browser review should cover desktop and mobile widths before merge. - The monitor-row test formats time in the host timezone. The verification command sets `TZ=UTC` to match CI. > 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 with GPT-5. The context-window size is not exposed in this environment. The model used reasoning, repository tools, code execution, and GitHub tools. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
1032 lines
36 KiB
TypeScript
1032 lines
36 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { useState } from "react";
|
|
import { createRoot as createReactRoot } 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(() => {
|
|
const api = {
|
|
list: vi.fn(),
|
|
listForTarget: vi.fn(),
|
|
get: vi.fn(),
|
|
getForTarget: vi.fn(),
|
|
create: vi.fn(),
|
|
createForTarget: vi.fn(),
|
|
addComment: vi.fn(),
|
|
addCommentForTarget: vi.fn(),
|
|
updateStatus: vi.fn(),
|
|
updateStatusForTarget: vi.fn(),
|
|
};
|
|
api.listForTarget.mockImplementation((target, options) =>
|
|
target.kind === "issue"
|
|
? api.list(target.issueId, target.documentKey, options)
|
|
: target.kind === "case"
|
|
? api.list(target.caseId, target.documentKey, options)
|
|
: api.list(target.routineId, target.documentKey, options));
|
|
api.getForTarget.mockImplementation((target, threadId) =>
|
|
target.kind === "issue"
|
|
? api.get(target.issueId, target.documentKey, threadId)
|
|
: target.kind === "case"
|
|
? api.get(target.caseId, target.documentKey, threadId)
|
|
: api.get(target.routineId, target.documentKey, threadId));
|
|
api.createForTarget.mockImplementation((target, data) =>
|
|
target.kind === "issue"
|
|
? api.create(target.issueId, target.documentKey, data)
|
|
: target.kind === "case"
|
|
? api.create(target.caseId, target.documentKey, data)
|
|
: api.create(target.routineId, target.documentKey, data));
|
|
api.addCommentForTarget.mockImplementation((target, threadId, data) =>
|
|
target.kind === "issue"
|
|
? api.addComment(target.issueId, target.documentKey, threadId, data)
|
|
: target.kind === "case"
|
|
? api.addComment(target.caseId, target.documentKey, threadId, data)
|
|
: api.addComment(target.routineId, target.documentKey, threadId, data));
|
|
api.updateStatusForTarget.mockImplementation((target, threadId, status) =>
|
|
target.kind === "issue"
|
|
? api.updateStatus(target.issueId, target.documentKey, threadId, status)
|
|
: target.kind === "case"
|
|
? api.updateStatus(target.caseId, target.documentKey, threadId, status)
|
|
: api.updateStatus(target.routineId, target.documentKey, threadId, status));
|
|
return api;
|
|
});
|
|
|
|
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, rect: { top: number; left: number; width: number; height: number }) => void;
|
|
onThreadFocus: (threadId: string, rect: { top: number; left: number; width: number; height: number }) => void;
|
|
}) => (
|
|
<>
|
|
<button
|
|
type="button"
|
|
data-testid="mock-annotation-selection"
|
|
disabled={props.newCommentDisabled}
|
|
onClick={() => {
|
|
props.onPendingAnchorChange(mockPendingAnchor);
|
|
props.onRequestComment(mockPendingAnchor, { top: 24, left: 32, width: 80, height: 18 });
|
|
props.onPendingAnchorChange(null);
|
|
}}
|
|
>
|
|
Mock selection
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="mock-annotation-thread"
|
|
onClick={() => props.onThreadFocus("thread-1", { top: 42, left: 48, width: 90, height: 18 })}
|
|
>
|
|
Mock thread
|
|
</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));
|
|
}
|
|
|
|
// Track every root so afterEach can unmount it. Tests mount into a throwaway
|
|
// container but never unmount, so without this the panel's window scroll/resize
|
|
// listeners and react-query subscriptions from earlier tests stay live and can
|
|
// recompute positioning against a detached host — an order-dependent flake that
|
|
// only surfaced under CI's fuller suite run.
|
|
const activeRoots: Array<ReturnType<typeof createReactRoot>> = [];
|
|
function createRoot(node: Parameters<typeof createReactRoot>[0]) {
|
|
const root = createReactRoot(node);
|
|
activeRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
async function unmountActiveRoots() {
|
|
if (activeRoots.length === 0) return;
|
|
const roots = activeRoots.splice(0);
|
|
await act(() => {
|
|
for (const root of roots) root.unmount();
|
|
});
|
|
}
|
|
|
|
async function flush() {
|
|
await act(() => {});
|
|
}
|
|
|
|
// Poll an assertion across React flushes until it passes or times out. The panel
|
|
// positions itself and loads threads through effects + react-query, so a fixed
|
|
// number of flushes can race on a loaded machine (CI). Waiting on the assertion
|
|
// itself is deterministic regardless of how many turns the settle takes.
|
|
async function waitFor(assertion: () => void, { timeout = 2000 }: { timeout?: number } = {}) {
|
|
const start = Date.now();
|
|
for (;;) {
|
|
try {
|
|
assertion();
|
|
return;
|
|
} catch (error) {
|
|
if (Date.now() - start > timeout) throw error;
|
|
await flush();
|
|
}
|
|
}
|
|
}
|
|
|
|
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 dispatchSubmitShortcut(textarea: HTMLTextAreaElement) {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, 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,
|
|
panelPlacement,
|
|
}: {
|
|
doc: IssueDocument;
|
|
draftDirty?: boolean;
|
|
draftConflicted?: boolean;
|
|
historicalPreview?: boolean;
|
|
locationHash?: string;
|
|
initialPanelOpen?: boolean;
|
|
panelPlacement?: "floating" | "inline" | "popover";
|
|
}) {
|
|
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}
|
|
panelPlacement={panelPlacement}
|
|
>
|
|
<p>Body content</p>
|
|
</IssueDocumentAnnotations>
|
|
</>
|
|
);
|
|
}
|
|
|
|
describe("IssueDocumentAnnotations", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await unmountActiveRoots();
|
|
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();
|
|
// The desktop panel docks into an in-flow gutter column beside the doc; it no
|
|
// longer floats over the viewport with position: fixed (PAP-504).
|
|
expect(anchor?.className).not.toContain("fixed");
|
|
expect(anchor?.className).toContain("lg:block");
|
|
expect(anchor?.querySelector(".sticky")).not.toBeNull();
|
|
});
|
|
|
|
it("stacks an inline panel below the document instead of floating over its host", 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} initialPanelOpen panelPlacement="inline" />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const inlinePanel = container.querySelector('[data-testid="document-annotation-panel-inline"]');
|
|
const floatingAnchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]');
|
|
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
|
expect(inlinePanel).not.toBeNull();
|
|
expect(floatingAnchor).toBeNull();
|
|
expect(panel?.className).toContain("w-full");
|
|
});
|
|
|
|
it("opens anchored compose and thread popovers in popover placement", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={makeDoc()} panelPlacement="popover" />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await waitFor(() => expect(container.querySelector('[data-testid="mock-annotation-selection"]')).not.toBeNull());
|
|
await act(async () => (container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement).click());
|
|
await waitFor(() => {
|
|
expect(container.querySelector('[data-testid="document-annotation-popover"]')).not.toBeNull();
|
|
expect(container.querySelector('[data-testid="document-annotation-popover-composer"]')).not.toBeNull();
|
|
expect(container.querySelector('[data-testid="document-annotation-panel-inline"]')).toBeNull();
|
|
});
|
|
await act(async () => (container.querySelector('[aria-label="Add annotation comment"] button') as HTMLButtonElement).click());
|
|
await act(async () => (container.querySelector('[data-testid="mock-annotation-thread"]') as HTMLButtonElement).click());
|
|
await waitFor(() => {
|
|
expect(container.querySelector('[data-testid="document-annotation-popover"] [data-thread-id="thread-1"]')).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
it("docks the desktop annotation panel into an in-flow gutter column beside the document", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<main id="main-content">
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</main>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
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();
|
|
// Gutter column: fixed width, in normal flow (no absolute/fixed positioning),
|
|
// and its host row is a flex layout so the doc sits to its left.
|
|
expect(anchor!.style.width).toBe("360px");
|
|
expect(anchor!.className).not.toContain("fixed");
|
|
expect(anchor!.style.left).toBe("");
|
|
const host = container.querySelector(".paperclip-doc-annotation-host") as HTMLElement | null;
|
|
expect(host!.className).toContain("lg:flex");
|
|
// The panel is sticky inside the gutter so it stays beside the doc while scrolling.
|
|
expect(anchor!.querySelector(".sticky")).not.toBeNull();
|
|
expect(panel!.style.width).toBe("360px");
|
|
});
|
|
});
|
|
|
|
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 waitFor(() => {
|
|
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 waitFor(() => {
|
|
const reason = container.querySelector(
|
|
'[data-testid="document-annotation-disabled-reason"]',
|
|
);
|
|
expect(reason).not.toBeNull();
|
|
expect(reason!.textContent).toMatch(/draft/i);
|
|
});
|
|
});
|
|
|
|
it("shows open and resolved threads together in a single list (no filter tabs)", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([
|
|
makeThread({ id: "open-1" }),
|
|
makeThread({ id: "resolved-1", status: "resolved" }),
|
|
makeThread({ id: "orphan-1", anchorState: "orphaned" }),
|
|
]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
// Open + resolved both render without any filter interaction.
|
|
await waitFor(() => {
|
|
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
|
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
|
});
|
|
// Orphaned threads can't be anchored in the doc, so they stay hidden.
|
|
expect(container.querySelector('[data-thread-id="orphan-1"]')).toBeNull();
|
|
|
|
// The Open/Resolved/Stale/Orphaned filter chips are gone.
|
|
const filterChip = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
["Open", "Resolved", "Stale", "Orphaned"].includes((button.textContent ?? "").trim()),
|
|
);
|
|
expect(filterChip).toBeUndefined();
|
|
});
|
|
|
|
it("orders threads by document position, not API/recency order", async () => {
|
|
// Returned out of document order: later-in-doc first, earlier-in-doc last.
|
|
mockAnnotationsApi.list.mockResolvedValue([
|
|
makeThread({ id: "thread-late", normalizedStart: 900, markdownStart: 900 }),
|
|
makeThread({ id: "thread-early", normalizedStart: 10, markdownStart: 10 }),
|
|
makeThread({ id: "thread-mid", normalizedStart: 400, markdownStart: 400 }),
|
|
]);
|
|
const root = createRoot(container);
|
|
const queryClient = makeQueryClient();
|
|
const doc = makeDoc();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness doc={doc} initialPanelOpen />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
const order = Array.from(container.querySelectorAll("[data-thread-id]"))
|
|
.map((el) => el.getAttribute("data-thread-id"));
|
|
expect(order).toEqual(["thread-early", "thread-mid", "thread-late"]);
|
|
});
|
|
});
|
|
|
|
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>,
|
|
);
|
|
});
|
|
// Click the open thread to expand it.
|
|
await waitFor(() => {
|
|
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
|
});
|
|
const threadCard = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
|
await act(async () => threadCard!.click());
|
|
await flush();
|
|
|
|
const expandedThread = container.querySelector('[data-thread-id="open-1"]');
|
|
const expandedText = expandedThread?.textContent ?? "";
|
|
expect(expandedText).toContain("Dotta");
|
|
expect(expandedText).not.toContain("· board");
|
|
expect(expandedText).toContain("UXDesigner");
|
|
expect(expandedText).toContain("· agent");
|
|
// Each rendered comment shows an author avatar.
|
|
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"]') ?? [];
|
|
expect(avatars.length).toBe(2);
|
|
});
|
|
|
|
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("keeps the composer visible with the draft when creating a thread fails", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([]);
|
|
mockAnnotationsApi.create.mockRejectedValue(new Error("Annotation anchor does not match the current document revision"));
|
|
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 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();
|
|
|
|
const composerAfterFailure = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
|
expect(composerAfterFailure).not.toBeNull();
|
|
expect(composerAfterFailure!.value).toBe("New anchored comment");
|
|
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
|
.toContain("Annotation anchor does not match the current document revision");
|
|
});
|
|
|
|
it("submits a new anchored comment with ⌘↵", 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();
|
|
|
|
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
|
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!, "Submitted via shortcut"));
|
|
await flush();
|
|
await act(async () => dispatchSubmitShortcut(composer!));
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", {
|
|
baseRevisionId: "rev-4",
|
|
baseRevisionNumber: 4,
|
|
selector: mockPendingAnchor.selector,
|
|
body: "Submitted via shortcut",
|
|
});
|
|
});
|
|
|
|
it("submits a reply with ⌘↵", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
|
|
mockAnnotationsApi.addComment.mockResolvedValue(makeThread({ id: "open-1" }).comments[0]);
|
|
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;
|
|
await act(async () => openThread!.click());
|
|
await flush();
|
|
|
|
const reply = container.querySelector(
|
|
'[data-testid="document-annotation-reply-open-1"]',
|
|
) as HTMLTextAreaElement | null;
|
|
expect(reply).not.toBeNull();
|
|
await act(async () => setTextareaValue(reply!, "Replying via shortcut"));
|
|
await flush();
|
|
await act(async () => dispatchSubmitShortcut(reply!));
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(mockAnnotationsApi.addComment).toHaveBeenCalledWith("issue-1", "plan", "open-1", {
|
|
body: "Replying via shortcut",
|
|
});
|
|
});
|
|
|
|
it("keeps a reply draft visible when submitting the reply fails", async () => {
|
|
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
|
|
mockAnnotationsApi.addComment.mockRejectedValue(new Error("Failed to add reply"));
|
|
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 reply = container.querySelector(
|
|
'[data-testid="document-annotation-reply-open-1"]',
|
|
) as HTMLTextAreaElement | null;
|
|
expect(reply).not.toBeNull();
|
|
await act(async () => setTextareaValue(reply!, "Reply should stay visible"));
|
|
await flush();
|
|
|
|
const replyButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent === "Reply",
|
|
);
|
|
expect(replyButton).not.toBeUndefined();
|
|
await act(async () => replyButton!.click());
|
|
await flush();
|
|
await flush();
|
|
|
|
const replyAfterFailure = container.querySelector(
|
|
'[data-testid="document-annotation-reply-open-1"]',
|
|
) as HTMLTextAreaElement | null;
|
|
expect(replyAfterFailure).not.toBeNull();
|
|
expect(replyAfterFailure!.value).toBe("Reply should stay visible");
|
|
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
|
.toContain("Failed to add reply");
|
|
});
|
|
|
|
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");
|
|
|
|
// Resolved threads stay in the same list (filter tabs were removed).
|
|
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");
|
|
expect(sheet?.className).toContain("z-(--z-60)");
|
|
expect(sheet?.className).toContain("bg-popover");
|
|
} finally {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
configurable: true,
|
|
value: originalMatchMedia,
|
|
});
|
|
}
|
|
});
|
|
});
|