@sentry/junior-dashboard 0.279.0 → 0.281.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 (164) hide show
  1. package/dist/app.js +105 -43
  2. package/dist/client-manifest.json +1 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +105 -43
  5. package/dist/public/chunks/AdminPage-M5RBVZFN.js +1 -0
  6. package/dist/public/chunks/AutomationPage-WALCREDT.js +3 -0
  7. package/dist/public/chunks/AutomationRunsPage-CV47E4UX.js +1 -0
  8. package/dist/public/chunks/AutomationsPage-57L3AQVJ.js +1 -0
  9. package/dist/public/chunks/CodePage-XJUYMFJH.js +1 -0
  10. package/dist/public/chunks/ConnectionsPage-3QQAGYWF.js +1 -0
  11. package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
  12. package/dist/public/chunks/LocationDetailPage-4IL5HQML.js +1 -0
  13. package/dist/public/chunks/LocationsPage-D2DUQAHJ.js +1 -0
  14. package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
  15. package/dist/public/chunks/PeoplePage-6F5GPQ7C.js +1 -0
  16. package/dist/public/chunks/PersonProfilePage-SPAYQPTD.js +1 -0
  17. package/dist/public/chunks/PersonalTokensPage-SHAUJ74M.js +1 -0
  18. package/dist/public/chunks/PluginUserPage-64FIWD7M.js +1 -0
  19. package/dist/public/chunks/RepositoryPage-L7IHOXSN.js +1 -0
  20. package/dist/public/chunks/SettingsPage-U7WG4FHE.js +1 -0
  21. package/dist/public/chunks/SystemPage-ZBC43OJH.js +1 -0
  22. package/dist/public/chunks/WorkspaceFormPage-3NVIIZ4P.js +2 -0
  23. package/dist/public/chunks/WorkspacesPage-G73HPBDD.js +1 -0
  24. package/dist/public/chunks/chunk-2BCS5AQL.js +1 -0
  25. package/dist/public/chunks/chunk-2J3LGF5Q.js +1 -0
  26. package/dist/public/chunks/chunk-2NNKHSZE.js +1 -0
  27. package/dist/public/chunks/chunk-3Y3ICT6K.js +1 -0
  28. package/dist/public/chunks/chunk-4AKUZW6R.js +1 -0
  29. package/dist/public/chunks/chunk-4TQSTCIN.js +13 -0
  30. package/dist/public/chunks/chunk-4UGZNVJM.js +1 -0
  31. package/dist/public/chunks/chunk-564V7YHG.js +1 -0
  32. package/dist/public/chunks/chunk-5ISD3QSC.js +1 -0
  33. package/dist/public/chunks/chunk-5LNGNJ3P.js +1 -0
  34. package/dist/public/chunks/chunk-5ZA674OU.js +1 -0
  35. package/dist/public/chunks/chunk-64GPEGGE.js +1 -0
  36. package/dist/public/chunks/chunk-6FHY65EQ.js +1 -0
  37. package/dist/public/chunks/chunk-6FSJ3W2W.js +1 -0
  38. package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
  39. package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -0
  40. package/dist/public/chunks/chunk-BKOACADP.js +1 -0
  41. package/dist/public/chunks/chunk-CQY6BYUB.js +1 -0
  42. package/dist/public/chunks/chunk-CR2ICLSH.js +1 -0
  43. package/dist/public/chunks/chunk-DEEO4D4N.js +1 -0
  44. package/dist/public/chunks/chunk-DZB4LPI6.js +1 -0
  45. package/dist/public/chunks/chunk-FE522UEA.js +1 -0
  46. package/dist/public/chunks/chunk-FSGVY7HB.js +1 -0
  47. package/dist/public/chunks/chunk-FVX42XAD.js +1 -0
  48. package/dist/public/chunks/chunk-G5NWYJTV.js +1 -0
  49. package/dist/public/chunks/chunk-GMZIELFU.js +1 -0
  50. package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
  51. package/dist/public/chunks/chunk-GYRM5ZIV.js +1 -0
  52. package/dist/public/chunks/chunk-GZBMHYYU.js +1 -0
  53. package/dist/public/chunks/chunk-H2ZU34CP.js +1 -0
  54. package/dist/public/chunks/chunk-H76B43OQ.js +1 -0
  55. package/dist/public/chunks/chunk-HCLRG2EC.js +1 -0
  56. package/dist/public/chunks/chunk-II4NXLSQ.js +2 -0
  57. package/dist/public/chunks/chunk-ILH47V7O.js +1 -0
  58. package/dist/public/chunks/chunk-IZWTFLCC.js +1 -0
  59. package/dist/public/chunks/chunk-J37MXUED.js +1 -0
  60. package/dist/public/chunks/chunk-JAZABOBA.js +0 -0
  61. package/dist/public/chunks/chunk-JPL2NRWK.js +1 -0
  62. package/dist/public/chunks/chunk-JVJ5UWIK.js +1 -0
  63. package/dist/public/chunks/chunk-K4TYMAA3.js +63 -0
  64. package/dist/public/chunks/chunk-KDUTK357.js +1 -0
  65. package/dist/public/chunks/chunk-L2H3ZWZN.js +1 -0
  66. package/dist/public/chunks/chunk-LBELRO5P.js +1 -0
  67. package/dist/public/chunks/chunk-LKW7MULF.js +1 -0
  68. package/dist/public/chunks/chunk-LZYAHJVQ.js +1 -0
  69. package/dist/public/chunks/chunk-MEHLQZDG.js +3 -0
  70. package/dist/public/chunks/chunk-MKKFXRYO.js +1 -0
  71. package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
  72. package/dist/public/chunks/chunk-NGQRNS3C.js +1 -0
  73. package/dist/public/chunks/chunk-ONDKE72P.js +1 -0
  74. package/dist/public/chunks/chunk-PG7M7L24.js +1 -0
  75. package/dist/public/chunks/chunk-PQPAPPEI.js +1 -0
  76. package/dist/public/chunks/chunk-PXHLJIOT.js +1 -0
  77. package/dist/public/chunks/chunk-QOX5RMLM.js +1 -0
  78. package/dist/public/chunks/chunk-QPBLZSNX.js +1 -0
  79. package/dist/public/chunks/chunk-QQUFROWT.js +1 -0
  80. package/dist/public/chunks/chunk-RB5FBDEY.js +1 -0
  81. package/dist/public/chunks/chunk-RGXXD4DM.js +41 -0
  82. package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
  83. package/dist/public/chunks/chunk-SIOW7OOW.js +1 -0
  84. package/dist/public/chunks/chunk-SUX4QBVF.js +1 -0
  85. package/dist/public/chunks/chunk-T4M53QQT.js +1 -0
  86. package/dist/public/chunks/chunk-TC7TU4GK.js +1 -0
  87. package/dist/public/chunks/chunk-TFJOEF3Q.js +1 -0
  88. package/dist/public/chunks/chunk-UJD3MBMB.js +1 -0
  89. package/dist/public/chunks/chunk-UK2V4UBT.js +1 -0
  90. package/dist/public/chunks/chunk-UT6HZPGL.js +1 -0
  91. package/dist/public/chunks/chunk-UZ44J2LH.js +1 -0
  92. package/dist/public/chunks/chunk-VC6IS564.js +1 -0
  93. package/dist/public/chunks/chunk-WLIOZO3W.js +1 -0
  94. package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
  95. package/dist/public/chunks/chunk-XDUSY26N.js +55 -0
  96. package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
  97. package/dist/public/chunks/chunk-YPMUHXYT.js +147 -0
  98. package/dist/public/chunks/chunk-ZIJ6SJDN.js +1 -0
  99. package/dist/public/chunks/code-highlighter-Q7WC7AEF.js +12 -0
  100. package/dist/public/client-4OAAOHF4.js +68 -0
  101. package/dist/tailwind.css +1 -1
  102. package/package.json +5 -4
  103. package/src/api/schema.ts +1 -1
  104. package/src/assets.ts +4 -1
  105. package/src/client/App.tsx +100 -23
  106. package/src/client/TracedRoutes.tsx +8 -4
  107. package/src/client/code-highlighter.ts +26 -0
  108. package/src/client/code.tsx +2 -22
  109. package/src/client/components/BottomSheet.tsx +1 -1
  110. package/src/client/components/DetailsPanel.tsx +2 -1
  111. package/src/client/components/Drawer.tsx +12 -71
  112. package/src/client/components/DrawerContent.tsx +76 -0
  113. package/src/client/components/ProfileMenu.tsx +12 -3
  114. package/src/client/components/UserInputForm.tsx +28 -5
  115. package/src/client/conversations/ConversationBrief.tsx +129 -65
  116. package/src/client/conversations/ConversationCatchUp.tsx +107 -0
  117. package/src/client/conversations/ConversationContextTags.tsx +110 -0
  118. package/src/client/conversations/ConversationDetails.tsx +13 -14
  119. package/src/client/conversations/ConversationEventLog.tsx +135 -70
  120. package/src/client/conversations/ConversationHeader.tsx +74 -65
  121. package/src/client/conversations/ConversationHeaderActions.tsx +36 -38
  122. package/src/client/conversations/ConversationHomeList.tsx +41 -3
  123. package/src/client/conversations/ConversationLinkedWork.tsx +30 -10
  124. package/src/client/conversations/ConversationMeta.tsx +21 -27
  125. package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
  126. package/src/client/conversations/ConversationPage.tsx +219 -157
  127. package/src/client/conversations/ConversationTranscript.tsx +234 -186
  128. package/src/client/conversations/ConversationUsageBreakdown.tsx +142 -64
  129. package/src/client/conversations/DetailsList.tsx +7 -1
  130. package/src/client/conversations/PendingMailboxStack.tsx +88 -44
  131. package/src/client/conversations/ShareConversationButton.tsx +5 -50
  132. package/src/client/conversations/SlackMark.tsx +16 -4
  133. package/src/client/conversations/TranscriptActivityGroup.tsx +7 -14
  134. package/src/client/conversations/TranscriptEventView.tsx +7 -3
  135. package/src/client/conversations/TranscriptReasoningView.tsx +4 -2
  136. package/src/client/conversations/TranscriptRows.tsx +61 -16
  137. package/src/client/conversations/TranscriptView.tsx +25 -13
  138. package/src/client/conversations/conversationArchive.ts +22 -0
  139. package/src/client/conversations/conversationCatchUpState.ts +96 -0
  140. package/src/client/conversations/conversationMemory.ts +8 -1
  141. package/src/client/conversations/queries.ts +85 -46
  142. package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
  143. package/src/client/conversations/transcriptBottomPinning.ts +40 -8
  144. package/src/client/conversations/transcriptListRows.ts +215 -0
  145. package/src/client/conversations/transcriptReadPosition.ts +24 -2
  146. package/src/client/conversations/turnContext.ts +1 -1
  147. package/src/client/conversations/virtualRows.ts +168 -0
  148. package/src/client/http.ts +6 -5
  149. package/src/client/pages/admin/AdminPage.tsx +1 -1
  150. package/src/client/pages/admin/ServiceAccountsSection.tsx +1 -1
  151. package/src/client/pages/memory/memoryDashboard.ts +1 -1
  152. package/src/client/pages/memory/memoryRecord.ts +1 -1
  153. package/src/client/pages/settings/ConnectionsPage.tsx +1 -1
  154. package/src/client/pages/user/PluginUserPage.tsx +0 -6
  155. package/src/client/pages/user/pluginUserPageRoutes.ts +5 -0
  156. package/src/client/primaryNav.ts +1 -1
  157. package/src/client/review-desk/reviewItemFormat.ts +1 -1
  158. package/src/client/sentry.ts +53 -3
  159. package/src/conversation-cache.ts +8 -10
  160. package/src/mock-reporting/fixtures.ts +43 -8
  161. package/src/mock-reporting/routes.ts +40 -3
  162. package/src/public-assets.ts +28 -20
  163. package/src/shell.ts +12 -2
  164. package/dist/public/client-ZCZXZBTL.js +0 -460
