@sentry/junior-dashboard 0.271.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.
Files changed (46) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +69 -67
  3. package/dist/index.js +40 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/app.ts +2 -0
  7. package/src/client/App.tsx +440 -430
  8. package/src/client/components/JuniorLogo.tsx +1 -1
  9. package/src/client/components/ProfileMenu.tsx +4 -41
  10. package/src/client/components/layout/DashboardChrome.tsx +9 -4
  11. package/src/client/components/layout/DashboardHeader.tsx +11 -51
  12. package/src/client/components/layout/NavRail.tsx +156 -0
  13. package/src/client/components/ui/sidebar.tsx +336 -0
  14. package/src/client/conversations/ConversationPage.tsx +14 -0
  15. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  16. package/src/client/conversations/PendingMailboxStack.tsx +22 -9
  17. package/src/client/conversations/TranscriptMessageShell.tsx +19 -8
  18. package/src/client/conversations/TranscriptMessageView.tsx +8 -1
  19. package/src/client/conversations/TranscriptRows.tsx +23 -0
  20. package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
  21. package/src/client/conversations/TranscriptView.tsx +22 -0
  22. package/src/client/conversations/conversationOutbox.ts +19 -0
  23. package/src/client/conversations/eventTranscript.ts +6 -0
  24. package/src/client/conversations/queries.ts +29 -20
  25. package/src/client/conversations/transcript.ts +12 -2
  26. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  27. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  28. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  29. package/src/client/format.ts +8 -1
  30. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  31. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  32. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  33. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  34. package/src/client/pages/settings/SettingsPage.tsx +62 -0
  35. package/src/client/review-desk/ReviewDeskPage.tsx +26 -40
  36. package/src/client/review-desk/ReviewDeskSidebar.tsx +29 -23
  37. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  38. package/src/client/review-desk/queries.ts +6 -5
  39. package/src/client/review-desk/reviewItemFormat.ts +3 -5
  40. package/src/client/types.ts +2 -0
  41. package/src/mock-reporting/read-positions.ts +39 -0
  42. package/src/mock-reporting/routes.ts +26 -1
  43. package/src/tailwind.css +1 -1
  44. package/src/client/components/AGENTS.md +0 -36
  45. package/src/client/conversations/AGENTS.md +0 -17
  46. package/src/client/pages/automations/AGENTS.md +0 -15
@@ -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
+ };
@@ -98,6 +98,11 @@ import {
98
98
  } from "../review-desk/reviewItemFormat";
99
99
  import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
100
100
  import { Transcript } from "./TranscriptView";
101
+ import {
102
+ readConversationStartPosition,
103
+ useMarkConversationRead,
104
+ useReadPositionAtOpen,
105
+ } from "./transcriptReadPosition";
101
106
  import { TranscriptLoading } from "./TranscriptLoading";
102
107
  import type { TranscriptViewMode } from "./transcriptRenderModel";
