@sentry/junior-dashboard 0.274.0 → 0.276.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 +28 -9
- package/dist/client.js +115 -92
- package/dist/index.js +28 -9
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/app.ts +8 -3
- package/src/client/App.tsx +11 -0
- package/src/client/components/Metric.tsx +0 -32
- package/src/client/components/ProfileMenu.tsx +56 -4
- package/src/client/components/layout/DashboardChrome.tsx +3 -3
- package/src/client/components/layout/DashboardHeader.tsx +41 -4
- package/src/client/components/layout/NavRail.tsx +43 -40
- package/src/client/conversations/ConversationComposer.tsx +42 -26
- package/src/client/conversations/ConversationDetails.tsx +149 -166
- package/src/client/conversations/ConversationHeader.tsx +126 -132
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
- package/src/client/conversations/ConversationMemories.tsx +118 -78
- package/src/client/conversations/ConversationMeta.tsx +94 -84
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +197 -422
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +5 -7
- package/src/client/conversations/ConversationTranscript.tsx +37 -8
- package/src/client/conversations/ConversationWatches.tsx +37 -15
- package/src/client/conversations/PendingMailboxStack.tsx +11 -1
- package/src/client/conversations/QuickQuestion.tsx +476 -0
- package/src/client/conversations/TelemetryMetrics.tsx +2 -15
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptEventView.tsx +25 -2
- package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
- package/src/client/conversations/TranscriptMessageView.tsx +46 -16
- package/src/client/conversations/TranscriptRows.tsx +9 -2
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +4 -18
- package/src/client/conversations/conversationMemory.ts +138 -0
- package/src/client/conversations/conversationSections.ts +18 -14
- package/src/client/conversations/eventTranscript.ts +8 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- package/src/client/review-desk/ReviewItemActions.tsx +1 -1
- package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
- package/src/client/review-desk/ReviewItemContext.tsx +41 -23
- package/src/client/review-desk/reviewItemFormat.ts +16 -0
- package/src/client/sentry.ts +77 -0
- package/src/client/types.ts +6 -0
- package/src/client.tsx +3 -0
- package/src/mock-reporting/watches.ts +2 -0
- package/src/shell.ts +27 -2
- package/src/tailwind.css +12 -19
- package/src/client/components/ActiveIndicator.tsx +0 -14
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { GitFork } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type RefObject,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { createPortal } from "react-dom";
|
|
10
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useNavigate } from "react-router";
|
|
12
|
+
|
|
13
|
+
import { getDashboardAgentName } from "../agentName";
|
|
14
|
+
import { useDashboardCoreData } from "../api";
|
|
15
|
+
import { DetailsPanel } from "../components/DetailsPanel";
|
|
16
|
+
import { ShimmerText } from "../components/ShimmerText";
|
|
17
|
+
import { Button } from "../components/ui/button";
|
|
18
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
19
|
+
import { DashboardApiError, post } from "../http";
|
|
20
|
+
import {
|
|
21
|
+
acceptedConversationMessageSchema,
|
|
22
|
+
forkConversationResponseSchema,
|
|
23
|
+
type ConversationDetailReport,
|
|
24
|
+
} from "@sentry/junior/api/schema";
|
|
25
|
+
import { cn } from "../styles";
|
|
26
|
+
import { ConversationComposer } from "./ConversationComposer";
|
|
27
|
+
import { conversationPath } from "./conversationRoutes";
|
|
28
|
+
import {
|
|
29
|
+
conversationDetailQueryKey,
|
|
30
|
+
conversationDetailQueryOptions,
|
|
31
|
+
} from "./queries";
|
|
32
|
+
import { TranscriptMessageShell } from "./TranscriptMessageShell";
|
|
33
|
+
|
|
34
|
+
const HIGHLIGHT_NAME = "quick-question";
|
|
35
|
+
const STARTER_PROMPTS = ["Explain this", "Is this right?", "What's the risk?"];
|
|
36
|
+
|
|
37
|
+
type QuickQuestionMessage = { role: "user" | "assistant"; text: string };
|
|
38
|
+
|
|
39
|
+
/** The selected text in one Junior reply. */
|
|
40
|
+
type QuickQuestionSelection = {
|
|
41
|
+
messageId: string;
|
|
42
|
+
quote: string;
|
|
43
|
+
range: Range;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type QuickQuestionThread = QuickQuestionSelection & {
|
|
47
|
+
conversationId: string;
|
|
48
|
+
/** Hidden fork that the first question starts. */
|
|
49
|
+
forkId?: string;
|
|
50
|
+
id: string;
|
|
51
|
+
/** Questions sent from this page, shown until the fork history has them. */
|
|
52
|
+
asked: string[];
|
|
53
|
+
askedAtMs?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Show or clear the selection highlight. Browsers without the API skip it. */
|
|
57
|
+
function setHighlight(range: Range | undefined) {
|
|
58
|
+
if (typeof CSS === "undefined" || !("highlights" in CSS)) return;
|
|
59
|
+
if (range) CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(range));
|
|
60
|
+
else CSS.highlights.delete(HIGHLIGHT_NAME);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Longest quote the API accepts. Longer selections are clipped. */
|
|
64
|
+
const MAX_QUOTE_CHARS = 4_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read the selection when it lies in the text of one Junior reply that can
|
|
68
|
+
* end a fork. `TranscriptMessageView` marks that text.
|
|
69
|
+
*/
|
|
70
|
+
function readSelection(
|
|
71
|
+
root: HTMLElement | null,
|
|
72
|
+
): QuickQuestionSelection | undefined {
|
|
73
|
+
const selection = window.getSelection();
|
|
74
|
+
if (!root || !selection || selection.isCollapsed || !selection.rangeCount)
|
|
75
|
+
return undefined;
|
|
76
|
+
const quote = selection.toString().trim().slice(0, MAX_QUOTE_CHARS).trim();
|
|
77
|
+
if (!quote) return undefined;
|
|
78
|
+
const range = selection.getRangeAt(0);
|
|
79
|
+
const node = range.commonAncestorContainer;
|
|
80
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
81
|
+
const message = element?.closest<HTMLElement>(
|
|
82
|
+
"[data-quick-question-message-id]",
|
|
83
|
+
);
|
|
84
|
+
const messageId = message?.dataset.quickQuestionMessageId;
|
|
85
|
+
if (!message || !messageId || !root.contains(message)) return undefined;
|
|
86
|
+
return { messageId, quote, range: range.cloneRange() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Show a "Quick question" button above a text selection in a Junior reply.
|
|
91
|
+
* It hides on scroll, because its position would go stale.
|
|
92
|
+
*/
|
|
93
|
+
export function QuickQuestionSelectionButton(props: {
|
|
94
|
+
rootRef: RefObject<HTMLElement | null>;
|
|
95
|
+
onAsk(selection: QuickQuestionSelection): void;
|
|
96
|
+
}) {
|
|
97
|
+
const { rootRef, onAsk } = props;
|
|
98
|
+
const [anchor, setAnchor] = useState<DOMRect>();
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const update = () =>
|
|
101
|
+
setAnchor(readSelection(rootRef.current)?.range.getBoundingClientRect());
|
|
102
|
+
const clearIfEmpty = () => {
|
|
103
|
+
if (!readSelection(rootRef.current)) setAnchor(undefined);
|
|
104
|
+
};
|
|
105
|
+
const hide = () => setAnchor(undefined);
|
|
106
|
+
document.addEventListener("mouseup", update);
|
|
107
|
+
document.addEventListener("keyup", update);
|
|
108
|
+
document.addEventListener("selectionchange", clearIfEmpty);
|
|
109
|
+
document.addEventListener("scroll", hide, true);
|
|
110
|
+
return () => {
|
|
111
|
+
document.removeEventListener("mouseup", update);
|
|
112
|
+
document.removeEventListener("keyup", update);
|
|
113
|
+
document.removeEventListener("selectionchange", clearIfEmpty);
|
|
114
|
+
document.removeEventListener("scroll", hide, true);
|
|
115
|
+
};
|
|
116
|
+
}, [rootRef]);
|
|
117
|
+
if (!anchor) return null;
|
|
118
|
+
// Show the button above the selection, or below it near the top edge.
|
|
119
|
+
const above = anchor.top >= 56;
|
|
120
|
+
return createPortal(
|
|
121
|
+
<div
|
|
122
|
+
className={cn(
|
|
123
|
+
"fixed z-50 -translate-x-1/2 rounded-lg border border-dashboard-border-emphasis bg-dashboard-surface-raised p-1 shadow-lg",
|
|
124
|
+
above && "-translate-y-full",
|
|
125
|
+
)}
|
|
126
|
+
style={{
|
|
127
|
+
left: anchor.left + anchor.width / 2,
|
|
128
|
+
top: above
|
|
129
|
+
? anchor.top - 8
|
|
130
|
+
: Math.min(anchor.bottom + 8, window.innerHeight - 56),
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<Button
|
|
134
|
+
className="h-8 text-dashboard-text"
|
|
135
|
+
// Keep the selection while the button takes the click.
|
|
136
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
137
|
+
onClick={() => {
|
|
138
|
+
const selection = readSelection(rootRef.current);
|
|
139
|
+
setAnchor(undefined);
|
|
140
|
+
if (!selection) return;
|
|
141
|
+
window.getSelection()?.removeAllRanges();
|
|
142
|
+
onAsk(selection);
|
|
143
|
+
}}
|
|
144
|
+
variant="quiet"
|
|
145
|
+
>
|
|
146
|
+
Quick question
|
|
147
|
+
</Button>
|
|
148
|
+
</div>,
|
|
149
|
+
document.body,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ask in a Quick question. The first question starts the hidden, read-only
|
|
155
|
+
* fork. A retry with the same thread key finds that fork again.
|
|
156
|
+
*/
|
|
157
|
+
function useAskQuickQuestion() {
|
|
158
|
+
const queryClient = useQueryClient();
|
|
159
|
+
return useMutation({
|
|
160
|
+
mutationFn: async (args: {
|
|
161
|
+
forkId?: string;
|
|
162
|
+
/** The composer key of this ask, so a retry cannot add the question twice. */
|
|
163
|
+
idempotencyKey: string;
|
|
164
|
+
messageId: string;
|
|
165
|
+
quote: string;
|
|
166
|
+
sourceConversationId: string;
|
|
167
|
+
text: string;
|
|
168
|
+
threadKey: string;
|
|
169
|
+
}) => {
|
|
170
|
+
const forkId =
|
|
171
|
+
args.forkId ??
|
|
172
|
+
(
|
|
173
|
+
await post(
|
|
174
|
+
forkConversationResponseSchema,
|
|
175
|
+
`/api/conversations/${encodeURIComponent(args.sourceConversationId)}/forks`,
|
|
176
|
+
{
|
|
177
|
+
idempotencyKey: args.threadKey,
|
|
178
|
+
messageId: args.messageId,
|
|
179
|
+
quickQuestion: { quote: args.quote },
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
).conversationId;
|
|
183
|
+
return post(
|
|
184
|
+
acceptedConversationMessageSchema,
|
|
185
|
+
`/api/conversations/${encodeURIComponent(forkId)}/messages`,
|
|
186
|
+
{ idempotencyKey: args.idempotencyKey, message: args.text },
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
onSuccess: (accepted) =>
|
|
190
|
+
queryClient.invalidateQueries({
|
|
191
|
+
queryKey: conversationDetailQueryKey(accepted.conversationId),
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Keep a Quick question as a fork that lists show. */
|
|
197
|
+
function useKeepQuickQuestion() {
|
|
198
|
+
const queryClient = useQueryClient();
|
|
199
|
+
return useMutation({
|
|
200
|
+
mutationFn: (forkId: string) =>
|
|
201
|
+
post(
|
|
202
|
+
forkConversationResponseSchema,
|
|
203
|
+
`/api/conversations/${encodeURIComponent(forkId)}/quick-question/keep`,
|
|
204
|
+
{},
|
|
205
|
+
),
|
|
206
|
+
onSuccess: () =>
|
|
207
|
+
queryClient.invalidateQueries({
|
|
208
|
+
queryKey: ["dashboard", "conversations"],
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Own one Quick question thread for a Conversation page. The page keeps it,
|
|
215
|
+
* so an answer that arrives while another panel shows is not lost. Closing
|
|
216
|
+
* leaves the hidden fork unlisted, so the question is gone for the reader.
|
|
217
|
+
*/
|
|
218
|
+
export function useQuickQuestionThread(conversationId: string) {
|
|
219
|
+
const [saved, setThread] = useState<QuickQuestionThread>();
|
|
220
|
+
// The page stays mounted across Conversations. A thread belongs to one.
|
|
221
|
+
const thread = saved?.conversationId === conversationId ? saved : undefined;
|
|
222
|
+
const ask = useAskQuickQuestion();
|
|
223
|
+
const { mutateAsync, reset } = ask;
|
|
224
|
+
|
|
225
|
+
const open = useCallback(
|
|
226
|
+
(selection: QuickQuestionSelection) => {
|
|
227
|
+
reset();
|
|
228
|
+
setHighlight(selection.range);
|
|
229
|
+
setThread({
|
|
230
|
+
...selection,
|
|
231
|
+
asked: [],
|
|
232
|
+
conversationId,
|
|
233
|
+
id: crypto.randomUUID(),
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
[conversationId, reset],
|
|
237
|
+
);
|
|
238
|
+
const close = useCallback(() => {
|
|
239
|
+
reset();
|
|
240
|
+
setHighlight(undefined);
|
|
241
|
+
setThread(undefined);
|
|
242
|
+
}, [reset]);
|
|
243
|
+
// Leaving the Conversation discards its thread.
|
|
244
|
+
useEffect(
|
|
245
|
+
() => () => {
|
|
246
|
+
setHighlight(undefined);
|
|
247
|
+
setThread(undefined);
|
|
248
|
+
},
|
|
249
|
+
[conversationId],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const submit = useCallback(
|
|
253
|
+
async (text: string, idempotencyKey: string) => {
|
|
254
|
+
if (!thread) return;
|
|
255
|
+
const accepted = await mutateAsync({
|
|
256
|
+
forkId: thread.forkId,
|
|
257
|
+
idempotencyKey,
|
|
258
|
+
messageId: thread.messageId,
|
|
259
|
+
quote: thread.quote,
|
|
260
|
+
sourceConversationId: conversationId,
|
|
261
|
+
text,
|
|
262
|
+
threadKey: thread.id,
|
|
263
|
+
});
|
|
264
|
+
// A closed or replaced thread drops its late result.
|
|
265
|
+
setThread((current) =>
|
|
266
|
+
current?.id === thread.id
|
|
267
|
+
? {
|
|
268
|
+
...current,
|
|
269
|
+
asked: [...current.asked, text],
|
|
270
|
+
askedAtMs: Date.now(),
|
|
271
|
+
forkId: accepted.conversationId,
|
|
272
|
+
}
|
|
273
|
+
: current,
|
|
274
|
+
);
|
|
275
|
+
},
|
|
276
|
+
[conversationId, mutateAsync, thread],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
close,
|
|
281
|
+
error: ask.error,
|
|
282
|
+
open,
|
|
283
|
+
pending: ask.isPending,
|
|
284
|
+
submit,
|
|
285
|
+
thread,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Messages of the fork after the selected reply: the questions and answers. */
|
|
290
|
+
function forkThreadMessages(
|
|
291
|
+
snapshot: ConversationDetailReport | undefined,
|
|
292
|
+
replyMessageId: string,
|
|
293
|
+
): QuickQuestionMessage[] {
|
|
294
|
+
const messages = (snapshot?.events ?? []).flatMap(({ data }) =>
|
|
295
|
+
data.type === "message" &&
|
|
296
|
+
(data.role === "user" || data.role === "assistant") &&
|
|
297
|
+
data.text
|
|
298
|
+
? [{ id: data.messageId, role: data.role, text: data.text }]
|
|
299
|
+
: [],
|
|
300
|
+
);
|
|
301
|
+
// The copied history ends with the selected reply. A page that does not
|
|
302
|
+
// reach back to it holds only new messages.
|
|
303
|
+
const reply = messages.findIndex((message) => message.id === replyMessageId);
|
|
304
|
+
return messages.slice(reply + 1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Side panel for one Quick question thread. */
|
|
308
|
+
export function QuickQuestionPanel(props: {
|
|
309
|
+
conversationId: string;
|
|
310
|
+
error: Error | null;
|
|
311
|
+
onClose(): void;
|
|
312
|
+
onSubmit(text: string, idempotencyKey: string): Promise<void>;
|
|
313
|
+
pending: boolean;
|
|
314
|
+
thread: QuickQuestionThread;
|
|
315
|
+
}) {
|
|
316
|
+
const { thread } = props;
|
|
317
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
318
|
+
const footerRef = useRef<HTMLDivElement>(null);
|
|
319
|
+
const me = useDashboardCoreData().data?.me.user;
|
|
320
|
+
const viewerName = me?.name?.trim() || me?.email || "You";
|
|
321
|
+
const agentName = getDashboardAgentName();
|
|
322
|
+
const fork = useQuery(
|
|
323
|
+
conversationDetailQueryOptions(thread.forkId, {
|
|
324
|
+
fastPollUntilMs: thread.askedAtMs && thread.askedAtMs + 10_000,
|
|
325
|
+
}),
|
|
326
|
+
).data;
|
|
327
|
+
const saved = forkThreadMessages(fork, thread.messageId);
|
|
328
|
+
const unsaved = thread.asked
|
|
329
|
+
.slice(saved.filter((message) => message.role === "user").length)
|
|
330
|
+
.map((text) => ({ role: "user" as const, text }));
|
|
331
|
+
const messages = [...saved, ...unsaved];
|
|
332
|
+
// A failed Turn ends without an answer. The person can then ask again.
|
|
333
|
+
const thinking =
|
|
334
|
+
props.pending ||
|
|
335
|
+
unsaved.length > 0 ||
|
|
336
|
+
(fork?.status === "active" && messages.at(-1)?.role === "user");
|
|
337
|
+
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
footerRef.current?.querySelector("textarea")?.focus();
|
|
340
|
+
}, [thread.id]);
|
|
341
|
+
|
|
342
|
+
// Keep the newest message in view.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
const scroller = scrollerRef.current;
|
|
345
|
+
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
|
346
|
+
}, [messages.length, thinking]);
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<DetailsPanel
|
|
350
|
+
label="Quick question"
|
|
351
|
+
sheetTitle="Quick question"
|
|
352
|
+
onClose={props.onClose}
|
|
353
|
+
title={
|
|
354
|
+
<h2 className="m-0 font-sans text-sm font-semibold text-dashboard-text">
|
|
355
|
+
Quick question
|
|
356
|
+
</h2>
|
|
357
|
+
}
|
|
358
|
+
>
|
|
359
|
+
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto]">
|
|
360
|
+
<div
|
|
361
|
+
className="grid content-start gap-5 overflow-y-auto px-5 py-4"
|
|
362
|
+
ref={scrollerRef}
|
|
363
|
+
>
|
|
364
|
+
<blockquote className="m-0 border-l-2 border-dashboard-accent pl-3 text-sm whitespace-pre-wrap text-dashboard-text">
|
|
365
|
+
{thread.quote}
|
|
366
|
+
</blockquote>
|
|
367
|
+
{messages.length === 0 && !props.pending ? (
|
|
368
|
+
<div className="flex flex-wrap gap-2">
|
|
369
|
+
{STARTER_PROMPTS.map((prompt) => (
|
|
370
|
+
<Button
|
|
371
|
+
className="h-8 rounded-full"
|
|
372
|
+
key={prompt}
|
|
373
|
+
// Each click is a new ask. The panel shows the error.
|
|
374
|
+
onClick={() =>
|
|
375
|
+
void props
|
|
376
|
+
.onSubmit(prompt, crypto.randomUUID())
|
|
377
|
+
.catch(() => {})
|
|
378
|
+
}
|
|
379
|
+
>
|
|
380
|
+
{prompt}
|
|
381
|
+
</Button>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
) : (
|
|
385
|
+
messages.map((message, index) => (
|
|
386
|
+
<TranscriptMessageShell
|
|
387
|
+
actor={message.role === "user" ? viewerName : agentName}
|
|
388
|
+
avatarUrl={message.role === "user" ? me?.avatarUrl : undefined}
|
|
389
|
+
key={index}
|
|
390
|
+
role={message.role}
|
|
391
|
+
>
|
|
392
|
+
<span
|
|
393
|
+
className={
|
|
394
|
+
message.role === "user"
|
|
395
|
+
? "text-sm font-semibold text-dashboard-text"
|
|
396
|
+
: "text-sm font-semibold text-dashboard-accent-text"
|
|
397
|
+
}
|
|
398
|
+
>
|
|
399
|
+
{message.role === "user" ? viewerName : agentName}
|
|
400
|
+
</span>
|
|
401
|
+
<DisplayText chat text={message.text} />
|
|
402
|
+
</TranscriptMessageShell>
|
|
403
|
+
))
|
|
404
|
+
)}
|
|
405
|
+
{thinking ? (
|
|
406
|
+
<TranscriptMessageShell actor={agentName} role="assistant">
|
|
407
|
+
<span className="text-sm font-semibold text-dashboard-accent-text">
|
|
408
|
+
{agentName}
|
|
409
|
+
</span>
|
|
410
|
+
<ShimmerText active className="text-sm text-dashboard-text-muted">
|
|
411
|
+
Thinking…
|
|
412
|
+
</ShimmerText>
|
|
413
|
+
</TranscriptMessageShell>
|
|
414
|
+
) : null}
|
|
415
|
+
</div>
|
|
416
|
+
<div
|
|
417
|
+
className="grid gap-1 border-t border-dashboard-border px-3 pt-3 pb-2"
|
|
418
|
+
ref={footerRef}
|
|
419
|
+
>
|
|
420
|
+
<ConversationComposer
|
|
421
|
+
compact
|
|
422
|
+
disabled={thinking}
|
|
423
|
+
draftId={`quick-question:${props.conversationId}`}
|
|
424
|
+
error={
|
|
425
|
+
props.error
|
|
426
|
+
? (props.error instanceof DashboardApiError &&
|
|
427
|
+
props.error.apiError) ||
|
|
428
|
+
"Could not ask. Try again."
|
|
429
|
+
: undefined
|
|
430
|
+
}
|
|
431
|
+
key={thread.id}
|
|
432
|
+
label="Quick question"
|
|
433
|
+
onSubmit={props.onSubmit}
|
|
434
|
+
placeholder={
|
|
435
|
+
messages.length ? "Ask a follow-up…" : "Ask a quick question…"
|
|
436
|
+
}
|
|
437
|
+
restoreDraftOnError
|
|
438
|
+
submitLabel="Ask"
|
|
439
|
+
/>
|
|
440
|
+
<div className="flex items-center justify-between gap-2 px-1 text-xs text-dashboard-text-muted">
|
|
441
|
+
<span>Closing discards this question.</span>
|
|
442
|
+
<KeepAsForkButton
|
|
443
|
+
disabled={thinking}
|
|
444
|
+
forkId={thread.forkId}
|
|
445
|
+
// A new thread needs a new keep request.
|
|
446
|
+
key={thread.id}
|
|
447
|
+
/>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
</DetailsPanel>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Keep the hidden fork: it shows in lists and is no longer read-only. */
|
|
456
|
+
function KeepAsForkButton(props: { disabled: boolean; forkId?: string }) {
|
|
457
|
+
const navigate = useNavigate();
|
|
458
|
+
const keep = useKeepQuickQuestion();
|
|
459
|
+
const forkId = props.forkId;
|
|
460
|
+
return (
|
|
461
|
+
<Button
|
|
462
|
+
className="h-7 px-2 text-xs"
|
|
463
|
+
disabled={!forkId || props.disabled || keep.isPending}
|
|
464
|
+
onClick={() =>
|
|
465
|
+
forkId &&
|
|
466
|
+
keep.mutate(forkId, {
|
|
467
|
+
onSuccess: () => navigate(conversationPath(forkId)),
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
variant="quiet"
|
|
471
|
+
>
|
|
472
|
+
<GitFork aria-hidden="true" size={13} />
|
|
473
|
+
{keep.isError ? "Retry keep" : "Keep as fork"}
|
|
474
|
+
</Button>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
@@ -115,7 +115,6 @@ function modelHeading(
|
|
|
115
115
|
/** Rows shown before the rest of a list rolls into one "N more" line. */
|
|
116
116
|
const MODEL_LIMIT = 4;
|
|
117
117
|
const AUXILIARY_LIMIT = 3;
|
|
118
|
-
const DETAILS_FOOTER = "Full breakdown in Details";
|
|
119
118
|
|
|
120
119
|
type RankedRow = UsageCardRow & { amount: number };
|
|
121
120
|
|
|
@@ -222,7 +221,6 @@ function tokenCard(
|
|
|
222
221
|
compactionCount: number | undefined,
|
|
223
222
|
liveModelId: string | undefined,
|
|
224
223
|
childAgentTotals: ConversationChildAgentTotals | undefined,
|
|
225
|
-
detailsHint: boolean,
|
|
226
224
|
): UsageCardProps {
|
|
227
225
|
const models = modelRows(
|
|
228
226
|
modelUsage,
|
|
@@ -232,7 +230,6 @@ function tokenCard(
|
|
|
232
230
|
formatCompactNumber,
|
|
233
231
|
);
|
|
234
232
|
return {
|
|
235
|
-
footer: detailsHint && models.length ? DETAILS_FOOTER : undefined,
|
|
236
233
|
note: compactionCount ? plural("compaction", compactionCount) : undefined,
|
|
237
234
|
sections: models.length
|
|
238
235
|
? [
|
|
@@ -278,7 +275,6 @@ function costCard(
|
|
|
278
275
|
auxiliaryCosts: ConversationAuxiliaryCosts | undefined,
|
|
279
276
|
liveModelId: string | undefined,
|
|
280
277
|
childAgentTotals: ConversationChildAgentTotals | undefined,
|
|
281
|
-
detailsHint: boolean,
|
|
282
278
|
): UsageCardProps | undefined {
|
|
283
279
|
const total = totalConversationCost(summary, auxiliaryCosts);
|
|
284
280
|
if (!total) return undefined;
|
|
@@ -315,10 +311,6 @@ function costCard(
|
|
|
315
311
|
});
|
|
316
312
|
}
|
|
317
313
|
return {
|
|
318
|
-
footer:
|
|
319
|
-
detailsHint && (models.length || operations.length)
|
|
320
|
-
? DETAILS_FOOTER
|
|
321
|
-
: undefined,
|
|
322
314
|
sections,
|
|
323
315
|
split: agentSplit(
|
|
324
316
|
childAgentTotals,
|
|
@@ -354,12 +346,11 @@ export function CostMetric(props: {
|
|
|
354
346
|
align?: "left" | "right";
|
|
355
347
|
auxiliaryCosts?: ConversationAuxiliaryCosts;
|
|
356
348
|
childAgentTotals?: ConversationChildAgentTotals;
|
|
357
|
-
/** Point to conversation details, which has the full breakdown. */
|
|
358
|
-
detailsHint?: boolean;
|
|
359
349
|
live?: boolean;
|
|
360
350
|
liveModelId?: string;
|
|
361
351
|
modelUsage?: ConversationModelUsage[];
|
|
362
352
|
summary: CostUsageSummary | undefined;
|
|
353
|
+
tooltipPlacement?: "above" | "below";
|
|
363
354
|
}) {
|
|
364
355
|
// Model history can include calls not yet counted in saved Run totals.
|
|
365
356
|
const summary = summarizeModelCost(props.modelUsage) ?? props.summary;
|
|
@@ -375,12 +366,11 @@ export function CostMetric(props: {
|
|
|
375
366
|
props.auxiliaryCosts,
|
|
376
367
|
props.liveModelId,
|
|
377
368
|
props.childAgentTotals,
|
|
378
|
-
Boolean(props.detailsHint),
|
|
379
369
|
);
|
|
380
370
|
return (
|
|
381
371
|
<MetricValue
|
|
382
372
|
align={props.align}
|
|
383
|
-
tooltipPlacement="above"
|
|
373
|
+
tooltipPlacement={props.tooltipPlacement ?? "above"}
|
|
384
374
|
tooltip={
|
|
385
375
|
!card && props.liveModelId
|
|
386
376
|
? [modelHeading(props.liveModelId, props.liveModelId)]
|
|
@@ -398,8 +388,6 @@ export function TokenMetric(props: {
|
|
|
398
388
|
align?: "left" | "right";
|
|
399
389
|
childAgentTotals?: ConversationChildAgentTotals;
|
|
400
390
|
compactionCount?: number;
|
|
401
|
-
/** Point to conversation details, which has the full breakdown. */
|
|
402
|
-
detailsHint?: boolean;
|
|
403
391
|
live?: boolean;
|
|
404
392
|
liveModelId?: string;
|
|
405
393
|
modelUsage?: ConversationModelUsage[];
|
|
@@ -417,7 +405,6 @@ export function TokenMetric(props: {
|
|
|
417
405
|
props.compactionCount,
|
|
418
406
|
props.liveModelId,
|
|
419
407
|
props.childAgentTotals,
|
|
420
|
-
Boolean(props.detailsHint),
|
|
421
408
|
)}
|
|
422
409
|
/>
|
|
423
410
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "../components/ui/tooltip";
|
|
11
11
|
import { formatMs } from "../format";
|
|
12
12
|
import { reviewItemIdFromToolCall } from "../review-desk/reviewItemFormat";
|
|
13
|
+
import { isMemoryCaptureEvent } from "./conversationMemory";
|
|
13
14
|
import type { RenderedTranscriptEntry } from "./transcriptRenderModel";
|
|
14
15
|
import { cn } from "../styles";
|
|
15
16
|
import { useTranscriptSearch } from "./transcriptSearch";
|
|
@@ -30,6 +31,10 @@ export function isCollapsibleActivityEntry(
|
|
|
30
31
|
// Child agent rows stay in the main view, like their result events.
|
|
31
32
|
if (entry.kind === "subagent") return false;
|
|
32
33
|
if (entry.kind === "message") return false;
|
|
34
|
+
// A Turn's captured memories stay visible, so the reader sees what it saved.
|
|
35
|
+
if (entry.kind === "structured_event" && isMemoryCaptureEvent(entry.part)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
33
38
|
// A review card stays visible where the agent added the item.
|
|
34
39
|
if (entry.kind === "tool" && reviewItemIdFromToolCall(entry.part)) {
|
|
35
40
|
return false;
|
|
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|
|
4
4
|
import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
|
|
5
5
|
import { cn, dashboardDisclosureChevronClass } from "../styles";
|
|
6
6
|
import { DisplayText } from "../display-text/DisplayText";
|
|
7
|
+
import { ReviewItemChangeRow } from "../review-desk/ReviewItemChangeRow";
|
|
7
8
|
import { messageRawText } from "./transcriptRenderModel";
|
|
8
9
|
import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
|
|
9
10
|
import { RedactedMarker } from "./TranscriptRedacted";
|
|
@@ -82,8 +83,8 @@ export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
|
|
|
82
83
|
</span>
|
|
83
84
|
);
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
const view = (
|
|
87
|
+
<>
|
|
87
88
|
{text ? (
|
|
88
89
|
<Collapsible
|
|
89
90
|
className="min-w-0"
|
|
@@ -120,6 +121,28 @@ export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
|
|
|
120
121
|
) : null}
|
|
121
122
|
</>
|
|
122
123
|
)}
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// A relay of a person change shows as its change row, like the person's
|
|
128
|
+
// own message, not as an Event line. A message with changes lists its own
|
|
129
|
+
// Events in `events`, so with none it only records changes.
|
|
130
|
+
const changes = props.message.reviewItemChanges ?? [];
|
|
131
|
+
const onlyChanges = changes.length > 0 && events.length === 0;
|
|
132
|
+
return (
|
|
133
|
+
<div className="min-w-0 py-1">
|
|
134
|
+
{onlyChanges ? null : view}
|
|
135
|
+
{changes.length ? (
|
|
136
|
+
<div className={cn("grid min-w-0 gap-2", !onlyChanges && "mt-2")}>
|
|
137
|
+
{changes.map((record, index) => (
|
|
138
|
+
<ReviewItemChangeRow
|
|
139
|
+
fallback={onlyChanges && changes.length === 1 ? view : null}
|
|
140
|
+
key={index}
|
|
141
|
+
record={record}
|
|
142
|
+
/>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
) : null}
|
|
123
146
|
</div>
|
|
124
147
|
);
|
|
125
148
|
}
|