From 663d50e29b7339dcd6fb1fd2d3d37c9ddd67f218 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:14:29 +0000 Subject: [PATCH] feat(dashboard): show conversation participant avatars Co-Authored-By: David Cramer --- .../client/components/ConversationSummary.tsx | 26 ++-- .../components/ParticipantAvatarStack.tsx | 146 ++++++++++++++++++ .../src/client/components/Tooltip.tsx | 4 + .../conversations/ConversationHomeList.tsx | 15 +- .../client/conversations/ConversationMeta.tsx | 49 +++--- .../junior-dashboard/src/client/format.ts | 1 + .../src/client/pages/dev/ComponentsPage.tsx | 23 +++ packages/junior-dashboard/src/client/types.ts | 1 + .../src/mock-reporting/fixtures.ts | 6 + .../junior/src/api/conversations/detail.ts | 8 + packages/junior/src/api/conversations/list.ts | 9 ++ .../src/api/conversations/participants.ts | 131 ++++++++++++++++ .../junior/src/api/schema/conversation.ts | 1 + .../api/conversations/list.test.ts | 11 ++ 14 files changed, 380 insertions(+), 51 deletions(-) create mode 100644 packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx create mode 100644 packages/junior/src/api/conversations/participants.ts diff --git a/packages/junior-dashboard/src/client/components/ConversationSummary.tsx b/packages/junior-dashboard/src/client/components/ConversationSummary.tsx index ecca4fa855..3682309752 100644 --- a/packages/junior-dashboard/src/client/components/ConversationSummary.tsx +++ b/packages/junior-dashboard/src/client/components/ConversationSummary.tsx @@ -2,12 +2,14 @@ import { Link } from "react-router"; import { conversationDisplayTitle, - conversationActorLabel, locationPath, - peoplePath, slackLocationLabel, } from "../format"; import type { Conversation } from "../types"; +import { + conversationParticipants, + ParticipantAvatarStack, +} from "./ParticipantAvatarStack"; /** Render the shared conversation title and identity. */ export function ConversationSummary(props: { conversation: Conversation }) { @@ -24,8 +26,7 @@ export function ConversationSummary(props: { conversation: Conversation }) { } function ConversationIdentity(props: { conversation: Conversation }) { - const email = props.conversation.actorIdentity?.email?.trim(); - const owner = conversationActorLabel(props.conversation); + const participants = conversationParticipants(props.conversation); const id = props.conversation.id; const location = slackLocationLabel(props.conversation, { includeId: false, @@ -51,19 +52,12 @@ function ConversationIdentity(props: { conversation: Conversation }) { {" · "} ) : null} - {email ? ( - event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - to={peoplePath(email)} - > - {owner} - - ) : owner ? ( - owner + {participants.length > 0 ? ( + + + ) : null} - {owner ? " · " : null} + {participants.length > 0 ? " · " : null} {id} ); diff --git a/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx b/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx new file mode 100644 index 0000000000..54e3e4fb3e --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx @@ -0,0 +1,146 @@ +import type { ActorIdentity } from "@sentry/junior/api/schema"; + +import { actorLabel } from "../format"; +import { cn } from "../styles"; +import type { Conversation } from "../types"; +import { Tooltip } from "./Tooltip"; + +const MAX_VISIBLE_PARTICIPANTS = 3; + +type ParticipantAvatarStackProps = { + participants: readonly ActorIdentity[]; + size: "detail" | "list"; +}; + +function participantName(participant: ActorIdentity): string { + return ( + participant.fullName?.trim() || + participant.slackUserName?.trim() || + participant.email?.trim() || + participant.slackUserId?.trim() || + "Unknown actor" + ); +} + +function participantDescription(participant: ActorIdentity): string { + const name = participantName(participant); + const email = participant.email?.trim(); + return email && email !== name ? `${name}, ${email}` : name; +} + +function participantInitials(participant: ActorIdentity): string { + const name = participantName(participant); + const words = name.split(/\s+/).filter(Boolean); + if (words.length > 1) { + return `${words[0]![0] ?? ""}${words.at(-1)?.[0] ?? ""}`.toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +function participantKey(participant: ActorIdentity, index: number): string { + return ( + participant.email?.trim().toLowerCase() || + participant.slackUserId?.trim() || + participant.slackUserName?.trim() || + `${actorLabel(participant) ?? "actor"}:${index}` + ); +} + +function ParticipantTooltipContent(props: { participant: ActorIdentity }) { + const name = participantName(props.participant); + const email = props.participant.email?.trim(); + return ( + + {name} + {email && email !== name ? ( + {email} + ) : null} + + ); +} + +function Avatar(props: { + participant: ActorIdentity; + size: ParticipantAvatarStackProps["size"]; +}) { + const label = participantDescription(props.participant); + return ( + } + focusable + triggerClassName="-ml-1.5 first:ml-0 pointer-events-none [@media(hover:hover)_and_(pointer:fine)]:pointer-events-auto" + > + event.stopPropagation()} + tabIndex={0} + > + {participantInitials(props.participant)} + + + ); +} + +/** Use projected participants, with the root actor as a legacy fallback. */ +export function conversationParticipants( + conversation: Conversation | undefined, +): readonly ActorIdentity[] { + if (conversation?.participants?.length) return conversation.participants; + return conversation?.actorIdentity ? [conversation.actorIdentity] : []; +} + +/** Show Conversation actors in first-appearance order. */ +export function ParticipantAvatarStack(props: ParticipantAvatarStackProps) { + if (props.participants.length === 0) return null; + const visible = props.participants.slice(0, MAX_VISIBLE_PARTICIPANTS); + const hidden = props.participants.slice(MAX_VISIBLE_PARTICIPANTS); + const label = props.participants.map(participantDescription).join("; "); + return ( + + {visible.map((participant, index) => ( + + ))} + {hidden.length > 0 ? ( + + {hidden.map((participant, index) => ( + + {participantDescription(participant)} + + ))} + + } + focusable + triggerClassName="-ml-1.5 pointer-events-none [@media(hover:hover)_and_(pointer:fine)]:pointer-events-auto" + > + event.stopPropagation()} + tabIndex={0} + > + +{hidden.length} + + + ) : null} + + ); +} diff --git a/packages/junior-dashboard/src/client/components/Tooltip.tsx b/packages/junior-dashboard/src/client/components/Tooltip.tsx index 29c309b0c1..9ff6aa1ed1 100644 --- a/packages/junior-dashboard/src/client/components/Tooltip.tsx +++ b/packages/junior-dashboard/src/client/components/Tooltip.tsx @@ -16,6 +16,7 @@ type TooltipProps = { children: ReactElement; className?: string; content: ReactNode; + focusable?: boolean; label?: ReactNode; placement?: "above" | "below"; triggerClassName?: string; @@ -51,6 +52,7 @@ export function Tooltip({ children, className, content, + focusable = false, label, placement = "above", triggerClassName, @@ -121,6 +123,8 @@ export function Tooltip({ setOpen(false) : undefined} + onFocus={focusable ? () => setOpen(true) : undefined} onPointerCancel={() => { touchStartedOpenRef.current = null; }} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx index a5f909af9b..85120a8d58 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx @@ -3,7 +3,6 @@ import { Archive, ArchiveRestore } from "lucide-react"; import { Link } from "react-router"; import { - conversationActorLabel, conversationDisplayTitle, formatRelativeTime, slackLocationLabel, @@ -11,6 +10,10 @@ import { } from "../format"; import { EmptyTelemetry } from "../components/EmptyTelemetry"; import { Skeleton } from "../components/Skeleton"; +import { + conversationParticipants, + ParticipantAvatarStack, +} from "../components/ParticipantAvatarStack"; import { cn } from "../styles"; import type { Conversation } from "../types"; import { ConversationSidebarAnnotations } from "./ConversationMeta"; @@ -208,7 +211,7 @@ function ConversationCard(props: { const status = visualStatusForConversation(conversation); const title = conversationDisplayTitle(conversation); const location = slackLocationLabel(conversation, { includeId: false }); - const actor = conversationActorLabel(conversation); + const participants = conversationParticipants(conversation); const isPrivate = conversation.visibility === "private"; return (
{location ? {location} : null} - {location && actor ? : null} - {actor ? {actor} : null} + {location && participants.length > 0 ? ( + + ) : null} + {participants.length > 0 ? ( + + ) : null} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx b/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx index ca529f2d60..a982770eb1 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx @@ -13,9 +13,7 @@ import { Link } from "react-router"; import type { ConversationDetailReport } from "@sentry/junior/api/schema"; import { - conversationActorLabel, formatConversationDuration, - peoplePath, slackLocationLabel, summarizeCost, summarizeModelUsage, @@ -23,6 +21,10 @@ import { automationPath, } from "../format"; import { Tooltip } from "../components/Tooltip"; +import { + conversationParticipants, + ParticipantAvatarStack, +} from "../components/ParticipantAvatarStack"; import { MetricList, type MetricListItem } from "../components/Metric"; import { cn } from "../styles"; import { CostMetric, DurationMetric, TokenMetric } from "./TelemetryMetrics"; @@ -450,10 +452,12 @@ export function hasConversationIdentity(props: { variant?: "compact" | "full"; }): boolean { const variant = props.variant ?? "full"; - const owner = conversationActorLabel(props.conversation); - if (variant === "compact") return Boolean(owner); + const participants = conversationParticipants(props.conversation); + if (variant === "compact") return participants.length > 0; const id = props.conversationId ?? props.conversation?.id; - return Boolean(owner || id || props.detail?.sentryConversationUrl); + return Boolean( + participants.length > 0 || id || props.detail?.sentryConversationUrl, + ); } /** Render the conversation owner, optionally with id and Sentry deep link. */ @@ -465,28 +469,13 @@ export function ConversationIdentity(props: { }) { if (!hasConversationIdentity(props)) return null; const variant = props.variant ?? "full"; - const email = props.conversation?.actorIdentity?.email?.trim(); - const owner = conversationActorLabel(props.conversation); + const participants = conversationParticipants(props.conversation); const id = props.conversationId ?? props.conversation?.id; - const ownerNode = owner ? ( - email ? ( - - {owner} - - ) : ( - owner - ) - ) : null; - if (variant === "compact") { - return ( - - {ownerNode} - - ); - } + const participantStack = + participants.length > 0 ? ( + + ) : null; + if (variant === "compact") return participantStack; const sentryLink = props.detail?.sentryConversationUrl ? ( - {ownerNode ? ( - {ownerNode} - ) : null} + {participantStack} {id ? ( - {ownerNode ? ( + {participantStack ? ( · ) : null} {id} @@ -513,7 +500,7 @@ export function ConversationIdentity(props: { ) : null} {sentryLink ? ( - {ownerNode || id ? ( + {participantStack || id ? ( · ) : null} {sentryLink} diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 70ad6d34c2..1154a70b94 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -938,6 +938,7 @@ export function buildConversations( locationId: summary.locationId, locationUrl: summary.locationUrl, actorIdentity: summary.actorIdentity, + participants: summary.participants, sentryTraceUrl: summary.sentryTraceUrl, startedAt: summary.startedAt, status: summary.status, diff --git a/packages/junior-dashboard/src/client/pages/dev/ComponentsPage.tsx b/packages/junior-dashboard/src/client/pages/dev/ComponentsPage.tsx index 3658d93c95..3a815aae1c 100644 --- a/packages/junior-dashboard/src/client/pages/dev/ComponentsPage.tsx +++ b/packages/junior-dashboard/src/client/pages/dev/ComponentsPage.tsx @@ -28,6 +28,7 @@ import { CardHeader } from "../../components/layout/CardHeader"; import { PageHeader } from "../../components/layout/PageHeader"; import { MetricList, MetricValue } from "../../components/Metric"; import { PageContentSkeleton } from "../../components/PageContentSkeleton"; +import { ParticipantAvatarStack } from "../../components/ParticipantAvatarStack"; import { StatCard } from "../../components/metrics/StatCard"; import { StatusChip } from "../../components/StatusChip"; import { StatusDot } from "../../components/StatusDot"; @@ -510,6 +511,28 @@ function FoundationsGalleryPage() { + +
+ + +
+
diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index c4ddf8c4ab..bd2d128e23 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -163,6 +163,7 @@ export type Conversation = { locationId?: string; locationUrl?: string; actorIdentity?: ConversationSummaryReport["actorIdentity"]; + participants?: ConversationSummaryReport["participants"]; sentryTraceUrl?: string; startedAt: string; status: ConversationSummaryReport["status"]; diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index c7f7a32232..cdd71c6630 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -341,6 +341,12 @@ function activeConversation(nowMs: number): ConversationDetailReport { channel: "CQA123", channelName: "proj-checkout", actorIdentity: actor("dev@example.com", "Morgan Lee", "morgan"), + participants: [ + actor("dev@example.com", "Morgan Lee", "morgan"), + actor("avery@sentry.io", "Avery Chen", "avery"), + actor("riley@sentry.io", "Riley Park", "riley"), + actor("jordan@sentry.io", "Jordan Blake", "jordan"), + ], assignedWork: true, unfinishedWork: true, isPriority: true, diff --git a/packages/junior/src/api/conversations/detail.ts b/packages/junior/src/api/conversations/detail.ts index c5075ac747..f5b7496c10 100644 --- a/packages/junior/src/api/conversations/detail.ts +++ b/packages/junior/src/api/conversations/detail.ts @@ -23,6 +23,7 @@ import { listConversationAnnotations } from "@/chat/plugins/annotations"; import { readLatestConversationBrief } from "@/chat/briefs/store"; import { readConversationSourceTask } from "@/chat/automations/read"; import { readConversationArchivedAt } from "./archive"; +import { readConversationParticipants } from "./participants"; /** Project stored metadata and a bounded event page into a signed history cursor. */ function projectConversationDetail(args: { @@ -36,6 +37,7 @@ function projectConversationDetail(args: { events: ConversationDetailReport["events"]; locationId?: string; modelUsage: NonNullable; + participants: NonNullable; previousSeq?: number; sourceTask?: ConversationDetailReport["sourceTask"]; teamDomainByTeamId?: ReadonlyMap; @@ -66,6 +68,9 @@ function projectConversationDetail(args: { annotations: canExposePayload ? args.annotations : [], ...(canExposePayload && args.brief ? { brief: args.brief } : undefined), events: args.events, + ...(canExposePayload && args.participants.length > 0 + ? { participants: args.participants } + : undefined), ...(args.previousSeq !== undefined ? { previousCursor: encodeConversationCursor({ @@ -109,6 +114,7 @@ async function readConversationDetailFromSql( briefVersion, modelUsage, metricsByRoot, + participantsByConversation, sourceTask, teamDomainByTeamId, ] = await Promise.all([ @@ -128,6 +134,7 @@ async function readConversationDetailFromSql( getDb(), includeDescendantMetrics ? [conversationId] : [], ), + readConversationParticipants(getDb(), [conversationId]), readConversationSourceTask({ conversationId, ...(options.viewer ? { viewer: options.viewer } : undefined), @@ -166,6 +173,7 @@ async function readConversationDetailFromSql( durationMs: metrics?.durationMs ?? record.durationMs, events: page.events, modelUsage, + participants: participantsByConversation.get(conversationId) ?? [], ...(sourceTask ? { sourceTask } : undefined), teamDomainByTeamId, ...(page.previousSeq === undefined diff --git a/packages/junior/src/api/conversations/list.ts b/packages/junior/src/api/conversations/list.ts index 048c7af90f..c614af629b 100644 --- a/packages/junior/src/api/conversations/list.ts +++ b/packages/junior/src/api/conversations/list.ts @@ -36,6 +36,7 @@ import { listConversationWork } from "@/chat/plugins/unfinished-work"; import { isConversationPriority } from "./priority"; import { readLastUserMessageAtByConversation } from "./user-message-activity"; import { readConversationActivityPreviews } from "./activity-preview"; +import { readConversationParticipants } from "./participants"; const CONVERSATION_FEED_LIMIT = 50; @@ -306,6 +307,7 @@ export async function readConversationFeedFromSql( annotationsByConversation, auxiliaryCostsByRoot, metricsByRoot, + participantsByConversation, teamDomainByTeamId, conversationWork, lastUserMessageAtByConversation, @@ -326,6 +328,7 @@ export async function readConversationFeedFromSql( includeDescendants: true, }), readRootConversationMetricsFromSql(db, conversationIds), + readConversationParticipants(db, conversationIds), resolveSlackTeamDomains( conversations.flatMap((conversation) => conversation.location?.provider === "slack" @@ -392,9 +395,15 @@ export async function readConversationFeedFromSql( const activityPreview = access?.canViewPrivateContent ? activityPreviewByConversation.get(conversation.conversationId) : undefined; + const participants = access?.canViewPrivateContent + ? participantsByConversation.get(conversation.conversationId) + : undefined; return { ...summary, ...work, + ...(participants && participants.length > 0 + ? { participants } + : undefined), ...(activityPreview ? { activityPreview } : undefined), ...(annotations.length > 0 ? { annotations } : undefined), ...(sidebarAnnotationsByConversation[conversation.conversationId] diff --git a/packages/junior/src/api/conversations/participants.ts b/packages/junior/src/api/conversations/participants.ts new file mode 100644 index 0000000000..131b2db50f --- /dev/null +++ b/packages/junior/src/api/conversations/participants.ts @@ -0,0 +1,131 @@ +import { asc, eq, inArray } from "drizzle-orm"; +import type { JuniorDatabase } from "@/db/db"; +import { + juniorConversationEvents, + juniorConversations, + juniorIdentities, + juniorUsers, +} from "@/db/schema"; +import type { ActorIdentity } from "../schema/conversation"; + +const identityColumns = { + displayName: juniorIdentities.displayName, + email: juniorIdentities.email, + handle: juniorIdentities.handle, + identityId: juniorIdentities.id, + provider: juniorIdentities.provider, + providerSubjectId: juniorIdentities.providerSubjectId, + userDisplayName: juniorUsers.displayName, + userEmail: juniorUsers.primaryEmail, + userId: juniorIdentities.userId, +}; + +type ParticipantIdentityRow = { + displayName: string | null; + email: string | null; + handle: string | null; + identityId: string; + provider: string; + providerSubjectId: string; + userDisplayName: string | null; + userEmail: string | null; + userId: string | null; +}; + +function participantIdentity( + row: ParticipantIdentityRow, +): ActorIdentity | undefined { + const fullName = row.userDisplayName?.trim() || row.displayName?.trim(); + const email = row.email?.trim() || row.userEmail?.trim(); + const slack = row.provider === "slack"; + const participant = { + ...(email ? { email } : undefined), + ...(fullName ? { fullName } : undefined), + ...(slack && row.providerSubjectId + ? { slackUserId: row.providerSubjectId } + : undefined), + ...(slack && row.handle ? { slackUserName: row.handle } : undefined), + }; + return Object.keys(participant).length > 0 ? participant : undefined; +} + +/** Read actor identities in first-appearance order for each Conversation. */ +export async function readConversationParticipants( + db: JuniorDatabase, + conversationIds: readonly string[], +): Promise> { + if (conversationIds.length === 0) return new Map(); + + const [rootRows, eventRows] = await Promise.all([ + db + .select({ + conversationId: juniorConversations.conversationId, + ...identityColumns, + }) + .from(juniorConversations) + .innerJoin( + juniorIdentities, + eq(juniorIdentities.id, juniorConversations.actorIdentityId), + ) + .leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)) + .where(inArray(juniorConversations.conversationId, [...conversationIds])), + db + .selectDistinctOn( + [ + juniorConversationEvents.conversationId, + juniorConversationEvents.actorIdentityId, + ], + { + conversationId: juniorConversationEvents.conversationId, + seq: juniorConversationEvents.seq, + ...identityColumns, + }, + ) + .from(juniorConversationEvents) + .innerJoin( + juniorIdentities, + eq(juniorIdentities.id, juniorConversationEvents.actorIdentityId), + ) + .leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)) + .where( + inArray(juniorConversationEvents.conversationId, [...conversationIds]), + ) + .orderBy( + asc(juniorConversationEvents.conversationId), + asc(juniorConversationEvents.actorIdentityId), + asc(juniorConversationEvents.seq), + ), + ]); + + const ordered = new Map< + string, + { identities: ActorIdentity[]; keys: Set } + >(); + const add = (conversationId: string, row: ParticipantIdentityRow) => { + const participant = participantIdentity(row); + if (!participant) return; + const group = ordered.get(conversationId) ?? { + identities: [], + keys: new Set(), + }; + const key = row.userId + ? `user:${row.userId}` + : `identity:${row.identityId}`; + if (!group.keys.has(key)) { + group.keys.add(key); + group.identities.push(participant); + } + ordered.set(conversationId, group); + }; + + for (const row of rootRows) add(row.conversationId, row); + for (const row of eventRows.sort((left, right) => left.seq - right.seq)) { + add(row.conversationId, row); + } + return new Map( + [...ordered].map(([conversationId, group]) => [ + conversationId, + group.identities, + ]), + ); +} diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index c1c1336896..da7ceffc20 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -252,6 +252,7 @@ export const conversationSummaryReportSchema = z lastProgressAt: z.string(), surface: conversationSurfaceSchema, actorIdentity: actorIdentitySchema.optional(), + participants: z.array(actorIdentitySchema).optional(), archivedAt: z.string().datetime().nullable().optional(), channel: z.string().optional(), channelName: z.string().optional(), diff --git a/packages/junior/tests/integration/api/conversations/list.test.ts b/packages/junior/tests/integration/api/conversations/list.test.ts index bfafeea38f..aed4920a23 100644 --- a/packages/junior/tests/integration/api/conversations/list.test.ts +++ b/packages/junior/tests/integration/api/conversations/list.test.ts @@ -752,6 +752,17 @@ describe("conversation list API", () => { }), conversationId: "slack:C1:shared-thread", isParticipant: true, + participants: [ + expect.objectContaining({ + email: "owner@example.com", + slackUserId: "U-OWNER", + }), + expect.objectContaining({ + email: "participant@example.com", + fullName: "Participant", + slackUserId: "U-PARTICIPANT", + }), + ], }); } finally { await fixture.close();