@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.
- package/dist/app.js +23 -30
- package/dist/client-manifest.json +1 -1
- package/dist/index.js +23 -30
- package/dist/public/chunks/{AdminPage-YEKZOKH6.js → AdminPage-M5RBVZFN.js} +1 -1
- package/dist/public/chunks/{AutomationPage-5GS3IUAK.js → AutomationPage-WALCREDT.js} +1 -1
- package/dist/public/chunks/{AutomationRunsPage-IPYEZU4L.js → AutomationRunsPage-CV47E4UX.js} +1 -1
- package/dist/public/chunks/{AutomationsPage-HSAYPC3C.js → AutomationsPage-57L3AQVJ.js} +1 -1
- package/dist/public/chunks/{CodePage-IPFKXWFB.js → CodePage-XJUYMFJH.js} +1 -1
- package/dist/public/chunks/{ConnectionsPage-5XUEK6FS.js → ConnectionsPage-3QQAGYWF.js} +1 -1
- package/dist/public/chunks/{LocationDetailPage-27ETUGR6.js → LocationDetailPage-4IL5HQML.js} +1 -1
- package/dist/public/chunks/{LocationsPage-WOKMQAGM.js → LocationsPage-D2DUQAHJ.js} +1 -1
- package/dist/public/chunks/{PeoplePage-6YQLDOUL.js → PeoplePage-6F5GPQ7C.js} +1 -1
- package/dist/public/chunks/{PersonProfilePage-GYMAZFIK.js → PersonProfilePage-SPAYQPTD.js} +1 -1
- package/dist/public/chunks/{PersonalTokensPage-4BCR744B.js → PersonalTokensPage-SHAUJ74M.js} +1 -1
- package/dist/public/chunks/{PluginUserPage-IP6WL7MD.js → PluginUserPage-64FIWD7M.js} +1 -1
- package/dist/public/chunks/{RepositoryPage-TRD44LQ3.js → RepositoryPage-L7IHOXSN.js} +1 -1
- package/dist/public/chunks/{SettingsPage-4E3SLQ36.js → SettingsPage-U7WG4FHE.js} +1 -1
- package/dist/public/chunks/{SystemPage-C3XBKXKW.js → SystemPage-ZBC43OJH.js} +1 -1
- package/dist/public/chunks/{WorkspaceFormPage-2LS2X67Q.js → WorkspaceFormPage-3NVIIZ4P.js} +1 -1
- package/dist/public/chunks/{WorkspacesPage-5WB3CUTX.js → WorkspacesPage-G73HPBDD.js} +1 -1
- package/dist/public/chunks/{chunk-VFEBNOPX.js → chunk-2J3LGF5Q.js} +1 -1
- package/dist/public/chunks/chunk-2NNKHSZE.js +1 -0
- package/dist/public/chunks/chunk-4TQSTCIN.js +13 -0
- package/dist/public/chunks/chunk-64GPEGGE.js +1 -0
- package/dist/public/chunks/{chunk-WZVO3HG4.js → chunk-6FHY65EQ.js} +1 -1
- package/dist/public/chunks/{chunk-YYNMZ4WC.js → chunk-DEEO4D4N.js} +1 -1
- package/dist/public/chunks/{chunk-SDKV3OID.js → chunk-FSGVY7HB.js} +1 -1
- package/dist/public/chunks/chunk-GZBMHYYU.js +1 -0
- package/dist/public/chunks/{chunk-NQMDED2R.js → chunk-II4NXLSQ.js} +1 -1
- package/dist/public/chunks/{chunk-B6HGRBBX.js → chunk-ILH47V7O.js} +1 -1
- package/dist/public/chunks/{chunk-GRMX2HX6.js → chunk-JPL2NRWK.js} +1 -1
- package/dist/public/chunks/{chunk-NQOJAZFQ.js → chunk-JVJ5UWIK.js} +1 -1
- package/dist/public/chunks/{chunk-V5UDSIRA.js → chunk-K4TYMAA3.js} +1 -1
- package/dist/public/chunks/{chunk-EFEREKX4.js → chunk-KDUTK357.js} +1 -1
- package/dist/public/chunks/{chunk-RHIXHBS5.js → chunk-LZYAHJVQ.js} +1 -1
- package/dist/public/chunks/{chunk-5GZDBGAR.js → chunk-MKKFXRYO.js} +1 -1
- package/dist/public/chunks/{chunk-PEQNT5MO.js → chunk-NGQRNS3C.js} +1 -1
- package/dist/public/chunks/{chunk-IA6BJDKN.js → chunk-ONDKE72P.js} +1 -1
- package/dist/public/chunks/{chunk-WJ3OXMVX.js → chunk-PG7M7L24.js} +1 -1
- package/dist/public/chunks/{chunk-6GQL3EVJ.js → chunk-QQUFROWT.js} +1 -1
- package/dist/public/chunks/{chunk-UAH6DWNS.js → chunk-RB5FBDEY.js} +1 -1
- package/dist/public/chunks/{chunk-A2QWWA7O.js → chunk-RGXXD4DM.js} +1 -1
- package/dist/public/chunks/{chunk-AYXSBMWA.js → chunk-TC7TU4GK.js} +1 -1
- package/dist/public/chunks/chunk-WLIOZO3W.js +1 -0
- package/dist/public/chunks/{chunk-VXT3FIVL.js → chunk-XDUSY26N.js} +11 -11
- package/dist/public/client-4OAAOHF4.js +68 -0
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/components/BottomSheet.tsx +1 -1
- package/src/client/components/DetailsPanel.tsx +2 -1
- package/src/client/conversations/ConversationBrief.tsx +129 -65
- package/src/client/conversations/ConversationDetails.tsx +13 -14
- package/src/client/conversations/ConversationEventLog.tsx +20 -97
- package/src/client/conversations/ConversationHeader.tsx +41 -26
- package/src/client/conversations/ConversationHeaderActions.tsx +3 -1
- package/src/client/conversations/ConversationLinkedWork.tsx +30 -10
- package/src/client/conversations/ConversationMeta.tsx +15 -15
- package/src/client/conversations/ConversationPage.tsx +31 -7
- package/src/client/conversations/ConversationTranscript.tsx +234 -186
- package/src/client/conversations/ConversationUsageBreakdown.tsx +142 -64
- package/src/client/conversations/DetailsList.tsx +7 -1
- package/src/client/conversations/PendingMailboxStack.tsx +88 -44
- package/src/client/conversations/TranscriptActivityGroup.tsx +7 -14
- package/src/client/conversations/TranscriptEventView.tsx +7 -3
- package/src/client/conversations/TranscriptReasoningView.tsx +4 -2
- package/src/client/conversations/TranscriptRows.tsx +37 -5
- package/src/client/conversations/TranscriptView.tsx +4 -0
- package/src/client/conversations/queries.ts +74 -43
- package/src/client/conversations/transcriptBottomPinning.ts +13 -5
- package/src/client/conversations/transcriptListRows.ts +215 -0
- package/src/client/conversations/virtualRows.ts +168 -0
- package/src/client/http.ts +6 -5
- package/src/client/sentry.ts +53 -3
- package/src/conversation-cache.ts +8 -10
- package/src/mock-reporting/fixtures.ts +0 -8
- package/dist/public/chunks/chunk-GO45RQDL.js +0 -1
- package/dist/public/chunks/chunk-N4AONKD3.js +0 -1
- package/dist/public/chunks/chunk-QNA7F7QB.js +0 -1
- package/dist/public/chunks/chunk-R4ORRE77.js +0 -1
- package/dist/public/chunks/chunk-Z2JPRGFT.js +0 -1
- package/dist/public/client-TYC7JKRE.js +0 -91
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import
|
|
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`.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
|
344
|
-
// a reader scroll, so it must not change follow
|
|
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
|
-
|
|
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
|
+
}
|