@sentry/junior-dashboard 0.280.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 (81) hide show
  1. package/dist/app.js +23 -30
  2. package/dist/client-manifest.json +1 -1
  3. package/dist/index.js +23 -30
  4. package/dist/public/chunks/{AdminPage-YEKZOKH6.js → AdminPage-M5RBVZFN.js} +1 -1
  5. package/dist/public/chunks/{AutomationPage-5GS3IUAK.js → AutomationPage-WALCREDT.js} +1 -1
  6. package/dist/public/chunks/{AutomationRunsPage-IPYEZU4L.js → AutomationRunsPage-CV47E4UX.js} +1 -1
  7. package/dist/public/chunks/{AutomationsPage-HSAYPC3C.js → AutomationsPage-57L3AQVJ.js} +1 -1
  8. package/dist/public/chunks/{CodePage-IPFKXWFB.js → CodePage-XJUYMFJH.js} +1 -1
  9. package/dist/public/chunks/{ConnectionsPage-5XUEK6FS.js → ConnectionsPage-3QQAGYWF.js} +1 -1
  10. package/dist/public/chunks/{LocationDetailPage-27ETUGR6.js → LocationDetailPage-4IL5HQML.js} +1 -1
  11. package/dist/public/chunks/{LocationsPage-WOKMQAGM.js → LocationsPage-D2DUQAHJ.js} +1 -1
  12. package/dist/public/chunks/{PeoplePage-6YQLDOUL.js → PeoplePage-6F5GPQ7C.js} +1 -1
  13. package/dist/public/chunks/{PersonProfilePage-GYMAZFIK.js → PersonProfilePage-SPAYQPTD.js} +1 -1
  14. package/dist/public/chunks/{PersonalTokensPage-4BCR744B.js → PersonalTokensPage-SHAUJ74M.js} +1 -1
  15. package/dist/public/chunks/{PluginUserPage-IP6WL7MD.js → PluginUserPage-64FIWD7M.js} +1 -1
  16. package/dist/public/chunks/{RepositoryPage-TRD44LQ3.js → RepositoryPage-L7IHOXSN.js} +1 -1
  17. package/dist/public/chunks/{SettingsPage-4E3SLQ36.js → SettingsPage-U7WG4FHE.js} +1 -1
  18. package/dist/public/chunks/{SystemPage-C3XBKXKW.js → SystemPage-ZBC43OJH.js} +1 -1
  19. package/dist/public/chunks/{WorkspaceFormPage-2LS2X67Q.js → WorkspaceFormPage-3NVIIZ4P.js} +1 -1
  20. package/dist/public/chunks/{WorkspacesPage-5WB3CUTX.js → WorkspacesPage-G73HPBDD.js} +1 -1
  21. package/dist/public/chunks/{chunk-VFEBNOPX.js → chunk-2J3LGF5Q.js} +1 -1
  22. package/dist/public/chunks/chunk-2NNKHSZE.js +1 -0
  23. package/dist/public/chunks/chunk-4TQSTCIN.js +13 -0
  24. package/dist/public/chunks/chunk-64GPEGGE.js +1 -0
  25. package/dist/public/chunks/{chunk-WZVO3HG4.js → chunk-6FHY65EQ.js} +1 -1
  26. package/dist/public/chunks/{chunk-YYNMZ4WC.js → chunk-DEEO4D4N.js} +1 -1
  27. package/dist/public/chunks/{chunk-SDKV3OID.js → chunk-FSGVY7HB.js} +1 -1
  28. package/dist/public/chunks/chunk-GZBMHYYU.js +1 -0
  29. package/dist/public/chunks/{chunk-NQMDED2R.js → chunk-II4NXLSQ.js} +1 -1
  30. package/dist/public/chunks/{chunk-B6HGRBBX.js → chunk-ILH47V7O.js} +1 -1
  31. package/dist/public/chunks/{chunk-GRMX2HX6.js → chunk-JPL2NRWK.js} +1 -1
  32. package/dist/public/chunks/{chunk-NQOJAZFQ.js → chunk-JVJ5UWIK.js} +1 -1
  33. package/dist/public/chunks/{chunk-V5UDSIRA.js → chunk-K4TYMAA3.js} +1 -1
  34. package/dist/public/chunks/{chunk-EFEREKX4.js → chunk-KDUTK357.js} +1 -1
  35. package/dist/public/chunks/{chunk-RHIXHBS5.js → chunk-LZYAHJVQ.js} +1 -1
  36. package/dist/public/chunks/{chunk-5GZDBGAR.js → chunk-MKKFXRYO.js} +1 -1
  37. package/dist/public/chunks/{chunk-PEQNT5MO.js → chunk-NGQRNS3C.js} +1 -1
  38. package/dist/public/chunks/{chunk-IA6BJDKN.js → chunk-ONDKE72P.js} +1 -1
  39. package/dist/public/chunks/{chunk-WJ3OXMVX.js → chunk-PG7M7L24.js} +1 -1
  40. package/dist/public/chunks/{chunk-6GQL3EVJ.js → chunk-QQUFROWT.js} +1 -1
  41. package/dist/public/chunks/{chunk-UAH6DWNS.js → chunk-RB5FBDEY.js} +1 -1
  42. package/dist/public/chunks/{chunk-A2QWWA7O.js → chunk-RGXXD4DM.js} +1 -1
  43. package/dist/public/chunks/{chunk-AYXSBMWA.js → chunk-TC7TU4GK.js} +1 -1
  44. package/dist/public/chunks/chunk-WLIOZO3W.js +1 -0
  45. package/dist/public/chunks/{chunk-VXT3FIVL.js → chunk-XDUSY26N.js} +11 -11
  46. package/dist/public/client-4OAAOHF4.js +68 -0
  47. package/dist/tailwind.css +1 -1
  48. package/package.json +4 -4
  49. package/src/client/components/BottomSheet.tsx +1 -1
  50. package/src/client/components/DetailsPanel.tsx +2 -1
  51. package/src/client/conversations/ConversationBrief.tsx +129 -65
  52. package/src/client/conversations/ConversationDetails.tsx +13 -14
  53. package/src/client/conversations/ConversationEventLog.tsx +20 -97
  54. package/src/client/conversations/ConversationHeader.tsx +41 -26
  55. package/src/client/conversations/ConversationHeaderActions.tsx +3 -1
  56. package/src/client/conversations/ConversationLinkedWork.tsx +30 -10
  57. package/src/client/conversations/ConversationMeta.tsx +15 -15
  58. package/src/client/conversations/ConversationPage.tsx +31 -7
  59. package/src/client/conversations/ConversationTranscript.tsx +234 -186
  60. package/src/client/conversations/ConversationUsageBreakdown.tsx +142 -64
  61. package/src/client/conversations/DetailsList.tsx +7 -1
  62. package/src/client/conversations/PendingMailboxStack.tsx +88 -44
  63. package/src/client/conversations/TranscriptActivityGroup.tsx +7 -14
  64. package/src/client/conversations/TranscriptEventView.tsx +7 -3
  65. package/src/client/conversations/TranscriptReasoningView.tsx +4 -2
  66. package/src/client/conversations/TranscriptRows.tsx +37 -5
  67. package/src/client/conversations/TranscriptView.tsx +4 -0
  68. package/src/client/conversations/queries.ts +74 -43
  69. package/src/client/conversations/transcriptBottomPinning.ts +13 -5
  70. package/src/client/conversations/transcriptListRows.ts +215 -0
  71. package/src/client/conversations/virtualRows.ts +168 -0
  72. package/src/client/http.ts +6 -5
  73. package/src/client/sentry.ts +53 -3
  74. package/src/conversation-cache.ts +8 -10
  75. package/src/mock-reporting/fixtures.ts +0 -8
  76. package/dist/public/chunks/chunk-GO45RQDL.js +0 -1
  77. package/dist/public/chunks/chunk-N4AONKD3.js +0 -1
  78. package/dist/public/chunks/chunk-QNA7F7QB.js +0 -1
  79. package/dist/public/chunks/chunk-R4ORRE77.js +0 -1
  80. package/dist/public/chunks/chunk-Z2JPRGFT.js +0 -1
  81. package/dist/public/client-TYC7JKRE.js +0 -91
