@sentry/junior-dashboard 0.279.0 → 0.281.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +105 -43
- package/dist/client-manifest.json +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +105 -43
- package/dist/public/chunks/AdminPage-M5RBVZFN.js +1 -0
- package/dist/public/chunks/AutomationPage-WALCREDT.js +3 -0
- package/dist/public/chunks/AutomationRunsPage-CV47E4UX.js +1 -0
- package/dist/public/chunks/AutomationsPage-57L3AQVJ.js +1 -0
- package/dist/public/chunks/CodePage-XJUYMFJH.js +1 -0
- package/dist/public/chunks/ConnectionsPage-3QQAGYWF.js +1 -0
- package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
- package/dist/public/chunks/LocationDetailPage-4IL5HQML.js +1 -0
- package/dist/public/chunks/LocationsPage-D2DUQAHJ.js +1 -0
- package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
- package/dist/public/chunks/PeoplePage-6F5GPQ7C.js +1 -0
- package/dist/public/chunks/PersonProfilePage-SPAYQPTD.js +1 -0
- package/dist/public/chunks/PersonalTokensPage-SHAUJ74M.js +1 -0
- package/dist/public/chunks/PluginUserPage-64FIWD7M.js +1 -0
- package/dist/public/chunks/RepositoryPage-L7IHOXSN.js +1 -0
- package/dist/public/chunks/SettingsPage-U7WG4FHE.js +1 -0
- package/dist/public/chunks/SystemPage-ZBC43OJH.js +1 -0
- package/dist/public/chunks/WorkspaceFormPage-3NVIIZ4P.js +2 -0
- package/dist/public/chunks/WorkspacesPage-G73HPBDD.js +1 -0
- package/dist/public/chunks/chunk-2BCS5AQL.js +1 -0
- package/dist/public/chunks/chunk-2J3LGF5Q.js +1 -0
- package/dist/public/chunks/chunk-2NNKHSZE.js +1 -0
- package/dist/public/chunks/chunk-3Y3ICT6K.js +1 -0
- package/dist/public/chunks/chunk-4AKUZW6R.js +1 -0
- package/dist/public/chunks/chunk-4TQSTCIN.js +13 -0
- package/dist/public/chunks/chunk-4UGZNVJM.js +1 -0
- package/dist/public/chunks/chunk-564V7YHG.js +1 -0
- package/dist/public/chunks/chunk-5ISD3QSC.js +1 -0
- package/dist/public/chunks/chunk-5LNGNJ3P.js +1 -0
- package/dist/public/chunks/chunk-5ZA674OU.js +1 -0
- package/dist/public/chunks/chunk-64GPEGGE.js +1 -0
- package/dist/public/chunks/chunk-6FHY65EQ.js +1 -0
- package/dist/public/chunks/chunk-6FSJ3W2W.js +1 -0
- package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
- package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -0
- package/dist/public/chunks/chunk-BKOACADP.js +1 -0
- package/dist/public/chunks/chunk-CQY6BYUB.js +1 -0
- package/dist/public/chunks/chunk-CR2ICLSH.js +1 -0
- package/dist/public/chunks/chunk-DEEO4D4N.js +1 -0
- package/dist/public/chunks/chunk-DZB4LPI6.js +1 -0
- package/dist/public/chunks/chunk-FE522UEA.js +1 -0
- package/dist/public/chunks/chunk-FSGVY7HB.js +1 -0
- package/dist/public/chunks/chunk-FVX42XAD.js +1 -0
- package/dist/public/chunks/chunk-G5NWYJTV.js +1 -0
- package/dist/public/chunks/chunk-GMZIELFU.js +1 -0
- package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
- package/dist/public/chunks/chunk-GYRM5ZIV.js +1 -0
- package/dist/public/chunks/chunk-GZBMHYYU.js +1 -0
- package/dist/public/chunks/chunk-H2ZU34CP.js +1 -0
- package/dist/public/chunks/chunk-H76B43OQ.js +1 -0
- package/dist/public/chunks/chunk-HCLRG2EC.js +1 -0
- package/dist/public/chunks/chunk-II4NXLSQ.js +2 -0
- package/dist/public/chunks/chunk-ILH47V7O.js +1 -0
- package/dist/public/chunks/chunk-IZWTFLCC.js +1 -0
- package/dist/public/chunks/chunk-J37MXUED.js +1 -0
- package/dist/public/chunks/chunk-JAZABOBA.js +0 -0
- package/dist/public/chunks/chunk-JPL2NRWK.js +1 -0
- package/dist/public/chunks/chunk-JVJ5UWIK.js +1 -0
- package/dist/public/chunks/chunk-K4TYMAA3.js +63 -0
- package/dist/public/chunks/chunk-KDUTK357.js +1 -0
- package/dist/public/chunks/chunk-L2H3ZWZN.js +1 -0
- package/dist/public/chunks/chunk-LBELRO5P.js +1 -0
- package/dist/public/chunks/chunk-LKW7MULF.js +1 -0
- package/dist/public/chunks/chunk-LZYAHJVQ.js +1 -0
- package/dist/public/chunks/chunk-MEHLQZDG.js +3 -0
- package/dist/public/chunks/chunk-MKKFXRYO.js +1 -0
- package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
- package/dist/public/chunks/chunk-NGQRNS3C.js +1 -0
- package/dist/public/chunks/chunk-ONDKE72P.js +1 -0
- package/dist/public/chunks/chunk-PG7M7L24.js +1 -0
- package/dist/public/chunks/chunk-PQPAPPEI.js +1 -0
- package/dist/public/chunks/chunk-PXHLJIOT.js +1 -0
- package/dist/public/chunks/chunk-QOX5RMLM.js +1 -0
- package/dist/public/chunks/chunk-QPBLZSNX.js +1 -0
- package/dist/public/chunks/chunk-QQUFROWT.js +1 -0
- package/dist/public/chunks/chunk-RB5FBDEY.js +1 -0
- package/dist/public/chunks/chunk-RGXXD4DM.js +41 -0
- package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
- package/dist/public/chunks/chunk-SIOW7OOW.js +1 -0
- package/dist/public/chunks/chunk-SUX4QBVF.js +1 -0
- package/dist/public/chunks/chunk-T4M53QQT.js +1 -0
- package/dist/public/chunks/chunk-TC7TU4GK.js +1 -0
- package/dist/public/chunks/chunk-TFJOEF3Q.js +1 -0
- package/dist/public/chunks/chunk-UJD3MBMB.js +1 -0
- package/dist/public/chunks/chunk-UK2V4UBT.js +1 -0
- package/dist/public/chunks/chunk-UT6HZPGL.js +1 -0
- package/dist/public/chunks/chunk-UZ44J2LH.js +1 -0
- package/dist/public/chunks/chunk-VC6IS564.js +1 -0
- package/dist/public/chunks/chunk-WLIOZO3W.js +1 -0
- package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
- package/dist/public/chunks/chunk-XDUSY26N.js +55 -0
- package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
- package/dist/public/chunks/chunk-YPMUHXYT.js +147 -0
- package/dist/public/chunks/chunk-ZIJ6SJDN.js +1 -0
- package/dist/public/chunks/code-highlighter-Q7WC7AEF.js +12 -0
- package/dist/public/client-4OAAOHF4.js +68 -0
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +1 -1
- package/src/assets.ts +4 -1
- package/src/client/App.tsx +100 -23
- package/src/client/TracedRoutes.tsx +8 -4
- package/src/client/code-highlighter.ts +26 -0
- package/src/client/code.tsx +2 -22
- package/src/client/components/BottomSheet.tsx +1 -1
- package/src/client/components/DetailsPanel.tsx +2 -1
- package/src/client/components/Drawer.tsx +12 -71
- package/src/client/components/DrawerContent.tsx +76 -0
- package/src/client/components/ProfileMenu.tsx +12 -3
- package/src/client/components/UserInputForm.tsx +28 -5
- package/src/client/conversations/ConversationBrief.tsx +129 -65
- package/src/client/conversations/ConversationCatchUp.tsx +107 -0
- package/src/client/conversations/ConversationContextTags.tsx +110 -0
- package/src/client/conversations/ConversationDetails.tsx +13 -14
- package/src/client/conversations/ConversationEventLog.tsx +135 -70
- package/src/client/conversations/ConversationHeader.tsx +74 -65
- package/src/client/conversations/ConversationHeaderActions.tsx +36 -38
- package/src/client/conversations/ConversationHomeList.tsx +41 -3
- package/src/client/conversations/ConversationLinkedWork.tsx +30 -10
- package/src/client/conversations/ConversationMeta.tsx +21 -27
- package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
- package/src/client/conversations/ConversationPage.tsx +219 -157
- 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/ShareConversationButton.tsx +5 -50
- package/src/client/conversations/SlackMark.tsx +16 -4
- 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 +61 -16
- package/src/client/conversations/TranscriptView.tsx +25 -13
- package/src/client/conversations/conversationArchive.ts +22 -0
- package/src/client/conversations/conversationCatchUpState.ts +96 -0
- package/src/client/conversations/conversationMemory.ts +8 -1
- package/src/client/conversations/queries.ts +85 -46
- package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
- package/src/client/conversations/transcriptBottomPinning.ts +40 -8
- package/src/client/conversations/transcriptListRows.ts +215 -0
- package/src/client/conversations/transcriptReadPosition.ts +24 -2
- package/src/client/conversations/turnContext.ts +1 -1
- package/src/client/conversations/virtualRows.ts +168 -0
- package/src/client/http.ts +6 -5
- package/src/client/pages/admin/AdminPage.tsx +1 -1
- package/src/client/pages/admin/ServiceAccountsSection.tsx +1 -1
- package/src/client/pages/memory/memoryDashboard.ts +1 -1
- package/src/client/pages/memory/memoryRecord.ts +1 -1
- package/src/client/pages/settings/ConnectionsPage.tsx +1 -1
- package/src/client/pages/user/PluginUserPage.tsx +0 -6
- package/src/client/pages/user/pluginUserPageRoutes.ts +5 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/reviewItemFormat.ts +1 -1
- package/src/client/sentry.ts +53 -3
- package/src/conversation-cache.ts +8 -10
- package/src/mock-reporting/fixtures.ts +43 -8
- package/src/mock-reporting/routes.ts +40 -3
- package/src/public-assets.ts +28 -20
- package/src/shell.ts +12 -2
- package/dist/public/client-ZCZXZBTL.js +0 -460
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Open a long Conversation at the reader's read
|
|
2
|
+
* Open a long Conversation away from its latest events: at the reader's read
|
|
3
|
+
* position, or at a linked event, Turn, or memory.
|
|
3
4
|
*
|
|
4
5
|
* The detail holds the latest page of events. When the read position is
|
|
5
6
|
* older than that page, the transcript starts with the page around the read
|
|
6
7
|
* position instead: the last read events and the unread events after them.
|
|
7
8
|
* The first detail fetch of a visit brings that page, so it needs no second
|
|
8
|
-
* request.
|
|
9
|
+
* request. A link to a row that the transcript does not hold starts it with
|
|
10
|
+
* the page around that row, which the server finds in one request.
|
|
9
11
|
* Like earlier pages, newer pages load when the reader scrolls near them, so
|
|
10
12
|
* the server reads only the pages that the reader reaches.
|
|
11
13
|
*
|
|
@@ -54,23 +56,33 @@ export type TranscriptHistory = {
|
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* page instead.
|
|
59
|
+
* The row that the transcript opens at: the `unreadCursor` of the read
|
|
60
|
+
* position, or a linked event seq or Turn id.
|
|
60
61
|
*/
|
|
61
|
-
export
|
|
62
|
+
export type HistoryStart =
|
|
63
|
+
| { around: string }
|
|
64
|
+
| { event: string }
|
|
65
|
+
| { turn: string };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load the transcript from the page around `start`. Returns `undefined` when
|
|
69
|
+
* there is no start, or when the first page fails, so the caller shows the
|
|
70
|
+
* latest page instead.
|
|
71
|
+
*/
|
|
72
|
+
export function useHistoryAround(input: {
|
|
62
73
|
conversationId: string;
|
|
63
74
|
/** The latest page, from the polled detail. */
|
|
64
75
|
detail: ConversationDetailReport | undefined;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
/** Skip the newer pages. The caller then passes no `unreadCursor`. */
|
|
76
|
+
start?: HistoryStart;
|
|
77
|
+
/** Skip the newer pages. The caller then passes no `start`. */
|
|
68
78
|
onShowLatest: () => void;
|
|
69
|
-
/** The page around `
|
|
70
|
-
|
|
79
|
+
/** The page around `start` failed. */
|
|
80
|
+
onStartFailed?: () => void;
|
|
81
|
+
/** The page around `start`, when the detail brought it. */
|
|
82
|
+
startPage?: ConversationEventPage;
|
|
71
83
|
}): (TranscriptHistory & { isPending: boolean }) | undefined {
|
|
72
|
-
const { conversationId, detail, onShowLatest,
|
|
73
|
-
|
|
84
|
+
const { conversationId, detail, onShowLatest, onStartFailed, start } = input;
|
|
85
|
+
const startKey = start ? Object.entries(start)[0]?.join(":") : undefined;
|
|
74
86
|
const latestEventsRef = useRef<ConversationReportEvent[]>([]);
|
|
75
87
|
const latestEvents = useMemo(() => {
|
|
76
88
|
const kept = keepLatestEvents(latestEventsRef.current, detail?.events);
|
|
@@ -78,28 +90,24 @@ export function useUnreadHistory(input: {
|
|
|
78
90
|
return kept;
|
|
79
91
|
}, [detail?.events]);
|
|
80
92
|
const latestFirstSeq = latestEvents[0]?.seq;
|
|
81
|
-
|
|
93
|
+
// The start whose newer pages joined the latest page.
|
|
94
|
+
const joinedRef = useRef<string>(undefined);
|
|
82
95
|
const newer = useInfiniteQuery({
|
|
83
|
-
// Only the page around the
|
|
84
|
-
//
|
|
85
|
-
enabled: Boolean(
|
|
96
|
+
// Only the page around the start loads by itself. The data never goes
|
|
97
|
+
// stale, so a refetch cannot reload every newer page at once.
|
|
98
|
+
enabled: Boolean(start),
|
|
86
99
|
staleTime: Infinity,
|
|
87
|
-
// Each visit starts again from the page around the
|
|
88
|
-
//
|
|
89
|
-
//
|
|
100
|
+
// Each visit starts again from the page around the start. Pages from an
|
|
101
|
+
// earlier visit can end at an old latest event, which would leave a gap
|
|
102
|
+
// before the latest page.
|
|
90
103
|
gcTime: 0,
|
|
91
|
-
queryKey: ["conversation", conversationId, "
|
|
104
|
+
queryKey: ["conversation", conversationId, "around", startKey, "newer"],
|
|
92
105
|
queryFn: ({ pageParam, signal }) =>
|
|
93
106
|
readConversationEventsFrom(conversationId, pageParam, signal),
|
|
94
|
-
initialPageParam: { around:
|
|
107
|
+
initialPageParam: (start ?? { around: "" }) as NewerPageParam,
|
|
95
108
|
// The page that came with the detail. Without it, the query reads it.
|
|
96
|
-
...(
|
|
97
|
-
? {
|
|
98
|
-
initialData: {
|
|
99
|
-
pageParams: [{ around: unreadCursor ?? "" }],
|
|
100
|
-
pages: [unreadPage],
|
|
101
|
-
},
|
|
102
|
-
}
|
|
109
|
+
...(input.startPage && start
|
|
110
|
+
? { initialData: { pageParams: [start], pages: [input.startPage] } }
|
|
103
111
|
: undefined),
|
|
104
112
|
getNextPageParam: (page): NewerPageParam | undefined =>
|
|
105
113
|
reachesLatest(page, latestFirstSeq) || !page.nextCursor
|
|
@@ -110,13 +118,7 @@ export function useUnreadHistory(input: {
|
|
|
110
118
|
const firstNewerPage = newer.data?.pages[0];
|
|
111
119
|
const earlier = useInfiniteQuery({
|
|
112
120
|
enabled: false,
|
|
113
|
-
queryKey: [
|
|
114
|
-
"conversation",
|
|
115
|
-
conversationId,
|
|
116
|
-
"unread",
|
|
117
|
-
unreadCursor,
|
|
118
|
-
"earlier",
|
|
119
|
-
],
|
|
121
|
+
queryKey: ["conversation", conversationId, "around", startKey, "earlier"],
|
|
120
122
|
queryFn: ({ pageParam, signal }) =>
|
|
121
123
|
readConversationEvents(conversationId, pageParam, signal),
|
|
122
124
|
initialPageParam: firstNewerPage?.previousCursor ?? "",
|
|
@@ -130,16 +132,20 @@ export function useUnreadHistory(input: {
|
|
|
130
132
|
const reached = lastNewerPage
|
|
131
133
|
? reachesLatest(lastNewerPage, latestFirstSeq)
|
|
132
134
|
: false;
|
|
133
|
-
if (reached) joinedRef.current =
|
|
135
|
+
if (reached) joinedRef.current = startKey;
|
|
134
136
|
// No newer page can fill the gap before the latest page, or the latest
|
|
135
137
|
// events already on screen would leave until one does.
|
|
136
138
|
const skipped =
|
|
137
139
|
lastNewerPage !== undefined &&
|
|
138
140
|
!reached &&
|
|
139
|
-
(joinedRef.current || !lastNewerPage.nextCursor);
|
|
141
|
+
(joinedRef.current === startKey || !lastNewerPage.nextCursor);
|
|
140
142
|
useEffect(() => {
|
|
141
143
|
if (skipped) onShowLatest();
|
|
142
144
|
}, [onShowLatest, skipped]);
|
|
145
|
+
const startFailed = Boolean(start && newer.error && !newerPages);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (startFailed) onStartFailed?.();
|
|
148
|
+
}, [onStartFailed, startFailed]);
|
|
143
149
|
const pages = useMemo(
|
|
144
150
|
() => [...(earlierPages ?? []), ...(newerPages ?? [])],
|
|
145
151
|
[earlierPages, newerPages],
|
|
@@ -156,7 +162,7 @@ export function useUnreadHistory(input: {
|
|
|
156
162
|
[detail, latestEvents, newerPages, pages, reached],
|
|
157
163
|
);
|
|
158
164
|
|
|
159
|
-
if (!
|
|
165
|
+
if (!start || skipped || startFailed) {
|
|
160
166
|
return undefined;
|
|
161
167
|
}
|
|
162
168
|
|
|
@@ -225,10 +231,10 @@ function reachesLatest(
|
|
|
225
231
|
return lastSeq + 1 >= latestFirstSeq;
|
|
226
232
|
}
|
|
227
233
|
|
|
228
|
-
/** The first page is around the
|
|
229
|
-
type NewerPageParam =
|
|
234
|
+
/** The first page is around the start. The rest follow it. */
|
|
235
|
+
type NewerPageParam = HistoryStart | { after: string };
|
|
230
236
|
|
|
231
|
-
/** Read one bounded page of events around or after a
|
|
237
|
+
/** Read one bounded page of events around a start or after a cursor. */
|
|
232
238
|
function readConversationEventsFrom(
|
|
233
239
|
conversationId: string,
|
|
234
240
|
param: NewerPageParam,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createContext,
|
|
2
3
|
useCallback,
|
|
3
4
|
useEffect,
|
|
4
5
|
useLayoutEffect,
|
|
@@ -52,6 +53,8 @@ type BottomPinResult = {
|
|
|
52
53
|
contentRef: RefCallback<HTMLDivElement>;
|
|
53
54
|
hasPendingUpdate: boolean;
|
|
54
55
|
jumpToBottom: () => void;
|
|
56
|
+
/** The `data-scroll-anchor` key of the row that holds the reader's place. */
|
|
57
|
+
scrollAnchorKey: () => string | undefined;
|
|
55
58
|
showJumpToLatest: boolean;
|
|
56
59
|
};
|
|
57
60
|
|
|
@@ -319,6 +322,8 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
319
322
|
historyVersion: string;
|
|
320
323
|
juniorMessageVersion: string;
|
|
321
324
|
pinRequestVersion?: number;
|
|
325
|
+
/** Open where a link shows its row, not at the latest message. */
|
|
326
|
+
startAtLink?: boolean;
|
|
322
327
|
/** Open at the "New" marker when the transcript has one. */
|
|
323
328
|
startAtUnread?: boolean;
|
|
324
329
|
view: TranscriptViewMode;
|
|
@@ -331,10 +336,14 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
331
336
|
endsAtLatestRef.current = input.endsAtLatest ?? true;
|
|
332
337
|
const followingRef = useRef(false);
|
|
333
338
|
const initializedRef = useRef(false);
|
|
334
|
-
|
|
339
|
+
// The Conversation and start that the transcript opened with. A link at
|
|
340
|
+
// open that fails becomes a normal start, so the transcript opens again.
|
|
341
|
+
const startedRef = useRef<string | null>(null);
|
|
335
342
|
const previousScrollTopRef = useRef<number | null>(null);
|
|
336
|
-
// The scroll position that the last restore
|
|
337
|
-
// 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.
|
|
338
347
|
const restoredScrollTopRef = useRef<number | null>(null);
|
|
339
348
|
const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
|
|
340
349
|
const pinRequestVersionRef = useRef(input.pinRequestVersion ?? 0);
|
|
@@ -415,7 +424,6 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
415
424
|
const root = scrollRootFor(contentElementRef.current);
|
|
416
425
|
if (!root) return;
|
|
417
426
|
|
|
418
|
-
if (source === "scroll") rememberPlace();
|
|
419
427
|
const snapshot = scrollSnapshot(root);
|
|
420
428
|
const previousScrollTop = previousScrollTopRef.current;
|
|
421
429
|
previousScrollTopRef.current = snapshot.scrollTop;
|
|
@@ -427,6 +435,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
427
435
|
if (restored !== null && Math.abs(snapshot.scrollTop - restored) < 1) {
|
|
428
436
|
return;
|
|
429
437
|
}
|
|
438
|
+
rememberPlace();
|
|
430
439
|
}
|
|
431
440
|
|
|
432
441
|
const intent = transcriptFollowIntent({
|
|
@@ -536,6 +545,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
536
545
|
);
|
|
537
546
|
const snapshot = scrollSnapshot(root);
|
|
538
547
|
previousScrollTopRef.current = snapshot.scrollTop;
|
|
548
|
+
restoredScrollTopRef.current = snapshot.scrollTop;
|
|
539
549
|
// Unread rows that fit above the bottom leave the reader at the latest.
|
|
540
550
|
const atBottom = endsAtLatestRef.current && isNearScrollBottom(snapshot);
|
|
541
551
|
setFollowingIntent(atBottom);
|
|
@@ -546,13 +556,20 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
546
556
|
|
|
547
557
|
useBrowserLayoutEffect(() => {
|
|
548
558
|
if (!contentElement || !input.conversationId) return;
|
|
549
|
-
|
|
559
|
+
const start = `${input.conversationId}:${input.startAtLink ? "link" : "start"}`;
|
|
560
|
+
if (startedRef.current === start) return;
|
|
550
561
|
|
|
551
562
|
const root = scrollRootFor(contentElement);
|
|
552
563
|
if (!root) return;
|
|
553
564
|
|
|
554
|
-
|
|
565
|
+
startedRef.current = start;
|
|
555
566
|
setHasPendingUpdate(false);
|
|
567
|
+
// The link scrolls to its row, maybe before this runs. Do not move away.
|
|
568
|
+
if (input.startAtLink) {
|
|
569
|
+
previousScrollTopRef.current = scrollSnapshot(root).scrollTop;
|
|
570
|
+
setFollowingIntent(false);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
556
573
|
if (input.startAtUnread && openAtUnreadMarker()) return;
|
|
557
574
|
|
|
558
575
|
setScrollTop(root, scrollSnapshot(root).scrollHeight);
|
|
@@ -565,6 +582,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
565
582
|
}, [
|
|
566
583
|
contentElement,
|
|
567
584
|
input.conversationId,
|
|
585
|
+
input.startAtLink,
|
|
568
586
|
input.startAtUnread,
|
|
569
587
|
openAtUnreadMarker,
|
|
570
588
|
setFollowingIntent,
|
|
@@ -678,7 +696,12 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
678
696
|
measurePosition("measure");
|
|
679
697
|
rememberPlace();
|
|
680
698
|
// Listen on the scroll root, so gestures outside transcript rows count.
|
|
681
|
-
|
|
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
|
+
});
|
|
682
705
|
target.addEventListener("wheel", onWheel, { passive: true });
|
|
683
706
|
target.addEventListener("touchstart", onTouchStart, {
|
|
684
707
|
passive: true,
|
|
@@ -688,7 +711,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
688
711
|
});
|
|
689
712
|
window.addEventListener("resize", syncAfterLayoutChange);
|
|
690
713
|
return () => {
|
|
691
|
-
target.removeEventListener("scroll", onScroll);
|
|
714
|
+
target.removeEventListener("scroll", onScroll, { capture: true });
|
|
692
715
|
target.removeEventListener("wheel", onWheel);
|
|
693
716
|
target.removeEventListener("touchstart", onTouchStart);
|
|
694
717
|
target.removeEventListener("touchmove", onTouchMove);
|
|
@@ -761,6 +784,8 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
761
784
|
scrollToBottom("auto");
|
|
762
785
|
}, [input.pinRequestVersion, scrollToBottom, setFollowingIntent]);
|
|
763
786
|
|
|
787
|
+
const scrollAnchorKey = useCallback(() => scrollAnchorRef.current?.key, []);
|
|
788
|
+
|
|
764
789
|
return useMemo(
|
|
765
790
|
() => ({
|
|
766
791
|
anchorRef,
|
|
@@ -768,6 +793,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
768
793
|
contentRef,
|
|
769
794
|
hasPendingUpdate,
|
|
770
795
|
jumpToBottom,
|
|
796
|
+
scrollAnchorKey,
|
|
771
797
|
showJumpToLatest: shouldShowJumpToLatest({
|
|
772
798
|
enabled: input.enabled,
|
|
773
799
|
endsAtLatest: input.endsAtLatest,
|
|
@@ -785,10 +811,16 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
785
811
|
input.endsAtLatest,
|
|
786
812
|
jumpToBottom,
|
|
787
813
|
openedAtUnread,
|
|
814
|
+
scrollAnchorKey,
|
|
788
815
|
],
|
|
789
816
|
);
|
|
790
817
|
}
|
|
791
818
|
|
|
819
|
+
/** Jump to the latest row from inside the transcript, like the floating pill. */
|
|
820
|
+
export const TranscriptJumpToLatestContext = createContext<
|
|
821
|
+
(() => void) | undefined
|
|
822
|
+
>(undefined);
|
|
823
|
+
|
|
792
824
|
/**
|
|
793
825
|
* Load the next history page when the reader scrolls near the edge where it
|
|
794
826
|
* goes: the top for earlier pages, the bottom for newer pages.
|
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Rows from stored events carry `data-transcript-seq`. While the page is
|
|
5
5
|
* visible, the reporter sends the sequence of the last row that has reached
|
|
6
6
|
* the viewport. The server keeps the highest value, so every client of the
|
|
7
|
-
* same user shares one position.
|
|
7
|
+
* same user shares one position. A write does not change the detail version,
|
|
8
|
+
* so other clients get it with the next detail change or a reload.
|
|
8
9
|
*
|
|
9
10
|
* The page reads the position once when a Conversation opens. That value
|
|
10
11
|
* places the "New" marker and, by the reader's preference, where the
|
|
@@ -139,6 +140,27 @@ function readPositionAtOpen(
|
|
|
139
140
|
return { lastReadSeq, unread: { afterSeq: lastReadSeq, throughSeq } };
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Put a stored read position in the cached detail. Polls answer 304 after a
|
|
145
|
+
* read, so the cache must also drop the unread cursor once the reader reaches
|
|
146
|
+
* the latest page, as the server would. Otherwise the next visit in this
|
|
147
|
+
* session opens at rows the reader has already read.
|
|
148
|
+
*/
|
|
149
|
+
function withReadPosition(
|
|
150
|
+
detail: ConversationDetailReport,
|
|
151
|
+
readPosition: ConversationReadPosition,
|
|
152
|
+
): ConversationDetailReport {
|
|
153
|
+
const firstSeq = detail.events[0]?.seq;
|
|
154
|
+
if (
|
|
155
|
+
!detail.unreadCursor ||
|
|
156
|
+
(firstSeq !== undefined && readPosition.lastReadSeq + 1 < firstSeq)
|
|
157
|
+
) {
|
|
158
|
+
return { ...detail, readPosition };
|
|
159
|
+
}
|
|
160
|
+
const { unreadCursor: _cursor, unreadPage: _page, ...rest } = detail;
|
|
161
|
+
return { ...rest, readPosition };
|
|
162
|
+
}
|
|
163
|
+
|
|
142
164
|
/**
|
|
143
165
|
* Send read positions for one Conversation. A position at or before the one
|
|
144
166
|
* in the cached detail is not sent, because the server already has it. The
|
|
@@ -157,7 +179,7 @@ export function useMarkConversationRead(conversationId: string) {
|
|
|
157
179
|
onSuccess: (readPosition) => {
|
|
158
180
|
queryClient.setQueryData<ConversationDetailReport>(
|
|
159
181
|
conversationDetailQueryKey(conversationId),
|
|
160
|
-
(detail) =>
|
|
182
|
+
(detail) => detail && withReadPosition(detail, readPosition),
|
|
161
183
|
);
|
|
162
184
|
},
|
|
163
185
|
});
|