Address Phase 4 UX review (document annotations)

Changes for the UX review items on PAP-9436:

- Highlights are now translucent boxes with state-specific outlines instead
  of baseline borders. Focused open anchors get a 2px primary outline plus
  fill so they read as a real "lift"; stale anchors get a dashed amber
  outline plus a ⚠ glyph at the run end; resolved anchors stay dashed and
  dim.
- Document count chip lives inside the document header row (next to the
  rev/updated chrome) via a new DocumentAnnotationsCountChip component, so
  it stays visible while the document is folded and conforms to neighbor
  rhythm. IssueDocumentAnnotations now exposes only the body wrapper +
  side panel and accepts controlled panelOpen state from the section.
- Comment authors render as `<name> · <role>` (e.g. `Dotta · board`,
  `UXDesigner · agent`) using the same agentMap / userProfileMap that
  IssueDetail already builds for IssueComments. Maps flow through
  IssueDocumentsSection → IssueDocumentAnnotations → DocumentAnnotationPanel.
- Side panel header drops the duplicate "PLAN" pill — the doc key already
  brought the user here. Header now reads `Comments / rev N`.
- Filter chips are real low-contrast chips (rounded ghost buttons with an
  active background tint) instead of three-weight Button variants.
- Thread action row is now Resolve (secondary) + Reply (primary), with
  Copy link collapsed into a `…` overflow dropdown.
- Persistent "+ New comment on selection" CTA pinned to the bottom of the
  panel with a ⌘⇧M keyboard shortcut. The CTA disables itself when a draft
  is dirty / conflicted / previewing history, matching the same gate the
  body selection toolbar already enforced.
- ThreadCard expanded state now uses `ring-1 ring-ring/70` rather than
  `border-primary/60` so the focus affordance is the standard
  design-system ring.
- Mobile bottom sheet is now the real shadcn Sheet (`SheetContent
  side="bottom"`) — backdrop, focus trap, drag handle, motion all come
  from the primitive.
- IssueDocumentsSection accepts storybook-only seeds
  (`defaultAnnotationPanelOpenKeys`, `defaultAnnotationFocusedThreadIds`,
  `forceEditDocumentKey`) so we can capture deterministic integrated
  screenshots instead of Storybook-isolated panel mocks.
- New integrated Storybook stories render the real IssueDocumentsSection
  chrome at 1440×900 desktop + 390×844 mobile, with edit-mode and dirty-
  draft variants. Stale state-only stories kept for visual debugging.
- design/PAP-9436-doc-annotation-ui/screenshots/ refreshed: the old
  Storybook-isolated captures are removed; 5 new integrated captures
  (01–05) replace them, and 4 isolated state captures (10–13) remain for
  reference.

Tests:
- Existing unit tests updated to the controlled-panel contract; new tests
  cover author identity rendering (`<name> · <role>`) and the persistent
  CTA.
- `pnpm vitest run` over the annotation surface (4 files, 23 tests) green.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-26 12:03:47 +00:00
1 parent a92e36bac0
commit 5a47efb2c4
23 files changed
+932 -278

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

