Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand All @@ -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,
Expand All @@ -51,19 +52,12 @@ function ConversationIdentity(props: { conversation: Conversation }) {
{" · "}
</>
) : null}
{email ? (
<Link
className="font-semibold text-dashboard-text underline decoration-white/20 underline-offset-2 transition-colors hover:text-dashboard-text hover:decoration-white/60"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
to={peoplePath(email)}
>
{owner}
</Link>
) : owner ? (
owner
{participants.length > 0 ? (
<span className="mr-1 inline-flex align-middle">
<ParticipantAvatarStack participants={participants} size="list" />
</span>
) : null}
{owner ? " · " : null}
{participants.length > 0 ? " · " : null}
{id}
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<span className="grid gap-0.5 font-sans">
<span className="font-semibold text-dashboard-text">{name}</span>
{email && email !== name ? (
<span className="font-mono text-dashboard-text-muted">{email}</span>
) : null}
</span>
);
}

function Avatar(props: {
participant: ActorIdentity;
size: ParticipantAvatarStackProps["size"];
}) {
const label = participantDescription(props.participant);
return (
<Tooltip
content={<ParticipantTooltipContent participant={props.participant} />}
focusable
triggerClassName="-ml-1.5 first:ml-0 pointer-events-none [@media(hover:hover)_and_(pointer:fine)]:pointer-events-auto"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatars block clicks without tooltips

Medium Severity

Avatar triggers opt back into pointer events for any hover-capable fine pointer, but Tooltip only mounts at min-width: 768px. On home cards the footer stays pointer-events-none over the overlay Link, so a narrow desktop window captures clicks on the stack without showing a tooltip or opening the conversation.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 663d50e. Configure here.

>
<span
aria-label={label}
className={cn(
"relative inline-grid shrink-0 place-items-center rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-bold leading-none text-dashboard-text-inverse shadow-sm focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
)}
onKeyDown={(event) => event.stopPropagation()}
tabIndex={0}
>
{participantInitials(props.participant)}
</span>
</Tooltip>
);
}

/** 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 (
<span
aria-label={`Conversation participants: ${label}`}
className="inline-flex items-center"
role="group"
>
{visible.map((participant, index) => (
<Avatar
key={participantKey(participant, index)}
participant={participant}
size={props.size}
/>
))}
{hidden.length > 0 ? (
<Tooltip
content={
<span className="grid gap-1 font-sans">
{hidden.map((participant, index) => (
<span key={participantKey(participant, index)}>
{participantDescription(participant)}
</span>
))}
</span>
}
focusable
triggerClassName="-ml-1.5 pointer-events-none [@media(hover:hover)_and_(pointer:fine)]:pointer-events-auto"
>
<span
aria-label={`${hidden.length} more participants: ${hidden
.map(participantDescription)
.join("; ")}`}
className={cn(
"relative inline-grid shrink-0 place-items-center rounded-full border-2 border-dashboard-bg bg-dashboard-fill-strong font-mono font-semibold leading-none text-dashboard-text-muted focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
)}
onKeyDown={(event) => event.stopPropagation()}
tabIndex={0}
>
+{hidden.length}
</span>
</Tooltip>
) : null}
</span>
);
}
4 changes: 4 additions & 0 deletions packages/junior-dashboard/src/client/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type TooltipProps = {
children: ReactElement;
className?: string;
content: ReactNode;
focusable?: boolean;
label?: ReactNode;
placement?: "above" | "below";
triggerClassName?: string;
Expand Down Expand Up @@ -51,6 +52,7 @@ export function Tooltip({
children,
className,
content,
focusable = false,
label,
placement = "above",
triggerClassName,
Expand Down Expand Up @@ -121,6 +123,8 @@ export function Tooltip({
<HoverCard.Trigger
aria-describedby={open ? tooltipId : undefined}
asChild
onBlur={focusable ? () => setOpen(false) : undefined}
onFocus={focusable ? () => setOpen(true) : undefined}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused tooltip closes before copy

Low Severity

The new focusable path closes the tooltip on trigger blur. After keyboard focus or a click on an avatar, moving to the popup to select the name or email dismisses it, so the selectable participant details cannot be copied.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 663d50e. Configure here.

onPointerCancel={() => {
touchStartedOpenRef.current = null;
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { Archive, ArchiveRestore } from "lucide-react";
import { Link } from "react-router";

import {
conversationActorLabel,
conversationDisplayTitle,
formatRelativeTime,
slackLocationLabel,
visualStatusForConversation,
} 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";
Expand Down Expand Up @@ -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 (
<article
Expand Down Expand Up @@ -271,8 +274,12 @@ function ConversationCard(props: {
)}
<div className="relative z-[1] flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 font-mono text-xs text-dashboard-text-muted pointer-events-none">
{location ? <span className="truncate">{location}</span> : null}
{location && actor ? <span aria-hidden="true">·</span> : null}
{actor ? <span className="truncate">{actor}</span> : null}
{location && participants.length > 0 ? (
<span aria-hidden="true">·</span>
) : null}
{participants.length > 0 ? (
<ParticipantAvatarStack participants={participants} size="list" />
) : null}
<ConversationSidebarAnnotations
annotations={conversation.sidebarAnnotations}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,18 @@ import { Link } from "react-router";
import type { ConversationDetailReport } from "@sentry/junior/api/schema";

import {
conversationActorLabel,
formatConversationDuration,
peoplePath,
slackLocationLabel,
summarizeCost,
summarizeModelUsage,
summarizeUsage,
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";
Expand Down Expand Up @@ -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. */
Expand All @@ -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 ? (
<Link
className="font-semibold text-dashboard-text underline decoration-white/20 underline-offset-2 transition-colors hover:text-dashboard-text hover:decoration-white/60"
to={peoplePath(email)}
>
{owner}
</Link>
) : (
owner
)
) : null;
if (variant === "compact") {
return (
<span className="inline-flex min-w-0 max-w-full items-center">
<span className="min-w-0 max-w-full truncate">{ownerNode}</span>
</span>
);
}
const participantStack =
participants.length > 0 ? (
<ParticipantAvatarStack participants={participants} size="detail" />
) : null;
if (variant === "compact") return participantStack;
const sentryLink = props.detail?.sentryConversationUrl ? (
<a
className="text-dashboard-text no-underline hover:underline"
Expand All @@ -500,20 +489,18 @@ export function ConversationIdentity(props: {

return (
<span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-x-1.5 gap-y-1">
{ownerNode ? (
<span className="min-w-0 max-w-full truncate">{ownerNode}</span>
) : null}
{participantStack}
{id ? (
<span className="inline-flex min-w-0 items-center gap-x-1.5" title={id}>
{ownerNode ? (
{participantStack ? (
<span className="text-dashboard-text-muted/50">·</span>
) : null}
<span className="min-w-0 max-w-[18rem] truncate">{id}</span>
</span>
) : null}
{sentryLink ? (
<span className="inline-flex min-w-0 items-center gap-x-1.5">
{ownerNode || id ? (
{participantStack || id ? (
<span className="text-dashboard-text-muted/50">·</span>
) : null}
{sentryLink}
Expand Down
1 change: 1 addition & 0 deletions packages/junior-dashboard/src/client/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading