@sentry/junior-dashboard 0.250.0 → 0.251.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 +70 -2
- package/dist/client.js +37 -37
- package/dist/index.js +70 -2
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/conversations/ConversationMeta.tsx +4 -31
- package/src/client/conversations/ConversationTranscript.tsx +18 -2
- package/src/client/conversations/ConversationUsageBreakdown.tsx +75 -34
- package/src/client/conversations/README.md +7 -0
- package/src/client/conversations/TranscriptAutomationTriggerView.tsx +117 -0
- package/src/mock-reporting/fixtures.ts +72 -0
|
@@ -24,33 +24,58 @@ import {
|
|
|
24
24
|
tokenTypeRows,
|
|
25
25
|
} from "./TelemetryMetrics";
|
|
26
26
|
|
|
27
|
+
/** One token type of a model, shown as an indented row under the model. */
|
|
28
|
+
type BreakdownPart = {
|
|
29
|
+
cost?: string;
|
|
30
|
+
count: string;
|
|
31
|
+
label: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
27
34
|
type BreakdownRow = {
|
|
28
35
|
cost: string;
|
|
29
36
|
count: string;
|
|
30
|
-
/** Muted second line, such as the token types of one model. */
|
|
31
|
-
detail?: string;
|
|
32
37
|
key: string;
|
|
33
38
|
label: ReactNode;
|
|
39
|
+
parts?: BreakdownPart[];
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
const NOT_RECORDED = "—";
|
|
37
43
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
44
|
+
/**
|
|
45
|
+
* List each recorded token type of one model with its own count and, when
|
|
46
|
+
* the provider reports it, its own cost. Reasoning tokens are part of the
|
|
47
|
+
* output tokens, so they have no cost of their own.
|
|
48
|
+
*/
|
|
49
|
+
function tokenTypeParts(
|
|
40
50
|
usage: ConversationModelUsage["usage"],
|
|
41
|
-
|
|
51
|
+
formatCost: (value: number) => string,
|
|
52
|
+
): BreakdownPart[] | undefined {
|
|
42
53
|
const summary = summarizeUsage(usage);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
:
|
|
48
|
-
|
|
54
|
+
if (!summary) return undefined;
|
|
55
|
+
const cost = summarizeCost(usage);
|
|
56
|
+
const costs: Readonly<Record<string, number | undefined>> = {
|
|
57
|
+
cached: cost?.cacheRead,
|
|
58
|
+
"cache write": cost?.cacheWrite,
|
|
59
|
+
input: cost?.input,
|
|
60
|
+
output: cost?.output,
|
|
61
|
+
};
|
|
62
|
+
const parts = tokenTypeRows(summary).map((row) => {
|
|
63
|
+
const value = costs[row.label];
|
|
64
|
+
return {
|
|
65
|
+
cost: value === undefined ? undefined : formatCost(value),
|
|
66
|
+
count: row.value,
|
|
67
|
+
label: row.label === "reasoning" ? "reasoning (in output)" : row.label,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
return parts.length ? parts : undefined;
|
|
49
71
|
}
|
|
50
72
|
|
|
73
|
+
const CELL = "text-right align-top font-sans tabular-nums";
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* One usage table. Every table in the breakdown shares column widths, so the
|
|
53
|
-
* cost column lines up from table to table.
|
|
77
|
+
* cost column lines up from table to table. Each row is its own `tbody`, so
|
|
78
|
+
* the token types of a model stay grouped under it.
|
|
54
79
|
*/
|
|
55
80
|
function BreakdownTable(props: {
|
|
56
81
|
/** Header of the count column, such as Tokens or Calls. */
|
|
@@ -83,31 +108,47 @@ function BreakdownTable(props: {
|
|
|
83
108
|
</th>
|
|
84
109
|
</tr>
|
|
85
110
|
</thead>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
>
|
|
92
|
-
<th
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
{row.
|
|
97
|
-
<span className="mt-0.5 block text-dashboard-text-faint">
|
|
98
|
-
{row.detail}
|
|
99
|
-
</span>
|
|
100
|
-
) : null}
|
|
111
|
+
{props.rows.map((row) => (
|
|
112
|
+
<tbody
|
|
113
|
+
className="border-b border-dashboard-border-subtle last-of-type:border-b-0"
|
|
114
|
+
key={row.key}
|
|
115
|
+
>
|
|
116
|
+
<tr className="text-dashboard-text">
|
|
117
|
+
<th
|
|
118
|
+
scope="row"
|
|
119
|
+
className={`pr-3 text-left font-normal ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}
|
|
120
|
+
>
|
|
121
|
+
<span className="block truncate">{row.label}</span>
|
|
101
122
|
</th>
|
|
102
|
-
<td className="
|
|
123
|
+
<td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
|
|
103
124
|
{row.count}
|
|
104
125
|
</td>
|
|
105
|
-
<td className="
|
|
126
|
+
<td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
|
|
106
127
|
{row.cost}
|
|
107
128
|
</td>
|
|
108
129
|
</tr>
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
{row.parts?.map((part, index, parts) => {
|
|
131
|
+
const padding = index === parts.length - 1 ? "pb-2" : "";
|
|
132
|
+
return (
|
|
133
|
+
<tr
|
|
134
|
+
className="text-2xs leading-5 text-dashboard-text-faint"
|
|
135
|
+
key={part.label}
|
|
136
|
+
>
|
|
137
|
+
<th
|
|
138
|
+
scope="row"
|
|
139
|
+
className={`truncate pl-3 pr-3 text-left font-normal ${padding}`}
|
|
140
|
+
>
|
|
141
|
+
{part.label}
|
|
142
|
+
</th>
|
|
143
|
+
<td className={`${CELL} ${padding}`}>{part.count}</td>
|
|
144
|
+
<td className={`${CELL} ${padding}`}>{part.cost}</td>
|
|
145
|
+
</tr>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</tbody>
|
|
149
|
+
))}
|
|
150
|
+
{props.total ? (
|
|
151
|
+
<tfoot>
|
|
111
152
|
<tr className="border-t border-dashboard-border font-semibold text-dashboard-text">
|
|
112
153
|
<th scope="row" className="py-2 text-left font-semibold">
|
|
113
154
|
{props.total.label}
|
|
@@ -119,8 +160,8 @@ function BreakdownTable(props: {
|
|
|
119
160
|
{props.total.cost}
|
|
120
161
|
</td>
|
|
121
162
|
</tr>
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
</tfoot>
|
|
164
|
+
) : null}
|
|
124
165
|
</table>
|
|
125
166
|
</div>
|
|
126
167
|
);
|
|
@@ -217,7 +258,6 @@ export function ConversationUsageBreakdown(props: {
|
|
|
217
258
|
rows={models.map(({ cost: itemCost, item, tokens: itemTokens }) => ({
|
|
218
259
|
cost: formatMaybeCost(itemCost),
|
|
219
260
|
count: formatTokens(itemTokens),
|
|
220
|
-
detail: tokenTypes(item.usage),
|
|
221
261
|
key: item.modelId,
|
|
222
262
|
label: (
|
|
223
263
|
<span className="flex min-w-0 items-baseline gap-2">
|
|
@@ -234,6 +274,7 @@ export function ConversationUsageBreakdown(props: {
|
|
|
234
274
|
) : null}
|
|
235
275
|
</span>
|
|
236
276
|
),
|
|
277
|
+
parts: tokenTypeParts(item.usage, formatCost),
|
|
237
278
|
}))}
|
|
238
279
|
total={
|
|
239
280
|
models.length > 1
|
|
@@ -25,6 +25,13 @@ shared controls and overlays.
|
|
|
25
25
|
- `TranscriptSubagentView` renders a child agent as a card with its name,
|
|
26
26
|
status, run time, and title. It matches the child agent rows in the details
|
|
27
27
|
drawer and opens the child transcript.
|
|
28
|
+
- `TranscriptAutomationTriggerView` renders the input of an Automation run as
|
|
29
|
+
one card. The report names that message in `sourceTask.triggerMessageId`.
|
|
30
|
+
The card shows the Automation name and a link to it. A second line shows the
|
|
31
|
+
trigger, the run time, and whose credentials the run used. The credential
|
|
32
|
+
part comes from the run's dispatch record, not the Automation's current
|
|
33
|
+
setting. It is left out when that record is gone. The run input is not from
|
|
34
|
+
a person, so it stays collapsed behind "Show prompt".
|
|
28
35
|
- `TranscriptAttachments` owns wrapping for both message attachments and file
|
|
29
36
|
deliveries. `ImageAttachment` owns image previews.
|
|
30
37
|
- `TranscriptTurnContextView` owns context content. The shared `Drawer` owns
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ArrowRight, ChevronRight } from "lucide-react";
|
|
2
|
+
import { useId, useState } from "react";
|
|
3
|
+
import type { ConversationDetailReport } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { ButtonLink } from "../components/Button";
|
|
6
|
+
import { ObjectIcon } from "../components/ObjectIcon";
|
|
7
|
+
import { automationPath, formatRelativeTime } from "../format";
|
|
8
|
+
import { messageRawText } from "./transcriptRenderModel";
|
|
9
|
+
import { HighlightText } from "./transcriptSearch";
|
|
10
|
+
import { RedactedMarker } from "./TranscriptRedacted";
|
|
11
|
+
import { cn } from "../styles";
|
|
12
|
+
import type { ConversationTranscript, TranscriptViewMessage } from "../types";
|
|
13
|
+
|
|
14
|
+
type SourceTask = NonNullable<ConversationDetailReport["sourceTask"]>;
|
|
15
|
+
|
|
16
|
+
/** True when a message is the trigger input of an Automation run. */
|
|
17
|
+
export function isAutomationTriggerMessage(
|
|
18
|
+
conversation: ConversationTranscript,
|
|
19
|
+
message: TranscriptViewMessage,
|
|
20
|
+
): boolean {
|
|
21
|
+
const triggerMessageId = conversation.sourceTask?.triggerMessageId;
|
|
22
|
+
return Boolean(
|
|
23
|
+
triggerMessageId &&
|
|
24
|
+
message.role === "user" &&
|
|
25
|
+
message.messageId === triggerMessageId,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runsAsLabel(sourceTask: SourceTask): string | undefined {
|
|
30
|
+
if (sourceTask.credentialMode === "system") return "Runs as system";
|
|
31
|
+
if (sourceTask.credentialMode === "creator" && sourceTask.credentialUser) {
|
|
32
|
+
return `Runs as ${sourceTask.credentialUser}`;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Show which Automation started this run, what triggered it, and whose
|
|
39
|
+
* credentials it used. The run input is not from a person, so it stays
|
|
40
|
+
* collapsed behind "Show prompt".
|
|
41
|
+
*/
|
|
42
|
+
export function TranscriptAutomationTriggerView(props: {
|
|
43
|
+
message: TranscriptViewMessage;
|
|
44
|
+
sourceTask: SourceTask;
|
|
45
|
+
}) {
|
|
46
|
+
const text = messageRawText(props.message);
|
|
47
|
+
const redacted = props.message.parts.some(
|
|
48
|
+
(part) => part.type === "text" && part.redacted,
|
|
49
|
+
);
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const promptId = useId();
|
|
52
|
+
const automationId = props.sourceTask.id?.trim();
|
|
53
|
+
const name = props.sourceTask.title?.trim() || "Automation";
|
|
54
|
+
const details = [
|
|
55
|
+
props.sourceTask.triggerLabel,
|
|
56
|
+
props.message.timestamp
|
|
57
|
+
? formatRelativeTime(new Date(props.message.timestamp).toISOString())
|
|
58
|
+
: undefined,
|
|
59
|
+
runsAsLabel(props.sourceTask),
|
|
60
|
+
].filter(Boolean);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<section
|
|
64
|
+
aria-label={name}
|
|
65
|
+
className="grid w-full min-w-0 max-w-[37.5rem] gap-2 rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-panel px-4 py-3"
|
|
66
|
+
>
|
|
67
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
68
|
+
<ObjectIcon objectType="automation" size={16} decorative />
|
|
69
|
+
<span className="min-w-0 flex-1 truncate font-sans text-sm font-semibold text-dashboard-text">
|
|
70
|
+
<HighlightText text={name} />
|
|
71
|
+
</span>
|
|
72
|
+
{automationId ? (
|
|
73
|
+
<ButtonLink
|
|
74
|
+
className="h-8 shrink-0 text-xs"
|
|
75
|
+
to={automationPath(automationId)}
|
|
76
|
+
>
|
|
77
|
+
Open automation
|
|
78
|
+
<ArrowRight aria-hidden="true" size={14} />
|
|
79
|
+
</ButtonLink>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
{details.length > 0 ? (
|
|
83
|
+
<p className="m-0 min-w-0 break-words text-xs leading-relaxed text-dashboard-text-muted">
|
|
84
|
+
{details.join(" · ")}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
{text ? (
|
|
88
|
+
<div className="min-w-0">
|
|
89
|
+
<button
|
|
90
|
+
aria-controls={promptId}
|
|
91
|
+
aria-expanded={open}
|
|
92
|
+
className="-mx-1 inline-flex cursor-pointer items-center gap-1 rounded-sm 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-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
93
|
+
onClick={() => setOpen((value) => !value)}
|
|
94
|
+
type="button"
|
|
95
|
+
>
|
|
96
|
+
<ChevronRight
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
className={cn("size-3 transition-transform", open && "rotate-90")}
|
|
99
|
+
strokeWidth={2.2}
|
|
100
|
+
/>
|
|
101
|
+
{open ? "Hide prompt" : "Show prompt"}
|
|
102
|
+
</button>
|
|
103
|
+
{open ? (
|
|
104
|
+
<pre
|
|
105
|
+
className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-dashboard-border-subtle bg-dashboard-bg px-3 py-2 font-mono text-xs leading-relaxed text-dashboard-text-muted"
|
|
106
|
+
id={promptId}
|
|
107
|
+
>
|
|
108
|
+
<HighlightText text={text} />
|
|
109
|
+
</pre>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
) : redacted ? (
|
|
113
|
+
<RedactedMarker />
|
|
114
|
+
) : null}
|
|
115
|
+
</section>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -125,6 +125,8 @@ const SANDBOX_CONVERSATION_ID = "slack:CQA999:1770010800.000400";
|
|
|
125
125
|
const FAILED_CONVERSATION_ID = "slack:CQA777:1770014400.000500";
|
|
126
126
|
const LONG_CONVERSATION_ID = "slack:CQA456:1770021600.000600";
|
|
127
127
|
const SCHEDULER_CONVERSATION_ID = "scheduler:daily-ops-digest";
|
|
128
|
+
/** Event automation run. Its trigger message id equals the conversation id. */
|
|
129
|
+
const EVENT_AUTOMATION_RUN_CONVERSATION_ID = "agent-dispatch:event-1";
|
|
128
130
|
export const DASHBOARD_QA_CONVERSATION_ID = "internal:dashboard-qa";
|
|
129
131
|
export const ARCHIVED_CONVERSATION_ID = "internal:archived-restore-qa";
|
|
130
132
|
const DASHBOARD_QA_PLAN_ID = "junior:internal:dashboard-qa:advisor-plan";
|
|
@@ -1395,6 +1397,75 @@ function simpleConversation(
|
|
|
1395
1397
|
});
|
|
1396
1398
|
}
|
|
1397
1399
|
|
|
1400
|
+
function eventAutomationRunConversation(
|
|
1401
|
+
nowMs: number,
|
|
1402
|
+
): ConversationDetailReport {
|
|
1403
|
+
const startedAt = iso(nowMs, -3 * 60 * 60_000);
|
|
1404
|
+
const at = (offsetMs: number) => iso(Date.parse(startedAt), offsetMs);
|
|
1405
|
+
return detail(nowMs, {
|
|
1406
|
+
conversationId: EVENT_AUTOMATION_RUN_CONVERSATION_ID,
|
|
1407
|
+
displayTitle: "Closed issue summary",
|
|
1408
|
+
surface: "internal",
|
|
1409
|
+
startedAt,
|
|
1410
|
+
lastSeenAt: at(14_000),
|
|
1411
|
+
lastProgressAt: at(14_000),
|
|
1412
|
+
cumulativeDurationMs: 14_000,
|
|
1413
|
+
sourceTask: {
|
|
1414
|
+
id: "event-1",
|
|
1415
|
+
kind: "event",
|
|
1416
|
+
label: "Summarize the closed issue",
|
|
1417
|
+
title: "Closed issue summary",
|
|
1418
|
+
triggerMessageId: EVENT_AUTOMATION_RUN_CONVERSATION_ID,
|
|
1419
|
+
triggerLabel: "Linear issue ACME-42 closed",
|
|
1420
|
+
credentialMode: "creator",
|
|
1421
|
+
credentialUser: "Alex Rivera",
|
|
1422
|
+
},
|
|
1423
|
+
events: [
|
|
1424
|
+
reportEvent(0, startedAt, {
|
|
1425
|
+
type: "message",
|
|
1426
|
+
messageId: EVENT_AUTOMATION_RUN_CONVERSATION_ID,
|
|
1427
|
+
role: "user",
|
|
1428
|
+
// Dispatch input is authored by the dispatch owner, not a person.
|
|
1429
|
+
actorIdentity: {
|
|
1430
|
+
fullName: "junior",
|
|
1431
|
+
slackUserId: "junior",
|
|
1432
|
+
slackUserName: "junior",
|
|
1433
|
+
},
|
|
1434
|
+
text: [
|
|
1435
|
+
"[task]",
|
|
1436
|
+
"",
|
|
1437
|
+
"This is a task, not a message from a person.",
|
|
1438
|
+
"",
|
|
1439
|
+
"About: Issue · ACME-42",
|
|
1440
|
+
'Created by: <@U123>. "Me" and "my" in the instructions mean this person: mention them in the message. If the instructions do not say "me" or "my", do not mention this person.',
|
|
1441
|
+
"Instructions: Summarize the closed issue",
|
|
1442
|
+
"",
|
|
1443
|
+
"Trusted summary: ACME-42 Checkout fails for saved cards was closed by Morgan Lee.",
|
|
1444
|
+
"",
|
|
1445
|
+
"End with `finishAutomationRun`.",
|
|
1446
|
+
].join("\n"),
|
|
1447
|
+
}),
|
|
1448
|
+
reportEvent(1, at(1_000), {
|
|
1449
|
+
type: "turn_lifecycle",
|
|
1450
|
+
turnId: "dispatch:event-1",
|
|
1451
|
+
state: "started",
|
|
1452
|
+
inputMessageIds: [EVENT_AUTOMATION_RUN_CONVERSATION_ID],
|
|
1453
|
+
}),
|
|
1454
|
+
reportEvent(2, at(13_000), {
|
|
1455
|
+
type: "message",
|
|
1456
|
+
messageId: `${EVENT_AUTOMATION_RUN_CONVERSATION_ID}:reply`,
|
|
1457
|
+
role: "assistant",
|
|
1458
|
+
text: "ACME-42 is closed. The saved-card checkout failure was fixed by the token refresh change and verified in production.",
|
|
1459
|
+
}),
|
|
1460
|
+
reportEvent(3, at(14_000), {
|
|
1461
|
+
type: "turn_lifecycle",
|
|
1462
|
+
turnId: "dispatch:event-1",
|
|
1463
|
+
state: "succeeded",
|
|
1464
|
+
}),
|
|
1465
|
+
],
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1398
1469
|
function actor(
|
|
1399
1470
|
email: string | undefined,
|
|
1400
1471
|
fullName: string,
|
|
@@ -1488,6 +1559,7 @@ function mockConversations(nowMs: number): MockConversation[] {
|
|
|
1488
1559
|
title: "Weekly project summary",
|
|
1489
1560
|
},
|
|
1490
1561
|
}),
|
|
1562
|
+
eventAutomationRunConversation(nowMs),
|
|
1491
1563
|
simpleConversation(nowMs, {
|
|
1492
1564
|
conversationId: ARCHIVED_CONVERSATION_ID,
|
|
1493
1565
|
displayTitle: "Archived restore target",
|