@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,25 +1,33 @@
1
1
  import {
2
2
  useCallback,
3
3
  useEffect,
4
+ useEffectEvent,
5
+ useLayoutEffect,
4
6
  useMemo,
5
7
  useRef,
6
8
  useState,
7
9
  useSyncExternalStore,
8
10
  type ReactNode,
9
11
  } from "react";
10
- import { useSearchParams } from "react-router";
12
+ import { useNavigate, useSearchParams } from "react-router";
11
13
  import type {
12
14
  ConversationDetailReport,
13
15
  ConversationFeed,
16
+ ConversationReportEvent,
14
17
  } from "@sentry/junior/api/schema";
15
18
 
16
19
  import type { ConversationData } from "./queries";
17
20
  import {
18
21
  useArchiveConversation,
22
+ type ArchivedFromConversationState,
19
23
  type PendingArchiveConversationUpdate,
20
24
  } from "./conversationArchive";
21
25
  import { usePinConversation } from "./usePinConversation";
22
26
  import { conversationIsResponding } from "./transcript";
27
+ import {
28
+ ConversationCatchUpContext,
29
+ useConversationCatchUp,
30
+ } from "./conversationCatchUpState";
23
31
  import { buildConversationMarkdown } from "../markdownExport";
24
32
  import { CopyButton } from "../components/CopyButton";
