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>
No files matched your search
|
Before Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 217 KiB |
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />,
|
||||
};
|
||||