@sentry/junior-dashboard 0.276.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 +196 -72
- package/dist/client.js +94 -89
- package/dist/index.d.ts +1 -1
- package/dist/index.js +191 -70
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +20 -2
- package/src/app.ts +80 -12
- package/src/auth.ts +5 -1
- package/src/client/App.tsx +9 -4
- package/src/client/components/DetailsPanel.tsx +1 -1
- package/src/client/components/FormNotice.tsx +11 -14
- package/src/client/components/ProfileMenu.tsx +5 -6
- package/src/client/components/ui/resizable.tsx +50 -0
- package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
- package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
- package/src/client/conversations/ConversationChildAgents.tsx +2 -1
- package/src/client/conversations/ConversationDetails.tsx +6 -3
- package/src/client/conversations/ConversationHeader.tsx +15 -2
- package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
- package/src/client/conversations/ConversationHomeList.tsx +1 -1
- package/src/client/conversations/ConversationMemories.tsx +64 -67
- package/src/client/conversations/ConversationMeta.tsx +2 -7
- package/src/client/conversations/ConversationPage.tsx +175 -79
- package/src/client/conversations/ConversationSidebar.tsx +82 -25
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/ConversationWatches.tsx +15 -21
- package/src/client/conversations/ConversationWorkspace.tsx +132 -44
- package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
- package/src/client/conversations/DetailsList.tsx +8 -14
- package/src/client/conversations/EventDetails.tsx +2 -62
- package/src/client/conversations/MemoryIcons.tsx +23 -0
- package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
- package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
- package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
- package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
- package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
- package/src/client/conversations/TranscriptView.tsx +89 -20
- package/src/client/conversations/autoArchiveQueries.ts +53 -0
- package/src/client/conversations/conversationArchive.ts +255 -0
- package/src/client/conversations/conversationMemory.ts +2 -1
- package/src/client/conversations/conversationSections.ts +24 -18
- 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 +121 -250
- 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/conversations/usePinConversation.ts +88 -0
- package/src/client/display-text/DisplayText.tsx +7 -2
- package/src/client/display-text/codeBlocks.ts +92 -0
- package/src/client/format.ts +3 -67
- package/src/client/http.ts +5 -5
- package/src/client/markdownExport.ts +32 -42
- package/src/client/pages/code/RepositoryTabs.tsx +2 -2
- 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/pages/settings/AutoArchiveSettings.tsx +145 -0
- package/src/client/pages/settings/SettingsPage.tsx +2 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
- package/src/client/review-desk/queries.ts +2 -1
- package/src/client/types.ts +19 -33
- package/src/conversation-cache.ts +7 -9
- package/src/mock-reporting/fixtures.ts +126 -60
- package/src/mock-reporting/routes.ts +50 -1
- package/src/tailwind.css +30 -0
|
@@ -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;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a long Conversation at the reader's read position.
|
|
3
|
+
*
|
|
4
|
+
* The detail holds the latest page of events. When the read position is
|
|
5
|
+
* older than that page, the transcript starts with the page around the read
|
|
6
|
+
* position instead: the last read events and the unread events after them.
|
|
7
|
+
* The first detail fetch of a visit brings that page, so it needs no second
|
|
8
|
+
* request.
|
|
9
|
+
* Like earlier pages, newer pages load when the reader scrolls near them, so
|
|
10
|
+
* the server reads only the pages that the reader reaches.
|
|
11
|
+
*
|
|
12
|
+
* The latest page joins the transcript only after the newer pages reach it,
|
|
13
|
+
* so the transcript never has a gap. Until then the transcript ends with a
|
|
14
|
+
* row that loads the next newer page. A reader who jumps to the latest skips
|
|
15
|
+
* the newer pages: the transcript shows the latest page from the detail at
|
|
16
|
+
* once, and earlier pages load above it as the reader scrolls up. When a
|
|
17
|
+
* poll skips past the newer pages after they join the latest page, or past a
|
|
18
|
+
* newer page that has nothing after it, the transcript shows the latest page
|
|
19
|
+
* in the same way, so it never drops the skipped events.
|
|
20
|
+
*/
|
|
21
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
22
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
23
|
+
import {
|
|
24
|
+
conversationEventPageSchema,
|
|
25
|
+
type ConversationDetailReport,
|
|
26
|
+
type ConversationEventPage,
|
|
27
|
+
type ConversationReportEvent,
|
|
28
|
+
} from "@sentry/junior/api/schema";
|
|
29
|
+
|
|
30
|
+
import { fetchDashboardJson } from "../http";
|
|
31
|
+
import { readConversationEvents } from "./queries";
|
|
32
|
+
import {
|
|
33
|
+
buildUnreadConversationTranscript,
|
|
34
|
+
conversationHistoryVersion,
|
|
35
|
+
} from "./transcript";
|
|
36
|
+
|
|
37
|
+
/** The transcript to show and how to load more of it. */
|
|
38
|
+
export type TranscriptHistory = {
|
|
39
|
+
data: ConversationDetailReport | undefined;
|
|
40
|
+
hasPreviousPage: boolean;
|
|
41
|
+
historyError: Error | null;
|
|
42
|
+
historyVersion: string;
|
|
43
|
+
isLoadingPreviousPage: boolean;
|
|
44
|
+
loadPreviousPage: () => void;
|
|
45
|
+
/** Set while newer pages have not reached the latest page. */
|
|
46
|
+
newer?: {
|
|
47
|
+
error: Error | null;
|
|
48
|
+
/** Load the next newer page, or retry a failed one. */
|
|
49
|
+
load: () => void;
|
|
50
|
+
loading: boolean;
|
|
51
|
+
/** Skip the newer pages and show the latest page. */
|
|
52
|
+
showLatest: () => void;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load the transcript from `unreadCursor`. Returns `undefined` when there is
|
|
58
|
+
* no cursor, or when the first page fails, so the caller shows the latest
|
|
59
|
+
* page instead.
|
|
60
|
+
*/
|
|
61
|
+
export function useUnreadHistory(input: {
|
|
62
|
+
conversationId: string;
|
|
63
|
+
/** The latest page, from the polled detail. */
|
|
64
|
+
detail: ConversationDetailReport | undefined;
|
|
65
|
+
/** `unreadCursor` from the detail when the Conversation opened. */
|
|
66
|
+
unreadCursor: string | undefined;
|
|
67
|
+
/** Skip the newer pages. The caller then passes no `unreadCursor`. */
|
|
68
|
+
onShowLatest: () => void;
|
|
69
|
+
/** The page around `unreadCursor`, when the detail brought it. */
|
|
70
|
+
unreadPage?: ConversationEventPage;
|
|
71
|
+
}): (TranscriptHistory & { isPending: boolean }) | undefined {
|
|
72
|
+
const { conversationId, detail, onShowLatest, unreadCursor, unreadPage } =
|
|
73
|
+
input;
|
|
74
|
+
const latestEventsRef = useRef<ConversationReportEvent[]>([]);
|
|
75
|
+
const latestEvents = useMemo(() => {
|
|
76
|
+
const kept = keepLatestEvents(latestEventsRef.current, detail?.events);
|
|
77
|
+
latestEventsRef.current = kept;
|
|
78
|
+
return kept;
|
|
79
|
+
}, [detail?.events]);
|
|
80
|
+
const latestFirstSeq = latestEvents[0]?.seq;
|
|
81
|
+
const joinedRef = useRef(false);
|
|
82
|
+
const newer = useInfiniteQuery({
|
|
83
|
+
// Only the page around the read position loads by itself. The data never
|
|
84
|
+
// goes stale, so a refetch cannot reload every newer page at once.
|
|
85
|
+
enabled: Boolean(unreadCursor),
|
|
86
|
+
staleTime: Infinity,
|
|
87
|
+
// Each visit starts again from the page around the read position. Pages
|
|
88
|
+
// from an earlier visit can end at an old latest event, which would
|
|
89
|
+
// leave a gap before the latest page.
|
|
90
|
+
gcTime: 0,
|
|
91
|
+
queryKey: ["conversation", conversationId, "unread", unreadCursor, "newer"],
|
|
92
|
+
queryFn: ({ pageParam, signal }) =>
|
|
93
|
+
readConversationEventsFrom(conversationId, pageParam, signal),
|
|
94
|
+
initialPageParam: { around: unreadCursor ?? "" } as NewerPageParam,
|
|
95
|
+
// The page that came with the detail. Without it, the query reads it.
|
|
96
|
+
...(unreadPage
|
|
97
|
+
? {
|
|
98
|
+
initialData: {
|
|
99
|
+
pageParams: [{ around: unreadCursor ?? "" }],
|
|
100
|
+
pages: [unreadPage],
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
: undefined),
|
|
104
|
+
getNextPageParam: (page): NewerPageParam | undefined =>
|
|
105
|
+
reachesLatest(page, latestFirstSeq) || !page.nextCursor
|
|
106
|
+
? undefined
|
|
107
|
+
: { after: page.nextCursor },
|
|
108
|
+
retry: false,
|
|
109
|
+
});
|
|
110
|
+
const firstNewerPage = newer.data?.pages[0];
|
|
111
|
+
const earlier = useInfiniteQuery({
|
|
112
|
+
enabled: false,
|
|
113
|
+
queryKey: [
|
|
114
|
+
"conversation",
|
|
115
|
+
conversationId,
|
|
116
|
+
"unread",
|
|
117
|
+
unreadCursor,
|
|
118
|
+
"earlier",
|
|
119
|
+
],
|
|
120
|
+
queryFn: ({ pageParam, signal }) =>
|
|
121
|
+
readConversationEvents(conversationId, pageParam, signal),
|
|
122
|
+
initialPageParam: firstNewerPage?.previousCursor ?? "",
|
|
123
|
+
getNextPageParam: (page) => page.previousCursor,
|
|
124
|
+
retry: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const newerPages = newer.data?.pages;
|
|
128
|
+
const earlierPages = earlier.data?.pages;
|
|
129
|
+
const lastNewerPage = newerPages?.at(-1);
|
|
130
|
+
const reached = lastNewerPage
|
|
131
|
+
? reachesLatest(lastNewerPage, latestFirstSeq)
|
|
132
|
+
: false;
|
|
133
|
+
if (reached) joinedRef.current = true;
|
|
134
|
+
// No newer page can fill the gap before the latest page, or the latest
|
|
135
|
+
// events already on screen would leave until one does.
|
|
136
|
+
const skipped =
|
|
137
|
+
lastNewerPage !== undefined &&
|
|
138
|
+
!reached &&
|
|
139
|
+
(joinedRef.current || !lastNewerPage.nextCursor);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (skipped) onShowLatest();
|
|
142
|
+
}, [onShowLatest, skipped]);
|
|
143
|
+
const pages = useMemo(
|
|
144
|
+
() => [...(earlierPages ?? []), ...(newerPages ?? [])],
|
|
145
|
+
[earlierPages, newerPages],
|
|
146
|
+
);
|
|
147
|
+
const data = useMemo(
|
|
148
|
+
() =>
|
|
149
|
+
detail && newerPages
|
|
150
|
+
? buildUnreadConversationTranscript(
|
|
151
|
+
detail,
|
|
152
|
+
pages,
|
|
153
|
+
reached ? latestEvents : [],
|
|
154
|
+
)
|
|
155
|
+
: undefined,
|
|
156
|
+
[detail, latestEvents, newerPages, pages, reached],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (!unreadCursor || skipped || (newer.error && !newerPages)) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const hasPreviousPage = earlier.data
|
|
164
|
+
? earlier.hasNextPage
|
|
165
|
+
: Boolean(firstNewerPage?.previousCursor);
|
|
166
|
+
// `cancelRefetch: false` lets a second call join the page read in flight.
|
|
167
|
+
// By default it cancels that read and starts the same read again.
|
|
168
|
+
return {
|
|
169
|
+
data,
|
|
170
|
+
hasPreviousPage,
|
|
171
|
+
historyError: earlier.error,
|
|
172
|
+
historyVersion: conversationHistoryVersion(pages),
|
|
173
|
+
isLoadingPreviousPage: earlier.isFetchingNextPage,
|
|
174
|
+
isPending: !newerPages,
|
|
175
|
+
loadPreviousPage: () => {
|
|
176
|
+
if (hasPreviousPage) void earlier.fetchNextPage({ cancelRefetch: false });
|
|
177
|
+
},
|
|
178
|
+
...(reached
|
|
179
|
+
? undefined
|
|
180
|
+
: {
|
|
181
|
+
newer: {
|
|
182
|
+
error: newer.error,
|
|
183
|
+
load: () => {
|
|
184
|
+
if (newer.hasNextPage) {
|
|
185
|
+
void newer.fetchNextPage({ cancelRefetch: false });
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
loading: newer.isFetchingNextPage,
|
|
189
|
+
showLatest: onShowLatest,
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Keep the events that new events push out of the latest page. Without them,
|
|
197
|
+
* the latest page would start after the newer pages again, and the transcript
|
|
198
|
+
* would drop its latest events until one more page loads. A poll that skips
|
|
199
|
+
* past the kept events starts over from its own events.
|
|
200
|
+
*/
|
|
201
|
+
function keepLatestEvents(
|
|
202
|
+
kept: ConversationReportEvent[],
|
|
203
|
+
events: ConversationReportEvent[] | undefined,
|
|
204
|
+
): ConversationReportEvent[] {
|
|
205
|
+
const firstSeq = events?.[0]?.seq;
|
|
206
|
+
const keptLastSeq = kept.at(-1)?.seq;
|
|
207
|
+
if (!events || firstSeq === undefined) return kept;
|
|
208
|
+
if (keptLastSeq === undefined || firstSeq > keptLastSeq + 1) return events;
|
|
209
|
+
const slid = kept.filter((event) => event.seq < firstSeq);
|
|
210
|
+
return slid.length ? [...slid, ...events] : events;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The newer pages reach the latest page when the latest page starts right
|
|
215
|
+
* after them. Without latest events, they reach it when nothing follows them.
|
|
216
|
+
*/
|
|
217
|
+
function reachesLatest(
|
|
218
|
+
page: ConversationEventPage,
|
|
219
|
+
latestFirstSeq: number | undefined,
|
|
220
|
+
): boolean {
|
|
221
|
+
const lastSeq = page.events.at(-1)?.seq;
|
|
222
|
+
if (lastSeq === undefined || latestFirstSeq === undefined) {
|
|
223
|
+
return !page.nextCursor;
|
|
224
|
+
}
|
|
225
|
+
return lastSeq + 1 >= latestFirstSeq;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The first page is around the read position. The rest follow it. */
|
|
229
|
+
type NewerPageParam = { around: string } | { after: string };
|
|
230
|
+
|
|
231
|
+
/** Read one bounded page of events around or after a history cursor. */
|
|
232
|
+
function readConversationEventsFrom(
|
|
233
|
+
conversationId: string,
|
|
234
|
+
param: NewerPageParam,
|
|
235
|
+
signal?: AbortSignal,
|
|
236
|
+
): Promise<ConversationEventPage> {
|
|
237
|
+
const query = new URLSearchParams(param);
|
|
238
|
+
return fetchDashboardJson(
|
|
239
|
+
conversationEventPageSchema,
|
|
240
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/events?${query}`,
|
|
241
|
+
signal,
|
|
242
|
+
).then((page) => {
|
|
243
|
+
if ("after" in param && page.nextCursor === param.after) {
|
|
244
|
+
throw new Error("Conversation history cursor did not advance");
|
|
245
|
+
}
|
|
246
|
+
return page;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
ConversationDetailReport,
|
|
4
|
+
ConversationFeed,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
6
|
+
import { pinConversationResponseSchema } from "@sentry/junior/api/schema";
|
|
7
|
+
|
|
8
|
+
import { patch } from "../http";
|
|
9
|
+
import { conversationDetailQueryKey } from "./queries";
|
|
10
|
+
|
|
11
|
+
type DetailCache = ConversationDetailReport & { detailEtag?: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pin or unpin one conversation for the viewer. The feed and detail caches
|
|
15
|
+
* change at once, so the row moves to or from the Pinned section before the
|
|
16
|
+
* request returns. A failed request puts the old caches back.
|
|
17
|
+
*/
|
|
18
|
+
export function usePinConversation(
|
|
19
|
+
conversationId: string,
|
|
20
|
+
options?: { onError?(pinned: boolean): void; onSuccess?(): void },
|
|
21
|
+
) {
|
|
22
|
+
const queryClient = useQueryClient();
|
|
23
|
+
const feeds = { queryKey: ["dashboard", "conversations"] };
|
|
24
|
+
const detailQueryKey = conversationDetailQueryKey(conversationId);
|
|
25
|
+
const setPinnedAt = (pinnedAt: string | null) => {
|
|
26
|
+
// Match the API: a pin also restores an archived conversation.
|
|
27
|
+
const viewerMarks = pinnedAt
|
|
28
|
+
? { archivedAt: null, pinnedAt }
|
|
29
|
+
: { pinnedAt };
|
|
30
|
+
queryClient.setQueriesData<ConversationFeed>(feeds, (feed) =>
|
|
31
|
+
feed
|
|
32
|
+
? {
|
|
33
|
+
...feed,
|
|
34
|
+
conversations: feed.conversations.map((conversation) =>
|
|
35
|
+
conversation.conversationId === conversationId
|
|
36
|
+
? { ...conversation, ...viewerMarks }
|
|
37
|
+
: conversation,
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
: feed,
|
|
41
|
+
);
|
|
42
|
+
// Clear the etag so the next read gets a full body, not a 304 that keeps
|
|
43
|
+
// this local value.
|
|
44
|
+
queryClient.setQueryData<DetailCache>(detailQueryKey, (detail) =>
|
|
45
|
+
detail ? { ...detail, ...viewerMarks, detailEtag: undefined } : detail,
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
return useMutation({
|
|
49
|
+
mutationFn: (pinned: boolean) =>
|
|
50
|
+
patch(
|
|
51
|
+
pinConversationResponseSchema,
|
|
52
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/pin`,
|
|
53
|
+
{ pinned },
|
|
54
|
+
),
|
|
55
|
+
onMutate: async (pinned) => {
|
|
56
|
+
await Promise.all([
|
|
57
|
+
queryClient.cancelQueries(feeds),
|
|
58
|
+
queryClient.cancelQueries({ queryKey: detailQueryKey }),
|
|
59
|
+
]);
|
|
60
|
+
const previous = {
|
|
61
|
+
detail: queryClient.getQueryData<DetailCache>(detailQueryKey),
|
|
62
|
+
feeds: queryClient.getQueriesData<ConversationFeed>(feeds),
|
|
63
|
+
};
|
|
64
|
+
setPinnedAt(pinned ? new Date().toISOString() : null);
|
|
65
|
+
return previous;
|
|
66
|
+
},
|
|
67
|
+
onError: (_error, pinned, previous) => {
|
|
68
|
+
previous?.feeds.forEach(([queryKey, feed]) => {
|
|
69
|
+
queryClient.setQueryData(queryKey, feed);
|
|
70
|
+
});
|
|
71
|
+
queryClient.setQueryData(detailQueryKey, previous?.detail);
|
|
72
|
+
options?.onError?.(pinned);
|
|
73
|
+
},
|
|
74
|
+
onSuccess: (result) => {
|
|
75
|
+
setPinnedAt(result.pinnedAt);
|
|
76
|
+
options?.onSuccess?.();
|
|
77
|
+
},
|
|
78
|
+
// A reader's first pin adds the conversation to their feed.
|
|
79
|
+
onSettled: () =>
|
|
80
|
+
Promise.all([
|
|
81
|
+
queryClient.invalidateQueries(feeds),
|
|
82
|
+
queryClient.invalidateQueries({
|
|
83
|
+
exact: true,
|
|
84
|
+
queryKey: detailQueryKey,
|
|
85
|
+
}),
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -2,7 +2,8 @@ import { memo } from "react";
|
|
|
2
2
|
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
3
3
|
|
|
4
4
|
import { HighlightedCode } from "../code";
|
|
5
|
-
import {
|
|
5
|
+
import { CopyButton } from "../components/CopyButton";
|
|
6
|
+
import { parseMarkdownBlocks } from "./codeBlocks";
|
|
6
7
|
import { MarkdownText } from "./markdown";
|
|
7
8
|
import { MentionPeopleProvider } from "./mentions";
|
|
8
9
|
|
|
@@ -56,10 +57,14 @@ function DisplayTextBlocks(props: { chat?: boolean; text: string }) {
|
|
|
56
57
|
|
|
57
58
|
return (
|
|
58
59
|
<div
|
|
59
|
-
className="min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel
|
|
60
|
+
className="group/code relative min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel py-2.5 pl-3 pr-10"
|
|
60
61
|
key={index}
|
|
61
62
|
>
|
|
62
63
|
<HighlightedCode code={block.code} language={block.language} />
|
|
64
|
+
{/* Touch screens cannot hover, so keep the button visible there. */}
|
|
65
|
+
<div className="absolute right-0.5 top-0.5 transition-opacity [@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover/code:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within/code:opacity-100">
|
|
66
|
+
<CopyButton getText={() => block.code} label="Copy code" />
|
|
67
|
+
</div>
|
|
63
68
|
</div>
|
|
64
69
|
);
|
|
65
70
|
})}
|