@sentry/junior-dashboard 0.272.0 → 0.274.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 (36) hide show
  1. package/dist/app.js +3 -3
  2. package/dist/client.js +69 -67
  3. package/dist/index.js +3 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +440 -430
  7. package/src/client/components/JuniorLogo.tsx +1 -1
  8. package/src/client/components/ProfileMenu.tsx +4 -41
  9. package/src/client/components/layout/DashboardChrome.tsx +9 -4
  10. package/src/client/components/layout/DashboardHeader.tsx +11 -51
  11. package/src/client/components/layout/NavRail.tsx +156 -0
  12. package/src/client/components/ui/sidebar.tsx +336 -0
  13. package/src/client/conversations/ConversationComposer.tsx +7 -4
  14. package/src/client/conversations/ConversationPage.tsx +3 -0
  15. package/src/client/conversations/ConversationSidebar.tsx +121 -60
  16. package/src/client/conversations/ConversationWorkspace.tsx +8 -3
  17. package/src/client/conversations/PendingMailboxStack.tsx +22 -9
  18. package/src/client/conversations/TranscriptMessageShell.tsx +19 -8
  19. package/src/client/conversations/TranscriptMessageView.tsx +8 -1
  20. package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
  21. package/src/client/conversations/composerAutocorrect.ts +12 -0
  22. package/src/client/conversations/conversationAge.ts +24 -0
  23. package/src/client/conversations/conversationOutbox.ts +19 -0
  24. package/src/client/conversations/conversationRoutes.ts +14 -5
  25. package/src/client/conversations/conversationSections.ts +64 -50
  26. package/src/client/conversations/eventTranscript.ts +6 -0
  27. package/src/client/conversations/queries.ts +1 -17
  28. package/src/client/conversations/transcript.ts +12 -2
  29. package/src/client/conversations/useConversationFinishedIndicators.ts +40 -8
  30. package/src/client/format.ts +8 -1
  31. package/src/client/pages/settings/SettingsPage.tsx +28 -0
  32. package/src/client/review-desk/ConversationReviewBar.tsx +20 -2
  33. package/src/client/review-desk/ReviewDeskPage.tsx +5 -1
  34. package/src/client/review-desk/queries.ts +34 -7
  35. package/src/client/types.ts +2 -0
  36. package/src/mock-reporting/routes.ts +1 -1
@@ -24,7 +24,10 @@ import { buildConversations, getDashboardTimeZone } from "../format";
24
24
  import { useDebouncedValue } from "../debouncedValue";
25
25
  import type { Conversation } from "../types";
26
26
  import { ConversationPage } from "./ConversationPage";
27
- import { useWaitingConversationIds } from "../review-desk/queries";
27
+ import {
28
+ useWaitingConversationIds,
29
+ useWaitingConversations,
30
+ } from "../review-desk/queries";
28
31
  import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
29
32
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
30
33
 
@@ -56,8 +59,9 @@ export function ConversationWorkspace() {
56
59
  if (selectedId) navigate(NEW_CONVERSATION_PATH);
57
60
  };
58
61
 
62
+ const waitingConversations = useWaitingConversations();
59
63
  const waitingConversationIds = useWaitingConversationIds();
