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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-13 12:35:45 -05:00
1 parent 5c597993cd
commit 5ce75c3adf
8 files changed
+124 -34

No files matched your search

+8 -3
View File
@@ -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<string, Agent>;
userProfileMap?: Map<string, CompanyUserProfile>;
entityNameMap: Map<string, string>;
entityTitleMap?: Map<string, string>;
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 = (
<div className="flex gap-3">
<p className="flex-1 min-w-0 truncate">
<Identity
name={actorName}
avatarUrl={actorAvatarUrl}
size="xs"
className="align-baseline"
/>
+12 -4
View File
@@ -88,6 +88,7 @@ interface IssueChatMessageContext {
feedbackTermsUrl: string | null;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
@@ -192,6 +193,7 @@ interface IssueChatThreadProps {
issueStatus?: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
@@ -444,12 +446,13 @@ function formatTimelineAssigneeLabel(
assignee: IssueTimelineAssignee,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
userLabelMap?: ReadonlyMap<string, string> | 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<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@@ -1458,11 +1461,11 @@ function IssueChatSystemMessage() {
Assignee
</span>
<span className="text-muted-foreground">
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId)}
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId)}
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)}
</span>
</div>
) : 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,
+15 -1
View File
@@ -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<CompanyUserDirectoryResponse>(
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";
}
+24 -6
View File
@@ -1,4 +1,5 @@
import type { Agent } from "@paperclipai/shared";
import type { CompanyUserProfile } from "./company-members";
type ActivityDetails = Record<string, unknown> | null | undefined;
@@ -16,6 +17,7 @@ type ActivityIssueReference = {
interface ActivityFormatOptions {
agentMap?: Map<string, Agent>;
userProfileMap?: Map<string, CompanyUserProfile>;
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;
}
+14 -9
View File
@@ -230,11 +230,12 @@ function authorNameForComment(
comment: IssueChatComment,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
userLabelMap?: ReadonlyMap<string, string> | 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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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 }),
});
}
+14
View File
@@ -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<string, Agent>();
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}
/>
+14
View File
@@ -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}
+23 -11
View File
@@ -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<string, Agent> }) {
function ActorIdentity({ evt, agentMap, userProfileMap }: { evt: ActivityEvent; agentMap: Map<string, Agent>; userProfileMap?: Map<string, import("../lib/company-members").CompanyUserProfile> }) {
const id = evt.actorId;
if (evt.actorType === "agent") {
const agent = agentMap.get(id);
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
}
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
if (evt.actorType === "user") return <Identity name="Board" size="sm" />;
if (evt.actorType === "user") {
const profile = userProfileMap?.get(id);
return <Identity name={profile?.label ?? "Board"} avatarUrl={profile?.image} size="sm" />;
}
return <Identity name={id || "Unknown"} size="sm" />;
}
@@ -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() {
<div className="space-y-1.5">
{activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ActorIdentity evt={evt} agentMap={agentMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
))}