[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>
This commit is contained in:
Dotta
2026-05-26 08:41:23 -05:00
committed by GitHub
parent f0ddd24d61
commit b7545823be
55 changed files with 25070 additions and 31 deletions
+59
View File
@@ -0,0 +1,59 @@
import type {
CreateDocumentAnnotationCommentRequest,
CreateDocumentAnnotationThreadRequest,
DocumentAnnotationComment,
DocumentAnnotationThread,
DocumentAnnotationThreadStatus,
DocumentAnnotationThreadWithComments,
UpdateDocumentAnnotationThreadRequest,
} from "@paperclipai/shared";
import { api } from "./client";
export type DocumentAnnotationListFilter = "open" | "resolved" | "all";
export const documentAnnotationsApi = {
list: (
issueId: string,
key: string,
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
) => {
const params = new URLSearchParams();
if (options.status) params.set("status", options.status);
if (options.includeComments) params.set("includeComments", "true");
const qs = params.toString();
return api.get<DocumentAnnotationThreadWithComments[]>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`,
);
},
get: (issueId: string, key: string, threadId: string) =>
api.get<DocumentAnnotationThreadWithComments>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
),
create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) =>
api.post<DocumentAnnotationThreadWithComments>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`,
data,
),
addComment: (
issueId: string,
key: string,
threadId: string,
data: CreateDocumentAnnotationCommentRequest,
) =>
api.post<DocumentAnnotationComment>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`,
data,
),
updateStatus: (
issueId: string,
key: string,
threadId: string,
status: DocumentAnnotationThreadStatus,
) => {
const payload: UpdateDocumentAnnotationThreadRequest = { status };
return api.patch<DocumentAnnotationThread>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
payload,
);
},
};
@@ -0,0 +1,200 @@
// @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;
});
});
@@ -0,0 +1,515 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, MessageSquarePlus } from "lucide-react";
import type {
DocumentAnnotationAnchorState,
DocumentAnnotationThreadStatus,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
buildAnchorFromContainerSelection,
getContainerTextOffset,
rangesForNormalizedSpan,
} from "@/lib/document-annotation-selection";
import type { DocumentAnnotationAnchorSelector } from "@paperclipai/shared";
export interface AnnotationOverlayThread {
id: string;
selectedText: string;
status: DocumentAnnotationThreadStatus;
anchorState: DocumentAnnotationAnchorState;
unreadCount?: number;
}
export interface PendingAnchor {
selector: DocumentAnnotationAnchorSelector;
selectedText: string;
}
export interface AnnotationLayerProps {
containerRef: React.RefObject<HTMLElement | null>;
markdown: string;
threads: AnnotationOverlayThread[];
focusedThreadId: string | null;
onThreadFocus: (threadId: string) => void;
/** Tracks the most recently captured pending selection. */
pendingAnchor: PendingAnchor | null;
onPendingAnchorChange: (anchor: PendingAnchor | null) => void;
onRequestComment: (anchor: PendingAnchor) => void;
/** Disables the "add comment" affordance when set. */
newCommentDisabled?: boolean;
newCommentDisabledReason?: string | null;
/** Hide resolved highlights even when included in the threads list. */
hideResolved?: boolean;
/** Test-only: override window object for layout calculations. */
testWindow?: { innerWidth: number; innerHeight: number };
/**
* When this number changes, re-read the current document selection and emit a
* pending anchor for the keyboard shortcut path.
*/
captureSelectionRequestId?: number;
}
interface HighlightRect {
threadId: string;
status: DocumentAnnotationThreadStatus;
anchorState: DocumentAnnotationAnchorState;
top: number;
left: number;
width: number;
height: number;
/** True for the last rect of this thread (used to anchor a glyph at the run end). */
isTail: boolean;
}
interface ToolbarPosition {
top: number;
left: number;
}
type NativeHighlightKind = "open" | "focused" | "stale" | "resolved";
type NativeHighlightRanges = Record<NativeHighlightKind, Range[]>;
type CssHighlight = object;
type HighlightConstructor = new (...ranges: Range[]) => CssHighlight;
type HighlightRegistry = {
set: (name: string, highlight: CssHighlight) => void;
delete: (name: string) => void;
};
const NATIVE_HIGHLIGHT_NAMES: Record<NativeHighlightKind, string> = {
open: "paperclip-doc-annotation-open",
focused: "paperclip-doc-annotation-focused",
stale: "paperclip-doc-annotation-stale",
resolved: "paperclip-doc-annotation-resolved",
};
const nativeHighlightInstances = new Map<string, NativeHighlightRanges>();
function getNativeHighlightApi(): { registry: HighlightRegistry; HighlightCtor: HighlightConstructor } | null {
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
const HighlightCtor = (globalThis as { Highlight?: HighlightConstructor }).Highlight;
if (!css?.highlights || typeof HighlightCtor !== "function") return null;
return { registry: css.highlights, HighlightCtor };
}
function emptyNativeHighlightRanges(): NativeHighlightRanges {
return {
open: [],
focused: [],
stale: [],
resolved: [],
};
}
function syncNativeHighlights(api = getNativeHighlightApi()) {
if (!api) return;
for (const kind of Object.keys(NATIVE_HIGHLIGHT_NAMES) as NativeHighlightKind[]) {
const ranges = Array.from(nativeHighlightInstances.values()).flatMap((entry) => entry[kind]);
const name = NATIVE_HIGHLIGHT_NAMES[kind];
if (ranges.length === 0) {
api.registry.delete(name);
} else {
api.registry.set(name, new api.HighlightCtor(...ranges));
}
}
}
function setNativeHighlightRanges(instanceId: string, ranges: NativeHighlightRanges) {
if (!getNativeHighlightApi()) return;
nativeHighlightInstances.set(instanceId, ranges);
syncNativeHighlights();
}
function clearNativeHighlightRanges(instanceId: string) {
if (!nativeHighlightInstances.delete(instanceId)) return;
syncNativeHighlights();
}
function elementFromNode(node: Node | null | undefined): HTMLElement | null {
if (!node) return null;
if (node instanceof HTMLElement) return node;
const parent = node.parentElement;
return parent instanceof HTMLElement ? parent : null;
}
function intersectRects(a: DOMRect, b: DOMRect): DOMRect | null {
const left = Math.max(a.left, b.left);
const top = Math.max(a.top, b.top);
const right = Math.min(a.right, b.right);
const bottom = Math.min(a.bottom, b.bottom);
if (right <= left || bottom <= top) return null;
return {
x: left,
y: top,
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
} as DOMRect;
}
function clipsOverflow(element: HTMLElement) {
if (element.classList.contains("fold-curtain__content")) return true;
if (typeof window === "undefined" || typeof window.getComputedStyle !== "function") return false;
const style = window.getComputedStyle(element);
return [style.overflow, style.overflowX, style.overflowY].some((value) =>
value === "hidden" || value === "clip" || value === "auto" || value === "scroll",
);
}
function visibleClipRectForRange(range: Range, container: HTMLElement): DOMRect | null {
let clipRect = container.getBoundingClientRect();
let element = elementFromNode(range.commonAncestorContainer);
while (element) {
if (clipsOverflow(element)) {
const nextClipRect = intersectRects(clipRect, element.getBoundingClientRect());
if (!nextClipRect) return null;
clipRect = nextClipRect;
}
if (element === container) break;
element = element.parentElement;
}
return clipRect;
}
function nativeHighlightKind(input: {
focused: boolean;
stale: boolean;
resolved: boolean;
}): NativeHighlightKind {
if (input.resolved) return "resolved";
if (input.stale) return "stale";
if (input.focused) return "focused";
return "open";
}
export function DocumentAnnotationLayer({
containerRef,
markdown,
threads,
focusedThreadId,
onThreadFocus,
pendingAnchor,
onPendingAnchorChange,
onRequestComment,
newCommentDisabled = false,
newCommentDisabledReason = null,
hideResolved = true,
captureSelectionRequestId,
}: AnnotationLayerProps) {
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const lastCaptureSelectionRequestIdRef = useRef<number>(0);
const reactId = useId();
const nativeHighlightInstanceId = useMemo(
() => `document-annotation-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
[reactId],
);
const nativeHighlightsSupported = getNativeHighlightApi() !== null;
const visibleThreads = useMemo(() => {
if (!hideResolved) return threads;
return threads.filter((thread) => thread.status !== "resolved" || thread.anchorState === "orphaned" || thread.id === focusedThreadId);
}, [threads, hideResolved, focusedThreadId]);
const computeHighlightRects = useCallback(() => {
const container = containerRef.current;
const overlay = overlayRef.current;
if (!container || !overlay) {
clearNativeHighlightRanges(nativeHighlightInstanceId);
setHighlightRects([]);
return;
}
const overlayRect = overlay.getBoundingClientRect();
const next: HighlightRect[] = [];
const nativeRanges = emptyNativeHighlightRanges();
for (const thread of visibleThreads) {
if (thread.anchorState === "orphaned") continue;
const isFocused = thread.id === focusedThreadId;
const isStale = thread.anchorState === "stale";
const isResolved = thread.status === "resolved";
const nativeKind = nativeHighlightKind({
focused: isFocused,
stale: isStale,
resolved: isResolved,
});
const ranges = rangesForNormalizedSpan({
container,
selectedText: thread.selectedText,
});
const startIndex = next.length;
for (const range of ranges) {
const visibleClipRect = visibleClipRectForRange(range, container);
if (!visibleClipRect) continue;
let rangeIsVisible = false;
for (const rect of Array.from(range.getClientRects())) {
if (rect.width === 0 || rect.height === 0) continue;
const visibleRect = intersectRects(rect, visibleClipRect);
if (!visibleRect) continue;
rangeIsVisible = true;
next.push({
threadId: thread.id,
status: thread.status,
anchorState: thread.anchorState,
top: visibleRect.top - overlayRect.top,
left: visibleRect.left - overlayRect.left,
width: visibleRect.width,
height: visibleRect.height,
isTail: false,
});
}
if (rangeIsVisible) nativeRanges[nativeKind].push(range);
}
if (next.length > startIndex) {
next[next.length - 1].isTail = true;
}
}
setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges);
setHighlightRects(next);
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]);
useLayoutEffect(() => {
computeHighlightRects();
}, [computeHighlightRects]);
useEffect(() => () => clearNativeHighlightRanges(nativeHighlightInstanceId), [nativeHighlightInstanceId]);
useEffect(() => {
if (typeof window === "undefined") return;
const container = containerRef.current;
const overlay = overlayRef.current;
let cancelled = false;
let frame: number | null = null;
const schedule = () => {
if (cancelled || frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
if (!cancelled) computeHighlightRects();
});
};
const handleResizeOrScroll = () => schedule();
window.addEventListener("resize", handleResizeOrScroll);
window.addEventListener("scroll", handleResizeOrScroll, true);
const resizeObserver = typeof window.ResizeObserver === "function"
? new window.ResizeObserver(schedule)
: null;
if (resizeObserver && container) resizeObserver.observe(container);
if (resizeObserver && overlay) resizeObserver.observe(overlay);
const mutationObserver = typeof window.MutationObserver === "function" && container
? new window.MutationObserver((mutations) => {
const onlyLayerMutations = mutations.every((mutation) => {
const target = elementFromNode(mutation.target);
return !!target?.closest(".paperclip-doc-annotation-layer, .paperclip-doc-annotation-visual-layer");
});
if (!onlyLayerMutations) schedule();
})
: null;
if (mutationObserver && container) {
mutationObserver.observe(container, {
childList: true,
characterData: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style", "data-state", "open", "hidden", "aria-expanded"],
});
}
schedule();
return () => {
cancelled = true;
if (frame !== null) window.cancelAnimationFrame(frame);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
window.removeEventListener("resize", handleResizeOrScroll);
window.removeEventListener("scroll", handleResizeOrScroll, true);
};
}, [computeHighlightRects, containerRef]);
const captureSelection = useCallback((): PendingAnchor | null => {
const container = containerRef.current;
const overlay = overlayRef.current;
if (!container || !overlay) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return null;
const containerOffset = getContainerTextOffset(container, range);
if (!containerOffset) return null;
const anchor = buildAnchorFromContainerSelection({ markdown, containerOffset });
if (!anchor) return null;
const overlayRect = overlay.getBoundingClientRect();
const rect = range.getBoundingClientRect();
const top = Math.max(0, rect.top - overlayRect.top - 36);
const left = Math.max(0, rect.left - overlayRect.left + rect.width / 2 - 80);
setToolbarPosition({ top, left });
return {
selector: anchor.selector,
selectedText: containerOffset.selectedText,
};
}, [containerRef, markdown]);
useEffect(() => {
if (typeof document === "undefined") return;
const handleSelectionChange = () => {
const anchor = captureSelection();
if (!anchor) {
onPendingAnchorChange(null);
setToolbarPosition(null);
return;
}
onPendingAnchorChange(anchor);
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => document.removeEventListener("selectionchange", handleSelectionChange);
}, [captureSelection, onPendingAnchorChange]);
useEffect(() => {
if (captureSelectionRequestId === undefined) return;
if (captureSelectionRequestId === 0) return;
if (lastCaptureSelectionRequestIdRef.current === captureSelectionRequestId) return;
lastCaptureSelectionRequestIdRef.current = captureSelectionRequestId;
const anchor = captureSelection();
if (anchor) {
onPendingAnchorChange(anchor);
onRequestComment(anchor);
}
}, [captureSelectionRequestId, captureSelection, onPendingAnchorChange, onRequestComment]);
const handleAddComment = () => {
if (pendingAnchor) onRequestComment(pendingAnchor);
};
return (
<>
{!nativeHighlightsSupported ? (
<div className="paperclip-doc-annotation-visual-layer pointer-events-none absolute inset-0 z-0" aria-hidden="true">
<div className="relative h-full w-full">
{highlightRects.map((rect, index) => {
const isFocused = rect.threadId === focusedThreadId;
const isStale = rect.anchorState === "stale";
const isResolved = rect.status === "resolved";
return (
<span
key={`visual-${rect.threadId}-${index}`}
data-thread-id={rect.threadId}
data-anchor-state={rect.anchorState}
data-status={rect.status}
data-focused={isFocused || undefined}
className={cn(
"paperclip-doc-annotation-highlight absolute rounded-none transition-colors",
// base box treatment (replaces the previous baseline border)
isResolved
? "bg-yellow-100 outline outline-1 outline-dashed outline-offset-0 outline-yellow-700/45 dark:bg-yellow-700 dark:outline-yellow-200/45"
: isStale
? "bg-yellow-200 outline outline-2 outline-dashed outline-offset-0 outline-yellow-700/65 dark:bg-yellow-600 dark:outline-yellow-200/70"
: isFocused
? "bg-yellow-300 outline outline-2 outline-offset-0 outline-yellow-700/85 shadow-[0_0_0_1px_var(--color-background)] dark:bg-yellow-500 dark:outline-yellow-200/85"
: "bg-yellow-200 dark:bg-yellow-600",
)}
style={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}}
/>
);
})}
</div>
</div>
) : null}
<div
className="paperclip-doc-annotation-layer pointer-events-none absolute inset-0 z-[2]"
aria-hidden="true"
>
<div ref={overlayRef} className="relative h-full w-full">
{highlightRects.map((rect, index) => {
const isFocused = rect.threadId === focusedThreadId;
return (
<button
key={`${rect.threadId}-${index}`}
type="button"
data-thread-id={rect.threadId}
data-anchor-state={rect.anchorState}
data-status={rect.status}
data-focused={isFocused || undefined}
aria-label="Open annotation thread"
className={cn(
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent",
isFocused && "ring-1 ring-transparent",
)}
style={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}}
onMouseDown={(event) => {
event.preventDefault();
onThreadFocus(rect.threadId);
}}
/>
);
})}
{highlightRects.map((rect, index) =>
rect.isTail && rect.anchorState === "stale" ? (
<span
key={`tail-${rect.threadId}-${index}`}
aria-hidden="true"
data-thread-id={rect.threadId}
className="paperclip-doc-annotation-tail pointer-events-none absolute inline-flex items-center justify-center rounded-sm bg-amber-500/95 text-amber-50 shadow-sm dark:bg-amber-500/90 dark:text-amber-50"
style={{
top: rect.top + Math.max(0, rect.height / 2 - 8),
left: rect.left + rect.width + 2,
width: 16,
height: 16,
}}
title="Anchor moved — needs review"
>
<AlertTriangle className="h-3 w-3" />
</span>
) : null,
)}
{pendingAnchor && toolbarPosition ? (
<div
data-testid="document-annotation-selection-toolbar"
role="toolbar"
aria-label="Selection actions"
className="paperclip-doc-annotation-selection-toolbar pointer-events-auto absolute z-10 flex items-center gap-1 rounded-md border border-border bg-popover px-1 py-1 shadow-md"
style={{ top: toolbarPosition.top, left: toolbarPosition.left }}
onMouseDown={(event) => event.preventDefault()}
>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={handleAddComment}
disabled={newCommentDisabled}
title={newCommentDisabled
? newCommentDisabledReason ?? undefined
: "Add comment on selection (⌘⇧M)"}
>
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
Comment
</Button>
</div>
) : null}
</div>
</div>
</>
);
}
@@ -0,0 +1,574 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type {
DocumentAnnotationComment,
DocumentAnnotationThreadStatus,
DocumentAnnotationThreadWithComments,
} from "@paperclipai/shared";
import {
Check,
Copy,
MoreHorizontal,
RotateCcw,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { cn, relativeTime } from "@/lib/utils";
import { documentAnnotationsApi } from "@/api/document-annotations";
import { MarkdownBody } from "./MarkdownBody";
import type { PendingAnchor } from "./DocumentAnnotationLayer";
import type { Agent } from "@paperclipai/shared";
import type { CompanyUserProfile } from "@/lib/company-members";
type AnnotationFilter = "open" | "resolved" | "stale" | "orphan";
const FILTERS: { id: AnnotationFilter; label: string }[] = [
{ id: "open", label: "Open" },
{ id: "resolved", label: "Resolved" },
{ id: "stale", label: "Stale" },
{ id: "orphan", label: "Orphaned" },
];
export interface AnnotationPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
issueId: string;
documentKey: string;
documentRevisionNumber: number;
baseRevisionId: string | null;
baseRevisionNumber: number;
threads: DocumentAnnotationThreadWithComments[];
focusedThreadId: string | null;
onFocusThread: (threadId: string | null) => void;
focusedCommentId: string | null;
/** External pending anchor captured from the layer for the composer. */
pendingAnchor: PendingAnchor | null;
onClearPendingAnchor: () => void;
/** Request the body layer to start a comment from the current text selection (⌘⇧M). */
onRequestCommentFromSelection?: () => void;
newCommentDisabled?: boolean;
newCommentDisabledReason?: string | null;
/** When mobile is true, render via shadcn Sheet at the bottom instead of side panel. */
isMobile?: boolean;
/** Desktop panel width calculated by the document frame. */
desktopWidth?: number;
className?: string;
/** Resolve `<authorAgentId>` to a display name. */
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
/** Resolve `<authorUserId>` to a display name. */
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}
export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
if (props.isMobile) {
return (
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
<SheetContent
side="bottom"
showCloseButton={false}
className="paperclip-doc-annotation-sheet flex max-h-[88vh] flex-col rounded-none border-t border-border bg-background p-0"
>
<SheetTitle className="sr-only">
Comments on {props.documentKey} revision {props.documentRevisionNumber}
</SheetTitle>
<div className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted-foreground/30" aria-hidden="true" />
<AnnotationPanelBody {...props} />
</SheetContent>
</Sheet>
);
}
if (!props.open) return null;
return (
<aside
role="complementary"
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
data-testid="document-annotation-panel"
className={cn(
"flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-card shadow-md",
props.className,
)}
style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined}
>
<AnnotationPanelBody {...props} />
</aside>
);
}
function AnnotationPanelBody(props: AnnotationPanelProps) {
const queryClient = useQueryClient();
const [filter, setFilter] = useState<AnnotationFilter>("open");
const [composerValue, setComposerValue] = useState("");
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
const filteredThreads = useMemo(() => {
return props.threads.filter((thread) => {
if (filter === "open") return thread.status === "open" && thread.anchorState !== "orphaned";
if (filter === "resolved") return thread.status === "resolved";
if (filter === "stale") return thread.anchorState === "stale";
if (filter === "orphan") return thread.anchorState === "orphaned";
return true;
});
}, [props.threads, filter]);
const counts = useMemo(() => {
const result = { open: 0, resolved: 0, stale: 0, orphan: 0 };
for (const thread of props.threads) {
if (thread.status === "resolved") result.resolved += 1;
if (thread.anchorState === "stale") result.stale += 1;
if (thread.anchorState === "orphaned") result.orphan += 1;
if (thread.status === "open" && thread.anchorState !== "orphaned") result.open += 1;
}
return result;
}, [props.threads]);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey)
&& query.queryKey[0] === "issues"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === props.issueId
&& query.queryKey[3] === props.documentKey,
});
}, [props.documentKey, props.issueId, queryClient]);
const createThread = useMutation({
mutationFn: async (body: string) => {
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
return documentAnnotationsApi.create(props.issueId, props.documentKey, {
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
selector: props.pendingAnchor.selector,
body,
});
},
onSuccess: (thread) => {
props.onClearPendingAnchor();
setComposerValue("");
props.onFocusThread(thread.id);
invalidateAll();
},
});
const addReply = useMutation({
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }),
onSuccess: (_data, variables) => {
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
invalidateAll();
},
});
const updateStatus = useMutation({
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
onSuccess: () => invalidateAll(),
});
useEffect(() => {
if (!props.open) {
setComposerValue("");
}
}, [props.open]);
useEffect(() => {
if (props.pendingAnchor && props.open) {
composerRef.current?.focus();
}
}, [props.open, props.pendingAnchor]);
useEffect(() => {
if (!props.focusedThreadId) return;
const focused = props.threads.find((thread) => thread.id === props.focusedThreadId);
if (!focused) return;
if (focused.anchorState === "orphaned") setFilter("orphan");
else if (focused.anchorState === "stale") setFilter("stale");
else if (focused.status === "resolved") setFilter("resolved");
else setFilter("open");
}, [props.focusedThreadId, props.threads]);
return (
<>
<header
data-testid={bodyTestId}
className="flex items-start justify-between gap-2 border-b border-border px-3 py-2.5"
>
<div className="min-w-0 leading-tight">
<p className="text-sm font-medium">Comments</p>
<p className="text-[11px] text-muted-foreground">
rev {props.documentRevisionNumber}
</p>
</div>
<Button
type="button"
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={() => {
props.onFocusThread(null);
props.onOpenChange(false);
}}
aria-label="Close annotation panel"
>
<X className="h-4 w-4" />
</Button>
</header>
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
{FILTERS.map((entry) => {
const count = counts[entry.id];
const isActive = filter === entry.id;
return (
<button
key={entry.id}
type="button"
onClick={() => setFilter(entry.id)}
data-active={isActive || undefined}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
isActive
? "border-border bg-muted text-foreground"
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
>
<span>{entry.label}</span>
<span className={cn("tabular-nums", isActive ? "text-muted-foreground" : "text-muted-foreground/70")}>
{count}
</span>
</button>
);
})}
</div>
{props.newCommentDisabled && props.newCommentDisabledReason ? (
<p
data-testid="document-annotation-disabled-reason"
className="border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
>
{props.newCommentDisabledReason}
</p>
) : null}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
{filteredThreads.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
</p>
) : (
<ul className="space-y-2">
{filteredThreads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
expanded={thread.id === props.focusedThreadId}
focusedCommentId={
thread.id === props.focusedThreadId ? props.focusedCommentId : null
}
onFocus={() => props.onFocusThread(thread.id)}
replyDraft={replyDrafts[thread.id] ?? ""}
onReplyChange={(value) =>
setReplyDrafts((current) => ({ ...current, [thread.id]: value }))
}
onSubmitReply={() => {
const body = (replyDrafts[thread.id] ?? "").trim();
if (!body) return;
addReply.mutate({ threadId: thread.id, body });
}}
onResolveToggle={() =>
updateStatus.mutate({
threadId: thread.id,
status: thread.status === "resolved" ? "open" : "resolved",
})
}
onCopyLink={() => copyAnnotationLink(props.documentKey, thread.id)}
pendingReply={addReply.isPending && addReply.variables?.threadId === thread.id}
pendingStatus={updateStatus.isPending && updateStatus.variables?.threadId === thread.id}
agentMap={props.agentMap}
userProfileMap={props.userProfileMap}
/>
))}
</ul>
)}
</div>
{props.pendingAnchor ? (
<div className="border-t border-border bg-muted/20 px-3 py-2">
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-background px-2 py-1 text-xs italic text-muted-foreground">
{truncate(props.pendingAnchor.selectedText, 160)}
</blockquote>
<Textarea
ref={composerRef}
data-testid="document-annotation-composer"
rows={3}
value={composerValue}
onChange={(event) => setComposerValue(event.target.value)}
placeholder="Write a comment…"
disabled={props.newCommentDisabled}
className="resize-y rounded-none text-sm"
/>
{createThread.isError ? (
<p className="mt-1 text-xs text-destructive">
{(createThread.error as Error).message || "Failed to create comment"}
</p>
) : null}
<div className="mt-2 flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
props.onClearPendingAnchor();
setComposerValue("");
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={
createThread.isPending
|| !composerValue.trim()
|| props.newCommentDisabled
|| !props.baseRevisionId
}
onClick={() => createThread.mutate(composerValue.trim())}
>
{createThread.isPending ? "Posting…" : "Comment"}
</Button>
</div>
</div>
) : null}
</>
);
}
function ThreadCard(props: {
thread: DocumentAnnotationThreadWithComments;
expanded: boolean;
focusedCommentId: string | null;
onFocus: () => void;
replyDraft: string;
onReplyChange: (value: string) => void;
onSubmitReply: () => void;
onResolveToggle: () => void;
onCopyLink: () => void;
pendingReply: boolean;
pendingStatus: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const { thread } = props;
const statusVariant: { variant: "default" | "outline" | "secondary"; label: string } =
thread.status === "resolved"
? { variant: "outline", label: "Resolved" }
: thread.anchorState === "orphaned"
? { variant: "outline", label: "Orphaned" }
: thread.anchorState === "stale"
? { variant: "outline", label: "Stale" }
: { variant: "default", label: "Open" };
const latestComment = thread.comments[thread.comments.length - 1];
return (
<li>
<article
role="article"
data-thread-id={thread.id}
data-anchor-state={thread.anchorState}
data-status={thread.status}
data-focused={props.expanded || undefined}
aria-labelledby={`thread-quote-${thread.id}`}
className={cn(
"rounded-none border border-border bg-card transition-colors",
props.expanded && "ring-1 ring-ring/70",
thread.status === "resolved" && "bg-muted/30",
)}
tabIndex={0}
onClick={props.onFocus}
>
<div className="flex items-center justify-between gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
<Badge variant={statusVariant.variant} className="px-1.5 py-0 text-[10px] uppercase tracking-[0.12em]">
{statusVariant.label}
</Badge>
<span>{relativeTime(thread.updatedAt)}</span>
</div>
<blockquote
id={`thread-quote-${thread.id}`}
className={cn(
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted/40 px-2 py-1 text-xs italic text-muted-foreground",
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted/30",
)}
>
{truncate(thread.selectedText, 120)}
</blockquote>
{props.expanded ? (
<div className="space-y-2 px-3 py-2">
{thread.comments.map((comment) => (
<CommentRow
key={comment.id}
comment={comment}
focused={props.focusedCommentId === comment.id}
agentMap={props.agentMap}
userProfileMap={props.userProfileMap}
/>
))}
<Textarea
data-testid={`document-annotation-reply-${thread.id}`}
rows={2}
value={props.replyDraft}
onChange={(event) => props.onReplyChange(event.target.value)}
placeholder="Reply…"
className="resize-y rounded-none text-sm"
disabled={props.pendingReply}
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={props.onResolveToggle}
disabled={props.pendingStatus}
className="gap-1"
>
{thread.status === "resolved" ? (
<>
<RotateCcw className="h-3 w-3" /> Reopen
</>
) : (
<>
<Check className="h-3 w-3" /> Resolve
</>
)}
</Button>
<Button
type="button"
size="sm"
disabled={!props.replyDraft.trim() || props.pendingReply}
onClick={props.onSubmitReply}
>
{props.pendingReply ? "Sending…" : "Reply"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
title="More actions"
aria-label="More thread actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
props.onCopyLink();
}}
>
<Copy className="h-3.5 w-3.5" />
Copy link
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
) : (
<p className="px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{thread.comments.length} comment{thread.comments.length === 1 ? "" : "s"}
</span>
{latestComment ? <span className="ml-1">· {truncate(latestComment.body, 120)}</span> : null}
</p>
)}
</article>
</li>
);
}
function CommentRow({
comment,
focused,
agentMap,
userProfileMap,
}: {
comment: DocumentAnnotationComment;
focused: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const author = resolveAuthor(comment, { agentMap, userProfileMap });
return (
<div
id={`comment-${comment.id}`}
data-focused={focused || undefined}
className={cn(
"rounded-none border border-border bg-background px-2 py-1.5",
focused && "ring-2 ring-primary/40",
)}
>
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">{author.name}</span>
{author.role === "agent" ? (
<span className="ml-1 text-muted-foreground">· agent</span>
) : null}
</span>
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
</div>
);
}
function resolveAuthor(
comment: DocumentAnnotationComment,
maps: {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
},
): { name: string; role: "board" | "agent" } {
if (comment.authorAgentId) {
const agent = maps.agentMap?.get(comment.authorAgentId);
return {
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
role: "agent",
};
}
if (comment.authorUserId) {
const profile = maps.userProfileMap?.get(comment.authorUserId);
return {
name: profile?.label ?? comment.authorUserId.slice(0, 8),
role: "board",
};
}
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
}
function truncate(value: string, limit: number) {
if (value.length <= limit) return value;
return `${value.slice(0, limit - 1)}`;
}
async function copyAnnotationLink(documentKey: string, threadId: string) {
if (typeof window === "undefined" || !navigator.clipboard) return;
const { pathname } = window.location;
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
try {
await navigator.clipboard.writeText(`${window.location.origin}${pathname}${hash}`);
} catch {
/* swallow */
}
}
@@ -0,0 +1,722 @@
// @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,
});
}
});
});
@@ -0,0 +1,382 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { documentAnnotationsApi } from "@/api/document-annotations";
import { queryKeys } from "@/lib/queryKeys";
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel";
import type { CompanyUserProfile } from "@/lib/company-members";
const DESKTOP_ANNOTATION_PANEL_WIDTH = 360;
const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
const DESKTOP_ANNOTATION_PANEL_GAP = 12;
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
export interface IssueDocumentAnnotationsProps {
issueId: string;
doc: IssueDocument;
/** The body that is being rendered/edited (current or historical revision). */
bodyMarkdown: string;
/** True when a draft has unsaved changes or is currently saving. */
draftDirty: boolean;
/** True when there is a remote conflict that requires user resolution. */
draftConflicted: boolean;
/** True when the document is being viewed in historical revision preview. */
historicalPreview: boolean;
/** Render the document body (rendered MarkdownBody or MarkdownEditor) inside the wrapper. */
children: ReactNode;
/** Current location hash so we can resolve deep-link targets. */
locationHash: string;
/** Controlled panel state. Caller owns this so the count chip can live in the doc header. */
panelOpen: boolean;
onPanelOpenChange: (open: boolean) => void;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
defaultFocusedThreadId?: string;
}
export function IssueDocumentAnnotations({
issueId,
doc,
bodyMarkdown,
draftDirty,
draftConflicted,
historicalPreview,
children,
locationHash,
panelOpen,
onPanelOpenChange,
agentMap,
userProfileMap,
defaultFocusedThreadId,
}: IssueDocumentAnnotationsProps) {
const containerRef = useRef<HTMLElement | null>(null);
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(defaultFocusedThreadId ?? null);
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
const [selectionAnchor, setSelectionAnchor] = useState<PendingAnchor | null>(null);
const [composerAnchor, setComposerAnchor] = useState<PendingAnchor | null>(null);
const [isMobile, setIsMobile] = useState(false);
const [desktopPanelFrame, setDesktopPanelFrame] = useState<{
left: number;
top: number;
maxHeight: number;
width: number;
} | null>(null);
const hashHandledRef = useRef<string | null>(null);
// Bus token to ask the body layer to capture the current selection into a pendingAnchor.
const [captureSelectionRequestId, setCaptureSelectionRequestId] = useState(0);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mediaQuery = window.matchMedia("(max-width: 1023px)");
const handler = () => setIsMobile(mediaQuery.matches);
handler();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}
return undefined;
}, []);
useEffect(() => {
if (!panelOpen || isMobile || typeof window === "undefined") {
setDesktopPanelFrame(null);
return;
}
const updatePanelFrame = () => {
const container = containerRef.current;
const rect = container?.getBoundingClientRect();
if (!container || !rect) {
setDesktopPanelFrame(null);
return;
}
const boundaryRect = container.closest("main")?.getBoundingClientRect();
const boundaryLeft = boundaryRect?.left ?? 0;
const boundaryRight = boundaryRect?.right ?? window.innerWidth;
const boundaryWidth = Math.max(0, boundaryRight - boundaryLeft);
const maxPanelWidth = Math.max(
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
);
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top);
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
const spaceRightOfDocument = boundaryRight
- desiredLeft
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
const width = spaceRightOfDocument >= DESKTOP_ANNOTATION_PANEL_MIN_WIDTH
? Math.min(desiredWidth, spaceRightOfDocument)
: desiredWidth;
const maxVisibleLeft = boundaryRight
- width
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
setDesktopPanelFrame({
left: Math.max(
boundaryLeft + DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
Math.min(desiredLeft, maxVisibleLeft),
),
top,
width,
maxHeight: Math.max(240, window.innerHeight - top - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN),
});
};
updatePanelFrame();
window.addEventListener("resize", updatePanelFrame);
window.addEventListener("scroll", updatePanelFrame, true);
const resizeObserver = typeof window.ResizeObserver === "function"
? new window.ResizeObserver(updatePanelFrame)
: null;
const observedContainer = containerRef.current;
if (resizeObserver && observedContainer) {
resizeObserver.observe(observedContainer);
const main = observedContainer.closest("main");
if (main) resizeObserver.observe(main);
}
return () => {
window.removeEventListener("resize", updatePanelFrame);
window.removeEventListener("scroll", updatePanelFrame, true);
resizeObserver?.disconnect();
};
}, [doc.key, isMobile, panelOpen]);
const annotationsQuery = useQuery({
queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
staleTime: 30_000,
});
const allThreads = annotationsQuery.data ?? [];
// Resolve deep link `#document-<key>&thread=...&comment=...` once per change.
useEffect(() => {
if (!locationHash) return;
if (hashHandledRef.current === locationHash) return;
const target = parseDocumentAnnotationHash(locationHash);
if (!target || target.documentKey !== doc.key) return;
if (!target.threadId) return;
hashHandledRef.current = locationHash;
onPanelOpenChange(true);
setFocusedThreadId(target.threadId);
setFocusedCommentId(target.commentId);
}, [doc.key, locationHash, onPanelOpenChange]);
const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId;
const newCommentDisabledReason = historicalPreview
? "New comments are disabled while previewing a historical revision."
: draftConflicted
? "Resolve the document conflict before adding new comments."
: draftDirty
? "Save the draft to anchor new comments."
: !doc.latestRevisionId
? "Document has no saved revision yet."
: null;
const handleSelectionAnchorChange = useCallback((anchor: PendingAnchor | null) => {
setSelectionAnchor(anchor);
}, []);
const handleClearComposerAnchor = useCallback(() => {
setSelectionAnchor(null);
setComposerAnchor(null);
}, []);
const handleRequestComment = useCallback((anchor: PendingAnchor) => {
if (newCommentDisabled) return;
setSelectionAnchor(null);
setComposerAnchor(anchor);
onPanelOpenChange(true);
}, [newCommentDisabled, onPanelOpenChange]);
const handleThreadFocus = useCallback((threadId: string | null) => {
setFocusedThreadId(threadId);
if (threadId) {
onPanelOpenChange(true);
setFocusedCommentId(null);
}
}, [onPanelOpenChange]);
const handleRequestCommentFromSelection = useCallback(() => {
if (newCommentDisabled) return;
if (selectionAnchor) {
handleRequestComment(selectionAnchor);
return;
}
// Trigger the layer to re-read the current selection and emit a pendingAnchor.
setCaptureSelectionRequestId((current) => current + 1);
}, [handleRequestComment, newCommentDisabled, selectionAnchor]);
// ⌘⇧M / Ctrl+Shift+M global shortcut while the panel is open.
useEffect(() => {
if (!panelOpen) return;
if (typeof window === "undefined") return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const isMeta = event.metaKey || event.ctrlKey;
if (!isMeta || !event.shiftKey) return;
if (event.key.toLowerCase() !== "m") return;
event.preventDefault();
handleRequestCommentFromSelection();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [panelOpen, handleRequestCommentFromSelection]);
const focusedThread = useMemo(() => {
if (!focusedThreadId) return null;
return allThreads.find((thread) => thread.id === focusedThreadId) ?? null;
}, [allThreads, focusedThreadId]);
const overlayThreads = useMemo(
() => allThreads.map((thread) => ({
id: thread.id,
selectedText: thread.selectedText,
status: thread.status,
anchorState: thread.anchorState,
})),
[allThreads],
);
const annotationPanel = panelOpen ? (
<DocumentAnnotationPanel
open={panelOpen}
onOpenChange={(open) => {
onPanelOpenChange(open);
if (!open) {
setSelectionAnchor(null);
setComposerAnchor(null);
setFocusedThreadId(null);
setFocusedCommentId(null);
}
}}
issueId={issueId}
documentKey={doc.key}
documentRevisionNumber={doc.latestRevisionNumber}
baseRevisionId={doc.latestRevisionId}
baseRevisionNumber={doc.latestRevisionNumber}
threads={allThreads as DocumentAnnotationThreadWithComments[]}
focusedThreadId={focusedThreadId}
focusedCommentId={focusedCommentId}
onFocusThread={(id) => {
setFocusedThreadId(id);
if (!id) setFocusedCommentId(null);
}}
pendingAnchor={composerAnchor}
onClearPendingAnchor={handleClearComposerAnchor}
onRequestCommentFromSelection={handleRequestCommentFromSelection}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
desktopWidth={desktopPanelFrame?.width}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
) : null;
return (
<div className="paperclip-doc-annotation-host relative">
<section
ref={(element) => {
containerRef.current = element;
}}
className="relative min-w-0"
data-testid={`document-annotation-body-${doc.key}`}
>
<div className="relative z-[1]">
{children}
</div>
{!historicalPreview && doc.latestRevisionId ? (
<DocumentAnnotationLayer
containerRef={containerRef}
markdown={bodyMarkdown}
threads={overlayThreads}
focusedThreadId={focusedThread?.id ?? null}
onThreadFocus={handleThreadFocus}
pendingAnchor={selectionAnchor}
onPendingAnchorChange={handleSelectionAnchorChange}
onRequestComment={handleRequestComment}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
hideResolved
captureSelectionRequestId={captureSelectionRequestId}
/>
) : null}
</section>
{panelOpen && !isMobile && desktopPanelFrame ? (
<div
data-testid="document-annotation-panel-anchor"
className="pointer-events-auto fixed hidden lg:block"
style={{
left: desktopPanelFrame.left,
maxHeight: desktopPanelFrame.maxHeight,
top: desktopPanelFrame.top,
width: desktopPanelFrame.width,
}}
>
{annotationPanel}
</div>
) : null}
{panelOpen && isMobile ? annotationPanel : null}
</div>
);
}
export interface DocumentAnnotationsCountChipProps {
issueId: string;
docKey: string;
panelOpen: boolean;
onToggle: () => void;
}
/**
* Renders the unresolved-count chip for a document. Lives in the document header row
* (next to `rev N ▾`) so it stays visible when the document is folded.
*/
export function DocumentAnnotationsCountChip({
issueId,
docKey,
panelOpen,
onToggle,
}: DocumentAnnotationsCountChipProps) {
const annotationsQuery = useQuery({
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
staleTime: 30_000,
});
const threads = annotationsQuery.data ?? [];
const openCount = useMemo(
() => threads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length,
[threads],
);
return (
<Button
type="button"
size="sm"
variant="ghost"
data-state={panelOpen ? "open" : "closed"}
className={cn(
"h-auto gap-1 rounded-md px-1.5 py-0 text-[11px] font-normal text-muted-foreground hover:text-foreground",
panelOpen && "bg-muted text-foreground",
openCount > 0 && "text-foreground",
)}
onClick={onToggle}
data-testid={`document-annotation-count-${docKey}`}
aria-label={openCount === 0
? `Open comments on ${docKey}`
: `Open ${openCount} unresolved comments on ${docKey}`}
aria-expanded={panelOpen}
>
<MessageSquare className="h-3 w-3" aria-hidden="true" />
<span className="tabular-nums">{openCount}</span>
<span className="hidden sm:inline">
{openCount === 1 ? "comment" : "comments"}
</span>
</Button>
);
}
+106 -27
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
DocumentRevision,
FeedbackDataSharingPreference,
FeedbackVote,
@@ -14,9 +15,11 @@ import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { deriveDocumentRevisionState } from "../lib/document-revisions";
import type { CompanyUserProfile } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
@@ -151,6 +154,11 @@ export function IssueDocumentsSection({
imageUploadHandler,
onVote,
extraActions,
agentMap,
userProfileMap,
defaultAnnotationPanelOpenKeys,
defaultAnnotationFocusedThreadIds,
forceEditDocumentKey,
}: {
issue: Issue;
canDeleteDocuments: boolean;
@@ -166,6 +174,17 @@ export function IssueDocumentsSection({
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
extraActions?: ReactNode;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
/**
* Seed which document annotation panels are open on first render. Mostly useful
* for Storybook / screenshot harnesses; runtime callers usually omit this.
*/
defaultAnnotationPanelOpenKeys?: string[];
/** Per-doc seed for the focused annotation thread id (Storybook-only). */
defaultAnnotationFocusedThreadIds?: Readonly<Record<string, string>>;
/** Force a doc into edit mode on mount (Storybook-only). */
forceEditDocumentKey?: string | null;
}) {
const queryClient = useQueryClient();
const location = useLocation();
@@ -174,6 +193,9 @@ export function IssueDocumentsSection({
const [draft, setDraft] = useState<DraftState | null>(null);
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
() => (defaultAnnotationPanelOpenKeys ?? []),
);
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
@@ -213,8 +235,10 @@ export function IssueDocumentsSection({
predicate: (query) =>
Array.isArray(query.queryKey)
&& query.queryKey[0] === "issues"
&& query.queryKey[1] === "document-revisions"
&& query.queryKey[2] === issue.id,
&& (
(query.queryKey[1] === "document-revisions" && query.queryKey[2] === issue.id)
|| (query.queryKey[1] === "document-annotations" && query.queryKey[2] === issue.id)
),
});
}, [issue.id, queryClient]);
@@ -368,6 +392,17 @@ export function IssueDocumentsSection({
setError(null);
};
const initialEditAppliedRef = useRef(false);
useEffect(() => {
if (!forceEditDocumentKey) return;
if (initialEditAppliedRef.current) return;
const target = (documents ?? []).find((entry) => entry.key === forceEditDocumentKey);
if (!target) return;
initialEditAppliedRef.current = true;
beginEdit(forceEditDocumentKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceEditDocumentKey, documents]);
const cancelDraft = () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
@@ -726,6 +761,24 @@ export function IssueDocumentsSection({
: [...current, key],
);
};
const setAnnotationPanelOpen = useCallback((key: string, nextOpen: boolean) => {
setAnnotationPanelOpenKeys((current) => {
const isOpen = current.includes(key);
if (nextOpen && !isOpen) return [...current, key];
if (!nextOpen && isOpen) return current.filter((entry) => entry !== key);
return current;
});
if (nextOpen) {
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
}
}, []);
const toggleAnnotationPanel = useCallback((key: string) => {
setAnnotationPanelOpenKeys((current) => {
if (current.includes(key)) return current.filter((entry) => entry !== key);
setFoldedDocumentKeys((folded) => folded.filter((entry) => entry !== key));
return [...current, key];
});
}, []);
return (
<div className="space-y-3">
@@ -936,6 +989,14 @@ export function IssueDocumentsSection({
>
updated {relativeTime(displayedUpdatedAt)}
</a>
{!isSystemIssueDocumentKey(doc.key) ? (
<DocumentAnnotationsCountChip
issueId={issue.id}
docKey={doc.key}
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
onToggle={() => toggleAnnotationPanel(doc.key)}
/>
) : null}
</div>
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
</div>
@@ -1153,31 +1214,49 @@ export function IssueDocumentsSection({
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
}`}
>
{isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName)
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
onChange={(body) => {
markDocumentDirty(doc.key);
setDraft((current) => {
if (current && current.key === doc.key && !current.isNew) {
return { ...current, body };
}
return current;
});
}}
placeholder="Markdown body"
bordered={false}
className="bg-transparent"
contentClassName={documentBodyContentClassName}
mentions={mentions}
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
renderFoldableBody(displayedBody, documentBodyContentClassName)
)}
<IssueDocumentAnnotations
issueId={issue.id}
doc={doc}
bodyMarkdown={displayedBody}
draftDirty={Boolean(activeDraft) && (
(activeDraft?.body ?? doc.body) !== doc.body
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
)}
draftConflicted={Boolean(activeConflict)}
historicalPreview={isHistoricalPreview}
locationHash={location.hash}
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
onPanelOpenChange={(next) => setAnnotationPanelOpen(doc.key, next)}
agentMap={agentMap}
userProfileMap={userProfileMap}
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
>
{isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName)
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
onChange={(body) => {
markDocumentDirty(doc.key);
setDraft((current) => {
if (current && current.key === doc.key && !current.isNew) {
return { ...current, body };
}
return current;
});
}}
placeholder="Markdown body"
bordered={false}
className="bg-transparent"
contentClassName={documentBodyContentClassName}
mentions={mentions}
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
renderFoldableBody(displayedBody, documentBodyContentClassName)
)}
</IssueDocumentAnnotations>
</div>
<div className="flex min-h-4 items-center justify-end px-1">
<span
+28
View File
@@ -89,6 +89,10 @@
--chip-match-identifier-bg: var(--muted);
--chip-match-identifier-fg: var(--muted-foreground);
--chip-match-identifier-border: var(--border);
--paperclip-doc-annotation-highlight-open: #fef08a;
--paperclip-doc-annotation-highlight-focused: #fde047;
--paperclip-doc-annotation-highlight-stale: #fef08a;
--paperclip-doc-annotation-highlight-resolved: #fef9c3;
}
.dark {
@@ -136,6 +140,30 @@
--chip-match-identifier-bg: var(--muted);
--chip-match-identifier-fg: var(--muted-foreground);
--chip-match-identifier-border: var(--border);
--paperclip-doc-annotation-highlight-open: #a16207;
--paperclip-doc-annotation-highlight-focused: #ca8a04;
--paperclip-doc-annotation-highlight-stale: #854d0e;
--paperclip-doc-annotation-highlight-resolved: #713f12;
}
::highlight(paperclip-doc-annotation-open) {
background-color: var(--paperclip-doc-annotation-highlight-open);
color: inherit;
}
::highlight(paperclip-doc-annotation-focused) {
background-color: var(--paperclip-doc-annotation-highlight-focused);
color: inherit;
}
::highlight(paperclip-doc-annotation-stale) {
background-color: var(--paperclip-doc-annotation-highlight-stale);
color: inherit;
}
::highlight(paperclip-doc-annotation-resolved) {
background-color: var(--paperclip-doc-annotation-highlight-resolved);
color: inherit;
}
@layer base {
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
buildDocumentAnnotationHash,
parseDocumentAnnotationHash,
} from "./document-annotation-hash";
describe("parseDocumentAnnotationHash", () => {
it("returns null for non-document hashes", () => {
expect(parseDocumentAnnotationHash("")).toBeNull();
expect(parseDocumentAnnotationHash("#issue-foo")).toBeNull();
});
it("parses document key only", () => {
expect(parseDocumentAnnotationHash("#document-plan")).toEqual({
documentKey: "plan",
threadId: null,
commentId: null,
});
});
it("parses thread and comment targets", () => {
expect(
parseDocumentAnnotationHash("#document-plan&thread=t1&comment=c2"),
).toEqual({
documentKey: "plan",
threadId: "t1",
commentId: "c2",
});
});
it("decodes URI-encoded keys", () => {
expect(parseDocumentAnnotationHash("#document-my%20notes&thread=abc")).toEqual({
documentKey: "my notes",
threadId: "abc",
commentId: null,
});
});
});
describe("buildDocumentAnnotationHash", () => {
it("builds a hash without thread or comment", () => {
expect(buildDocumentAnnotationHash({ documentKey: "plan", threadId: null, commentId: null })).toBe(
"#document-plan",
);
});
it("includes thread target", () => {
expect(
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: null }),
).toBe("#document-plan&thread=t1");
});
it("includes both targets", () => {
expect(
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: "c2" }),
).toBe("#document-plan&thread=t1&comment=c2");
});
it("survives a round trip", () => {
const target = { documentKey: "plan-2", threadId: "t-abc", commentId: "c-xyz" };
expect(parseDocumentAnnotationHash(buildDocumentAnnotationHash(target))).toEqual(target);
});
});
+32
View File
@@ -0,0 +1,32 @@
export interface DocumentAnnotationHashTarget {
documentKey: string;
threadId: string | null;
commentId: string | null;
}
const DOCUMENT_HASH_PREFIX = "#document-";
export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHashTarget | null {
if (!hash.startsWith(DOCUMENT_HASH_PREFIX)) return null;
const stripped = hash.slice(DOCUMENT_HASH_PREFIX.length);
const [rawKey, ...rest] = stripped.split("&");
if (!rawKey) return null;
const documentKey = decodeURIComponent(rawKey);
const params = new URLSearchParams(rest.join("&"));
const threadId = params.get("thread");
const commentId = params.get("comment");
return {
documentKey,
threadId: threadId && threadId.length > 0 ? threadId : null,
commentId: commentId && commentId.length > 0 ? commentId : null,
};
}
export function buildDocumentAnnotationHash(target: DocumentAnnotationHashTarget): string {
const params = new URLSearchParams();
if (target.threadId) params.set("thread", target.threadId);
if (target.commentId) params.set("comment", target.commentId);
const qs = params.toString();
const encodedKey = encodeURIComponent(target.documentKey);
return qs ? `${DOCUMENT_HASH_PREFIX}${encodedKey}&${qs}` : `${DOCUMENT_HASH_PREFIX}${encodedKey}`;
}
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { verifyDocumentAnchorSelector } from "@paperclipai/shared";
import {
buildAnchorFromContainerSelection,
getContainerTextOffset,
rangesForNormalizedSpan,
} from "./document-annotation-selection";
const MARKDOWN = `# Plan
We **should** keep the current markdown stack for the first version.
- Highlight a text segment in a plan document.
- Anchor comments without mutating markdown.
## Acceptance
The annotation feature is ready when the basic flow works.`;
const RENDERED_HTML = `
<div>
<h1>Plan</h1>
<p>We should keep the current markdown stack for the first version.</p>
<ul>
<li>Highlight a text segment in a plan document.</li>
<li>Anchor comments without mutating markdown.</li>
</ul>
<h2>Acceptance</h2>
<p>The annotation feature is ready when the basic flow works.</p>
</div>
`;
function makeContainer(): HTMLElement {
const div = document.createElement("div");
div.innerHTML = RENDERED_HTML;
document.body.appendChild(div);
return div.firstElementChild as HTMLElement;
}
function selectText(container: HTMLElement, needle: string): Range {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
let node = walker.nextNode();
while (node) {
const data = (node as Text).data;
const index = data.indexOf(needle);
if (index !== -1) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + needle.length);
return range;
}
node = walker.nextNode();
}
throw new Error(`Could not find "${needle}" in container`);
}
describe("buildAnchorFromContainerSelection", () => {
it("produces a selector that verifies against the same markdown", () => {
const container = makeContainer();
const range = selectText(container, "current markdown stack");
const offset = getContainerTextOffset(container, range);
expect(offset).not.toBeNull();
const anchor = buildAnchorFromContainerSelection({
markdown: MARKDOWN,
containerOffset: offset!,
});
expect(anchor).not.toBeNull();
const verified = verifyDocumentAnchorSelector({
markdown: MARKDOWN,
selector: anchor!.selector,
});
expect(verified.ok).toBe(true);
expect(verified.anchor?.selectedText).toBe("current markdown stack");
});
it("returns null for empty selections", () => {
const container = makeContainer();
const range = document.createRange();
range.setStart(container, 0);
range.setEnd(container, 0);
const offset = getContainerTextOffset(container, range);
expect(offset).toBeNull();
});
it("returns null when selection is outside container", () => {
const container = makeContainer();
const outside = document.createElement("div");
outside.textContent = "outside";
document.body.appendChild(outside);
const range = document.createRange();
range.selectNodeContents(outside);
const offset = getContainerTextOffset(container, range);
expect(offset).toBeNull();
});
});
describe("rangesForNormalizedSpan", () => {
it("walks DOM text nodes to find span ranges", () => {
const container = makeContainer();
const ranges = rangesForNormalizedSpan({
container,
selectedText: "Highlight a text segment",
});
expect(ranges.length).toBeGreaterThan(0);
const merged = ranges.map((range) => range.toString()).join("");
expect(merged.replace(/\s+/g, " ")).toContain("Highlight a text segment");
});
it("returns an empty array if selected text is missing", () => {
const container = makeContainer();
const ranges = rangesForNormalizedSpan({
container,
selectedText: "this string does not exist in the document",
});
expect(ranges).toEqual([]);
});
});
+202
View File
@@ -0,0 +1,202 @@
import {
createDocumentAnchorSelector,
normalizeAnchorText,
projectMarkdownToText,
resolveProjectionRange,
type DocumentAnnotationAnchorSelector,
type DocumentTextProjection,
type DocumentTextRange,
} from "@paperclipai/shared";
export interface ContainerTextOffset {
/** Byte offset of the selection start within the flattened container text. */
startOffset: number;
/** Byte offset of the selection end within the flattened container text. */
endOffset: number;
/** Raw flattened text content of the container. */
containerText: string;
/** Raw text inside the selection. */
selectedText: string;
}
export function getContainerTextOffset(
container: HTMLElement,
range: Range,
): ContainerTextOffset | null {
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) {
return null;
}
const preRange = document.createRange();
preRange.selectNodeContents(container);
preRange.setEnd(range.startContainer, range.startOffset);
const startOffset = preRange.toString().length;
preRange.setEnd(range.endContainer, range.endOffset);
const endOffset = preRange.toString().length;
if (endOffset <= startOffset) return null;
return {
startOffset,
endOffset,
containerText: container.textContent ?? "",
selectedText: range.toString(),
};
}
export interface SelectionAnchorResult {
selector: DocumentAnnotationAnchorSelector;
range: DocumentTextRange;
projection: DocumentTextProjection;
}
export function buildAnchorFromContainerSelection(input: {
markdown: string;
containerOffset: ContainerTextOffset;
}): SelectionAnchorResult | null {
const projection = projectMarkdownToText(input.markdown);
const needle = normalizeAnchorText(input.containerOffset.selectedText);
if (!needle) return null;
const occurrences = findAllOccurrences(projection.text, needle);
if (occurrences.length === 0) return null;
const renderedTextLength = Math.max(1, normalizeAnchorText(input.containerOffset.containerText).length);
const renderedRatio = input.containerOffset.startOffset / renderedTextLength;
const projectionLength = Math.max(1, projection.text.length);
const expectedNormalized = Math.round(renderedRatio * projectionLength);
const best = pickClosestOccurrence(occurrences, expectedNormalized);
if (best == null) return null;
const normalizedStart = best;
const normalizedEnd = best + needle.length;
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
if (!range) return null;
if (normalizeAnchorText(range.text) !== needle) return null;
const selector = createDocumentAnchorSelector(projection, range);
return { selector, range, projection };
}
function findAllOccurrences(haystack: string, needle: string): number[] {
if (!needle) return [];
const out: number[] = [];
let cursor = haystack.indexOf(needle);
while (cursor !== -1) {
out.push(cursor);
cursor = haystack.indexOf(needle, cursor + 1);
}
return out;
}
function pickClosestOccurrence(occurrences: number[], expected: number): number | null {
if (occurrences.length === 0) return null;
if (occurrences.length === 1) return occurrences[0] ?? null;
let best = occurrences[0] ?? 0;
let bestDistance = Math.abs(best - expected);
for (const candidate of occurrences) {
const distance = Math.abs(candidate - expected);
if (distance < bestDistance) {
best = candidate;
bestDistance = distance;
}
}
return best;
}
/**
* Walk text nodes inside `container` and return a list of `Range`s that cover the
* normalized-text span `[normalizedStart, normalizedEnd)`. Each Range can be
* rectangle-projected to draw a highlight overlay.
*/
export function rangesForNormalizedSpan(input: {
container: HTMLElement;
selectedText: string;
}): Range[] {
const normalizedNeedle = normalizeAnchorText(input.selectedText);
if (!normalizedNeedle) return [];
const containerText = input.container.textContent ?? "";
const normalizedContainerText = normalizeAnchorText(containerText);
const containerOccurrenceIndex = normalizedContainerText.indexOf(normalizedNeedle);
if (containerOccurrenceIndex === -1) return [];
// Convert from normalized container offset back to raw container offset
// by walking the raw text and matching whitespace squashing.
const rawIndex = mapNormalizedOffsetToRaw(containerText, containerOccurrenceIndex);
if (rawIndex < 0) return [];
const rawNeedleLength = matchRawLengthForNormalized(
containerText.slice(rawIndex),
normalizedNeedle.length,
);
if (rawNeedleLength <= 0) return [];
const rawStart = rawIndex;
const rawEnd = rawIndex + rawNeedleLength;
return buildRangesForRawSpan(input.container, rawStart, rawEnd);
}
function mapNormalizedOffsetToRaw(rawText: string, normalizedOffset: number): number {
let normalizedCursor = 0;
let lastWasWhitespace = true; // mimic trim() at start
for (let index = 0; index < rawText.length; index += 1) {
const char = rawText[index] ?? "";
if (/\s/.test(char)) {
if (!lastWasWhitespace) {
if (normalizedCursor === normalizedOffset) return index;
normalizedCursor += 1;
lastWasWhitespace = true;
}
continue;
}
if (normalizedCursor === normalizedOffset) return index;
normalizedCursor += 1;
lastWasWhitespace = false;
}
return -1;
}
function matchRawLengthForNormalized(rawTail: string, normalizedLength: number): number {
let normalizedCount = 0;
let lastWasWhitespace = false;
for (let index = 0; index < rawTail.length; index += 1) {
const char = rawTail[index] ?? "";
if (/\s/.test(char)) {
if (!lastWasWhitespace) {
normalizedCount += 1;
if (normalizedCount >= normalizedLength) return index;
lastWasWhitespace = true;
}
} else {
normalizedCount += 1;
lastWasWhitespace = false;
if (normalizedCount >= normalizedLength) return index + 1;
}
}
return rawTail.length;
}
function buildRangesForRawSpan(container: HTMLElement, rawStart: number, rawEnd: number): Range[] {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
const ranges: Range[] = [];
let cursor = 0;
let node: Node | null = walker.nextNode();
while (node) {
const textNode = node as Text;
const length = textNode.data.length;
const nodeStart = cursor;
const nodeEnd = cursor + length;
if (nodeEnd > rawStart && nodeStart < rawEnd) {
const startWithin = Math.max(0, rawStart - nodeStart);
const endWithin = Math.min(length, rawEnd - nodeStart);
if (endWithin > startWithin) {
const range = document.createRange();
range.setStart(textNode, startWithin);
range.setEnd(textNode, endWithin);
ranges.push(range);
}
}
cursor = nodeEnd;
if (cursor >= rawEnd) break;
node = walker.nextNode();
}
return ranges;
}
+2
View File
@@ -63,6 +63,8 @@ export const queryKeys = {
documents: (issueId: string) => ["issues", "documents", issueId] as const,
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
documentAnnotations: (issueId: string, key: string, status: "open" | "resolved" | "all" = "all") =>
["issues", "document-annotations", issueId, key, status] as const,
activity: (issueId: string) => ["issues", "activity", issueId] as const,
runs: (issueId: string) => ["issues", "runs", issueId] as const,
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
+2
View File
@@ -3736,6 +3736,8 @@ export function IssueDetail() {
});
}}
extraActions={!hasAttachments ? attachmentUploadButton : null}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
{attachmentsInitialLoading ? (
@@ -0,0 +1,571 @@
import { useMemo, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
Agent,
DocumentAnnotationThreadWithComments,
Issue,
IssueDocument,
} from "@paperclipai/shared";
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
import { DocumentAnnotationLayer, type PendingAnchor } from "@/components/DocumentAnnotationLayer";
import {
DocumentAnnotationsCountChip,
IssueDocumentAnnotations,
} from "@/components/IssueDocumentAnnotations";
import { IssueDocumentsSection } from "@/components/IssueDocumentsSection";
import { MarkdownBody } from "@/components/MarkdownBody";
import { MarkdownEditor } from "@/components/MarkdownEditor";
import { queryKeys } from "@/lib/queryKeys";
import type { CompanyUserProfile } from "@/lib/company-members";
const sampleMarkdown = `# Plan: Document Highlights And Comment Threads
We should **keep** the current markdown document stack for the first version.
The existing editor is MDXEditor on top of Lexical, and the current code already uses Lexical-level customization.
## Reader And Goal
Reader: board reviewer, CTO, and implementing engineers.
## Anchor Strategy
Do not insert comment markers into markdown. The markdown document body must
remain portable and readable.
Use a sidecar anchor made from two selectors:
- Text quote selector: exact selected text plus prefix/suffix context.
- Text position selector: normalized rendered-text offsets plus markdown source offsets.
## Future Work
Phase 5 covers QA validation across desktop and mobile.`;
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: "keep the current markdown document stack",
prefixText: "We should ",
suffixText: " for the first version",
normalizedStart: 0,
normalizedEnd: 40,
markdownStart: 0,
markdownEnd: 40,
anchorSelector: {
quote: {
exact: "keep the current markdown document stack",
prefix: "We should ",
suffix: " for the first version",
},
position: { normalizedStart: 0, normalizedEnd: 40, markdownStart: 0, markdownEnd: 40 },
},
createdByAgentId: null,
createdByUserId: "user-1",
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date("2026-05-12T10:00:00Z"),
updatedAt: new Date("2026-05-12T10:01:00Z"),
comments: [
{
id: "comment-1",
companyId: "co-1",
threadId: id,
issueId: "issue-1",
documentId: "doc-1",
body: "Could we benchmark the editor against a CRDT alternative before committing?",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
createdByRunId: null,
createdAt: new Date("2026-05-12T10:00:00Z"),
updatedAt: new Date("2026-05-12T10:00:00Z"),
},
{
id: "comment-2",
companyId: "co-1",
threadId: id,
issueId: "issue-1",
documentId: "doc-1",
body: "We did a small spike — happy to share results in the plan.",
authorType: "agent",
authorAgentId: "agent-uxdesigner",
authorUserId: null,
createdByRunId: "run-1",
createdAt: new Date("2026-05-12T10:01:00Z"),
updatedAt: new Date("2026-05-12T10:01:00Z"),
},
],
...overrides,
};
}
const baseThreads: DocumentAnnotationThreadWithComments[] = [
makeThread({ id: "open-1" }),
makeThread({
id: "stale-1",
anchorState: "stale",
anchorConfidence: "fuzzy",
selectedText: "two selectors",
prefixText: "anchor made from ",
suffixText: ":",
comments: [
{
id: "comment-stale",
companyId: "co-1",
threadId: "stale-1",
issueId: "issue-1",
documentId: "doc-1",
body: "Original wording was slightly different — re-anchor when convenient.",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
createdByRunId: null,
createdAt: new Date("2026-05-12T11:00:00Z"),
updatedAt: new Date("2026-05-12T11:00:00Z"),
},
],
}),
makeThread({
id: "resolved-1",
status: "resolved",
selectedText: "Reader: board reviewer, CTO, and implementing engineers",
comments: [
{
id: "comment-resolved",
companyId: "co-1",
threadId: "resolved-1",
issueId: "issue-1",
documentId: "doc-1",
body: "Updated reader list to add the security lead.",
authorType: "agent",
authorAgentId: "agent-uxdesigner",
authorUserId: null,
createdByRunId: "run-1",
createdAt: new Date("2026-05-12T12:00:00Z"),
updatedAt: new Date("2026-05-12T12:00:00Z"),
},
],
}),
makeThread({
id: "orphan-1",
anchorState: "orphaned",
selectedText: "an earlier paragraph that has been rewritten",
comments: [
{
id: "comment-orphan",
companyId: "co-1",
threadId: "orphan-1",
issueId: "issue-1",
documentId: "doc-1",
body: "This anchor lost its location after the rewrite. Original quote preserved.",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
createdByRunId: null,
createdAt: new Date("2026-05-12T13:00:00Z"),
updatedAt: new Date("2026-05-12T13:00:00Z"),
},
],
}),
];
const integratedAgentMap: ReadonlyMap<string, Pick<Agent, "id" | "name">> = new Map([
["agent-uxdesigner", { id: "agent-uxdesigner", name: "UXDesigner" }],
]);
const integratedUserProfileMap: ReadonlyMap<string, CompanyUserProfile> = new Map([
["user-1", { label: "Dotta", image: null }],
]);
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
mutations: { retry: false },
},
});
}
const integratedIssueId = "issue-storybook-1";
const integratedDoc: IssueDocument = {
id: "doc-storybook-1",
companyId: "co-1",
issueId: integratedIssueId,
key: "plan",
title: "Plan",
format: "markdown",
body: sampleMarkdown,
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-05-12T09:00:00Z"),
updatedAt: new Date("2026-05-12T10:01:00Z"),
};
function makeIntegratedIssue(): Issue {
return {
id: integratedIssueId,
companyId: "co-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Highlighting and comments on documents",
description: null,
status: "in_progress",
workMode: "standard",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 9402,
identifier: "PAP-9402",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
documentSummaries: [
{
id: integratedDoc.id,
companyId: integratedDoc.companyId,
issueId: integratedIssueId,
key: integratedDoc.key,
title: integratedDoc.title,
format: integratedDoc.format,
latestRevisionId: integratedDoc.latestRevisionId,
latestRevisionNumber: integratedDoc.latestRevisionNumber,
createdByAgentId: null,
createdByUserId: "user-1",
updatedByAgentId: null,
updatedByUserId: "user-1",
lockedAt: integratedDoc.lockedAt,
lockedByAgentId: integratedDoc.lockedByAgentId,
lockedByUserId: integratedDoc.lockedByUserId,
createdAt: integratedDoc.createdAt,
updatedAt: integratedDoc.updatedAt,
},
],
legacyPlanDocument: null,
planDocument: integratedDoc,
createdAt: new Date("2026-05-10T00:00:00Z"),
updatedAt: new Date("2026-05-12T10:01:00Z"),
};
}
/**
* Storybook fetch stub for the integrated stories. The annotation surface is
* driven by prefilled React Query data, but MarkdownEditor in edit mode can
* fire an autosave PUT on first onChange. Without this stub the cell would
* render a "Request failed: 404" string from the section's error state — which
* defeats the purpose of the integrated capture.
*/
function useIntegratedFetchStub(issueId: string, doc: IssueDocument) {
// Install once per mount; the cleanup restores the previous fetch.
// The preview's global fetch fixture is still in place — we only intercept
// the document mutation URL pattern for this issue.
useMemo(() => {
if (typeof window === "undefined") return;
const upsertUrlPath = `/api/issues/${issueId}/documents/${doc.key}`;
const original = window.fetch.bind(window);
const wrapped: typeof window.fetch = async (input, init) => {
const rawUrl = typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const method = (init?.method ?? (typeof input === "object" && "method" in input ? (input as Request).method : "GET")).toUpperCase();
const url = new URL(rawUrl, window.location.origin);
if (url.pathname === upsertUrlPath && (method === "PUT" || method === "GET")) {
return Response.json({ ...doc, latestRevisionNumber: doc.latestRevisionNumber + 1 });
}
return original(input, init);
};
window.fetch = wrapped;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issueId, doc.key]);
}
function IntegratedSurface({
threads = baseThreads,
focusedThreadId = "open-1",
initialPanelOpen = true,
beginEditOnMount = false,
}: {
threads?: DocumentAnnotationThreadWithComments[];
focusedThreadId?: string | null;
initialPanelOpen?: boolean;
beginEditOnMount?: boolean;
}) {
const issue = useMemo(makeIntegratedIssue, []);
useIntegratedFetchStub(issue.id, integratedDoc);
const queryClient = useMemo(() => {
const client = makeClient();
// Prefill documents + annotations cache so React Query renders without hitting the network.
client.setQueryData(queryKeys.issues.documents(issue.id), [integratedDoc]);
client.setQueryData(
queryKeys.issues.documentAnnotations(issue.id, integratedDoc.key, "all"),
threads,
);
return client;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issue.id]);
const panelKeys = initialPanelOpen ? [integratedDoc.key] : [];
const focusedThreadIds = focusedThreadId ? { [integratedDoc.key]: focusedThreadId } : undefined;
const editKey = beginEditOnMount ? integratedDoc.key : null;
return (
<QueryClientProvider client={queryClient}>
<div className="paperclip-doc-annotation-integrated mx-auto max-w-[1320px] p-4">
<div className="rounded-lg border border-border bg-background p-4">
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={false}
agentMap={integratedAgentMap}
userProfileMap={integratedUserProfileMap}
defaultAnnotationPanelOpenKeys={panelKeys}
defaultAnnotationFocusedThreadIds={focusedThreadIds}
forceEditDocumentKey={editKey}
/>
</div>
</div>
</QueryClientProvider>
);
}
function DirtyDraftWithIntegratedHeader() {
const issue = useMemo(makeIntegratedIssue, []);
useIntegratedFetchStub(issue.id, integratedDoc);
const queryClient = useMemo(() => {
const client = makeClient();
client.setQueryData(queryKeys.issues.documents(issue.id), [integratedDoc]);
client.setQueryData(
queryKeys.issues.documentAnnotations(issue.id, integratedDoc.key, "all"),
baseThreads,
);
return client;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issue.id]);
const [panelOpen, setPanelOpen] = useState(true);
const [draftBody, setDraftBody] = useState(`${sampleMarkdown}\n\nA work-in-progress edit that is unsaved.`);
return (
<QueryClientProvider client={queryClient}>
<div className="paperclip-doc-annotation-integrated mx-auto max-w-[1320px] p-4">
<div className="rounded-lg border border-border bg-background p-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 min-w-0">
<h3 className="w-full text-sm font-medium text-muted-foreground shrink-0 sm:w-auto">Documents</h3>
</div>
<div className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground"></span>
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
plan
</span>
<span className="text-[11px] text-muted-foreground">rev 4 </span>
<span className="truncate text-[11px] text-muted-foreground">updated 2h ago</span>
<DocumentAnnotationsCountChip
issueId={issue.id}
docKey={integratedDoc.key}
panelOpen={panelOpen}
onToggle={() => setPanelOpen((current) => !current)}
/>
</div>
</div>
</div>
<div className="mt-3 space-y-3">
<IssueDocumentAnnotations
issueId={issue.id}
doc={integratedDoc}
bodyMarkdown={draftBody}
draftDirty
draftConflicted={false}
historicalPreview={false}
locationHash=""
panelOpen={panelOpen}
onPanelOpenChange={setPanelOpen}
agentMap={integratedAgentMap}
userProfileMap={integratedUserProfileMap}
defaultFocusedThreadId="open-1"
>
<MarkdownEditor
value={draftBody}
onChange={(body) => setDraftBody(body)}
placeholder="Markdown body"
bordered={false}
className="bg-transparent"
contentClassName="paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7"
/>
</IssueDocumentAnnotations>
<div className="flex min-h-4 items-center justify-end px-1">
<span className="text-[11px] text-amber-300">Autosaving</span>
</div>
</div>
</div>
</div>
</div>
</div>
</QueryClientProvider>
);
}
function StatesShowcase({ focusedThreadId = "open-1" }: { focusedThreadId?: string }) {
const queryClient = useMemo(() => makeClient(), []);
const bodyRef = useRef<HTMLElement | null>(null);
const [pendingAnchor, setPendingAnchor] = useState<PendingAnchor | null>(null);
const [focused, setFocused] = useState<string | null>(focusedThreadId);
return (
<QueryClientProvider client={queryClient}>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="relative rounded-lg border border-border bg-card p-4">
<section
ref={(element) => {
bodyRef.current = element;
}}
className="relative"
>
<MarkdownBody className="text-[15px] leading-7">{sampleMarkdown}</MarkdownBody>
<DocumentAnnotationLayer
containerRef={bodyRef}
markdown={sampleMarkdown}
threads={baseThreads.map((thread) => ({
id: thread.id,
selectedText: thread.selectedText,
status: thread.status,
anchorState: thread.anchorState,
}))}
focusedThreadId={focused}
onThreadFocus={(id) => setFocused(id)}
pendingAnchor={pendingAnchor}
onPendingAnchorChange={setPendingAnchor}
onRequestComment={() => {}}
hideResolved={false}
/>
</section>
</div>
<DocumentAnnotationPanel
open
onOpenChange={() => {}}
issueId="issue-1"
documentKey="plan"
documentRevisionNumber={4}
baseRevisionId="rev-4"
baseRevisionNumber={4}
threads={baseThreads}
focusedThreadId={focused}
focusedCommentId={null}
onFocusThread={(id) => setFocused(id)}
pendingAnchor={null}
onClearPendingAnchor={() => setPendingAnchor(null)}
agentMap={integratedAgentMap}
userProfileMap={integratedUserProfileMap}
/>
</div>
</QueryClientProvider>
);
}
const meta = {
title: "Product/Documents/Annotations",
component: StatesShowcase,
parameters: {
docs: {
description: {
component:
"Document annotation surface for issue documents. Stories under 'Integrated' render the real IssueDocumentsSection chrome (count chip in header, panel + body in their actual layout). Stories under 'States' isolate the panel/layer for unit-level visual debugging.",
},
},
},
} satisfies Meta<typeof StatesShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
// ---------------------------------------------------------------------------
// Integrated stories — render IssueDocumentsSection with all chrome.
// These are the captures the UX gate requires.
// ---------------------------------------------------------------------------
export const IntegratedDesktopOpen: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
render: () => <IntegratedSurface focusedThreadId="open-1" initialPanelOpen />,
};
export const IntegratedDesktopZeroComments: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
render: () => <IntegratedSurface threads={[]} initialPanelOpen={false} focusedThreadId={null} />,
};
export const IntegratedDesktopEditMode: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
render: () => (
<IntegratedSurface focusedThreadId="open-1" initialPanelOpen beginEditOnMount />
),
};
export const IntegratedDesktopDirtyDraft: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
render: () => <DirtyDraftWithIntegratedHeader />,
};
export const IntegratedMobileBottomSheet: Story = {
parameters: { viewport: { defaultViewport: "mobile1" } },
render: () => <IntegratedSurface focusedThreadId="open-1" initialPanelOpen />,
};
// ---------------------------------------------------------------------------
// Isolated state stories (kept for unit-level visual debugging).
// ---------------------------------------------------------------------------
export const DesktopOpenFocused: Story = {
render: () => <StatesShowcase focusedThreadId="open-1" />,
};
export const DesktopResolvedFocused: Story = {
render: () => <StatesShowcase focusedThreadId="resolved-1" />,
};
export const DesktopStaleFocused: Story = {
render: () => <StatesShowcase focusedThreadId="stale-1" />,
};
export const DesktopOrphanedFocused: Story = {
render: () => <StatesShowcase focusedThreadId="orphan-1" />,
};