60
- const { finishedConversationIds, markRead } =
64
+ const { finishedConversationIds, heldConversationId, markRead } =
61
65
  useConversationFinishedIndicators(
62
66
  conversations,
63
67
  selectedId,
@@ -149,13 +153,14 @@ export function ConversationWorkspace() {
149
153
  conversations={conversations}
150
154
  error={feed.error?.message}
151
155
  finishedConversationIds={finishedConversationIds}
156
+ heldConversationId={heldConversationId}
152
157
  loading={feed.isPending}
153
158
  onNewConversation={openCreate}
154
159
  onQueryChange={setQuery}
155
160
  query={query}
156
161
  selectedId={selectedId}
157
162
  timeZone={getDashboardTimeZone()}
158
- waitingConversationIds={waitingConversationIds}
163
+ waitingConversations={waitingConversations}
159
164
  />
160
165
  </div>
161
166
  <section
@@ -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 ? (
@@ -0,0 +1,12 @@
1
+ import { useLocalStorage } from "@uidotdev/usehooks";
2
+
3
+ const AUTOCORRECT_STORAGE_KEY = "junior:composer:autocorrect";
4
+
5
+ /**
6
+ * Edit the browser-only choice to let the keyboard autocorrect, spell check,
7
+ * and capitalize the message composer. Off by default because these change
8
+ * ids, paths, and code.
9
+ */
10
+ export function useComposerAutocorrect() {
11
+ return useLocalStorage<boolean>(AUTOCORRECT_STORAGE_KEY, false);
12
+ }
@@ -0,0 +1,24 @@
1
+ import { getDashboardTimeZone } from "../format";
2
+
3
+ /**
4
+ * Format an activity time as a short age for dense lists: `now`, `12m`, `3h`,
5
+ * `2d`, `3w`, then a month and day.
6
+ */
7
+ export function formatCompactAge(value: string, nowMs = Date.now()): string {
8
+ const time = Date.parse(value);
9
+ if (!Number.isFinite(time)) return "";
10
+
11
+ const minutes = Math.floor(Math.max(0, nowMs - time) / 60_000);
12
+ if (minutes < 1) return "now";
13
+ if (minutes < 60) return `${minutes}m`;
14
+ const hours = Math.floor(minutes / 60);
15
+ if (hours < 24) return `${hours}h`;
16
+ const days = Math.floor(hours / 24);
17
+ if (days < 7) return `${days}d`;
18
+ if (days < 30) return `${Math.floor(days / 7)}w`;
19
+ return new Date(time).toLocaleDateString("en-US", {
20
+ day: "numeric",
21
+ month: "short",
22
+ timeZone: getDashboardTimeZone(),
23
+ });
24
+ }
@@ -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 &&
@@ -2,16 +2,21 @@ import type { ConversationReportEvent } from "@sentry/junior/api/schema";
2
2
 
3
3
  /**
4
4
  * Build the dashboard route for a conversation id. A `turnId` opens the
5
- * start of that Turn in the event log.
5
+ * start of that Turn in the event log. `reviewItems` opens the Review items
6
+ * panel.
6
7
  */
7
8
  export function conversationPath(
8
9
  conversationId: string,
9
- options?: { turnId?: string },
10
+ options?: { reviewItems?: boolean; turnId?: string },
10
11
  ): string {
11
12
  const path = `/conversations/${encodeURIComponent(conversationId)}`;
12
- return options?.turnId
13
- ? `${path}?${CONVERSATION_TURN_PARAM}=${encodeURIComponent(options.turnId)}`
14
- : path;
13
+ const params = new URLSearchParams();
14
+ if (options?.turnId) params.set(CONVERSATION_TURN_PARAM, options.turnId);
15
+ if (options?.reviewItems) {
16
+ params.set(CONVERSATION_REVIEW_PARAM, CONVERSATION_REVIEW_OPEN);
17
+ }
18
+ const search = params.toString();
19
+ return search ? `${path}?${search}` : path;
15
20
  }
16
21
 
17
22
  /** Dashboard home route. Legacy `/conversations/new` redirects here. */
@@ -32,6 +37,10 @@ export function parseConversationEventSeq(
32
37
  return value && /^\d+$/.test(value) ? Number(value) : undefined;
33
38
  }
34
39
 
40
+ /** Query param that opens the Review items panel of a Conversation. */
41
+ export const CONVERSATION_REVIEW_PARAM = "review";
42
+ export const CONVERSATION_REVIEW_OPEN = "open";
43
+
35
44
  /** Query param that opens the start of one Turn in the event log. */
36
45
  export const CONVERSATION_TURN_PARAM = "turn";
37
46
 
@@ -7,21 +7,13 @@ export type ConversationSection = {
7
7
  };
8
8
 
9
9
  const DAY_MS = 24 * 60 * 60 * 1000;
10
- const SECTION_ORDER = [
11
- "attention",
12
- "priority",
13
- "today",
14
- "yesterday",
15
- "last-week",
16
- "2-weeks",
17
- "3-weeks",
18
- "older",
19
- ] as const;
20
10
 
21
11
  /**
22
- * Group conversations into progressively broader activity sections.
12
+ * Group conversations by last activity: Today, Yesterday, Previous 7 days,
13
+ * Previous 30 days, then one section per month.
23
14
  *
24
- * Conversations in `attentionIds` go to the first section, before Priority.
15
+ * Conversations in `attentionIds` lead their section, so unread and waiting
16
+ * rows sit above read rows without leaving their time group.
25
17
  */
26
18
  export function buildConversationSections(
27
19
  conversations: Conversation[],
@@ -32,16 +24,21 @@ export function buildConversationSections(
32
24
  },
33
25
  ): ConversationSection[] {
34
26
  const nowDay = calendarDay(options.nowMs, options.timeZone);
35
- const sections = new Map<string, ConversationSection>();
36
- const sorted = [...conversations].sort(
27
+ const sections = new Map<string, ConversationSection & { order: number }>();
28
+ const byActivity = [...conversations].sort(
37
29
  (left, right) => activityTime(right) - activityTime(left),
38
30
  );
31
+ const sorted = options.attentionIds
32
+ ? attentionFirst(byActivity, options.attentionIds)
33
+ : byActivity;
39
34
 
40
35
  for (const conversation of sorted) {
41
- const time = activityTime(conversation);
42
- const section = options.attentionIds?.has(conversation.id)
43
- ? { key: "attention", label: "Needs attention" }
44
- : conversationSection(conversation, time, nowDay, options.timeZone);
36
+ const section = conversationSection(
37
+ activityTime(conversation),
38
+ nowDay,
39
+ options.nowMs,
40
+ options.timeZone,
41
+ );
45
42
  const existing = sections.get(section.key);
46
43
  if (existing) {
47
44
  existing.conversations.push(conversation);
@@ -50,9 +47,9 @@ export function buildConversationSections(
50
47
  }
51
48
  }
52
49
 
53
- return [...sections.values()].sort(
54
- (left, right) => sectionRank(left.key) - sectionRank(right.key),
55
- );
50
+ return [...sections.values()]
51
+ .sort((left, right) => left.order - right.order)
52
+ .map(({ order: _order, ...section }) => section);
56
53
  }
57
54
 
58
55
  /** Join the conversations that wait on the viewer or finished unseen. */
@@ -82,44 +79,61 @@ function activityTime(conversation: Conversation): number {
82
79
  return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
83
80
  }
84
81
 
82
+ /**
83
+ * Pick the section for an activity time. `order` sorts sections newest
84
+ * first.
85
+ */
85
86
  function conversationSection(
86
- conversation: Conversation,
87
87
  time: number,
88
88
  nowDay: number,
89
+ nowMs: number,
89
90
  timeZone: string,
90
- ): Pick<ConversationSection, "key" | "label"> {
91
- if (!Number.isFinite(time)) return { key: "older", label: "Older" };
92
-
93
- // Priority membership is decided by the conversation feed API.
94
- if (conversation.isPriority) {
95
- return { key: "priority", label: "Priority" };
91
+ ): Pick<ConversationSection, "key" | "label"> & { order: number } {
92
+ if (!Number.isFinite(time)) {
93
+ return { key: "older", label: "Older", order: Number.POSITIVE_INFINITY };
96
94
  }
97
95
 
98
- const day = calendarDay(time, timeZone);
99
- const ageInDays = Math.max(0, Math.floor((nowDay - day) / DAY_MS));
100
- if (ageInDays === 0) return { key: "today", label: "Today" };
101
- if (ageInDays === 1) return { key: "yesterday", label: "Yesterday" };
102
- if (ageInDays < 7) {
103
- return {
104
- key: `day-${day}`,
105
- label: new Intl.DateTimeFormat("en-US", {
106
- timeZone,
107
- weekday: "long",
108
- }).format(time),
109
- };
96
+ const ageInDays = Math.max(
97
+ 0,
98
+ Math.round((nowDay - calendarDay(time, timeZone)) / DAY_MS),
99
+ );
100
+ if (ageInDays === 0) return { key: "today", label: "Today", order: 0 };
101
+ if (ageInDays === 1) {
102
+ return { key: "yesterday", label: "Yesterday", order: 1 };
103
+ }
104
+ if (ageInDays <= 7) {
105
+ return { key: "previous-7-days", label: "Previous 7 days", order: 2 };
110
106
  }
111
- if (ageInDays < 14) return { key: "last-week", label: "Last week" };
112
- if (ageInDays < 21) return { key: "2-weeks", label: "2 weeks ago" };
113
- if (ageInDays < 28) return { key: "3-weeks", label: "3 weeks ago" };
114
- return { key: "older", label: "Older" };
107
+ if (ageInDays <= 30) {
108
+ return { key: "previous-30-days", label: "Previous 30 days", order: 3 };
109
+ }
110
+
111
+ const month = yearMonth(time, timeZone);
112
+ const nowMonth = yearMonth(nowMs, timeZone);
113
+ return {
114
+ key: `month-${month.year}-${month.month}`,
115
+ label: new Intl.DateTimeFormat("en-US", {
116
+ month: "long",
117
+ timeZone,
118
+ ...(month.year === nowMonth.year ? undefined : { year: "numeric" }),
119
+ }).format(time),
120
+ // Month sections follow the rolling windows, newest month first.
121
+ order:
122
+ 4 + (nowMonth.year - month.year) * 12 + (nowMonth.month - month.month),
123
+ };
115
124
  }
116
125
 
117
- function sectionRank(key: string): number {
118
- const known = SECTION_ORDER.indexOf(key as (typeof SECTION_ORDER)[number]);
119
- if (known >= 0) return known;
120
- // Weekday buckets sit between Yesterday and Last week.
121
- if (key.startsWith("day-")) return SECTION_ORDER.indexOf("yesterday") + 0.5;
122
- return SECTION_ORDER.length;
126
+ function yearMonth(
127
+ time: number,
128
+ timeZone: string,
129
+ ): { month: number; year: number } {
130
+ const parts = new Intl.DateTimeFormat("en-US", {
131
+ month: "numeric",
132
+ timeZone,
133
+ year: "numeric",
134
+ }).formatToParts(time);
135
+ const values = new Map(parts.map((part) => [part.type, Number(part.value)]));
136
+ return { month: values.get("month") ?? 1, year: values.get("year") ?? 0 };
123
137
  }
124
138
 
125
139
  function calendarDay(time: number, timeZone: string): number {
@@ -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(
@@ -12,6 +12,12 @@ type StoredConversationStates = Record<string, StoredConversationState>;
12
12
 
13
13
  type ConversationFinishedIndicators = {
14
14
  finishedConversationIds: ReadonlySet<string>;
15
+ /**
16
+ * The selected conversation when it was unread at the moment it opened.
17
+ * Lists keep it in its unread place until another conversation opens, so
18
+ * the row does not move under the pointer.
19
+ */
20
+ heldConversationId?: string;
15
21
  markRead(conversationId: string, lastReadAt: string): void;
16
22
  };
17
23
 
@@ -23,6 +29,23 @@ export function useConversationFinishedIndicators(
23
29
  pruneMissing: boolean,
24
30
  ): ConversationFinishedIndicators {
25
31
  const [storedStates, setStoredStates] = useState(readStoredStates);
32
+ const [held, setHeld] = useState<{ heldId?: string; selectedId?: string }>(
33
+ {},
34
+ );
35
+ // Read the unread state when the selection changes, before the effect
36
+ // below or the opened page marks it read. Hold only a row that the list
37
+ // already showed. A deep link has no row under the pointer, and the
38
+ // selected row never leads its section, so it does not move.
39
+ if (held.selectedId !== selectedId) {
40
+ const selected = conversations.find(({ id }) => id === selectedId);
41
+ setHeld({
42
+ heldId:
43
+ selected && finishedSinceRead(storedStates, selected)
44
+ ? selected.id
45
+ : undefined,
46
+ selectedId,
47
+ });
48
+ }
26
49
 
27
50
  useEffect(() => {
28
51
  if (!ready) return;
@@ -55,6 +78,7 @@ export function useConversationFinishedIndicators(
55
78
  () => finishedConversationIds(storedStates, conversations, selectedId),
56
79
  [conversations, selectedId, storedStates],
57
80
  ),
81
+ heldConversationId: held.heldId,
58
82
  markRead,
59
83
  };
60
84
  }
@@ -67,19 +91,27 @@ export function finishedConversationIds(
67
91
  ): ReadonlySet<string> {
68
92
  return new Set(
69
93
  conversations
70
- .filter((conversation) => {
71
- const state = states[conversation.id];
72
- return (
94
+ .filter(
95
+ (conversation) =>
73
96
  conversation.id !== selectedId &&
74
- conversation.status === "completed" &&
75
- state !== undefined &&
76
- isAfter(conversation.lastSeenAt, state.lastReadAt)
77
- );
78
- })
97
+ finishedSinceRead(states, conversation),
98
+ )
79
99
  .map((conversation) => conversation.id),
80
100
  );
81
101
  }
82
102
 
103
+ function finishedSinceRead(
104
+ states: StoredConversationStates,
105
+ conversation: Conversation,
106
+ ): boolean {
107
+ const state = states[conversation.id];
108
+ return (
109
+ conversation.status === "completed" &&
110
+ state !== undefined &&
111
+ isAfter(conversation.lastSeenAt, state.lastReadAt)
112
+ );
113
+ }
114
+
83
115
  /** Record reads and remove conversations outside the visible list. */
84
116
  export function reconcileStoredStates(
85
117
  current: StoredConversationStates,
@@ -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";