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