@@ -1,4 +1,10 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ type ComponentPropsWithoutRef,
6
+ type ReactNode,
7
+ } from "react";
2
8
 
3
9
  import { cn } from "../styles";
4
10
  import {
@@ -48,13 +54,39 @@ export function TranscriptUnreadMarker() {
48
54
  );
49
55
  }
50
56
 
57
+ /**
58
+ * Open rows by key, for the rows of one transcript list. Rows far from the
59
+ * viewport unmount, so a row that opens again reads its state here.
60
+ */
61
+ export const TranscriptOpenRows = createContext<Map<string, boolean> | null>(
62
+ null,
63
+ );
64
+
65
+ /**
66
+ * Hold whether a transcript row part is open. With a key, the state stays
67
+ * while the row unmounts outside the viewport.
68
+ */
69
+ export function useTranscriptRowOpen(
70
+ key: string | undefined,
71
+ ): [boolean, (open: boolean) => void] {
72
+ const openRows = useContext(TranscriptOpenRows);
73
+ const [open, setOpenState] = useState(
74
+ () => (key !== undefined && openRows?.get(key)) || false,
75
+ );
76
+ const setOpen = (value: boolean) => {
77
+ setOpenState(value);
78
+ if (key !== undefined) openRows?.set(key, value);
79
+ };
80
+ return [open, setOpen];
81
+ }
82
+
51
83
  /**
52
84
  * Lay out one transcript row at its real size.
53
85
  *
54
- * Rows do not use `content-visibility: auto`. A skipped row has an estimated
55
- * size, and it changes size when the reader scrolls near it. Above the
56
- * reader, each change needs a scroll correction while they scroll, and on
57
- * phones those corrections make the transcript jump.
86
+ * Rows do not use `content-visibility: auto`. The list mounts only the rows
87
+ * near the viewport, and a row that mounts above the reader can measure a
88
+ * different size than its estimate. The scroll owner corrects the scroll
89
+ * position for that, so keep estimates close and overscan large.
58
90
  */
