From 284508e54af69d83b8a50b25b55f29eaffb8979a Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 11 Jun 2026 18:59:54 +0000 Subject: [PATCH] =?UTF-8?q?Show=20author=20avatars=20+=20=E2=8C=98?= =?UTF-8?q?=E2=86=B5=20submit=20in=20document=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the issue document annotation panel: - Render the commenting author's avatar (agent icon or user image/ initials) next to each comment in an expanded thread. - ⌘/Ctrl+Enter now submits the new-comment composer and thread replies, with hint text in the placeholders. Widen the agent map prop to optionally carry the agent icon. Add tests for both shortcuts and avatar rendering. Co-Authored-By: Claude Opus 4.8 --- ui/src/components/DocumentAnnotationPanel.tsx | 66 +++++++++++--- .../IssueDocumentAnnotations.test.tsx | 85 ++++++++++++++++++- .../components/IssueDocumentAnnotations.tsx | 2 +- 3 files changed, 140 insertions(+), 13 deletions(-) diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index 2edad684db7..8e62ffadd7f 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -22,8 +22,11 @@ import { } from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; 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 { AgentIcon } from "./AgentIconPicker"; +import { deriveInitials } from "./Identity"; import { MarkdownBody } from "./MarkdownBody"; import type { PendingAnchor } from "./DocumentAnnotationLayer"; import type { Agent } from "@paperclipai/shared"; @@ -63,7 +66,7 @@ export interface AnnotationPanelProps { desktopWidth?: number; className?: string; /** Resolve `` to a display name. */ - agentMap?: ReadonlyMap>; + agentMap?: ReadonlyMap & Partial>>; /** Resolve `` to a display name. */ userProfileMap?: ReadonlyMap; } @@ -312,7 +315,21 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { rows={3} value={composerValue} onChange={(event) => setComposerValue(event.target.value)} - placeholder="Write a comment…" + onKeyDown={(event) => { + if (isSubmitShortcut(event)) { + event.preventDefault(); + const body = composerValue.trim(); + if ( + body + && !createThread.isPending + && !props.newCommentDisabled + && props.baseRevisionId + ) { + createThread.mutate(body); + } + } + }} + placeholder="Write a comment… (⌘↵ to submit)" disabled={props.newCommentDisabled} className="resize-y rounded-none text-sm" /> @@ -365,7 +382,7 @@ function ThreadCard(props: { onCopyLink: () => void; pendingReply: boolean; pendingStatus: boolean; - agentMap?: ReadonlyMap>; + agentMap?: ReadonlyMap & Partial>>; userProfileMap?: ReadonlyMap; }) { const { thread } = props; @@ -427,7 +444,15 @@ function ThreadCard(props: { rows={2} value={props.replyDraft} onChange={(event) => props.onReplyChange(event.target.value)} - placeholder="Reply…" + onKeyDown={(event) => { + if (isSubmitShortcut(event)) { + event.preventDefault(); + if (props.replyDraft.trim() && !props.pendingReply) { + props.onSubmitReply(); + } + } + }} + placeholder="Reply… (⌘↵ to submit)" className="resize-y rounded-none text-sm" disabled={props.pendingReply} /> @@ -506,7 +531,7 @@ function CommentRow({ }: { comment: DocumentAnnotationComment; focused: boolean; - agentMap?: ReadonlyMap>; + agentMap?: ReadonlyMap & Partial>>; userProfileMap?: ReadonlyMap; }) { const author = resolveAuthor(comment, { agentMap, userProfileMap }); @@ -520,31 +545,49 @@ function CommentRow({ )} >
- - {author.name} + + + {author.role === "agent" ? ( + + + + ) : ( + <> + {author.imageUrl ? : null} + {deriveInitials(author.name)} + + )} + + {author.name} {author.role === "agent" ? ( - · agent + · agent ) : null} - {relativeTime(comment.createdAt)} + {relativeTime(comment.createdAt)}
{comment.body} ); } +/** ⌘/Ctrl + Enter submits the composer or reply. */ +function isSubmitShortcut(event: React.KeyboardEvent): boolean { + return event.key === "Enter" && (event.metaKey || event.ctrlKey); +} + function resolveAuthor( comment: DocumentAnnotationComment, maps: { - agentMap?: ReadonlyMap>; + agentMap?: ReadonlyMap & Partial>>; userProfileMap?: ReadonlyMap; }, -): { name: string; role: "board" | "agent" } { +): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; imageUrl?: string | null } { if (comment.authorAgentId) { const agent = maps.agentMap?.get(comment.authorAgentId); return { name: agent?.name ?? comment.authorAgentId.slice(0, 8), role: "agent", + agentIcon: agent?.icon, }; } if (comment.authorUserId) { @@ -552,6 +595,7 @@ function resolveAuthor( return { name: profile?.label ?? comment.authorUserId.slice(0, 8), role: "board", + imageUrl: profile?.image ?? null, }; } return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" }; diff --git a/ui/src/components/IssueDocumentAnnotations.test.tsx b/ui/src/components/IssueDocumentAnnotations.test.tsx index db3060b949c..c25c5b7bff6 100644 --- a/ui/src/components/IssueDocumentAnnotations.test.tsx +++ b/ui/src/components/IssueDocumentAnnotations.test.tsx @@ -110,6 +110,12 @@ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { textarea.dispatchEvent(new Event("input", { bubbles: true })); } +function dispatchSubmitShortcut(textarea: HTMLTextAreaElement) { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }), + ); +} + function makeQueryClient() { return new QueryClient({ defaultOptions: { @@ -555,11 +561,15 @@ describe("IssueDocumentAnnotations", () => { await act(async () => threadCard!.click()); await flush(); - const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? ""; + const expandedThread = container.querySelector('[data-thread-id="open-1"]'); + const expandedText = expandedThread?.textContent ?? ""; expect(expandedText).toContain("Dotta"); expect(expandedText).not.toContain("· board"); expect(expandedText).toContain("UXDesigner"); expect(expandedText).toContain("· agent"); + // Each rendered comment shows an author avatar. + const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"]') ?? []; + expect(avatars.length).toBe(2); }); it("does not render a persistent New comment on selection hint when panel is open", async () => { @@ -679,6 +689,79 @@ describe("IssueDocumentAnnotations", () => { expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1); }); + it("submits a new anchored comment with ⌘↵", async () => { + mockAnnotationsApi.list.mockResolvedValue([]); + mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" })); + const root = createRoot(container); + const queryClient = makeQueryClient(); + const doc = makeDoc(); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null; + await act(async () => selectButton!.click()); + await flush(); + + const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null; + expect(composer).not.toBeNull(); + await act(async () => setTextareaValue(composer!, "Submitted via shortcut")); + await flush(); + await act(async () => dispatchSubmitShortcut(composer!)); + await flush(); + await flush(); + + expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", { + baseRevisionId: "rev-4", + baseRevisionNumber: 4, + selector: mockPendingAnchor.selector, + body: "Submitted via shortcut", + }); + }); + + it("submits a reply with ⌘↵", async () => { + mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]); + mockAnnotationsApi.addComment.mockResolvedValue(makeThread({ id: "open-1" }).comments[0]); + const root = createRoot(container); + const queryClient = makeQueryClient(); + const doc = makeDoc(); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null; + await act(async () => openThread!.click()); + await flush(); + + const reply = container.querySelector( + '[data-testid="document-annotation-reply-open-1"]', + ) as HTMLTextAreaElement | null; + expect(reply).not.toBeNull(); + await act(async () => setTextareaValue(reply!, "Replying via shortcut")); + await flush(); + await act(async () => dispatchSubmitShortcut(reply!)); + await flush(); + await flush(); + + expect(mockAnnotationsApi.addComment).toHaveBeenCalledWith("issue-1", "plan", "open-1", { + body: "Replying via shortcut", + }); + }); + it("shows resolve and reopen actions and updates thread status", async () => { mockAnnotationsApi.list.mockResolvedValue([ makeThread({ id: "open-1" }), diff --git a/ui/src/components/IssueDocumentAnnotations.tsx b/ui/src/components/IssueDocumentAnnotations.tsx index e4bd44bcbb1..b38768b36c4 100644 --- a/ui/src/components/IssueDocumentAnnotations.tsx +++ b/ui/src/components/IssueDocumentAnnotations.tsx @@ -34,7 +34,7 @@ export interface IssueDocumentAnnotationsProps { /** Controlled panel state. Caller owns this so the count chip can live in the doc header. */ panelOpen: boolean; onPanelOpenChange: (open: boolean) => void; - agentMap?: ReadonlyMap>; + agentMap?: ReadonlyMap & Partial>>; userProfileMap?: ReadonlyMap; /** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */ defaultFocusedThreadId?: string;