mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Simplify document comment panel (PAP-10960)
Streamline the inline document annotation UI per board feedback: - Drop the "Comments" panel header, the Open/Resolved/Stale/Orphaned filter chips, and the "No open comments yet" empty state. The revision indicator now lives top-right next to the close button, and all anchorable threads render in a single list. - Remove the per-thread "Open · just now" status/timestamp row; keep the per-comment timestamp beside each author avatar. - Show the current user's avatar + name directly above the composer when writing a new comment. - Clamp quote snippets to two lines with a roomier line-height so the next line's letter tops are never clipped. - Drop the "⌘↵ to submit" hints from the composer/reply placeholders. - Keep the selected segment brightly highlighted in the document while a comment is being composed (pendingHighlightText) so the selection no longer disappears when the panel opens. - Make thread creation, replies, and resolve/reopen optimistic so submission feels instant instead of flashing empty then repopulating. - Clamp the floating desktop panel below the sticky top nav so the comments thread no longer tucks under the nav bar while scrolling. Adds a Storybook story (Product/Document Comments) covering the empty, populated, and composing states. Updates annotation tests for the filter-less single-list behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
17d64fcdf8
commit
1c89cf067a
5 files changed
+464
-143
No files matched your search
@@ -48,8 +48,17 @@ export interface AnnotationLayerProps {
|
||||
* pending anchor for the keyboard shortcut path.
|
||||
*/
|
||||
captureSelectionRequestId?: number;
|
||||
/**
|
||||
* Text of a comment currently being composed. We keep this segment brightly
|
||||
* highlighted in the document even after the native browser selection is lost
|
||||
* (e.g. once focus moves into the composer textarea).
|
||||
*/
|
||||
pendingHighlightText?: string | null;
|
||||
}
|
||||
|
||||
/** Synthetic thread id used to render the in-progress (pending) comment highlight. */
|
||||
const PENDING_HIGHLIGHT_THREAD_ID = "__paperclip-pending-annotation__";
|
||||
|
||||
interface HighlightRect {
|
||||
threadId: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
@@ -60,6 +69,8 @@ interface HighlightRect {
|
||||
height: number;
|
||||
/** True for the last rect of this thread (used to anchor a glyph at the run end). */
|
||||
isTail: boolean;
|
||||
/** True when this run should render with the brighter focused/pending treatment. */
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
interface ToolbarPosition {
|
||||
@@ -203,6 +214,7 @@ export function DocumentAnnotationLayer({
|
||||
newCommentDisabledReason = null,
|
||||
hideResolved = true,
|
||||
captureSelectionRequestId,
|
||||
pendingHighlightText = null,
|
||||
}: AnnotationLayerProps) {
|
||||
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
|
||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
|
||||
@@ -231,19 +243,17 @@ export function DocumentAnnotationLayer({
|
||||
const overlayRect = overlay.getBoundingClientRect();
|
||||
const next: HighlightRect[] = [];
|
||||
const nativeRanges = emptyNativeHighlightRanges();
|
||||
for (const thread of visibleThreads) {
|
||||
if (thread.anchorState === "orphaned") continue;
|
||||
const isFocused = thread.id === focusedThreadId;
|
||||
const isStale = thread.anchorState === "stale";
|
||||
const isResolved = thread.status === "resolved";
|
||||
const nativeKind = nativeHighlightKind({
|
||||
focused: isFocused,
|
||||
stale: isStale,
|
||||
resolved: isResolved,
|
||||
});
|
||||
const pushRunRects = (run: {
|
||||
threadId: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
anchorState: DocumentAnnotationAnchorState;
|
||||
focused: boolean;
|
||||
selectedText: string;
|
||||
nativeKind: NativeHighlightKind;
|
||||
}) => {
|
||||
const ranges = rangesForNormalizedSpan({
|
||||
container,
|
||||
selectedText: thread.selectedText,
|
||||
selectedText: run.selectedText,
|
||||
});
|
||||
const startIndex = next.length;
|
||||
for (const range of ranges) {
|
||||
@@ -256,9 +266,10 @@ export function DocumentAnnotationLayer({
|
||||
if (!visibleRect) continue;
|
||||
rangeIsVisible = true;
|
||||
next.push({
|
||||
threadId: thread.id,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
threadId: run.threadId,
|
||||
status: run.status,
|
||||
anchorState: run.anchorState,
|
||||
focused: run.focused,
|
||||
top: visibleRect.top - overlayRect.top,
|
||||
left: visibleRect.left - overlayRect.left,
|
||||
width: visibleRect.width,
|
||||
@@ -266,15 +277,41 @@ export function DocumentAnnotationLayer({
|
||||
isTail: false,
|
||||
});
|
||||
}
|
||||
if (rangeIsVisible) nativeRanges[nativeKind].push(range);
|
||||
if (rangeIsVisible) nativeRanges[run.nativeKind].push(range);
|
||||
}
|
||||
if (next.length > startIndex) {
|
||||
next[next.length - 1].isTail = true;
|
||||
}
|
||||
};
|
||||
for (const thread of visibleThreads) {
|
||||
if (thread.anchorState === "orphaned") continue;
|
||||
const isFocused = thread.id === focusedThreadId;
|
||||
const isStale = thread.anchorState === "stale";
|
||||
const isResolved = thread.status === "resolved";
|
||||
pushRunRects({
|
||||
threadId: thread.id,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
focused: isFocused,
|
||||
selectedText: thread.selectedText,
|
||||
nativeKind: nativeHighlightKind({ focused: isFocused, stale: isStale, resolved: isResolved }),
|
||||
});
|
||||
}
|
||||
// Keep the in-progress (pending) comment selection brightly highlighted so the
|
||||
// segment stays anchored in the document while the composer is open.
|
||||
if (pendingHighlightText && pendingHighlightText.trim().length > 0) {
|
||||
pushRunRects({
|
||||
threadId: PENDING_HIGHLIGHT_THREAD_ID,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
focused: true,
|
||||
selectedText: pendingHighlightText,
|
||||
nativeKind: "focused",
|
||||
});
|
||||
}
|
||||
setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges);
|
||||
setHighlightRects(next);
|
||||
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]);
|
||||
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, pendingHighlightText, visibleThreads]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
computeHighlightRects();
|
||||
@@ -398,7 +435,7 @@ export function DocumentAnnotationLayer({
|
||||
<div className="paperclip-doc-annotation-visual-layer pointer-events-none absolute inset-0 z-0" aria-hidden="true">
|
||||
<div className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
const isFocused = rect.focused;
|
||||
const isStale = rect.anchorState === "stale";
|
||||
const isResolved = rect.status === "resolved";
|
||||
return (
|
||||
@@ -437,7 +474,8 @@ export function DocumentAnnotationLayer({
|
||||
>
|
||||
<div ref={overlayRef} className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
if (rect.threadId === PENDING_HIGHLIGHT_THREAD_ID) return null;
|
||||
const isFocused = rect.focused;
|
||||
return (
|
||||
<button
|
||||
key={`${rect.threadId}-${index}`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationComment,
|
||||
DocumentAnnotationThreadStatus,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,6 +24,8 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { deriveInitials } from "./Identity";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
@@ -32,15 +33,6 @@ import type { PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
type AnnotationFilter = "open" | "resolved" | "stale" | "orphan";
|
||||
|
||||
const FILTERS: { id: AnnotationFilter; label: string }[] = [
|
||||
{ id: "open", label: "Open" },
|
||||
{ id: "resolved", label: "Resolved" },
|
||||
{ id: "stale", label: "Stale" },
|
||||
{ id: "orphan", label: "Orphaned" },
|
||||
];
|
||||
|
||||
export interface AnnotationPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -110,32 +102,36 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
||||
|
||||
function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<AnnotationFilter>("open");
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return props.threads.filter((thread) => {
|
||||
if (filter === "open") return thread.status === "open" && thread.anchorState !== "orphaned";
|
||||
if (filter === "resolved") return thread.status === "resolved";
|
||||
if (filter === "stale") return thread.anchorState === "stale";
|
||||
if (filter === "orphan") return thread.anchorState === "orphaned";
|
||||
return true;
|
||||
});
|
||||
}, [props.threads, filter]);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const currentUser = useMemo(() => {
|
||||
const user = session?.user;
|
||||
return {
|
||||
id: user?.id ?? null,
|
||||
name: user?.name?.trim() || user?.email?.trim() || "You",
|
||||
image: user?.image ?? null,
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result = { open: 0, resolved: 0, stale: 0, orphan: 0 };
|
||||
for (const thread of props.threads) {
|
||||
if (thread.status === "resolved") result.resolved += 1;
|
||||
if (thread.anchorState === "stale") result.stale += 1;
|
||||
if (thread.anchorState === "orphaned") result.orphan += 1;
|
||||
if (thread.status === "open" && thread.anchorState !== "orphaned") result.open += 1;
|
||||
}
|
||||
return result;
|
||||
}, [props.threads]);
|
||||
// Show every thread that can be anchored in the document (orphaned threads have
|
||||
// lost their anchor). Filters were removed in favour of a single simple list.
|
||||
const visibleThreads = useMemo(
|
||||
() => props.threads.filter((thread) => thread.anchorState !== "orphaned"),
|
||||
[props.threads],
|
||||
);
|
||||
|
||||
const annotationsQueryKey = useMemo(
|
||||
() => queryKeys.issues.documentAnnotations(props.issueId, props.documentKey, "all"),
|
||||
[props.documentKey, props.issueId],
|
||||
);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -159,27 +155,101 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
body,
|
||||
});
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
// Optimistically drop the new thread into the cache so submission feels instant.
|
||||
onMutate: async (body: string) => {
|
||||
const anchor = props.pendingAnchor;
|
||||
if (!anchor || !props.baseRevisionId) return undefined;
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticThread = buildOptimisticThread({
|
||||
body,
|
||||
selectedText: anchor.selectedText,
|
||||
issueId: props.issueId,
|
||||
documentKey: props.documentKey,
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
author: currentUser,
|
||||
});
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => [...(current ?? []), optimisticThread],
|
||||
);
|
||||
// Clear the composer immediately — the optimistic thread now carries the text.
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
props.onFocusThread(thread.id);
|
||||
invalidateAll();
|
||||
props.onFocusThread(optimisticThread.id);
|
||||
return { previous, optimisticId: optimisticThread.id };
|
||||
},
|
||||
onError: (_error, _body, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSuccess: (thread, _body, context) => {
|
||||
// Swap the optimistic placeholder for the real thread before refetch settles.
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((entry) =>
|
||||
entry.id === context?.optimisticId ? thread : entry,
|
||||
),
|
||||
);
|
||||
props.onFocusThread(thread.id);
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
const addReply = useMutation({
|
||||
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
|
||||
documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }),
|
||||
onSuccess: (_data, variables) => {
|
||||
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
|
||||
invalidateAll();
|
||||
// Optimistically append the reply so it stays on screen through the round-trip.
|
||||
onMutate: async ({ threadId, body }) => {
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticComment = buildOptimisticComment({
|
||||
body,
|
||||
threadId,
|
||||
issueId: props.issueId,
|
||||
author: currentUser,
|
||||
});
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((thread) =>
|
||||
thread.id === threadId
|
||||
? { ...thread, comments: [...thread.comments, optimisticComment], updatedAt: optimisticComment.createdAt }
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
setReplyDrafts((current) => ({ ...current, [threadId]: "" }));
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
|
||||
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
|
||||
onSuccess: () => invalidateAll(),
|
||||
onMutate: async ({ threadId, status }) => {
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((thread) =>
|
||||
thread.id === threadId ? { ...thread, status } : thread,
|
||||
),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -194,28 +264,15 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
}
|
||||
}, [props.open, props.pendingAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.focusedThreadId) return;
|
||||
const focused = props.threads.find((thread) => thread.id === props.focusedThreadId);
|
||||
if (!focused) return;
|
||||
if (focused.anchorState === "orphaned") setFilter("orphan");
|
||||
else if (focused.anchorState === "stale") setFilter("stale");
|
||||
else if (focused.status === "resolved") setFilter("resolved");
|
||||
else setFilter("open");
|
||||
}, [props.focusedThreadId, props.threads]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
<div
|
||||
data-testid={bodyTestId}
|
||||
className="flex items-start justify-between gap-2 border-b border-border bg-popover px-3 py-2.5"
|
||||
className="flex items-center justify-end gap-1 border-b border-border bg-popover px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-sm font-medium">Comments</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
rev {props.documentRevisionNumber}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
rev {props.documentRevisionNumber}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
@@ -229,31 +286,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1 border-b border-border bg-popover px-3 py-2">
|
||||
{FILTERS.map((entry) => {
|
||||
const count = counts[entry.id];
|
||||
const isActive = filter === entry.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(entry.id)}
|
||||
data-active={isActive || undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
|
||||
isActive
|
||||
? "border-border bg-muted text-foreground"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{entry.label}</span>
|
||||
<span className={cn("tabular-nums", isActive ? "text-muted-foreground" : "text-muted-foreground/70")}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{props.newCommentDisabled && props.newCommentDisabledReason ? (
|
||||
<p
|
||||
@@ -264,13 +296,9 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
</p>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
|
||||
</p>
|
||||
) : (
|
||||
{visibleThreads.length === 0 ? null : (
|
||||
<ul className="space-y-2">
|
||||
{filteredThreads.map((thread) => (
|
||||
{visibleThreads.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
@@ -306,9 +334,16 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
</div>
|
||||
{props.pendingAnchor ? (
|
||||
<div className="border-t border-border bg-popover px-3 py-2">
|
||||
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground">
|
||||
<blockquote className="mb-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{truncate(props.pendingAnchor.selectedText, 160)}
|
||||
</blockquote>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
{currentUser.image ? <AvatarImage src={currentUser.image} alt={currentUser.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(currentUser.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{currentUser.name}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
data-testid="document-annotation-composer"
|
||||
@@ -329,7 +364,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Write a comment… (⌘↵ to submit)"
|
||||
placeholder="Write a comment…"
|
||||
disabled={props.newCommentDisabled}
|
||||
className="resize-y rounded-none text-sm"
|
||||
/>
|
||||
@@ -386,14 +421,6 @@ function ThreadCard(props: {
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const { thread } = props;
|
||||
const statusVariant: { variant: "default" | "outline" | "secondary"; label: string } =
|
||||
thread.status === "resolved"
|
||||
? { variant: "outline", label: "Resolved" }
|
||||
: thread.anchorState === "orphaned"
|
||||
? { variant: "outline", label: "Orphaned" }
|
||||
: thread.anchorState === "stale"
|
||||
? { variant: "outline", label: "Stale" }
|
||||
: { variant: "default", label: "Open" };
|
||||
const latestComment = thread.comments[thread.comments.length - 1];
|
||||
|
||||
return (
|
||||
@@ -413,16 +440,10 @@ function ThreadCard(props: {
|
||||
tabIndex={0}
|
||||
onClick={props.onFocus}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant={statusVariant.variant} className="px-1.5 py-0 text-[10px] uppercase tracking-[0.12em]">
|
||||
{statusVariant.label}
|
||||
</Badge>
|
||||
<span>{relativeTime(thread.updatedAt)}</span>
|
||||
</div>
|
||||
<blockquote
|
||||
id={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground",
|
||||
"mx-3 mt-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted",
|
||||
)}
|
||||
>
|
||||
@@ -452,7 +473,7 @@ function ThreadCard(props: {
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Reply… (⌘↵ to submit)"
|
||||
placeholder="Reply…"
|
||||
className="resize-y rounded-none text-sm"
|
||||
disabled={props.pendingReply}
|
||||
/>
|
||||
@@ -601,6 +622,81 @@ function resolveAuthor(
|
||||
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
|
||||
}
|
||||
|
||||
interface OptimisticAuthor {
|
||||
id: string | null;
|
||||
name: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
function optimisticId(prefix: string): string {
|
||||
const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
||||
return `${prefix}-${random}`;
|
||||
}
|
||||
|
||||
function buildOptimisticComment(input: {
|
||||
body: string;
|
||||
threadId: string;
|
||||
issueId: string;
|
||||
author: OptimisticAuthor;
|
||||
}): DocumentAnnotationComment {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: optimisticId("optimistic-comment"),
|
||||
companyId: "",
|
||||
threadId: input.threadId,
|
||||
issueId: input.issueId,
|
||||
documentId: "",
|
||||
body: input.body,
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: input.author.id,
|
||||
createdByRunId: null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptimisticThread(input: {
|
||||
body: string;
|
||||
selectedText: string;
|
||||
issueId: string;
|
||||
documentKey: string;
|
||||
baseRevisionId: string;
|
||||
baseRevisionNumber: number;
|
||||
author: OptimisticAuthor;
|
||||
}): DocumentAnnotationThreadWithComments {
|
||||
const id = optimisticId("optimistic-thread");
|
||||
const now = new Date();
|
||||
const comment = buildOptimisticComment({
|
||||
body: input.body,
|
||||
threadId: id,
|
||||
issueId: input.issueId,
|
||||
author: input.author,
|
||||
});
|
||||
// Only the fields the panel + overlay read need to be accurate; the optimistic
|
||||
// thread is swapped for the server copy on success. Cast through unknown so we
|
||||
// don't have to fabricate every backend-only column.
|
||||
return {
|
||||
id,
|
||||
issueId: input.issueId,
|
||||
documentKey: input.documentKey,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
selectedText: input.selectedText,
|
||||
originalRevisionId: input.baseRevisionId,
|
||||
originalRevisionNumber: input.baseRevisionNumber,
|
||||
currentRevisionId: input.baseRevisionId,
|
||||
currentRevisionNumber: input.baseRevisionNumber,
|
||||
createdByUserId: input.author.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
comments: [comment],
|
||||
} as unknown as DocumentAnnotationThreadWithComments;
|
||||
}
|
||||
|
||||
function truncate(value: string, limit: number) {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
|
||||
@@ -448,10 +448,11 @@ describe("IssueDocumentAnnotations", () => {
|
||||
expect(reason!.textContent).toMatch(/draft/i);
|
||||
});
|
||||
|
||||
it("filters resolved threads behind their tab", async () => {
|
||||
it("shows open and resolved threads together in a single list (no filter tabs)", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||
makeThread({ id: "orphan-1", anchorState: "orphaned" }),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
@@ -467,19 +468,17 @@ describe("IssueDocumentAnnotations", () => {
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// Open filter shows only open
|
||||
// Open + resolved both render without any filter interaction.
|
||||
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
|
||||
|
||||
// Switch to Resolved
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||
// Orphaned threads can't be anchored in the doc, so they stay hidden.
|
||||
expect(container.querySelector('[data-thread-id="orphan-1"]')).toBeNull();
|
||||
|
||||
// The Open/Resolved/Stale/Orphaned filter chips are gone.
|
||||
const filterChip = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
["Open", "Resolved", "Stale", "Orphaned"].includes((button.textContent ?? "").trim()),
|
||||
);
|
||||
expect(filterChip).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders author name + role from agent and user maps", async () => {
|
||||
@@ -795,13 +794,7 @@ describe("IssueDocumentAnnotations", () => {
|
||||
await flush();
|
||||
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "open-1", "resolved");
|
||||
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
await flush();
|
||||
|
||||
// Resolved threads stay in the same list (filter tabs were removed).
|
||||
const resolvedThread = container.querySelector('[data-thread-id="resolved-1"]') as HTMLElement | null;
|
||||
expect(resolvedThread).not.toBeNull();
|
||||
await act(async () => resolvedThread!.click());
|
||||
|
||||
@@ -105,7 +105,12 @@ export function IssueDocumentAnnotations({
|
||||
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
);
|
||||
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
|
||||
const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top);
|
||||
// Clamp the panel below the sticky top nav (the scroll container's top edge)
|
||||
// so the comments thread never tucks under the nav bar while scrolling.
|
||||
const boundaryTop = boundaryRect?.top ?? DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const minTop = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, boundaryTop)
|
||||
+ DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const top = Math.max(minTop, rect.top);
|
||||
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
|
||||
const spaceRightOfDocument = boundaryRight
|
||||
- desiredLeft
|
||||
@@ -328,6 +333,7 @@ export function IssueDocumentAnnotations({
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
hideResolved
|
||||
captureSelectionRequestId={captureSelectionRequestId}
|
||||
pendingHighlightText={composerAnchor?.selectedText ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationThreadWithComments,
|
||||
DocumentAnnotationThreadStatus,
|
||||
DocumentAnnotationAnchorState,
|
||||
} from "@paperclipai/shared";
|
||||
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
|
||||
import type { PendingAnchor } from "@/components/DocumentAnnotationLayer";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const issueId = "issue-doc-comments";
|
||||
const documentKey = "plan";
|
||||
const currentUserId = "user-board";
|
||||
|
||||
const userProfileMap = new Map<string, CompanyUserProfile>([
|
||||
[currentUserId, { label: "Dotta", image: null }],
|
||||
["user-pm", { label: "Mara Product", image: null }],
|
||||
]);
|
||||
|
||||
function makeThread(
|
||||
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||
): DocumentAnnotationThreadWithComments {
|
||||
const id = overrides.id ?? "thread-1";
|
||||
const status: DocumentAnnotationThreadStatus = overrides.status ?? "open";
|
||||
const anchorState: DocumentAnnotationAnchorState = overrides.anchorState ?? "active";
|
||||
return {
|
||||
id,
|
||||
companyId: "co-1",
|
||||
issueId,
|
||||
documentId: "doc-1",
|
||||
documentKey,
|
||||
status,
|
||||
anchorState,
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: "rev-4",
|
||||
originalRevisionNumber: 4,
|
||||
currentRevisionId: "rev-4",
|
||||
currentRevisionNumber: 4,
|
||||
selectedText:
|
||||
"the assistant should keep the existing editor selection highlighted while the comment composer is open",
|
||||
prefixText: "We agreed ",
|
||||
suffixText: ".",
|
||||
normalizedStart: 0,
|
||||
normalizedEnd: 22,
|
||||
markdownStart: 0,
|
||||
markdownEnd: 22,
|
||||
anchorSelector: {
|
||||
quote: { exact: "selection", prefix: "We ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: currentUserId,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-06-12T00:01:00Z"),
|
||||
updatedAt: new Date("2026-06-12T00:02:00Z"),
|
||||
comments: [
|
||||
{
|
||||
id: `${id}-c1`,
|
||||
companyId: "co-1",
|
||||
threadId: id,
|
||||
issueId,
|
||||
documentId: "doc-1",
|
||||
body: "Please confirm this is still the behaviour we want.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-pm",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-06-12T00:01:00Z"),
|
||||
updatedAt: new Date("2026-06-12T00:01:00Z"),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingAnchor: PendingAnchor = {
|
||||
selector: {
|
||||
quote: { exact: "extremely snappy", prefix: "feels ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 16, markdownStart: 0, markdownEnd: 16 },
|
||||
},
|
||||
selectedText:
|
||||
"submission of comments should be optimistic so the whole interaction feels extremely snappy",
|
||||
};
|
||||
|
||||
function PanelFrame({
|
||||
label,
|
||||
threads,
|
||||
focusedThreadId = null,
|
||||
pending = null,
|
||||
}: {
|
||||
label: string;
|
||||
threads: DocumentAnnotationThreadWithComments[];
|
||||
focusedThreadId?: string | null;
|
||||
pending?: PendingAnchor | null;
|
||||
}) {
|
||||
const [client] = useState(() => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(queryKeys.auth.session, {
|
||||
session: { id: "sess-1", userId: currentUserId },
|
||||
user: { id: currentUserId, email: "dotta@magicmachine.co", name: "Dotta", image: null },
|
||||
});
|
||||
return qc;
|
||||
});
|
||||
const [focused, setFocused] = useState<string | null>(focusedThreadId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="paperclip-story__label">{label}</div>
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="h-[460px]">
|
||||
<DocumentAnnotationPanel
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
issueId={issueId}
|
||||
documentKey={documentKey}
|
||||
documentRevisionNumber={4}
|
||||
baseRevisionId="rev-4"
|
||||
baseRevisionNumber={4}
|
||||
threads={threads}
|
||||
focusedThreadId={focused}
|
||||
onFocusThread={setFocused}
|
||||
focusedCommentId={null}
|
||||
pendingAnchor={pending}
|
||||
onClearPendingAnchor={() => undefined}
|
||||
desktopWidth={360}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentCommentsMatrix() {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner">
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="paperclip-story__label">Document comments · PAP-10960</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">Simplified annotation panel</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
No header, no filter chips, no empty-state copy. Revision indicator sits top-right.
|
||||
The composer shows the author avatar + name, two-line clamped quote, and a plain
|
||||
"Reply" affordance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-6 p-5">
|
||||
<PanelFrame label="No comments yet" threads={[]} />
|
||||
<PanelFrame
|
||||
label="With comments (one expanded)"
|
||||
threads={[
|
||||
makeThread({ id: "thread-1" }),
|
||||
makeThread({ id: "thread-2", status: "resolved", selectedText: "a resolved thread stays in the same list" }),
|
||||
]}
|
||||
focusedThreadId="thread-1"
|
||||
/>
|
||||
<PanelFrame label="Composing a new comment" threads={[makeThread({ id: "thread-1" })]} pending={pendingAnchor} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Document Comments",
|
||||
component: DocumentCommentsMatrix,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Inline document comment panel (PAP-10960): simplified to drop the header, filter chips, and empty-state, surface the author identity in the composer, and clamp quotes to two lines.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof DocumentCommentsMatrix>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Panel: Story = {};
|
||||
Reference in new issue
Block a user