@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.
@@ -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
- /** Name each recorded token type, such as `42k input · 4.6k output`. */
39
- function tokenTypes(
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
- ): string | undefined {
51
+ formatCost: (value: number) => string,
52
+ ): BreakdownPart[] | undefined {
42
53
  const summary = summarizeUsage(usage);
43
- const text = summary
44
- ? tokenTypeRows(summary)
45
- .map((row) => `${row.value} ${row.label}`)
46
- .join(" · ")
47
- : "";
48
- return text || undefined;
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
- <tbody>
87
- {props.rows.map((row) => (
88
- <tr
89
- className="border-b border-dashboard-border-subtle last:border-b-0"
90
- key={row.key}
91
- >
92
- <th scope="row" className="py-2 pr-3 text-left font-normal">
93
- <span className="block truncate text-dashboard-text">
94
- {row.label}
95
- </span>
96
- {row.detail ? (
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="py-2 text-right align-top font-sans tabular-nums text-dashboard-text">
123
+ <td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
103
124
  {row.count}
104
125
  </td>
105
- <td className="py-2 text-right align-top font-sans tabular-nums text-dashboard-text">
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
- {props.total ? (
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
- ) : null}
123
- </tbody>
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",