@sentry/junior-dashboard 0.272.0 → 0.273.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ import * as React from "react";
2
+ import { mergeProps } from "@base-ui/react/merge-props";
3
+ import { useRender } from "@base-ui/react/use-render";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { cn } from "@dashboard/lib/utils";
6
+
7
+ import {
8
+ Tooltip,
9
+ TooltipContent,
10
+ TooltipTrigger,
11
+ } from "@dashboard/components/ui/tooltip";
12
+
13
+ const SIDEBAR_WIDTH = "16rem";
14
+ const SIDEBAR_WIDTH_ICON = "3rem";
15
+
16
+ type SidebarContextProps = {
17
+ state: "expanded" | "collapsed";
18
+ };
19
+
20
+ const SidebarContext = React.createContext<SidebarContextProps | null>(null);
21
+
22
+ function useSidebar() {
23
+ const context = React.useContext(SidebarContext);
24
+ if (!context) {
25
+ throw new Error("useSidebar must be used within a SidebarProvider.");
26
+ }
27
+
28
+ return context;
29
+ }
30
+
31
+ /**
32
+ * Own the sidebar state and width variables for the page frame.
33
+ *
34
+ * The dashboard keeps only the desktop sidebar. It drops the stock toggle
35
+ * state, cookie, Cmd/Ctrl+B shortcut, and mobile sheet: the nav rail stays
36
+ * collapsed, and `DashboardHeader` owns mobile navigation.
37
+ */
38
+ function SidebarProvider({
39
+ open = true,
40
+ className,
41
+ style,
42
+ children,
43
+ ...props
44
+ }: React.ComponentProps<"div"> & {
45
+ open?: boolean;
46
+ }) {
47
+ // We add a state so that we can do data-state="expanded" or "collapsed".
48
+ // This makes it easier to style the sidebar with Tailwind classes.
49
+ const state = open ? "expanded" : "collapsed";
50
+
51
+ const contextValue = React.useMemo<SidebarContextProps>(
52
+ () => ({ state }),
53
+ [state],
54
+ );
55
+
56
+ return (
57
+ <SidebarContext.Provider value={contextValue}>
58
+ <div
59
+ data-slot="sidebar-wrapper"
60
+ style={
61
+ {
62
+ "--sidebar-width": SIDEBAR_WIDTH,
63
+ "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
64
+ ...style,
65
+ } as React.CSSProperties
66
+ }
67
+ className={cn(
68
+ "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
69
+ className,
70
+ )}
71
+ {...props}
72
+ >
73
+ {children}
74
+ </div>
75
+ </SidebarContext.Provider>
76
+ );
77
+ }
78
+
79
+ /** Render the fixed sidebar from the `md` breakpoint up. */
80
+ function Sidebar({
81
+ side = "left",
82
+ variant = "sidebar",
83
+ collapsible = "offcanvas",
84
+ className,
85
+ children,
86
+ ...props
87
+ }: React.ComponentProps<"div"> & {
88
+ side?: "left" | "right";
89
+ variant?: "sidebar" | "floating" | "inset";
90
+ collapsible?: "offcanvas" | "icon" | "none";
91
+ }) {
92
+ const { state } = useSidebar();
93
+
94
+ if (collapsible === "none") {
95
+ return (
96
+ <div
97
+ data-slot="sidebar"
98
+ className={cn(
99
+ "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
100
+ className,
101
+ )}
102
+ {...props}
103
+ >
104
+ {children}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <div
111
+ className="group peer hidden text-sidebar-foreground md:block"
112
+ data-state={state}
113
+ data-collapsible={state === "collapsed" ? collapsible : ""}
114
+ data-variant={variant}
115
+ data-side={side}
116
+ data-slot="sidebar"
117
+ >
118
+ {/* This is what handles the sidebar gap on desktop */}
119
+ <div
120
+ data-slot="sidebar-gap"
121
+ className={cn(
122
+ "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
123
+ "group-data-[collapsible=offcanvas]:w-0",
124
+ "group-data-[side=right]:rotate-180",
125
+ variant === "floating" || variant === "inset"
126
+ ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
127
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
128
+ )}
129
+ />
130
+ <div
131
+ data-slot="sidebar-container"
132
+ data-side={side}
133
+ className={cn(
134
+ "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
135
+ // Adjust the padding for floating and inset variants.
136
+ variant === "floating" || variant === "inset"
137
+ ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
138
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
139
+ className,
140
+ )}
141
+ {...props}
142
+ >
143
+ <div
144
+ data-sidebar="sidebar"
145
+ data-slot="sidebar-inner"
146
+ className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
147
+ >
148
+ {children}
149
+ </div>
150
+ </div>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ /** Hold content pinned to the top of the sidebar. */
156
+ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
157
+ return (
158
+ <div
159
+ data-slot="sidebar-header"
160
+ data-sidebar="header"
161
+ className={cn("flex flex-col gap-2 p-2", className)}
162
+ {...props}
163
+ />
164
+ );
165
+ }
166
+
167
+ /** Hold content pinned to the bottom of the sidebar. */
168
+ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
169
+ return (
170
+ <div
171
+ data-slot="sidebar-footer"
172
+ data-sidebar="footer"
173
+ className={cn("flex flex-col gap-2 p-2", className)}
174
+ {...props}
175
+ />
176
+ );
177
+ }
178
+
179
+ /** Scroll the sidebar body between the header and the footer. */
180
+ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
181
+ return (
182
+ <div
183
+ data-slot="sidebar-content"
184
+ data-sidebar="content"
185
+ className={cn(
186
+ "no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
187
+ className,
188
+ )}
189
+ {...props}
190
+ />
191
+ );
192
+ }
193
+
194
+ /** Group related sidebar menus with shared padding. */
195
+ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
196
+ return (
197
+ <div
198
+ data-slot="sidebar-group"
199
+ data-sidebar="group"
200
+ className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
201
+ {...props}
202
+ />
203
+ );
204
+ }
205
+
206
+ /** List sidebar menu items. */
207
+ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
208
+ return (
209
+ <ul
210
+ data-slot="sidebar-menu"
211
+ data-sidebar="menu"
212
+ className={cn("flex w-full min-w-0 flex-col gap-0", className)}
213
+ {...props}
214
+ />
215
+ );
216
+ }
217
+
218
+ /** Wrap one menu button and its badge. */
219
+ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
220
+ return (
221
+ <li
222
+ data-slot="sidebar-menu-item"
223
+ data-sidebar="menu-item"
224
+ className={cn("group/menu-item relative", className)}
225
+ {...props}
226
+ />
227
+ );
228
+ }
229
+
230
+ const sidebarMenuButtonVariants = cva(
231
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
232
+ {
233
+ variants: {
234
+ variant: {
235
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
236
+ outline:
237
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
238
+ },
239
+ size: {
240
+ default: "h-8 text-sm",
241
+ sm: "h-7 text-xs",
242
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
243
+ },
244
+ },
245
+ defaultVariants: {
246
+ variant: "default",
247
+ size: "default",
248
+ },
249
+ },
250
+ );
251
+
252
+ /** Render a menu link or button, with a tooltip while the sidebar is collapsed. */
253
+ function SidebarMenuButton({
254
+ render,
255
+ isActive = false,
256
+ variant = "default",
257
+ size = "default",
258
+ tooltip,
259
+ className,
260
+ ...props
261
+ }: useRender.ComponentProps<"button"> &
262
+ React.ComponentProps<"button"> & {
263
+ isActive?: boolean;
264
+ tooltip?: string | React.ComponentProps<typeof TooltipContent>;
265
+ } & VariantProps<typeof sidebarMenuButtonVariants>) {
266
+ const { state } = useSidebar();
267
+ const comp = useRender({
268
+ defaultTagName: "button",
269
+ props: mergeProps<"button">(
270
+ {
271
+ className: cn(sidebarMenuButtonVariants({ variant, size }), className),
272
+ },
273
+ props,
274
+ ),
275
+ render: !tooltip ? render : <TooltipTrigger render={render} />,
276
+ state: {
277
+ slot: "sidebar-menu-button",
278
+ sidebar: "menu-button",
279
+ size,
280
+ active: isActive,
281
+ },
282
+ });
283
+
284
+ if (!tooltip) {
285
+ return comp;
286
+ }
287
+
288
+ if (typeof tooltip === "string") {
289
+ tooltip = {
290
+ children: tooltip,
291
+ };
292
+ }
293
+
294
+ return (
295
+ <Tooltip>
296
+ {comp}
297
+ <TooltipContent
298
+ side="right"
299
+ align="center"
300
+ hidden={state !== "collapsed"}
301
+ {...tooltip}
302
+ />
303
+ </Tooltip>
304
+ );
305
+ }
306
+
307
+ /** Show a count beside a menu button. */
308
+ function SidebarMenuBadge({
309
+ className,
310
+ ...props
311
+ }: React.ComponentProps<"div">) {
312
+ return (
313
+ <div
314
+ data-slot="sidebar-menu-badge"
315
+ data-sidebar="menu-badge"
316
+ className={cn(
317
+ "pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
318
+ className,
319
+ )}
320
+ {...props}
321
+ />
322
+ );
323
+ }
324
+
325
+ export {
326
+ Sidebar,
327
+ SidebarContent,
328
+ SidebarFooter,
329
+ SidebarGroup,
330
+ SidebarHeader,
331
+ SidebarMenu,
332
+ SidebarMenuBadge,
333
+ SidebarMenuButton,
334
+ SidebarMenuItem,
335
+ SidebarProvider,
336
+ };
@@ -522,6 +522,7 @@ export function ConversationPage(props: {
522
522
  // runs without interactive auth, so it has no approval to show.
523
523
  view === "rich" && detail.data?.isParticipant && !agentTree ? (
524
524
  <ConversationReplyFooter
525
+ authors={detail.data.authors}
525
526
  conversationId={conversationId}
526
527
  // Only pass committed ids for mailbox de-dupe. The full transcript is
527
528
  // too large to re-enter the footer on every live poll while typing.
@@ -620,6 +621,7 @@ export function ConversationPage(props: {
620
621
  * footer tree. Fast chat UIs isolate the composer the same way.
621
622
  */
622
623
  const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
624
+ authors?: ConversationDetailReport["authors"];
623
625
  committedMessageIds: readonly string[];
624
626
  conversationId: string;
625
627
  onPinRequest: () => void;
@@ -874,6 +876,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
874
876
  >
875
877
  <ConversationReviewBar conversationId={props.conversationId} />
876
878
  <PendingMailboxStack
879
+ authors={props.authors}
877
880
  cancelError={cancelError}
878
881
  cancelPending={cancelPendingMessages.isPending}
879
882
  cancelTargetInboundMessageId={cancelTargetInboundMessageId}
@@ -6,7 +6,10 @@ import {
6
6
  type ReactElement,
7
7
  type ReactNode,
8
8
  } from "react";
9
- import { INPUT_IMAGE_TYPES } from "@sentry/junior/api/schema";
9
+ import {
10
+ INPUT_IMAGE_TYPES,
11
+ type ConversationDetailReport,
12
+ } from "@sentry/junior/api/schema";
10
13
  import {
11
14
  AlertCircle,
12
15
  Clock3,
@@ -230,14 +233,17 @@ function PendingMetaIcons(props: {
230
233
  );
231
234
  }
232
235
 
233
- function PendingAuthorAvatar(props: { message: ConversationMailboxMessage }) {
236
+ function PendingAuthorAvatar(props: {
237
+ authors?: ConversationDetailReport["authors"];
238
+ message: ConversationMailboxMessage;
239
+ }) {
240
+ const { actorIdentityId } = props.message;
234
241
  // Pending rows can come from web or Slack. Redacted rows hide the author.
235
- if (!props.message.actorIdentity) {
236
- return <ActorAvatar name="?" size="list" />;
237
- }
238
- return (
239
- <ParticipantAvatar participant={props.message.actorIdentity} size="list" />
240
- );
242
+ const author =
243
+ (actorIdentityId ? props.authors?.[actorIdentityId] : undefined) ??
244
+ props.message.actorIdentity;
245
+ if (!author) return <ActorAvatar name="?" size="list" />;
246
+ return <ParticipantAvatar participant={author} size="list" />;
241
247
  }
242
248
 
243
249
  /** Show small previews so the reader can tell which images a queued message has. */
@@ -294,6 +300,7 @@ function PendingAttachmentThumbnails(props: {
294
300
 
295
301
  function PendingRow(props: {
296
302
  actionsDisabled: boolean;
303
+ authors?: ConversationDetailReport["authors"];
297
304
  cancelError: boolean;
298
305
  cancelPending: boolean;
299
306
  canEdit: boolean;
@@ -322,7 +329,10 @@ function PendingRow(props: {
322
329
  className="items-center"
323
330
  left={
324
331
  <>
325
- <PendingAuthorAvatar message={props.message} />
332
+ <PendingAuthorAvatar
333
+ authors={props.authors}
334
+ message={props.message}
335
+ />
326
336
  {redacted ? null : (
327
337
  <PendingAttachmentThumbnails
328
338
  conversationId={props.conversationId}
@@ -438,6 +448,8 @@ function sameEmail(left: string | undefined, right: string | undefined) {
438
448
 
439
449
  /** Render accepted mailbox rows as a compact stack attached above the composer. */
440
450
  export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
451
+ /** Author of each row, by `actorIdentityId`. */
452
+ authors?: ConversationDetailReport["authors"];
441
453
  cancelError?: boolean;
442
454
  cancelPending?: boolean;
443
455
  cancelTargetInboundMessageId?: string;
@@ -506,6 +518,7 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
506
518
  return (
507
519
  <PendingRow
508
520
  actionsDisabled={actionsDisabled}
521
+ authors={props.authors}
509
522
  cancelError={Boolean(props.cancelError && isTarget)}
510
523
  cancelPending={Boolean(props.cancelPending && isTarget)}
511
524
  canEdit={ownWebRow}
@@ -1,10 +1,13 @@
1
1
  import type { ClipboardEventHandler, ReactNode } from "react";
2
+ import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
2
3
  import { transcriptRoleKind } from "../format";
3
4
  import { cn } from "../styles";
4
5
 
5
6
  /** Align message text and attachments beside their avatars. */
6
7
  export function TranscriptMessageShell(props: {
7
8
  actor: string;
9
+ /** Junior API path of the author photo. Initials show without it. */
10
+ avatarUrl?: string;
8
11
  children: ReactNode;
9
12
  onCopy?: ClipboardEventHandler<HTMLElement>;
10
13
  role: string;
@@ -36,14 +39,22 @@ export function TranscriptMessageShell(props: {
36
39
  src="/_junior/dashboard/avatar.png"
37
40
  />
38
41
  ) : (
39
- <span className="grid size-8 place-items-center rounded-full bg-violet-300 font-sans text-xs font-semibold text-dashboard-text-inverse">
40
- {props.actor
41
- .split(/\s+/)
42
- .map((word) => word[0])
43
- .slice(0, 2)
44
- .join("")
45
- .toUpperCase()}
46
- </span>
42
+ <Avatar className="size-8 border-0 bg-violet-300 shadow-none">
43
+ <AvatarImage
44
+ alt=""
45
+ loading="lazy"
46
+ referrerPolicy="no-referrer"
47
+ src={props.avatarUrl}
48
+ />
49
+ <AvatarFallback>
50
+ {props.actor
51
+ .split(/\s+/)
52
+ .map((word) => word[0])
53
+ .slice(0, 2)
54
+ .join("")
55
+ .toUpperCase()}
56
+ </AvatarFallback>
57
+ </Avatar>
47
58
  )}
48
59
  </div>
49
60
  ) : null}
@@ -40,6 +40,9 @@ export const TranscriptMessageView = memo(
40
40
  <TranscriptMessageShell
41
41
  role={props.message.role}
42
42
  actor={transcriptMessageActorLabel(props.conversation, props.message)}
43
+ avatarUrl={
44
+ transcriptMessageActor(props.conversation, props.message)?.avatarUrl
45
+ }
43
46
  onCopy={(event) => {
44
47
  const selection = event.currentTarget.ownerDocument.getSelection();
45
48
  if (
@@ -97,7 +100,8 @@ export const TranscriptMessageView = memo(
97
100
  previous.conversation.conversationId === next.conversation.conversationId &&
98
101
  previous.conversation.surface === next.conversation.surface &&
99
102
  previous.conversation.canFork === next.conversation.canFork &&
100
- previous.conversation.actorIdentity === next.conversation.actorIdentity,
103
+ previous.conversation.actorIdentity === next.conversation.actorIdentity &&
104
+ previous.conversation.authors === next.conversation.authors,
101
105
  );
102
106
 
103
107
  /** Render a redacted primary chat message without exposing body content. */
@@ -109,6 +113,9 @@ export function RedactedMessageView(props: {
109
113
  <TranscriptMessageShell
110
114
  role={props.message.role}
111
115
  actor={transcriptMessageActorLabel(props.conversation, props.message)}
116
+ avatarUrl={
117
+ transcriptMessageActor(props.conversation, props.message)?.avatarUrl
118
+ }
112
119
  >
113
120
  <TranscriptMessageHeader
114
121
  message={props.message}
@@ -203,7 +203,7 @@ function TurnContextActor(props: { actor: ActorIdentity; timestamp?: number }) {
203
203
  aria-label="Speaker"
204
204
  className="flex min-w-0 items-center gap-3 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
205
205
  >
206
- <ActorAvatar name={name} size="detail" />
206
+ <ActorAvatar imageUrl={props.actor.avatarUrl} name={name} size="detail" />
207
207
  <div className="min-w-0 flex-1">
208
208
  <div className="flex min-w-0 items-baseline justify-between gap-3">
209
209
  {email ? (
@@ -1,8 +1,10 @@
1
+ import type { QueryClient } from "@tanstack/react-query";
1
2
  import type {
2
3
  ActorIdentity,
3
4
  ConversationPendingMessage,
4
5
  InputImage,
5
6
  } from "@sentry/junior/api/schema";
7
+ import type { DashboardCoreData } from "../types";
6
8
 
7
9
  /** Client-owned mailbox row waiting on accept, server visibility, or retry. */
8
10
  export type ConversationOutboxMessage = {
@@ -27,6 +29,22 @@ export type ConversationMailboxMessage = ConversationPendingMessage & {
27
29
  idempotencyKey?: string;
28
30
  };
29
31
 
32
+ /** Identity of the viewer, for the outbox rows that they send. */
33
+ export function outboxActorIdentity(
34
+ queryClient: QueryClient,
35
+ ): ActorIdentity | undefined {
36
+ const user = queryClient.getQueryData<DashboardCoreData>([
37
+ "dashboard",
38
+ "core",
39
+ ])?.me.user;
40
+ if (!user?.email) return undefined;
41
+ return {
42
+ ...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : undefined),
43
+ email: user.email,
44
+ fullName: user.name || undefined,
45
+ };
46
+ }
47
+
30
48
  /** Stable React Query key for one conversation's local send outbox. */
31
49
  export function conversationOutboxQueryKey(conversationId: string | undefined) {
32
50
  return ["conversation", conversationId, "outbox"] as const;
@@ -163,6 +181,7 @@ function sameActorIdentity(
163
181
  if (left === right) return true;
164
182
  if (!left || !right) return false;
165
183
  return (
184
+ left.avatarUrl === right.avatarUrl &&
166
185
  left.email === right.email &&
167
186
  left.fullName === right.fullName &&
168
187
  left.slackUserId === right.slackUserId &&
@@ -93,6 +93,9 @@ export function pendingTranscriptMessage(
93
93
  ...(message.actorIdentity
94
94
  ? { actorIdentity: message.actorIdentity }
95
95
  : undefined),
96
+ ...(message.actorIdentityId
97
+ ? { actorIdentityId: message.actorIdentityId }
98
+ : undefined),
96
99
  ...(message.mentions ? { mentions: message.mentions } : undefined),
97
100
  };
98
101
  }
@@ -222,6 +225,9 @@ export function transcriptMessagesFromEvents(
222
225
  ...(data.actorIdentity
223
226
  ? { actorIdentity: data.actorIdentity }
224
227
  : undefined),
228
+ ...(data.actorIdentityId
229
+ ? { actorIdentityId: data.actorIdentityId }
230
+ : undefined),
225
231
  ...(data.agentInvocationId
226
232
  ? { agentInvocationId: data.agentInvocationId }
227
233
  : undefined),
@@ -1,7 +1,6 @@
1
1
  import { useDeferredValue, useEffect, useMemo, useRef } from "react";
2
2
  import {
3
3
  queryOptions,
4
- type QueryClient,
5
4
  useInfiniteQuery,
6
5
  useMutation,
7
6
  useMutationState,
@@ -9,7 +8,6 @@ import {
9
8
  useQueryClient,
10
9
  } from "@tanstack/react-query";
11
10
  import type {
12
- ActorIdentity,
13
11
  ConversationDetailReport,
14
12
  ConversationEventPage,
15
13
  ConversationFeed,
@@ -30,7 +28,6 @@ import {
30
28
  stopConversationTurnResponseSchema,
31
29
  } from "@sentry/junior/api/schema";
32
30
 
33
- import type { DashboardCoreData } from "../types";
34
31
  import {
35
32
  DashboardApiError,
36
33
  del,
@@ -45,6 +42,7 @@ import {
45
42
  failConversationOutboxMessage,
46
43
  isInlineOutboxMessage,
47
44
  mergeConversationMailboxMessages,
45
+ outboxActorIdentity,
48
46
  acceptConversationOutboxMessage,
49
47
  reuseConversationMailboxMessages,
50
48
  splitInlineMailboxMessages,
@@ -213,20 +211,6 @@ function keepLatestReadPosition(
213
211
  return { ...next, readPosition: kept };
214
212
  }
215
213
 
216
- function outboxActorIdentity(
217
- queryClient: QueryClient,
218
- ): ActorIdentity | undefined {
219
- const user = queryClient.getQueryData<DashboardCoreData>([
220
- "dashboard",
221
- "core",
222
- ])?.me.user;
223
- if (!user?.email) return undefined;
224
- return {
225
- email: user.email,
226
- fullName: user.name || undefined,
227
- };
228
- }
229
-
230
214
  /** Create one dashboard conversation and refresh the personal feed. */
231
215
  export function useCreateConversation() {
232
216
  const queryClient = useQueryClient();
@@ -223,8 +223,18 @@ export function reuseConversationEventReferences(
223
223
  JSON.stringify(previous.annotations) === JSON.stringify(next.annotations)
224
224
  ? previous.annotations
225
225
  : next.annotations;
226
- if (events === next.events && annotations === next.annotations) return next;
227
- return { ...next, events, annotations };
226
+ const authors =
227
+ JSON.stringify(previous.authors) === JSON.stringify(next.authors)
228
+ ? previous.authors
229
+ : next.authors;
230
+ if (
231
+ events === next.events &&
232
+ annotations === next.annotations &&
233
+ authors === next.authors
234
+ ) {
235
+ return next;
236
+ }
237
+ return { ...next, events, annotations, authors };
228
238
  }
229
239
 
230
240
  function sameConversationEventVersion(
@@ -354,6 +354,10 @@ export function transcriptMessageActor(
354
354
  ): ActorIdentity | undefined {
355
355
  if (transcriptRoleKind(message.role) !== "user") return undefined;
356
356
  if (message.agentInvocationId) return undefined;
357
+ const author = message.actorIdentityId
358
+ ? conversation.authors?.[message.actorIdentityId]
359
+ : undefined;
360
+ if (author) return author;
357
361
  if (actorLabel(message.actorIdentity)) return message.actorIdentity;
358
362
  if (actorLabel(conversation.actorIdentity)) return conversation.actorIdentity;
359
363
  return undefined;
@@ -370,7 +374,10 @@ export function transcriptMessageActorLabel(
370
374
  // The conversation actor started the child agent but did not write its
371
375
  // input, so do not fall back to that actor here.
372
376
  if (message.agentInvocationId) return "Agent invocation";
373
- return actorLabel(transcriptMessageActor(conversation, message)) ?? "User";
377
+ // Show the person's name, not their email. A web send carries both, and
378
+ // its durable Message carries only the name.
379
+ const actor = transcriptMessageActor(conversation, message);
380
+ return actor?.fullName?.trim() || (actorLabel(actor) ?? "User");
374
381
  }
375
382
  if (kind === "system") return "System";
376
383
  if (kind === "tool") return "Tool";