59
91
  export function TranscriptRow(props: {
60
92
  /** Stable row id that holds the reader's place while layout changes. */
@@ -52,6 +52,8 @@ export function Transcript(props: {
52
52
  /** The reader's own sends to show at the end until history has them. */
53
53
  pendingMessages?: readonly ConversationMailboxMessage[];
54
54
  responding?: boolean;
55
+ /** Keep the rows around this event seq mounted, so the page can show it. */
56
+ revealSeq?: number;
55
57
  search?: string;
56
58
  onSearchChange?(value: string): void;
57
59
  selectedEventSeq?: number;
@@ -182,6 +184,8 @@ export function Transcript(props: {
182
184
  conversation={props.transcript}
183
185
  pendingMessages={props.pendingMessages}
184
186
  responding={props.responding ?? props.live ?? false}
187
+ revealSeq={props.revealSeq}
188
+ scrollAnchorKey={bottomPinning.scrollAnchorKey}
185
189
  unread={props.unread}
186
190
  />
187
191
  </TranscriptJumpToLatestContext>
@@ -1,4 +1,5 @@
1
1
  import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
2
+ import * as z from "zod";
2
3
  import {
3
4
  type QueryClient,
4
5
  queryOptions,
@@ -22,13 +23,14 @@ import {
22
23
  conversationDetailReportSchema,
23
24
  conversationDetailWithMailboxReportSchema,
24
25
  conversationEventPageSchema,
25
- conversationPendingMessagesReportSchema,
26
+ conversationUnchangedDetailWithMailboxSchema,
26
27
  forkConversationResponseSchema,
27
28
  steerConversationPendingMessagesResponseSchema,
28
29
  stopConversationTurnResponseSchema,
29
30
  } from "@sentry/junior/api/schema";
30
31
 
31
32
  import { REVIEW_ITEMS_QUERY_KEY } from "../review-desk/queries";
33
+ import { readResponseJson } from "../sentry";
32
34
  import {
33
35
  DashboardApiError,
34
36
  del,
@@ -102,29 +104,6 @@ function isOpenPrefetch(snapshot: ConversationSnapshot | undefined) {
102
104
  );
103
105
  }
104
106
 
105
- /** Read the mailbox before history so acknowledged input is already committed. */
106
- async function readConversationSnapshot(
107
- conversationId: string,
108
- signal?: AbortSignal,
109
- previous?: ConversationSnapshot,
110
- unreadPage?: boolean,
111
- ): Promise<ConversationSnapshot> {
112
- // With no cached copy, the transcript waits for this read. The server reads
113
- // both in the same order in one request, which saves a round trip. Polls
114
- // read the mailbox apart, so the detail can still answer 304.
115
- if (!previous) {
116
- return readConversationData(conversationId, signal, undefined, {
117
- mailbox: true,
118
- unreadPage,
119
- });
120
- }
121
- const mailbox = await readConversationPendingMessages(conversationId, signal);
122
- const detail = await readConversationData(conversationId, signal, previous, {
123
- unreadPage,
124
- });
125
- return { ...detail, mailbox };
126
- }
127
-
128
107
  const CONVERSATION_SIDE_READS = ["agents", "memories", "watches"] as const;
129
108
 
130
109
  /**
@@ -185,11 +164,13 @@ export function conversationDetailQueryOptions(
185
164
  const previous = client.getQueryData<ConversationSnapshot>(
186
165
  conversationDetailQueryKey(conversationId),
187
166
  );
188
- const next = await readConversationSnapshot(
167
+ // The server reads the mailbox before history, so acknowledged input
168
+ // is in at least one of them.
169
+ const next = await readConversationData(
189
170
  conversationId!,
190
171
  signal,
191
172
  previous,
192
- options?.unreadPage,
173
+ { mailbox: true, unreadPage: options?.unreadPage },
193
174
  );
194
175
  refreshSideReadsOnChange(client, conversationId!, previous, next);
195
176
  return next;
@@ -866,6 +847,10 @@ export async function readConversationData(
866
847
  const query = new URLSearchParams();
867
848
  if (options?.unreadPage) query.set("unreadPage", "true");
868
849
  if (options?.mailbox) query.set("mailbox", "true");
850
+ // A poll asks only for the events after the cached ones. The unread page
851
+ // needs the latest page, so a read that asks for it reads that page.
852
+ const after = options?.unreadPage ? undefined : previous?.appendCursor;
853
+ if (after) query.set("after", after);
869
854
  const search = query.size ? `?${query}` : "";
870
855
  const response = await readDashboardResponse(
871
856
  `/api/conversations/${encodeURIComponent(conversationId)}${search}`,
@@ -875,11 +860,69 @@ export async function readConversationData(
875
860
  : undefined,
876
861
  );
877
862
  if (response.status === 304 && previous) return previous;
878
- const report = (
879
- options?.mailbox
880
- ? conversationDetailWithMailboxReportSchema
881
- : conversationDetailReportSchema
882
- ).parse(await response.json());
863
+ if (!options?.mailbox) {
864
+ return withDetailEtag(
865
+ withCachedEvents(
866
+ await readResponseJson(response, conversationDetailReportSchema),
867
+ previous,
868
+ ),
869
+ response,
870
+ );
871
+ }
872
+ const report = await readResponseJson(
873
+ response,
874
+ z.union([
875
+ conversationUnchangedDetailWithMailboxSchema,
876
+ conversationDetailWithMailboxReportSchema,
877
+ ]),
878
+ );
879
+ if (!("unchanged" in report)) {
880
+ return withDetailEtag(withCachedEvents(report, previous), response);
881
+ }
882
+ // Only a request that sent a version can get this answer.
883
+ if (!previous) throw new Error("Unchanged detail without a cached copy");
884
+ return { ...previous, mailbox: report.mailbox };
885
+ }
886
+
887
+ /**
888
+ * Put the cached events before the newer events of an `appended` report.
889
+ * The cached detail keeps its cursors: they still point before its events.
890
+ */
891
+ function withCachedEvents(
892
+ report: ConversationSnapshot,
893
+ previous: ConversationSnapshot | undefined,
894
+ ): ConversationSnapshot {
895
+ if (!report.appended) return report;
896
+ // Only a request that sent an `appendCursor` can get this answer.
897
+ if (!previous) throw new Error("Appended detail without a cached copy");
898
+ const { appended: _appended, ...rest } = report;
899
+ const lastSeq = previous.events.at(-1)?.seq ?? -1;
900
+ const firstSeq = previous.events[0]?.seq;
901
+ // Like the server, keep the unread cursor while the reader is behind the
902
+ // first event.
903
+ const behind =
904
+ rest.readPosition !== undefined &&
905
+ firstSeq !== undefined &&
906
+ rest.readPosition.lastReadSeq + 1 < firstSeq;
907
+ return {
908
+ ...rest,
909
+ events: [
910
+ ...previous.events,
911
+ ...report.events.filter((event) => event.seq > lastSeq),
912
+ ],
913
+ ...(previous.previousCursor
914
+ ? { previousCursor: previous.previousCursor }
915
+ : undefined),
916
+ ...(behind && previous.unreadCursor
917
+ ? { unreadCursor: previous.unreadCursor, unreadPage: previous.unreadPage }
918
+ : undefined),
919
+ };
920
+ }
921
+
922
+ function withDetailEtag<T extends ConversationDetailReport>(
923
+ report: T,
924
+ response: Response,
925
+ ): T & { detailEtag?: string } {
883
926
  return {
884
927
  ...report,
885
928
  detailEtag:
@@ -887,18 +930,6 @@ export async function readConversationData(
887
930
  };
888
931
  }
889
932
 
890
- /** Read accepted mailbox messages that have not reached durable history yet. */
891
- export function readConversationPendingMessages(
892
- conversationId: string,
893
- signal?: AbortSignal,
894
- ): Promise<ConversationPendingMessagesReport> {
895
- return fetchDashboardJson(
896
- conversationPendingMessagesReportSchema,
897
- `/api/conversations/${encodeURIComponent(conversationId)}/pending-messages`,
898
- signal,
899
- );
900
- }
901
-
902
933
  /** Read one bounded page of events before the supplied history cursor. */
903
934
  export function readConversationEvents(
904
935
  conversationId: string,
@@ -340,8 +340,10 @@ export function usePinnedTranscriptBottom(input: {
340
340
  // open that fails becomes a normal start, so the transcript opens again.
341
341
  const startedRef = useRef<string | null>(null);
342
342
  const previousScrollTopRef = useRef<number | null>(null);
343
- // The scroll position that the last restore set. Its scroll event is not
344
- // a reader scroll, so it must not change follow intent.
343
+ // The scroll position that the last restore or open at the marker set.
344
+ // Its scroll event is not a reader scroll, so it must not change follow
345
+ // intent or the recorded place. Rows can change size before that event,
346
+ // and the restore after them needs the place from before.
345
347
  const restoredScrollTopRef = useRef<number | null>(null);
346
348
  const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
347
349
  const pinRequestVersionRef = useRef(input.pinRequestVersion ?? 0);
@@ -422,7 +424,6 @@ export function usePinnedTranscriptBottom(input: {
422
424
  const root = scrollRootFor(contentElementRef.current);
423
425
  if (!root) return;
424
426
 
425
- if (source === "scroll") rememberPlace();
426
427
  const snapshot = scrollSnapshot(root);
427
428
  const previousScrollTop = previousScrollTopRef.current;
428
429
  previousScrollTopRef.current = snapshot.scrollTop;
@@ -434,6 +435,7 @@ export function usePinnedTranscriptBottom(input: {
434
435
  if (restored !== null && Math.abs(snapshot.scrollTop - restored) < 1) {
435
436
  return;
436
437
  }
438
+ rememberPlace();
437
439
  }
438
440
 
439
441
  const intent = transcriptFollowIntent({
@@ -543,6 +545,7 @@ export function usePinnedTranscriptBottom(input: {
543
545
  );
544
546
  const snapshot = scrollSnapshot(root);
545
547
  previousScrollTopRef.current = snapshot.scrollTop;
548
+ restoredScrollTopRef.current = snapshot.scrollTop;
546
549
  // Unread rows that fit above the bottom leave the reader at the latest.
547
550
  const atBottom = endsAtLatestRef.current && isNearScrollBottom(snapshot);
548
551
  setFollowingIntent(atBottom);
@@ -693,7 +696,12 @@ export function usePinnedTranscriptBottom(input: {
693
696
  measurePosition("measure");
694
697
  rememberPlace();
695
698
  // Listen on the scroll root, so gestures outside transcript rows count.
696
- target.addEventListener("scroll", onScroll, { passive: true });
699
+ // Capture runs first at the root, so the place is recorded before a
700
+ // virtual list mounts the rows for the new position.
701
+ target.addEventListener("scroll", onScroll, {
702
+ capture: true,
703
+ passive: true,
704
+ });
697
705
  target.addEventListener("wheel", onWheel, { passive: true });
698
706
  target.addEventListener("touchstart", onTouchStart, {
699
707
  passive: true,
@@ -703,7 +711,7 @@ export function usePinnedTranscriptBottom(input: {
703
711
  });
704
712
  window.addEventListener("resize", syncAfterLayoutChange);
705
713
  return () => {
706
- target.removeEventListener("scroll", onScroll);
714
+ target.removeEventListener("scroll", onScroll, { capture: true });
707
715
  target.removeEventListener("wheel", onWheel);
708
716
  target.removeEventListener("touchstart", onTouchStart);
709
717
  target.removeEventListener("touchmove", onTouchMove);
@@ -0,0 +1,215 @@
1
+ import {
2
+ isCollapsibleActivityEntry,
3
+ quietTurnEntryKeys,
4
+ } from "./TranscriptActivityGroup";
5
+ import type { RenderedTranscriptEntry } from "./transcriptRenderModel";
6
+ import { entryMatchesSearch } from "./transcriptSearch";
7
+ import type { TranscriptUnreadRange } from "./transcriptReadPosition";
8
+
9
+ /** One row of the rich transcript list, in seq order. */
10
+ export type TranscriptListRow =
11
+ | { kind: "unread_marker"; key: string }
12
+ | {
13
+ kind: "entry";
14
+ entry: RenderedTranscriptEntry;
15
+ key: string;
16
+ seq?: number;
17
+ }
18
+ | {
19
+ kind: "activity";
20
+ entries: RenderedTranscriptEntry[];
21
+ key: string;
22
+ /** Last event sequence in the group. */
23
+ seq?: number;
24
+ };
25
+
26
+ /** The key of the "New" marker row. */
27
+ export const UNREAD_MARKER_KEY = "unread-marker";
28
+
29
+ /**
30
+ * Turn transcript entries into list rows. Runs of collapsible activity
31
+ * become one group row, search drops rows that do not match, and the
32
+ * "New" marker becomes its own row before the first unread row.
33
+ *
34
+ * `knownActivityKeys` maps entry keys to group keys across calls, so a
35
+ * group keeps its key while events extend either edge.
36
+ */
37
+ export function transcriptListRows(input: {
38
+ entries: readonly RenderedTranscriptEntry[];
39
+ keyPrefix: string;
40
+ knownActivityKeys: Map<string, string>;
41
+ /** The normalized search query, when search is active. */
42
+ search?: string;
43
+ unread?: TranscriptUnreadRange;
44
+ }): TranscriptListRow[] {
45
+ const { entries, search } = input;
46
+ const quietKeys = quietTurnEntryKeys(entries);
47
+ const collapsible = (entry: RenderedTranscriptEntry) =>
48
+ isCollapsibleActivityEntry(entry) || quietKeys.has(entry.key);
49
+ const matches = (entry: RenderedTranscriptEntry) =>
50
+ search === undefined || entryMatchesSearch(entry, search);
51
+ const claimedActivityKeys = new Set<string>();
52
+ const rows: TranscriptListRow[] = [];
53
+ // Search results are not a reading order, so they get no marker.
54
+ const markerSeq =
55
+ search === undefined ? unreadMarkerSeq(entries, input.unread) : undefined;
56
+ let markerPending = markerSeq !== undefined;
57
+ // Entries are in seq order, so the first row that reaches the marker seq
58
+ // gets the marker.
59
+ const pushRow = (
60
+ row: Exclude<TranscriptListRow, { kind: "unread_marker" }>,
61
+ ) => {
62
+ if (markerPending && row.seq !== undefined && row.seq >= markerSeq!) {
63
+ markerPending = false;
64
+ rows.push({ kind: "unread_marker", key: UNREAD_MARKER_KEY });
65
+ }
66
+ rows.push(row);
67
+ };
68
+
69
+ for (let index = 0; index < entries.length; ) {
70
+ const entry = entries[index]!;
71
+ if (collapsible(entry)) {
72
+ const group: RenderedTranscriptEntry[] = [];
73
+ while (index < entries.length && collapsible(entries[index]!)) {
74
+ group.push(entries[index]!);
75
+ index += 1;
76
+ }
77
+ const visible = group.filter(matches);
78
+ if (visible.length > 0) {
79
+ // The marker goes before the group when any of it is unread.
80
+ pushRow({
81
+ kind: "activity",
82
+ entries: visible,
83
+ key: stableActivityGroupKey({
84
+ claimedKeys: claimedActivityKeys,
85
+ entries: group,
86
+ keyPrefix: input.keyPrefix,
87
+ knownKeys: input.knownActivityKeys,
88
+ }),
89
+ seq: lastEntrySeq(group),
90
+ });
91
+ }
92
+ continue;
93
+ }
94
+ if (matches(entry)) {
95
+ pushRow({
96
+ kind: "entry",
97
+ entry,
98
+ key: `${input.keyPrefix}:${entry.key}`,
99
+ seq: entry.seq,
100
+ });
101
+ }
102
+ index += 1;
103
+ }
104
+ return rows;
105
+ }
106
+
107
+ /**
108
+ * Find the first row whose last seq is at or after `seq`. The New marker
109
+ * sorts with the row before it, and other rows without a seq come last, so a
110
+ * binary search works. Returns `rows.length` when no row reaches it.
111
+ */
112
+ export function rowIndexForSeq(
113
+ rows: readonly TranscriptListRow[],
114
+ seq: number,
115
+ ): number {
116
+ let low = 0;
117
+ let high = rows.length;
118
+ while (low < high) {
119
+ const middle = (low + high) >> 1;
120
+ const row = rows[middle]!;
121
+ const rowSeq =
122
+ row.kind === "unread_marker"
123
+ ? (rowSeqBefore(rows, middle) ?? Number.NEGATIVE_INFINITY)
124
+ : row.seq;
125
+ if (rowSeq !== undefined && rowSeq < seq) low = middle + 1;
126
+ else high = middle;
127
+ }
128
+ return low;
129
+ }
130
+
131
+ function rowSeqBefore(
132
+ rows: readonly TranscriptListRow[],
133
+ index: number,
134
+ ): number | undefined {
135
+ const row = rows[index - 1];
136
+ return row === undefined || row.kind === "unread_marker"
137
+ ? undefined
138
+ : row.seq;
139
+ }
140
+
141
+ /**
142
+ * Group rows whose heights are alike, so a row can borrow the measured
143
+ * height of others before it renders.
144
+ */
145
+ export function transcriptRowSizeClass(row: TranscriptListRow): string {
146
+ if (row.kind !== "entry") return row.kind;
147
+ const { entry } = row;
148
+ return entry.kind === "message"
149
+ ? `message:${entry.message.role}`
150
+ : entry.kind;
151
+ }
152
+
153
+ /** Guess the height of a row of a size class before any of them measure. */
154
+ export function defaultTranscriptRowSize(sizeClass: string): number {
155
+ if (sizeClass === "unread_marker") return 32;
156
+ if (sizeClass === "activity") return 36;
157
+ if (sizeClass === "attachments_delivered") return 240;
158
+ if (sizeClass === "message:assistant") return 120;
159
+ if (sizeClass.startsWith("message:")) return 80;
160
+ return 44;
161
+ }
162
+
163
+ /**
164
+ * Find the seq of the first unread row. Entries are in seq order, and pending
165
+ * sends without a seq come last, so a binary search finds the first seq after
166
+ * the read position. Rows after the range arrived after open, so they get no
167
+ * marker.
168
+ */
169
+ function unreadMarkerSeq(
170
+ entries: readonly RenderedTranscriptEntry[],
171
+ unread: TranscriptUnreadRange | undefined,
172
+ ): number | undefined {
173
+ if (!unread) return undefined;
174
+ let low = 0;
175
+ let high = entries.length;
176
+ while (low < high) {
177
+ const middle = (low + high) >> 1;
178
+ const seq = entries[middle]!.seq;
179
+ if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1;
180
+ else high = middle;
181
+ }
182
+ const seq = entries[low]?.seq;
183
+ return seq !== undefined && seq <= unread.throughSeq ? seq : undefined;
184
+ }
185
+
186
+ function lastEntrySeq(
187
+ entries: readonly RenderedTranscriptEntry[],
188
+ ): number | undefined {
189
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
190
+ const seq = entries[index]!.seq;
191
+ if (seq !== undefined) return seq;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ /** Keep one activity group mounted while new or historical events extend either edge. */
197
+ function stableActivityGroupKey(args: {
198
+ claimedKeys: Set<string>;
199
+ entries: RenderedTranscriptEntry[];
200
+ keyPrefix: string;
201
+ knownKeys: Map<string, string>;
202
+ }): string {
203
+ const entryKeys = args.entries.map(
204
+ (entry) => `${args.keyPrefix}:${entry.key}`,
205
+ );
206
+ const knownKey = entryKeys
207
+ .map((entryKey) => args.knownKeys.get(entryKey))
208
+ .find((key) => key !== undefined && !args.claimedKeys.has(key));
209
+ const groupKey =
210
+ knownKey ?? `${args.keyPrefix}:activity:${args.entries[0]!.key}`;
211
+
212
+ args.claimedKeys.add(groupKey);
213
+ entryKeys.forEach((entryKey) => args.knownKeys.set(entryKey, groupKey));
214
+ return groupKey;
215
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ defaultRangeExtractor,
3
+ measureElement,
4
+ useVirtualizer,
5
+ type Range,
6
+ type Virtualizer,
7
+ } from "@tanstack/react-virtual";
8
+ import {
9
+ useCallback,
10
+ useLayoutEffect,
11
+ useRef,
12
+ useState,
13
+ type Key,
14
+ type RefObject,
15
+ } from "react";
16
+
17
+ import { scrollRootFor } from "./transcriptBottomPinning";
18
+
19
+ /** Rows mounted beyond the viewport on each side. */
20
+ const OVERSCAN_ROWS = 10;
21
+
22
+ /**
23
+ * Pick and measure the rows of a list near the viewport of its scroll root.
24
+ * The transcript scroll owner holds the reader's place, so this never moves
25
+ * the scroll position. Rows at `keptIndexes` stay mounted outside the
26
+ * viewport, so code that finds them in the DOM keeps working.
27
+ *
28
+ * A row that mounts above the reader with a size other than its estimate
29
+ * moves what they read, and the scroll owner corrects that with a scroll.
30
+ * Phones show such corrections as jumps, so a row's estimate is the mean
31
+ * size of the measured rows of its `sizeClass`, which is close for rows of
32
+ * one kind. `defaultSize` covers a class before any of its rows measure.
33
+ *
34
+ * Callers place each row at `start - options.scrollMargin` with
35
+ * `measureElement` as its ref, inside a list that is `getTotalSize()` tall.
36
+ */
37
+ export function useVirtualRows(input: {
38
+ count: number;
39
+ defaultSize: (sizeClass: string) => number;
40
+ gap?: number;
41
+ getItemKey: (index: number) => Key;
42
+ keptIndexes: readonly (number | undefined)[];
43
+ listRef: RefObject<HTMLElement | null>;
44
+ sizeClass: (index: number) => string;
45
+ }) {
46
+ const { listRef } = input;
47
+ // The virtualizer reads estimates only for rows without a measured size.
48
+ const sizes = useRef(new Map<string, { count: number; total: number }>());
49
+ const rowSizes = useRef(new Map<Key, { size: number; sizeClass: string }>());
50
+ // The estimate that each unmeasured row has now, by row key.
51
+ const estimates = useRef(new Map<Key, number>());
52
+ const rowInput = useRef(input);
53
+ rowInput.current = input;
54
+ const estimateSize = useCallback((index: number) => {
55
+ const key = rowInput.current.getItemKey(index);
56
+ const current = estimates.current.get(key);
57
+ if (current !== undefined) return current;
58
+ const sizeClass = rowInput.current.sizeClass(index);
59
+ const measured = sizes.current.get(sizeClass);
60
+ const estimate = measured?.count
61
+ ? measured.total / measured.count
62
+ : rowInput.current.defaultSize(sizeClass);
63
+ estimates.current.set(key, estimate);
64
+ return estimate;
65
+ }, []);
66
+ // Each row counts once, with its latest size, so rows that grow while
67
+ // they stream do not outweigh the others.
68
+ const recordSize = (key: Key, sizeClass: string, size: number) => {
69
+ const previous = rowSizes.current.get(key);
70
+ if (previous) {
71
+ const total = sizes.current.get(previous.sizeClass)!;
72
+ total.count -= 1;
73
+ total.total -= previous.size;
74
+ }
75
+ rowSizes.current.set(key, { size, sizeClass });
76
+ const total = sizes.current.get(sizeClass) ?? { count: 0, total: 0 };
77
+ total.count += 1;
78
+ total.total += size;
79
+ sizes.current.set(sizeClass, total);
80
+ };
81
+ // A new estimate for a row moves the rows after it. The scroll owner
82
+ // corrects that from the row that it recorded at the last scroll. So rows
83
+ // take the latest estimates only on the render after a scroll, when that
84
+ // row is current. A scroll by other code, such as a reveal, is then
85
+ // recorded before rows move. Other renders keep each row's estimate.
86
+ const instance = useRef<Virtualizer<Element, HTMLElement>>(null);
87
+ const placement = useRef<{
88
+ getItemKey: (index: number) => Key;
89
+ scrollOffset?: number | null;
90
+ }>(null);
91
+ const scrollOffset = instance.current?.scrollOffset;
92
+ if (!placement.current || placement.current.scrollOffset !== scrollOffset) {
93
+ estimates.current.clear();
94
+ placement.current = {
95
+ getItemKey: (index) => rowInput.current.getItemKey(index),
96
+ scrollOffset,
97
+ };
98
+ }
99
+ const [scrollMargin, setScrollMargin] = useState(0);
100
+ const keptKey = input.keptIndexes
101
+ .filter((index) => index !== undefined && index >= 0 && index < input.count)
102
+ .join(",");
103
+ const rangeExtractor = useCallback(
104
+ (range: Range) => {
105
+ const indexes = new Set(defaultRangeExtractor(range));
106
+ if (keptKey) {
107
+ for (const index of keptKey.split(",")) indexes.add(Number(index));
108
+ }
109
+ // Rows stay in document order, so the scroll owner can search them.
110
+ return [...indexes].sort((left, right) => left - right);
111
+ },
112
+ [keptKey],
113
+ );
114
+ // Controls above the list, such as the earlier-page control, move it
115
+ // down from the top of the scroll content. The margin only picks which
116
+ // rows mount, so a reading from the last scroll or resize is close
117
+ // enough: the overscan rows cover a small error.
118
+ const measureScrollMargin = useCallback(() => {
119
+ const list = listRef.current;
120
+ const root = scrollRootFor(list);
121
+ if (!list || !(root instanceof Element)) return;
122
+ const margin =
123
+ list.getBoundingClientRect().top -
124
+ root.getBoundingClientRect().top +
125
+ root.scrollTop;
126
+ setScrollMargin((current) =>
127
+ Math.abs(margin - current) >= 1 ? margin : current,
128
+ );
129
+ }, [listRef]);
130
+ useLayoutEffect(measureScrollMargin, [measureScrollMargin]);
131
+ // The dashboard build does not use React Compiler, so nothing memoizes
132
+ // the values that this hook returns.
133
+ // oxlint-disable-next-line react/incompatible-library
134
+ const virtualizer = useVirtualizer<Element, HTMLElement>({
135
+ count: input.count,
136
+ estimateSize,
137
+ gap: input.gap,
138
+ // A new function makes the virtualizer place every row again.
139
+ getItemKey: placement.current.getItemKey,
140
+ measureElement: (element, entry, instance) => {
141
+ const size = measureElement(element, entry, instance);
142
+ const index = instance.indexFromElement(element);
143
+ recordSize(
144
+ rowInput.current.getItemKey(index),
145
+ rowInput.current.sizeClass(index),
146
+ size,
147
+ );
148
+ return size;
149
+ },
150
+ // A server render has no scroll root, so mount the first rows that
151
+ // fill about one screen.
152
+ initialRect:
153
+ typeof window === "undefined" ? { height: 1_000, width: 0 } : undefined,
154
+ getScrollElement: () => {
155
+ const root = scrollRootFor(listRef.current);
156
+ return root instanceof Element ? root : null;
157
+ },
158
+ onChange: measureScrollMargin,
159
+ overscan: OVERSCAN_ROWS,
160
+ rangeExtractor,
161
+ scrollMargin,
162
+ // The scroll owner moves the scroll position. This only reads it.
163
+ scrollToFn: () => {},
164
+ });
165
+ virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
166
+ instance.current = virtualizer;
167
+ return virtualizer;
168
+ }