@sentry/junior-dashboard 0.277.0 → 0.278.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 (45) hide show
  1. package/dist/app.js +30 -41
  2. package/dist/client.js +90 -90
  3. package/dist/index.js +30 -41
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +1 -1
  7. package/src/client/App.tsx +9 -4
  8. package/src/client/components/DetailsPanel.tsx +1 -1
  9. package/src/client/components/ui/resizable.tsx +50 -0
  10. package/src/client/conversations/AutoArchiveToggle.tsx +50 -14
  11. package/src/client/conversations/ConversationHeader.tsx +6 -2
  12. package/src/client/conversations/ConversationHeaderActions.tsx +4 -0
  13. package/src/client/conversations/ConversationMeta.tsx +2 -7
  14. package/src/client/conversations/ConversationPage.tsx +155 -72
  15. package/src/client/conversations/ConversationSidebar.tsx +2 -1
  16. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  17. package/src/client/conversations/ConversationWorkspace.tsx +129 -41
  18. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  19. package/src/client/conversations/EventDetails.tsx +2 -62
  20. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  21. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  22. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  23. package/src/client/conversations/TranscriptView.tsx +89 -20
  24. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  25. package/src/client/conversations/eventLog.ts +2 -31
  26. package/src/client/conversations/eventTranscript.ts +9 -42
  27. package/src/client/conversations/queries.ts +76 -15
  28. package/src/client/conversations/transcript.ts +46 -16
  29. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  30. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  31. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  32. package/src/client/conversations/transcriptSearch.tsx +3 -11
  33. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  34. package/src/client/http.ts +5 -5
  35. package/src/client/markdownExport.ts +32 -42
  36. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  37. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  38. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  39. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  40. package/src/client/pages/memory/memoryRecord.ts +1 -6
  41. package/src/client/types.ts +18 -33
  42. package/src/conversation-cache.ts +6 -8
  43. package/src/mock-reporting/fixtures.ts +23 -33
  44. package/src/mock-reporting/routes.ts +3 -1
  45. package/src/tailwind.css +30 -0
