@sentry/junior-dashboard 0.231.0 → 0.233.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 +76 -33
- package/dist/client.js +34 -33
- package/dist/index.js +76 -33
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/conversations/ConversationDetailsDrawer.tsx +23 -17
- package/src/client/conversations/ConversationEventLog.tsx +49 -9
- package/src/client/conversations/ConversationHeader.tsx +2 -0
- package/src/client/conversations/ConversationHeaderActions.tsx +7 -0
- package/src/client/conversations/EventDetails.tsx +94 -11
- package/src/client/conversations/EventUsage.tsx +118 -0
- package/src/client/conversations/ShareConversationButton.tsx +101 -0
- package/src/client/conversations/eventLog.ts +59 -6
- package/src/client/conversations/eventTranscript.ts +2 -0
- package/src/client/conversations/transcriptBottomPinning.ts +2 -2
- package/src/mock-reporting/fixtures.ts +73 -29
|
@@ -6,6 +6,7 @@ import { ButtonLink } from "../components/Button";
|
|
|
6
6
|
import { Drawer } from "../components/Drawer";
|
|
7
7
|
import { SegmentedTabs } from "../components/SegmentedTabs";
|
|
8
8
|
import { ConversationMemories } from "./ConversationMemories";
|
|
9
|
+
import { ShareConversationButton } from "./ShareConversationButton";
|
|
9
10
|
|
|
10
11
|
const tabs = [
|
|
11
12
|
{ label: "Details", value: "details" },
|
|
@@ -85,9 +86,8 @@ export function ConversationDetailsDrawer(props: {
|
|
|
85
86
|
No additional conversation details.
|
|
86
87
|
</p>
|
|
87
88
|
)}
|
|
88
|
-
{tab === "details"
|
|
89
|
-
|
|
90
|
-
<footer className="mt-5 grid gap-3 border-t border-dashboard-border pt-4 text-xs text-dashboard-text-muted">
|
|
89
|
+
{tab === "details" ? (
|
|
90
|
+
<footer className="mt-5 grid gap-6 border-t border-dashboard-border pt-4 text-xs text-dashboard-text-muted">
|
|
91
91
|
{props.lastActivityAt ? (
|
|
92
92
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
93
93
|
<span>Last activity</span>
|
|
@@ -102,20 +102,26 @@ export function ConversationDetailsDrawer(props: {
|
|
|
102
102
|
</time>
|
|
103
103
|
</div>
|
|
104
104
|
) : null}
|
|
105
|
-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
105
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
106
|
+
<ShareConversationButton
|
|
107
|
+
key={props.conversationId}
|
|
108
|
+
conversationId={props.conversationId}
|
|
109
|
+
/>
|
|
110
|
+
{props.sentryConversationUrl ? (
|
|
111
|
+
<ButtonLink
|
|
112
|
+
className="w-fit focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
113
|
+
to={props.sentryConversationUrl}
|
|
114
|
+
rel="noopener noreferrer"
|
|
115
|
+
target="_blank"
|
|
116
|
+
>
|
|
117
|
+
Open in Sentry
|
|
118
|
+
<ExternalLink
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
className="size-3.5 shrink-0"
|
|
121
|
+
/>
|
|
122
|
+
</ButtonLink>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
119
125
|
</footer>
|
|
120
126
|
) : null}
|
|
121
127
|
</SegmentedTabs>
|
|
@@ -5,7 +5,12 @@ import { createPortal } from "react-dom";
|
|
|
5
5
|
import { Drawer } from "../components/Drawer";
|
|
6
6
|
import { cn } from "../styles";
|
|
7
7
|
import type { ConversationTranscript } from "../types";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
eventLogModel,
|
|
10
|
+
eventLogSummary,
|
|
11
|
+
eventLogTone,
|
|
12
|
+
eventLogUsage,
|
|
13
|
+
} from "./eventLog";
|
|
9
14
|
import { EventDetails } from "./EventDetails";
|
|
10
15
|
import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
|
|
11
16
|
|
|
@@ -25,6 +30,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
25
30
|
() =>
|
|
26
31
|
events.map((event) => ({
|
|
27
32
|
event,
|
|
33
|
+
model: eventLogModel(event),
|
|
34
|
+
usage: eventLogUsage(event),
|
|
28
35
|
summary: eventLogSummary(event.data).replace(/\s+/g, " ").slice(0, 300),
|
|
29
36
|
})),
|
|
30
37
|
[events],
|
|
@@ -33,8 +40,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
33
40
|
const visibleRows = useMemo(
|
|
34
41
|
() =>
|
|
35
42
|
search.active
|
|
36
|
-
? rows.filter(({ event, summary }) =>
|
|
37
|
-
`${summary} ${JSON.stringify(event)}`
|
|
43
|
+
? rows.filter(({ event, summary, usage }) =>
|
|
44
|
+
`${summary} ${usage} ${JSON.stringify(event)}`
|
|
38
45
|
.toLowerCase()
|
|
39
46
|
.includes(search.normalizedQuery),
|
|
40
47
|
)
|
|
@@ -79,7 +86,7 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
79
86
|
aria-label="Events"
|
|
80
87
|
className="m-0 list-none divide-y divide-dashboard-border-subtle p-0"
|
|
81
88
|
>
|
|
82
|
-
{visibleRows.map(({ event, summary }) => (
|
|
89
|
+
{visibleRows.map(({ event, summary, model, usage }) => (
|
|
83
90
|
<li key={event.seq}>
|
|
84
91
|
<button
|
|
85
92
|
aria-haspopup="dialog"
|
|
@@ -105,11 +112,44 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
105
112
|
>
|
|
106
113
|
<HighlightText text={event.data.type} />
|
|
107
114
|
</span>
|
|
108
|
-
<span
|
|
109
|
-
className="
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
<span className="col-span-2 row-start-2 grid min-w-0 gap-1 text-dashboard-text @min-[48rem]:col-span-1 @min-[48rem]:row-auto">
|
|
116
|
+
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
|
117
|
+
<span
|
|
118
|
+
className={cn(
|
|
119
|
+
"break-all",
|
|
120
|
+
model
|
|
121
|
+
? "font-semibold text-violet-300"
|
|
122
|
+
: "text-dashboard-text-muted",
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
<HighlightText
|
|
126
|
+
text={model?.modelId ?? "Model not recorded"}
|
|
127
|
+
/>
|
|
128
|
+
</span>
|
|
129
|
+
{model?.modelProfile ? (
|
|
130
|
+
<span className="text-dashboard-text-muted">
|
|
131
|
+
<HighlightText text={model.modelProfile} />
|
|
132
|
+
</span>
|
|
133
|
+
) : null}
|
|
134
|
+
{model?.reasoningLevel ? (
|
|
135
|
+
<span className="text-dashboard-text-muted">
|
|
136
|
+
<HighlightText
|
|
137
|
+
text={`${model.reasoningLevel} reasoning`}
|
|
138
|
+
/>
|
|
139
|
+
</span>
|
|
140
|
+
) : null}
|
|
141
|
+
</span>
|
|
142
|
+
<span className="truncate" title={summary}>
|
|
143
|
+
<HighlightText text={summary} />
|
|
144
|
+
</span>
|
|
145
|
+
{usage ? (
|
|
146
|
+
<span
|
|
147
|
+
className="text-dashboard-text-muted"
|
|
148
|
+
title="Usage for this event only; costs are estimated USD"
|
|
149
|
+
>
|
|
150
|
+
<HighlightText text={usage} />
|
|
151
|
+
</span>
|
|
152
|
+
) : null}
|
|
113
153
|
</span>
|
|
114
154
|
<ChevronRight
|
|
115
155
|
aria-hidden="true"
|
|
@@ -111,6 +111,7 @@ export function ConversationHeader(props: {
|
|
|
111
111
|
<div className="absolute right-0 top-[calc(100%+0.35rem)] z-40 w-56 rounded-xl border border-white/[0.08] bg-dashboard-surface-raised/95 p-1.5 shadow-2xl shadow-black/75 backdrop-blur-xl">
|
|
112
112
|
<ConversationHeaderActions
|
|
113
113
|
archive={props.archive}
|
|
114
|
+
conversationId={props.conversationId}
|
|
114
115
|
copyAction={menuCopyAction}
|
|
115
116
|
detailsOpen={detailsOpen}
|
|
116
117
|
layout="menu"
|
|
@@ -185,6 +186,7 @@ export function ConversationHeader(props: {
|
|
|
185
186
|
</div>
|
|
186
187
|
<ConversationHeaderActions
|
|
187
188
|
archive={props.archive}
|
|
189
|
+
conversationId={props.conversationId}
|
|
188
190
|
copyAction={props.copyAction}
|
|
189
191
|
detailsOpen={detailsOpen}
|
|
190
192
|
onDetailsClick={() => setDetailsOpen(true)}
|
|
@@ -14,6 +14,7 @@ import { Button, ToggleButton } from "../components/Button";
|
|
|
14
14
|
import { IconButtonTooltip } from "../components/Tooltip";
|
|
15
15
|
import { cn } from "../styles";
|
|
16
16
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
17
|
+
import { ShareConversationButton } from "./ShareConversationButton";
|
|
17
18
|
|
|
18
19
|
export type ConversationArchiveAction = {
|
|
19
20
|
archived: boolean;
|
|
@@ -26,6 +27,7 @@ export type ConversationArchiveAction = {
|
|
|
26
27
|
/** Show conversation actions in the header or mobile menu. */
|
|
27
28
|
export function ConversationHeaderActions(props: {
|
|
28
29
|
archive: ConversationArchiveAction;
|
|
30
|
+
conversationId: string;
|
|
29
31
|
copyAction?: ReactNode;
|
|
30
32
|
detailsOpen: boolean;
|
|
31
33
|
/** `bar` is the desktop icon row. `menu` stacks actions for mobile overflow. */
|
|
@@ -65,6 +67,11 @@ export function ConversationHeaderActions(props: {
|
|
|
65
67
|
>
|
|
66
68
|
<ScrollText aria-hidden="true" size={16} strokeWidth={2} />
|
|
67
69
|
</MenuActionButton>
|
|
70
|
+
<ShareConversationButton
|
|
71
|
+
key={props.conversationId}
|
|
72
|
+
conversationId={props.conversationId}
|
|
73
|
+
layout="menu"
|
|
74
|
+
/>
|
|
68
75
|
{props.copyAction}
|
|
69
76
|
<ArchiveConversationButton {...props.archive} layout="menu" />
|
|
70
77
|
<MenuActionButton
|
|
@@ -3,13 +3,64 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { RedactedMarker } from "./TranscriptRedacted";
|
|
5
5
|
import { TranscriptText } from "./TranscriptText";
|
|
6
|
+
import { eventLogModel } from "./eventLog";
|
|
7
|
+
import { formatCostBreakdown } from "../format";
|
|
8
|
+
import { EventUsage } from "./EventUsage";
|
|
6
9
|
|
|
7
10
|
/** Present event content as readable sections, with the exact report available on demand. */
|
|
8
11
|
export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
9
12
|
const data = event.data;
|
|
13
|
+
const model = eventLogModel(event);
|
|
14
|
+
const { usage, ...callAttributes } = event.modelCall ?? {};
|
|
10
15
|
let content: ReactNode;
|
|
11
16
|
|
|
12
17
|
switch (data.type) {
|
|
18
|
+
case "turn_routed": {
|
|
19
|
+
const {
|
|
20
|
+
type: _,
|
|
21
|
+
modelId: _modelId,
|
|
22
|
+
modelProfile: _profile,
|
|
23
|
+
reasoningLevel: _reasoning,
|
|
24
|
+
costUsd,
|
|
25
|
+
...metadata
|
|
26
|
+
} = data;
|
|
27
|
+
content = (
|
|
28
|
+
<DetailSection title="Route selection">
|
|
29
|
+
<DetailValue
|
|
30
|
+
value={{
|
|
31
|
+
...metadata,
|
|
32
|
+
...(costUsd !== undefined
|
|
33
|
+
? { routerCostUsd: formatCostBreakdown({ total: costUsd }) }
|
|
34
|
+
: undefined),
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
</DetailSection>
|
|
38
|
+
);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "handoff": {
|
|
42
|
+
const {
|
|
43
|
+
type: _,
|
|
44
|
+
modelId: _modelId,
|
|
45
|
+
modelProfile: _profile,
|
|
46
|
+
reasoningLevel: _reasoning,
|
|
47
|
+
summary,
|
|
48
|
+
...metadata
|
|
49
|
+
} = data;
|
|
50
|
+
content = (
|
|
51
|
+
<>
|
|
52
|
+
{Object.keys(metadata).length > 0 ? (
|
|
53
|
+
<DetailValue value={metadata} />
|
|
54
|
+
) : null}
|
|
55
|
+
{summary ? (
|
|
56
|
+
<DetailSection title="Continuation summary">
|
|
57
|
+
<TranscriptText text={summary} />
|
|
58
|
+
</DetailSection>
|
|
59
|
+
) : null}
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
13
64
|
case "message": {
|
|
14
65
|
const { type: _, text, redacted, ...metadata } = data;
|
|
15
66
|
content = (
|
|
@@ -98,7 +149,30 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
|
98
149
|
}
|
|
99
150
|
|
|
100
151
|
return (
|
|
101
|
-
<div className="grid min-w-0 gap-6">
|
|
152
|
+
<div className="@container grid min-w-0 gap-6">
|
|
153
|
+
<DetailSection
|
|
154
|
+
title={
|
|
155
|
+
data.type === "handoff" ? "Target configuration" : "Configuration"
|
|
156
|
+
}
|
|
157
|
+
>
|
|
158
|
+
<DetailValue
|
|
159
|
+
value={{
|
|
160
|
+
model: model?.modelId ?? "Not recorded",
|
|
161
|
+
...(model
|
|
162
|
+
? {
|
|
163
|
+
modelProfile: model.modelProfile ?? "Not recorded",
|
|
164
|
+
reasoningLevel: model.reasoningLevel ?? "Not recorded",
|
|
165
|
+
}
|
|
166
|
+
: undefined),
|
|
167
|
+
...callAttributes,
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
</DetailSection>
|
|
171
|
+
{event.modelCall ? (
|
|
172
|
+
<DetailSection title="Call usage">
|
|
173
|
+
<EventUsage usage={usage} />
|
|
174
|
+
</DetailSection>
|
|
175
|
+
) : null}
|
|
102
176
|
{content}
|
|
103
177
|
<details className="border-t border-dashboard-border pt-4">
|
|
104
178
|
<summary className="cursor-pointer text-xs text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-dashboard-focus">
|
|
@@ -114,8 +188,8 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
|
114
188
|
|
|
115
189
|
function DetailSection(props: { title: string; children: ReactNode }) {
|
|
116
190
|
return (
|
|
117
|
-
<section aria-label={props.title} className="grid min-w-0 gap-
|
|
118
|
-
<h3 className="m-0
|
|
191
|
+
<section aria-label={props.title} className="grid min-w-0 gap-2">
|
|
192
|
+
<h3 className="m-0 border-b border-dashboard-border pb-2 text-sm font-semibold text-dashboard-text">
|
|
119
193
|
{props.title}
|
|
120
194
|
</h3>
|
|
121
195
|
{props.children}
|
|
@@ -131,7 +205,8 @@ function fieldLabel(key: string): string {
|
|
|
131
205
|
return (words.charAt(0).toUpperCase() + words.slice(1))
|
|
132
206
|
.replace(/\bid\b/gi, "ID")
|
|
133
207
|
.replace(/\bids\b/gi, "IDs")
|
|
134
|
-
.replace(/\busd\b/gi, "USD")
|
|
208
|
+
.replace(/\busd\b/gi, "USD")
|
|
209
|
+
.replace(/\bapi\b/gi, "API");
|
|
135
210
|
}
|
|
136
211
|
|
|
137
212
|
// Keep arbitrary tool and context payloads readable without guessing their schema.
|
|
@@ -150,9 +225,11 @@ function DetailValue({ value }: { value: unknown }) {
|
|
|
150
225
|
);
|
|
151
226
|
}
|
|
152
227
|
if (typeof value === "object" && value !== null) {
|
|
153
|
-
const fields = Object.entries(value)
|
|
228
|
+
const fields = Object.entries(value).filter(
|
|
229
|
+
([, entry]) => entry !== undefined,
|
|
230
|
+
);
|
|
154
231
|
return fields.length ? (
|
|
155
|
-
<dl className="m-0 grid min-w-0
|
|
232
|
+
<dl className="m-0 grid min-w-0 divide-y divide-dashboard-border-subtle">
|
|
156
233
|
{fields.map(([key, entry]) => {
|
|
157
234
|
const nested = typeof entry === "object" && entry !== null;
|
|
158
235
|
return (
|
|
@@ -160,15 +237,21 @@ function DetailValue({ value }: { value: unknown }) {
|
|
|
160
237
|
key={key}
|
|
161
238
|
className={
|
|
162
239
|
nested
|
|
163
|
-
? "grid min-w-0 gap-2"
|
|
164
|
-
: "grid min-w-0 grid-cols-[minmax(0,
|
|
240
|
+
? "grid min-w-0 gap-2 py-2"
|
|
241
|
+
: "grid min-w-0 grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-baseline gap-x-4 py-2 @min-[30rem]:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]"
|
|
165
242
|
}
|
|
166
243
|
>
|
|
167
|
-
<dt className="break-words text-
|
|
244
|
+
<dt className="break-words text-xs leading-relaxed text-dashboard-text-muted">
|
|
168
245
|
{fieldLabel(key)}
|
|
169
246
|
</dt>
|
|
170
247
|
<dd className="m-0 min-w-0">
|
|
171
|
-
|
|
248
|
+
{nested ? (
|
|
249
|
+
<div className="border-l border-dashboard-border pl-3">
|
|
250
|
+
<DetailValue value={entry} />
|
|
251
|
+
</div>
|
|
252
|
+
) : (
|
|
253
|
+
<DetailValue value={entry} />
|
|
254
|
+
)}
|
|
172
255
|
</dd>
|
|
173
256
|
</div>
|
|
174
257
|
);
|
|
@@ -179,7 +262,7 @@ function DetailValue({ value }: { value: unknown }) {
|
|
|
179
262
|
);
|
|
180
263
|
}
|
|
181
264
|
return (
|
|
182
|
-
<span className="block whitespace-pre-wrap break-words font-mono text-
|
|
265
|
+
<span className="block whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-dashboard-text [overflow-wrap:anywhere]">
|
|
183
266
|
{value === null ? "null" : value === "" ? "Empty text" : String(value)}
|
|
184
267
|
</span>
|
|
185
268
|
);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ConversationUsage } from "@sentry/junior/api/schema";
|
|
2
|
+
|
|
3
|
+
import { formatCostBreakdown, summarizeCost } from "../format";
|
|
4
|
+
import { cn } from "../styles";
|
|
5
|
+
|
|
6
|
+
/** Align recorded token counts with their costs without adding overlapping counters. */
|
|
7
|
+
export function EventUsage({ usage }: { usage?: ConversationUsage }) {
|
|
8
|
+
const cost = summarizeCost(usage);
|
|
9
|
+
const rows = [
|
|
10
|
+
{ label: "Input", tokens: usage?.inputTokens, cost: usage?.cost?.input },
|
|
11
|
+
{ label: "Output", tokens: usage?.outputTokens, cost: usage?.cost?.output },
|
|
12
|
+
{
|
|
13
|
+
label: "Cache read",
|
|
14
|
+
tokens: usage?.cachedInputTokens,
|
|
15
|
+
cost: usage?.cost?.cacheRead,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: "Cache write",
|
|
19
|
+
tokens: usage?.cacheCreationTokens,
|
|
20
|
+
cost: usage?.cost?.cacheWrite,
|
|
21
|
+
},
|
|
22
|
+
{ label: "Reasoning", tokens: usage?.reasoningTokens },
|
|
23
|
+
{ label: "Total", tokens: usage?.totalTokens, cost: cost?.total },
|
|
24
|
+
].filter((row) => row.tokens !== undefined || row.cost !== undefined);
|
|
25
|
+
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
return (
|
|
28
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
29
|
+
Token usage and cost were not recorded for this call.
|
|
30
|
+
</p>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<div className="min-w-0 overflow-x-auto">
|
|
37
|
+
<table
|
|
38
|
+
aria-label="Model call token and cost breakdown"
|
|
39
|
+
className="w-full min-w-72 table-fixed border-collapse text-xs leading-relaxed"
|
|
40
|
+
>
|
|
41
|
+
<colgroup>
|
|
42
|
+
<col />
|
|
43
|
+
<col className="w-24" />
|
|
44
|
+
<col className="w-24" />
|
|
45
|
+
</colgroup>
|
|
46
|
+
<thead>
|
|
47
|
+
<tr className="border-b border-dashboard-border text-dashboard-text-muted">
|
|
48
|
+
<th scope="col" className="py-2 text-left font-normal">
|
|
49
|
+
Usage
|
|
50
|
+
</th>
|
|
51
|
+
<th scope="col" className="py-2 text-right font-normal">
|
|
52
|
+
Tokens
|
|
53
|
+
</th>
|
|
54
|
+
<th scope="col" className="py-2 text-right font-normal">
|
|
55
|
+
Cost (USD)
|
|
56
|
+
</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{rows.map((row) => (
|
|
61
|
+
<tr
|
|
62
|
+
key={row.label}
|
|
63
|
+
className={cn(
|
|
64
|
+
"border-b border-dashboard-border-subtle last:border-b-0",
|
|
65
|
+
row.label === "Total" &&
|
|
66
|
+
"border-t border-dashboard-border font-semibold",
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<th
|
|
70
|
+
scope="row"
|
|
71
|
+
className={cn(
|
|
72
|
+
"py-2 text-left font-normal",
|
|
73
|
+
row.label === "Total"
|
|
74
|
+
? "font-semibold text-dashboard-text"
|
|
75
|
+
: "text-dashboard-text-muted",
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{row.label}
|
|
79
|
+
</th>
|
|
80
|
+
<td className="py-2 text-right font-mono tabular-nums text-dashboard-text">
|
|
81
|
+
{row.tokens === undefined ? (
|
|
82
|
+
<span
|
|
83
|
+
aria-label="Not recorded"
|
|
84
|
+
className="text-dashboard-text-faint"
|
|
85
|
+
>
|
|
86
|
+
—
|
|
87
|
+
</span>
|
|
88
|
+
) : (
|
|
89
|
+
row.tokens.toLocaleString("en-US")
|
|
90
|
+
)}
|
|
91
|
+
</td>
|
|
92
|
+
<td className="py-2 text-right font-mono tabular-nums text-dashboard-text">
|
|
93
|
+
{row.cost === undefined ? (
|
|
94
|
+
<span
|
|
95
|
+
aria-label="Not recorded separately"
|
|
96
|
+
className="text-dashboard-text-faint"
|
|
97
|
+
>
|
|
98
|
+
—
|
|
99
|
+
</span>
|
|
100
|
+
) : (
|
|
101
|
+
<span title={`${row.cost} USD`}>
|
|
102
|
+
{formatCostBreakdown({ total: row.cost })}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
</td>
|
|
106
|
+
</tr>
|
|
107
|
+
))}
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</div>
|
|
111
|
+
<p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
|
|
112
|
+
This call only · Estimated costs
|
|
113
|
+
<br />
|
|
114
|
+
Input excludes cache reads and writes. Reasoning is included in output.
|
|
115
|
+
</p>
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useHref } from "react-router";
|
|
3
|
+
import { Check, Share2 } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "../components/Button";
|
|
6
|
+
import { conversationPath } from "./conversationRoutes";
|
|
7
|
+
|
|
8
|
+
/** Share a conversation link without changing who can access it. */
|
|
9
|
+
export function ShareConversationButton(props: {
|
|
10
|
+
conversationId: string;
|
|
11
|
+
layout?: "button" | "menu";
|
|
12
|
+
}) {
|
|
13
|
+
const href = useHref(conversationPath(props.conversationId));
|
|
14
|
+
const [status, setStatus] = useState<
|
|
15
|
+
"idle" | "sharing" | "copied" | "copy-failed" | "share-failed"
|
|
16
|
+
>("idle");
|
|
17
|
+
const label =
|
|
18
|
+
status === "copied"
|
|
19
|
+
? "Link copied"
|
|
20
|
+
: status === "copy-failed"
|
|
21
|
+
? "Copy failed — retry"
|
|
22
|
+
: status === "share-failed"
|
|
23
|
+
? "Share failed — retry"
|
|
24
|
+
: "Share";
|
|
25
|
+
const Icon = status === "copied" ? Check : Share2;
|
|
26
|
+
const feedback =
|
|
27
|
+
status === "copied"
|
|
28
|
+
? "Conversation link copied. Access has not changed."
|
|
29
|
+
: status === "copy-failed"
|
|
30
|
+
? "Could not copy the link. Try again or copy it from the address bar."
|
|
31
|
+
: status === "share-failed"
|
|
32
|
+
? "Could not share the link. Try again or copy it from the address bar."
|
|
33
|
+
: "";
|
|
34
|
+
|
|
35
|
+
async function shareLink() {
|
|
36
|
+
setStatus("sharing");
|
|
37
|
+
const url = new URL(href, window.location.origin).href;
|
|
38
|
+
// Touch-first devices use the native sheet, including tablets. Desktop
|
|
39
|
+
// browsers keep copy-link behavior even when they support native sharing.
|
|
40
|
+
if (
|
|
41
|
+
window.matchMedia("(pointer: coarse)").matches &&
|
|
42
|
+
typeof navigator.share === "function"
|
|
43
|
+
) {
|
|
44
|
+
try {
|
|
45
|
+
await navigator.share({ url });
|
|
46
|
+
setStatus("idle");
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Dismissing the sheet must not copy a link or show an error.
|
|
49
|
+
setStatus(
|
|
50
|
+
error instanceof DOMException && error.name === "AbortError"
|
|
51
|
+
? "idle"
|
|
52
|
+
: "share-failed",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await navigator.clipboard.writeText(url);
|
|
60
|
+
setStatus("copied");
|
|
61
|
+
} catch {
|
|
62
|
+
setStatus("copy-failed");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const content = (
|
|
67
|
+
<>
|
|
68
|
+
<Icon aria-hidden="true" size={16} strokeWidth={2} />
|
|
69
|
+
<span>{label}</span>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
const button =
|
|
73
|
+
props.layout === "menu" ? (
|
|
74
|
+
<button
|
|
75
|
+
aria-label={label}
|
|
76
|
+
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
77
|
+
disabled={status === "sharing"}
|
|
78
|
+
onClick={() => void shareLink()}
|
|
79
|
+
type="button"
|
|
80
|
+
>
|
|
81
|
+
{content}
|
|
82
|
+
</button>
|
|
83
|
+
) : (
|
|
84
|
+
<Button
|
|
85
|
+
aria-label={label}
|
|
86
|
+
disabled={status === "sharing"}
|
|
87
|
+
onClick={() => void shareLink()}
|
|
88
|
+
>
|
|
89
|
+
{content}
|
|
90
|
+
</Button>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
{button}
|
|
96
|
+
<span className="sr-only" role="status">
|
|
97
|
+
{feedback}
|
|
98
|
+
</span>
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -1,6 +1,57 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ConversationReportEvent,
|
|
3
|
+
ConversationReportEventData,
|
|
4
|
+
} from "@sentry/junior/api/schema";
|
|
2
5
|
|
|
3
6
|
import { toolCallPreview } from "./toolCallPreview";
|
|
7
|
+
import {
|
|
8
|
+
formatCompactNumber,
|
|
9
|
+
formatCostBreakdown,
|
|
10
|
+
summarizeCost,
|
|
11
|
+
} from "../format";
|
|
12
|
+
|
|
13
|
+
/** Read recorded event configuration, including reports from before model context was added. */
|
|
14
|
+
export function eventLogModel(
|
|
15
|
+
event: ConversationReportEvent,
|
|
16
|
+
): ConversationReportEvent["model"] {
|
|
17
|
+
if (event.model) return event.model;
|
|
18
|
+
const data = event.data;
|
|
19
|
+
if (data.type === "turn_routed" || data.type === "handoff") {
|
|
20
|
+
return {
|
|
21
|
+
modelId: data.modelId,
|
|
22
|
+
modelProfile: data.modelProfile,
|
|
23
|
+
reasoningLevel: data.reasoningLevel,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep call costs distinct from router cost and never repeat usage on tool results. */
|
|
30
|
+
export function eventLogUsage(event: ConversationReportEvent): string {
|
|
31
|
+
const usage = event.modelCall?.usage;
|
|
32
|
+
const cost = summarizeCost(usage);
|
|
33
|
+
const fields = [
|
|
34
|
+
cost ? `${formatCostBreakdown(cost)} call` : undefined,
|
|
35
|
+
event.data.type === "turn_routed" && event.data.costUsd !== undefined
|
|
36
|
+
? `${formatCostBreakdown({ total: event.data.costUsd })} router`
|
|
37
|
+
: undefined,
|
|
38
|
+
...(
|
|
39
|
+
[
|
|
40
|
+
["inputTokens", "in"],
|
|
41
|
+
["outputTokens", "out"],
|
|
42
|
+
["cachedInputTokens", "cache read"],
|
|
43
|
+
["cacheCreationTokens", "cache write"],
|
|
44
|
+
["reasoningTokens", "reasoning"],
|
|
45
|
+
["totalTokens", "total"],
|
|
46
|
+
] as const
|
|
47
|
+
).map(([key, label]) =>
|
|
48
|
+
usage?.[key] !== undefined
|
|
49
|
+
? `${formatCompactNumber(usage[key])} ${label}`
|
|
50
|
+
: undefined,
|
|
51
|
+
),
|
|
52
|
+
];
|
|
53
|
+
return fields.filter((field) => field !== undefined).join(" · ");
|
|
54
|
+
}
|
|
4
55
|
|
|
5
56
|
/** Summarize an event without merging it with earlier or later events. */
|
|
6
57
|
export function eventLogSummary(data: ConversationReportEventData): string {
|
|
@@ -10,9 +61,11 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
10
61
|
case "message_handled":
|
|
11
62
|
return data.messageId;
|
|
12
63
|
case "assistant_message":
|
|
13
|
-
return
|
|
14
|
-
.
|
|
15
|
-
|
|
64
|
+
return (
|
|
65
|
+
data.parts
|
|
66
|
+
.map((part) => (part.redacted ? "Content hidden" : part.text))
|
|
67
|
+
.join(" · ") || "Model call recorded"
|
|
68
|
+
);
|
|
16
69
|
case "tool_calls":
|
|
17
70
|
return data.calls
|
|
18
71
|
.map((call) => {
|
|
@@ -25,7 +78,7 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
25
78
|
? `${data.state} · ${data.failureCode}${data.failureReason ? ` · ${data.failureReason}` : ""}`
|
|
26
79
|
: `${data.state} · ${data.turnId}`;
|
|
27
80
|
case "turn_routed":
|
|
28
|
-
return
|
|
81
|
+
return `Route selected · ${data.source}${data.confidence !== undefined ? ` · ${Math.round(data.confidence * 100)}% confidence` : ""}`;
|
|
29
82
|
case "turn_context":
|
|
30
83
|
return `${data.pluginName} · ${data.kind} · v${data.version}`;
|
|
31
84
|
case "guardian_action_reviewed":
|
|
@@ -39,7 +92,7 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
39
92
|
case "compaction":
|
|
40
93
|
return data.summary ?? "Agent history compacted";
|
|
41
94
|
case "handoff":
|
|
42
|
-
return
|
|
95
|
+
return "Handoff · New configuration applied";
|
|
43
96
|
case "subagent":
|
|
44
97
|
return `${data.subagentKind} · ${data.status} · ${data.childConversationId}`;
|
|
45
98
|
}
|