25
33
  import {
@@ -39,6 +47,7 @@ import {
39
47
  memoryAnchorKey,
40
48
  useConversationMemories,
41
49
  type ConversationMemoryState,
50
+ type MemoryJump,
42
51
  type OpenMemoryPopover,
43
52
  } from "./conversationMemory";
44
53
  import { ConversationEventDetails } from "./ConversationEventDetails";
@@ -57,14 +66,13 @@ import {
57
66
  import { cn } from "../styles";
58
67
  import { ConversationForkLinks } from "./ConversationForkLinks";
59
68
  import { ConversationAgentTree } from "./ConversationAgentTree";
69
+ import { conversationContextTags } from "./ConversationContextTags";
60
70
  import { conversationLinks } from "./ConversationLinkedWork";
61
71
  import {
62
72
  ConversationAnnotations,
63
73
  conversationCost,
64
- conversationLocation,
65
74
  ConversationPrivacyChip,
66
75
  ConversationStats,
67
- hasConversationAnnotations,
68
76
  hasConversationStats,
69
77
  } from "./ConversationMeta";
70
78
  import {
@@ -100,9 +108,10 @@ import {
100
108
  import { TranscriptLoading } from "./TranscriptLoading";
101
109
  import { recordTranscriptShown } from "../sentry";
102
110
  import {
103
- useUnreadHistory,
111
+ useHistoryAround,
112
+ type HistoryStart,
104
113
  type TranscriptHistory,
105
- } from "./transcriptUnreadHistory";
114
+ } from "./transcriptAroundHistory";
106
115
  import type { TranscriptViewMode } from "./transcriptRenderModel";
107
116
  import {
108
117
  SubagentTranscriptDrawer,
@@ -143,6 +152,7 @@ export function ConversationPage(props: {
143
152
  () => false,
144
153
  );
145
154
  const pageRef = useRef<HTMLDivElement>(null);
155
+ const focusDetailsOnOpen = useRef(false);
146
156
  const [subagentTarget, setSubagentTarget] =
147
157
  useState<SubagentTranscriptTarget>();
148
158
  const [searchParams, setSearchParams] = useSearchParams();
@@ -184,7 +194,11 @@ export function ConversationPage(props: {
184
194
  const [turnContext, setTurnContext] = useState<TurnContextSelection>();
185
195
  const [memoryPopover, setMemoryPopover] = useState<OpenMemoryPopover>();
186
196
  // A memory chosen in details waits here until its transcript row renders.
187
- const [memoryJump, setMemoryJump] = useState<OpenMemoryPopover>();
197
+ const [memoryJump, setMemoryJump] = useState<MemoryJump>();
198
+ // The transcript mounts only rows near the viewport. The rows of the last
199
+ // memory jump stay mounted, so the jump finds its row and the popover
200
+ // keeps it.
201
+ const [revealSeq, setRevealSeq] = useState<number>();
188
202
  // The page stays mounted across Conversations. Memory state belongs to one.
189
203
  const [memoryConversationId, setMemoryConversationId] = useState(
190
204
  props.conversationId,
@@ -193,6 +207,7 @@ export function ConversationPage(props: {
193
207
  setMemoryConversationId(props.conversationId);
194
208
  setMemoryPopover(undefined);
195
209
  setMemoryJump(undefined);
210
+ setRevealSeq(undefined);
196
211
  }
197
212
  const quickQuestion = useQuickQuestionThread(props.conversationId);
198
213
  // Event details and turn context also show over a Quick question.
@@ -200,6 +215,14 @@ export function ConversationPage(props: {
200
215
  Boolean(quickQuestion.thread) && !eventOpen && !turnContext;
201
216
  const showDetails =
202
217
  detailsOpen && !eventOpen && !turnContext && !quickQuestion.thread;
218
+ useLayoutEffect(() => {
219
+ if (!showDetails || !focusDetailsOnOpen.current) return;
220
+ focusDetailsOnOpen.current = false;
221
+ // The inline opener leaves the tab order while details are visible.
222
+ pageRef.current
223
+ ?.querySelector<HTMLButtonElement>('[data-slot="details-panel-close"]')
224
+ ?.focus({ preventScroll: true });
225
+ }, [showDetails]);
203
226
  const [viewState, setView] = useState<TranscriptViewMode>(() =>
204
227
  eventOpen ? "raw" : "rich",
205
228
  );
@@ -214,11 +237,33 @@ export function ConversationPage(props: {
214
237
  const [startAtUnread] = useState(
215
238
  () => readConversationStartPosition() === "unread",
216
239
  );
240
+ // A link at open shows its row, so the transcript does not open at the
241
+ // latest message or the read position, unless the link fails first.
242
+ const [startAtLink, setStartAtLink] = useState(
243
+ () => selectedEventSeq !== undefined || linkedTurnId !== undefined,
244
+ );
217
245
  const detail = props.detail;
218
246
  // Jump to latest, a send, or the mobile keyboard skips the newer pages for
219
247
  // the rest of the visit. It can come before the unread page loads.
220
248
  const [showLatest, setShowLatest] = useState(false);
221
- const opensAtUnread = startAtUnread && !showLatest;
249
+ // A link at open wins over the read position. When the loaded rows hold
250
+ // the link, the transcript keeps them and does not page to the read
251
+ // position.
252
+ const opensAtUnread = startAtUnread && !showLatest && !startAtLink;
253
+ // A linked row that the transcript does not hold. The transcript opens at
254
+ // the page around it until the reader jumps to the latest. A link at open
255
+ // starts there, so the first render reads no other page.
256
+ const [linkStart, setLinkStart] = useState(() =>
257
+ linkedTurnId !== undefined
258
+ ? unheldLinkStart(detail.data?.events, { turn: linkedTurnId })
259
+ : selectedEventSeq === undefined
260
+ ? undefined
261
+ : unheldLinkStart(detail.data?.events, { event: selectedEventSeq }),
262
+ );
263
+ const showLatestPage = useCallback(() => {
264
+ setShowLatest(true);
265
+ setLinkStart(undefined);
266
+ }, []);
222
267
  const memoryList = useConversationMemories(conversationId).data;
223
268
  const memoryState = useMemo<ConversationMemoryState>(
224
269
  () => ({
@@ -229,41 +274,78 @@ export function ConversationPage(props: {
229
274
  [memoryList, memoryPopover],
230
275
  );
231
276
  const archive = useArchiveConversation(conversationId);
277
+ const navigate = useNavigate();
232
278
  const pin = usePinConversation(conversationId);
233
279
  const markRead = useMarkConversationRead(conversationId);
234
280
  const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
235
- const unreadHistory = useUnreadHistory({
281
+ const dropLink = useCallback(() => {
282
+ setLinkStart(undefined);
283
+ setStartAtLink(false);
284
+ setMemoryJump(undefined);
285
+ setSearchParams(
286
+ (current) => {
287
+ const next = new URLSearchParams(current);
288
+ next.delete(CONVERSATION_EVENT_PARAM);
289
+ next.delete(CONVERSATION_TURN_PARAM);
290
+ return next;
291
+ },
292
+ { replace: true },
293
+ );
294
+ }, [setSearchParams]);
295
+ const unreadCursor = opensAtUnread ? readAtOpen.unreadCursor : undefined;
296
+ const aroundHistory = useHistoryAround({
236
297
  conversationId,
237
298
  // Cached earlier pages from a past visit would look like newer events.
238
299
  detail: detail.latest,
239
- onShowLatest: () => setShowLatest(true),
240
- unreadCursor: opensAtUnread ? readAtOpen.unreadCursor : undefined,
241
- unreadPage: readAtOpen.unreadPage,
300
+ onShowLatest: showLatestPage,
301
+ // A link that fails to load is dropped. The transcript goes back to
302
+ // where it was.
303
+ ...(linkStart
304
+ ? { onStartFailed: dropLink, start: linkStart }
305
+ : unreadCursor
306
+ ? {
307
+ start: { around: unreadCursor },
308
+ ...(readAtOpen.unreadPage
309
+ ? { startPage: readAtOpen.unreadPage }
310
+ : undefined),
311
+ }
312
+ : undefined),
242
313
  });
243
- // The page is keyed by Conversation, so this is the cached copy at open.
244
- const [cachedAtOpen] = useState(() => detail.data);
245
- // A reader far behind sees the events from their read position. Others
246
- // see the latest page and the pages before it. A cached transcript stays
247
- // on screen until this visit's fetch tells where to open and the unread
248
- // events load. It loads no earlier pages and shows no marker: both would
249
- // come from the cached read position. It reports no reads, because it sits
250
- // at rows the reader has not read yet. Then the transcript opens at the
251
- // "New" marker, if the reader has not moved.
252
- const holdCachedTranscript = Boolean(
253
- cachedAtOpen &&
254
- (unreadHistory?.isPending || (opensAtUnread && !detail.fetchedAtOpen)),
314
+ // The transcript on screen, so it can stay while the next one loads. The
315
+ // page is keyed by Conversation, so at open this is the cached copy.
316
+ const shownRef = useRef(detail.data);
317
+ // A reader far behind sees the events from their read position, and a
318
+ // link shows the events around its row. Others see the latest page and
319
+ // the pages before it. The transcript on screen stays until this visit's
320
+ // fetch tells where to open and the page around that row loads. It loads
321
+ // no earlier pages and shows no marker: at open, both would come from the
322
+ // cached read position. It reports no reads, because it can sit at rows
323
+ // the reader has not read yet. Then the transcript opens at the row, if
324
+ // the reader has not moved.
325
+ const holdShown = Boolean(
326
+ shownRef.current &&
327
+ (aroundHistory?.isPending ||
328
+ (opensAtUnread && !linkStart && !detail.fetchedAtOpen)),
255
329
  );
256
- const history: TranscriptHistory = holdCachedTranscript
330
+ const transcriptPending =
331
+ detail.isPending || Boolean(aroundHistory?.isPending && !shownRef.current);
332
+ const history: TranscriptHistory = holdShown
257
333
  ? {
258
334
  ...detail,
259
- data: cachedAtOpen,
335
+ data: shownRef.current,
260
336
  hasPreviousPage: false,
261
337
  isLoadingPreviousPage: false,
262
338
  loadPreviousPage: () => {},
263
339
  }
264
- : (unreadHistory ?? detail);
265
- const transcriptPending =
266
- detail.isPending || Boolean(unreadHistory?.isPending && !cachedAtOpen);
340
+ : (aroundHistory ?? detail);
341
+ if (history.data) shownRef.current = history.data;
342
+ const catchUp = useConversationCatchUp({
343
+ conversationId,
344
+ events: history.data?.events,
345
+ hasNewerPage: Boolean(history.newer),
346
+ hasPreviousPage: history.hasPreviousPage,
347
+ unread: holdShown ? undefined : readAtOpen.unread,
348
+ });
267
349
  const feedConversation = conversations.find(
268
350
  (item) => item.id === conversationId,
269
351
  );
@@ -277,6 +359,11 @@ export function ConversationPage(props: {
277
359
  <ParticipantAvatarStack participants={participants} size="detail" />
278
360
  ) : null;
279
361
  const conversationDetail = detail.data;
362
+ const links = conversationLinks(conversationDetail);
363
+ const showUsageBreakdown = hasConversationUsageBreakdown({
364
+ conversation,
365
+ detail: conversationDetail,
366
+ });
280
367
  const title =
281
368
  conversationDetailTitle(detail.data) ??
282
369
  conversationDisplayTitle(conversation);
@@ -342,53 +429,29 @@ export function ConversationPage(props: {
342
429
  },
343
430
  [eventOpen, setSelectedEventSeq],
344
431
  );
345
- // A linked event, Turn, or memory can be outside the loaded pages. Look
346
- // for it one page at a time, in the direction where it can be.
347
- const historyRef = useRef(history);
348
- historyRef.current = history;
349
- const loadToward = useCallback((search: HistorySearch | undefined) => {
350
- if (search === "load-earlier") historyRef.current.loadPreviousPage();
351
- else if (search === "load-newer") historyRef.current.newer?.load();
352
- }, []);
353
- const searchHistory = (direction: HistorySearchDirection) =>
354
- nextHistorySearch(history, direction, holdCachedTranscript);
355
- const oldestLoadedSeq = transcript?.events[0]?.seq;
356
- const newestLoadedSeq = transcript?.events.at(-1)?.seq;
357
- const linkedEventDirection: HistorySearchDirection | undefined =
358
- selectedEventSeq === undefined ||
359
- oldestLoadedSeq === undefined ||
360
- newestLoadedSeq === undefined
361
- ? undefined
362
- : selectedEventSeq < oldestLoadedSeq
363
- ? "earlier"
364
- : selectedEventSeq > newestLoadedSeq &&
365
- (history.newer || holdCachedTranscript)
366
- ? "newer"
367
- : undefined;
368
- // Replace a found Turn link with a link to its first event, or drop it
369
- // when history has no such event.
432
+ // A linked event, Turn, or memory can be outside the loaded rows. Then the
433
+ // transcript opens at the page around it, which the server finds by the
434
+ // event seq or Turn id. Only a new link does this, so a reader who jumps to
435
+ // the latest stays there. Returns false when the loaded rows hold the link.
436
+ const openLink = useEffectEvent((link: ConversationLink) => {
437
+ const start = unheldLinkStart(history.data?.events, link);
438
+ if (start) setLinkStart(start);
439
+ return Boolean(start);
440
+ });
441
+ useEffect(() => {
442
+ if (linkedTurnId !== undefined) openLink({ turn: linkedTurnId });
443
+ else if (selectedEventSeq !== undefined) {
444
+ openLink({ event: selectedEventSeq });
445
+ }
446
+ }, [linkedTurnId, selectedEventSeq]);
447
+ // Replace a found Turn link with a link to its first event.
370
448
  const linkedTurnSeq = linkedTurnId
371
449
  ? turnStartedEventSeq(transcript?.events, linkedTurnId)
372
450
  : undefined;
373
- const linkedTurnMissing =
374
- linkedTurnId !== undefined &&
375
- linkedTurnSeq === undefined &&
376
- transcript !== undefined;
377
- const linkedSearch = linkedEventDirection
378
- ? searchHistory(linkedEventDirection)
379
- : linkedTurnMissing
380
- ? searchHistory("any")
381
- : undefined;
382
- useEffect(() => loadToward(linkedSearch), [linkedSearch, loadToward]);
383
- const linkedTurnNotFound = linkedTurnMissing && linkedSearch === "end";
384
451
  useEffect(() => {
385
452
  if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
386
- else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
387
- }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
388
- // Show a chosen memory at its transcript row. Wait for the first page, then
389
- // load pages toward the row until it renders, or stop when history ends.
390
- const memorySearch =
391
- memoryJump && transcript ? searchHistory("any") : undefined;
453
+ }, [linkedTurnSeq, setSelectedEventSeq]);
454
+ // Show a chosen memory at its transcript row, once the row renders.
392
455
  useEffect(() => {
393
456
  if (!memoryJump || !transcript) return;
394
457
  const anchor = pageRef.current?.querySelector(
@@ -398,22 +461,17 @@ export function ConversationPage(props: {
398
461
  anchor.scrollIntoView({ block: "center" });
399
462
  setMemoryPopover(memoryJump);
400
463
  setMemoryJump(undefined);
401
- } else if (memorySearch === "failed" || memorySearch === "end") {
464
+ } else if (!holdShown && !openLink({ event: memoryJump.seq })) {
465
+ // The loaded rows hold the event, but no row shows the memory.
402
466
  setMemoryJump(undefined);
403
- } else {
404
- loadToward(memorySearch);
405
467
  }
406
- }, [loadToward, memoryJump, memorySearch, transcript, view]);
468
+ }, [holdShown, memoryJump, transcript, view]);
407
469
  // Keep the panel while the transcript or a linked event loads, and for the
408
470
  // render before a found Turn link becomes an event link.
409
471
  const eventLoading =
410
472
  eventOpen &&
411
473
  !selectedEvent &&
412
- (transcriptPending ||
413
- linkedTurnSeq !== undefined ||
414
- linkedSearch === "load-earlier" ||
415
- linkedSearch === "load-newer" ||
416
- linkedSearch === "wait");
474
+ (transcriptPending || holdShown || linkedTurnId !== undefined);
417
475
  const showEvent = Boolean(selectedEvent) || eventLoading;
418
476
  // Step through the same filtered rows that the event log shows.
419
477
  const events = transcript?.events;
@@ -436,9 +494,9 @@ export function ConversationPage(props: {
436
494
  [turnContext?.id],
437
495
  );
438
496
  const requestPin = useCallback(() => {
439
- setShowLatest(true);
497
+ showLatestPage();
440
498
  setPinRequestVersion((version) => version + 1);
441
- }, []);
499
+ }, [showLatestPage]);
442
500
  const onOpenSubagentTranscript = useCallback(
443
501
  ({
444
502
  part,
@@ -480,7 +538,7 @@ export function ConversationPage(props: {
480
538
  className={cn(
481
539
  "grid h-full min-h-0 min-w-0",
482
540
  (showDetails || showEvent || turnContext || showQuickQuestion) &&
483
- "xl:grid-cols-[minmax(0,1fr)_25rem]",
541
+ "xl:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_25rem]",
484
542
  )}
485
543
  >
486
544
  <ChatLayout
@@ -489,15 +547,20 @@ export function ConversationPage(props: {
489
547
  agentTree={
490
548
  agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
491
549
  }
550
+ contextTags={conversationContextTags(conversation)}
492
551
  conversationId={conversationId}
493
- cost={conversationCost(conversation, detail.data, "below")}
552
+ cost={conversationCost(conversation, conversationDetail, "below")}
494
553
  detailsOpen={showDetails}
495
554
  onDetailsClick={() => {
496
555
  if (eventOpen) setSelectedEventSeq(undefined);
497
556
  setTurnContext(undefined);
498
557
  quickQuestion.close();
499
- if (showDetails) closeDetails();
500
- else setDetailsChoice(true);
558
+ if (showDetails) {
559
+ closeDetails();
560
+ } else {
561
+ focusDetailsOnOpen.current = true;
562
+ setDetailsChoice(true);
563
+ }
501
564
  }}
502
565
  forks={
503
566
  detail.data?.forkedFromConversationId ||
@@ -529,9 +592,7 @@ export function ConversationPage(props: {
529
592
  />
530
593
  }
531
594
  linkedWork={
532
- hasConversationAnnotations(detail.data?.annotations) ? (
533
- <ConversationAnnotations detail={detail.data} />
534
- ) : null
595
+ links.length ? <ConversationAnnotations links={links} /> : null
535
596
  }
536
597
  pin={{
537
598
  // The API lets only participants pin, the same as archive.
@@ -545,15 +606,25 @@ export function ConversationPage(props: {
545
606
  archived: Boolean(conversation?.archivedAt),
546
607
  disabled: !conversation || archive.isPending,
547
608
  error: Boolean(archive.error),
548
- onClick: () =>
609
+ onClick: () => {
610
+ const archived = !conversation!.archivedAt;
549
611
  archive.mutate({
550
- archived: !conversation?.archivedAt,
612
+ archived,
551
613
  lastSeenAt: conversation!.lastSeenAt,
552
- }),
614
+ });
615
+ // Archiving hides this conversation, so go home at once.
616
+ // The mutation keeps running after this page unmounts.
617
+ if (archived) {
618
+ navigate("/", {
619
+ state: {
620
+ archivedConversation: conversation!,
621
+ } satisfies ArchivedFromConversationState,
622
+ });
623
+ }
624
+ },
553
625
  pending: archive.isPending,
554
626
  }}
555
627
  live={live}
556
- location={conversationLocation(conversation, detail.data)}
557
628
  onSearchChange={setSearch}
558
629
  onViewChange={onViewChange}
559
630
  privacy={
@@ -600,39 +671,41 @@ export function ConversationPage(props: {
600
671
  </div>
601
672
  ) : null}
602
673
  <ConversationMemoryContext value={memoryState}>
603
- <Transcript
604
- hasPreviousPage={history.hasPreviousPage}
605
- historyError={history.historyError}
606
- historyVersion={history.historyVersion}
607
- lastReadSeq={readAtOpen.lastReadSeq}
608
- live={live}
609
- loadingPreviousPage={history.isLoadingPreviousPage}
610
- newerHistory={history.newer}
611
- onLoadPreviousPage={history.loadPreviousPage}
612
- onRead={holdCachedTranscript ? undefined : markRead}
613
- onSelectedEventSeqChange={setSelectedEventSeq}
614
- // Sends and the thinking row belong after the latest event.
615
- pendingMessages={
616
- history.newer ? undefined : detail.inlineMessages
617
- }
618
- pinRequestVersion={pinRequestVersion}
619
- responding={
620
- !detail.error &&
621
- !history.newer &&
622
- conversationIsResponding(detail.data)
623
- }
624
- onOpenSubagentTranscript={onOpenSubagentTranscript}
625
- search={search}
626
- onSearchChange={setSearch}
627
- selectedEventSeq={selectedEventSeq}
628
- startAtUnread={startAtUnread}
629
- transcript={transcript}
630
- turnContextPanel={turnContextPanel}
631
- unread={
632
- holdCachedTranscript ? undefined : readAtOpen.unread
633
- }
634
- view={view}
635
- />
674
+ <ConversationCatchUpContext value={catchUp}>
675
+ <Transcript
676
+ hasPreviousPage={history.hasPreviousPage}
677
+ historyError={history.historyError}
678
+ historyVersion={history.historyVersion}
679
+ lastReadSeq={readAtOpen.lastReadSeq}
680
+ live={live}
681
+ loadingPreviousPage={history.isLoadingPreviousPage}
682
+ newerHistory={history.newer}
683
+ onLoadPreviousPage={history.loadPreviousPage}
684
+ onRead={holdShown ? undefined : markRead}
685
+ onSelectedEventSeqChange={setSelectedEventSeq}
686
+ // Sends and the thinking row belong after the latest event.
687
+ pendingMessages={
688
+ history.newer ? undefined : detail.inlineMessages
689
+ }
690
+ pinRequestVersion={pinRequestVersion}
691
+ revealSeq={revealSeq}
692
+ responding={
693
+ !detail.error &&
694
+ !history.newer &&
695
+ conversationIsResponding(detail.data)
696
+ }
697
+ onOpenSubagentTranscript={onOpenSubagentTranscript}
698
+ search={search}
699
+ onSearchChange={setSearch}
700
+ selectedEventSeq={selectedEventSeq}
701
+ startAtLink={startAtLink}
702
+ startAtUnread={startAtUnread}
703
+ transcript={transcript}
704
+ turnContextPanel={turnContextPanel}
705
+ unread={holdShown ? undefined : readAtOpen.unread}
706
+ view={view}
707
+ />
708
+ </ConversationCatchUpContext>
636
709
  </ConversationMemoryContext>
637
710
  </>
638
711
  )}
@@ -695,29 +768,30 @@ export function ConversationPage(props: {
695
768
  // can hide them.
696
769
  if (view !== "rich") onViewChange("rich");
697
770
  setSearch("");
771
+ setRevealSeq(entry.seq);
698
772
  setMemoryJump({
699
773
  memoryId: entry.memory.id,
774
+ seq: entry.seq,
700
775
  source: entry.source,
701
776
  turnId: entry.turnId,
702
777
  });
703
778
  }}
704
- links={conversationLinks(detail.data)}
779
+ links={links}
705
780
  overview={
706
781
  hasConversationStats({
707
782
  conversation,
708
783
  detail: detail.data,
784
+ showUsageBreakdown,
709
785
  }) ? (
710
786
  <ConversationStats
711
787
  conversation={conversation}
712
788
  detail={detail.data}
789
+ showUsageBreakdown={showUsageBreakdown}
713
790
  />
714
791
  ) : null
715
792
  }
716
793
  usage={
717
- hasConversationUsageBreakdown({
718
- conversation,
719
- detail: detail.data,
720
- }) ? (
794
+ showUsageBreakdown ? (
721
795
  <ConversationUsageBreakdown
722
796
  conversation={conversation}
723
797
  detail={detail.data}
@@ -812,32 +886,20 @@ function committedMessageIds(
812
886
 
813
887
  const EMPTY_MESSAGE_IDS: readonly string[] = [];
814
888
 
815
- type HistorySearchDirection = "any" | "earlier" | "newer";
816
- type HistorySearch = "end" | "failed" | "load-earlier" | "load-newer" | "wait";
889
+ type ConversationLink = { event: number } | { turn: string };
817
890
 
818
- /**
819
- * The next step to find a row that the loaded pages do not have: load one
820
- * more page toward it, or wait for the page that loads. "any" looks at newer
821
- * pages first. A failed page stops the search, but the link stays, so a
822
- * retry can still find the row.
823
- */
824
- function nextHistorySearch(
825
- history: TranscriptHistory,
826
- direction: HistorySearchDirection,
827
- held: boolean,
828
- ): HistorySearch {
829
- if (held || history.isLoadingPreviousPage || history.newer?.loading) {
830
- return "wait";
831
- }
832
- if (direction !== "earlier" && history.newer && !history.newer.error) {
833
- return "load-newer";
834
- }
835
- if (
836
- direction !== "newer" &&
837
- history.hasPreviousPage &&
838
- !history.historyError
839
- ) {
840
- return "load-earlier";
841
- }
842
- return history.historyError || history.newer?.error ? "failed" : "end";
891
+ /** The page to open for a link, or nothing when `events` hold its row. */
892
+ function unheldLinkStart(
893
+ events: readonly ConversationReportEvent[] | undefined,
894
+ link: ConversationLink,
895
+ ): HistoryStart | undefined {
896
+ const seq =
897
+ "event" in link ? link.event : turnStartedEventSeq(events, link.turn);
898
+ const held =
899
+ seq !== undefined &&
900
+ events !== undefined &&
901
+ seq >= (events[0]?.seq ?? Infinity) &&
902
+ seq <= (events.at(-1)?.seq ?? -Infinity);
903
+ if (held) return undefined;
904
+ return "event" in link ? { event: String(link.event) } : { turn: link.turn };
843
905
  }