@sentry/junior-dashboard 0.266.0 → 0.268.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 (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -9,6 +9,11 @@ export type ConversationOutboxMessage = {
9
9
  actorIdentity?: ActorIdentity;
10
10
  createdAt: string;
11
11
  idempotencyKey: string;
12
+ /**
13
+ * Sent while Junior was idle and the queue was empty. The transcript shows
14
+ * the row in place, so it does not pass through the queue first.
15
+ */
16
+ inline?: boolean;
12
17
  message: string;
13
18
  images?: InputImage[];
14
19
  messageId: string;
@@ -31,6 +36,7 @@ export function conversationOutboxQueryKey(conversationId: string | undefined) {
31
36
  export function conversationOutboxMessageForSubmit(input: {
32
37
  actorIdentity?: ActorIdentity;
33
38
  idempotencyKey: string;
39
+ inline?: boolean;
34
40
  message: string;
35
41
  images?: InputImage[];
36
42
  messageId: string;
@@ -41,6 +47,7 @@ export function conversationOutboxMessageForSubmit(input: {
41
47
  actorIdentity: input.actorIdentity,
42
48
  createdAt,
43
49
  idempotencyKey: input.idempotencyKey,
50
+ ...(input.inline ? { inline: true } : undefined),
44
51
  message: input.message,
45
52
  ...(input.images?.length ? { images: input.images } : undefined),
46
53
  messageId: input.messageId,
@@ -153,6 +160,45 @@ function sameActorIdentity(
153
160
  );
154
161
  }
155
162
 
163
+ /** True while an outbox row belongs in the transcript instead of the queue. */
164
+ export function isInlineOutboxMessage(
165
+ message: ConversationOutboxMessage,
166
+ ): boolean {
167
+ // A failed send moves to the queue, which owns the retry action.
168
+ return Boolean(message.inline) && message.status !== "failed";
169
+ }
170
+
171
+ /**
172
+ * Split pending rows between the queue and the transcript.
173
+ *
174
+ * Inline rows prefer the server copy once the mailbox has it, so attachments
175
+ * and identity match what the queue would show.
176
+ */
177
+ export function splitInlineMailboxMessages(
178
+ merged: readonly ConversationMailboxMessage[],
179
+ server: readonly ConversationPendingMessage[] | undefined,
180
+ outbox: readonly ConversationOutboxMessage[],
181
+ ): {
182
+ inline: ConversationMailboxMessage[];
183
+ queued: ConversationMailboxMessage[];
184
+ } {
185
+ const inlineRows = outbox.filter(isInlineOutboxMessage);
186
+ if (inlineRows.length === 0) {
187
+ return { inline: [], queued: merged as ConversationMailboxMessage[] };
188
+ }
189
+ const inlineIds = new Set(inlineRows.map((message) => message.messageId));
190
+ const serverById = new Map(
191
+ (server ?? []).map((message) => [message.messageId, message]),
192
+ );
193
+ return {
194
+ inline: inlineRows.map(
195
+ (message) =>
196
+ serverById.get(message.messageId) ?? mailboxMessageFromOutbox(message),
197
+ ),
198
+ queued: merged.filter((message) => !inlineIds.has(message.messageId)),
199
+ };
200
+ }
201
+
156
202
  /** Append or replace one outbox row by idempotency key. */
157
203
  export function upsertConversationOutboxMessage(
158
204
  current: readonly ConversationOutboxMessage[] | undefined,
@@ -28,6 +28,15 @@ export function useEventTime() {
28
28
  day: "numeric",
29
29
  timeZoneName: "short",
30
30
  }),
31
+ // The details header is narrow, so it leaves out milliseconds.
32
+ shortTimestamp: new Intl.DateTimeFormat(undefined, {
33
+ ...clock,
34
+ fractionalSecondDigits: undefined,
35
+ year: "numeric",
36
+ month: "short",
37
+ day: "numeric",
38
+ timeZoneName: "short",
39
+ }),
31
40
  // A log can span both standard time and daylight-saving time.
32
41
  zone: new Intl.DateTimeFormat(undefined, {
33
42
  timeZone,
@@ -54,7 +63,10 @@ export function useEventTime() {
54
63
  return formatters.clock.format(new Date(value));
55
64
  },
56
65
  formatTimestamp(value: string) {
57
- return local ? formatters.timestamp.format(new Date(value)) : value;
66
+ return formatters.timestamp.format(new Date(value));
67
+ },
68
+ formatShortTimestamp(value: string) {
69
+ return formatters.shortTimestamp.format(new Date(value));
58
70
  },
59
71
  };
60
72
  }
@@ -2,6 +2,7 @@ import { resolveMessageCards } from "@sentry/junior/api/schema";
2
2
  import type {
3
3
  ConversationPendingMessage,
4
4
  ConversationReportEvent,
5
+ InputImage,
5
6
  } from "@sentry/junior/api/schema";
6
7
 
7
8
  import type {
@@ -10,6 +11,11 @@ import type {
10
11
  TranscriptViewPart,
11
12
  } from "../types";
12
13
 
14
+ /** A mailbox row, or a local send that can still carry its own images. */
15
+ type PendingTranscriptInput = ConversationPendingMessage & {
16
+ images?: InputImage[];
17
+ };
18
+
13
19
  type ToolCall =
14
20
  | Extract<
15
21
  ConversationReportEvent["data"],
@@ -67,7 +73,7 @@ function historyMessageIds(
67
73
 
68
74
  /** Project one accepted mailbox row into a pending transcript message. */
69
75
  export function pendingTranscriptMessage(
70
- message: ConversationPendingMessage,
76
+ message: PendingTranscriptInput,
71
77
  index: number,
72
78
  ): TranscriptViewMessage {
73
79
  return {
@@ -77,6 +83,7 @@ export function pendingTranscriptMessage(
77
83
  ? [{ type: "text", redacted: true }]
78
84
  : [{ type: "text", text: message.text ?? "" }],
79
85
  ...(message.attachments ? { attachments: message.attachments } : undefined),
86
+ ...(message.images?.length ? { images: message.images } : undefined),
80
87
  pending: true,
81
88
  role: "user",
82
89
  source: message.source,
@@ -97,7 +104,7 @@ export function pendingTranscriptMessage(
97
104
  */
98
105
  export function mergePendingTranscriptMessages(
99
106
  messages: TranscriptViewMessage[],
100
- pending: readonly ConversationPendingMessage[] | undefined,
107
+ pending: readonly PendingTranscriptInput[] | undefined,
101
108
  ): TranscriptViewMessage[] {
102
109
  if (!pending?.length) return messages;
103
110
  const extras = unresolvedPendingTranscriptMessages(messages, pending);
@@ -112,7 +119,7 @@ export function mergePendingTranscriptMessages(
112
119
  */
113
120
  export function unresolvedPendingTranscriptMessages(
114
121
  messages: readonly TranscriptViewMessage[],
115
- pending: readonly ConversationPendingMessage[] | undefined,
122
+ pending: readonly PendingTranscriptInput[] | undefined,
116
123
  ): TranscriptViewMessage[] {
117
124
  if (!pending?.length) return [];
118
125
  const committedIds = historyMessageIds(messages);
@@ -124,7 +131,7 @@ export function unresolvedPendingTranscriptMessages(
124
131
  /** Reduce the ordered reporting event API into dashboard-only transcript rows. */
125
132
  export function conversationTranscriptMessages(
126
133
  conversation: ConversationTranscript,
127
- pendingMessages?: readonly ConversationPendingMessage[],
134
+ pendingMessages?: readonly PendingTranscriptInput[],
128
135
  ): TranscriptViewMessage[] {
129
136
  return transcriptMessagesFromEvents(
130
137
  conversation.events,
@@ -137,7 +144,7 @@ export function conversationTranscriptMessages(
137
144
  export function transcriptMessagesFromEvents(
138
145
  events: ConversationReportEvent[],
139
146
  annotations: ConversationTranscript["annotations"],
140
- pendingMessages?: readonly ConversationPendingMessage[],
147
+ pendingMessages?: readonly PendingTranscriptInput[],
141
148
  ): TranscriptViewMessage[] {
142
149
  const replacedToolIds = specialToolIds(events);
143
150
  const tools = new Map<
@@ -26,6 +26,7 @@ import {
26
26
  conversationEventPageSchema,
27
27
  conversationPendingMessagesReportSchema,
28
28
  forkConversationResponseSchema,
29
+ steerConversationPendingMessagesResponseSchema,
29
30
  stopConversationTurnResponseSchema,
30
31
  } from "@sentry/junior/api/schema";
31
32
 
@@ -42,8 +43,11 @@ import {
42
43
  conversationOutboxMessageForSubmit,
43
44
  conversationOutboxQueryKey,
44
45
  failConversationOutboxMessage,
46
+ isInlineOutboxMessage,
45
47
  mergeConversationMailboxMessages,
46
48
  acceptConversationOutboxMessage,
49
+ reuseConversationMailboxMessages,
50
+ splitInlineMailboxMessages,
47
51
  upsertConversationOutboxMessage,
48
52
  type ConversationMailboxMessage,
49
53
  type ConversationOutboxMessage,
@@ -130,6 +134,11 @@ export function usePendingArchiveConversationUpdates() {
130
134
  });
131
135
  }
132
136
 
137
+ // Right after a send, Junior usually reads the message within a few seconds.
138
+ // Poll fast for that window only, then fall back to the normal live interval.
139
+ const FAST_POLL_INTERVAL_MS = 500;
140
+ const FAST_POLL_WINDOW_MS = 6_000;
141
+
133
142
  type ConversationSnapshot = ConversationDetailReport & {
134
143
  mailbox?: ConversationPendingMessagesReport;
135
144
  detailEtag?: string;
@@ -149,7 +158,11 @@ async function readConversationSnapshot(
149
158
  /** Refresh history and mailbox as one visible snapshot, including idle input. */
150
159
  export function conversationDetailQueryOptions(
151
160
  conversationId: string | undefined,
152
- options?: { awaitingMessage?: boolean },
161
+ options?: {
162
+ awaitingMessage?: boolean;
163
+ /** Poll fast until this time so a new send reaches history quickly. */
164
+ fastPollUntilMs?: number;
165
+ },
153
166
  ) {
154
167
  return queryOptions({
155
168
  enabled: Boolean(conversationId),
@@ -171,12 +184,14 @@ export function conversationDetailQueryOptions(
171
184
  );
172
185
  },
173
186
  refetchInterval: (query) =>
174
- options?.awaitingMessage ||
175
- query.state.data?.status === "active" ||
176
- Boolean(query.state.data?.mailbox?.messages.length) ||
177
- Boolean(query.state.data?.mailbox?.authorization)
178
- ? 2_000
179
- : 10_000,
187
+ options?.fastPollUntilMs && Date.now() < options.fastPollUntilMs
188
+ ? FAST_POLL_INTERVAL_MS
189
+ : options?.awaitingMessage ||
190
+ query.state.data?.status === "active" ||
191
+ Boolean(query.state.data?.mailbox?.messages.length) ||
192
+ Boolean(query.state.data?.mailbox?.authorization)
193
+ ? 2_000
194
+ : 10_000,
180
195
  retry: false,
181
196
  });
182
197
  }
@@ -213,6 +228,8 @@ export function useCreateConversation() {
213
228
  ...conversationOutboxMessageForSubmit({
214
229
  ...args,
215
230
  actorIdentity: outboxActorIdentity(queryClient),
231
+ // A new Conversation has no Turn or queue yet.
232
+ inline: true,
216
233
  messageId: accepted.messageId,
217
234
  }),
218
235
  status: "accepted",
@@ -261,6 +278,8 @@ export function useForkConversation(sourceConversationId: string) {
261
278
  idempotencyKey: args.idempotencyKey,
262
279
  message: args.message,
263
280
  actorIdentity: outboxActorIdentity(queryClient),
281
+ // A new fork has no Turn or queue yet.
282
+ inline: true,
264
283
  messageId: accepted.messageId,
265
284
  }),
266
285
  status: "accepted",
@@ -278,15 +297,20 @@ export function useAppendConversationMessage(conversationId: string) {
278
297
  const queryClient = useQueryClient();
279
298
  const outboxQueryKey = conversationOutboxQueryKey(conversationId);
280
299
  return useMutation({
281
- mutationFn: (args: {
300
+ mutationFn: ({
301
+ inline: _inline,
302
+ ...body
303
+ }: {
282
304
  idempotencyKey: string;
305
+ /** Show the send in the transcript instead of the queue. */
306
+ inline?: boolean;
283
307
  message: string;
284
308
  images?: InputImage[];
285
309
  }) =>
286
310
  post(
287
311
  acceptedConversationMessageSchema,
288
312
  `/api/conversations/${encodeURIComponent(conversationId)}/messages`,
289
- args,
313
+ body,
290
314
  ),
291
315
  onMutate: async (args) => {
292
316
  // Use the durable id from the first render. No server read is needed,
@@ -396,6 +420,52 @@ export function useCancelConversationPendingMessages(conversationId: string) {
396
420
  });
397
421
  }
398
422
 
423
+ /** Ask Junior to read the viewer's own queued web messages now. */
424
+ export function useSteerConversationPendingMessages(conversationId: string) {
425
+ const queryClient = useQueryClient();
426
+ return useMutation({
427
+ mutationFn: (args: { inboundMessageIds: string[] }) =>
428
+ post(
429
+ steerConversationPendingMessagesResponseSchema,
430
+ `/api/conversations/${encodeURIComponent(conversationId)}/pending-messages/steer`,
431
+ args,
432
+ ),
433
+ onSuccess: async (result) => {
434
+ const steered = new Set(result.steeredInboundMessageIds);
435
+ if (steered.size === 0) return;
436
+ // A live poll that read the mailbox before the steer would restore
437
+ // `defer`. Cancel it; onSettled refetches after the write.
438
+ await queryClient.cancelQueries({
439
+ exact: true,
440
+ queryKey: conversationDetailQueryKey(conversationId),
441
+ });
442
+ queryClient.setQueryData<ConversationSnapshot>(
443
+ conversationDetailQueryKey(conversationId),
444
+ (current) =>
445
+ current?.mailbox
446
+ ? {
447
+ ...current,
448
+ mailbox: {
449
+ ...current.mailbox,
450
+ messages: current.mailbox.messages.map((message) =>
451
+ steered.has(message.inboundMessageId)
452
+ ? { ...message, delivery: "interrupt" as const }
453
+ : message,
454
+ ),
455
+ },
456
+ }
457
+ : current,
458
+ );
459
+ },
460
+ onSettled: async () => {
461
+ await queryClient.invalidateQueries({
462
+ exact: true,
463
+ queryKey: conversationDetailQueryKey(conversationId),
464
+ });
465
+ },
466
+ });
467
+ }
468
+
399
469
  /**
400
470
  * Local cache of queued rows that a stop removed. The worker drops them only
401
471
  * when it observes the stop, so the mailbox can still list them for a while.
@@ -627,11 +697,20 @@ export function useConversationData(conversationId: string | undefined) {
627
697
  staleTime: Infinity,
628
698
  gcTime: Infinity,
629
699
  });
630
- const awaitingMessage = outbox.data.some(
700
+ const awaitingRows = outbox.data.filter(
631
701
  (message) => message.status !== "failed",
632
702
  );
703
+ const latestSendAtMs = Math.max(
704
+ 0,
705
+ ...awaitingRows.map((message) => Date.parse(message.createdAt)),
706
+ );
633
707
  const detail = useQuery(
634
- conversationDetailQueryOptions(conversationId, { awaitingMessage }),
708
+ conversationDetailQueryOptions(conversationId, {
709
+ awaitingMessage: awaitingRows.length > 0,
710
+ fastPollUntilMs: latestSendAtMs
711
+ ? latestSendAtMs + FAST_POLL_WINDOW_MS
712
+ : undefined,
713
+ }),
635
714
  );
636
715
  // Defer the whole server snapshot, not history alone. Queue removal and the
637
716
  // matching transcript must paint together while composer input stays urgent.
@@ -647,17 +726,23 @@ export function useConversationData(conversationId: string | undefined) {
647
726
  // contains its real Message id. History and mailbox arrive in one render.
648
727
  useEffect(() => {
649
728
  if (!mailbox || !detailData) return;
650
- const observedIds = new Set(
729
+ const queuedIds = new Set(
651
730
  mailbox.messages.map((message) => message.messageId),
652
731
  );
732
+ const historyIds = new Set<string>();
653
733
  for (const event of detailData.events) {
654
- if (event.data.type === "message") observedIds.add(event.data.messageId);
734
+ if (event.data.type === "message") historyIds.add(event.data.messageId);
655
735
  }
656
736
  queryClient.setQueryData<ConversationOutboxMessage[]>(
657
737
  conversationOutboxQueryKey(conversationId),
658
738
  (current) => {
739
+ // An inline row stays until history has it. The mailbox copy alone
740
+ // would put it back in the queue.
659
741
  const next = current?.filter(
660
- (message) => !observedIds.has(message.messageId),
742
+ (message) =>
743
+ !historyIds.has(message.messageId) &&
744
+ (isInlineOutboxMessage(message) ||
745
+ !queuedIds.has(message.messageId)),
661
746
  );
662
747
  return next?.length === current?.length ? current : next;
663
748
  },
@@ -727,23 +812,37 @@ export function useConversationData(conversationId: string | undefined) {
727
812
  const pendingMessagesRef = useRef<ConversationMailboxMessage[] | undefined>(
728
813
  undefined,
729
814
  );
730
- const pendingMessages = useMemo(() => {
815
+ const inlineMessagesRef = useRef<ConversationMailboxMessage[] | undefined>(
816
+ undefined,
817
+ );
818
+ const { inlineMessages, pendingMessages } = useMemo(() => {
731
819
  // Hide stopped rows from both sources, or the outbox copy of a stopped
732
820
  // send would look unseen and come back.
733
821
  const stopped = stoppedMessageIds.data;
734
- const next = mergeConversationMailboxMessages(
735
- stopped.length
736
- ? mailbox?.messages.filter(
737
- (message) => !stopped.includes(message.inboundMessageId),
738
- )
739
- : mailbox?.messages,
740
- stopped.length
741
- ? outbox.data.filter((message) => !stopped.includes(message.messageId))
742
- : outbox.data,
822
+ const server = stopped.length
823
+ ? mailbox?.messages.filter(
824
+ (message) => !stopped.includes(message.inboundMessageId),
825
+ )
826
+ : mailbox?.messages;
827
+ const local = stopped.length
828
+ ? outbox.data.filter((message) => !stopped.includes(message.messageId))
829
+ : outbox.data;
830
+ const split = splitInlineMailboxMessages(
831
+ mergeConversationMailboxMessages(server, local),
832
+ server,
833
+ local,
834
+ );
835
+ const queued = reuseConversationMailboxMessages(
743
836
  pendingMessagesRef.current,
837
+ split.queued,
838
+ );
839
+ const inline = reuseConversationMailboxMessages(
840
+ inlineMessagesRef.current,
841
+ split.inline,
744
842
  );
745
- pendingMessagesRef.current = next;
746
- return next;
843
+ pendingMessagesRef.current = queued;
844
+ inlineMessagesRef.current = inline;
845
+ return { inlineMessages: inline, pendingMessages: queued };
747
846
  }, [outbox.data, mailbox?.messages, stoppedMessageIds.data]);
748
847
  const invalidHistoryCursor = isInvalidCursorError(history.error);
749
848
  const shouldRefreshDetail = Boolean(
@@ -797,6 +896,8 @@ export function useConversationData(conversationId: string | undefined) {
797
896
  isLoadingPreviousPage,
798
897
  pendingAuthorization: mailbox?.authorization,
799
898
  pendingGeneratedAt: mailbox?.generatedAt,
899
+ /** The reader's own sends that the transcript shows before history has them. */
900
+ inlineMessages,
800
901
  pendingMessages,
801
902
  loadCompleteTranscript: () => {
802
903
  if (!conversationId || !detailData) {