Add document annotation UI (Phase 4)

Wires the Phase 3 annotation API into IssueDocumentsSection with a
selection toolbar, anchored highlight overlay, side panel, and mobile
bottom sheet — no markdown body mutation. Covers open/resolved/stale/
orphaned states, deep links (#document-<key>&thread=...&comment=...),
dirty-draft disable, and revision remap refresh.

- New: ui/src/api/document-annotations.ts client
- New: DocumentAnnotationLayer (overlay rectangles + selection toolbar)
- New: DocumentAnnotationPanel (filter chips, threads, composer, mobile)
- New: IssueDocumentAnnotations wrapper (count chip, deep link, dirty
  rule); used by IssueDocumentsSection for non-system documents
- New: document-annotation-hash and document-annotation-selection helpers
- Tests: hash parsing, selection→anchor projection (validated against
  shared verifyDocumentAnchorSelector), panel filter/disable/deep-link
- Stories + screenshot script: open/resolved/stale/orphaned + dirty
  + mobile bottom sheet (design/PAP-9436-doc-annotation-ui/screenshots)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-26 12:03:47 +00:00
1 parent cd5b735631
commit a92e36bac0
19 files changed
+2178 -27

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// Capture screenshots of the Document Annotation storybook stories.
// Usage: node scripts/screenshot-doc-annotations.mjs <storybook-static-dir> <output-dir>
import http from "node:http";
import path from "node:path";
import fs from "node:fs/promises";
import { chromium } from "@playwright/test";
async function main() {
const [, , staticDir, outDir] = process.argv;
if (!staticDir || !outDir) {
console.error("usage: node scripts/screenshot-doc-annotations.mjs <storybook-static-dir> <output-dir>");
process.exit(1);
}
await fs.mkdir(outDir, { recursive: true });
const absStaticDir = path.resolve(staticDir);
const server = http.createServer(async (req, res) => {
try {
let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
if (urlPath.endsWith("/")) urlPath += "iframe.html";
const filePath = path.resolve(absStaticDir, `.${urlPath}`);
if (!filePath.startsWith(absStaticDir + path.sep) && filePath !== absStaticDir) {
res.writeHead(403);
res.end("Forbidden");
return;
}
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = {
".html": "text/html; charset=utf-8",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff": "font/woff",
".woff2": "font/woff2",
".map": "application/json",
}[ext] || "application/octet-stream";
res.writeHead(200, { "content-type": mime });
res.end(buf);
} catch (err) {
res.writeHead(404);
res.end(String(err));
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const baseUrl = `http://127.0.0.1:${port}/iframe.html`;
const browser = await chromium.launch();
try {
const stories = [
{ id: "product-documents-annotations--desktop-open-focused", file: "01-desktop-open-focused.png", width: 1280, height: 900 },
{ id: "product-documents-annotations--desktop-resolved-focused", file: "02-desktop-resolved-focused.png", width: 1280, height: 900 },
{ id: "product-documents-annotations--desktop-stale-focused", file: "03-desktop-stale-focused.png", width: 1280, height: 900 },
{ id: "product-documents-annotations--desktop-orphaned-focused", file: "04-desktop-orphaned-focused.png", width: 1280, height: 900 },
{ id: "product-documents-annotations--dirty-draft-disables-new-comments", file: "05-dirty-draft-disabled.png", width: 720, height: 720 },
{ id: "product-documents-annotations--mobile-bottom-sheet-view", file: "06-mobile-bottom-sheet.png", width: 375, height: 812 },
];
for (const story of stories) {
const ctx = await browser.newContext({
viewport: { width: story.width, height: story.height },
deviceScaleFactor: 2,
colorScheme: "light",
});
const page = await ctx.newPage();
const url = `${baseUrl}?id=${story.id}&viewMode=story`;
await page.goto(url, { waitUntil: "networkidle" });
await page.evaluate(() => {
document.documentElement.style.colorScheme = "light";
});
await page.waitForTimeout(700);
const out = path.join(outDir, story.file);
await page.screenshot({ path: out, fullPage: true });
console.log("wrote", out);
await ctx.close();
}
} finally {
await browser.close();
server.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+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,265 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { 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 };
}
interface HighlightRect {
threadId: string;
status: DocumentAnnotationThreadStatus;
anchorState: DocumentAnnotationAnchorState;
top: number;
left: number;
width: number;
height: number;
}
interface ToolbarPosition {
top: number;
left: number;
}
const POSITION_REFRESH_INTERVAL_MS = 250;
export function DocumentAnnotationLayer({
containerRef,
markdown,
threads,
focusedThreadId,
onThreadFocus,
pendingAnchor,
onPendingAnchorChange,
onRequestComment,
newCommentDisabled = false,
newCommentDisabledReason = null,
hideResolved = true,
}: AnnotationLayerProps) {
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const layerContainerRef = useRef<HTMLDivElement | null>(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) {
setHighlightRects([]);
return;
}
const overlayRect = overlay.getBoundingClientRect();
const next: HighlightRect[] = [];
for (const thread of visibleThreads) {
if (thread.anchorState === "orphaned") continue;
const ranges = rangesForNormalizedSpan({
container,
selectedText: thread.selectedText,
});
for (const range of ranges) {
for (const rect of Array.from(range.getClientRects())) {
if (rect.width === 0 || rect.height === 0) continue;
next.push({
threadId: thread.id,
status: thread.status,
anchorState: thread.anchorState,
top: rect.top - overlayRect.top,
left: rect.left - overlayRect.left,
width: rect.width,
height: rect.height,
});
}
}
}
setHighlightRects(next);
}, [containerRef, visibleThreads]);
useLayoutEffect(() => {
computeHighlightRects();
}, [computeHighlightRects]);
useEffect(() => {
if (typeof window === "undefined") return;
let cancelled = false;
const schedule = () => {
if (cancelled) return;
computeHighlightRects();
};
const interval = window.setInterval(schedule, POSITION_REFRESH_INTERVAL_MS);
const handleResize = () => schedule();
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleResize, true);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleResize, true);
};
}, [computeHighlightRects]);
useEffect(() => {
if (typeof document === "undefined") return;
const handleSelectionChange = () => {
const container = containerRef.current;
const overlay = overlayRef.current;
if (!container || !overlay) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
onPendingAnchorChange(null);
setToolbarPosition(null);
return;
}
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
onPendingAnchorChange(null);
setToolbarPosition(null);
return;
}
const containerOffset = getContainerTextOffset(container, range);
if (!containerOffset) {
onPendingAnchorChange(null);
setToolbarPosition(null);
return;
}
const anchor = buildAnchorFromContainerSelection({
markdown,
containerOffset,
});
if (!anchor) {
onPendingAnchorChange(null);
setToolbarPosition(null);
return;
}
onPendingAnchorChange({
selector: anchor.selector,
selectedText: containerOffset.selectedText,
});
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 });
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => document.removeEventListener("selectionchange", handleSelectionChange);
}, [containerRef, markdown, onPendingAnchorChange]);
const handleAddComment = () => {
if (pendingAnchor) onRequestComment(pendingAnchor);
};
return (
<div
ref={layerContainerRef}
className="paperclip-doc-annotation-layer pointer-events-none absolute inset-0 z-0"
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-highlight pointer-events-auto absolute cursor-pointer rounded-sm border-b-2 transition-colors",
rect.status === "resolved"
? "border-dashed border-muted-foreground/40 hover:bg-muted/40"
: rect.anchorState === "stale"
? "border-dashed border-amber-500/60 hover:bg-amber-500/10"
: isFocused
? "border-primary bg-primary/10"
: "border-muted-foreground/50 hover:bg-muted/30",
)}
style={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}}
onMouseDown={(event) => {
event.preventDefault();
onThreadFocus(rect.threadId);
}}
/>
);
})}
{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,465 @@
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, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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";
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;
newCommentDisabled?: boolean;
newCommentDisabledReason?: string | null;
/** When mobile is true, render as a bottom sheet style panel instead of side panel. */
isMobile?: boolean;
className?: string;
}
export function DocumentAnnotationPanel(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 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 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(),
});
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]);
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]);
if (!props.open) return null;
const baseClasses = props.isMobile
? "fixed inset-x-0 bottom-0 z-40 max-h-[80vh] rounded-t-xl border-t border-border bg-background shadow-2xl"
: "sticky top-4 z-30 flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col rounded-lg border border-border bg-card shadow-md";
return (
<aside
role="complementary"
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
data-testid="document-annotation-panel"
className={cn(baseClasses, "flex flex-col overflow-hidden", props.className)}
>
<header className="flex items-start justify-between gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0">
<p className="text-sm font-medium">Comments</p>
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
{props.documentKey} · 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.5 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"
size="sm"
variant={isActive ? "default" : "secondary"}
className="h-7 gap-1 px-2 text-[11px] font-medium"
onClick={() => setFilter(entry.id)}
>
{entry.label}
<span className="opacity-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}
/>
))}
</ul>
)}
</div>
{props.pendingAnchor ? (
<div className="border-t border-border bg-muted/20 px-3 py-2">
<p className="mb-1 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
New comment on selection
</p>
<blockquote className="mb-2 max-h-24 overflow-y-auto rounded-md border-l-2 border-muted-foreground/60 bg-background px-2 py-1 text-xs italic text-muted-foreground">
{truncate(props.pendingAnchor.selectedText, 240)}
</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 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}
</aside>
);
}
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;
}) {
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-md border bg-card transition-colors",
props.expanded ? "border-primary/60" : "border-border",
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 max-h-20 overflow-hidden rounded-sm border-l-2 px-2 py-1 text-xs italic text-muted-foreground",
thread.anchorState === "stale" || thread.status === "resolved"
? "border-dashed border-muted-foreground/40"
: "border-muted-foreground/60",
)}
>
{truncate(thread.selectedText, 160)}
</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}
/>
))}
<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 text-sm"
disabled={props.pendingReply}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={props.onCopyLink}
className="gap-1"
title="Copy link"
>
<Copy className="h-3 w-3" /> Copy link
</Button>
<Button
type="button"
size="sm"
variant="outline"
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>
</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,
}: {
comment: DocumentAnnotationComment;
focused: boolean;
}) {
return (
<div
id={`comment-${comment.id}`}
data-focused={focused || undefined}
className={cn(
"rounded-md 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 text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
<span>{comment.authorType === "user" ? "Board" : "Agent"}</span>
<span>{relativeTime(comment.createdAt)}</span>
</div>
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
</div>
);
}
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,301 @@
// @vitest-environment jsdom
import { act } 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 { IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
const mockAnnotationsApi = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
updateStatus: vi.fn(),
}));
vi.mock("@/api/document-annotations", () => ({
documentAnnotationsApi: mockAnnotationsApi,
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("./DocumentAnnotationLayer", () => ({
DocumentAnnotationLayer: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
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",
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,
};
}
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}>
<IssueDocumentAnnotations
issueId="issue-1"
doc={doc}
bodyMarkdown={doc.body}
draftDirty={false}
draftConflicted={false}
historicalPreview={false}
locationHash=""
>
<p>Body content</p>
</IssueDocumentAnnotations>
</QueryClientProvider>,
);
});
await flush();
await flush();
const chip = container.querySelector('[data-testid="document-annotation-count-plan"]');
expect(chip).not.toBeNull();
expect(chip!.textContent).toContain("1");
await act(async () => {
(chip as HTMLButtonElement).click();
});
await flush();
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
expect(panel).not.toBeNull();
});
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}>
<IssueDocumentAnnotations
issueId="issue-1"
doc={doc}
bodyMarkdown={doc.body}
draftDirty={false}
draftConflicted={false}
historicalPreview={false}
locationHash="#document-plan&thread=thread-99"
>
<p>Body</p>
</IssueDocumentAnnotations>
</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}>
<IssueDocumentAnnotations
issueId="issue-1"
doc={doc}
bodyMarkdown={doc.body}
draftDirty
draftConflicted={false}
historicalPreview={false}
locationHash=""
>
<p>Body</p>
</IssueDocumentAnnotations>
</QueryClientProvider>,
);
});
await flush();
await flush();
const chip = container.querySelector(
'[data-testid="document-annotation-count-plan"]',
) as HTMLButtonElement | null;
expect(chip).not.toBeNull();
await act(async () => {
chip!.click();
});
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}>
<IssueDocumentAnnotations
issueId="issue-1"
doc={doc}
bodyMarkdown={doc.body}
draftDirty={false}
draftConflicted={false}
historicalPreview={false}
locationHash=""
>
<p>Body</p>
</IssueDocumentAnnotations>
</QueryClientProvider>,
);
});
await flush();
await flush();
const chip = container.querySelector(
'[data-testid="document-annotation-count-plan"]',
) as HTMLButtonElement;
await act(async () => chip.click());
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();
});
});
@@ -0,0 +1,207 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { 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";
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;
}
export function IssueDocumentAnnotations({
issueId,
doc,
bodyMarkdown,
draftDirty,
draftConflicted,
historicalPreview,
children,
locationHash,
}: IssueDocumentAnnotationsProps) {
const containerRef = useRef<HTMLElement | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(null);
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
const [pendingAnchor, setPendingAnchor] = useState<PendingAnchor | null>(null);
const [isMobile, setIsMobile] = useState(false);
const hashHandledRef = useRef<string | null>(null);
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;
}, []);
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 ?? [];
const openCount = useMemo(
() => allThreads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length,
[allThreads],
);
// 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;
setPanelOpen(true);
setFocusedThreadId(target.threadId);
setFocusedCommentId(target.commentId);
}, [doc.key, locationHash]);
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 handleRequestComment = useCallback((anchor: PendingAnchor) => {
if (newCommentDisabled) return;
setPendingAnchor(anchor);
setPanelOpen(true);
}, [newCommentDisabled]);
const handleThreadFocus = useCallback((threadId: string | null) => {
setFocusedThreadId(threadId);
if (threadId) {
setPanelOpen(true);
setFocusedCommentId(null);
}
}, []);
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],
);
return (
<div className="paperclip-doc-annotation-host flex flex-col gap-3 lg:flex-row lg:items-start">
<div className="relative min-w-0 flex-1">
<div className="mb-2 flex items-center justify-end gap-1.5">
<Button
type="button"
size="sm"
variant={openCount > 0 ? "secondary" : "ghost"}
className={cn(
"h-7 gap-1 rounded-full px-2 text-[11px] font-medium",
openCount === 0 && "text-muted-foreground",
)}
onClick={() => setPanelOpen((current) => !current)}
data-testid={`document-annotation-count-${doc.key}`}
aria-label={openCount === 0
? `Open comments on ${doc.key}`
: `Open ${openCount} unresolved comments on ${doc.key}`}
aria-expanded={panelOpen}
>
<MessageSquare className="h-3 w-3" aria-hidden="true" />
<span aria-hidden="true">{openCount}</span>
<span className="hidden sm:inline">{openCount === 1 ? " comment" : " comments"}</span>
</Button>
</div>
<section
ref={(element) => {
containerRef.current = element;
}}
className="relative"
data-testid={`document-annotation-body-${doc.key}`}
>
{children}
{!historicalPreview && doc.latestRevisionId ? (
<DocumentAnnotationLayer
containerRef={containerRef}
markdown={bodyMarkdown}
threads={overlayThreads}
focusedThreadId={focusedThread?.id ?? null}
onThreadFocus={handleThreadFocus}
pendingAnchor={pendingAnchor}
onPendingAnchorChange={(anchor) => setPendingAnchor(anchor)}
onRequestComment={handleRequestComment}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
hideResolved
/>
) : null}
</section>
</div>
{panelOpen ? (
<DocumentAnnotationPanel
open={panelOpen}
onOpenChange={(open) => {
setPanelOpen(open);
if (!open) {
setPendingAnchor(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={pendingAnchor}
onClearPendingAnchor={() => setPendingAnchor(null)}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
className={isMobile ? undefined : "lg:w-[360px] lg:max-w-[360px]"}
/>
) : null}
</div>
);
}
+43 -27
View File
@@ -17,6 +17,7 @@ import { deriveDocumentRevisionState } from "../lib/document-revisions";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
import { IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
@@ -213,8 +214,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]);
@@ -1153,31 +1156,44 @@ 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}
>
{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
@@ -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,
@@ -0,0 +1,329 @@
import { useMemo, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DocumentAnnotationThreadWithComments } from "@paperclipai/shared";
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
import { DocumentAnnotationLayer, type PendingAnchor } from "@/components/DocumentAnnotationLayer";
import { MarkdownBody } from "@/components/MarkdownBody";
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-1",
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-1",
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"),
},
],
}),
];
function makeClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
}
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)}
/>
</div>
</QueryClientProvider>
);
}
function DirtyDraftBlocked() {
const queryClient = useMemo(() => makeClient(), []);
return (
<QueryClientProvider client={queryClient}>
<DocumentAnnotationPanel
open
onOpenChange={() => {}}
issueId="issue-1"
documentKey="plan"
documentRevisionNumber={4}
baseRevisionId="rev-4"
baseRevisionNumber={4}
threads={baseThreads}
focusedThreadId={null}
focusedCommentId={null}
onFocusThread={() => {}}
pendingAnchor={null}
onClearPendingAnchor={() => {}}
newCommentDisabled
newCommentDisabledReason="Save the draft to anchor new comments."
/>
</QueryClientProvider>
);
}
function MobileBottomSheet() {
const queryClient = useMemo(() => makeClient(), []);
return (
<QueryClientProvider client={queryClient}>
<div className="relative h-[640px] bg-card p-4">
<MarkdownBody className="text-[15px] leading-7">{sampleMarkdown}</MarkdownBody>
<DocumentAnnotationPanel
open
onOpenChange={() => {}}
issueId="issue-1"
documentKey="plan"
documentRevisionNumber={4}
baseRevisionId="rev-4"
baseRevisionNumber={4}
threads={baseThreads}
focusedThreadId="open-1"
focusedCommentId={null}
onFocusThread={() => {}}
pendingAnchor={null}
onClearPendingAnchor={() => {}}
isMobile
/>
</div>
</QueryClientProvider>
);
}
const meta = {
title: "Product/Documents/Annotations",
component: StatesShowcase,
parameters: {
docs: {
description: {
component:
"Document annotation surface for issue documents. Covers the count chip, side panel, mobile bottom sheet, dirty-draft disable, and the open/resolved/stale/orphaned state matrix.",
},
},
},
} satisfies Meta<typeof StatesShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
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" />,
};
export const DirtyDraftDisablesNewComments: Story = {
render: () => <DirtyDraftBlocked />,
};
export const MobileBottomSheetView: Story = {
parameters: { viewport: { defaultViewport: "mobile1" } },
render: () => <MobileBottomSheet />,
};