@sentry/junior-dashboard 0.279.0 → 0.280.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 (147) hide show
  1. package/dist/app.js +100 -31
  2. package/dist/client-manifest.json +1 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +100 -31
  5. package/dist/public/chunks/AdminPage-YEKZOKH6.js +1 -0
  6. package/dist/public/chunks/AutomationPage-5GS3IUAK.js +3 -0
  7. package/dist/public/chunks/AutomationRunsPage-IPYEZU4L.js +1 -0
  8. package/dist/public/chunks/AutomationsPage-HSAYPC3C.js +1 -0
  9. package/dist/public/chunks/CodePage-IPFKXWFB.js +1 -0
  10. package/dist/public/chunks/ConnectionsPage-5XUEK6FS.js +1 -0
  11. package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
  12. package/dist/public/chunks/LocationDetailPage-27ETUGR6.js +1 -0
  13. package/dist/public/chunks/LocationsPage-WOKMQAGM.js +1 -0
  14. package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
  15. package/dist/public/chunks/PeoplePage-6YQLDOUL.js +1 -0
  16. package/dist/public/chunks/PersonProfilePage-GYMAZFIK.js +1 -0
  17. package/dist/public/chunks/PersonalTokensPage-4BCR744B.js +1 -0
  18. package/dist/public/chunks/PluginUserPage-IP6WL7MD.js +1 -0
  19. package/dist/public/chunks/RepositoryPage-TRD44LQ3.js +1 -0
  20. package/dist/public/chunks/SettingsPage-4E3SLQ36.js +1 -0
  21. package/dist/public/chunks/SystemPage-C3XBKXKW.js +1 -0
  22. package/dist/public/chunks/WorkspaceFormPage-2LS2X67Q.js +2 -0
  23. package/dist/public/chunks/WorkspacesPage-5WB3CUTX.js +1 -0
  24. package/dist/public/chunks/chunk-2BCS5AQL.js +1 -0
  25. package/dist/public/chunks/chunk-3Y3ICT6K.js +1 -0
  26. package/dist/public/chunks/chunk-4AKUZW6R.js +1 -0
  27. package/dist/public/chunks/chunk-4UGZNVJM.js +1 -0
  28. package/dist/public/chunks/chunk-564V7YHG.js +1 -0
  29. package/dist/public/chunks/chunk-5GZDBGAR.js +1 -0
  30. package/dist/public/chunks/chunk-5ISD3QSC.js +1 -0
  31. package/dist/public/chunks/chunk-5LNGNJ3P.js +1 -0
  32. package/dist/public/chunks/chunk-5ZA674OU.js +1 -0
  33. package/dist/public/chunks/chunk-6FSJ3W2W.js +1 -0
  34. package/dist/public/chunks/chunk-6GQL3EVJ.js +1 -0
  35. package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
  36. package/dist/public/chunks/chunk-A2QWWA7O.js +41 -0
  37. package/dist/public/chunks/chunk-AYXSBMWA.js +1 -0
  38. package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -0
  39. package/dist/public/chunks/chunk-B6HGRBBX.js +1 -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-DZB4LPI6.js +1 -0
  44. package/dist/public/chunks/chunk-EFEREKX4.js +1 -0
  45. package/dist/public/chunks/chunk-FE522UEA.js +1 -0
  46. package/dist/public/chunks/chunk-FVX42XAD.js +1 -0
  47. package/dist/public/chunks/chunk-G5NWYJTV.js +1 -0
  48. package/dist/public/chunks/chunk-GMZIELFU.js +1 -0
  49. package/dist/public/chunks/chunk-GO45RQDL.js +1 -0
  50. package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
  51. package/dist/public/chunks/chunk-GRMX2HX6.js +1 -0
  52. package/dist/public/chunks/chunk-GYRM5ZIV.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-IA6BJDKN.js +1 -0
  57. package/dist/public/chunks/chunk-IZWTFLCC.js +1 -0
  58. package/dist/public/chunks/chunk-J37MXUED.js +1 -0
  59. package/dist/public/chunks/chunk-JAZABOBA.js +0 -0
  60. package/dist/public/chunks/chunk-L2H3ZWZN.js +1 -0
  61. package/dist/public/chunks/chunk-LBELRO5P.js +1 -0
  62. package/dist/public/chunks/chunk-LKW7MULF.js +1 -0
  63. package/dist/public/chunks/chunk-MEHLQZDG.js +3 -0
  64. package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
  65. package/dist/public/chunks/chunk-N4AONKD3.js +1 -0
  66. package/dist/public/chunks/chunk-NQMDED2R.js +2 -0
  67. package/dist/public/chunks/chunk-NQOJAZFQ.js +1 -0
  68. package/dist/public/chunks/chunk-PEQNT5MO.js +1 -0
  69. package/dist/public/chunks/chunk-PQPAPPEI.js +1 -0
  70. package/dist/public/chunks/chunk-PXHLJIOT.js +1 -0
  71. package/dist/public/chunks/chunk-QNA7F7QB.js +1 -0
  72. package/dist/public/chunks/chunk-QOX5RMLM.js +1 -0
  73. package/dist/public/chunks/chunk-QPBLZSNX.js +1 -0
  74. package/dist/public/chunks/chunk-R4ORRE77.js +1 -0
  75. package/dist/public/chunks/chunk-RHIXHBS5.js +1 -0
  76. package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
  77. package/dist/public/chunks/chunk-SDKV3OID.js +1 -0
  78. package/dist/public/chunks/chunk-SIOW7OOW.js +1 -0
  79. package/dist/public/chunks/chunk-SUX4QBVF.js +1 -0
  80. package/dist/public/chunks/chunk-T4M53QQT.js +1 -0
  81. package/dist/public/chunks/chunk-TFJOEF3Q.js +1 -0
  82. package/dist/public/chunks/chunk-UAH6DWNS.js +1 -0
  83. package/dist/public/chunks/chunk-UJD3MBMB.js +1 -0
  84. package/dist/public/chunks/chunk-UK2V4UBT.js +1 -0
  85. package/dist/public/chunks/chunk-UT6HZPGL.js +1 -0
  86. package/dist/public/chunks/chunk-UZ44J2LH.js +1 -0
  87. package/dist/public/chunks/chunk-V5UDSIRA.js +63 -0
  88. package/dist/public/chunks/chunk-VC6IS564.js +1 -0
  89. package/dist/public/chunks/chunk-VFEBNOPX.js +1 -0
  90. package/dist/public/chunks/chunk-VXT3FIVL.js +55 -0
  91. package/dist/public/chunks/chunk-WJ3OXMVX.js +1 -0
  92. package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
  93. package/dist/public/chunks/chunk-WZVO3HG4.js +1 -0
  94. package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
  95. package/dist/public/chunks/chunk-YPMUHXYT.js +147 -0
  96. package/dist/public/chunks/chunk-YYNMZ4WC.js +1 -0
  97. package/dist/public/chunks/chunk-Z2JPRGFT.js +1 -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-TYC7JKRE.js +91 -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/Drawer.tsx +12 -71
  110. package/src/client/components/DrawerContent.tsx +76 -0
  111. package/src/client/components/ProfileMenu.tsx +12 -3
  112. package/src/client/components/UserInputForm.tsx +28 -5
  113. package/src/client/conversations/ConversationCatchUp.tsx +107 -0
  114. package/src/client/conversations/ConversationContextTags.tsx +110 -0
  115. package/src/client/conversations/ConversationEventLog.tsx +213 -71
  116. package/src/client/conversations/ConversationHeader.tsx +35 -41
  117. package/src/client/conversations/ConversationHeaderActions.tsx +34 -38
  118. package/src/client/conversations/ConversationHomeList.tsx +41 -3
  119. package/src/client/conversations/ConversationMeta.tsx +6 -12
  120. package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
  121. package/src/client/conversations/ConversationPage.tsx +188 -150
  122. package/src/client/conversations/ShareConversationButton.tsx +5 -50
  123. package/src/client/conversations/SlackMark.tsx +16 -4
  124. package/src/client/conversations/TranscriptRows.tsx +24 -11
  125. package/src/client/conversations/TranscriptView.tsx +21 -13
  126. package/src/client/conversations/conversationArchive.ts +22 -0
  127. package/src/client/conversations/conversationCatchUpState.ts +96 -0
  128. package/src/client/conversations/conversationMemory.ts +8 -1
  129. package/src/client/conversations/queries.ts +11 -3
  130. package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
  131. package/src/client/conversations/transcriptBottomPinning.ts +27 -3
  132. package/src/client/conversations/transcriptReadPosition.ts +24 -2
  133. package/src/client/conversations/turnContext.ts +1 -1
  134. package/src/client/pages/admin/AdminPage.tsx +1 -1
  135. package/src/client/pages/admin/ServiceAccountsSection.tsx +1 -1
  136. package/src/client/pages/memory/memoryDashboard.ts +1 -1
  137. package/src/client/pages/memory/memoryRecord.ts +1 -1
  138. package/src/client/pages/settings/ConnectionsPage.tsx +1 -1
  139. package/src/client/pages/user/PluginUserPage.tsx +0 -6
  140. package/src/client/pages/user/pluginUserPageRoutes.ts +5 -0
  141. package/src/client/primaryNav.ts +1 -1
  142. package/src/client/review-desk/reviewItemFormat.ts +1 -1
  143. package/src/mock-reporting/fixtures.ts +43 -0
  144. package/src/mock-reporting/routes.ts +40 -3
  145. package/src/public-assets.ts +28 -20
  146. package/src/shell.ts +12 -2
  147. package/dist/public/client-ZCZXZBTL.js +0 -460