+25 -13
View File
@@ -54,29 +54,41 @@ async function main() {
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 },
// INTEGRATED — these are the captures the UX gate requires. They render
// IssueDocumentsSection chrome (count chip in header row, body + side panel
// wired together, edit mode wrapping the layer, real shadcn Sheet on mobile).
{ id: "product-documents-annotations--integrated-desktop-open", file: "01-integrated-desktop-open.png", width: 1440, height: 900 },
{ id: "product-documents-annotations--integrated-desktop-zero-comments", file: "02-integrated-desktop-zero-count.png", width: 1440, height: 900 },
{ id: "product-documents-annotations--integrated-desktop-edit-mode", file: "03-integrated-desktop-edit-mode.png", width: 1440, height: 900 },
{ id: "product-documents-annotations--integrated-desktop-dirty-draft", file: "04-integrated-desktop-dirty-draft.png", width: 1440, height: 900 },
{ id: "product-documents-annotations--integrated-mobile-bottom-sheet", file: "05-integrated-mobile-sheet.png", width: 390, height: 844 },
// ISOLATED — kept around for visual debugging of state pieces.
{ id: "product-documents-annotations--desktop-open-focused", file: "10-states-open-focused.png", width: 1280, height: 900 },
{ id: "product-documents-annotations--desktop-resolved-focused", file: "11-states-resolved-focused.png", width: 1280, height: 900 },
{ id: "product-documents-annotations--desktop-stale-focused", file: "12-states-stale-focused.png", width: 1280, height: 900 },
{ id: "product-documents-annotations--desktop-orphaned-focused", file: "13-states-orphaned-focused.png", width: 1280, height: 900 },
];
const themeArg = (process.env.SCREENSHOT_THEME || "dark").toLowerCase();
const theme = themeArg === "light" ? "light" : "dark";
for (const story of stories) {
const ctx = await browser.newContext({
viewport: { width: story.width, height: story.height },
deviceScaleFactor: 2,
colorScheme: "light",
colorScheme: theme,
});
const page = await ctx.newPage();
const url = `${baseUrl}?id=${story.id}&viewMode=story`;
const url = `${baseUrl}?id=${story.id}&viewMode=story&globals=theme:${theme}`;
await page.goto(url, { waitUntil: "networkidle" });
await page.evaluate(() => {
document.documentElement.style.colorScheme = "light";
});
await page.waitForTimeout(700);
await page.evaluate((appliedTheme) => {
document.documentElement.classList.toggle("dark", appliedTheme === "dark");
document.documentElement.style.colorScheme = appliedTheme;
}, theme);
// Allow async query prefill + the layer's interval-driven layout pass to settle.
await page.waitForTimeout(900);
const out = path.join(outDir, story.file);
await page.screenshot({ path: out, fullPage: true });
await page.screenshot({ path: out, fullPage: false });
console.log("wrote", out);
await ctx.close();
}
+80 -45
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { MessageSquarePlus } from "lucide-react";
import { AlertTriangle, MessageSquarePlus } from "lucide-react";
import type {
DocumentAnnotationAnchorState,
DocumentAnnotationThreadStatus,
@@ -43,6 +43,11 @@ export interface AnnotationLayerProps {
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 (used by the panel's "+ New comment on selection" / ⌘⇧M CTA).
*/
captureSelectionRequestId?: number;
}
interface HighlightRect {
@@ -53,6 +58,8 @@ interface HighlightRect {
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 {
@@ -74,6 +81,7 @@ export function DocumentAnnotationLayer({
newCommentDisabled = false,
newCommentDisabledReason = null,
hideResolved = true,
captureSelectionRequestId,
}: AnnotationLayerProps) {
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
@@ -100,6 +108,7 @@ export function DocumentAnnotationLayer({
container,
selectedText: thread.selectedText,
});
const startIndex = next.length;
for (const range of ranges) {
for (const rect of Array.from(range.getClientRects())) {
if (rect.width === 0 || rect.height === 0) continue;
@@ -111,9 +120,13 @@ export function DocumentAnnotationLayer({
left: rect.left - overlayRect.left,
width: rect.width,
height: rect.height,
isTail: false,
});
}
}
if (next.length > startIndex) {
next[next.length - 1].isTail = true;
}
}
setHighlightRects(next);
}, [containerRef, visibleThreads]);
@@ -141,53 +154,53 @@ export function DocumentAnnotationLayer({
};
}, [computeHighlightRects]);
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 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,
});
const anchor = captureSelection();
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 });
onPendingAnchorChange(anchor);
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => document.removeEventListener("selectionchange", handleSelectionChange);
}, [containerRef, markdown, onPendingAnchorChange]);
}, [captureSelection, onPendingAnchorChange]);
useEffect(() => {
if (captureSelectionRequestId === undefined) return;
if (captureSelectionRequestId === 0) return;
const anchor = captureSelection();
if (anchor) {
onPendingAnchorChange(anchor);
onRequestComment(anchor);
}
}, [captureSelectionRequestId, captureSelection, onPendingAnchorChange, onRequestComment]);
const handleAddComment = () => {
if (pendingAnchor) onRequestComment(pendingAnchor);
@@ -202,6 +215,8 @@ export function DocumentAnnotationLayer({
<div ref={overlayRef} 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 (
<button
key={`${rect.threadId}-${index}`}
@@ -210,16 +225,17 @@ export function DocumentAnnotationLayer({
data-anchor-state={rect.anchorState}
data-status={rect.status}
data-focused={isFocused || undefined}
aria-label={`Open annotation thread`}
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"
"paperclip-doc-annotation-highlight pointer-events-auto absolute cursor-pointer rounded-sm transition-colors",
// base box treatment (replaces the previous baseline border)
isResolved
? "bg-muted-foreground/8 outline outline-1 outline-dashed outline-offset-0 outline-muted-foreground/45 hover:bg-muted-foreground/15"
: isStale
? "bg-amber-500/12 outline outline-2 outline-dashed outline-offset-0 outline-amber-500/70 hover:bg-amber-500/20 dark:bg-amber-500/18 dark:outline-amber-400/80"
: isFocused
? "border-primary bg-primary/10"
: "border-muted-foreground/50 hover:bg-muted/30",
? "bg-primary/20 outline outline-2 outline-offset-0 outline-primary/90 shadow-[0_0_0_1px_var(--color-primary-foreground)] dark:shadow-[0_0_0_1px_color-mix(in_oklab,var(--color-background)_60%,transparent)]"
: "bg-muted-foreground/15 hover:bg-muted-foreground/25",
)}
style={{
top: rect.top,
@@ -234,6 +250,25 @@ export function DocumentAnnotationLayer({
/>
);
})}
{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"
+195 -55
View File
@@ -5,14 +5,30 @@ import type {
DocumentAnnotationThreadStatus,
DocumentAnnotationThreadWithComments,
} from "@paperclipai/shared";
import { Check, Copy, RotateCcw, X } from "lucide-react";
import {
Check,
Copy,
MessageSquarePlus,
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";
@@ -38,19 +54,62 @@ export interface AnnotationPanelProps {
/** 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 as a bottom sheet style panel instead of side panel. */
/** When mobile is true, render via shadcn Sheet at the bottom instead of side panel. */
isMobile?: boolean;
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-t-xl 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(
"sticky top-4 z-30 flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md",
props.className,
)}
>
<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) => {
@@ -73,6 +132,17 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
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.");
@@ -107,17 +177,6 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
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("");
@@ -140,24 +199,25 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
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";
const startNewCommentFromSelection = useCallback(() => {
if (props.newCommentDisabled) return;
if (props.pendingAnchor) {
composerRef.current?.focus();
return;
}
props.onRequestCommentFromSelection?.();
}, [props]);
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">
<>
<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] uppercase tracking-[0.16em] text-muted-foreground">
{props.documentKey} · rev {props.documentRevisionNumber}
<p className="text-[11px] text-muted-foreground">
rev {props.documentRevisionNumber}
</p>
</div>
<Button
@@ -174,22 +234,28 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
<X className="h-4 w-4" />
</Button>
</header>
<div className="flex flex-wrap gap-1.5 border-b border-border px-3 py-2">
<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
<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)}
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",
)}
>
{entry.label}
<span className="opacity-70">{count}</span>
</Button>
<span>{entry.label}</span>
<span className={cn("tabular-nums", isActive ? "text-muted-foreground" : "text-muted-foreground/70")}>
{count}
</span>
</button>
);
})}
</div>
@@ -235,6 +301,8 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
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>
@@ -290,8 +358,29 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
</Button>
</div>
</div>
) : null}
</aside>
) : (
<div className="border-t border-border bg-card/60 px-3 py-2">
<Button
type="button"
size="sm"
variant="secondary"
className="w-full justify-start gap-2 text-[12px]"
onClick={startNewCommentFromSelection}
disabled={props.newCommentDisabled}
title={props.newCommentDisabled
? props.newCommentDisabledReason ?? undefined
: "Select text in the document, then click here (⌘⇧M)"}
data-testid="document-annotation-new-comment-cta"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
<span className="flex-1 text-left">New comment on selection</span>
<kbd className="rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] text-muted-foreground">
⌘⇧M
</kbd>
</Button>
</div>
)}
</>
);
}
@@ -307,6 +396,8 @@ function ThreadCard(props: {
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 } =
@@ -329,8 +420,8 @@ function ThreadCard(props: {
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",
"rounded-md border border-border bg-card transition-colors",
props.expanded && "ring-1 ring-ring/70",
thread.status === "resolved" && "bg-muted/30",
)}
tabIndex={0}
@@ -360,6 +451,8 @@ function ThreadCard(props: {
key={comment.id}
comment={comment}
focused={props.focusedCommentId === comment.id}
agentMap={props.agentMap}
userProfileMap={props.userProfileMap}
/>
))}
<Textarea
@@ -371,21 +464,11 @@ function ThreadCard(props: {
className="resize-y text-sm"
disabled={props.pendingReply}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<div className="flex 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"
variant="secondary"
onClick={props.onResolveToggle}
disabled={props.pendingStatus}
className="gap-1"
@@ -408,6 +491,31 @@ function ThreadCard(props: {
>
{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>
) : (
@@ -426,10 +534,15 @@ function ThreadCard(props: {
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}`}
@@ -439,15 +552,42 @@ function CommentRow({
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 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>
<span className="ml-1 text-muted-foreground">· {author.role}</span>
</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)}…`;
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, useState } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
@@ -8,7 +8,10 @@ import type {
IssueDocument,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import {
DocumentAnnotationsCountChip,
IssueDocumentAnnotations,
} from "./IssueDocumentAnnotations";
const mockAnnotationsApi = vi.hoisted(() => ({
list: vi.fn(),
@@ -124,6 +127,47 @@ function makeThread(
};
}
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;
@@ -146,17 +190,7 @@ describe("IssueDocumentAnnotations", () => {
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>
<Harness doc={doc} />
</QueryClientProvider>,
);
});
@@ -184,17 +218,7 @@ describe("IssueDocumentAnnotations", () => {
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>
<Harness doc={doc} locationHash="#document-plan&thread=thread-99" />
</QueryClientProvider>,
);
});
@@ -216,32 +240,13 @@ describe("IssueDocumentAnnotations", () => {
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>
<Harness doc={doc} draftDirty initialPanelOpen />
</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"]',
);
@@ -261,29 +266,13 @@ describe("IssueDocumentAnnotations", () => {
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>
<Harness doc={doc} initialPanelOpen />
</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();
@@ -298,4 +287,112 @@ describe("IssueDocumentAnnotations", () => {
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).toContain("· board");
expect(expandedText).toContain("UXDesigner");
expect(expandedText).toContain("· agent");
});
it("exposes a persistent + New comment on selection CTA 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).not.toBeNull();
expect(cta!.textContent).toMatch(/New comment on selection/i);
expect(cta!.textContent).toMatch(/⌘⇧M/);
});
});
+105 -36
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -9,6 +9,7 @@ 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";
export interface IssueDocumentAnnotationsProps {
issueId: string;
@@ -25,6 +26,13 @@ export interface IssueDocumentAnnotationsProps {
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({
@@ -36,14 +44,20 @@ export function IssueDocumentAnnotations({
historicalPreview,
children,
locationHash,
panelOpen,
onPanelOpenChange,
agentMap,
userProfileMap,
defaultFocusedThreadId,
}: IssueDocumentAnnotationsProps) {
const containerRef = useRef<HTMLElement | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(null);
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(defaultFocusedThreadId ?? 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);
// 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;
@@ -64,11 +78,6 @@ export function IssueDocumentAnnotations({
});
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;
@@ -77,10 +86,10 @@ export function IssueDocumentAnnotations({
if (!target || target.documentKey !== doc.key) return;
if (!target.threadId) return;
hashHandledRef.current = locationHash;
setPanelOpen(true);
onPanelOpenChange(true);
setFocusedThreadId(target.threadId);
setFocusedCommentId(target.commentId);
}, [doc.key, locationHash]);
}, [doc.key, locationHash, onPanelOpenChange]);
const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId;
const newCommentDisabledReason = historicalPreview
@@ -96,16 +105,38 @@ export function IssueDocumentAnnotations({
const handleRequestComment = useCallback((anchor: PendingAnchor) => {
if (newCommentDisabled) return;
setPendingAnchor(anchor);
setPanelOpen(true);
}, [newCommentDisabled]);
onPanelOpenChange(true);
}, [newCommentDisabled, onPanelOpenChange]);
const handleThreadFocus = useCallback((threadId: string | null) => {
setFocusedThreadId(threadId);
if (threadId) {
setPanelOpen(true);
onPanelOpenChange(true);
setFocusedCommentId(null);
}
}, []);
}, [onPanelOpenChange]);
const handleRequestCommentFromSelection = useCallback(() => {
if (newCommentDisabled) return;
// Trigger the layer to re-read the current selection and emit a pendingAnchor.
setCaptureSelectionRequestId((current) => current + 1);
}, [newCommentDisabled]);
// ⌘⇧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;
@@ -125,27 +156,6 @@ export function IssueDocumentAnnotations({
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;
@@ -167,6 +177,7 @@ export function IssueDocumentAnnotations({
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
hideResolved
captureSelectionRequestId={captureSelectionRequestId}
/>
) : null}
</section>
@@ -175,7 +186,7 @@ export function IssueDocumentAnnotations({
<DocumentAnnotationPanel
open={panelOpen}
onOpenChange={(open) => {
setPanelOpen(open);
onPanelOpenChange(open);
if (!open) {
setPendingAnchor(null);
setFocusedThreadId(null);
@@ -196,12 +207,70 @@ export function IssueDocumentAnnotations({
}}
pendingAnchor={pendingAnchor}
onClearPendingAnchor={() => setPendingAnchor(null)}
onRequestCommentFromSelection={handleRequestCommentFromSelection}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
className={isMobile ? undefined : "lg:w-[360px] lg:max-w-[360px]"}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
) : 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>
);
}
+64 -1
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,10 +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 { IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
@@ -152,6 +154,11 @@ export function IssueDocumentsSection({
imageUploadHandler,
onVote,
extraActions,
agentMap,
userProfileMap,
defaultAnnotationPanelOpenKeys,
defaultAnnotationFocusedThreadIds,
forceEditDocumentKey,
}: {
issue: Issue;
canDeleteDocuments: boolean;
@@ -167,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();
@@ -175,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);
@@ -371,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);
@@ -729,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">
@@ -939,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>
@@ -1167,6 +1225,11 @@ export function IssueDocumentsSection({
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)
+2
View File
@@ -3736,6 +3736,8 @@ export function IssueDetail() {
});
}}
extraActions={!hasAttachments ? attachmentUploadButton : null}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
{attachmentsInitialLoading ? (
@@ -1,10 +1,23 @@
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 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
@@ -91,7 +104,7 @@ function makeThread(
documentId: "doc-1",
body: "We did a small spike — happy to share results in the plan.",
authorType: "agent",
authorAgentId: "agent-1",
authorAgentId: "agent-uxdesigner",
authorUserId: null,
createdByRunId: "run-1",
createdAt: new Date("2026-05-12T10:01:00Z"),
@@ -141,7 +154,7 @@ const baseThreads: DocumentAnnotationThreadWithComments[] = [
documentId: "doc-1",
body: "Updated reader list to add the security lead.",
authorType: "agent",
authorAgentId: "agent-1",
authorAgentId: "agent-uxdesigner",
authorUserId: null,
createdByRunId: "run-1",
createdAt: new Date("2026-05-12T12:00:00Z"),
@@ -172,12 +185,259 @@ const baseThreads: DocumentAnnotationThreadWithComments[] = [
}),
];
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 }, mutations: { retry: false } },
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",
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",
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);
@@ -227,59 +487,8 @@ function StatesShowcase({ focusedThreadId = "open-1" }: { focusedThreadId?: stri
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
agentMap={integratedAgentMap}
userProfileMap={integratedUserProfileMap}
/>
</div>
</QueryClientProvider>
@@ -293,7 +502,7 @@ const meta = {
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.",
"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.",
},
},
},
@@ -303,6 +512,42 @@ 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" />,
};
@@ -318,12 +563,3 @@ export const DesktopStaleFocused: Story = {
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 />,
};