From 5ce75c3adfd81c489717fe6f2f99d1b9712692d5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 12:35:45 -0500 Subject: [PATCH] Resolve human user names and avatars in activity feeds Activity feeds, issue detail, dashboard, chat thread, and live update toasts were showing raw user IDs (e.g. "FdpsZ") or generic "Board" for human users. Now all these surfaces resolve the user directory to display actual names and profile images. Co-Authored-By: Paperclip --- ui/src/components/ActivityRow.tsx | 11 ++++++--- ui/src/components/IssueChatThread.tsx | 16 +++++++++--- ui/src/context/LiveUpdatesProvider.tsx | 16 +++++++++++- ui/src/lib/activity-format.ts | 30 ++++++++++++++++++----- ui/src/lib/issue-chat-messages.ts | 23 ++++++++++------- ui/src/pages/Activity.tsx | 14 +++++++++++ ui/src/pages/Dashboard.tsx | 14 +++++++++++ ui/src/pages/IssueDetail.tsx | 34 +++++++++++++++++--------- 8 files changed, 124 insertions(+), 34 deletions(-) diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index dbe88785275..c969a9599aa 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -4,6 +4,7 @@ import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; import { formatActivityVerb } from "../lib/activity-format"; import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared"; +import type { CompanyUserProfile } from "../lib/company-members"; function entityLink(entityType: string, entityId: string, name?: string | null): string | null { switch (entityType) { @@ -19,13 +20,14 @@ function entityLink(entityType: string, entityId: string, name?: string | null): interface ActivityRowProps { event: ActivityEvent; agentMap: Map; + userProfileMap?: Map; entityNameMap: Map; entityTitleMap?: Map; className?: string; } -export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { - const verb = formatActivityVerb(event.action, event.details, { agentMap }); +export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { + const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap }); const isHeartbeatEvent = event.entityType === "heartbeat_run"; const heartbeatAgentId = isHeartbeatEvent @@ -43,13 +45,16 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl : entityLink(event.entityType, event.entityId, name); const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null; - const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown"); + const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null; + const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown")); + const actorAvatarUrl = userProfile?.image ?? null; const inner = (

diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 00d3df61241..f9405948fb8 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -88,6 +88,7 @@ interface IssueChatMessageContext { feedbackTermsUrl: string | null; agentMap?: Map; currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, @@ -192,6 +193,7 @@ interface IssueChatThreadProps { issueStatus?: string; agentMap?: Map; currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, @@ -444,12 +446,13 @@ function formatTimelineAssigneeLabel( assignee: IssueTimelineAssignee, agentMap?: Map, currentUserId?: string | null, + userLabelMap?: ReadonlyMap | null, ) { if (assignee.agentId) { return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8); } if (assignee.userId) { - return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board"; + return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board"; } return "Unassigned"; } @@ -1402,7 +1405,7 @@ function IssueChatFeedbackButtons({ } function IssueChatSystemMessage() { - const { agentMap, currentUserId } = useContext(IssueChatCtx); + const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1458,11 +1461,11 @@ function IssueChatSystemMessage() { Assignee - {formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId)} + {formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)} - {formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId)} + {formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)}

) : null} @@ -1778,6 +1781,7 @@ export function IssueChatThread({ issueStatus, agentMap, currentUserId, + userLabelMap, onVote, onAdd, onCancelRun, @@ -1856,6 +1860,7 @@ export function IssueChatThread({ projectId, agentMap, currentUserId, + userLabelMap, }), [ comments, @@ -1870,6 +1875,7 @@ export function IssueChatThread({ projectId, agentMap, currentUserId, + userLabelMap, ], ); @@ -1912,6 +1918,7 @@ export function IssueChatThread({ feedbackTermsUrl, agentMap, currentUserId, + userLabelMap, onVote, onInterruptQueued, interruptingQueuedRunId, @@ -1923,6 +1930,7 @@ export function IssueChatThread({ feedbackTermsUrl, agentMap, currentUserId, + userLabelMap, onVote, onInterruptQueued, interruptingQueuedRunId, diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index ac66d5e06ab..f5647f7f830 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -3,6 +3,7 @@ import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-quer import type { Agent, Issue, LiveEvent } from "@paperclipai/shared"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; +import type { CompanyUserDirectoryResponse } from "../api/access"; import { authApi } from "../api/auth"; import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; @@ -50,6 +51,19 @@ function resolveAgentName( return agent?.name ?? null; } +function resolveUserName( + queryClient: QueryClient, + companyId: string, + userId: string, +): string | null { + const directory = queryClient.getQueryData( + queryKeys.access.companyUserDirectory(companyId), + ); + if (!directory) return null; + const entry = directory.users.find((u) => u.principalId === userId); + return entry?.user?.name?.trim() || entry?.user?.email?.trim() || null; +} + function truncate(text: string, max: number): string { if (text.length <= max) return text; return text.slice(0, max - 1) + "\u2026"; @@ -66,7 +80,7 @@ function resolveActorLabel( } if (actorType === "system") return "System"; if (actorType === "user" && actorId) { - return "Board"; + return resolveUserName(queryClient, companyId, actorId) ?? "Board"; } return "Someone"; } diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index bfebb113744..fa8ee21e46c 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -1,4 +1,5 @@ import type { Agent } from "@paperclipai/shared"; +import type { CompanyUserProfile } from "./company-members"; type ActivityDetails = Record | null | undefined; @@ -16,6 +17,7 @@ type ActivityIssueReference = { interface ActivityFormatOptions { agentMap?: Map; + userProfileMap?: Map; currentUserId?: string | null; } @@ -116,9 +118,11 @@ function readIssueReferences(details: ActivityDetails, key: string): ActivityIss return value.filter(isActivityIssueReference); } -function formatUserLabel(userId: string | null | undefined, currentUserId?: string | null): string { +function formatUserLabel(userId: string | null | undefined, options: ActivityFormatOptions = {}): string { if (!userId || userId === "local-board") return "Board"; - if (currentUserId && userId === currentUserId) return "You"; + if (options.currentUserId && userId === options.currentUserId) return "You"; + const profile = options.userProfileMap?.get(userId); + if (profile) return profile.label; return `user ${userId.slice(0, 5)}`; } @@ -127,7 +131,7 @@ function formatParticipantLabel(participant: ActivityParticipant, options: Activ const agentId = participant.agentId ?? ""; return options.agentMap?.get(agentId)?.name ?? "agent"; } - return formatUserLabel(participant.userId, options.currentUserId); + return formatUserLabel(participant.userId, options); } function formatIssueReferenceLabel(reference: ActivityIssueReference): string { @@ -165,7 +169,20 @@ function formatIssueUpdatedVerb(details: ActivityDetails): string | null { return null; } -function formatIssueUpdatedAction(details: ActivityDetails): string | null { +function formatAssigneeName(details: ActivityDetails, options: ActivityFormatOptions): string | null { + if (!details) return null; + const agentId = details.assigneeAgentId; + const userId = details.assigneeUserId; + if (typeof agentId === "string" && agentId) { + return options.agentMap?.get(agentId)?.name ?? "agent"; + } + if (typeof userId === "string" && userId) { + return formatUserLabel(userId, options); + } + return null; +} + +function formatIssueUpdatedAction(details: ActivityDetails, options: ActivityFormatOptions = {}): string | null { if (!details) return null; const previous = asRecord(details._previous) ?? {}; const parts: string[] = []; @@ -187,7 +204,8 @@ function formatIssueUpdatedAction(details: ActivityDetails): string | null { ); } if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) { - parts.push(details.assigneeAgentId || details.assigneeUserId ? "assigned the issue" : "unassigned the issue"); + const assigneeName = formatAssigneeName(details, options); + parts.push(assigneeName ? `assigned the issue to ${assigneeName}` : "unassigned the issue"); } if (details.title !== undefined) parts.push("updated the title"); if (details.description !== undefined) parts.push("updated the description"); @@ -264,7 +282,7 @@ export function formatIssueActivityAction( options: ActivityFormatOptions = {}, ): string { if (action === "issue.updated") { - const issueUpdatedAction = formatIssueUpdatedAction(details); + const issueUpdatedAction = formatIssueUpdatedAction(details, options); if (issueUpdatedAction) return issueUpdatedAction; } diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index e9c9ecc6e79..850535a0ee7 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -230,11 +230,12 @@ function authorNameForComment( comment: IssueChatComment, agentMap?: Map, currentUserId?: string | null, + userLabelMap?: ReadonlyMap | null, ) { if (comment.authorAgentId) { return agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8); } - return formatAssigneeUserLabel(comment.authorUserId ?? null, currentUserId) ?? "You"; + return formatAssigneeUserLabel(comment.authorUserId ?? null, currentUserId, userLabelMap) ?? "You"; } function formatStatusLabel(status: string) { @@ -245,12 +246,13 @@ function createCommentMessage(args: { comment: IssueChatComment; agentMap?: Map; currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; companyId?: string | null; projectId?: string | null; }): ThreadMessage { - const { comment, agentMap, currentUserId, companyId, projectId } = args; + const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args; const createdAt = toDate(comment.createdAt); - const authorName = authorNameForComment(comment, agentMap, currentUserId); + const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap); const custom = { kind: "comment", commentId: comment.id, @@ -295,13 +297,14 @@ function createTimelineEventMessage(args: { event: IssueTimelineEvent; agentMap?: Map; currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; }) { - const { event, agentMap, currentUserId } = args; + const { event, agentMap, currentUserId, userLabelMap } = args; const actorName = event.actorType === "agent" ? (agentMap?.get(event.actorId)?.name ?? event.actorId.slice(0, 8)) : event.actorType === "system" ? "System" - : (formatAssigneeUserLabel(event.actorId, currentUserId) ?? "Board"); + : (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board"); const lines: string[] = [`${actorName} updated this issue`]; if (event.statusChange) { @@ -312,10 +315,10 @@ function createTimelineEventMessage(args: { if (event.assigneeChange) { const from = event.assigneeChange.from.agentId ? (agentMap?.get(event.assigneeChange.from.agentId)?.name ?? event.assigneeChange.from.agentId.slice(0, 8)) - : (formatAssigneeUserLabel(event.assigneeChange.from.userId, currentUserId) ?? "Unassigned"); + : (formatAssigneeUserLabel(event.assigneeChange.from.userId, currentUserId, userLabelMap) ?? "Unassigned"); const to = event.assigneeChange.to.agentId ? (agentMap?.get(event.assigneeChange.to.agentId)?.name ?? event.assigneeChange.to.agentId.slice(0, 8)) - : (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId) ?? "Unassigned"); + : (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId, userLabelMap) ?? "Unassigned"); lines.push(`Assignee: ${from} -> ${to}`); } @@ -703,6 +706,7 @@ export function buildIssueChatMessages(args: { projectId?: string | null; agentMap?: Map; currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; }) { const { comments, @@ -718,6 +722,7 @@ export function buildIssueChatMessages(args: { projectId, agentMap, currentUserId, + userLabelMap, } = args; const orderedMessages: MessageWithOrder[] = []; @@ -726,7 +731,7 @@ export function buildIssueChatMessages(args: { orderedMessages.push({ createdAtMs: toTimestamp(comment.createdAt), order: 1, - message: createCommentMessage({ comment, agentMap, currentUserId, companyId, projectId }), + message: createCommentMessage({ comment, agentMap, currentUserId, userLabelMap, companyId, projectId }), }); } @@ -734,7 +739,7 @@ export function buildIssueChatMessages(args: { orderedMessages.push({ createdAtMs: toTimestamp(event.createdAt), order: 0, - message: createTimelineEventMessage({ event, agentMap, currentUserId }), + message: createTimelineEventMessage({ event, agentMap, currentUserId, userLabelMap }), }); } diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx index cfb347bba3d..685b7974cb8 100644 --- a/ui/src/pages/Activity.tsx +++ b/ui/src/pages/Activity.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { activityApi } from "../api/activity"; +import { accessApi } from "../api/access"; import { agentsApi } from "../api/agents"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { goalsApi } from "../api/goals"; +import { buildCompanyUserProfileMap } from "../lib/company-members"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -60,6 +62,17 @@ export function Activity() { enabled: !!selectedCompanyId, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const userProfileMap = useMemo( + () => buildCompanyUserProfileMap(companyMembers?.users), + [companyMembers?.users], + ); + const agentMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(a.id, a); @@ -129,6 +142,7 @@ export function Activity() { key={event.id} event={event} agentMap={agentMap} + userProfileMap={userProfileMap} entityNameMap={entityNameMap} entityTitleMap={entityTitleMap} /> diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index c30f3bb4f11..8112a95fd60 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -3,10 +3,12 @@ import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { dashboardApi } from "../api/dashboard"; import { activityApi } from "../api/activity"; +import { accessApi } from "../api/access"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { heartbeatsApi } from "../api/heartbeats"; +import { buildCompanyUserProfileMap } from "../lib/company-members"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -80,6 +82,17 @@ export function Dashboard() { enabled: !!selectedCompanyId, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const userProfileMap = useMemo( + () => buildCompanyUserProfileMap(companyMembers?.users), + [companyMembers?.users], + ); + const recentIssues = issues ? getRecentIssues(issues) : []; const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]); @@ -318,6 +331,7 @@ export function Dashboard() { key={event.id} event={event} agentMap={agentMap} + userProfileMap={userProfileMap} entityNameMap={entityNameMap} entityTitleMap={entityTitleMap} className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 90c976d90fa..d26d37f536b 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -17,7 +17,7 @@ import { usePanel } from "../context/PanelContext"; import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; -import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members"; import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { @@ -222,14 +222,17 @@ function mergeOptimisticFeedbackVote( ]; } -function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map }) { +function ActorIdentity({ evt, agentMap, userProfileMap }: { evt: ActivityEvent; agentMap: Map; userProfileMap?: Map }) { const id = evt.actorId; if (evt.actorType === "agent") { const agent = agentMap.get(id); return ; } if (evt.actorType === "system") return ; - if (evt.actorType === "user") return ; + if (evt.actorType === "user") { + const profile = userProfileMap?.get(id); + return ; + } return ; } @@ -534,8 +537,8 @@ export function IssueDetail() { enabled: !!selectedCompanyId, }); const { data: companyMembers } = useQuery({ - queryKey: queryKeys.access.companyMembers(selectedCompanyId!), - queryFn: () => accessApi.listMembers(selectedCompanyId!), + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); @@ -589,6 +592,14 @@ export function IssueDetail() { for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); + const userProfileMap = useMemo( + () => buildCompanyUserProfileMap(companyMembers?.users), + [companyMembers?.users], + ); + const userLabelMap = useMemo( + () => buildCompanyUserLabelMap(companyMembers?.users), + [companyMembers?.users], + ); const transcriptRuns = useMemo( () => resolveIssueChatTranscriptRuns({ @@ -611,9 +622,9 @@ export function IssueDetail() { return buildMarkdownMentionOptions({ agents, projects: orderedProjects, - members: companyMembers?.members, + members: companyMembers?.users, }); - }, [agents, companyMembers?.members, orderedProjects]); + }, [agents, companyMembers?.users, orderedProjects]); const resolvedProject = useMemo( () => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null), @@ -665,7 +676,7 @@ export function IssueDetail() { const commentReassignOptions = useMemo(() => { const options: Array<{ id: string; label: string; searchText?: string }> = []; - options.push(...buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId] })); + options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] })); const activeAgents = [...(agents ?? [])] .filter((agent) => agent.status !== "terminated") .sort((a, b) => a.name.localeCompare(b.name)); @@ -676,7 +687,7 @@ export function IssueDetail() { options.push({ id: `user:${currentUserId}`, label: "Me" }); } return options; - }, [agents, companyMembers?.members, currentUserId]); + }, [agents, companyMembers?.users, currentUserId]); const actualAssigneeValue = useMemo( () => assigneeValueFromSelection(issue ?? {}), @@ -2133,6 +2144,7 @@ export function IssueDetail() { issueStatus={issue.status} agentMap={agentMap} currentUserId={currentUserId} + userLabelMap={userLabelMap} enableLiveTranscriptPolling={false} transcriptsByRunId={issueChatTranscriptByRun} hasOutputForRun={issueChatHasOutputForRun} @@ -2237,8 +2249,8 @@ export function IssueDetail() {
{activity.slice(0, 20).map((evt) => (
- - {formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })} + + {formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })} {relativeTime(evt.createdAt)}
))}