103
108
  import {
@@ -166,6 +171,8 @@ export function ConversationPage(props: {
166
171
  const conversations = buildConversations(summaries);
167
172
  const detail = useConversationData(conversationId);
168
173
  const archive = useArchiveConversation(conversationId);
174
+ const markRead = useMarkConversationRead(conversationId);
175
+ const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
169
176
  const feedConversation = conversations.find(
170
177
  (item) => item.id === conversationId,
171
178
  );
@@ -484,9 +491,11 @@ export function ConversationPage(props: {
484
491
  hasPreviousPage={detail.hasPreviousPage}
485
492
  historyError={detail.historyError}
486
493
  historyVersion={detail.historyVersion}
494
+ lastReadSeq={readAtOpen.lastReadSeq}
487
495
  live={live}
488
496
  loadingPreviousPage={detail.isLoadingPreviousPage}
489
497
  onLoadPreviousPage={detail.loadPreviousPage}
498
+ onRead={markRead}
490
499
  onSelectedEventSeqChange={setSelectedEventSeq}
491
500
  pendingMessages={detail.inlineMessages}
492
501
  pinRequestVersion={pinRequestVersion}
@@ -497,8 +506,10 @@ export function ConversationPage(props: {
497
506
  search={search}
498
507
  onSearchChange={setSearch}
499
508
  selectedEventSeq={selectedEventSeq}
509
+ startAtUnread={readConversationStartPosition() === "unread"}
500
510
  transcript={transcript}
501
511
  turnContextPanel={turnContextPanel}
512
+ unread={readAtOpen.unread}
502
513
  view={view}
503
514
  />
504
515
  </>
@@ -511,6 +522,7 @@ export function ConversationPage(props: {
511
522
  // runs without interactive auth, so it has no approval to show.
512
523
  view === "rich" && detail.data?.isParticipant && !agentTree ? (
513
524
  <ConversationReplyFooter
525
+ authors={detail.data.authors}
514
526
  conversationId={conversationId}
515
527
  // Only pass committed ids for mailbox de-dupe. The full transcript is
516
528
  // too large to re-enter the footer on every live poll while typing.
@@ -609,6 +621,7 @@ export function ConversationPage(props: {
609
621
  * footer tree. Fast chat UIs isolate the composer the same way.
610
622
  */
611
623
  const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
624
+ authors?: ConversationDetailReport["authors"];
612
625
  committedMessageIds: readonly string[];
613
626
  conversationId: string;
614
627
  onPinRequest: () => void;
@@ -863,6 +876,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
863
876
  >
864
877
  <ConversationReviewBar conversationId={props.conversationId} />
865
878
  <PendingMailboxStack
879
+ authors={props.authors}
866
880
  cancelError={cancelError}
867
881
  cancelPending={cancelPendingMessages.isPending}
868
882
  cancelTargetInboundMessageId={cancelTargetInboundMessageId}
@@ -49,8 +49,13 @@ import {
49
49
  type RenderedTranscriptEntry,
50
50
  } from "./transcriptRenderModel";
51
51
  import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch";
52
- import { TranscriptRow, TranscriptRows } from "./TranscriptRows";
52
+ import {
53
+ TranscriptRow,
54
+ TranscriptRows,
55
+ TranscriptUnreadMarker,
56
+ } from "./TranscriptRows";
53
57
  import { TranscriptTimestampProvider } from "./TranscriptTimestamp";
58
+ import type { TranscriptUnreadRange } from "./transcriptReadPosition";
54
59
 
55
60
  type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
56
61
  type TranscriptContextEntry = Extract<TranscriptEntry, { kind: "context" }>;
@@ -85,6 +90,8 @@ export const ConversationTranscriptView = memo(
85
90
  /** Sends that history does not have yet. History wins on message id. */
86
91
  pendingMessages?: readonly ConversationMailboxMessage[];
87
92
  responding?: boolean;
93
+ /** Show the "New" marker before the first row in this range. */
94
+ unread?: TranscriptUnreadRange;
88
95
  }) {
89
96
  // Cards can change while their Message events stay unchanged.
90
97
  const { events, annotations } = props.conversation;
@@ -106,6 +113,7 @@ export const ConversationTranscriptView = memo(
106
113
  conversation={props.conversation}
107
114
  messages={messages}
108
115
  responding={props.responding}
116
+ unread={props.unread}
109
117
  />
110
118
  </section>
111
119
  </TranscriptTimestampProvider>
@@ -121,6 +129,7 @@ function SegmentEvents(props: {
121
129
  conversation: ConversationTranscript;
122
130
  messages: TranscriptViewMessage[];
123
131
  responding?: boolean;
132
+ unread?: TranscriptUnreadRange;
124
133
  }) {
125
134
  return (
126
135
  <TranscriptRows>
@@ -134,6 +143,7 @@ function SegmentEvents(props: {
134
143
  onOpenSubagentTranscript={props.onOpenSubagentTranscript}
135
144
  transcript={props.messages}
136
145
  conversation={props.conversation}
146
+ unread={props.unread}
137
147
  />
138
148
  ) : props.conversation.eventHistory.status === "redacted" &&
139
149
  props.messages.length > 0 ? (
@@ -205,6 +215,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
205
215
  }) => void;
206
216
  transcript: TranscriptViewMessage[];
207
217
  conversation: ConversationTranscript;
218
+ unread?: TranscriptUnreadRange;
208
219
  }) {
209
220
  const entries = useMemo(
210
221
  () => groupTranscriptMessages(props.transcript),
@@ -214,6 +225,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
214
225
  <TranscriptEntryList
215
226
  entries={entries}
216
227
  keyPrefix={props.conversation.conversationId}
228
+ unread={props.unread}
217
229
  renderContext={(entry) => (
218
230
  <TranscriptRailEvent
219
231
  kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
@@ -314,11 +326,26 @@ function TranscriptEntryList(props: {
314
326
  renderReasoning: (entry: TranscriptReasoningEntry) => ReactNode;
315
327
  renderSubagent: (entry: TranscriptSubagentEntry) => ReactNode;
316
328
  renderTool: (entry: TranscriptToolEntry) => ReactNode;
329
+ unread?: TranscriptUnreadRange;
317
330
  }) {
318
331
  const search = useTranscriptSearch();
319
332
  const activityKeys = useRef(new Map<string, string>());
320
333
  const claimedActivityKeys = new Set<string>();
321
334
  const rows: ReactNode[] = [];
335
+ // Search results are not a reading order, so they get no marker.
336
+ const markerSeq = search.active
337
+ ? undefined
338
+ : unreadMarkerSeq(props.entries, props.unread);
339
+ let unreadMarkerPending = markerSeq !== undefined;
340
+ // Entries are in seq order, so the first row that reaches the marker seq
341
+ // gets the marker.
342
+ const pushRow = (seq: number | undefined, row: ReactNode) => {
343
+ if (unreadMarkerPending && seq !== undefined && seq >= markerSeq!) {
344
+ unreadMarkerPending = false;
345
+ rows.push(<TranscriptUnreadMarker key="unread-marker" />);
346
+ }
347
+ rows.push(row);
348
+ };
322
349
 
323
350
  const renderEntry = (entry: TranscriptEntry): ReactNode => {
324
351
  if (entry.kind === "subagent") return props.renderSubagent(entry);
@@ -359,8 +386,16 @@ function TranscriptEntryList(props: {
359
386
  keyPrefix: props.keyPrefix,
360
387
  knownKeys: activityKeys.current,
361
388
  });
362
- rows.push(
363
- <TranscriptRow anchorKey={activityKey} indent key={activityKey}>
389
+ const lastSeq = lastEntrySeq(activityEntries);
390
+ // The marker goes before the group when any of it is unread.
391
+ pushRow(
392
+ lastSeq,
393
+ <TranscriptRow
394
+ anchorKey={activityKey}
395
+ indent
396
+ key={activityKey}
397
+ seq={lastSeq}
398
+ >
364
399
  <TranscriptActivityGroup
365
400
  entries={visibleEntries}
366
401
  renderEntry={renderEntry}
@@ -373,8 +408,9 @@ function TranscriptEntryList(props: {
373
408
 
374
409
  if (!search.active || entryMatchesSearch(entry, search.normalizedQuery)) {
375
410
  const rowKey = `${props.keyPrefix}:${entry.key}`;
376
- rows.push(
377
- <TranscriptRow anchorKey={rowKey} key={rowKey}>
411
+ pushRow(
412
+ entry.seq,
413
+ <TranscriptRow anchorKey={rowKey} key={rowKey} seq={entry.seq}>
378
414
  {renderEntry(entry)}
379
415
  </TranscriptRow>,
380
416
  );
@@ -391,6 +427,39 @@ function TranscriptEntryList(props: {
391
427
  return rows;
392
428
  }
393
429
 
430
+ /**
431
+ * Find the seq of the first unread row. Entries are in seq order, and pending
432
+ * sends without a seq come last, so a binary search finds the first seq after
433
+ * the read position. Rows after the range arrived after open, so they get no
434
+ * marker.
435
+ */
436
+ function unreadMarkerSeq(
437
+ entries: readonly TranscriptEntry[],
438
+ unread: TranscriptUnreadRange | undefined,
439
+ ): number | undefined {
440
+ if (!unread) return undefined;
441
+ let low = 0;
442
+ let high = entries.length;
443
+ while (low < high) {
444
+ const middle = (low + high) >> 1;
445
+ const seq = entries[middle]!.seq;
446
+ if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1;
447
+ else high = middle;
448
+ }
449
+ const seq = entries[low]?.seq;
450
+ return seq !== undefined && seq <= unread.throughSeq ? seq : undefined;
451
+ }
452
+
453
+ function lastEntrySeq(
454
+ entries: readonly RenderedTranscriptEntry[],
455
+ ): number | undefined {
456
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
457
+ const seq = entries[index]!.seq;
458
+ if (seq !== undefined) return seq;
459
+ }
460
+ return undefined;
461
+ }
462
+
394
463
  /** Keep one activity group mounted while new or historical events extend either edge. */
395
464
  function stableActivityGroupKey(args: {
396
465
  claimedKeys: Set<string>;
@@ -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}