@sentry/junior-dashboard 0.249.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.
@@ -486,45 +486,18 @@ function LocationLink(props: { label: string; locationUrl?: string }) {
486
486
  function SourceTask(props: {
487
487
  sourceTask: NonNullable<ConversationDetailReport["sourceTask"]>;
488
488
  }) {
489
- const kindLabel =
490
- props.sourceTask.kind === "scheduled"
491
- ? "Scheduled automation"
492
- : "Event automation";
493
489
  const automationId = props.sourceTask.id?.trim();
494
490
  const title = props.sourceTask.title?.trim();
495
- const link = automationId ? (
491
+ const label = title ? `Automation: ${title}` : "Automation";
492
+ return automationId ? (
496
493
  <Link
497
494
  className={dashboardInlineLinkClass}
498
495
  to={automationPath(automationId)}
499
496
  >
500
- Triggered by {kindLabel}
497
+ {label}
501
498
  </Link>
502
499
  ) : (
503
- <span>Triggered by {kindLabel}</span>
504
- );
505
- // Keep tooltips to short identity fields. Full task prompts belong on the
506
- // task details page, not in hover chrome.
507
- if (!title) return link;
508
- return (
509
- <Tooltip
510
- align="left"
511
- content={
512
- <span className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
513
- <span>Title</span>
514
- <span className="text-dashboard-text">{title}</span>
515
- {automationId ? (
516
- <>
517
- <span>ID</span>
518
- <span className="break-all text-dashboard-text">
519
- {automationId}
520
- </span>
521
- </>
522
- ) : null}
523
- </span>
524
- }
525
- >
526
- {link}
527
- </Tooltip>
500
+ <span>{label}</span>
528
501
  );
529
502
  }
530
503
 
@@ -30,6 +30,10 @@ import {
30
30
  TranscriptMessageView,
31
31
  } from "./TranscriptMessageView";
32
32
  import { TranscriptEventView } from "./TranscriptEventView";
33
+ import {
34
+ isAutomationTriggerMessage,
35
+ TranscriptAutomationTriggerView,
36
+ } from "./TranscriptAutomationTriggerView";
33
37
  import { TranscriptTypingIndicator } from "./TranscriptTypingIndicator";
34
38
  import {
35
39
  groupTranscriptMessages,
@@ -196,7 +200,13 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
196
200
  />
197
201
  )}
198
202
  renderMessage={(entry) =>
199
- entry.message.eventType ? (
203
+ props.conversation.sourceTask &&
204
+ isAutomationTriggerMessage(props.conversation, entry.message) ? (
205
+ <TranscriptAutomationTriggerView
206
+ message={entry.message}
207
+ sourceTask={props.conversation.sourceTask}
208
+ />
209
+ ) : entry.message.eventType ? (
200
210
  <TranscriptRailEvent
201
211
  kind="event"
202
212
  objectType={entry.message.eventObjectType}
@@ -401,7 +411,13 @@ function RedactedTranscriptView(props: {
401
411
  />
402
412
  )}
403
413
  renderMessage={(entry) =>
404
- entry.message.eventType ? (
414
+ props.conversation.sourceTask &&
415
+ isAutomationTriggerMessage(props.conversation, entry.message) ? (
416
+ <TranscriptAutomationTriggerView
417
+ message={entry.message}
418
+ sourceTask={props.conversation.sourceTask}
419
+ />
420
+ ) : entry.message.eventType ? (
405
421
  <TranscriptRailEvent
406
422
  kind="event"
407
423
  objectType={entry.message.eventObjectType}
@@ -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
+ }
@@ -0,0 +1,207 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { Plug } from "lucide-react";
3
+ import type { z } from "zod";
4
+ import {
5
+ connectionListSchema,
6
+ disconnectConnectionResponseSchema,
7
+ startConnectionResponseSchema,
8
+ type connectionSchema,
9
+ } from "@sentry/junior/api/schema";
10
+ import { del, fetchDashboardJson, post } from "../http";
11
+ import { dashboardContainerClass } from "../styles";
12
+ import { getDashboardAgentName } from "../agentName";
13
+ import { useDocumentTitle } from "../documentTitle";
14
+ import { Button } from "../components/Button";
15
+ import { InlineError } from "../components/InlineError";
16
+ import { Card } from "../components/layout/Card";
17
+
18
+ type Connection = z.infer<typeof connectionSchema>;
19
+
20
+ const connectionsQueryKey = ["dashboard", "connections"] as const;
21
+
22
+ function connectionPath(provider: string): string {
23
+ return `/api/connections/${encodeURIComponent(provider)}`;
24
+ }
25
+
26
+ /** Connect and disconnect the viewer's plugin accounts. */
27
+ export function ConnectionsPage() {
28
+ useDocumentTitle("Connected accounts");
29
+ const queryClient = useQueryClient();
30
+ const connectionsQuery = useQuery({
31
+ queryKey: connectionsQueryKey,
32
+ queryFn: ({ signal }) =>
33
+ fetchDashboardJson(connectionListSchema, "/api/connections", signal),
34
+ // OAuth finishes in another tab. Refresh when the viewer returns.
35
+ refetchOnWindowFocus: true,
36
+ retry: false,
37
+ });
38
+ const connectMutation = useMutation({
39
+ mutationFn: (input: { provider: string; popup: Window | null }) =>
40
+ post(startConnectionResponseSchema, connectionPath(input.provider), {}),
41
+ onSuccess: ({ authorizationUrl }, { popup }) => {
42
+ // A blocked popup returns null, so use this tab. A tab the viewer
43
+ // closed means they cancelled; keep them on the dashboard.
44
+ if (!popup) {
45
+ window.location.assign(authorizationUrl);
46
+ return;
47
+ }
48
+ if (popup.closed) return;
49
+ popup.opener = null;
50
+ popup.location.href = authorizationUrl;
51
+ },
52
+ onError: (_error, { popup }) => {
53
+ popup?.close();
54
+ },
55
+ });
56
+ const disconnectMutation = useMutation({
57
+ mutationFn: (connection: Connection) =>
58
+ del(
59
+ disconnectConnectionResponseSchema,
60
+ connectionPath(connection.provider),
61
+ ),
62
+ onSuccess: (_result, connection) => {
63
+ queryClient.setQueryData<{ connections: Connection[] }>(
64
+ connectionsQueryKey,
65
+ (current) =>
66
+ current && {
67
+ connections: current.connections.map((item) =>
68
+ item.provider === connection.provider
69
+ ? {
70
+ provider: item.provider,
71
+ displayName: item.displayName,
72
+ description: item.description,
73
+ connected: false,
74
+ }
75
+ : item,
76
+ ),
77
+ },
78
+ );
79
+ },
80
+ });
81
+ const connections = connectionsQuery.data?.connections ?? [];
82
+ const busy = connectMutation.isPending || disconnectMutation.isPending;
83
+ const pendingProvider = connectMutation.isPending
84
+ ? connectMutation.variables?.provider
85
+ : disconnectMutation.isPending
86
+ ? disconnectMutation.variables?.provider
87
+ : undefined;
88
+ const listFailed = !connectionsQuery.data && connectionsQuery.isError;
89
+ const displayedError = connectMutation.isError
90
+ ? "Could not start the connection. Try again."
91
+ : disconnectMutation.isError
92
+ ? "Could not disconnect the account. Try again."
93
+ : listFailed
94
+ ? "Could not load connected accounts. Try again."
95
+ : undefined;
96
+
97
+ return (
98
+ <div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
99
+ <section className="mx-auto w-full max-w-3xl">
100
+ <h1 className="m-0 text-2xl font-bold">Connected accounts</h1>
101
+ <p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
102
+ Connect an account so {getDashboardAgentName()} can act as you in
103
+ conversations you start here.
104
+ </p>
105
+
106
+ <Card className="mb-0 mt-6" padding="md" variant="raised">
107
+ {displayedError ? (
108
+ <InlineError className={listFailed ? undefined : "mb-4"}>
109
+ {displayedError}
110
+ </InlineError>
111
+ ) : null}
112
+ {listFailed ? null : (
113
+ <div className="space-y-2">
114
+ {connectionsQuery.isPending ? (
115
+ <p className="m-0 text-sm text-dashboard-text-muted">
116
+ Loading connected accounts…
117
+ </p>
118
+ ) : connections.length === 0 ? (
119
+ <p className="m-0 text-sm text-dashboard-text-muted">
120
+ No installed plugins support account connections.
121
+ </p>
122
+ ) : (
123
+ connections.map((connection) => (
124
+ <div
125
+ className="flex items-center gap-3 rounded-md border border-dashboard-border-strong p-3"
126
+ key={connection.provider}
127
+ >
128
+ <Plug
129
+ className="shrink-0 text-dashboard-accent"
130
+ size={16}
131
+ />
132
+ <div className="min-w-0 flex-1">
133
+ <p className="m-0 truncate text-sm font-semibold">
134
+ {connection.displayName}
135
+ </p>
136
+ <p className="mt-1 mb-0 line-clamp-2 break-words text-xs text-dashboard-text-muted">
137
+ {connection.connected ? (
138
+ connection.account ? (
139
+ <>
140
+ Connected as{" "}
141
+ {connection.account.url ? (
142
+ <a
143
+ className="text-dashboard-text"
144
+ href={connection.account.url}
145
+ rel="noreferrer"
146
+ target="_blank"
147
+ >
148
+ {connection.account.label}
149
+ </a>
150
+ ) : (
151
+ connection.account.label
152
+ )}
153
+ </>
154
+ ) : (
155
+ "Connected"
156
+ )
157
+ ) : (
158
+ connection.description
159
+ )}
160
+ </p>
161
+ </div>
162
+ {connection.connected ? (
163
+ <Button
164
+ aria-busy={pendingProvider === connection.provider}
165
+ aria-label={`Disconnect ${connection.displayName}`}
166
+ disabled={busy}
167
+ onClick={() => {
168
+ connectMutation.reset();
169
+ disconnectMutation.mutate(connection);
170
+ }}
171
+ tone="danger"
172
+ >
173
+ {pendingProvider === connection.provider
174
+ ? "Disconnecting…"
175
+ : "Disconnect"}
176
+ </Button>
177
+ ) : (
178
+ <Button
179
+ aria-busy={pendingProvider === connection.provider}
180
+ aria-label={`Connect ${connection.displayName}`}
181
+ disabled={busy}
182
+ onClick={() => {
183
+ disconnectMutation.reset();
184
+ // Open the tab during the click so popup blockers
185
+ // allow it, then point it at the provider.
186
+ const popup = window.open("about:blank", "_blank");
187
+ connectMutation.mutate({
188
+ provider: connection.provider,
189
+ popup,
190
+ });
191
+ }}
192
+ >
193
+ {pendingProvider === connection.provider
194
+ ? "Connecting…"
195
+ : "Connect"}
196
+ </Button>
197
+ )}
198
+ </div>
199
+ ))
200
+ )}
201
+ </div>
202
+ )}
203
+ </Card>
204
+ </section>
205
+ </div>
206
+ );
207
+ }