@@ -1,4 +1,11 @@
1
- import { memo, useMemo, useRef, type ReactNode } from "react";
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react";
2
9
  import { History } from "lucide-react";
3
10
  import type { ConversationMailboxMessage } from "./conversationOutbox";
4
11
 
@@ -20,11 +27,7 @@ import type {
20
27
  } from "../types";
21
28
  import { TranscriptSubagentView } from "./TranscriptSubagentView";
22
29
  import { TranscriptContextEventView } from "./TranscriptContextEventView";
23
- import {
24
- isCollapsibleActivityEntry,
25
- quietTurnEntryKeys,
26
- TranscriptActivityGroup,
27
- } from "./TranscriptActivityGroup";
30
+ import { TranscriptActivityGroup } from "./TranscriptActivityGroup";
28
31
  import { TranscriptToolView } from "./TranscriptToolView";
29
32
  import { TranscriptReasoningView } from "./TranscriptReasoningView";
30
33
  import { TranscriptAttachmentsDeliveredView } from "./TranscriptAttachmentsDeliveredView";
@@ -50,16 +53,23 @@ import {
50
53
  TranscriptAutomationTriggerView,
51
54
  } from "./TranscriptAutomationTriggerView";
52
55
  import { TranscriptTypingIndicator } from "./TranscriptTypingIndicator";
