Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ export function ConversationPage(props: {
// runs without interactive auth, so it has no approval to show.
view === "rich" && detail.data?.isParticipant && !agentTree ? (
<ConversationReplyFooter
authors={detail.data.authors}
conversationId={conversationId}
// Only pass committed ids for mailbox de-dupe. The full transcript is
// too large to re-enter the footer on every live poll while typing.
Expand Down Expand Up @@ -620,6 +621,7 @@ export function ConversationPage(props: {
* footer tree. Fast chat UIs isolate the composer the same way.
*/
const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
authors?: ConversationDetailReport["authors"];
committedMessageIds: readonly string[];
conversationId: string;
onPinRequest: () => void;
Expand Down Expand Up @@ -874,6 +876,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
>
<ConversationReviewBar conversationId={props.conversationId} />
<PendingMailboxStack
authors={props.authors}
cancelError={cancelError}
cancelPending={cancelPendingMessages.isPending}
cancelTargetInboundMessageId={cancelTargetInboundMessageId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { INPUT_IMAGE_TYPES } from "@sentry/junior/api/schema";
import {
INPUT_IMAGE_TYPES,
type ConversationDetailReport,
} from "@sentry/junior/api/schema";
import {
AlertCircle,
Clock3,
Expand Down Expand Up @@ -230,14 +233,17 @@ function PendingMetaIcons(props: {
);
}

function PendingAuthorAvatar(props: { message: ConversationMailboxMessage }) {
function PendingAuthorAvatar(props: {
authors?: ConversationDetailReport["authors"];
message: ConversationMailboxMessage;
}) {
const { actorIdentityId } = props.message;
// Pending rows can come from web or Slack. Redacted rows hide the author.
if (!props.message.actorIdentity) {
return <ActorAvatar name="?" size="list" />;
}
return (
<ParticipantAvatar participant={props.message.actorIdentity} size="list" />
);
const author =
(actorIdentityId ? props.authors?.[actorIdentityId] : undefined) ??
props.message.actorIdentity;
if (!author) return <ActorAvatar name="?" size="list" />;
return <ParticipantAvatar participant={author} size="list" />;
}

/** Show small previews so the reader can tell which images a queued message has. */
Expand Down Expand Up @@ -294,6 +300,7 @@ function PendingAttachmentThumbnails(props: {

function PendingRow(props: {
actionsDisabled: boolean;
authors?: ConversationDetailReport["authors"];
cancelError: boolean;
cancelPending: boolean;
canEdit: boolean;
Expand Down Expand Up @@ -322,7 +329,10 @@ function PendingRow(props: {
className="items-center"
left={
<>
<PendingAuthorAvatar message={props.message} />
<PendingAuthorAvatar
authors={props.authors}
message={props.message}
/>
{redacted ? null : (
<PendingAttachmentThumbnails
conversationId={props.conversationId}
Expand Down Expand Up @@ -438,6 +448,8 @@ function sameEmail(left: string | undefined, right: string | undefined) {

/** Render accepted mailbox rows as a compact stack attached above the composer. */
export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
/** Author of each row, by `actorIdentityId`. */
authors?: ConversationDetailReport["authors"];
cancelError?: boolean;
cancelPending?: boolean;
cancelTargetInboundMessageId?: string;
Expand Down Expand Up @@ -506,6 +518,7 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
return (
<PendingRow
actionsDisabled={actionsDisabled}
authors={props.authors}
cancelError={Boolean(props.cancelError && isTarget)}
cancelPending={Boolean(props.cancelPending && isTarget)}
canEdit={ownWebRow}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { ClipboardEventHandler, ReactNode } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { transcriptRoleKind } from "../format";
import { cn } from "../styles";

/** Align message text and attachments beside their avatars. */
export function TranscriptMessageShell(props: {
actor: string;
/** Junior API path of the author photo. Initials show without it. */
avatarUrl?: string;
children: ReactNode;
onCopy?: ClipboardEventHandler<HTMLElement>;
role: string;
Expand Down Expand Up @@ -36,14 +39,22 @@ export function TranscriptMessageShell(props: {
src="/_junior/dashboard/avatar.png"
/>
) : (
<span className="grid size-8 place-items-center rounded-full bg-violet-300 font-sans text-xs font-semibold text-dashboard-text-inverse">
{props.actor
.split(/\s+/)
.map((word) => word[0])
.slice(0, 2)
.join("")
.toUpperCase()}
</span>
<Avatar className="size-8 border-0 bg-violet-300 shadow-none">
<AvatarImage
alt=""
loading="lazy"
referrerPolicy="no-referrer"
src={props.avatarUrl}
/>
<AvatarFallback>
{props.actor
.split(/\s+/)
.map((word) => word[0])
.slice(0, 2)
.join("")
.toUpperCase()}
</AvatarFallback>
</Avatar>
)}
</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const TranscriptMessageView = memo(
<TranscriptMessageShell
role={props.message.role}
actor={transcriptMessageActorLabel(props.conversation, props.message)}
avatarUrl={
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
}
onCopy={(event) => {
const selection = event.currentTarget.ownerDocument.getSelection();
if (
Expand Down Expand Up @@ -97,7 +100,8 @@ export const TranscriptMessageView = memo(
previous.conversation.conversationId === next.conversation.conversationId &&
previous.conversation.surface === next.conversation.surface &&
previous.conversation.canFork === next.conversation.canFork &&
previous.conversation.actorIdentity === next.conversation.actorIdentity,
previous.conversation.actorIdentity === next.conversation.actorIdentity &&
previous.conversation.authors === next.conversation.authors,
);

/** Render a redacted primary chat message without exposing body content. */
Expand All @@ -109,6 +113,9 @@ export function RedactedMessageView(props: {
<TranscriptMessageShell
role={props.message.role}
actor={transcriptMessageActorLabel(props.conversation, props.message)}
avatarUrl={
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
}
>
<TranscriptMessageHeader
message={props.message}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function TurnContextActor(props: { actor: ActorIdentity; timestamp?: number }) {
aria-label="Speaker"
className="flex min-w-0 items-center gap-3 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
>
<ActorAvatar name={name} size="detail" />
<ActorAvatar imageUrl={props.actor.avatarUrl} name={name} size="detail" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline justify-between gap-3">
{email ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { QueryClient } from "@tanstack/react-query";
import type {
ActorIdentity,
ConversationPendingMessage,
InputImage,
} from "@sentry/junior/api/schema";
import type { DashboardCoreData } from "../types";

/** Client-owned mailbox row waiting on accept, server visibility, or retry. */
export type ConversationOutboxMessage = {
Expand All @@ -27,6 +29,22 @@ export type ConversationMailboxMessage = ConversationPendingMessage & {
idempotencyKey?: string;
};

/** Identity of the viewer, for the outbox rows that they send. */
export function outboxActorIdentity(
queryClient: QueryClient,
): ActorIdentity | undefined {
const user = queryClient.getQueryData<DashboardCoreData>([
"dashboard",
"core",
])?.me.user;
if (!user?.email) return undefined;
return {
...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : undefined),
email: user.email,
fullName: user.name || undefined,
};
}

/** Stable React Query key for one conversation's local send outbox. */
export function conversationOutboxQueryKey(conversationId: string | undefined) {
return ["conversation", conversationId, "outbox"] as const;
Expand Down Expand Up @@ -163,6 +181,7 @@ function sameActorIdentity(
if (left === right) return true;
if (!left || !right) return false;
return (
left.avatarUrl === right.avatarUrl &&
left.email === right.email &&
left.fullName === right.fullName &&
left.slackUserId === right.slackUserId &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export function pendingTranscriptMessage(
...(message.actorIdentity
? { actorIdentity: message.actorIdentity }
: undefined),
...(message.actorIdentityId
? { actorIdentityId: message.actorIdentityId }
: undefined),
...(message.mentions ? { mentions: message.mentions } : undefined),
};
}
Expand Down Expand Up @@ -222,6 +225,9 @@ export function transcriptMessagesFromEvents(
...(data.actorIdentity
? { actorIdentity: data.actorIdentity }
: undefined),
...(data.actorIdentityId
? { actorIdentityId: data.actorIdentityId }
: undefined),
...(data.agentInvocationId
? { agentInvocationId: data.agentInvocationId }
: undefined),
Expand Down
18 changes: 1 addition & 17 deletions packages/junior-dashboard/src/client/conversations/queries.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { useDeferredValue, useEffect, useMemo, useRef } from "react";
import {
queryOptions,
type QueryClient,
useInfiniteQuery,
useMutation,
useMutationState,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type {
ActorIdentity,
ConversationDetailReport,
ConversationEventPage,
ConversationFeed,
Expand All @@ -30,7 +28,6 @@ import {
stopConversationTurnResponseSchema,
} from "@sentry/junior/api/schema";

import type { DashboardCoreData } from "../types";
import {
DashboardApiError,
del,
Expand All @@ -45,6 +42,7 @@ import {
failConversationOutboxMessage,
isInlineOutboxMessage,
mergeConversationMailboxMessages,
outboxActorIdentity,
acceptConversationOutboxMessage,
reuseConversationMailboxMessages,
splitInlineMailboxMessages,
Expand Down Expand Up @@ -213,20 +211,6 @@ function keepLatestReadPosition(
return { ...next, readPosition: kept };
}

function outboxActorIdentity(
queryClient: QueryClient,
): ActorIdentity | undefined {
const user = queryClient.getQueryData<DashboardCoreData>([
"dashboard",
"core",
])?.me.user;
if (!user?.email) return undefined;
return {
email: user.email,
fullName: user.name || undefined,
};
}

/** Create one dashboard conversation and refresh the personal feed. */
export function useCreateConversation() {
const queryClient = useQueryClient();
Expand Down
14 changes: 12 additions & 2 deletions packages/junior-dashboard/src/client/conversations/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,18 @@ export function reuseConversationEventReferences(
JSON.stringify(previous.annotations) === JSON.stringify(next.annotations)
? previous.annotations
: next.annotations;
if (events === next.events && annotations === next.annotations) return next;
return { ...next, events, annotations };
const authors =
JSON.stringify(previous.authors) === JSON.stringify(next.authors)
? previous.authors
: next.authors;
if (
events === next.events &&
annotations === next.annotations &&
authors === next.authors
) {
return next;
}
return { ...next, events, annotations, authors };
}

function sameConversationEventVersion(
Expand Down
9 changes: 8 additions & 1 deletion packages/junior-dashboard/src/client/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ export function transcriptMessageActor(
): ActorIdentity | undefined {
if (transcriptRoleKind(message.role) !== "user") return undefined;
if (message.agentInvocationId) return undefined;
const author = message.actorIdentityId
? conversation.authors?.[message.actorIdentityId]
: undefined;
if (author) return author;
if (actorLabel(message.actorIdentity)) return message.actorIdentity;
if (actorLabel(conversation.actorIdentity)) return conversation.actorIdentity;
return undefined;
Expand All @@ -370,7 +374,10 @@ export function transcriptMessageActorLabel(
// The conversation actor started the child agent but did not write its
// input, so do not fall back to that actor here.
if (message.agentInvocationId) return "Agent invocation";
return actorLabel(transcriptMessageActor(conversation, message)) ?? "User";
// Show the person's name, not their email. A web send carries both, and
// its durable Message carries only the name.
const actor = transcriptMessageActor(conversation, message);
return actor?.fullName?.trim() || (actorLabel(actor) ?? "User");
}
if (kind === "system") return "System";
if (kind === "tool") return "Tool";
Expand Down
2 changes: 2 additions & 0 deletions packages/junior-dashboard/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export type TranscriptViewMessage = {
actorIdentity?: ActorIdentity;
/** Agent invocation whose input this is. An agent wrote it, not a person. */
agentInvocationId?: string;
/** Identity id of the author. Conversation `authors` maps it to the person. */
actorIdentityId?: string;
contexts?: TranscriptViewTurnContext[];
/** Mailbox delivery mode while the message is still pending history commit. */
delivery?: "defer" | "interrupt";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,15 @@ describe("conversation transcript", () => {
});

it("reuses events while keeping fresh poll metadata", () => {
const previous = { ...detail(), annotations: [] };
const previous = {
...detail(),
annotations: [],
authors: { "identity-1": { fullName: "Owner Person" } },
};
const next = {
...previous,
annotations: [],
authors: { "identity-1": { fullName: "Owner Person" } },
cumulativeDurationMs: previous.cumulativeDurationMs + 2_000,
generatedAt: "2026-07-23T00:00:02.000Z",
lastProgressAt: "2026-07-23T00:00:02.000Z",
Expand All @@ -274,6 +279,8 @@ describe("conversation transcript", () => {

const result = reuseConversationEventReferences(previous, next);
expect(result.annotations).toBe(previous.annotations);
// Memoized footer and message views compare authors by reference.
expect(result.authors).toBe(previous.authors);
expect(result.events).toBe(previous.events);
expect(result.generatedAt).toBe("2026-07-23T00:00:02.000Z");
});
Expand Down
Loading
Loading