@sentry/junior-dashboard 0.274.0 → 0.275.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 +2 -2
- package/dist/client.js +72 -72
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- 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/ConversationDetails.tsx +117 -162
- package/src/client/conversations/ConversationHeader.tsx +119 -133
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationMeta.tsx +49 -44
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +33 -368
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +5 -7
- package/src/client/conversations/TelemetryMetrics.tsx +0 -14
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +1 -15
- package/src/client/conversations/conversationSections.ts +18 -14
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- package/src/tailwind.css +4 -0
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ConversationDetailReport,
|
|
4
|
+
ConversationPendingMessagesReport,
|
|
5
|
+
InputImage,
|
|
6
|
+
} from "@sentry/junior/api/schema";
|
|
7
|
+
|
|
8
|
+
import { useDashboardCoreData } from "../api";
|
|
9
|
+
import { useReviewItemData } from "../review-desk/queries";
|
|
10
|
+
import { reviewItemWaitsForAnswer } from "../review-desk/reviewItemFormat";
|
|
11
|
+
import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
|
|
12
|
+
import { readStoredComposerImages } from "./ComposerImages";
|
|
13
|
+
import { ComposerDock } from "./ComposerDock";
|
|
14
|
+
import {
|
|
15
|
+
ConversationComposer,
|
|
16
|
+
restoreStoredDraftText,
|
|
17
|
+
type ConversationComposerHandle,
|
|
18
|
+
} from "./ConversationComposer";
|
|
19
|
+
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
20
|
+
import { PendingAuthorization } from "./ConversationMeta";
|
|
21
|
+
import { PendingMailboxStack } from "./PendingMailboxStack";
|
|
22
|
+
import {
|
|
23
|
+
useAppendConversationMessage,
|
|
24
|
+
useCancelConversationPendingMessages,
|
|
25
|
+
useSteerConversationPendingMessages,
|
|
26
|
+
useStopConversationTurn,
|
|
27
|
+
} from "./queries";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Own mutation state and mailbox chrome outside the page tree that re-renders
|
|
31
|
+
* on every live transcript poll. Keeps composer props stable while typing.
|
|
32
|
+
*
|
|
33
|
+
* Memoized so metadata-only polls that keep mailbox identity stable skip the
|
|
34
|
+
* footer tree. Fast chat UIs isolate the composer the same way.
|
|
35
|
+
*/
|
|
36
|
+
export const ConversationReplyFooter = memo(ReplyFooter);
|
|
37
|
+
|
|
38
|
+
function ReplyFooter(props: {
|
|
39
|
+
authors?: ConversationDetailReport["authors"];
|
|
40
|
+
committedMessageIds: readonly string[];
|
|
41
|
+
conversationId: string;
|
|
42
|
+
onPinRequest: () => void;
|
|
43
|
+
pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
|
|
44
|
+
pendingGeneratedAtRef: { current: string | undefined };
|
|
45
|
+
pendingMessages: readonly ConversationMailboxMessage[];
|
|
46
|
+
/** The last Review item that Junior added in the transcript. */
|
|
47
|
+
reviewItemId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* True when history is live and no Turn has started, so the next Turn will
|
|
50
|
+
* read a new send at once.
|
|
51
|
+
*/
|
|
52
|
+
sendsInline: boolean;
|
|
53
|
+
/** True while the conversation has a Turn that a stop can end. */
|
|
54
|
+
stoppable: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
const appendMessage = useAppendConversationMessage(props.conversationId);
|
|
57
|
+
// A message closes the review items that it answers. Ask before it drops
|
|
58
|
+
// answers that the reader did not send.
|
|
59
|
+
const unsentAnswers = useConfirmUnsentAnswers();
|
|
60
|
+
const stopTurn = useStopConversationTurn(props.conversationId);
|
|
61
|
+
const stopTurnRef = useRef(stopTurn);
|
|
62
|
+
stopTurnRef.current = stopTurn;
|
|
63
|
+
const cancelPendingMessages = useCancelConversationPendingMessages(
|
|
64
|
+
props.conversationId,
|
|
65
|
+
);
|
|
66
|
+
const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
|
|
67
|
+
useState<string>();
|
|
68
|
+
const [queueActionError, setQueueActionError] = useState<string>();
|
|
69
|
+
// While Junior waits for an answer, hide the message box and the queued
|
|
70
|
+
// messages, so the reader answers in the question and does not type the
|
|
71
|
+
// answer as a new message.
|
|
72
|
+
// Dismiss on the question closes the item and shows the message box again.
|
|
73
|
+
// The message box stays mounted, so its text and images stay, and it does
|
|
74
|
+
// not hide while it has focus.
|
|
75
|
+
const reviewItem = useReviewItemData(props.reviewItemId).data;
|
|
76
|
+
const [composerShownForItemId, setComposerShownForItemId] =
|
|
77
|
+
useState<string>();
|
|
78
|
+
const [composerFocused, setComposerFocused] = useState(false);
|
|
79
|
+
const question =
|
|
80
|
+
reviewItem &&
|
|
81
|
+
!props.stoppable &&
|
|
82
|
+
!composerFocused &&
|
|
83
|
+
reviewItemWaitsForAnswer(reviewItem) &&
|
|
84
|
+
composerShownForItemId !== reviewItem.id
|
|
85
|
+
? reviewItem
|
|
86
|
+
: undefined;
|
|
87
|
+
// The message box can show only because it has focus. Using the queued
|
|
88
|
+
// messages moves focus out of it, so keep them shown for this item.
|
|
89
|
+
const keepComposerShown = () => {
|
|
90
|
+
if (composerFocused && reviewItem && reviewItemWaitsForAnswer(reviewItem)) {
|
|
91
|
+
setComposerShownForItemId(reviewItem.id);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// Keep submit identity stable across mutation status flips so the memoized
|
|
95
|
+
// composer does not re-render while the reader is still typing.
|
|
96
|
+
const appendMessageRef = useRef(appendMessage);
|
|
97
|
+
appendMessageRef.current = appendMessage;
|
|
98
|
+
const cancelPendingMessagesRef = useRef(cancelPendingMessages);
|
|
99
|
+
cancelPendingMessagesRef.current = cancelPendingMessages;
|
|
100
|
+
const onPinRequestRef = useRef(props.onPinRequest);
|
|
101
|
+
onPinRequestRef.current = props.onPinRequest;
|
|
102
|
+
const pendingMessageVersion = props.pendingMessages
|
|
103
|
+
.map((message) =>
|
|
104
|
+
[
|
|
105
|
+
message.inboundMessageId,
|
|
106
|
+
message.messageId,
|
|
107
|
+
message.clientStatus,
|
|
108
|
+
message.delivery,
|
|
109
|
+
].join(":"),
|
|
110
|
+
)
|
|
111
|
+
.join("|");
|
|
112
|
+
const pendingMessageVersionRef = useRef(pendingMessageVersion);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (pendingMessageVersionRef.current === pendingMessageVersion) return;
|
|
115
|
+
pendingMessageVersionRef.current = pendingMessageVersion;
|
|
116
|
+
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
117
|
+
onPinRequestRef.current();
|
|
118
|
+
}, [pendingMessageVersion]);
|
|
119
|
+
// No Turn runs and nothing waits in the queue, so the next send would
|
|
120
|
+
// leave the queue almost at once. Show it in the transcript instead. A
|
|
121
|
+
// conversation can be active before its Turn starts; that still counts.
|
|
122
|
+
const committedIds = new Set(props.committedMessageIds);
|
|
123
|
+
const sendInlineRef = useRef(false);
|
|
124
|
+
sendInlineRef.current =
|
|
125
|
+
props.sendsInline &&
|
|
126
|
+
!props.pendingMessages.some(
|
|
127
|
+
(message) => !committedIds.has(message.messageId),
|
|
128
|
+
);
|
|
129
|
+
const onSubmit = useCallback(
|
|
130
|
+
async (message: string, idempotencyKey: string, images?: InputImage[]) => {
|
|
131
|
+
await appendMessageRef.current.mutateAsync({
|
|
132
|
+
idempotencyKey,
|
|
133
|
+
inline: sendInlineRef.current,
|
|
134
|
+
message,
|
|
135
|
+
images,
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
[],
|
|
139
|
+
);
|
|
140
|
+
const onRetry = useCallback((message: ConversationMailboxMessage) => {
|
|
141
|
+
if (!message.idempotencyKey || (!message.text && !message.images?.length))
|
|
142
|
+
return;
|
|
143
|
+
void appendMessageRef.current.mutateAsync({
|
|
144
|
+
idempotencyKey: message.idempotencyKey,
|
|
145
|
+
message: message.text ?? "",
|
|
146
|
+
images: message.images,
|
|
147
|
+
});
|
|
148
|
+
}, []);
|
|
149
|
+
const onSubmitStart = useCallback(() => {
|
|
150
|
+
// Keep an in-flight remove intact so optimistic cache rollback stays coherent.
|
|
151
|
+
if (!cancelPendingMessagesRef.current.isPending) {
|
|
152
|
+
cancelPendingMessagesRef.current.reset();
|
|
153
|
+
}
|
|
154
|
+
if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
|
|
155
|
+
setQueueActionError(undefined);
|
|
156
|
+
onPinRequestRef.current();
|
|
157
|
+
}, []);
|
|
158
|
+
const cancellableMessageIds = props.pendingMessages
|
|
159
|
+
.filter((message) => message.clientStatus === undefined)
|
|
160
|
+
.map((message) => message.inboundMessageId);
|
|
161
|
+
const onCancelMessage = useCallback(
|
|
162
|
+
(message: ConversationMailboxMessage) => {
|
|
163
|
+
const receivedBefore = props.pendingGeneratedAtRef.current;
|
|
164
|
+
if (!receivedBefore) return;
|
|
165
|
+
cancelPendingMessagesRef.current.mutate({
|
|
166
|
+
inboundMessageIds: [message.inboundMessageId],
|
|
167
|
+
receivedBefore,
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
[props.pendingGeneratedAtRef],
|
|
171
|
+
);
|
|
172
|
+
const cancelTargetInboundMessageId =
|
|
173
|
+
cancelPendingMessages.variables?.inboundMessageIds[0];
|
|
174
|
+
const cancelError = Boolean(
|
|
175
|
+
cancelPendingMessages.error &&
|
|
176
|
+
cancelPendingMessages.variables?.inboundMessageIds.some((id) =>
|
|
177
|
+
cancellableMessageIds.includes(id),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Edit uses its own cancel mutation so remove errors stay on remove.
|
|
182
|
+
const editCancel = useCancelConversationPendingMessages(props.conversationId);
|
|
183
|
+
const editCancelRef = useRef(editCancel);
|
|
184
|
+
editCancelRef.current = editCancel;
|
|
185
|
+
const editingRef = useRef(false);
|
|
186
|
+
const composerRef = useRef<ConversationComposerHandle>(null);
|
|
187
|
+
const viewerEmail = useDashboardCoreData().data?.me.user.email;
|
|
188
|
+
const onEditMessage = useCallback(
|
|
189
|
+
async (message: ConversationMailboxMessage) => {
|
|
190
|
+
const receivedBefore = props.pendingGeneratedAtRef.current;
|
|
191
|
+
// The image read runs before the cancel starts, so isPending alone does
|
|
192
|
+
// not stop a second click.
|
|
193
|
+
if (!receivedBefore || editingRef.current) return;
|
|
194
|
+
editingRef.current = true;
|
|
195
|
+
const inboundMessageId = message.inboundMessageId;
|
|
196
|
+
setEditTargetInboundMessageId(inboundMessageId);
|
|
197
|
+
setQueueActionError(undefined);
|
|
198
|
+
try {
|
|
199
|
+
// Read images first. A failed read must leave the message queued.
|
|
200
|
+
const images = await readStoredComposerImages(
|
|
201
|
+
props.conversationId,
|
|
202
|
+
message.attachments,
|
|
203
|
+
);
|
|
204
|
+
// The reader left during the read. Keep the message in the queue.
|
|
205
|
+
if (!composerRef.current) return;
|
|
206
|
+
// Check before the cancel. After it, images that do not fit are lost.
|
|
207
|
+
if (!composerRef.current.canRestoreImages(images)) {
|
|
208
|
+
setQueueActionError("Remove some draft images to edit that message.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const result = await editCancelRef.current.mutateAsync({
|
|
212
|
+
inboundMessageIds: [inboundMessageId],
|
|
213
|
+
receivedBefore,
|
|
214
|
+
});
|
|
215
|
+
// Junior can read the message before the cancel lands. Then it is
|
|
216
|
+
// already in the Conversation and there is nothing to edit.
|
|
217
|
+
if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) {
|
|
218
|
+
setQueueActionError("Junior already read that message.");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const restored = { images, text: message.text ?? "" };
|
|
222
|
+
if (composerRef.current) {
|
|
223
|
+
composerRef.current.restoreMessage(restored);
|
|
224
|
+
} else {
|
|
225
|
+
// The reader left during the cancel. Keep the text in the saved
|
|
226
|
+
// draft so it is there when they come back.
|
|
227
|
+
restoreStoredDraftText(props.conversationId, restored.text);
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
setQueueActionError("Could not edit the queued message. Try again.");
|
|
231
|
+
} finally {
|
|
232
|
+
editingRef.current = false;
|
|
233
|
+
setEditTargetInboundMessageId(undefined);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
[props.conversationId, props.pendingGeneratedAtRef],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const steerPendingMessages = useSteerConversationPendingMessages(
|
|
240
|
+
props.conversationId,
|
|
241
|
+
);
|
|
242
|
+
const steerPendingMessagesRef = useRef(steerPendingMessages);
|
|
243
|
+
steerPendingMessagesRef.current = steerPendingMessages;
|
|
244
|
+
const onSteerMessage = useCallback((message: ConversationMailboxMessage) => {
|
|
245
|
+
if (steerPendingMessagesRef.current.isPending) return;
|
|
246
|
+
setQueueActionError(undefined);
|
|
247
|
+
steerPendingMessagesRef.current.mutate(
|
|
248
|
+
{ inboundMessageIds: [message.inboundMessageId] },
|
|
249
|
+
{
|
|
250
|
+
onError: () =>
|
|
251
|
+
setQueueActionError(
|
|
252
|
+
"Could not send the queued message now. Try again.",
|
|
253
|
+
),
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
}, []);
|
|
257
|
+
const steerTargetInboundMessageId = steerPendingMessages.isPending
|
|
258
|
+
? steerPendingMessages.variables?.inboundMessageIds[0]
|
|
259
|
+
: undefined;
|
|
260
|
+
|
|
261
|
+
const onStop = useCallback(async () => {
|
|
262
|
+
const result = await stopTurnRef.current.mutateAsync();
|
|
263
|
+
const restored = result.stoppedMessages.map((message) => message.text);
|
|
264
|
+
return restored.length ? restored.join("\n") : undefined;
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const onMailboxLayoutChange = useCallback(() => {
|
|
268
|
+
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
269
|
+
onPinRequestRef.current();
|
|
270
|
+
}, []);
|
|
271
|
+
// Mobile keyboard open shrinks the transcript. Focus must re-pin the latest
|
|
272
|
+
// message so the reader does not stay stuck above the docked composer.
|
|
273
|
+
const onComposerFocus = useCallback(() => {
|
|
274
|
+
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
275
|
+
onPinRequestRef.current();
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<ComposerDock
|
|
280
|
+
above={
|
|
281
|
+
<>
|
|
282
|
+
{props.pendingAuthorization ? (
|
|
283
|
+
<PendingAuthorization authorization={props.pendingAuthorization} />
|
|
284
|
+
) : null}
|
|
285
|
+
{/* Queued messages hide with the message box, so the question is
|
|
286
|
+
the last thing above the dock. */}
|
|
287
|
+
<div
|
|
288
|
+
hidden={Boolean(question)}
|
|
289
|
+
onFocus={keepComposerShown}
|
|
290
|
+
onPointerDown={keepComposerShown}
|
|
291
|
+
>
|
|
292
|
+
<PendingMailboxStack
|
|
293
|
+
authors={props.authors}
|
|
294
|
+
cancelError={cancelError}
|
|
295
|
+
cancelPending={cancelPendingMessages.isPending}
|
|
296
|
+
cancelTargetInboundMessageId={cancelTargetInboundMessageId}
|
|
297
|
+
committedMessageIds={props.committedMessageIds}
|
|
298
|
+
conversationId={props.conversationId}
|
|
299
|
+
editTargetInboundMessageId={editTargetInboundMessageId}
|
|
300
|
+
messages={props.pendingMessages}
|
|
301
|
+
onCancelMessage={onCancelMessage}
|
|
302
|
+
onEditMessage={onEditMessage}
|
|
303
|
+
onLayoutChange={onMailboxLayoutChange}
|
|
304
|
+
onRetry={onRetry}
|
|
305
|
+
onSteerMessage={props.stoppable ? onSteerMessage : undefined}
|
|
306
|
+
steerTargetInboundMessageId={steerTargetInboundMessageId}
|
|
307
|
+
viewerEmail={viewerEmail}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
{unsentAnswers.dialog}
|
|
311
|
+
</>
|
|
312
|
+
}
|
|
313
|
+
>
|
|
314
|
+
<div
|
|
315
|
+
hidden={Boolean(question)}
|
|
316
|
+
onBlur={(event) => {
|
|
317
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
318
|
+
setComposerFocused(false);
|
|
319
|
+
}
|
|
320
|
+
}}
|
|
321
|
+
onFocus={() => setComposerFocused(true)}
|
|
322
|
+
>
|
|
323
|
+
<ConversationComposer
|
|
324
|
+
confirmSubmit={unsentAnswers.confirmSubmit}
|
|
325
|
+
draftId={props.conversationId}
|
|
326
|
+
error={
|
|
327
|
+
queueActionError ??
|
|
328
|
+
(stopTurn.error ? "Could not stop Junior. Try again." : undefined)
|
|
329
|
+
}
|
|
330
|
+
ref={composerRef}
|
|
331
|
+
label="Continue this conversation"
|
|
332
|
+
submitLabel="Send"
|
|
333
|
+
onFocus={onComposerFocus}
|
|
334
|
+
onStop={props.stoppable ? onStop : undefined}
|
|
335
|
+
stopPending={stopTurn.isPending}
|
|
336
|
+
onSubmitStart={onSubmitStart}
|
|
337
|
+
onSubmit={onSubmit}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
</ComposerDock>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
TooltipContent,
|
|
37
37
|
TooltipTrigger,
|
|
38
38
|
} from "../components/ui/tooltip";
|
|
39
|
-
import {
|
|
39
|
+
import { Spinner } from "../components/ui/spinner";
|
|
40
40
|
import type { WaitingConversation } from "../review-desk/queries";
|
|
41
41
|
|
|
42
42
|
type ConversationSidebarEntry =
|
|
@@ -443,12 +443,10 @@ function ConversationRowState(props: {
|
|
|
443
443
|
}) {
|
|
444
444
|
if (props.status === "active") {
|
|
445
445
|
return (
|
|
446
|
-
<
|
|
447
|
-
|
|
448
|
-
className="
|
|
449
|
-
|
|
450
|
-
Running
|
|
451
|
-
</ShimmerText>
|
|
446
|
+
<Spinner
|
|
447
|
+
aria-label="Running"
|
|
448
|
+
className="size-3.5 text-dashboard-text-muted"
|
|
449
|
+
/>
|
|
452
450
|
);
|
|
453
451
|
}
|
|
454
452
|
if (props.status === "failed") {
|
|
@@ -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,8 +346,6 @@ 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[];
|
|
@@ -375,7 +365,6 @@ export function CostMetric(props: {
|
|
|
375
365
|
props.auxiliaryCosts,
|
|
376
366
|
props.liveModelId,
|
|
377
367
|
props.childAgentTotals,
|
|
378
|
-
Boolean(props.detailsHint),
|
|
379
368
|
);
|
|
380
369
|
return (
|
|
381
370
|
<MetricValue
|
|
@@ -398,8 +387,6 @@ export function TokenMetric(props: {
|
|
|
398
387
|
align?: "left" | "right";
|
|
399
388
|
childAgentTotals?: ConversationChildAgentTotals;
|
|
400
389
|
compactionCount?: number;
|
|
401
|
-
/** Point to conversation details, which has the full breakdown. */
|
|
402
|
-
detailsHint?: boolean;
|
|
403
390
|
live?: boolean;
|
|
404
391
|
liveModelId?: string;
|
|
405
392
|
modelUsage?: ConversationModelUsage[];
|
|
@@ -417,7 +404,6 @@ export function TokenMetric(props: {
|
|
|
417
404
|
props.compactionCount,
|
|
418
405
|
props.liveModelId,
|
|
419
406
|
props.childAgentTotals,
|
|
420
|
-
Boolean(props.detailsHint),
|
|
421
407
|
)}
|
|
422
408
|
/>
|
|
423
409
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
3
|
-
import {
|
|
3
|
+
import { ArrowDown } from "lucide-react";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
ConversationTranscript,
|
|
@@ -8,11 +8,6 @@ import type {
|
|
|
8
8
|
} from "../types";
|
|
9
9
|
import { EmptyTelemetry } from "../components/EmptyTelemetry";
|
|
10
10
|
import { Button } from "../components/ui/button";
|
|
11
|
-
import {
|
|
12
|
-
Tooltip,
|
|
13
|
-
TooltipContent,
|
|
14
|
-
TooltipTrigger,
|
|
15
|
-
} from "../components/ui/tooltip";
|
|
16
11
|
import { ConversationTranscriptView } from "./ConversationTranscript";
|
|
17
12
|
import { ConversationEventLog } from "./ConversationEventLog";
|
|
18
13
|
import {
|
|
@@ -191,33 +186,21 @@ function JumpToLatestButton(props: {
|
|
|
191
186
|
}) {
|
|
192
187
|
if (!props.visible) return null;
|
|
193
188
|
|
|
194
|
-
const label = props.hasPendingUpdate
|
|
195
|
-
? "Jump to latest update"
|
|
196
|
-
: "Jump to latest";
|
|
197
|
-
|
|
198
189
|
return (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
className="absolute right-1.5 top-1.5 size-2 bg-emerald-300"
|
|
214
|
-
/>
|
|
215
|
-
) : null}
|
|
216
|
-
</Button>
|
|
217
|
-
}
|
|
218
|
-
/>
|
|
219
|
-
<TooltipContent size="compact">{label}</TooltipContent>
|
|
220
|
-
</Tooltip>
|
|
190
|
+
// Sticky offsets count from inside the scroll region's bottom padding
|
|
191
|
+
// (pb-4 / md:pb-6). The negative offsets cancel most of it, so the pill
|
|
192
|
+
// sits 12px (16px on md) above the composer, centered on the transcript.
|
|
193
|
+
<div className="pointer-events-none sticky -bottom-2.5 z-20 flex h-0 justify-center md:-bottom-5">
|
|
194
|
+
<Button
|
|
195
|
+
aria-label={
|
|
196
|
+
props.hasPendingUpdate ? "Jump to latest update" : "Jump to latest"
|
|
197
|
+
}
|
|
198
|
+
className="pointer-events-auto h-8 -translate-y-full gap-1.5 rounded-full pl-3 pr-3.5 text-xs shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
|
|
199
|
+
onClick={props.onClick}
|
|
200
|
+
>
|
|
201
|
+
<ArrowDown aria-hidden="true" size={14} strokeWidth={2} />
|
|
202
|
+
Jump to latest
|
|
203
|
+
</Button>
|
|
221
204
|
</div>
|
|
222
205
|
);
|
|
223
206
|
}
|
|
@@ -27,8 +27,6 @@ export type UsageCardSplit = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export type UsageCardProps = {
|
|
30
|
-
/** Closing hint, such as where to find the full breakdown. */
|
|
31
|
-
footer?: string;
|
|
32
30
|
/** Muted detail after the title, such as a compaction count. */
|
|
33
31
|
note?: string;
|
|
34
32
|
sections: UsageCardSection[];
|
|
@@ -64,8 +62,7 @@ export function UsageCard(props: UsageCardProps) {
|
|
|
64
62
|
<span
|
|
65
63
|
className={cn(
|
|
66
64
|
"grid gap-2.5",
|
|
67
|
-
|
|
68
|
-
"border-b border-dashboard-border pb-3",
|
|
65
|
+
props.sections.length > 0 && "border-b border-dashboard-border pb-3",
|
|
69
66
|
)}
|
|
70
67
|
>
|
|
71
68
|
<span className="flex items-baseline justify-between gap-3">
|
|
@@ -156,17 +153,6 @@ export function UsageCard(props: UsageCardProps) {
|
|
|
156
153
|
) : null}
|
|
157
154
|
</span>
|
|
158
155
|
))}
|
|
159
|
-
{props.footer ? (
|
|
160
|
-
<span
|
|
161
|
-
className={cn(
|
|
162
|
-
"text-dashboard-text-faint",
|
|
163
|
-
props.sections.length > 0 &&
|
|
164
|
-
"border-t border-dashboard-border pt-3",
|
|
165
|
-
)}
|
|
166
|
-
>
|
|
167
|
-
{props.footer}
|
|
168
|
-
</span>
|
|
169
|
-
) : null}
|
|
170
156
|
</span>
|
|
171
157
|
);
|
|
172
158
|
}
|
|
@@ -7,13 +7,18 @@ export type ConversationSection = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
// Sorts before every time section.
|
|
11
|
+
const ATTENTION_SECTION = {
|
|
12
|
+
key: "attention",
|
|
13
|
+
label: "Needs attention",
|
|
14
|
+
order: Number.NEGATIVE_INFINITY,
|
|
15
|
+
};
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
|
-
* Group conversations by last activity: Today,
|
|
13
|
-
* Previous 30 days, then one section per month.
|
|
18
|
+
* Group conversations into Needs attention, then by last activity: Today,
|
|
19
|
+
* Yesterday, Previous 7 days, Previous 30 days, then one section per month.
|
|
14
20
|
*
|
|
15
|
-
* Conversations in `attentionIds`
|
|
16
|
-
* rows sit above read rows without leaving their time group.
|
|
21
|
+
* Conversations in `attentionIds` go to Needs attention, newest first.
|
|
17
22
|
*/
|
|
18
23
|
export function buildConversationSections(
|
|
19
24
|
conversations: Conversation[],
|
|
@@ -25,20 +30,19 @@ export function buildConversationSections(
|
|
|
25
30
|
): ConversationSection[] {
|
|
26
31
|
const nowDay = calendarDay(options.nowMs, options.timeZone);
|
|
27
32
|
const sections = new Map<string, ConversationSection & { order: number }>();
|
|
28
|
-
const
|
|
33
|
+
const sorted = [...conversations].sort(
|
|
29
34
|
(left, right) => activityTime(right) - activityTime(left),
|
|
30
35
|
);
|
|
31
|
-
const sorted = options.attentionIds
|
|
32
|
-
? attentionFirst(byActivity, options.attentionIds)
|
|
33
|
-
: byActivity;
|
|
34
36
|
|
|
35
37
|
for (const conversation of sorted) {
|
|
36
|
-
const section =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const section = options.attentionIds?.has(conversation.id)
|
|
39
|
+
? ATTENTION_SECTION
|
|
40
|
+
: conversationSection(
|
|
41
|
+
activityTime(conversation),
|
|
42
|
+
nowDay,
|
|
43
|
+
options.nowMs,
|
|
44
|
+
options.timeZone,
|
|
45
|
+
);
|
|
42
46
|
const existing = sections.get(section.key);
|
|
43
47
|
if (existing) {
|
|
44
48
|
existing.conversations.push(conversation);
|