56
+ import { groupTranscriptMessages } from "./transcriptRenderModel";
57
+ import { useTranscriptSearch } from "./transcriptSearch";
53
58
  import {
54
- groupTranscriptMessages,
55
- type RenderedTranscriptEntry,
56
- } from "./transcriptRenderModel";
57
- import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch";
58
- import {
59
+ TranscriptOpenRows,
59
60
  TranscriptRow,
60
61
  TranscriptRows,
61
62
  TranscriptUnreadMarker,
62
63
  } from "./TranscriptRows";
64
+ import {
65
+ defaultTranscriptRowSize,
66
+ rowIndexForSeq,
67
+ transcriptListRows,
68
+ transcriptRowSizeClass,
69
+ UNREAD_MARKER_KEY,
70
+ type TranscriptListRow,
71
+ } from "./transcriptListRows";
72
+ import { useVirtualRows } from "./virtualRows";
63
73
  import { TranscriptTimestampProvider } from "./TranscriptTimestamp";
64
74
  import type { TranscriptUnreadRange } from "./transcriptReadPosition";
65
75
 
@@ -81,7 +91,11 @@ type TranscriptToolEntry = Extract<TranscriptEntry, { kind: "tool" }>;
81
91
 
82
92
  function renderReasoningEntry(entry: TranscriptReasoningEntry): ReactNode {
83
93
  return (
84
- <TranscriptReasoningView part={entry.part} timestamp={entry.timestamp} />
94
+ <TranscriptReasoningView
95
+ openKey={entry.key}
96
+ part={entry.part}
97
+ timestamp={entry.timestamp}
98
+ />
85
99
  );
86
100
  }