@@ -1,25 +1,32 @@
1
1
  import {
2
2
  useCallback,
3
3
  useEffect,
4
+ useEffectEvent,
4
5
  useMemo,
5
6
  useRef,
6
7
  useState,
7
8
  useSyncExternalStore,
8
9
  type ReactNode,
9
10
  } from "react";
10
- import { useSearchParams } from "react-router";
11
+ import { useNavigate, useSearchParams } from "react-router";
11
12
  import type {
12
13
  ConversationDetailReport,
13
14
  ConversationFeed,
15
+ ConversationReportEvent,
14
16
  } from "@sentry/junior/api/schema";
15
17
 
16
18
  import type { ConversationData } from "./queries";
17
19
  import {
18
20
  useArchiveConversation,
21
+ type ArchivedFromConversationState,
19
22
  type PendingArchiveConversationUpdate,
20
23
  } from "./conversationArchive";
21
24
  import { usePinConversation } from "./usePinConversation";
22
25
  import { conversationIsResponding } from "./transcript";
26
+ import {
27
+ ConversationCatchUpContext,
28
+ useConversationCatchUp,
29
+ } from "./conversationCatchUpState";
23
30
  import { buildConversationMarkdown } from "../markdownExport";
24
31
  import { CopyButton } from "../components/CopyButton";
