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)}
))}