87
101
 
@@ -96,6 +110,10 @@ export const ConversationTranscriptView = memo(
96
110
  /** Sends that history does not have yet. History wins on message id. */
97
111
  pendingMessages?: readonly ConversationMailboxMessage[];
98
112
  responding?: boolean;
113
+ /** Keep the rows around this event seq mounted, to show it. */
114
+ revealSeq?: number;
115
+ /** The row that holds the reader's place, from the scroll owner. */
116
+ scrollAnchorKey?: () => string | undefined;
99
117
  /** Show the "New" marker before the first row in this range. */
100
118
  unread?: TranscriptUnreadRange;
101
119
  }) {
@@ -119,6 +137,8 @@ export const ConversationTranscriptView = memo(
119
137
  conversation={props.conversation}
120
138
  messages={messages}
121
139
  responding={props.responding}
140
+ revealSeq={props.revealSeq}
141
+ scrollAnchorKey={props.scrollAnchorKey}
122
142
  unread={props.unread}
123
143
  />
124
144
  </section>
@@ -127,16 +147,22 @@ export const ConversationTranscriptView = memo(
127
147
  },
128
148
  );
129
149
 
130
- function SegmentEvents(props: {
131
- onOpenSubagentTranscript?: (args: {
132
- part: TranscriptViewSubagentPart;
150
+ function SegmentEvents(
151
+ props: TranscriptListPlacement & {
152
+ onOpenSubagentTranscript?: (args: {
153
+ part: TranscriptViewSubagentPart;
154
+ conversation: ConversationTranscript;
155
+ }) => void;
133
156
  conversation: ConversationTranscript;
134
- }) => void;
135
- conversation: ConversationTranscript;
136
- messages: TranscriptViewMessage[];
137
- responding?: boolean;
138
- unread?: TranscriptUnreadRange;
139
- }) {
157
+ messages: TranscriptViewMessage[];
158
+ responding?: boolean;
159
+ unread?: TranscriptUnreadRange;
160
+ },
161
+ ) {
162
+ const placement = {
163
+ revealSeq: props.revealSeq,
164
+ scrollAnchorKey: props.scrollAnchorKey,
165
+ };
140
166
  return (
141
167
  <TranscriptRows>
142
168
  {props.conversation.eventHistory.status === "expired" ? (
@@ -146,6 +172,7 @@ function SegmentEvents(props: {
146
172
  />
147
173
  ) : props.conversation.eventHistory.status === "available" ? (
148
174
  <VisibleTranscriptEntries
175
+ {...placement}
149
176
  onOpenSubagentTranscript={props.onOpenSubagentTranscript}
150
177
  transcript={props.messages}
151
178
  conversation={props.conversation}
@@ -154,12 +181,14 @@ function SegmentEvents(props: {
154
181
  ) : props.conversation.eventHistory.status === "redacted" &&
155
182
  props.messages.length > 0 ? (
156
183
  <RedactedTranscriptView
184
+ {...placement}
157
185
  onOpenSubagentTranscript={props.onOpenSubagentTranscript}
158
186
  conversation={props.conversation}
159
187
  messages={props.messages}
160
188
  />
161
189
  ) : props.messages.length > 0 ? (
162
190
  <VisibleTranscriptEntries
191
+ {...placement}
163
192
  onOpenSubagentTranscript={props.onOpenSubagentTranscript}
164
193
  transcript={props.messages}
165
194
  conversation={props.conversation}
@@ -214,15 +243,17 @@ function ExpiredTranscriptView(props: {
214
243
  );
215
244
  }
216
245
 
217
- const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
218
- onOpenSubagentTranscript?: (args: {
219
- part: TranscriptViewSubagentPart;
246
+ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(
247
+ props: TranscriptListPlacement & {
248
+ onOpenSubagentTranscript?: (args: {
249
+ part: TranscriptViewSubagentPart;
250
+ conversation: ConversationTranscript;
251
+ }) => void;
252
+ transcript: TranscriptViewMessage[];
220
253
  conversation: ConversationTranscript;
221
- }) => void;
222
- transcript: TranscriptViewMessage[];
223
- conversation: ConversationTranscript;
224
- unread?: TranscriptUnreadRange;
225
- }) {
254
+ unread?: TranscriptUnreadRange;
255
+ },
256
+ ) {
226
257
  const entries = useMemo(
227
258
  () => groupTranscriptMessages(props.transcript),
228
259
  [props.transcript],
@@ -230,7 +261,11 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
230
261
  return (
231
262
  <TranscriptEntryList
232
263
  entries={entries}
264
+ // Open rows and row sizes belong to one Conversation.
265
+ key={props.conversation.conversationId}
233
266
  keyPrefix={props.conversation.conversationId}
267
+ revealSeq={props.revealSeq}
268
+ scrollAnchorKey={props.scrollAnchorKey}
234
269
  unread={props.unread}
235
270
  renderContext={(entry) => (
236
271
  <TranscriptRailEvent kind="compaction">
@@ -264,7 +299,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
264
299
  kind="event"
265
300
  objectType={entry.message.eventObjectType}
266
301
  >
267
- <TranscriptEventView message={entry.message} />
302
+ <TranscriptEventView message={entry.message} openKey={entry.key} />
268
303
  <TranscriptRecalledMemories
269
304
  className="mt-1"
270
305
  message={entry.message}
@@ -341,9 +376,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
341
376
  );
342
377
  });
343
378
 
344
- function TranscriptEntryList(props: {
345
- entries: TranscriptEntry[];
346
- keyPrefix: string;
379
+ type TranscriptEntryRenderers = {
347
380
  renderAttachmentsDelivered: (
348
381
  entry: TranscriptAttachmentsDeliveredEntry,
349
382
  ) => ReactNode;
@@ -354,179 +387,194 @@ function TranscriptEntryList(props: {
354
387
  renderReasoning: (entry: TranscriptReasoningEntry) => ReactNode;
355
388
  renderSubagent: (entry: TranscriptSubagentEntry) => ReactNode;
356
389
  renderTool: (entry: TranscriptToolEntry) => ReactNode;
357
- unread?: TranscriptUnreadRange;
358
- }) {
390
+ };
391
+
392
+ /**
393
+ * Render transcript entries as list rows. Only the rows near the viewport
394
+ * mount. These rows also stay mounted, because the scroll owner and the
395
+ * page find them in the DOM: the row that holds the reader's place, the
396
+ * "New" marker and the row after it, and the rows around `revealSeq`.
397
+ */
398
+ function TranscriptEntryList(
399
+ props: TranscriptEntryRenderers &
400
+ TranscriptListPlacement & {
401
+ entries: TranscriptEntry[];
402
+ keyPrefix: string;
403
+ unread?: TranscriptUnreadRange;
404
+ },
405
+ ) {
359
406
  const search = useTranscriptSearch();
360
407
  const activityKeys = useRef(new Map<string, string>());
361
- const quietKeys = useMemo(
362
- () => quietTurnEntryKeys(props.entries),
363
- [props.entries],
408
+ const [openRows] = useState(() => new Map<string, boolean>());
409
+ const listRef = useRef<HTMLDivElement>(null);
410
+ const searchQuery = search.active ? search.normalizedQuery : undefined;
411
+ const rows = useMemo(
412
+ () =>
413
+ transcriptListRows({
414
+ entries: props.entries,
415
+ keyPrefix: props.keyPrefix,
416
+ knownActivityKeys: activityKeys.current,
417
+ search: searchQuery,
418
+ unread: props.unread,
419
+ }),
420
+ [props.entries, props.keyPrefix, props.unread, searchQuery],
364
421
  );
365
- const collapsible = (entry: TranscriptEntry) =>
366
- isCollapsibleActivityEntry(entry) || quietKeys.has(entry.key);
367
- const claimedActivityKeys = new Set<string>();
368
- const rows: ReactNode[] = [];
369
- // Search results are not a reading order, so they get no marker.
370
- const markerSeq = search.active
371
- ? undefined
372
- : unreadMarkerSeq(props.entries, props.unread);
373
- let unreadMarkerPending = markerSeq !== undefined;
374
- // Entries are in seq order, so the first row that reaches the marker seq
375
- // gets the marker.
376
- const pushRow = (seq: number | undefined, row: ReactNode) => {
377
- if (unreadMarkerPending && seq !== undefined && seq >= markerSeq!) {
378
- unreadMarkerPending = false;
379
- rows.push(<TranscriptUnreadMarker key="unread-marker" />);
380
- }
381
- rows.push(row);
382
- };
383
-
384
- const renderEntry = (entry: TranscriptEntry): ReactNode => {
385
- if (entry.kind === "subagent") return props.renderSubagent(entry);
386
- if (entry.kind === "context") return props.renderContext(entry);
387
- if (entry.kind === "attachments_delivered") {
388
- return props.renderAttachmentsDelivered(entry);
389
- }
390
- if (entry.kind === "structured_event") {
391
- return props.renderStructuredEvent(entry);
392
- }
393
- if (entry.kind === "failure") return props.renderFailure(entry);
394
- if (entry.kind === "reasoning") return props.renderReasoning(entry);
395
- if (entry.kind === "tool") return props.renderTool(entry);
396
- return props.renderMessage(entry);
397
- };
398
-
399
- for (let index = 0; index < props.entries.length; ) {
400
- const entry = props.entries[index]!;
401
-
402
- if (collapsible(entry)) {
403
- const activityEntries: RenderedTranscriptEntry[] = [];
404
- while (
405
- index < props.entries.length &&
406
- collapsible(props.entries[index]!)
407
- ) {
408
- activityEntries.push(props.entries[index]!);
409
- index += 1;
422
+ const indexByKey = useMemo(
423
+ () => new Map(rows.map((row, index) => [row.key, index])),
424
+ [rows],
425
+ );
426
+ const anchorKey = props.scrollAnchorKey?.();
427
+ const markerIndex = indexByKey.get(UNREAD_MARKER_KEY);
428
+ // A memory row can sit at its event or on the message before it.
429
+ const revealIndex =
430
+ props.revealSeq === undefined
431
+ ? undefined
432
+ : rowIndexForSeq(rows, props.revealSeq);
433
+ const virtualizer = useVirtualRows({
434
+ count: rows.length,
435
+ defaultSize: defaultTranscriptRowSize,
436
+ // The gap between rows, like the gap of the transcript stack.
437
+ gap: 4,
438
+ getItemKey: (index) => rows[index]!.key,
439
+ keptIndexes: [
440
+ anchorKey === undefined ? undefined : indexByKey.get(anchorKey),
441
+ // The scroll owner opens at the marker and holds the reader's place
442
+ // at the first unread row after it.
443
+ markerIndex,
444
+ markerIndex === undefined ? undefined : markerIndex + 1,
445
+ revealIndex,
446
+ revealIndex === undefined ? undefined : revealIndex - 1,
447
+ ],
448
+ listRef,
449
+ sizeClass: (index) => transcriptRowSizeClass(rows[index]!),
450
+ });
451
+ // Rows render again only when the renderers change, not on each scroll.
452
+ const {
453
+ renderAttachmentsDelivered,
454
+ renderContext,
455
+ renderFailure,
456
+ renderMessage,
457
+ renderReasoning,
458
+ renderStructuredEvent,
459
+ renderSubagent,
460
+ renderTool,
461
+ } = props;
462
+ const renderEntry = useCallback(
463
+ (entry: TranscriptEntry): ReactNode => {
464
+ if (entry.kind === "subagent") return renderSubagent(entry);
465
+ if (entry.kind === "context") return renderContext(entry);
466
+ if (entry.kind === "attachments_delivered") {
467
+ return renderAttachmentsDelivered(entry);
410
468
  }
411
- const visibleEntries = search.active
412
- ? activityEntries.filter((candidate) =>
413
- entryMatchesSearch(candidate, search.normalizedQuery),
414
- )
415
- : activityEntries;
416
- if (visibleEntries.length > 0) {
417
- const activityKey = stableActivityGroupKey({
418
- claimedKeys: claimedActivityKeys,
419
- entries: activityEntries,
420
- keyPrefix: props.keyPrefix,
421
- knownKeys: activityKeys.current,
422
- });
423
- const lastSeq = lastEntrySeq(activityEntries);
424
- // The marker goes before the group when any of it is unread.
425
- pushRow(
426
- lastSeq,
427
- <TranscriptRow
428
- anchorKey={activityKey}
429
- indent
430
- key={activityKey}
431
- seq={lastSeq}
432
- >
433
- <TranscriptActivityGroup
434
- entries={visibleEntries}
435
- renderEntry={renderEntry}
436
- />
437
- </TranscriptRow>,
438
- );
469
+ if (entry.kind === "structured_event") {
470
+ return renderStructuredEvent(entry);
439
471
  }
440
- continue;
441
- }
442
-
443
- if (!search.active || entryMatchesSearch(entry, search.normalizedQuery)) {
444
- const rowKey = `${props.keyPrefix}:${entry.key}`;
445
- pushRow(
446
- entry.seq,
447
- <TranscriptRow anchorKey={rowKey} key={rowKey} seq={entry.seq}>
448
- {renderEntry(entry)}
449
- </TranscriptRow>,
450
- );
451
- }
452
- index += 1;
453
- }
472
+ if (entry.kind === "failure") return renderFailure(entry);
473
+ if (entry.kind === "reasoning") return renderReasoning(entry);
474
+ if (entry.kind === "tool") return renderTool(entry);
475
+ return renderMessage(entry);
476
+ },
477
+ [
478
+ renderAttachmentsDelivered,
479
+ renderContext,
480
+ renderFailure,
481
+ renderMessage,
482
+ renderReasoning,
483
+ renderStructuredEvent,
484
+ renderSubagent,
485
+ renderTool,
486
+ ],
487
+ );
454
488
 
455
489
  if (search.active && rows.length === 0) {
456
490
  return <EmptyTelemetry>No events match your search.</EmptyTelemetry>;
457
491
  }
458
492
 
459
- // Let rows participate in the parent transcript stack so messages, tool
460
- // groups, and the thinking indicator share one vertical gap.
461
- return rows;
493
+ return (
494
+ <TranscriptOpenRows value={openRows}>
495
+ <div
496
+ className="relative min-w-0"
497
+ ref={listRef}
498
+ style={{ height: virtualizer.getTotalSize() }}
499
+ >
500
+ {virtualizer.getVirtualItems().map((item) => (
501
+ <VirtualTranscriptRow
502
+ index={item.index}
503
+ key={item.key}
504
+ measure={virtualizer.measureElement}
505
+ offset={item.start - virtualizer.options.scrollMargin}
506
+ renderEntry={renderEntry}
507
+ row={rows[item.index]!}
508
+ />
509
+ ))}
510
+ </div>
511
+ </TranscriptOpenRows>
512
+ );
462
513
  }
463
514
 
515
+ /** Where the list keeps rows mounted outside the viewport. */
516
+ type TranscriptListPlacement = {
517
+ /** Keep the rows around this event seq mounted, to show it. */
518
+ revealSeq?: number;
519
+ /** The row that holds the reader's place, from the scroll owner. */
520
+ scrollAnchorKey?: () => string | undefined;
521
+ };
522
+
464
523
  /**
465
- * Find the seq of the first unread row. Entries are in seq order, and pending
466
- * sends without a seq come last, so a binary search finds the first seq after
467
- * the read position. Rows after the range arrived after open, so they get no
468
- * marker.
524
+ * Place one list row at its offset. Rows render again only when their row
525
+ * or offset changes, not when the mounted range changes.
469
526
  */
470
- function unreadMarkerSeq(
471
- entries: readonly TranscriptEntry[],
472
- unread: TranscriptUnreadRange | undefined,
473
- ): number | undefined {
474
- if (!unread) return undefined;
475
- let low = 0;
476
- let high = entries.length;
477
- while (low < high) {
478
- const middle = (low + high) >> 1;
479
- const seq = entries[middle]!.seq;
480
- if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1;
481
- else high = middle;
482
- }
483
- const seq = entries[low]?.seq;
484
- return seq !== undefined && seq <= unread.throughSeq ? seq : undefined;
485
- }
486
-
487
- function lastEntrySeq(
488
- entries: readonly RenderedTranscriptEntry[],
489
- ): number | undefined {
490
- for (let index = entries.length - 1; index >= 0; index -= 1) {
491
- const seq = entries[index]!.seq;
492
- if (seq !== undefined) return seq;
493
- }
494
- return undefined;
495
- }
496
-
497
- /** Keep one activity group mounted while new or historical events extend either edge. */
498
- function stableActivityGroupKey(args: {
499
- claimedKeys: Set<string>;
500
- entries: RenderedTranscriptEntry[];
501
- keyPrefix: string;
502
- knownKeys: Map<string, string>;
503
- }): string {
504
- const entryKeys = args.entries.map(
505
- (entry) => `${args.keyPrefix}:${entry.key}`,
527
+ const VirtualTranscriptRow = memo(function VirtualTranscriptRow(props: {
528
+ index: number;
529
+ measure: (element: HTMLElement | null) => void;
530
+ offset: number;
531
+ renderEntry: (entry: TranscriptEntry) => ReactNode;
532
+ row: TranscriptListRow;
533
+ }) {
534
+ const { row } = props;
535
+ return (
536
+ <div
537
+ className="absolute left-0 top-0 w-full"
538
+ data-index={props.index}
539
+ ref={props.measure}
540
+ style={{ transform: `translateY(${props.offset}px)` }}
541
+ >
542
+ {row.kind === "unread_marker" ? (
543
+ <TranscriptUnreadMarker />
544
+ ) : row.kind === "activity" ? (
545
+ <TranscriptRow anchorKey={row.key} indent seq={row.seq}>
546
+ <TranscriptActivityGroup
547
+ entries={row.entries}
548
+ openKey={row.key}
549
+ renderEntry={props.renderEntry}
550
+ />
551
+ </TranscriptRow>
552
+ ) : (
553
+ <TranscriptRow anchorKey={row.key} seq={row.seq}>
554
+ {props.renderEntry(row.entry)}
555
+ </TranscriptRow>
556
+ )}
557
+ </div>
506
558
  );
507
- const knownKey = entryKeys
508
- .map((entryKey) => args.knownKeys.get(entryKey))
509
- .find((key) => key !== undefined && !args.claimedKeys.has(key));
510
- const groupKey =
511
- knownKey ?? `${args.keyPrefix}:activity:${args.entries[0]!.key}`;
512
-
513
- args.claimedKeys.add(groupKey);
514
- entryKeys.forEach((entryKey) => args.knownKeys.set(entryKey, groupKey));
515
- return groupKey;
516
- }
559
+ });
517
560
 
518
- function RedactedTranscriptView(props: {
519
- onOpenSubagentTranscript?: (args: {
520
- part: TranscriptViewSubagentPart;
561
+ function RedactedTranscriptView(
562
+ props: TranscriptListPlacement & {
563
+ onOpenSubagentTranscript?: (args: {
564
+ part: TranscriptViewSubagentPart;
565
+ conversation: ConversationTranscript;
566
+ }) => void;
521
567
  conversation: ConversationTranscript;
522
- }) => void;
523
- conversation: ConversationTranscript;
524
- messages: TranscriptViewMessage[];
525
- }) {
568
+ messages: TranscriptViewMessage[];
569
+ },
570
+ ) {
526
571
  return (
527
572
  <TranscriptEntryList
528
573
  entries={groupTranscriptMessages(props.messages)}
574
+ key={props.conversation.conversationId}
529
575
  keyPrefix={`${props.conversation.conversationId}:redacted`}
576
+ revealSeq={props.revealSeq}
577
+ scrollAnchorKey={props.scrollAnchorKey}
530
578
  renderContext={(entry) => (
531
579
  <TranscriptRailEvent kind="compaction">
532
580
  <TranscriptContextEventView
@@ -556,7 +604,7 @@ function RedactedTranscriptView(props: {
556
604
  kind="event"
557
605
  objectType={entry.message.eventObjectType}
558
606
  >
559
- <TranscriptEventView message={entry.message} />
607
+ <TranscriptEventView message={entry.message} openKey={entry.key} />
560
608
  </TranscriptRailEvent>
561
609
  ) : entry.message.context ? (
562
610
  <TranscriptRailEvent kind="message_context">