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;