25
32
  import {
@@ -39,6 +46,7 @@ import {
39
46
  memoryAnchorKey,
40
47
  useConversationMemories,
41
48
  type ConversationMemoryState,
49
+ type MemoryJump,
42
50
  type OpenMemoryPopover,
43
51
  } from "./conversationMemory";
44
52
  import { ConversationEventDetails } from "./ConversationEventDetails";
@@ -57,14 +65,13 @@ import {
57
65
  import { cn } from "../styles";
58
66
  import { ConversationForkLinks } from "./ConversationForkLinks";
59
67
  import { ConversationAgentTree } from "./ConversationAgentTree";
68
+ import { conversationContextTags } from "./ConversationContextTags";
60
69
  import { conversationLinks } from "./ConversationLinkedWork";
61
70
  import {
62
71
  ConversationAnnotations,
63
72
  conversationCost,
64
- conversationLocation,
65
73
  ConversationPrivacyChip,
66
74
  ConversationStats,
67
- hasConversationAnnotations,
68
75
  hasConversationStats,
69
76
  } from "./ConversationMeta";
70
77
  import {
@@ -100,9 +107,10 @@ import {
100
107
  import { TranscriptLoading } from "./TranscriptLoading";
101
108
  import { recordTranscriptShown } from "../sentry";
102
109
  import {
103
- useUnreadHistory,
110
+ useHistoryAround,
111
+ type HistoryStart,
104
112
  type TranscriptHistory,
105
- } from "./transcriptUnreadHistory";
113
+ } from "./transcriptAroundHistory";
106
114
  import type { TranscriptViewMode } from "./transcriptRenderModel";
107
115
  import {
108
116
  SubagentTranscriptDrawer,
@@ -184,7 +192,7 @@ export function ConversationPage(props: {
184
192
  const [turnContext, setTurnContext] = useState<TurnContextSelection>();
185
193
  const [memoryPopover, setMemoryPopover] = useState<OpenMemoryPopover>();
186
194
  // A memory chosen in details waits here until its transcript row renders.
187
- const [memoryJump, setMemoryJump] = useState<OpenMemoryPopover>();
195
+ const [memoryJump, setMemoryJump] = useState<MemoryJump>();
188
196
  // The page stays mounted across Conversations. Memory state belongs to one.
189
197
  const [memoryConversationId, setMemoryConversationId] = useState(
190
198
  props.conversationId,
@@ -214,11 +222,33 @@ export function ConversationPage(props: {
214
222
  const [startAtUnread] = useState(
215
223
  () => readConversationStartPosition() === "unread",
216
224
  );
225
+ // A link at open shows its row, so the transcript does not open at the
226
+ // latest message or the read position, unless the link fails first.
227
+ const [startAtLink, setStartAtLink] = useState(
228
+ () => selectedEventSeq !== undefined || linkedTurnId !== undefined,
229
+ );
217
230
  const detail = props.detail;
218
231
  // Jump to latest, a send, or the mobile keyboard skips the newer pages for
219
232
  // the rest of the visit. It can come before the unread page loads.
220
233
  const [showLatest, setShowLatest] = useState(false);
221
- const opensAtUnread = startAtUnread && !showLatest;
234
+ // A link at open wins over the read position. When the loaded rows hold
235
+ // the link, the transcript keeps them and does not page to the read
236
+ // position.
237
+ const opensAtUnread = startAtUnread && !showLatest && !startAtLink;
238
+ // A linked row that the transcript does not hold. The transcript opens at
239
+ // the page around it until the reader jumps to the latest. A link at open
240
+ // starts there, so the first render reads no other page.
241
+ const [linkStart, setLinkStart] = useState(() =>
242
+ linkedTurnId !== undefined
243
+ ? unheldLinkStart(detail.data?.events, { turn: linkedTurnId })
244
+ : selectedEventSeq === undefined
245
+ ? undefined
246
+ : unheldLinkStart(detail.data?.events, { event: selectedEventSeq }),
247
+ );
248
+ const showLatestPage = useCallback(() => {
249
+ setShowLatest(true);
250
+ setLinkStart(undefined);
251
+ }, []);
222
252
  const memoryList = useConversationMemories(conversationId).data;
223
253
  const memoryState = useMemo<ConversationMemoryState>(
224
254
  () => ({
@@ -229,41 +259,78 @@ export function ConversationPage(props: {
229
259
  [memoryList, memoryPopover],
230
260
  );
231
261
  const archive = useArchiveConversation(conversationId);
262
+ const navigate = useNavigate();
232
263
  const pin = usePinConversation(conversationId);
233
264
  const markRead = useMarkConversationRead(conversationId);
234
265
  const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
235
- const unreadHistory = useUnreadHistory({
266
+ const dropLink = useCallback(() => {
267
+ setLinkStart(undefined);
268
+ setStartAtLink(false);
269
+ setMemoryJump(undefined);
270
+ setSearchParams(
271
+ (current) => {
272
+ const next = new URLSearchParams(current);
273
+ next.delete(CONVERSATION_EVENT_PARAM);
274
+ next.delete(CONVERSATION_TURN_PARAM);
275
+ return next;
276
+ },
277
+ { replace: true },
278
+ );
279
+ }, [setSearchParams]);
280
+ const unreadCursor = opensAtUnread ? readAtOpen.unreadCursor : undefined;
281
+ const aroundHistory = useHistoryAround({
236
282
  conversationId,
237
283
  // Cached earlier pages from a past visit would look like newer events.
238
284
  detail: detail.latest,
239
- onShowLatest: () => setShowLatest(true),
240
- unreadCursor: opensAtUnread ? readAtOpen.unreadCursor : undefined,
241
- unreadPage: readAtOpen.unreadPage,
285
+ onShowLatest: showLatestPage,
286
+ // A link that fails to load is dropped. The transcript goes back to
287
+ // where it was.
288
+ ...(linkStart
289
+ ? { onStartFailed: dropLink, start: linkStart }
290
+ : unreadCursor
291
+ ? {
292
+ start: { around: unreadCursor },
293
+ ...(readAtOpen.unreadPage
294
+ ? { startPage: readAtOpen.unreadPage }
295
+ : undefined),
296
+ }
297
+ : undefined),
242
298
  });
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)),
299
+ // The transcript on screen, so it can stay while the next one loads. The
300
+ // page is keyed by Conversation, so at open this is the cached copy.
301
+ const shownRef = useRef(detail.data);
302
+ // A reader far behind sees the events from their read position, and a
303
+ // link shows the events around its row. Others see the latest page and
304
+ // the pages before it. The transcript on screen stays until this visit's
305
+ // fetch tells where to open and the page around that row loads. It loads
306
+ // no earlier pages and shows no marker: at open, both would come from the
307
+ // cached read position. It reports no reads, because it can sit at rows
308
+ // the reader has not read yet. Then the transcript opens at the row, if
309
+ // the reader has not moved.
310
+ const holdShown = Boolean(
311
+ shownRef.current &&
312
+ (aroundHistory?.isPending ||
313
+ (opensAtUnread && !linkStart && !detail.fetchedAtOpen)),
255
314
  );
256
- const history: TranscriptHistory = holdCachedTranscript
315
+ const transcriptPending =
316
+ detail.isPending || Boolean(aroundHistory?.isPending && !shownRef.current);
317
+ const history: TranscriptHistory = holdShown
257
318
  ? {
258
319
  ...detail,
259
- data: cachedAtOpen,
320
+ data: shownRef.current,
260
321
  hasPreviousPage: false,
261
322
  isLoadingPreviousPage: false,
262
323
  loadPreviousPage: () => {},
263
324
  }
264
- : (unreadHistory ?? detail);
265
- const transcriptPending =
266
- detail.isPending || Boolean(unreadHistory?.isPending && !cachedAtOpen);
325
+ : (aroundHistory ?? detail);
326
+ if (history.data) shownRef.current = history.data;
327
+ const catchUp = useConversationCatchUp({
328
+ conversationId,
329
+ events: history.data?.events,
330
+ hasNewerPage: Boolean(history.newer),
331
+ hasPreviousPage: history.hasPreviousPage,
332
+ unread: holdShown ? undefined : readAtOpen.unread,
333
+ });
267
334
  const feedConversation = conversations.find(
268
335
  (item) => item.id === conversationId,
269
336
  );
@@ -277,6 +344,7 @@ export function ConversationPage(props: {
277
344
  <ParticipantAvatarStack participants={participants} size="detail" />
278
345
  ) : null;
279
346
  const conversationDetail = detail.data;
347
+ const links = conversationLinks(conversationDetail);
280
348
  const title =
281
349
  conversationDetailTitle(detail.data) ??
282
350
  conversationDisplayTitle(conversation);
@@ -342,53 +410,29 @@ export function ConversationPage(props: {
342
410
  },
343
411
  [eventOpen, setSelectedEventSeq],
344
412
  );
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.
413
+ // A linked event, Turn, or memory can be outside the loaded rows. Then the
414
+ // transcript opens at the page around it, which the server finds by the
415
+ // event seq or Turn id. Only a new link does this, so a reader who jumps to
416
+ // the latest stays there. Returns false when the loaded rows hold the link.
417
+ const openLink = useEffectEvent((link: ConversationLink) => {
418
+ const start = unheldLinkStart(history.data?.events, link);
419
+ if (start) setLinkStart(start);
420
+ return Boolean(start);
421
+ });
422
+ useEffect(() => {
423
+ if (linkedTurnId !== undefined) openLink({ turn: linkedTurnId });
424
+ else if (selectedEventSeq !== undefined) {
425
+ openLink({ event: selectedEventSeq });
426
+ }
427
+ }, [linkedTurnId, selectedEventSeq]);
428
+ // Replace a found Turn link with a link to its first event.
370
429
  const linkedTurnSeq = linkedTurnId
371
430
  ? turnStartedEventSeq(transcript?.events, linkedTurnId)
372
431
  : 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
432
  useEffect(() => {
385
433
  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;
434
+ }, [linkedTurnSeq, setSelectedEventSeq]);
435
+ // Show a chosen memory at its transcript row, once the row renders.
392
436
  useEffect(() => {
393
437
  if (!memoryJump || !transcript) return;
394
438
  const anchor = pageRef.current?.querySelector(
@@ -398,22 +442,17 @@ export function ConversationPage(props: {
398
442
  anchor.scrollIntoView({ block: "center" });
399
443
  setMemoryPopover(memoryJump);
400
444
  setMemoryJump(undefined);
401
- } else if (memorySearch === "failed" || memorySearch === "end") {
445
+ } else if (!holdShown && !openLink({ event: memoryJump.seq })) {
446
+ // The loaded rows hold the event, but no row shows the memory.
402
447
  setMemoryJump(undefined);
403
- } else {
404
- loadToward(memorySearch);
405
448
  }
406
- }, [loadToward, memoryJump, memorySearch, transcript, view]);
449
+ }, [holdShown, memoryJump, transcript, view]);
407
450
  // Keep the panel while the transcript or a linked event loads, and for the
408
451
  // render before a found Turn link becomes an event link.
409
452
  const eventLoading =
410
453
  eventOpen &&
411
454
  !selectedEvent &&
412
- (transcriptPending ||
413
- linkedTurnSeq !== undefined ||
414
- linkedSearch === "load-earlier" ||
415
- linkedSearch === "load-newer" ||
416
- linkedSearch === "wait");
455
+ (transcriptPending || holdShown || linkedTurnId !== undefined);
417
456
  const showEvent = Boolean(selectedEvent) || eventLoading;
418
457
  // Step through the same filtered rows that the event log shows.
419
458
  const events = transcript?.events;
@@ -436,9 +475,9 @@ export function ConversationPage(props: {
436
475
  [turnContext?.id],
437
476
  );
438
477
  const requestPin = useCallback(() => {
439
- setShowLatest(true);
478
+ showLatestPage();
440
479
  setPinRequestVersion((version) => version + 1);
441
- }, []);
480
+ }, [showLatestPage]);
442
481
  const onOpenSubagentTranscript = useCallback(
443
482
  ({
444
483
  part,
@@ -489,8 +528,9 @@ export function ConversationPage(props: {
489
528
  agentTree={
490
529
  agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
491
530
  }
531
+ contextTags={conversationContextTags(conversation)}
492
532
  conversationId={conversationId}
493
- cost={conversationCost(conversation, detail.data, "below")}
533
+ cost={conversationCost(conversation, conversationDetail, "below")}
494
534
  detailsOpen={showDetails}
495
535
  onDetailsClick={() => {
496
536
  if (eventOpen) setSelectedEventSeq(undefined);
@@ -529,9 +569,7 @@ export function ConversationPage(props: {
529
569
  />
530
570
  }
531
571
  linkedWork={
532
- hasConversationAnnotations(detail.data?.annotations) ? (
533
- <ConversationAnnotations detail={detail.data} />
534
- ) : null
572
+ links.length ? <ConversationAnnotations links={links} /> : null
535
573
  }
536
574
  pin={{
537
575
  // The API lets only participants pin, the same as archive.
@@ -545,15 +583,25 @@ export function ConversationPage(props: {
545
583
  archived: Boolean(conversation?.archivedAt),
546
584
  disabled: !conversation || archive.isPending,
547
585
  error: Boolean(archive.error),
548
- onClick: () =>
586
+ onClick: () => {
587
+ const archived = !conversation!.archivedAt;
549
588
  archive.mutate({
550
- archived: !conversation?.archivedAt,
589
+ archived,
551
590
  lastSeenAt: conversation!.lastSeenAt,
552
- }),
591
+ });
592
+ // Archiving hides this conversation, so go home at once.
593
+ // The mutation keeps running after this page unmounts.
594
+ if (archived) {
595
+ navigate("/", {
596
+ state: {
597
+ archivedConversation: conversation!,
598
+ } satisfies ArchivedFromConversationState,
599
+ });
600
+ }
601
+ },
553
602
  pending: archive.isPending,
554
603
  }}
555
604
  live={live}
556
- location={conversationLocation(conversation, detail.data)}
557
605
  onSearchChange={setSearch}
558
606
  onViewChange={onViewChange}
559
607
  privacy={
@@ -600,39 +648,40 @@ export function ConversationPage(props: {
600
648
  </div>
601
649
  ) : null}
602
650
  <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
- />
651
+ <ConversationCatchUpContext value={catchUp}>
652
+ <Transcript
653
+ hasPreviousPage={history.hasPreviousPage}
654
+ historyError={history.historyError}
655
+ historyVersion={history.historyVersion}
656
+ lastReadSeq={readAtOpen.lastReadSeq}
657
+ live={live}
658
+ loadingPreviousPage={history.isLoadingPreviousPage}
659
+ newerHistory={history.newer}
660
+ onLoadPreviousPage={history.loadPreviousPage}
661
+ onRead={holdShown ? undefined : markRead}
662
+ onSelectedEventSeqChange={setSelectedEventSeq}
663
+ // Sends and the thinking row belong after the latest event.
664
+ pendingMessages={
665
+ history.newer ? undefined : detail.inlineMessages
666
+ }
667
+ pinRequestVersion={pinRequestVersion}
668
+ responding={
669
+ !detail.error &&
670
+ !history.newer &&
671
+ conversationIsResponding(detail.data)
672
+ }
673
+ onOpenSubagentTranscript={onOpenSubagentTranscript}
674
+ search={search}
675
+ onSearchChange={setSearch}
676
+ selectedEventSeq={selectedEventSeq}
677
+ startAtLink={startAtLink}
678
+ startAtUnread={startAtUnread}
679
+ transcript={transcript}
680
+ turnContextPanel={turnContextPanel}
681
+ unread={holdShown ? undefined : readAtOpen.unread}
682
+ view={view}
683
+ />
684
+ </ConversationCatchUpContext>
636
685
  </ConversationMemoryContext>
637
686
  </>
638
687
  )}
@@ -697,11 +746,12 @@ export function ConversationPage(props: {
697
746
  setSearch("");
698
747
  setMemoryJump({
699
748
  memoryId: entry.memory.id,
749
+ seq: entry.seq,
700
750
  source: entry.source,
701
751
  turnId: entry.turnId,
702
752
  });
703
753
  }}
704
- links={conversationLinks(detail.data)}
754
+ links={links}
705
755
  overview={
706
756
  hasConversationStats({
707
757
  conversation,
@@ -812,32 +862,20 @@ function committedMessageIds(
812
862
 
813
863
  const EMPTY_MESSAGE_IDS: readonly string[] = [];
814
864
 
815
- type HistorySearchDirection = "any" | "earlier" | "newer";
816
- type HistorySearch = "end" | "failed" | "load-earlier" | "load-newer" | "wait";
865
+ type ConversationLink = { event: number } | { turn: string };
817
866
 
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";
867
+ /** The page to open for a link, or nothing when `events` hold its row. */
868
+ function unheldLinkStart(
869
+ events: readonly ConversationReportEvent[] | undefined,
870
+ link: ConversationLink,
871
+ ): HistoryStart | undefined {
872
+ const seq =
873
+ "event" in link ? link.event : turnStartedEventSeq(events, link.turn);
874
+ const held =
875
+ seq !== undefined &&
876
+ events !== undefined &&
877
+ seq >= (events[0]?.seq ?? Infinity) &&
878
+ seq <= (events.at(-1)?.seq ?? -Infinity);
879
+ if (held) return undefined;
880
+ return "event" in link ? { event: String(link.event) } : { turn: link.turn };
843
881
  }
@@ -3,18 +3,10 @@ import { useHref } from "react-router";
3
3
  import { Check, Share2 } from "lucide-react";
4
4
 
5
5
  import { Button } from "../components/ui/button";
6
- import {
7
- Tooltip,
8
- TooltipContent,
9
- TooltipTrigger,
10
- } from "../components/ui/tooltip";
11
6
  import { conversationPath } from "./conversationRoutes";
12
7
 
13
8
  /** Share a conversation link without changing who can access it. */
14
- export function ShareConversationButton(props: {
15
- conversationId: string;
16
- layout?: "button" | "icon" | "menu";
17
- }) {
9
+ export function ShareConversationButton(props: { conversationId: string }) {
18
10
  const href = useHref(conversationPath(props.conversationId));
19
11
  const [status, setStatus] = useState<
20
12
  "idle" | "sharing" | "copied" | "copy-failed" | "share-failed"
@@ -68,55 +60,18 @@ export function ShareConversationButton(props: {
68
60
  }
69
61
  }
70
62
 
71
- const content = (
63
+ return (
72
64
  <>
73
- <Icon aria-hidden="true" size={16} strokeWidth={2} />
74
- <span>{label}</span>
75
- </>
76
- );
77
- const button =
78
- props.layout === "icon" ? (
79
- <Tooltip>
80
- <TooltipTrigger
81
- render={
82
- <Button
83
- focusableWhenDisabled
84
- aria-label={label}
85
- className="text-dashboard-text-muted"
86
- disabled={status === "sharing"}
87
- onClick={() => void shareLink()}
88
- size="icon"
89
- >
90
- <Icon aria-hidden="true" size={15} />
91
- </Button>
92
- }
93
- />
94
- <TooltipContent size="compact" side="bottom">
95
- {label}
96
- </TooltipContent>
97
- </Tooltip>
98
- ) : props.layout === "menu" ? (
99
65
  <Button
100
66
  aria-label={label}
101
67
  disabled={status === "sharing"}
68
+ focusableWhenDisabled
102
69
  onClick={() => void shareLink()}
103
70
  size="menu"
104
71
  >
105
- {content}
106
- </Button>
107
- ) : (
108
- <Button
109
- aria-label={label}
110
- disabled={status === "sharing"}
111
- onClick={() => void shareLink()}
112
- >
113
- {content}
72
+ <Icon aria-hidden="true" size={16} strokeWidth={2} />
73
+ <span>{label}</span>
114
74
  </Button>
115
- );
116
-
117
- return (
118
- <>
119
- {button}
120
75
  <span className="sr-only" role="status">
121
76
  {feedback}
122
77
  </span>
@@ -9,10 +9,22 @@ export function SlackMark(props: { className?: string }) {
9
9
  viewBox="0 0 127 127"
10
10
  >
11
11
  {/* Keep the geometry from packages/docs/public/integrations/slack.svg. */}
12
- <path d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z" />
13
- <path d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z" />
14
- <path d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z" />
15
- <path d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z" />
12
+ <path
13
+ className="[--slack-mark-color:#E01E5A]"
14
+ d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
15
+ />
16
+ <path
17
+ className="[--slack-mark-color:#36C5F0]"
18
+ d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
19
+ />
20
+ <path
21
+ className="[--slack-mark-color:#2EB67D]"
22
+ d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
23
+ />
24
+ <path
25
+ className="[--slack-mark-color:#ECB22E]"
26
+ d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
27
+ />
16
28
  </svg>
17
29
  );
18
30
  }