@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
|
@@ -16,24 +16,15 @@ export function TranscriptContextEventView(props: {
|
|
|
16
16
|
timestamp?: number;
|
|
17
17
|
}) {
|
|
18
18
|
const event = props.part.event;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
: "";
|
|
24
|
-
const compactionModel =
|
|
25
|
-
event.type === "compaction" && event.modelProfile && event.modelId
|
|
26
|
-
? ` using the ${event.modelProfile} profile (${event.modelId})`
|
|
27
|
-
: "";
|
|
19
|
+
const compactionDetail = event.details
|
|
20
|
+
? ` at approximately ${formatCompactNumber(event.details.estimatedInputTokens)} estimated tokens`
|
|
21
|
+
: "";
|
|
22
|
+
const compactionModel = event.modelId ? ` using ${event.modelId}` : "";
|
|
28
23
|
return (
|
|
29
24
|
<article className="min-w-0 py-0.5">
|
|
30
25
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
|
31
|
-
<strong
|
|
32
|
-
|
|
33
|
-
handoff ? "text-sky-100" : "text-amber-100"
|
|
34
|
-
}`}
|
|
35
|
-
>
|
|
36
|
-
{handoff ? "Model handoff" : "Context compacted"}
|
|
26
|
+
<strong className="font-sans text-xs font-semibold text-amber-100">
|
|
27
|
+
Context compacted
|
|
37
28
|
</strong>
|
|
38
29
|
{typeof props.timestamp === "number" ? (
|
|
39
30
|
<span className="text-xs text-dashboard-text-muted">
|
|
@@ -42,9 +33,7 @@ export function TranscriptContextEventView(props: {
|
|
|
42
33
|
) : null}
|
|
43
34
|
</div>
|
|
44
35
|
<div className="mt-1 text-xs leading-relaxed text-dashboard-text-muted">
|
|
45
|
-
{
|
|
46
|
-
? `Handed off to ${event.modelId} via the ${event.modelProfile} profile${event.reasoningLevel ? ` (${event.reasoningLevel})` : ""}.`
|
|
47
|
-
: `Earlier context was summarized${compactionDetail}${compactionModel} before execution continued.`}
|
|
36
|
+
{`Earlier context was summarized${compactionDetail}${compactionModel} before execution continued.`}
|
|
48
37
|
</div>
|
|
49
38
|
{event.summary ? (
|
|
50
39
|
<Collapsible className="mt-2">
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ConversationMemoryState,
|
|
19
19
|
type MemoryAnchor,
|
|
20
20
|
} from "./conversationMemory";
|
|
21
|
+
import { MemoryLinkIcons } from "./MemoryIcons";
|
|
21
22
|
import { memoryRecallContent } from "./turnContext";
|
|
22
23
|
|
|
23
24
|
function memoryCount(count: number): string {
|
|
@@ -54,32 +55,26 @@ function MemoryPopover(props: {
|
|
|
54
55
|
aria-label={props.label}
|
|
55
56
|
className="max-h-[min(28rem,var(--available-height))] w-96 p-1.5"
|
|
56
57
|
>
|
|
57
|
-
<ul className="m-0 grid list-none
|
|
58
|
+
<ul className="m-0 grid list-none divide-y divide-dashboard-border-subtle p-0">
|
|
58
59
|
{props.memories.map((memory) => (
|
|
59
|
-
<li
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{memory.visibility === "private"
|
|
76
|
-
|
|
77
|
-
className="text-dashboard-text-muted no-underline hover:text-dashboard-text hover:underline"
|
|
78
|
-
to={memoryPath(memory.id)}
|
|
79
|
-
>
|
|
80
|
-
Open
|
|
81
|
-
</Link>
|
|
82
|
-
</div>
|
|
60
|
+
<li className="py-1 first:pt-0 last:pb-0" key={memory.id}>
|
|
61
|
+
<Link
|
|
62
|
+
className={cn(
|
|
63
|
+
"group grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-2.5 py-2 no-underline transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus",
|
|
64
|
+
memory.id === highlightId && "bg-dashboard-fill-strong",
|
|
65
|
+
)}
|
|
66
|
+
ref={
|
|
67
|
+
memory.id === highlightId
|
|
68
|
+
? (node) => node?.scrollIntoView({ block: "nearest" })
|
|
69
|
+
: undefined
|
|
70
|
+
}
|
|
71
|
+
to={memoryPath(memory.id)}
|
|
72
|
+
>
|
|
73
|
+
<p className="m-0 line-clamp-4 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
|
|
74
|
+
{memory.content}
|
|
75
|
+
</p>
|
|
76
|
+
<MemoryLinkIcons private={memory.visibility === "private"} />
|
|
77
|
+
</Link>
|
|
83
78
|
</li>
|
|
84
79
|
))}
|
|
85
80
|
</ul>
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
MessageSquareText,
|
|
15
15
|
Minimize2,
|
|
16
16
|
Paperclip,
|
|
17
|
-
Send,
|
|
18
17
|
Sparkles,
|
|
19
18
|
TriangleAlert,
|
|
20
19
|
Webhook,
|
|
@@ -27,7 +26,6 @@ import type { TranscriptViewStructuredEventPart } from "../types";
|
|
|
27
26
|
type TranscriptRailEventKind =
|
|
28
27
|
| "attachments_delivered"
|
|
29
28
|
| "compaction"
|
|
30
|
-
| "handoff"
|
|
31
29
|
| "message_context"
|
|
32
30
|
| "event"
|
|
33
31
|
| "structured_event"
|
|
@@ -119,12 +117,6 @@ function transcriptRailMarker(kind: TranscriptRailEventKind): {
|
|
|
119
117
|
icon: Bot,
|
|
120
118
|
};
|
|
121
119
|
}
|
|
122
|
-
if (kind === "handoff") {
|
|
123
|
-
return {
|
|
124
|
-
className: "text-sky-200",
|
|
125
|
-
icon: Send,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
120
|
return {
|
|
129
121
|
className: "text-amber-200",
|
|
130
122
|
icon: Minimize2,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./turnContext";
|
|
30
30
|
import { cn } from "../styles";
|
|
31
31
|
import { DetailsCard } from "./DetailsList";
|
|
32
|
+
import { MemoryLinkIcons } from "./MemoryIcons";
|
|
32
33
|
import { HighlightText } from "./transcriptSearch";
|
|
33
34
|
|
|
34
35
|
/** What one turn context view shows: who spoke, when, and the context. */
|
|
@@ -270,17 +271,20 @@ function TurnContextCard(props: { context: TranscriptViewTurnContext }) {
|
|
|
270
271
|
);
|
|
271
272
|
}
|
|
272
273
|
|
|
273
|
-
// Each row links to the memory's page
|
|
274
|
+
// Each row shows the full memory and links to the memory's page.
|
|
274
275
|
function MemoryRecall(props: { memories: MemoryRecallContent["memories"] }) {
|
|
275
276
|
return (
|
|
276
|
-
<ul className="-mx-1.5 -mb-1.5 -mt-1 grid list-none
|
|
277
|
+
<ul className="-mx-1.5 -mb-1.5 -mt-1 grid list-none divide-y divide-dashboard-border-subtle p-0">
|
|
277
278
|
{props.memories.map((memory) => (
|
|
278
|
-
<li key={memory.id}>
|
|
279
|
+
<li className="py-1 first:pt-0 last:pb-0" key={memory.id}>
|
|
279
280
|
<Link
|
|
280
|
-
className="
|
|
281
|
+
className="group grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1.5 no-underline transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
281
282
|
to={memoryPath(memory.id)}
|
|
282
283
|
>
|
|
283
|
-
<
|
|
284
|
+
<span className="whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
|
|
285
|
+
<HighlightText text={memory.content} />
|
|
286
|
+
</span>
|
|
287
|
+
<MemoryLinkIcons private={memory.scope === "personal"} />
|
|
284
288
|
</Link>
|
|
285
289
|
</li>
|
|
286
290
|
))}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, type ReactNode, type Ref } from "react";
|
|
2
2
|
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
3
3
|
import { ArrowDown } from "lucide-react";
|
|
4
4
|
|
|
@@ -8,12 +8,13 @@ import type {
|
|
|
8
8
|
} from "../types";
|
|
9
9
|
import { EmptyTelemetry } from "../components/EmptyTelemetry";
|
|
10
10
|
import { Button } from "../components/ui/button";
|
|
11
|
+
import { cn } from "../styles";
|
|
11
12
|
import { ConversationTranscriptView } from "./ConversationTranscript";
|
|
12
13
|
import { ConversationEventLog } from "./ConversationEventLog";
|
|
13
14
|
import {
|
|
14
15
|
transcriptBottomVersion,
|
|
15
16
|
transcriptJuniorMessageVersion,
|
|
16
|
-
|
|
17
|
+
useAutoLoadHistoryPage,
|
|
17
18
|
usePinnedTranscriptBottom,
|
|
18
19
|
} from "./transcriptBottomPinning";
|
|
19
20
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
type TranscriptUnreadRange,
|
|
23
24
|
} from "./transcriptReadPosition";
|
|
24
25
|
import { TranscriptSearchProvider } from "./transcriptSearch";
|
|
26
|
+
import type { TranscriptHistory } from "./transcriptUnreadHistory";
|
|
25
27
|
import {
|
|
26
28
|
TurnContextPanelProvider,
|
|
27
29
|
type TurnContextPanel,
|
|
@@ -36,6 +38,11 @@ export function Transcript(props: {
|
|
|
36
38
|
/** The read position when the Conversation opened. */
|
|
37
39
|
lastReadSeq?: number;
|
|
38
40
|
loadingPreviousPage?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Newer pages between the read position and the latest. They load on
|
|
43
|
+
* scroll. Jump to latest skips them.
|
|
44
|
+
*/
|
|
45
|
+
newerHistory?: TranscriptHistory["newer"];
|
|
39
46
|
pinRequestVersion?: number;
|
|
40
47
|
onLoadPreviousPage?: () => void;
|
|
41
48
|
/** Receive the last event sequence the reader saw in the transcript. */
|
|
@@ -68,6 +75,7 @@ export function Transcript(props: {
|
|
|
68
75
|
const bottomPinning = usePinnedTranscriptBottom({
|
|
69
76
|
conversationId: props.transcript?.conversationId,
|
|
70
77
|
enabled: props.live ?? false,
|
|
78
|
+
endsAtLatest: !props.newerHistory,
|
|
71
79
|
historyVersion: props.historyVersion ?? "empty",
|
|
72
80
|
juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
|
|
73
81
|
pinRequestVersion: props.pinRequestVersion,
|
|
@@ -88,7 +96,8 @@ export function Transcript(props: {
|
|
|
88
96
|
paused: view !== "rich" || search.trim().length > 0,
|
|
89
97
|
});
|
|
90
98
|
const loadPreviousPage = () => props.onLoadPreviousPage?.();
|
|
91
|
-
const previousPageRef =
|
|
99
|
+
const previousPageRef = useAutoLoadHistoryPage({
|
|
100
|
+
edge: "top",
|
|
92
101
|
enabled: Boolean(
|
|
93
102
|
props.onLoadPreviousPage &&
|
|
94
103
|
props.hasPreviousPage &&
|
|
@@ -98,6 +107,13 @@ export function Transcript(props: {
|
|
|
98
107
|
historyVersion: props.historyVersion ?? "empty",
|
|
99
108
|
onLoad: loadPreviousPage,
|
|
100
109
|
});
|
|
110
|
+
const newer = props.newerHistory;
|
|
111
|
+
const newerPageRef = useAutoLoadHistoryPage({
|
|
112
|
+
edge: "bottom",
|
|
113
|
+
enabled: Boolean(newer && !newer.loading && !newer.error),
|
|
114
|
+
historyVersion: props.historyVersion ?? "empty",
|
|
115
|
+
onLoad: () => newer?.load(),
|
|
116
|
+
});
|
|
101
117
|
|
|
102
118
|
if (!props.transcript) {
|
|
103
119
|
return (
|
|
@@ -120,22 +136,17 @@ export function Transcript(props: {
|
|
|
120
136
|
</div>
|
|
121
137
|
) : null}
|
|
122
138
|
{props.hasPreviousPage || props.loadingPreviousPage ? (
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? `Loading earlier ${historyLabel}…`
|
|
135
|
-
: `Show earlier ${historyLabel}`}
|
|
136
|
-
</button>
|
|
137
|
-
<span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
|
|
138
|
-
</div>
|
|
139
|
+
<HistoryPageControl
|
|
140
|
+
className="mb-3"
|
|
141
|
+
label="Load earlier events"
|
|
142
|
+
loading={props.loadingPreviousPage}
|
|
143
|
+
onClick={loadPreviousPage}
|
|
144
|
+
ref={previousPageRef}
|
|
145
|
+
>
|
|
146
|
+
{props.loadingPreviousPage
|
|
147
|
+
? `Loading earlier ${historyLabel}…`
|
|
148
|
+
: `Show earlier ${historyLabel}`}
|
|
149
|
+
</HistoryPageControl>
|
|
139
150
|
) : null}
|
|
140
151
|
{props.historyError ? (
|
|
141
152
|
<div
|
|
@@ -164,6 +175,30 @@ export function Transcript(props: {
|
|
|
164
175
|
/>
|
|
165
176
|
</TurnContextPanelProvider>
|
|
166
177
|
)}
|
|
178
|
+
{newer ? (
|
|
179
|
+
<>
|
|
180
|
+
{newer.error ? (
|
|
181
|
+
<div
|
|
182
|
+
aria-live="polite"
|
|
183
|
+
className="mt-2 text-center font-sans text-xs text-amber-100/65"
|
|
184
|
+
>
|
|
185
|
+
Newer events could not be loaded.
|
|
186
|
+
</div>
|
|
187
|
+
) : null}
|
|
188
|
+
{/* The bottom margin keeps the jump control off this row. */}
|
|
189
|
+
<HistoryPageControl
|
|
190
|
+
className="mb-12 mt-3"
|
|
191
|
+
label="Load newer events"
|
|
192
|
+
loading={newer.loading}
|
|
193
|
+
onClick={newer.load}
|
|
194
|
+
ref={newerPageRef}
|
|
195
|
+
>
|
|
196
|
+
{newer.loading
|
|
197
|
+
? `Loading newer ${historyLabel}…`
|
|
198
|
+
: `Show newer ${historyLabel}`}
|
|
199
|
+
</HistoryPageControl>
|
|
200
|
+
</>
|
|
201
|
+
) : null}
|
|
167
202
|
<div
|
|
168
203
|
aria-hidden="true"
|
|
169
204
|
className="h-px"
|
|
@@ -171,7 +206,11 @@ export function Transcript(props: {
|
|
|
171
206
|
/>
|
|
172
207
|
<JumpToLatestButton
|
|
173
208
|
hasPendingUpdate={bottomPinning.hasPendingUpdate}
|
|
174
|
-
onClick={
|
|
209
|
+
onClick={() => {
|
|
210
|
+
// The latest page is in the detail, so the jump needs no request.
|
|
211
|
+
newer?.showLatest();
|
|
212
|
+
bottomPinning.jumpToBottom();
|
|
213
|
+
}}
|
|
175
214
|
visible={bottomPinning.showJumpToLatest}
|
|
176
215
|
/>
|
|
177
216
|
</div>
|
|
@@ -179,6 +218,36 @@ export function Transcript(props: {
|
|
|
179
218
|
);
|
|
180
219
|
}
|
|
181
220
|
|
|
221
|
+
/** A divider with one control that loads another page of history. */
|
|
222
|
+
function HistoryPageControl(props: {
|
|
223
|
+
children: ReactNode;
|
|
224
|
+
className: string;
|
|
225
|
+
label: string;
|
|
226
|
+
loading?: boolean;
|
|
227
|
+
onClick: () => void;
|
|
228
|
+
ref?: Ref<HTMLDivElement>;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
className={cn("flex items-center gap-3", props.className)}
|
|
233
|
+
ref={props.ref}
|
|
234
|
+
>
|
|
235
|
+
<span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
|
|
236
|
+
<button
|
|
237
|
+
aria-label={props.label}
|
|
238
|
+
className="shrink-0 cursor-pointer border-0 bg-transparent px-1 py-0.5 font-sans text-xs font-medium text-dashboard-text-muted transition-colors hover:text-dashboard-text focus-visible:outline focus-visible:outline-1 focus-visible:outline-dashboard-focus/55 disabled:cursor-not-allowed disabled:opacity-50"
|
|
239
|
+
disabled={props.loading}
|
|
240
|
+
onClick={props.onClick}
|
|
241
|
+
title={props.label}
|
|
242
|
+
type="button"
|
|
243
|
+
>
|
|
244
|
+
{props.children}
|
|
245
|
+
</button>
|
|
246
|
+
<span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
182
251
|
function JumpToLatestButton(props: {
|
|
183
252
|
hasPendingUpdate: boolean;
|
|
184
253
|
onClick: () => void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import {
|
|
3
|
+
archiveDoneConversationsResponseSchema,
|
|
4
|
+
conversationAutoArchiveSchema,
|
|
5
|
+
type ConversationDetailReport,
|
|
6
|
+
} from "@sentry/junior/api/schema";
|
|
7
|
+
|
|
8
|
+
import { patch, post } from "../http";
|
|
9
|
+
import { conversationDetailQueryKey } from "./queries";
|
|
10
|
+
|
|
11
|
+
/** Turn auto-archive on or off for one conversation and the viewer. */
|
|
12
|
+
export function useSetConversationAutoArchive(conversationId: string) {
|
|
13
|
+
const queryClient = useQueryClient();
|
|
14
|
+
return useMutation({
|
|
15
|
+
mutationFn: (autoArchive: boolean) =>
|
|
16
|
+
patch(
|
|
17
|
+
conversationAutoArchiveSchema,
|
|
18
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/auto-archive`,
|
|
19
|
+
{ autoArchive },
|
|
20
|
+
),
|
|
21
|
+
onSuccess: (result) => {
|
|
22
|
+
queryClient.setQueryData<
|
|
23
|
+
ConversationDetailReport & { detailEtag?: string }
|
|
24
|
+
>(conversationDetailQueryKey(conversationId), (detail) =>
|
|
25
|
+
detail
|
|
26
|
+
? {
|
|
27
|
+
...detail,
|
|
28
|
+
autoArchive: result.autoArchive,
|
|
29
|
+
detailEtag: undefined,
|
|
30
|
+
}
|
|
31
|
+
: detail,
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Archive all of the viewer's done conversations once. */
|
|
38
|
+
export function useArchiveDoneConversations() {
|
|
39
|
+
const queryClient = useQueryClient();
|
|
40
|
+
return useMutation({
|
|
41
|
+
mutationFn: () =>
|
|
42
|
+
post(
|
|
43
|
+
archiveDoneConversationsResponseSchema,
|
|
44
|
+
"/api/conversations/archive-done",
|
|
45
|
+
{},
|
|
46
|
+
),
|
|
47
|
+
onSuccess: async () => {
|
|
48
|
+
await queryClient.invalidateQueries({
|
|
49
|
+
queryKey: ["dashboard", "conversations"],
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useMutation,
|
|
3
|
+
useMutationState,
|
|
4
|
+
useQueryClient,
|
|
5
|
+
} from "@tanstack/react-query";
|
|
6
|
+
import type {
|
|
7
|
+
ConversationDetailReport,
|
|
8
|
+
ConversationFeed,
|
|
9
|
+
ConversationSummaryReport,
|
|
10
|
+
} from "@sentry/junior/api/schema";
|
|
11
|
+
import { archiveConversationResponseSchema } from "@sentry/junior/api/schema";
|
|
12
|
+
|
|
13
|
+
import { patch } from "../http";
|
|
14
|
+
import {
|
|
15
|
+
conversationDetailQueryKey,
|
|
16
|
+
type ConversationSnapshot,
|
|
17
|
+
} from "./queries";
|
|
18
|
+
|
|
19
|
+
function archivedConversationQueryKey(conversationId: string) {
|
|
20
|
+
return ["dashboard", "archived-conversation", conversationId] as const;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read active/archived status from a dashboard conversations query key. */
|
|
24
|
+
function conversationFeedStatus(
|
|
25
|
+
queryKey: readonly unknown[],
|
|
26
|
+
): "active" | "archived" | undefined {
|
|
27
|
+
const status = queryKey[queryKey.length - 1];
|
|
28
|
+
return status === "active" || status === "archived" ? status : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ArchivedConversationSnapshot = {
|
|
32
|
+
conversation: ConversationSummaryReport;
|
|
33
|
+
feedQueryHashes: string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const archiveConversationMutationKey = [
|
|
37
|
+
"dashboard",
|
|
38
|
+
"archive-conversation",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
type ArchiveConversationVariables = {
|
|
42
|
+
archived: boolean;
|
|
43
|
+
lastSeenAt: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type ArchiveConversationMutationContext = {
|
|
47
|
+
archivedQueryKey: ReturnType<typeof archivedConversationQueryKey>;
|
|
48
|
+
archivedSnapshot?: ArchivedConversationSnapshot;
|
|
49
|
+
detailQueryKey: ReturnType<typeof conversationDetailQueryKey>;
|
|
50
|
+
previousArchivedSnapshot?: ArchivedConversationSnapshot;
|
|
51
|
+
previousDetail?: ConversationDetailReport;
|
|
52
|
+
previousFeeds: Array<[readonly unknown[], ConversationFeed | undefined]>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type PendingArchiveConversationUpdate = {
|
|
56
|
+
archived: boolean;
|
|
57
|
+
conversation?: ConversationSummaryReport;
|
|
58
|
+
conversationId: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Read pending archive state so refetches cannot visually replace optimistic UI. */
|
|
62
|
+
export function usePendingArchiveConversationUpdates() {
|
|
63
|
+
return useMutationState({
|
|
64
|
+
filters: {
|
|
65
|
+
mutationKey: archiveConversationMutationKey,
|
|
66
|
+
status: "pending",
|
|
67
|
+
},
|
|
68
|
+
select: (mutation) => {
|
|
69
|
+
const variables = mutation.state
|
|
70
|
+
.variables as ArchiveConversationVariables;
|
|
71
|
+
const context = mutation.state.context as
|
|
72
|
+
| ArchiveConversationMutationContext
|
|
73
|
+
| undefined;
|
|
74
|
+
const conversationId = mutation.options.mutationKey?.[2];
|
|
75
|
+
return {
|
|
76
|
+
archived: variables.archived,
|
|
77
|
+
conversation: context?.archivedSnapshot?.conversation,
|
|
78
|
+
conversationId:
|
|
79
|
+
typeof conversationId === "string" ? conversationId : "",
|
|
80
|
+
} satisfies PendingArchiveConversationUpdate;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Archive or restore one conversation with an immediate reversible cache update. */
|
|
86
|
+
export function useArchiveConversation(
|
|
87
|
+
conversationId: string,
|
|
88
|
+
options?: {
|
|
89
|
+
onError?(): void;
|
|
90
|
+
onSuccess?(archivedAt: string | null): void;
|
|
91
|
+
},
|
|
92
|
+
) {
|
|
93
|
+
const queryClient = useQueryClient();
|
|
94
|
+
return useMutation({
|
|
95
|
+
mutationKey: [...archiveConversationMutationKey, conversationId],
|
|
96
|
+
mutationFn: (args: ArchiveConversationVariables) =>
|
|
97
|
+
patch(
|
|
98
|
+
archiveConversationResponseSchema,
|
|
99
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/archive`,
|
|
100
|
+
args,
|
|
101
|
+
),
|
|
102
|
+
onMutate: async (args) => {
|
|
103
|
+
const conversationQueries = { queryKey: ["dashboard", "conversations"] };
|
|
104
|
+
const detailQueryKey = conversationDetailQueryKey(conversationId);
|
|
105
|
+
const archivedQueryKey = archivedConversationQueryKey(conversationId);
|
|
106
|
+
await Promise.all([
|
|
107
|
+
queryClient.cancelQueries(conversationQueries),
|
|
108
|
+
queryClient.cancelQueries({ queryKey: detailQueryKey }),
|
|
109
|
+
]);
|
|
110
|
+
const previousFeeds =
|
|
111
|
+
queryClient.getQueriesData<ConversationFeed>(conversationQueries);
|
|
112
|
+
const previousDetail =
|
|
113
|
+
queryClient.getQueryData<ConversationDetailReport>(detailQueryKey);
|
|
114
|
+
const previousArchivedSnapshot =
|
|
115
|
+
queryClient.getQueryData<ArchivedConversationSnapshot>(
|
|
116
|
+
archivedQueryKey,
|
|
117
|
+
);
|
|
118
|
+
const archivedAt = args.archived ? new Date().toISOString() : null;
|
|
119
|
+
// Match the API: archive also unpins.
|
|
120
|
+
const viewerMarks = args.archived
|
|
121
|
+
? { archivedAt, pinnedAt: null }
|
|
122
|
+
: { archivedAt };
|
|
123
|
+
// Snapshot from any loaded feed, including archived-only fixtures that
|
|
124
|
+
// were never archived through this client session.
|
|
125
|
+
const archivedSnapshot =
|
|
126
|
+
buildArchivedConversationSnapshot(previousFeeds, conversationId) ??
|
|
127
|
+
previousArchivedSnapshot;
|
|
128
|
+
|
|
129
|
+
previousFeeds.forEach(([queryKey, feed]) => {
|
|
130
|
+
if (!feed) return;
|
|
131
|
+
const conversationExists = feed.conversations.some(
|
|
132
|
+
(conversation) => conversation.conversationId === conversationId,
|
|
133
|
+
);
|
|
134
|
+
const feedStatus = conversationFeedStatus(queryKey);
|
|
135
|
+
const shouldRestore =
|
|
136
|
+
!args.archived &&
|
|
137
|
+
!conversationExists &&
|
|
138
|
+
Boolean(archivedSnapshot) &&
|
|
139
|
+
feedStatus !== "archived";
|
|
140
|
+
const conversations = shouldRestore
|
|
141
|
+
? [
|
|
142
|
+
...feed.conversations,
|
|
143
|
+
// The archive cleared the pin, so the restored row is unpinned.
|
|
144
|
+
{
|
|
145
|
+
...archivedSnapshot!.conversation,
|
|
146
|
+
archivedAt: null,
|
|
147
|
+
pinnedAt: null,
|
|
148
|
+
},
|
|
149
|
+
].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
|
|
150
|
+
: feed.conversations.map((conversation) =>
|
|
151
|
+
conversation.conversationId === conversationId
|
|
152
|
+
? { ...conversation, ...viewerMarks }
|
|
153
|
+
: conversation,
|
|
154
|
+
);
|
|
155
|
+
queryClient.setQueryData<ConversationFeed>(queryKey, {
|
|
156
|
+
...feed,
|
|
157
|
+
conversations,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
queryClient.setQueryData<ConversationSnapshot>(
|
|
161
|
+
detailQueryKey,
|
|
162
|
+
(detail) =>
|
|
163
|
+
detail
|
|
164
|
+
? { ...detail, ...viewerMarks, detailEtag: undefined }
|
|
165
|
+
: detail,
|
|
166
|
+
);
|
|
167
|
+
if (archivedSnapshot) {
|
|
168
|
+
queryClient.setQueryData(archivedQueryKey, archivedSnapshot);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
archivedQueryKey,
|
|
172
|
+
archivedSnapshot,
|
|
173
|
+
detailQueryKey,
|
|
174
|
+
previousArchivedSnapshot,
|
|
175
|
+
previousDetail,
|
|
176
|
+
previousFeeds,
|
|
177
|
+
} satisfies ArchiveConversationMutationContext;
|
|
178
|
+
},
|
|
179
|
+
onError: (_error, _args, context) => {
|
|
180
|
+
context?.previousFeeds.forEach(([queryKey, feed]) => {
|
|
181
|
+
queryClient.setQueryData(queryKey, feed);
|
|
182
|
+
});
|
|
183
|
+
if (context) {
|
|
184
|
+
queryClient.setQueryData(
|
|
185
|
+
context.detailQueryKey,
|
|
186
|
+
context.previousDetail,
|
|
187
|
+
);
|
|
188
|
+
if (context.previousArchivedSnapshot) {
|
|
189
|
+
queryClient.setQueryData(
|
|
190
|
+
context.archivedQueryKey,
|
|
191
|
+
context.previousArchivedSnapshot,
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
queryClient.removeQueries({
|
|
195
|
+
exact: true,
|
|
196
|
+
queryKey: context.archivedQueryKey,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
options?.onError?.();
|
|
201
|
+
},
|
|
202
|
+
onSuccess: (result, args) => {
|
|
203
|
+
queryClient.setQueryData<ConversationSnapshot>(
|
|
204
|
+
conversationDetailQueryKey(conversationId),
|
|
205
|
+
(detail) =>
|
|
206
|
+
detail
|
|
207
|
+
? {
|
|
208
|
+
...detail,
|
|
209
|
+
archivedAt: result.archivedAt,
|
|
210
|
+
detailEtag: undefined,
|
|
211
|
+
}
|
|
212
|
+
: detail,
|
|
213
|
+
);
|
|
214
|
+
if (!args.archived) {
|
|
215
|
+
queryClient.removeQueries({
|
|
216
|
+
exact: true,
|
|
217
|
+
queryKey: archivedConversationQueryKey(conversationId),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
options?.onSuccess?.(result.archivedAt);
|
|
221
|
+
},
|
|
222
|
+
onSettled: async () => {
|
|
223
|
+
await Promise.all([
|
|
224
|
+
queryClient.invalidateQueries({
|
|
225
|
+
queryKey: ["dashboard", "conversations"],
|
|
226
|
+
}),
|
|
227
|
+
queryClient.invalidateQueries({
|
|
228
|
+
queryKey: ["dashboard", "locations"],
|
|
229
|
+
}),
|
|
230
|
+
queryClient.invalidateQueries({ queryKey: ["dashboard", "people"] }),
|
|
231
|
+
queryClient.invalidateQueries({
|
|
232
|
+
exact: true,
|
|
233
|
+
queryKey: conversationDetailQueryKey(conversationId),
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildArchivedConversationSnapshot(
|
|
241
|
+
feeds: Array<[readonly unknown[], ConversationFeed | undefined]>,
|
|
242
|
+
conversationId: string,
|
|
243
|
+
): ArchivedConversationSnapshot | undefined {
|
|
244
|
+
let conversation: ConversationSummaryReport | undefined;
|
|
245
|
+
const feedQueryHashes: string[] = [];
|
|
246
|
+
for (const [queryKey, feed] of feeds) {
|
|
247
|
+
const feedConversation = feed?.conversations.find(
|
|
248
|
+
(item) => item.conversationId === conversationId,
|
|
249
|
+
);
|
|
250
|
+
if (!feedConversation) continue;
|
|
251
|
+
conversation ??= feedConversation;
|
|
252
|
+
feedQueryHashes.push(JSON.stringify(queryKey));
|
|
253
|
+
}
|
|
254
|
+
return conversation ? { conversation, feedQueryHashes } : undefined;
|
|
255
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createContext, useContext } from "react";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
5
|
import { fetchDashboardJson } from "../http";
|
|
6
|
+
import { conversationSideReadQueryKey } from "./queries";
|
|
6
7
|
|
|
7
8
|
const conversationMemoryListSchema = z
|
|
8
9
|
.object({
|
|
@@ -45,7 +46,7 @@ export function useConversationMemories(conversationId: string) {
|
|
|
45
46
|
`/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
|
|
46
47
|
signal,
|
|
47
48
|
),
|
|
48
|
-
queryKey:
|
|
49
|
+
queryKey: conversationSideReadQueryKey(conversationId, "memories"),
|
|
49
50
|
retry: false,
|
|
50
51
|
});
|
|
51
52
|
}
|