@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,11 +1,13 @@
1
1
  /**
2
- * Open a long Conversation at the reader's read position.
2
+ * Open a long Conversation away from its latest events: at the reader's read
3
+ * position, or at a linked event, Turn, or memory.
3
4
  *
4
5
  * The detail holds the latest page of events. When the read position is
5
6
  * older than that page, the transcript starts with the page around the read
6
7
  * position instead: the last read events and the unread events after them.
7
8
  * The first detail fetch of a visit brings that page, so it needs no second
8
- * request.
9
+ * request. A link to a row that the transcript does not hold starts it with
10
+ * the page around that row, which the server finds in one request.
9
11
  * Like earlier pages, newer pages load when the reader scrolls near them, so
10
12
  * the server reads only the pages that the reader reaches.
11
13
  *
@@ -54,23 +56,33 @@ export type TranscriptHistory = {
54
56
  };
55
57
 
56
58
  /**
57
- * Load the transcript from `unreadCursor`. Returns `undefined` when there is
58
- * no cursor, or when the first page fails, so the caller shows the latest
59
- * page instead.
59
+ * The row that the transcript opens at: the `unreadCursor` of the read
60
+ * position, or a linked event seq or Turn id.
60
61
  */
61
- export function useUnreadHistory(input: {
62
+ export type HistoryStart =
63
+ | { around: string }
64
+ | { event: string }
65
+ | { turn: string };
66
+
67
+ /**
68
+ * Load the transcript from the page around `start`. Returns `undefined` when
69
+ * there is no start, or when the first page fails, so the caller shows the
70
+ * latest page instead.
71
+ */
72
+ export function useHistoryAround(input: {
62
73
  conversationId: string;
63
74
  /** The latest page, from the polled detail. */
64
75
  detail: ConversationDetailReport | undefined;
65
- /** `unreadCursor` from the detail when the Conversation opened. */
66
- unreadCursor: string | undefined;
67
- /** Skip the newer pages. The caller then passes no `unreadCursor`. */
76
+ start?: HistoryStart;
77
+ /** Skip the newer pages. The caller then passes no `start`. */
68
78
  onShowLatest: () => void;
69
- /** The page around `unreadCursor`, when the detail brought it. */
70
- unreadPage?: ConversationEventPage;
79
+ /** The page around `start` failed. */
80
+ onStartFailed?: () => void;
81
+ /** The page around `start`, when the detail brought it. */
82
+ startPage?: ConversationEventPage;
71
83
  }): (TranscriptHistory & { isPending: boolean }) | undefined {
72
- const { conversationId, detail, onShowLatest, unreadCursor, unreadPage } =
73
- input;
84
+ const { conversationId, detail, onShowLatest, onStartFailed, start } = input;
85
+ const startKey = start ? Object.entries(start)[0]?.join(":") : undefined;
74
86
  const latestEventsRef = useRef<ConversationReportEvent[]>([]);
75
87
  const latestEvents = useMemo(() => {
76
88
  const kept = keepLatestEvents(latestEventsRef.current, detail?.events);
@@ -78,28 +90,24 @@ export function useUnreadHistory(input: {
78
90
  return kept;
79
91
  }, [detail?.events]);
80
92
  const latestFirstSeq = latestEvents[0]?.seq;
81
- const joinedRef = useRef(false);
93
+ // The start whose newer pages joined the latest page.
94
+ const joinedRef = useRef<string>(undefined);
82
95
  const newer = useInfiniteQuery({
83
- // Only the page around the read position loads by itself. The data never
84
- // goes stale, so a refetch cannot reload every newer page at once.
85
- enabled: Boolean(unreadCursor),
96
+ // Only the page around the start loads by itself. The data never goes
97
+ // stale, so a refetch cannot reload every newer page at once.
98
+ enabled: Boolean(start),
86
99
  staleTime: Infinity,
87
- // Each visit starts again from the page around the read position. Pages
88
- // from an earlier visit can end at an old latest event, which would
89
- // leave a gap before the latest page.
100
+ // Each visit starts again from the page around the start. Pages from an
101
+ // earlier visit can end at an old latest event, which would leave a gap
102
+ // before the latest page.
90
103
  gcTime: 0,
91
- queryKey: ["conversation", conversationId, "unread", unreadCursor, "newer"],
104
+ queryKey: ["conversation", conversationId, "around", startKey, "newer"],
92
105
  queryFn: ({ pageParam, signal }) =>
93
106
  readConversationEventsFrom(conversationId, pageParam, signal),
94
- initialPageParam: { around: unreadCursor ?? "" } as NewerPageParam,
107
+ initialPageParam: (start ?? { around: "" }) as NewerPageParam,
95
108
  // The page that came with the detail. Without it, the query reads it.
96
- ...(unreadPage
97
- ? {
98
- initialData: {
99
- pageParams: [{ around: unreadCursor ?? "" }],
100
- pages: [unreadPage],
101
- },
102
- }
109
+ ...(input.startPage && start
110
+ ? { initialData: { pageParams: [start], pages: [input.startPage] } }
103
111
  : undefined),
104
112
  getNextPageParam: (page): NewerPageParam | undefined =>
105
113
  reachesLatest(page, latestFirstSeq) || !page.nextCursor
@@ -110,13 +118,7 @@ export function useUnreadHistory(input: {
110
118
  const firstNewerPage = newer.data?.pages[0];
111
119
  const earlier = useInfiniteQuery({
112
120
  enabled: false,
113
- queryKey: [
114
- "conversation",
115
- conversationId,
116
- "unread",
117
- unreadCursor,
118
- "earlier",
119
- ],
121
+ queryKey: ["conversation", conversationId, "around", startKey, "earlier"],
120
122
  queryFn: ({ pageParam, signal }) =>
121
123
  readConversationEvents(conversationId, pageParam, signal),
122
124
  initialPageParam: firstNewerPage?.previousCursor ?? "",
@@ -130,16 +132,20 @@ export function useUnreadHistory(input: {
130
132
  const reached = lastNewerPage
131
133
  ? reachesLatest(lastNewerPage, latestFirstSeq)
132
134
  : false;
133
- if (reached) joinedRef.current = true;
135
+ if (reached) joinedRef.current = startKey;
134
136
  // No newer page can fill the gap before the latest page, or the latest
135
137
  // events already on screen would leave until one does.
136
138
  const skipped =
137
139
  lastNewerPage !== undefined &&
138
140
  !reached &&
139
- (joinedRef.current || !lastNewerPage.nextCursor);
141
+ (joinedRef.current === startKey || !lastNewerPage.nextCursor);
140
142
  useEffect(() => {
141
143
  if (skipped) onShowLatest();
142
144
  }, [onShowLatest, skipped]);
145
+ const startFailed = Boolean(start && newer.error && !newerPages);
146
+ useEffect(() => {
147
+ if (startFailed) onStartFailed?.();
148
+ }, [onStartFailed, startFailed]);
143
149
  const pages = useMemo(
144
150
  () => [...(earlierPages ?? []), ...(newerPages ?? [])],
145
151
  [earlierPages, newerPages],
@@ -156,7 +162,7 @@ export function useUnreadHistory(input: {
156
162
  [detail, latestEvents, newerPages, pages, reached],
157
163
  );
158
164
 
159
- if (!unreadCursor || skipped || (newer.error && !newerPages)) {
165
+ if (!start || skipped || startFailed) {
160
166
  return undefined;
161
167
  }
162
168
 
@@ -225,10 +231,10 @@ function reachesLatest(
225
231
  return lastSeq + 1 >= latestFirstSeq;
226
232
  }
227
233
 
228
- /** The first page is around the read position. The rest follow it. */
229
- type NewerPageParam = { around: string } | { after: string };
234
+ /** The first page is around the start. The rest follow it. */
235
+ type NewerPageParam = HistoryStart | { after: string };
230
236
 
231
- /** Read one bounded page of events around or after a history cursor. */
237
+ /** Read one bounded page of events around a start or after a cursor. */
232
238
  function readConversationEventsFrom(
233
239
  conversationId: string,
234
240
  param: NewerPageParam,
@@ -1,4 +1,5 @@
1
1
  import {
2
+ createContext,
2
3
  useCallback,
3
4
  useEffect,
4
5
  useLayoutEffect,
@@ -52,6 +53,8 @@ type BottomPinResult = {
52
53
  contentRef: RefCallback<HTMLDivElement>;
53
54
  hasPendingUpdate: boolean;
54
55
  jumpToBottom: () => void;
56
+ /** The `data-scroll-anchor` key of the row that holds the reader's place. */
57
+ scrollAnchorKey: () => string | undefined;
55
58
  showJumpToLatest: boolean;
56
59
  };
57
60
 
@@ -319,6 +322,8 @@ export function usePinnedTranscriptBottom(input: {
319
322
  historyVersion: string;
320
323
  juniorMessageVersion: string;
321
324
  pinRequestVersion?: number;
325
+ /** Open where a link shows its row, not at the latest message. */
326
+ startAtLink?: boolean;
322
327
  /** Open at the "New" marker when the transcript has one. */
323
328
  startAtUnread?: boolean;
324
329
  view: TranscriptViewMode;
@@ -331,10 +336,14 @@ export function usePinnedTranscriptBottom(input: {
331
336
  endsAtLatestRef.current = input.endsAtLatest ?? true;
332
337
  const followingRef = useRef(false);
333
338
  const initializedRef = useRef(false);
334
- const initializedConversationRef = useRef<string | null>(null);
339
+ // The Conversation and start that the transcript opened with. A link at
340
+ // open that fails becomes a normal start, so the transcript opens again.
341
+ const startedRef = useRef<string | null>(null);
335
342
  const previousScrollTopRef = useRef<number | null>(null);
336
- // The scroll position that the last restore set. Its scroll event is not
337
- // a reader scroll, so it must not change follow intent.
343
+ // The scroll position that the last restore or open at the marker set.
344
+ // Its scroll event is not a reader scroll, so it must not change follow
345
+ // intent or the recorded place. Rows can change size before that event,
346
+ // and the restore after them needs the place from before.
338
347
  const restoredScrollTopRef = useRef<number | null>(null);
339
348
  const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
340
349
  const pinRequestVersionRef = useRef(input.pinRequestVersion ?? 0);
@@ -415,7 +424,6 @@ export function usePinnedTranscriptBottom(input: {
415
424
  const root = scrollRootFor(contentElementRef.current);
416
425
  if (!root) return;
417
426
 
418
- if (source === "scroll") rememberPlace();
419
427
  const snapshot = scrollSnapshot(root);
420
428
  const previousScrollTop = previousScrollTopRef.current;
421
429
  previousScrollTopRef.current = snapshot.scrollTop;
@@ -427,6 +435,7 @@ export function usePinnedTranscriptBottom(input: {
427
435
  if (restored !== null && Math.abs(snapshot.scrollTop - restored) < 1) {
428
436
  return;
429
437
  }
438
+ rememberPlace();
430
439
  }
431
440
 
432
441
  const intent = transcriptFollowIntent({
@@ -536,6 +545,7 @@ export function usePinnedTranscriptBottom(input: {
536
545
  );
537
546
  const snapshot = scrollSnapshot(root);
538
547
  previousScrollTopRef.current = snapshot.scrollTop;
548
+ restoredScrollTopRef.current = snapshot.scrollTop;
539
549
  // Unread rows that fit above the bottom leave the reader at the latest.
540
550
  const atBottom = endsAtLatestRef.current && isNearScrollBottom(snapshot);
541
551
  setFollowingIntent(atBottom);
@@ -546,13 +556,20 @@ export function usePinnedTranscriptBottom(input: {
546
556
 
547
557
  useBrowserLayoutEffect(() => {
548
558
  if (!contentElement || !input.conversationId) return;
549
- if (initializedConversationRef.current === input.conversationId) return;
559
+ const start = `${input.conversationId}:${input.startAtLink ? "link" : "start"}`;
560
+ if (startedRef.current === start) return;
550
561
 
551
562
  const root = scrollRootFor(contentElement);
552
563
  if (!root) return;
553
564
 
554
- initializedConversationRef.current = input.conversationId;
565
+ startedRef.current = start;
555
566
  setHasPendingUpdate(false);
567
+ // The link scrolls to its row, maybe before this runs. Do not move away.
568
+ if (input.startAtLink) {
569
+ previousScrollTopRef.current = scrollSnapshot(root).scrollTop;
570
+ setFollowingIntent(false);
571
+ return;
572
+ }
556
573
  if (input.startAtUnread && openAtUnreadMarker()) return;
557
574
 
558
575
  setScrollTop(root, scrollSnapshot(root).scrollHeight);
@@ -565,6 +582,7 @@ export function usePinnedTranscriptBottom(input: {
565
582
  }, [
566
583
  contentElement,
567
584
  input.conversationId,
585
+ input.startAtLink,
568
586
  input.startAtUnread,
569
587
  openAtUnreadMarker,
570
588
  setFollowingIntent,
@@ -678,7 +696,12 @@ export function usePinnedTranscriptBottom(input: {
678
696
  measurePosition("measure");
679
697
  rememberPlace();
680
698
  // Listen on the scroll root, so gestures outside transcript rows count.
681
- target.addEventListener("scroll", onScroll, { passive: true });
699
+ // Capture runs first at the root, so the place is recorded before a
700
+ // virtual list mounts the rows for the new position.
701
+ target.addEventListener("scroll", onScroll, {
702
+ capture: true,
703
+ passive: true,
704
+ });
682
705
  target.addEventListener("wheel", onWheel, { passive: true });
683
706
  target.addEventListener("touchstart", onTouchStart, {
684
707
  passive: true,
@@ -688,7 +711,7 @@ export function usePinnedTranscriptBottom(input: {
688
711
  });
689
712
  window.addEventListener("resize", syncAfterLayoutChange);
690
713
  return () => {
691
- target.removeEventListener("scroll", onScroll);
714
+ target.removeEventListener("scroll", onScroll, { capture: true });
692
715
  target.removeEventListener("wheel", onWheel);
693
716
  target.removeEventListener("touchstart", onTouchStart);
694
717
  target.removeEventListener("touchmove", onTouchMove);
@@ -761,6 +784,8 @@ export function usePinnedTranscriptBottom(input: {
761
784
  scrollToBottom("auto");
762
785
  }, [input.pinRequestVersion, scrollToBottom, setFollowingIntent]);
763
786
 
787
+ const scrollAnchorKey = useCallback(() => scrollAnchorRef.current?.key, []);
788
+
764
789
  return useMemo(
765
790
  () => ({
766
791
  anchorRef,
@@ -768,6 +793,7 @@ export function usePinnedTranscriptBottom(input: {
768
793
  contentRef,
769
794
  hasPendingUpdate,
770
795
  jumpToBottom,
796
+ scrollAnchorKey,
771
797
  showJumpToLatest: shouldShowJumpToLatest({
772
798
  enabled: input.enabled,
773
799
  endsAtLatest: input.endsAtLatest,
@@ -785,10 +811,16 @@ export function usePinnedTranscriptBottom(input: {
785
811
  input.endsAtLatest,
786
812
  jumpToBottom,
787
813
  openedAtUnread,
814
+ scrollAnchorKey,
788
815
  ],
789
816
  );
790
817
  }
791
818
 
819
+ /** Jump to the latest row from inside the transcript, like the floating pill. */
820
+ export const TranscriptJumpToLatestContext = createContext<
821
+ (() => void) | undefined
822
+ >(undefined);
823
+
792
824
  /**
793
825
  * Load the next history page when the reader scrolls near the edge where it
794
826
  * goes: the top for earlier pages, the bottom for newer pages.
@@ -0,0 +1,215 @@
1
+ import {
2
+ isCollapsibleActivityEntry,
3
+ quietTurnEntryKeys,
4
+ } from "./TranscriptActivityGroup";
5
+ import type { RenderedTranscriptEntry } from "./transcriptRenderModel";
6
+ import { entryMatchesSearch } from "./transcriptSearch";
7
+ import type { TranscriptUnreadRange } from "./transcriptReadPosition";
8
+
9
+ /** One row of the rich transcript list, in seq order. */
10
+ export type TranscriptListRow =
11
+ | { kind: "unread_marker"; key: string }
12
+ | {
13
+ kind: "entry";
14
+ entry: RenderedTranscriptEntry;
15
+ key: string;
16
+ seq?: number;
17
+ }
18
+ | {
19
+ kind: "activity";
20
+ entries: RenderedTranscriptEntry[];
21
+ key: string;
22
+ /** Last event sequence in the group. */
23
+ seq?: number;
24
+ };
25
+
26
+ /** The key of the "New" marker row. */
27
+ export const UNREAD_MARKER_KEY = "unread-marker";
28
+
29
+ /**
30
+ * Turn transcript entries into list rows. Runs of collapsible activity
31
+ * become one group row, search drops rows that do not match, and the
32
+ * "New" marker becomes its own row before the first unread row.
33
+ *
34
+ * `knownActivityKeys` maps entry keys to group keys across calls, so a
35
+ * group keeps its key while events extend either edge.
36
+ */
37
+ export function transcriptListRows(input: {
38
+ entries: readonly RenderedTranscriptEntry[];
39
+ keyPrefix: string;
40
+ knownActivityKeys: Map<string, string>;
41
+ /** The normalized search query, when search is active. */
42
+ search?: string;
43
+ unread?: TranscriptUnreadRange;
44
+ }): TranscriptListRow[] {
45
+ const { entries, search } = input;
46
+ const quietKeys = quietTurnEntryKeys(entries);
47
+ const collapsible = (entry: RenderedTranscriptEntry) =>
48
+ isCollapsibleActivityEntry(entry) || quietKeys.has(entry.key);
49
+ const matches = (entry: RenderedTranscriptEntry) =>
50
+ search === undefined || entryMatchesSearch(entry, search);
51
+ const claimedActivityKeys = new Set<string>();
52
+ const rows: TranscriptListRow[] = [];
53
+ // Search results are not a reading order, so they get no marker.
54
+ const markerSeq =
55
+ search === undefined ? unreadMarkerSeq(entries, input.unread) : undefined;
56
+ let markerPending = markerSeq !== undefined;
57
+ // Entries are in seq order, so the first row that reaches the marker seq
58
+ // gets the marker.
59
+ const pushRow = (
60
+ row: Exclude<TranscriptListRow, { kind: "unread_marker" }>,
61
+ ) => {
62
+ if (markerPending && row.seq !== undefined && row.seq >= markerSeq!) {
63
+ markerPending = false;
64
+ rows.push({ kind: "unread_marker", key: UNREAD_MARKER_KEY });
65
+ }
66
+ rows.push(row);
67
+ };
68
+
69
+ for (let index = 0; index < entries.length; ) {
70
+ const entry = entries[index]!;
71
+ if (collapsible(entry)) {
72
+ const group: RenderedTranscriptEntry[] = [];
73
+ while (index < entries.length && collapsible(entries[index]!)) {
74
+ group.push(entries[index]!);
75
+ index += 1;
76
+ }
77
+ const visible = group.filter(matches);
78
+ if (visible.length > 0) {
79
+ // The marker goes before the group when any of it is unread.
80
+ pushRow({
81
+ kind: "activity",
82
+ entries: visible,
83
+ key: stableActivityGroupKey({
84
+ claimedKeys: claimedActivityKeys,
85
+ entries: group,
86
+ keyPrefix: input.keyPrefix,
87
+ knownKeys: input.knownActivityKeys,
88
+ }),
89
+ seq: lastEntrySeq(group),
90
+ });
91
+ }
92
+ continue;
93
+ }
94
+ if (matches(entry)) {
95
+ pushRow({
96
+ kind: "entry",
97
+ entry,
98
+ key: `${input.keyPrefix}:${entry.key}`,
99
+ seq: entry.seq,
100
+ });
101
+ }
102
+ index += 1;
103
+ }
104
+ return rows;
105
+ }
106
+
107
+ /**
108
+ * Find the first row whose last seq is at or after `seq`. The New marker
109
+ * sorts with the row before it, and other rows without a seq come last, so a
110
+ * binary search works. Returns `rows.length` when no row reaches it.
111
+ */
112
+ export function rowIndexForSeq(
113
+ rows: readonly TranscriptListRow[],
114
+ seq: number,
115
+ ): number {
116
+ let low = 0;
117
+ let high = rows.length;
118
+ while (low < high) {
119
+ const middle = (low + high) >> 1;
120
+ const row = rows[middle]!;
121
+ const rowSeq =
122
+ row.kind === "unread_marker"
123
+ ? (rowSeqBefore(rows, middle) ?? Number.NEGATIVE_INFINITY)
124
+ : row.seq;
125
+ if (rowSeq !== undefined && rowSeq < seq) low = middle + 1;
126
+ else high = middle;
127
+ }
128
+ return low;
129
+ }
130
+
131
+ function rowSeqBefore(
132
+ rows: readonly TranscriptListRow[],
133
+ index: number,
134
+ ): number | undefined {
135
+ const row = rows[index - 1];
136
+ return row === undefined || row.kind === "unread_marker"
137
+ ? undefined
138
+ : row.seq;
139
+ }
140
+
141
+ /**
142
+ * Group rows whose heights are alike, so a row can borrow the measured
143
+ * height of others before it renders.
144
+ */
145
+ export function transcriptRowSizeClass(row: TranscriptListRow): string {
146
+ if (row.kind !== "entry") return row.kind;
147
+ const { entry } = row;
148
+ return entry.kind === "message"
149
+ ? `message:${entry.message.role}`
150
+ : entry.kind;
151
+ }
152
+
153
+ /** Guess the height of a row of a size class before any of them measure. */
154
+ export function defaultTranscriptRowSize(sizeClass: string): number {
155
+ if (sizeClass === "unread_marker") return 32;
156
+ if (sizeClass === "activity") return 36;
157
+ if (sizeClass === "attachments_delivered") return 240;
158
+ if (sizeClass === "message:assistant") return 120;
159
+ if (sizeClass.startsWith("message:")) return 80;
160
+ return 44;
161
+ }
162
+
163
+ /**
164
+ * Find the seq of the first unread row. Entries are in seq order, and pending
165
+ * sends without a seq come last, so a binary search finds the first seq after
166
+ * the read position. Rows after the range arrived after open, so they get no
167
+ * marker.
168
+ */
169
+ function unreadMarkerSeq(
170
+ entries: readonly RenderedTranscriptEntry[],
171
+ unread: TranscriptUnreadRange | undefined,
172
+ ): number | undefined {
173
+ if (!unread) return undefined;
174
+ let low = 0;
175
+ let high = entries.length;
176
+ while (low < high) {
177
+ const middle = (low + high) >> 1;
178
+ const seq = entries[middle]!.seq;
179
+ if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1;
180
+ else high = middle;
181
+ }
182
+ const seq = entries[low]?.seq;
183
+ return seq !== undefined && seq <= unread.throughSeq ? seq : undefined;
184
+ }
185
+
186
+ function lastEntrySeq(
187
+ entries: readonly RenderedTranscriptEntry[],
188
+ ): number | undefined {
189
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
190
+ const seq = entries[index]!.seq;
191
+ if (seq !== undefined) return seq;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ /** Keep one activity group mounted while new or historical events extend either edge. */
197
+ function stableActivityGroupKey(args: {
198
+ claimedKeys: Set<string>;
199
+ entries: RenderedTranscriptEntry[];
200
+ keyPrefix: string;
201
+ knownKeys: Map<string, string>;
202
+ }): string {
203
+ const entryKeys = args.entries.map(
204
+ (entry) => `${args.keyPrefix}:${entry.key}`,
205
+ );
206
+ const knownKey = entryKeys
207
+ .map((entryKey) => args.knownKeys.get(entryKey))
208
+ .find((key) => key !== undefined && !args.claimedKeys.has(key));
209
+ const groupKey =
210
+ knownKey ?? `${args.keyPrefix}:activity:${args.entries[0]!.key}`;
211
+
212
+ args.claimedKeys.add(groupKey);
213
+ entryKeys.forEach((entryKey) => args.knownKeys.set(entryKey, groupKey));
214
+ return groupKey;
215
+ }
@@ -4,7 +4,8 @@
4
4
  * Rows from stored events carry `data-transcript-seq`. While the page is
5
5
  * visible, the reporter sends the sequence of the last row that has reached
6
6
  * the viewport. The server keeps the highest value, so every client of the
7
- * same user shares one position. Detail polls bring it back to other clients.
7
+ * same user shares one position. A write does not change the detail version,
8
+ * so other clients get it with the next detail change or a reload.
8
9
  *
9
10
  * The page reads the position once when a Conversation opens. That value
10
11
  * places the "New" marker and, by the reader's preference, where the
@@ -139,6 +140,27 @@ function readPositionAtOpen(
139
140
  return { lastReadSeq, unread: { afterSeq: lastReadSeq, throughSeq } };
140
141
  }
141
142
 
143
+ /**
144
+ * Put a stored read position in the cached detail. Polls answer 304 after a
145
+ * read, so the cache must also drop the unread cursor once the reader reaches
146
+ * the latest page, as the server would. Otherwise the next visit in this
147
+ * session opens at rows the reader has already read.
148
+ */
149
+ function withReadPosition(
150
+ detail: ConversationDetailReport,
151
+ readPosition: ConversationReadPosition,
152
+ ): ConversationDetailReport {
153
+ const firstSeq = detail.events[0]?.seq;
154
+ if (
155
+ !detail.unreadCursor ||
156
+ (firstSeq !== undefined && readPosition.lastReadSeq + 1 < firstSeq)
157
+ ) {
158
+ return { ...detail, readPosition };
159
+ }
160
+ const { unreadCursor: _cursor, unreadPage: _page, ...rest } = detail;
161
+ return { ...rest, readPosition };
162
+ }
163
+
142
164
  /**
143
165
  * Send read positions for one Conversation. A position at or before the one
144
166
  * in the cached detail is not sent, because the server already has it. The
@@ -157,7 +179,7 @@ export function useMarkConversationRead(conversationId: string) {
157
179
  onSuccess: (readPosition) => {
158
180
  queryClient.setQueryData<ConversationDetailReport>(
159
181
  conversationDetailQueryKey(conversationId),
160
- (detail) => (detail ? { ...detail, readPosition } : detail),
182
+ (detail) => detail && withReadPosition(detail, readPosition),
161
183
  );
162
184
  },
163
185
  });
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import * as z from "zod";
2
2
 
3
3
  import type { TranscriptViewTurnContext } from "../types";
4
4