diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 917045c0a73..25275fba484 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -387,15 +387,17 @@ function ReplyButton({ onClick }: { onClick: () => void }) { function ReplyQuote({ replyTo, agentMap, + currentUserId, targetDeleted, onClick, }: { replyTo: IssueCommentReplyToMetadata; agentMap?: Map; + currentUserId?: string | null; targetDeleted: boolean; onClick?: () => void; }) { - const authorName = formatReplyAuthorName(replyTo, agentMap); + const authorName = formatReplyAuthorName(replyTo, { agentMap, currentUserId }); const body = ( @@ -434,6 +436,7 @@ function ReplyQuote({ function CommentCard({ comment, agentMap, + currentUserId, companyId, projectId, feedbackVote = null, @@ -450,6 +453,7 @@ function CommentCard({ }: { comment: CommentWithRunMeta; agentMap?: Map; + currentUserId?: string | null; companyId?: string | null; projectId?: string | null; feedbackVote?: FeedbackVoteValue | null; @@ -548,6 +552,7 @@ function CommentCard({ { const { excerpt, truncated } = buildReplyExcerpt(comment.body); - const authorName = comment.authorAgentId - ? agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8) - : "You"; + const authorName = formatReplyAuthorName( + { + authorType: comment.authorType, + authorAgentId: comment.authorAgentId, + authorUserId: comment.authorUserId, + }, + { agentMap, currentUserId }, + ); setReplyingTo({ commentId: comment.id, authorName, excerpt, excerptTruncated: truncated }); editorRef.current?.focus(); }, - [agentMap], + [agentMap, currentUserId], ); useEffect(() => { @@ -1224,6 +1235,7 @@ export function CommentThread({ key={comment.id} comment={comment} agentMap={agentMap} + currentUserId={currentUserId} companyId={companyId} projectId={projectId} highlightCommentId={highlightCommentId} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 1100e555b00..c633f934fb2 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -863,17 +863,21 @@ function IssueChatReplyButton({ onClick }: { onClick: () => void }) { function IssueChatReplyQuote({ replyTo, agentMap, + currentUserId, + userProfileMap, targetDeleted, onAccent, onClick, }: { replyTo: IssueCommentReplyToMetadata; agentMap?: Map; + currentUserId?: string | null; + userProfileMap?: ReadonlyMap | null; targetDeleted: boolean; onAccent?: boolean; onClick?: () => void; }) { - const authorName = formatReplyAuthorName(replyTo, agentMap); + const authorName = formatReplyAuthorName(replyTo, { agentMap, currentUserId, userProfileMap }); const barClass = onAccent ? "bg-white/60" : "bg-primary/40"; const authorClass = onAccent ? "text-white" : "text-foreground"; const excerptClass = onAccent ? "text-white/80" : "text-muted-foreground"; @@ -1692,6 +1696,8 @@ function IssueChatUserMessage({ onScrollToComment(replyTo.commentId) : undefined} @@ -1835,6 +1841,8 @@ function IssueChatAssistantMessage({ stoppingRunLabel = "Stopping...", stopRunVariant = "stop", runFinalizationActions = [], + currentUserId, + userProfileMap, onReply, onScrollToComment, highlightCommentId, @@ -2089,6 +2097,8 @@ function IssueChatAssistantMessage({ onScrollToComment(replyTo.commentId) : undefined} /> @@ -4739,7 +4749,7 @@ export function IssueChatThread({ return true; } - // --- Reply-to-comment composer state (P3, PAP-13802) --- + // --- Reply-to-comment composer state --- const [replyingTo, setReplyingTo] = useState(null); const [highlightCommentId, setHighlightCommentId] = useState(null); const highlightTimerRef = useRef | null>(null); @@ -4749,8 +4759,11 @@ export function IssueChatThread({ const ids = new Set(); for (const message of messages) { const custom = message.metadata?.custom as Record | undefined; - if (custom?.deletedAt && typeof custom.commentId === "string") { - ids.add(custom.commentId); + if (custom?.deletedAt) { + // Match the comment-id fallback used when rendering the message, so a deleted source + // without an explicit custom.commentId still marks its replies as tombstoned. + const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id; + ids.add(commentId); } } return ids; diff --git a/ui/src/fixtures/issueChatUxFixtures.ts b/ui/src/fixtures/issueChatUxFixtures.ts index 56806ef29e8..50d742bfcac 100644 --- a/ui/src/fixtures/issueChatUxFixtures.ts +++ b/ui/src/fixtures/issueChatUxFixtures.ts @@ -339,7 +339,7 @@ export const issueChatUxReviewEvents: IssueTimelineEvent[] = [ ]; /** - * Reply-to-comment (PAP-13802 P3) scenario: a sent reply with a quoted block, a reply whose + * Reply-to-comment scenario: a sent reply with a quoted block, a reply whose * source was deleted (tombstone), and the source comments they point back to. Seed * {@link issueChatUxReplyDraftKey} in localStorage to also render the composer reply chip. */ diff --git a/ui/src/lib/comment-reply.test.ts b/ui/src/lib/comment-reply.test.ts index 72abd62dcd0..9e5ba7daae6 100644 --- a/ui/src/lib/comment-reply.test.ts +++ b/ui/src/lib/comment-reply.test.ts @@ -96,21 +96,59 @@ describe("formatReplyAuthorName", () => { it("uses the agent name when the target is an agent in the map", () => { expect( - formatReplyAuthorName({ authorType: "agent", authorAgentId: "agent-1" }, agentMap), + formatReplyAuthorName({ authorType: "agent", authorAgentId: "agent-1", authorUserId: null }, { agentMap }), ).toBe("CodexCoder"); }); it("falls back to a short agent id when the agent is unknown", () => { expect( - formatReplyAuthorName({ authorType: "agent", authorAgentId: "agent-abcdef123" }, agentMap), + formatReplyAuthorName( + { authorType: "agent", authorAgentId: "agent-abcdef123", authorUserId: null }, + { agentMap }, + ), ).toBe("agent-ab"); }); it("labels system authors as System", () => { - expect(formatReplyAuthorName({ authorType: "system", authorAgentId: null })).toBe("System"); + expect( + formatReplyAuthorName({ authorType: "system", authorAgentId: null, authorUserId: null }), + ).toBe("System"); }); - it("labels user authors as You", () => { - expect(formatReplyAuthorName({ authorType: "user", authorAgentId: null })).toBe("You"); + it("labels the current user's own comment as You", () => { + expect( + formatReplyAuthorName( + { authorType: "user", authorAgentId: null, authorUserId: "user-1" }, + { currentUserId: "user-1" }, + ), + ).toBe("You"); + }); + + it("resolves another user's profile label instead of mislabeling them You", () => { + const userProfileMap = new Map([["user-2", { label: "Ada Lovelace", image: null }]]); + expect( + formatReplyAuthorName( + { authorType: "user", authorAgentId: null, authorUserId: "user-2" }, + { currentUserId: "user-1", userProfileMap }, + ), + ).toBe("Ada Lovelace"); + }); + + it("labels another user without a profile as User (never You)", () => { + expect( + formatReplyAuthorName( + { authorType: "user", authorAgentId: null, authorUserId: "user-2" }, + { currentUserId: "user-1" }, + ), + ).toBe("User"); + }); + + it("labels the board principal as Board", () => { + expect( + formatReplyAuthorName( + { authorType: "user", authorAgentId: null, authorUserId: "local-board" }, + { currentUserId: "user-1" }, + ), + ).toBe("Board"); }); }); diff --git a/ui/src/lib/comment-reply.ts b/ui/src/lib/comment-reply.ts index 1132db7a34f..b4e66b4172a 100644 --- a/ui/src/lib/comment-reply.ts +++ b/ui/src/lib/comment-reply.ts @@ -1,4 +1,5 @@ import type { Agent, IssueComment, IssueCommentReplyToMetadata } from "@paperclipai/shared"; +import type { CompanyUserProfile } from "./company-members"; /** * Max characters shown in a reply excerpt (composer chip + sent quoted header). @@ -32,16 +33,30 @@ export function buildOptimisticReplyTo(target: IssueComment): IssueCommentReplyT } /** - * Human-readable author label for a reply target. Mirrors {@link CommentCard}'s author rendering: - * agent comments show the agent name, everything else collapses to "You". + * Human-readable author label for a reply target. Mirrors the comment bubble's own author + * rendering: agent comments show the agent name; a user comment shows "You" only when it is the + * current viewer's, otherwise the resolved profile label (or "Board"/"User" when no profile is + * available). Resolving the current user matters in multi-user threads, where every non-current + * user would otherwise be mislabeled "You". */ export function formatReplyAuthorName( - replyTo: Pick, - agentMap?: Map, + replyTo: Pick, + options: { + agentMap?: Map | null; + currentUserId?: string | null; + userProfileMap?: ReadonlyMap | null; + } = {}, ): string { if (replyTo.authorAgentId) { - return agentMap?.get(replyTo.authorAgentId)?.name ?? replyTo.authorAgentId.slice(0, 8); + return options.agentMap?.get(replyTo.authorAgentId)?.name ?? replyTo.authorAgentId.slice(0, 8); } if (replyTo.authorType === "system") return "System"; - return "You"; + const authorUserId = replyTo.authorUserId ?? null; + if (authorUserId && options.currentUserId && authorUserId === options.currentUserId) { + return "You"; + } + const profileLabel = authorUserId ? options.userProfileMap?.get(authorUserId)?.label?.trim() : undefined; + if (profileLabel) return profileLabel; + if (authorUserId === "local-board") return "Board"; + return "User"; } diff --git a/ui/src/pages/IssueChatUxLab.tsx b/ui/src/pages/IssueChatUxLab.tsx index 3bada5d7953..760f71f1dd0 100644 --- a/ui/src/pages/IssueChatUxLab.tsx +++ b/ui/src/pages/IssueChatUxLab.tsx @@ -320,7 +320,7 @@ export function IssueChatUxLab() { id="reply-to-comment" eyebrow="Reply to comment" title="Reply chip, quoted block, and tombstone" - description="PAP-13802 P3: hover a bubble to reveal Reply, the composer shows a quoted/clamped source chip (click to jump to the original, X or Escape to clear), sent replies render a quoted header above the body, and a reply whose source was deleted falls back to an 'Original comment deleted' tombstone." + description="Hover a bubble to reveal Reply, the composer shows a quoted/clamped source chip (click to jump to the original, X or Escape to clear), sent replies render a quoted header above the body, and a reply whose source was deleted falls back to an 'Original comment deleted' tombstone." accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_28%),var(--background)]" >