Show author avatars + ⌘↵ submit in document comments

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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-06-16 10:02:03 +00:00
1 parent 5791df13ac
commit 284508e54a
3 files changed
+140 -13

No files matched your search

+55 -11
View File
@@ -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 `<authorAgentId>` to a display name. */
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
/** Resolve `<authorUserId>` to a display name. */
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}
@@ -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<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
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<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const author = resolveAuthor(comment, { agentMap, userProfileMap });
@@ -520,31 +545,49 @@ function CommentRow({
)}
>
<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="flex min-w-0 items-center gap-1.5">
<Avatar size="xs" className="shrink-0">
{author.role === "agent" ? (
<AvatarFallback>
<AgentIcon icon={author.agentIcon} className="h-3 w-3" />
</AvatarFallback>
) : (
<>
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
</>
)}
</Avatar>
<span className="truncate font-medium text-foreground">{author.name}</span>
{author.role === "agent" ? (
<span className="ml-1 text-muted-foreground">· agent</span>
<span className="text-muted-foreground">· agent</span>
) : null}
</span>
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
</div>
);
}
/** ⌘/Ctrl + Enter submits the composer or reply. */
function isSubmitShortcut(event: React.KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
}
function resolveAuthor(
comment: DocumentAnnotationComment,
maps: {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
},
): { 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" };
@@ -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(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen />
</QueryClientProvider>,
);
});
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" }),
@@ -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<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
defaultFocusedThreadId?: string;