@sentry/junior-dashboard 0.279.0 → 0.280.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 +100 -31
- package/dist/client-manifest.json +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +100 -31
- package/dist/public/chunks/AdminPage-YEKZOKH6.js +1 -0
- package/dist/public/chunks/AutomationPage-5GS3IUAK.js +3 -0
- package/dist/public/chunks/AutomationRunsPage-IPYEZU4L.js +1 -0
- package/dist/public/chunks/AutomationsPage-HSAYPC3C.js +1 -0
- package/dist/public/chunks/CodePage-IPFKXWFB.js +1 -0
- package/dist/public/chunks/ConnectionsPage-5XUEK6FS.js +1 -0
- package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
- package/dist/public/chunks/LocationDetailPage-27ETUGR6.js +1 -0
- package/dist/public/chunks/LocationsPage-WOKMQAGM.js +1 -0
- package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
- package/dist/public/chunks/PeoplePage-6YQLDOUL.js +1 -0
- package/dist/public/chunks/PersonProfilePage-GYMAZFIK.js +1 -0
- package/dist/public/chunks/PersonalTokensPage-4BCR744B.js +1 -0
- package/dist/public/chunks/PluginUserPage-IP6WL7MD.js +1 -0
- package/dist/public/chunks/RepositoryPage-TRD44LQ3.js +1 -0
- package/dist/public/chunks/SettingsPage-4E3SLQ36.js +1 -0
- package/dist/public/chunks/SystemPage-C3XBKXKW.js +1 -0
- package/dist/public/chunks/WorkspaceFormPage-2LS2X67Q.js +2 -0
- package/dist/public/chunks/WorkspacesPage-5WB3CUTX.js +1 -0
- package/dist/public/chunks/chunk-2BCS5AQL.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-4UGZNVJM.js +1 -0
- package/dist/public/chunks/chunk-564V7YHG.js +1 -0
- package/dist/public/chunks/chunk-5GZDBGAR.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-6FSJ3W2W.js +1 -0
- package/dist/public/chunks/chunk-6GQL3EVJ.js +1 -0
- package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
- package/dist/public/chunks/chunk-A2QWWA7O.js +41 -0
- package/dist/public/chunks/chunk-AYXSBMWA.js +1 -0
- package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -0
- package/dist/public/chunks/chunk-B6HGRBBX.js +1 -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-DZB4LPI6.js +1 -0
- package/dist/public/chunks/chunk-EFEREKX4.js +1 -0
- package/dist/public/chunks/chunk-FE522UEA.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-GO45RQDL.js +1 -0
- package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
- package/dist/public/chunks/chunk-GRMX2HX6.js +1 -0
- package/dist/public/chunks/chunk-GYRM5ZIV.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-IA6BJDKN.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-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-MEHLQZDG.js +3 -0
- package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
- package/dist/public/chunks/chunk-N4AONKD3.js +1 -0
- package/dist/public/chunks/chunk-NQMDED2R.js +2 -0
- package/dist/public/chunks/chunk-NQOJAZFQ.js +1 -0
- package/dist/public/chunks/chunk-PEQNT5MO.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-QNA7F7QB.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-R4ORRE77.js +1 -0
- package/dist/public/chunks/chunk-RHIXHBS5.js +1 -0
- package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
- package/dist/public/chunks/chunk-SDKV3OID.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-TFJOEF3Q.js +1 -0
- package/dist/public/chunks/chunk-UAH6DWNS.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-V5UDSIRA.js +63 -0
- package/dist/public/chunks/chunk-VC6IS564.js +1 -0
- package/dist/public/chunks/chunk-VFEBNOPX.js +1 -0
- package/dist/public/chunks/chunk-VXT3FIVL.js +55 -0
- package/dist/public/chunks/chunk-WJ3OXMVX.js +1 -0
- package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
- package/dist/public/chunks/chunk-WZVO3HG4.js +1 -0
- package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
- package/dist/public/chunks/chunk-YPMUHXYT.js +147 -0
- package/dist/public/chunks/chunk-YYNMZ4WC.js +1 -0
- package/dist/public/chunks/chunk-Z2JPRGFT.js +1 -0
- package/dist/public/chunks/chunk-ZIJ6SJDN.js +1 -0
- package/dist/public/chunks/code-highlighter-Q7WC7AEF.js +12 -0
- package/dist/public/client-TYC7JKRE.js +91 -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/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/ConversationCatchUp.tsx +107 -0
- package/src/client/conversations/ConversationContextTags.tsx +110 -0
- package/src/client/conversations/ConversationEventLog.tsx +213 -71
- package/src/client/conversations/ConversationHeader.tsx +35 -41
- package/src/client/conversations/ConversationHeaderActions.tsx +34 -38
- package/src/client/conversations/ConversationHomeList.tsx +41 -3
- package/src/client/conversations/ConversationMeta.tsx +6 -12
- package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
- package/src/client/conversations/ConversationPage.tsx +188 -150
- package/src/client/conversations/ShareConversationButton.tsx +5 -50
- package/src/client/conversations/SlackMark.tsx +16 -4
- package/src/client/conversations/TranscriptRows.tsx +24 -11
- package/src/client/conversations/TranscriptView.tsx +21 -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 +11 -3
- package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
- package/src/client/conversations/transcriptBottomPinning.ts +27 -3
- package/src/client/conversations/transcriptReadPosition.ts +24 -2
- package/src/client/conversations/turnContext.ts +1 -1
- 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/mock-reporting/fixtures.ts +43 -0
- 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,6 +1,10 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { cn } from "../styles";
|
|
4
|
+
import {
|
|
5
|
+
ConversationCatchUpButton,
|
|
6
|
+
ConversationCatchUpCard,
|
|
7
|
+
} from "./ConversationCatchUp";
|
|
4
8
|
|
|
5
9
|
/** Keep row padding and the gap between transcript entries in one place. */
|
|
6
10
|
export function TranscriptRows({
|
|
@@ -20,17 +24,26 @@ export function TranscriptRows({
|
|
|
20
24
|
*/
|
|
21
25
|
export function TranscriptUnreadMarker() {
|
|
22
26
|
return (
|
|
23
|
-
<div
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
<div className="grid gap-2 py-1" data-transcript-unread-marker="">
|
|
28
|
+
{/* Equal side columns keep "New" centered when the catch-up offer shows. */}
|
|
29
|
+
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
|
30
|
+
<span
|
|
31
|
+
aria-label="New messages"
|
|
32
|
+
className="h-px bg-dashboard-accent/50"
|
|
33
|
+
role="separator"
|
|
34
|
+
/>
|
|
35
|
+
<span
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
className="font-sans text-xs font-semibold text-dashboard-accent-text"
|
|
38
|
+
>
|
|
39
|
+
New
|
|
40
|
+
</span>
|
|
41
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
42
|
+
<span className="h-px flex-1 bg-dashboard-accent/50" />
|
|
43
|
+
<ConversationCatchUpButton />
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<ConversationCatchUpCard />
|
|
34
47
|
</div>
|
|
35
48
|
);
|
|
36
49
|
}
|
|
@@ -12,6 +12,7 @@ import { cn } from "../styles";
|
|
|
12
12
|
import { ConversationTranscriptView } from "./ConversationTranscript";
|
|
13
13
|
import { ConversationEventLog } from "./ConversationEventLog";
|
|
14
14
|
import {
|
|
15
|
+
TranscriptJumpToLatestContext,
|
|
15
16
|
transcriptBottomVersion,
|
|
16
17
|
transcriptJuniorMessageVersion,
|
|
17
18
|
useAutoLoadHistoryPage,
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
type TranscriptUnreadRange,
|
|
24
25
|
} from "./transcriptReadPosition";
|
|
25
26
|
import { TranscriptSearchProvider } from "./transcriptSearch";
|
|
26
|
-
import type { TranscriptHistory } from "./
|
|
27
|
+
import type { TranscriptHistory } from "./transcriptAroundHistory";
|
|
27
28
|
import {
|
|
28
29
|
TurnContextPanelProvider,
|
|
29
30
|
type TurnContextPanel,
|
|
@@ -61,6 +62,8 @@ export function Transcript(props: {
|
|
|
61
62
|
transcript?: ConversationTranscript;
|
|
62
63
|
/** Show turn context in the owner's panel. Without it, a drawer opens. */
|
|
63
64
|
turnContextPanel?: TurnContextPanel;
|
|
65
|
+
/** Open where a link shows its row instead of the latest message. */
|
|
66
|
+
startAtLink?: boolean;
|
|
64
67
|
/** Open at the "New" marker instead of the latest message. */
|
|
65
68
|
startAtUnread?: boolean;
|
|
66
69
|
/** Rows that were unread when the Conversation opened get a marker. */
|
|
@@ -79,6 +82,7 @@ export function Transcript(props: {
|
|
|
79
82
|
historyVersion: props.historyVersion ?? "empty",
|
|
80
83
|
juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
|
|
81
84
|
pinRequestVersion: props.pinRequestVersion,
|
|
85
|
+
startAtLink: props.startAtLink,
|
|
82
86
|
startAtUnread: view === "rich" && props.startAtUnread,
|
|
83
87
|
view,
|
|
84
88
|
versions: {
|
|
@@ -114,6 +118,11 @@ export function Transcript(props: {
|
|
|
114
118
|
historyVersion: props.historyVersion ?? "empty",
|
|
115
119
|
onLoad: () => newer?.load(),
|
|
116
120
|
});
|
|
121
|
+
const jumpToLatest = () => {
|
|
122
|
+
// The latest page is in the detail, so the jump needs no request.
|
|
123
|
+
newer?.showLatest();
|
|
124
|
+
bottomPinning.jumpToBottom();
|
|
125
|
+
};
|
|
117
126
|
|
|
118
127
|
if (!props.transcript) {
|
|
119
128
|
return (
|
|
@@ -162,17 +171,20 @@ export function Transcript(props: {
|
|
|
162
171
|
conversation={props.transcript}
|
|
163
172
|
onSearchChange={props.onSearchChange ?? setLocalSearch}
|
|
164
173
|
onSelectedSeqChange={props.onSelectedEventSeqChange}
|
|
174
|
+
scrollAnchorKey={bottomPinning.scrollAnchorKey}
|
|
165
175
|
selectedSeq={props.selectedEventSeq}
|
|
166
176
|
/>
|
|
167
177
|
) : (
|
|
168
178
|
<TurnContextPanelProvider panel={props.turnContextPanel}>
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
<TranscriptJumpToLatestContext value={jumpToLatest}>
|
|
180
|
+
<ConversationTranscriptView
|
|
181
|
+
onOpenSubagentTranscript={props.onOpenSubagentTranscript}
|
|
182
|
+
conversation={props.transcript}
|
|
183
|
+
pendingMessages={props.pendingMessages}
|
|
184
|
+
responding={props.responding ?? props.live ?? false}
|
|
185
|
+
unread={props.unread}
|
|
186
|
+
/>
|
|
187
|
+
</TranscriptJumpToLatestContext>
|
|
176
188
|
</TurnContextPanelProvider>
|
|
177
189
|
)}
|
|
178
190
|
{newer ? (
|
|
@@ -206,11 +218,7 @@ export function Transcript(props: {
|
|
|
206
218
|
/>
|
|
207
219
|
<JumpToLatestButton
|
|
208
220
|
hasPendingUpdate={bottomPinning.hasPendingUpdate}
|
|
209
|
-
onClick={
|
|
210
|
-
// The latest page is in the detail, so the jump needs no request.
|
|
211
|
-
newer?.showLatest();
|
|
212
|
-
bottomPinning.jumpToBottom();
|
|
213
|
-
}}
|
|
221
|
+
onClick={jumpToLatest}
|
|
214
222
|
visible={bottomPinning.showJumpToLatest}
|
|
215
223
|
/>
|
|
216
224
|
</div>
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
import { archiveConversationResponseSchema } from "@sentry/junior/api/schema";
|
|
12
12
|
|
|
13
13
|
import { patch } from "../http";
|
|
14
|
+
import type { Conversation } from "../types";
|
|
14
15
|
import {
|
|
15
16
|
conversationDetailQueryKey,
|
|
16
17
|
type ConversationSnapshot,
|
|
@@ -82,6 +83,27 @@ export function usePendingArchiveConversationUpdates() {
|
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Router state for home after a conversation archives itself. Home reports
|
|
88
|
+
* the background archive result, because the conversation page is gone.
|
|
89
|
+
*/
|
|
90
|
+
export type ArchivedFromConversationState = {
|
|
91
|
+
archivedConversation: Conversation;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Read the newest archive or restore status for one conversation. */
|
|
95
|
+
export function useLatestArchiveStatus(conversationId: string | undefined) {
|
|
96
|
+
const statuses = useMutationState({
|
|
97
|
+
filters: {
|
|
98
|
+
exact: true,
|
|
99
|
+
mutationKey: [...archiveConversationMutationKey, conversationId],
|
|
100
|
+
},
|
|
101
|
+
select: (mutation) => mutation.state.status,
|
|
102
|
+
});
|
|
103
|
+
// The mutation cache keeps insertion order, so the last entry is newest.
|
|
104
|
+
return conversationId ? statuses.at(-1) : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
/** Archive or restore one conversation with an immediate reversible cache update. */
|
|
86
108
|
export function useArchiveConversation(
|
|
87
109
|
conversationId: string,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offer a catch-up when a reader comes back to a lot of unread activity. The
|
|
3
|
+
* reader asks for it, because each summary costs a model call and a short
|
|
4
|
+
* wait.
|
|
5
|
+
*/
|
|
6
|
+
import { useMutation } from "@tanstack/react-query";
|
|
7
|
+
import { createContext, useContext } from "react";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
conversationCatchUpSchema,
|
|
11
|
+
type ConversationCatchUp,
|
|
12
|
+
type ConversationDetailReport,
|
|
13
|
+
} from "@sentry/junior/api/schema";
|
|
14
|
+
import { post } from "../http";
|
|
15
|
+
import type { TranscriptUnreadRange } from "./transcriptReadPosition";
|
|
16
|
+
|
|
17
|
+
/** Fewer unread Turns than this are quicker to read than a summary. */
|
|
18
|
+
const CATCH_UP_MIN_TURNS = 2;
|
|
19
|
+
|
|
20
|
+
/** The catch-up offer for the unread range of one Conversation visit. */
|
|
21
|
+
export type ConversationCatchUpState = {
|
|
22
|
+
data?: ConversationCatchUp;
|
|
23
|
+
error: boolean;
|
|
24
|
+
pending: boolean;
|
|
25
|
+
request(): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Count the Turns that finished in the unread range. When the range goes past
|
|
30
|
+
* the loaded rows, the reader has at least a page of unread rows.
|
|
31
|
+
*/
|
|
32
|
+
function hasEnoughUnread(
|
|
33
|
+
events: ConversationDetailReport["events"],
|
|
34
|
+
unread: TranscriptUnreadRange,
|
|
35
|
+
pages: { hasNewerPage: boolean; hasPreviousPage: boolean },
|
|
36
|
+
): boolean {
|
|
37
|
+
if (pages.hasNewerPage) return true;
|
|
38
|
+
const first = events[0];
|
|
39
|
+
if (pages.hasPreviousPage && first && first.seq > unread.afterSeq) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
let turns = 0;
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
if (event.seq <= unread.afterSeq || event.seq > unread.throughSeq) continue;
|
|
45
|
+
if (
|
|
46
|
+
event.data.type === "turn_lifecycle" &&
|
|
47
|
+
event.data.state !== "started"
|
|
48
|
+
) {
|
|
49
|
+
turns += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return turns >= CATCH_UP_MIN_TURNS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Offer a catch-up only when enough is unread. */
|
|
56
|
+
export function useConversationCatchUp(input: {
|
|
57
|
+
conversationId: string;
|
|
58
|
+
events?: ConversationDetailReport["events"];
|
|
59
|
+
hasNewerPage: boolean;
|
|
60
|
+
hasPreviousPage: boolean;
|
|
61
|
+
unread?: TranscriptUnreadRange;
|
|
62
|
+
}): ConversationCatchUpState | undefined {
|
|
63
|
+
const { unread } = input;
|
|
64
|
+
const mutation = useMutation({
|
|
65
|
+
mutationFn: (range: TranscriptUnreadRange) =>
|
|
66
|
+
post(
|
|
67
|
+
conversationCatchUpSchema,
|
|
68
|
+
`/api/conversations/${encodeURIComponent(input.conversationId)}/catch-up`,
|
|
69
|
+
range,
|
|
70
|
+
),
|
|
71
|
+
});
|
|
72
|
+
// Once asked, keep the answer even if the loaded rows change.
|
|
73
|
+
const asked = !mutation.isIdle;
|
|
74
|
+
if (
|
|
75
|
+
!unread ||
|
|
76
|
+
(!asked && (!input.events || !hasEnoughUnread(input.events, unread, input)))
|
|
77
|
+
) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
data: mutation.data,
|
|
82
|
+
error: mutation.isError,
|
|
83
|
+
pending: mutation.isPending,
|
|
84
|
+
request: () => mutation.mutate(unread),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Only the Conversation page sets it, so nested transcripts offer nothing. */
|
|
89
|
+
export const ConversationCatchUpContext = createContext<
|
|
90
|
+
ConversationCatchUpState | undefined
|
|
91
|
+
>(undefined);
|
|
92
|
+
|
|
93
|
+
/** Read the catch-up offer of the current Conversation page. */
|
|
94
|
+
export function useConversationCatchUpState() {
|
|
95
|
+
return useContext(ConversationCatchUpContext);
|
|
96
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
|
-
import
|
|
3
|
+
import * as z from "zod";
|
|
4
4
|
|
|
5
5
|
import { fetchDashboardJson } from "../http";
|
|
6
6
|
import { conversationSideReadQueryKey } from "./queries";
|
|
@@ -11,6 +11,7 @@ const conversationMemoryListSchema = z
|
|
|
11
11
|
z
|
|
12
12
|
.object({
|
|
13
13
|
memoryIds: z.array(z.string().min(1)).min(1),
|
|
14
|
+
seq: z.number().int().min(0),
|
|
14
15
|
source: z.enum(["captured", "recalled"]),
|
|
15
16
|
turnId: z.string().min(1).optional(),
|
|
16
17
|
})
|
|
@@ -76,6 +77,8 @@ export function anchorMemories(
|
|
|
76
77
|
/** One row of the Conversation memory list. */
|
|
77
78
|
export type ConversationMemoryEntry = {
|
|
78
79
|
memory: ConversationMemory;
|
|
80
|
+
/** The recall or capture event, where the transcript opens. */
|
|
81
|
+
seq: number;
|
|
79
82
|
source: MemorySource;
|
|
80
83
|
turnId?: string;
|
|
81
84
|
};
|
|
@@ -99,6 +102,7 @@ export function conversationMemoryEntries(
|
|
|
99
102
|
seen.add(key);
|
|
100
103
|
entries.push({
|
|
101
104
|
memory,
|
|
105
|
+
seq: event.seq,
|
|
102
106
|
source: event.source,
|
|
103
107
|
...(event.turnId ? { turnId: event.turnId } : undefined),
|
|
104
108
|
});
|
|
@@ -110,6 +114,9 @@ export function conversationMemoryEntries(
|
|
|
110
114
|
/** The open memory popover, and the memory it highlights. */
|
|
111
115
|
export type OpenMemoryPopover = MemoryAnchor & { memoryId?: string };
|
|
112
116
|
|
|
117
|
+
/** A memory chosen in details, and the event where the transcript opens. */
|
|
118
|
+
export type MemoryJump = OpenMemoryPopover & { seq: number };
|
|
119
|
+
|
|
113
120
|
/** Shared memory popover state for one Conversation page. */
|
|
114
121
|
export type ConversationMemoryState = {
|
|
115
122
|
list?: ConversationMemoryList;
|
|
@@ -221,7 +221,9 @@ export function conversationDetailQueryOptions(
|
|
|
221
221
|
/**
|
|
222
222
|
* Read positions only move forward. A poll that started before a read report
|
|
223
223
|
* finished must not put an older position back in the cache, nor the unread
|
|
224
|
-
* cursor of that older position.
|
|
224
|
+
* cursor of that older position. The cache already holds the cursor for the
|
|
225
|
+
* newer position: a read does not change the detail version, so no later poll
|
|
226
|
+
* would bring it back.
|
|
225
227
|
*/
|
|
226
228
|
function keepLatestReadPosition(
|
|
227
229
|
previous: ConversationSnapshot,
|
|
@@ -230,8 +232,14 @@ function keepLatestReadPosition(
|
|
|
230
232
|
const kept = previous.readPosition;
|
|
231
233
|
if (!kept) return next;
|
|
232
234
|
if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
|
|
233
|
-
const { unreadCursor: _olderCursor, ...rest } = next;
|
|
234
|
-
return {
|
|
235
|
+
const { unreadCursor: _olderCursor, unreadPage: _olderPage, ...rest } = next;
|
|
236
|
+
return {
|
|
237
|
+
...rest,
|
|
238
|
+
readPosition: kept,
|
|
239
|
+
...(previous.unreadCursor
|
|
240
|
+
? { unreadCursor: previous.unreadCursor, unreadPage: previous.unreadPage }
|
|
241
|
+
: undefined),
|
|
242
|
+
};
|
|
235
243
|
}
|
|
236
244
|
|
|
237
245
|
/** Create one dashboard conversation and refresh the personal feed. */
|
|
@@ -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,7 +336,9 @@ 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
343
|
// The scroll position that the last restore set. Its scroll event is not
|
|
337
344
|
// a reader scroll, so it must not change follow intent.
|
|
@@ -546,13 +553,20 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
546
553
|
|
|
547
554
|
useBrowserLayoutEffect(() => {
|
|
548
555
|
if (!contentElement || !input.conversationId) return;
|
|
549
|
-
|
|
556
|
+
const start = `${input.conversationId}:${input.startAtLink ? "link" : "start"}`;
|
|
557
|
+
if (startedRef.current === start) return;
|
|
550
558
|
|
|
551
559
|
const root = scrollRootFor(contentElement);
|
|
552
560
|
if (!root) return;
|
|
553
561
|
|
|
554
|
-
|
|
562
|
+
startedRef.current = start;
|
|
555
563
|
setHasPendingUpdate(false);
|
|
564
|
+
// The link scrolls to its row, maybe before this runs. Do not move away.
|
|
565
|
+
if (input.startAtLink) {
|
|
566
|
+
previousScrollTopRef.current = scrollSnapshot(root).scrollTop;
|
|
567
|
+
setFollowingIntent(false);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
556
570
|
if (input.startAtUnread && openAtUnreadMarker()) return;
|
|
557
571
|
|
|
558
572
|
setScrollTop(root, scrollSnapshot(root).scrollHeight);
|
|
@@ -565,6 +579,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
565
579
|
}, [
|
|
566
580
|
contentElement,
|
|
567
581
|
input.conversationId,
|
|
582
|
+
input.startAtLink,
|
|
568
583
|
input.startAtUnread,
|
|
569
584
|
openAtUnreadMarker,
|
|
570
585
|
setFollowingIntent,
|
|
@@ -761,6 +776,8 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
761
776
|
scrollToBottom("auto");
|
|
762
777
|
}, [input.pinRequestVersion, scrollToBottom, setFollowingIntent]);
|
|
763
778
|
|
|
779
|
+
const scrollAnchorKey = useCallback(() => scrollAnchorRef.current?.key, []);
|
|
780
|
+
|
|
764
781
|
return useMemo(
|
|
765
782
|
() => ({
|
|
766
783
|
anchorRef,
|
|
@@ -768,6 +785,7 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
768
785
|
contentRef,
|
|
769
786
|
hasPendingUpdate,
|
|
770
787
|
jumpToBottom,
|
|
788
|
+
scrollAnchorKey,
|
|
771
789
|
showJumpToLatest: shouldShowJumpToLatest({
|
|
772
790
|
enabled: input.enabled,
|
|
773
791
|
endsAtLatest: input.endsAtLatest,
|
|
@@ -785,10 +803,16 @@ export function usePinnedTranscriptBottom(input: {
|
|
|
785
803
|
input.endsAtLatest,
|
|
786
804
|
jumpToBottom,
|
|
787
805
|
openedAtUnread,
|
|
806
|
+
scrollAnchorKey,
|
|
788
807
|
],
|
|
789
808
|
);
|
|
790
809
|
}
|
|
791
810
|
|
|
811
|
+
/** Jump to the latest row from inside the transcript, like the floating pill. */
|
|
812
|
+
export const TranscriptJumpToLatestContext = createContext<
|
|
813
|
+
(() => void) | undefined
|
|
814
|
+
>(undefined);
|
|
815
|
+
|
|
792
816
|
/**
|
|
793
817
|
* Load the next history page when the reader scrolls near the edge where it
|
|
794
818
|
* goes: the top for earlier pages, the bottom for newer pages.
|
|
@@ -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
|
});
|
|
@@ -5,7 +5,7 @@ import type { PluginUserPageLink } from "@sentry/junior-plugin-api";
|
|
|
5
5
|
import { Card } from "../../components/layout/Card";
|
|
6
6
|
import { PageHeader } from "../../components/layout/PageHeader";
|
|
7
7
|
import { dashboardContainerClass } from "../../styles";
|
|
8
|
-
import { pluginUserPagePath } from "../user/
|
|
8
|
+
import { pluginUserPagePath } from "../user/pluginUserPageRoutes";
|
|
9
9
|
import {
|
|
10
10
|
ServiceAccountsSection,
|
|
11
11
|
useServiceAccounts,
|