@@ -51,9 +51,6 @@ function specialToolIds(events: ConversationReportEvent[]): Set<string> {
51
51
  const ids = new Set<string>();
52
52
  for (const event of events) {
53
53
  const data = event.data;
54
- if (data.type === "handoff" && data.triggeringToolCallId) {
55
- ids.add(data.triggeringToolCallId);
56
- }
57
54
  if (data.type === "subagent" && data.parentToolCallId) {
58
55
  ids.add(data.parentToolCallId);
59
56
  }
@@ -243,6 +240,7 @@ export function transcriptMessagesFromEvents(
243
240
  ...(data.explicitMention !== undefined
244
241
  ? { explicitMention: data.explicitMention }
245
242
  : undefined),
243
+ ...(data.quiet ? { quiet: data.quiet } : undefined),
246
244
  ...(data.reviewItemChanges
247
245
  ? { reviewItemChanges: data.reviewItemChanges }
248
246
  : undefined),
@@ -308,22 +306,6 @@ export function transcriptMessagesFromEvents(
308
306
  continue;
309
307
  }
310
308
 
311
- if (data.type === "turn_routed") {
312
- const message = latestUserMessageByTurn.get(data.turnId);
313
- if (message) {
314
- message.route = {
315
- modelProfile: data.modelProfile,
316
- modelId: data.modelId,
317
- reasoningLevel: data.reasoningLevel,
318
- ...(data.confidence !== undefined
319
- ? { confidence: data.confidence }
320
- : undefined),
321
- source: data.source,
322
- };
323
- }
324
- continue;
325
- }
326
-
327
309
  if (data.type === "turn_context") {
328
310
  const message = latestUserMessageByTurn.get(data.turnId);
329
311
  if (message) {
@@ -437,34 +419,19 @@ export function transcriptMessagesFromEvents(
437
419
  continue;
438
420
  }
439
421
 
440
- if (data.type === "compaction" || data.type === "handoff") {
422
+ if (data.type === "compaction") {
441
423
  messages.push(
442
424
  eventMessage(event, "system", [
443
425
  {
444
426
  type: "context_event",
445
427
  ...(event.mentions ? { mentions: event.mentions } : undefined),
446
- event:
447
- data.type === "handoff"
448
- ? {
449
- type: data.type,
450
- createdAt: event.createdAt,
451
- modelId: data.modelId,
452
- modelProfile: data.modelProfile,
453
- ...(data.summary ? { summary: data.summary } : undefined),
454
- ...(data.reasoningLevel
455
- ? { reasoningLevel: data.reasoningLevel }
456
- : undefined),
457
- }
458
- : {
459
- type: data.type,
460
- createdAt: event.createdAt,
461
- ...(data.modelId ? { modelId: data.modelId } : undefined),
462
- ...(data.modelProfile
463
- ? { modelProfile: data.modelProfile }
464
- : undefined),
465
- ...(data.summary ? { summary: data.summary } : undefined),
466
- ...(data.details ? { details: data.details } : undefined),
467
- },
428
+ event: {
429
+ type: data.type,
430
+ createdAt: event.createdAt,
431
+ ...(data.modelId ? { modelId: data.modelId } : undefined),
432
+ ...(data.summary ? { summary: data.summary } : undefined),
433
+ ...(data.details ? { details: data.details } : undefined),
434
+ },
468
435
  },
469
436
  ]),
470
437
  );
@@ -1,4 +1,4 @@
1
- import { useDeferredValue, useEffect, useMemo, useRef } from "react";
1
+ import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  type QueryClient,
4
4
  queryOptions,
@@ -16,6 +16,7 @@ import type {
16
16
  } from "@sentry/junior/api/schema";
17
17
  import {
18
18
  acceptedConversationMessageSchema,
19
+ CONVERSATION_DETAIL_VERSION_HEADER,
19
20
  webMessageId,
20
21
  cancelConversationPendingMessagesResponseSchema,
21
22
  conversationDetailReportSchema,
@@ -80,9 +81,15 @@ async function readConversationSnapshot(
80
81
  conversationId: string,
81
82
  signal?: AbortSignal,
82
83
  previous?: ConversationSnapshot,
84
+ unreadPage?: boolean,
83
85
  ): Promise<ConversationSnapshot> {
84
86
  const mailbox = await readConversationPendingMessages(conversationId, signal);
85
- const detail = await readConversationData(conversationId, signal, previous);
87
+ const detail = await readConversationData(
88
+ conversationId,
89
+ signal,
90
+ previous,
91
+ unreadPage,
92
+ );
86
93
  return { ...detail, mailbox };
87
94
  }
88
95
 
@@ -119,8 +126,13 @@ export function conversationDetailQueryOptions(
119
126
  awaitingMessage?: boolean;
120
127
  /** Poll fast until this time so a new send reaches history quickly. */
121
128
  fastPollUntilMs?: number;
129
+ /** Also read the page around the read position. */
130
+ unreadPage?: boolean;
122
131
  },
123
132
  ) {
133
+ // `unreadPage` adds a page to one fetch. The detail in the cache is the
134
+ // same, so it stays out of the key.
135
+ // oxlint-disable-next-line @tanstack/query/exhaustive-deps
124
136
  return queryOptions({
125
137
  enabled: Boolean(conversationId),
126
138
  queryKey: conversationDetailQueryKey(conversationId),
@@ -132,6 +144,7 @@ export function conversationDetailQueryOptions(
132
144
  conversationId!,
133
145
  signal,
134
146
  previous,
147
+ options?.unreadPage,
135
148
  );
136
149
  // Side reads do not poll. A new ETag means the Conversation changed,
137
150
  // so the details panel may be stale too.
@@ -170,7 +183,8 @@ export function conversationDetailQueryOptions(
170
183
 
171
184
  /**
172
185
  * Read positions only move forward. A poll that started before a read report
173
- * finished must not put an older position back in the cache.
186
+ * finished must not put an older position back in the cache, nor the unread
187
+ * cursor of that older position.
174
188
  */
175
189
  function keepLatestReadPosition(
176
190
  previous: ConversationSnapshot,
@@ -179,7 +193,8 @@ function keepLatestReadPosition(
179
193
  const kept = previous.readPosition;
180
194
  if (!kept) return next;
181
195
  if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
182
- return { ...next, readPosition: kept };
196
+ const { unreadCursor: _olderCursor, ...rest } = next;
197
+ return { ...rest, readPosition: kept };
183
198
  }
184
199
 
185
200
  /** Create one dashboard conversation and refresh the personal feed. */
@@ -481,9 +496,29 @@ export function useStopConversationTurn(conversationId: string) {
481
496
  });
482
497
  }
483
498
 
499
+ /** The open Conversation's data, from `useConversationData`. */
500
+ export type ConversationData = ReturnType<typeof useConversationData>;
501
+
484
502
  /** Fetch a bounded conversation snapshot and older pages on demand. */
485
- export function useConversationData(conversationId: string | undefined) {
503
+ export function useConversationData(
504
+ conversationId: string | undefined,
505
+ options?: {
506
+ /**
507
+ * Ask the first fetch of this visit for the page around the read
508
+ * position. Polls do not ask, so the server reads that page once.
509
+ */
510
+ unreadPageAtOpen?: boolean;
511
+ },
512
+ ) {
486
513
  const queryClient = useQueryClient();
514
+ // The shell keeps one hook across Conversations, so a new Conversation
515
+ // starts a new visit.
516
+ const [visit, setVisit] = useState({ conversationId, fetched: false });
517
+ if (visit.conversationId !== conversationId) {
518
+ setVisit({ conversationId, fetched: false });
519
+ }
520
+ const fetchedAtOpen =
521
+ visit.conversationId === conversationId && visit.fetched;
487
522
  const outbox = useQuery({
488
523
  enabled: Boolean(conversationId),
489
524
  // Local-only cache. Never replace optimistic rows with an empty fetch result.
@@ -521,11 +556,20 @@ export function useConversationData(conversationId: string | undefined) {
521
556
  fastPollUntilMs: latestSendAtMs
522
557
  ? latestSendAtMs + FAST_POLL_WINDOW_MS
523
558
  : undefined,
559
+ unreadPage: Boolean(options?.unreadPageAtOpen) && !fetchedAtOpen,
524
560
  }),
525
- // A reopened Conversation shows the cached copy at once, but it can miss
526
- // messages that arrived while the reader was away. Fetch it each visit.
527
- refetchOnMount: "always",
528
561
  });
562
+ // A reopened Conversation shows the cached copy at once, but it can miss
563
+ // messages that arrived while the reader was away. Fetch it once at the
564
+ // start of each visit, even when the cached copy is still fresh. A fetch
565
+ // that is already running counts.
566
+ const refetchDetail = detail.refetch;
567
+ useEffect(() => {
568
+ if (conversationId) void refetchDetail({ cancelRefetch: false });
569
+ }, [conversationId, refetchDetail]);
570
+ if (!fetchedAtOpen && detail.isFetchedAfterMount && !detail.isFetching) {
571
+ setVisit({ conversationId, fetched: true });
572
+ }
529
573
  // Defer the whole server snapshot, not history alone. Queue removal and the
530
574
  // matching transcript must paint together while composer input stays urgent.
531
575
  const deferredDetail = useDeferredValue(detail.data);
@@ -679,7 +723,6 @@ export function useConversationData(conversationId: string | undefined) {
679
723
  const isLoadingPreviousPage =
680
724
  history.isFetchingNextPage || historyNeedsReconciliation;
681
725
 
682
- const refetchDetail = detail.refetch;
683
726
  useEffect(() => {
684
727
  if (shouldRefreshDetail) void refetchDetail();
685
728
  }, [refetchDetail, shouldRefreshDetail]);
@@ -695,11 +738,18 @@ export function useConversationData(conversationId: string | undefined) {
695
738
 
696
739
  const fetchNextHistoryPage = history.fetchNextPage;
697
740
  useEffect(() => {
698
- if (historyNeedsReconciliation) void fetchNextHistoryPage();
741
+ if (historyNeedsReconciliation) {
742
+ void fetchNextHistoryPage({ cancelRefetch: false });
743
+ }
699
744
  }, [fetchNextHistoryPage, historyNeedsReconciliation]);
700
745
 
701
746
  return {
702
747
  data,
748
+ /**
749
+ * The latest page alone, without earlier pages. It is not deferred, so it
750
+ * matches `fetchedDetail` and the read position that came with it.
751
+ */
752
+ latest: detail.data,
703
753
  error: detail.error,
704
754
  historyError,
705
755
  historyVersion: conversationHistoryVersion(historyPages ?? []),
@@ -707,6 +757,8 @@ export function useConversationData(conversationId: string | undefined) {
707
757
  ? history.hasNextPage
708
758
  : Boolean(detailData?.previousCursor),
709
759
  isPending: detail.isPending,
760
+ /** The first fetch of this visit has ended. */
761
+ fetchedAtOpen,
710
762
  /** The latest detail after a fetch in this visit, before it is deferred. */
711
763
  fetchedDetail:
712
764
  detail.isFetchedAfterMount && !detail.isFetching
@@ -732,8 +784,10 @@ export function useConversationData(conversationId: string | undefined) {
732
784
  const hasPreviousPage = history.data
733
785
  ? history.hasNextPage
734
786
  : Boolean(detailData?.previousCursor);
735
- if (conversationId && hasPreviousPage && !history.isFetchingNextPage) {
736
- void history.fetchNextPage();
787
+ if (conversationId && hasPreviousPage) {
788
+ // A second call joins the read in flight. By default it cancels
789
+ // that read and starts the same read again.
790
+ void history.fetchNextPage({ cancelRefetch: false });
737
791
  }
738
792
  },
739
793
  };
@@ -744,15 +798,22 @@ export async function readConversationData(
744
798
  conversationId: string,
745
799
  signal?: AbortSignal,
746
800
  previous?: ConversationSnapshot,
801
+ unreadPage?: boolean,
747
802
  ): Promise<ConversationSnapshot> {
748
803
  const response = await readDashboardResponse(
749
- `/api/conversations/${encodeURIComponent(conversationId)}`,
804
+ `/api/conversations/${encodeURIComponent(conversationId)}${unreadPage ? "?unreadPage=true" : ""}`,
750
805
  signal,
751
- previous?.detailEtag,
806
+ previous?.detailEtag
807
+ ? { [CONVERSATION_DETAIL_VERSION_HEADER]: previous.detailEtag }
808
+ : undefined,
752
809
  );
753
810
  if (response.status === 304 && previous) return previous;
754
811
  const report = conversationDetailReportSchema.parse(await response.json());
755
- return { ...report, detailEtag: response.headers.get("etag") ?? undefined };
812
+ return {
813
+ ...report,
814
+ detailEtag:
815
+ response.headers.get(CONVERSATION_DETAIL_VERSION_HEADER) ?? undefined,
816
+ };
756
817
  }
757
818
 
758
819
  /** Read accepted mailbox messages that have not reached durable history yet. */
@@ -13,22 +13,8 @@ export function buildConversationTranscript(
13
13
  detail: ConversationDetailReport,
14
14
  historyPages: ConversationHistoryPage[],
15
15
  ): ConversationDetailReport {
16
- if (detail.eventHistory.status !== "available") {
17
- return withoutModelUsage(detail);
18
- }
19
-
20
- const restrictedHistory = historyPages.find(
21
- (page) => page.eventHistory.status !== "available",
22
- );
23
- if (restrictedHistory) {
24
- return withoutModelUsage({
25
- ...detail,
26
- eventHistory: restrictedHistory.eventHistory,
27
- events: restrictedHistory.events,
28
- previousCursor: restrictedHistory.previousCursor,
29
- });
30
- }
31
-
16
+ const restricted = restrictedTranscript(detail, historyPages);
17
+ if (restricted) return restricted;
32
18
  if (historyPages.length === 0) return detail;
33
19
 
34
20
  return {
@@ -45,6 +31,30 @@ export function buildConversationTranscript(
45
31
  };
46
32
  }
47
33
 
34
+ /**
35
+ * Build a transcript that opens at the read position.
36
+ *
37
+ * `pages` hold one unbroken range of events around the read position. The
38
+ * caller supplies `latestEvents` only when `pages` reach them, so the
39
+ * transcript never shows a gap between the two.
40
+ */
41
+ export function buildUnreadConversationTranscript(
42
+ detail: ConversationDetailReport,
43
+ pages: ConversationEventPage[],
44
+ latestEvents: ConversationReportEvent[],
45
+ ): ConversationDetailReport {
46
+ const restricted = restrictedTranscript(detail, pages);
47
+ if (restricted) return restricted;
48
+ return {
49
+ ...detail,
50
+ events: orderedEvents([
51
+ ...pages.flatMap((page) => page.events),
52
+ ...latestEvents,
53
+ ]),
54
+ previousCursor: oldestPreviousCursor(pages),
55
+ };
56
+ }
57
+
48
58
  /** Materialize one immutable detail snapshot through its exact history cursor chain. */
49
59
  export async function loadCompleteConversationTranscript(args: {
50
60
  detail: ConversationDetailReport;
@@ -198,6 +208,26 @@ function orderedEvents(
198
208
  );
199
209
  }
200
210
 
211
+ /** Show only what restricted history allows, from detail or from a page. */
212
+ function restrictedTranscript(
213
+ detail: ConversationDetailReport,
214
+ pages: ConversationEventPage[],
215
+ ): ConversationDetailReport | undefined {
216
+ if (detail.eventHistory.status !== "available") {
217
+ return withoutModelUsage(detail);
218
+ }
219
+ const restrictedPage = pages.find(
220
+ (page) => page.eventHistory.status !== "available",
221
+ );
222
+ if (!restrictedPage) return undefined;
223
+ return withoutModelUsage({
224
+ ...detail,
225
+ eventHistory: restrictedPage.eventHistory,
226
+ events: restrictedPage.events,
227
+ previousCursor: restrictedPage.previousCursor,
228
+ });
229
+ }
230
+
201
231
  function withoutModelUsage(
202
232
  detail: ConversationDetailReport,
203
233
  ): ConversationDetailReport {
@@ -20,7 +20,7 @@ import {
20
20
  } from "./transcriptScrollAnchor";
21
21
 
22
22
  const BOTTOM_PROXIMITY_PX = 96;
23
- const PREVIOUS_PAGE_PROXIMITY_PX = 600;
23
+ const HISTORY_PAGE_PROXIMITY_PX = 600;
24
24
  const USER_SCROLL_DELTA_PX = 2;
25
25
  // Leave a little earlier context above the "New" marker.
26
26
  const UNREAD_START_OFFSET_PX = 48;
@@ -133,8 +133,7 @@ function changesVisibleTranscript(event: ConversationReportEvent): boolean {
133
133
  data.type === "subagent" ||
134
134
  data.type === "structured_event" ||
135
135
  data.type === "attachments_delivered" ||
136
- data.type === "compaction" ||
137
- data.type === "handoff"
136
+ data.type === "compaction"
138
137
  );
139
138
  }
140
139
 
@@ -208,18 +207,6 @@ function eventTailVersion(event: ConversationReportEvent | undefined): string {
208
207
  ].join(":");
209
208
  case "compaction":
210
209
  return [data.type, data.summary?.length ?? 0, event.createdAt].join(":");
211
- case "handoff":
212
- return [
213
- data.type,
214
- data.modelProfile,
215
- data.modelId,
216
- data.summary?.length ?? 0,
217
- event.createdAt,
218
- ].join(":");
219
- case "turn_routed":
220
- return [data.type, data.turnId, data.modelProfile, data.modelId].join(
221
- ":",
222
- );
223
210
  case "guardian_action_reviewed":
224
211
  return [data.type, data.turnId, data.toolCallId, data.decision].join(":");
225
212
  case "turn_context":
@@ -262,18 +249,22 @@ export function terminalReplyPinState(input: {
262
249
 
263
250
  /**
264
251
  * Show the jump control only when the reader left the bottom and a newer tail
265
- * arrived, or when the transcript opened at unread rows. Staying pinned, or
266
- * intentionally following, must not flash it.
252
+ * arrived, when the transcript opened at unread rows, or when it does not end
253
+ * at the latest event. Staying pinned, or intentionally following, must not
254
+ * flash it.
267
255
  */
268
256
  export function shouldShowJumpToLatest(input: {
269
257
  enabled: boolean;
258
+ endsAtLatest?: boolean;
270
259
  following: boolean;
271
260
  hasPendingUpdate: boolean;
272
261
  openedAtUnread?: boolean;
273
262
  }): boolean {
274
263
  return (
275
264
  !input.following &&
276
- (Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate))
265
+ (input.endsAtLatest === false ||
266
+ Boolean(input.openedAtUnread) ||
267
+ (input.enabled && input.hasPendingUpdate))
277
268
  );
278
269
  }
279
270
 
@@ -319,6 +310,12 @@ export function programmaticSettleScrollAction(input: {
319
310
  export function usePinnedTranscriptBottom(input: {
320
311
  conversationId?: string;
321
312
  enabled: boolean;
313
+ /**
314
+ * False while newer pages are not loaded. The end of the transcript is
315
+ * then not the latest event, so the reader cannot follow it, and a page
316
+ * that loads below them does not pull them down.
317
+ */
318
+ endsAtLatest?: boolean;
322
319
  historyVersion: string;
323
320
  juniorMessageVersion: string;
324
321
  pinRequestVersion?: number;
@@ -330,6 +327,8 @@ export function usePinnedTranscriptBottom(input: {
330
327
  const anchorRef = useRef<HTMLDivElement | null>(null);
331
328
  const contentElementRef = useRef<HTMLDivElement | null>(null);
332
329
  const enabledRef = useRef(input.enabled);
330
+ const endsAtLatestRef = useRef(true);
331
+ endsAtLatestRef.current = input.endsAtLatest ?? true;
333
332
  const followingRef = useRef(false);
334
333
  const initializedRef = useRef(false);
335
334
  const initializedConversationRef = useRef<string | null>(null);
@@ -447,6 +446,7 @@ export function usePinnedTranscriptBottom(input: {
447
446
  }
448
447
 
449
448
  if (intent === "follow") {
449
+ if (!endsAtLatestRef.current) return;
450
450
  setFollowingIntent(true);
451
451
  setHasPendingUpdate(false);
452
452
  return;
@@ -537,7 +537,7 @@ export function usePinnedTranscriptBottom(input: {
537
537
  const snapshot = scrollSnapshot(root);
538
538
  previousScrollTopRef.current = snapshot.scrollTop;
539
539
  // Unread rows that fit above the bottom leave the reader at the latest.
540
- const atBottom = isNearScrollBottom(snapshot);
540
+ const atBottom = endsAtLatestRef.current && isNearScrollBottom(snapshot);
541
541
  setFollowingIntent(atBottom);
542
542
  setOpenedAtUnread(!atBottom);
543
543
  rememberPlace();
@@ -574,8 +574,13 @@ export function usePinnedTranscriptBottom(input: {
574
574
  // reader moved. Then open at the marker, as if it was there at the start.
575
575
  const followLatest = useCallback(() => {
576
576
  if (awaitingUnreadMarkerRef.current && openAtUnreadMarker()) return;
577
+ if (!endsAtLatestRef.current) {
578
+ setFollowingIntent(false);
579
+ restorePlace();
580
+ return;
581
+ }
577
582
  scrollToBottom("auto");
578
- }, [openAtUnreadMarker, scrollToBottom]);
583
+ }, [openAtUnreadMarker, restorePlace, scrollToBottom, setFollowingIntent]);
579
584
 
580
585
  const syncAfterLayoutChange = useCallback(() => {
581
586
  if (followingRef.current) {
@@ -749,6 +754,8 @@ export function usePinnedTranscriptBottom(input: {
749
754
  const version = input.pinRequestVersion ?? 0;
750
755
  if (version === pinRequestVersionRef.current) return;
751
756
  pinRequestVersionRef.current = version;
757
+ // The reader asked for the latest event, not the "New" marker.
758
+ awaitingUnreadMarkerRef.current = false;
752
759
  setFollowingIntent(true);
753
760
  setHasPendingUpdate(false);
754
761
  scrollToBottom("auto");
@@ -763,6 +770,7 @@ export function usePinnedTranscriptBottom(input: {
763
770
  jumpToBottom,
764
771
  showJumpToLatest: shouldShowJumpToLatest({
765
772
  enabled: input.enabled,
773
+ endsAtLatest: input.endsAtLatest,
766
774
  following,
767
775
  hasPendingUpdate,
768
776
  openedAtUnread,
@@ -774,14 +782,19 @@ export function usePinnedTranscriptBottom(input: {
774
782
  following,
775
783
  hasPendingUpdate,
776
784
  input.enabled,
785
+ input.endsAtLatest,
777
786
  jumpToBottom,
778
787
  openedAtUnread,
779
788
  ],
780
789
  );
781
790
  }
782
791
 
783
- /** Load the previous history page when the reader scrolls near the top. */
784
- export function useAutoLoadPreviousPage(input: {
792
+ /**
793
+ * Load the next history page when the reader scrolls near the edge where it
794
+ * goes: the top for earlier pages, the bottom for newer pages.
795
+ */
796
+ export function useAutoLoadHistoryPage(input: {
797
+ edge: "bottom" | "top";
785
798
  enabled: boolean;
786
799
  historyVersion: string;
787
800
  onLoad: () => void;
@@ -803,12 +816,15 @@ export function useAutoLoadPreviousPage(input: {
803
816
  },
804
817
  {
805
818
  root: root && !isWindowRoot(root) ? root : null,
806
- rootMargin: `${PREVIOUS_PAGE_PROXIMITY_PX}px 0px 0px 0px`,
819
+ rootMargin:
820
+ input.edge === "top"
821
+ ? `${HISTORY_PAGE_PROXIMITY_PX}px 0px 0px 0px`
822
+ : `0px 0px ${HISTORY_PAGE_PROXIMITY_PX}px 0px`,
807
823
  },
808
824
  );
809
825
  observer.observe(sentinel);
810
826
  return () => observer.disconnect();
811
- }, [input.enabled, input.historyVersion, sentinel]);
827
+ }, [input.edge, input.enabled, input.historyVersion, sentinel]);
812
828
 
813
829
  return setSentinel;
814
830
  }
@@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
17
17
  import {
18
18
  conversationReadPositionSchema,
19
19
  type ConversationDetailReport,
20
+ type ConversationEventPage,
20
21
  type ConversationReadPosition,
21
22
  } from "@sentry/junior/api/schema";
22
23
  import { put } from "../http";
@@ -42,6 +43,10 @@ export type TranscriptUnreadRange = { afterSeq: number; throughSeq: number };
42
43
  export type ReadPositionAtOpen = {
43
44
  lastReadSeq?: number;
44
45
  unread?: TranscriptUnreadRange;
46
+ /** Set when unread events start before the latest page. */
47
+ unreadCursor?: string;
48
+ /** The page around `unreadCursor`, when the fetch brought it. */
49
+ unreadPage?: ConversationEventPage;
45
50
  };
46
51
 
47
52
  /** Edit the browser-only preference for where Conversations open. */
@@ -74,7 +79,9 @@ export function readConversationStartPosition(): ConversationStartPosition {
74
79
  * The read position comes from the first detail, which can be a cached copy.
75
80
  * The last unread row comes from the first detail fetched in this visit, so a
76
81
  * stale cache cannot hide messages that arrived while the reader was away,
77
- * and later polls cannot add rows that arrived after open.
82
+ * and later polls cannot add rows that arrived after open. `unreadCursor`
83
+ * and its page come from that fetch too: a cache from a visit that read to
84
+ * the end has no cursor, even when more than a page arrived after that visit.
78
85
  */
79
86
  export function useReadPositionAtOpen(
80
87
  detail: DetailAtOpen | undefined,
@@ -83,24 +90,41 @@ export function useReadPositionAtOpen(
83
90
  ): ReadPositionAtOpen {
84
91
  // Store derived state during render so the first transcript render has it.
85
92
  const [opened, setOpened] = useState<{ lastReadSeq?: number }>();
86
- const [fetchedEnd, setFetchedEnd] = useState<{ throughSeq?: number }>();
93
+ const [fetchedEnd, setFetchedEnd] = useState<{
94
+ throughSeq?: number;
95
+ unreadCursor?: string;
96
+ unreadPage?: ConversationEventPage;
97
+ }>();
87
98
  const read =
88
99
  opened ??
89
100
  (detail ? { lastReadSeq: detail.readPosition?.lastReadSeq } : undefined);
90
101
  if (!opened && read) setOpened(read);
91
102
  const end =
92
103
  fetchedEnd ??
93
- (fetched ? { throughSeq: fetched.events.at(-1)?.seq } : undefined);
104
+ (fetched
105
+ ? {
106
+ throughSeq: fetched.events.at(-1)?.seq,
107
+ unreadCursor: fetched.unreadCursor,
108
+ unreadPage: fetched.unreadPage,
109
+ }
110
+ : undefined);
94
111
  if (!fetchedEnd && end) setFetchedEnd(end);
95
- return readPositionAtOpen(
96
- read?.lastReadSeq,
97
- end ? end.throughSeq : detail?.events.at(-1)?.seq,
98
- );
112
+ return {
113
+ ...readPositionAtOpen(
114
+ read?.lastReadSeq,
115
+ end ? end.throughSeq : detail?.events.at(-1)?.seq,
116
+ ),
117
+ ...(end?.unreadCursor
118
+ ? { unreadCursor: end.unreadCursor, unreadPage: end.unreadPage }
119
+ : undefined),
120
+ };
99
121
  }
100
122
 
101
123
  type DetailAtOpen = {
102
124
  events: readonly { seq: number }[];
103
125
  readPosition?: ConversationReadPosition;
126
+ unreadCursor?: string;
127
+ unreadPage?: ConversationEventPage;
104
128
  };
105
129
 
106
130
  /** Find the rows after the read position, through the last row at open. */
@@ -234,20 +234,7 @@ export function messageRawText(message: TranscriptViewMessage): string {
234
234
  .map((attachment) => attachment.filename)
235
235
  .join("\n");
236
236
  }
237
- if (part.event.type !== "handoff") {
238
- return ["context compacted", part.event.summary]
239
- .filter((line): line is string => line !== undefined)
240
- .join("\n");
241
- }
242
- return [
243
- "model handoff",
244
- `model ${part.event.modelId}`,
245
- `profile ${part.event.modelProfile}`,
246
- part.event.summary,
247
- part.event.reasoningLevel
248
- ? `reasoning ${part.event.reasoningLevel}`
249
- : undefined,
250
- ]
237
+ return ["context compacted", part.event.summary]
251
238
  .filter((line): line is string => line !== undefined)
252
239
  .join("\n");
253
240
  })
@@ -194,17 +194,9 @@ export function entryMatchesSearch(
194
194
 
195
195
  if (entry.kind === "context") {
196
196
  const event = entry.part.event;
197
- return event.type === "handoff"
198
- ? [
199
- "model handoff",
200
- event.modelProfile,
201
- event.modelId,
202
- event.reasoningLevel,
203
- event.summary,
204
- ].some((value) => textContains(value, normalizedQuery))
205
- : ["context compacted", event.summary].some((value) =>
206
- textContains(value, normalizedQuery),
207
- );
197
+ return ["context compacted", event.summary].some((value) =>
198
+ textContains(value, normalizedQuery),
199
+ );
208
200
  }
209
201
 
210
202
  return false;