@sentry/junior-dashboard 0.232.0 → 0.234.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.
Files changed (45) hide show
  1. package/dist/app.js +163 -46
  2. package/dist/client.js +55 -52
  3. package/dist/index.js +163 -46
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +359 -345
  7. package/src/client/api.ts +17 -1
  8. package/src/client/components/ActorAvatar.tsx +37 -0
  9. package/src/client/components/Button.tsx +12 -10
  10. package/src/client/components/Field.tsx +14 -1
  11. package/src/client/components/FormNotice.tsx +40 -0
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/Select.tsx +18 -0
  15. package/src/client/components/StatusChip.tsx +15 -12
  16. package/src/client/components/TextInput.tsx +25 -5
  17. package/src/client/conversations/ConversationEventLog.tsx +49 -9
  18. package/src/client/conversations/EventDetails.tsx +94 -11
  19. package/src/client/conversations/EventUsage.tsx +118 -0
  20. package/src/client/conversations/eventLog.ts +59 -6
  21. package/src/client/conversations/eventTranscript.ts +2 -0
  22. package/src/client/conversations/transcriptBottomPinning.ts +2 -2
  23. package/src/client/http.ts +31 -3
  24. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
  25. package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
  26. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  27. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  28. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  29. package/src/client/pages/automations/AutomationFilters.tsx +171 -0
  30. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  31. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  32. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  33. package/src/client/pages/automations/AutomationRow.tsx +358 -0
  34. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  35. package/src/client/pages/automations/AutomationsPage.tsx +157 -408
  36. package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
  37. package/src/client/pages/automations/README.md +19 -0
  38. package/src/client/pages/automations/automationDraft.ts +110 -0
  39. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  40. package/src/client/pages/dev/ComponentsPage.tsx +50 -4
  41. package/src/client/searchParams.ts +4 -2
  42. package/src/client.tsx +7 -4
  43. package/src/mock-reporting/automation-collection.ts +101 -0
  44. package/src/mock-reporting/fixtures.ts +82 -32
  45. package/src/mock-reporting/routes.ts +14 -9
@@ -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
+ }
@@ -1,6 +1,57 @@
1
- import type { ConversationReportEventData } from "@sentry/junior/api/schema";
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 data.parts
14
- .map((part) => (part.redacted ? "Content hidden" : part.text))
15
- .join(" · ");
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 `${data.modelProfile} · ${data.modelId} · ${data.reasoningLevel}`;
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 `${data.modelProfile} · ${data.modelId}`;
95
+ return "Handoff · New configuration applied";
43
96
  case "subagent":
44
97
  return `${data.subagentKind} · ${data.status} · ${data.childConversationId}`;
45
98
  }
@@ -242,6 +242,8 @@ export function transcriptMessagesFromEvents(
242
242
  }
243
243
 
244
244
  if (data.type === "assistant_message") {
245
+ // Calls with no reasoning still carry usage in the event log, not a chat bubble.
246
+ if (data.parts.length === 0) continue;
245
247
  messages.push(
246
248
  eventMessage(
247
249
  event,
@@ -73,7 +73,7 @@ export function transcriptJuniorMessageVersion(
73
73
  data.redacted ? "redacted" : (data.text?.length ?? 0),
74
74
  ].join(":");
75
75
  }
76
- if (data.type === "assistant_message") {
76
+ if (data.type === "assistant_message" && data.parts.length > 0) {
77
77
  const lastPart = data.parts.at(-1);
78
78
  return [
79
79
  event.seq,
@@ -117,7 +117,7 @@ function changesVisibleTranscript(event: ConversationReportEvent): boolean {
117
117
  if (data.type === "turn_lifecycle") return data.state === "failed";
118
118
  return (
119
119
  data.type === "message" ||
120
- data.type === "assistant_message" ||
120
+ (data.type === "assistant_message" && data.parts.length > 0) ||
121
121
  data.type === "tool_calls" ||
122
122
  data.type === "subagent" ||
123
123
  data.type === "structured_event" ||
@@ -4,11 +4,20 @@ import { recordDashboardServerVersion } from "./dashboard-version";
4
4
  /** An authenticated dashboard request rejected by the product API. */
5
5
  export class DashboardApiError extends Error {
6
6
  readonly status: number;
7
+ readonly fields?: Record<string, string[]>;
8
+ readonly code?: string;
7
9
  readonly apiError?: string;
8
10
 
9
- constructor(path: string, status: number, apiError?: string) {
11
+ constructor(
12
+ path: string,
13
+ status: number,
14
+ apiError?: string,
15
+ detail?: { fields?: Record<string, string[]>; code?: string },
16
+ ) {
10
17
  super(`${path} returned ${status}`);
11
18
  this.status = status;
19
+ this.fields = detail?.fields;
20
+ this.code = detail?.code;
12
21
  if (apiError?.trim()) this.apiError = apiError.trim();
13
22
  }
14
23
  }
@@ -18,13 +27,32 @@ async function throwDashboardApiError(
18
27
  response: Response,
19
28
  ): Promise<never> {
20
29
  let apiError: string | undefined;
30
+ let fields: Record<string, string[]> | undefined;
31
+ let code: string | undefined;
21
32
  try {
22
- const body = (await response.json()) as { error?: unknown };
33
+ const body = (await response.json()) as {
34
+ error?: unknown;
35
+ code?: unknown;
36
+ fields?: unknown;
37
+ };
23
38
  if (typeof body.error === "string") apiError = body.error;
39
+ if (typeof body.code === "string") code = body.code;
40
+ if (body.fields && typeof body.fields === "object") {
41
+ fields = Object.fromEntries(
42
+ Object.entries(body.fields).filter(
43
+ (entry): entry is [string, string[]] =>
44
+ Array.isArray(entry[1]) &&
45
+ entry[1].every((value) => typeof value === "string"),
46
+ ),
47
+ );
48
+ }
24
49
  } catch {
25
50
  // Keep the status-only fallback when the body is not JSON.
26
51
  }
27
- throw new DashboardApiError(path, response.status, apiError);
52
+ throw new DashboardApiError(path, response.status, apiError, {
53
+ fields,
54
+ code,
55
+ });
28
56
  }
29
57
 
30
58
  function restartDashboardSignIn(): void {
@@ -1,6 +1,7 @@
1
+ import { AutomationLifecycle } from "./AutomationLifecycle";
2
+ import { automationOutcomeLabel } from "./automationOutcomes";
1
3
  import type { AutomationSummary } from "@sentry/junior/api/schema";
2
- import { Link } from "react-router";
3
- import { MapPin } from "lucide-react";
4
+ import { Link, useLocation } from "react-router";
4
5
  import { Detail, DetailList } from "../../components/DetailList";
5
6
  import { Drawer } from "../../components/Drawer";
6
7
  import {
@@ -17,6 +18,7 @@ export function AutomationDetailsDrawer(props: {
17
18
  range: TimeRangeDays;
18
19
  automation: AutomationSummary | undefined;
19
20
  }) {
21
+ const location = useLocation();
20
22
  if (!props.automation) return null;
21
23
 
22
24
  const { automation } = props;
@@ -38,22 +40,21 @@ export function AutomationDetailsDrawer(props: {
38
40
  { label: "Schedule", value: automation.schedule },
39
41
  {
40
42
  label: "Next run",
41
- value: automation.nextRunAt
42
- ? formatRunDate(automation.nextRunAt)
43
- : "None",
43
+ value:
44
+ automation.status === "active" && automation.nextRunAt
45
+ ? formatRunDate(automation.nextRunAt)
46
+ : "None",
44
47
  },
45
48
  ]
46
49
  : [
47
- { label: "Resource", value: automation.resource },
48
- { label: "Events", value: automation.events.join(", ") },
50
+ { label: "Watching", value: automation.resource },
51
+ {
52
+ label: "Events",
53
+ value: automation.events
54
+ .map((event) => event.replaceAll(/[._]/g, " "))
55
+ .join(", "),
56
+ },
49
57
  ];
50
- const statusLabel =
51
- automation.kind === "scheduled"
52
- ? automation.status
53
- : automation.triggerAvailable
54
- ? "ready"
55
- : "unavailable";
56
-
57
58
  const titleId = "automation-details-drawer-title";
58
59
 
59
60
  return (
@@ -68,9 +69,8 @@ export function AutomationDetailsDrawer(props: {
68
69
  >
69
70
  {automation.title}
70
71
  </h2>
71
- <div className="mt-1 break-words font-mono text-xs leading-snug text-dashboard-text-muted capitalize">
72
- {automation.kind} automation · {statusLabel} ·{" "}
73
- {formatDate(automation.createdAt)}
72
+ <div className="mt-1 break-words text-sm text-dashboard-text-muted">
73
+ Created by {createdBy} · {automation.destination.label}
74
74
  </div>
75
75
  </>
76
76
  }
@@ -80,53 +80,70 @@ export function AutomationDetailsDrawer(props: {
80
80
  >
81
81
  <section className="grid gap-5">
82
82
  <div>
83
- <div className="mb-2 font-mono text-xs uppercase tracking-[0.12em] text-dashboard-text-muted">
84
- Instruction
85
- </div>
83
+ <h3 className="mt-0 mb-2 text-sm font-medium text-dashboard-text-muted">
84
+ What to do
85
+ </h3>
86
86
  <TranscriptText text={automation.instruction} />
87
87
  </div>
88
+ <AutomationLifecycle
89
+ key={`${automation.kind}:${automation.id}`}
90
+ automation={automation}
91
+ editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
92
+ />
88
93
  <DetailList>
89
94
  {details.map((detail) => (
90
95
  <Detail key={detail.label} label={detail.label}>
91
96
  {detail.value}
92
97
  </Detail>
93
98
  ))}
94
- <Detail label="Outcomes">
95
- {automation.outcomes.length === 0
96
- ? "None"
97
- : `${automation.outcomes.length} message${automation.outcomes.length === 1 ? "" : "s"}`}
98
- </Detail>
99
- <Detail label="Destination">
100
- <span className="inline-flex items-center gap-1.5">
101
- <MapPin
102
- aria-hidden="true"
103
- className="text-cyan-300/70"
104
- size={13}
105
- />
106
- {automation.destination.label} ·{" "}
107
- {automation.destination.visibility}
108
- </span>
109
- </Detail>
110
- <Detail label="Created">
111
- {createdBy} · {formatDate(automation.createdAt)}
112
- </Detail>
113
- <Detail label="Executions">
114
- <AutomationExecutionSummary
115
- range={props.range}
116
- automation={automation}
117
- />
99
+ <Detail label="On success">
100
+ {automation.outcomes.length === 0 ? (
101
+ "No message on success"
102
+ ) : (
103
+ <ol className="m-0 pl-4">
104
+ {automation.outcomes.map((outcome, index) => (
105
+ <li key={index}>
106
+ {automationOutcomeLabel(outcome, automation.destination)}
107
+ </li>
108
+ ))}
109
+ </ol>
110
+ )}
118
111
  </Detail>
119
112
  </DetailList>
120
- {automation.totalRuns > 0 ? (
121
- <div className="pt-1">
122
- <Link
123
- className="inline-flex items-center justify-center rounded border border-white/12 bg-white/[0.03] px-3 py-2 font-mono text-xs font-medium text-dashboard-text no-underline transition-colors hover:border-white/25 hover:bg-white/[0.06]"
124
- to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
125
- >
126
- View all executions
127
- </Link>
128
- </div>
113
+ {automation.totalRuns > 0 &&
114
+ automation.lastRunStatus !== "failed" &&
115
+ automation.lastRunStatus !== "blocked" ? (
116
+ <Link
117
+ className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
118
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
119
+ >
120
+ View all runs
121
+ </Link>
129
122
  ) : null}
123
+ <details>
124
+ <summary className="cursor-pointer py-3 text-sm text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
125
+ More details
126
+ </summary>
127
+ <DetailList>
128
+ <Detail label="Connected accounts">
129
+ {automation.credentialMode === "creator" ? (
130
+ <>
131
+ Uses accounts connected by{" "}
132
+ {automation.ownedByViewer ? "you" : automation.createdBy}.
133
+ </>
134
+ ) : (
135
+ "Junior’s accounts only"
136
+ )}
137
+ </Detail>
138
+ <Detail label="Created">{formatDate(automation.createdAt)}</Detail>
139
+ <Detail label="Run history">
140
+ <AutomationExecutionSummary
141
+ range={props.range}
142
+ automation={automation}
143
+ />
144
+ </Detail>
145
+ </DetailList>
146
+ </details>
130
147
  </section>
131
148
  </Drawer>
132
149
  );
@@ -169,7 +186,7 @@ function AutomationExecutionSummary(props: {
169
186
  ) : (
170
187
  <>{automation.totalRuns} total</>
171
188
  )}
172
- {automation.lastRunAt ? " · Last execution " : " · Never run"}
189
+ {automation.lastRunAt ? " · Last run " : " · Never run"}
173
190
  {automation.lastRunAt && automation.lastConversationId ? (
174
191
  <Link
175
192
  className="text-dashboard-text underline decoration-white/20 underline-offset-2 hover:decoration-white/60"
@@ -0,0 +1,172 @@
1
+ import { Link, useLocation, useParams } from "react-router";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import {
5
+ automationEditSchema,
6
+ type AutomationSummary,
7
+ } from "@sentry/junior/api/schema";
8
+ import { useAutomationData } from "../../api";
9
+ import { fetchDashboardJson } from "../../http";
10
+ import { Button } from "../../components/Button";
11
+ import { FormNotice } from "../../components/FormNotice";
12
+ import { StatusChip } from "../../components/StatusChip";
13
+ import { AutomationsNavigation } from "./AutomationsPageLayout";
14
+ import { AutomationEditor } from "./AutomationEditor";
15
+ import { AutomationEditorLoading } from "./AutomationEditorLoading";
16
+ import { AutomationFormSection } from "./AutomationFormSection";
17
+ import { automationOutcomeLabel } from "./automationOutcomes";
18
+
19
+ /** Load a linkable editor, with readable public values for non-creators. */
20
+ export function AutomationEditPage(props: { enabled: boolean }) {
21
+ const { kind, automationId } = useParams();
22
+ const location = useLocation();
23
+ const summary = useAutomationData(props.enabled, automationId);
24
+ const canEdit =
25
+ summary.data?.ownedByViewer &&
26
+ !(summary.data.kind === "scheduled" && summary.data.status === "completed");
27
+ const edit = useQuery({
28
+ queryKey: ["dashboard", "automations", "edit", kind, automationId],
29
+ enabled: props.enabled && Boolean(canEdit) && summary.data?.kind === kind,
30
+ retry: false,
31
+ queryFn: ({ signal }) =>
32
+ fetchDashboardJson(
33
+ automationEditSchema,
34
+ `/api/automations/${kind}/${encodeURIComponent(automationId!)}/edit`,
35
+ signal,
36
+ ),
37
+ });
38
+ const returnPath = `/automations/list${location.search}`;
39
+ const value = summary.data;
40
+ const status =
41
+ value?.kind === "event"
42
+ ? value.status === "paused"
43
+ ? "paused"
44
+ : value.triggerAvailable
45
+ ? "active"
46
+ : "unavailable"
47
+ : value?.status;
48
+ const statusTone =
49
+ status === "active"
50
+ ? "success"
51
+ : status === "blocked" || status === "unavailable"
52
+ ? "warning"
53
+ : "neutral";
54
+ return (
55
+ <>
56
+ <AutomationsNavigation />
57
+ <div className="mx-auto w-full min-w-0 max-w-5xl px-5 pt-7 sm:px-8 sm:pt-9">
58
+ <Link
59
+ to={returnPath}
60
+ className="inline-flex min-h-8 items-center gap-2 text-sm text-dashboard-text-muted no-underline hover:text-dashboard-text"
61
+ >
62
+ <ArrowLeft aria-hidden size={15} />
63
+ All automations
64
+ </Link>
65
+ <div className="mt-4 mb-8 flex flex-wrap items-start justify-between gap-4">
66
+ <div className="min-w-0">
67
+ <h1 className="m-0 break-words font-display text-3xl font-light tracking-tight">
68
+ {value && !canEdit ? value.title : "Edit automation"}
69
+ </h1>
70
+ {value ? (
71
+ <p className="mt-2 mb-0 text-sm text-dashboard-text-muted">
72
+ Created by {value.ownedByViewer ? "you" : value.createdBy} ·{" "}
73
+ {value.destination.label}
74
+ </p>
75
+ ) : null}
76
+ </div>
77
+ {value ? (
78
+ <StatusChip tone={statusTone}>
79
+ {status === "unavailable" ? "Trigger unavailable" : status}
80
+ </StatusChip>
81
+ ) : null}
82
+ </div>
83
+ {(!value && summary.error) ||
84
+ (!edit.data && edit.error) ||
85
+ (value && value.kind !== kind) ? (
86
+ <FormNotice tone="error" title="Automation could not be loaded.">
87
+ It may no longer be available, or you may not have access.{" "}
88
+ <Button
89
+ onClick={() => {
90
+ void summary.refetch();
91
+ if (canEdit) void edit.refetch();
92
+ }}
93
+ >
94
+ Try again
95
+ </Button>
96
+ </FormNotice>
97
+ ) : !value || (canEdit && !edit.data) ? (
98
+ <AutomationEditorLoading />
99
+ ) : canEdit && edit.data ? (
100
+ <AutomationEditor
101
+ key={`${kind}:${automationId}`}
102
+ automation={edit.data}
103
+ summary={value}
104
+ returnPath={returnPath}
105
+ />
106
+ ) : (
107
+ <AutomationReadOnly automation={value} />
108
+ )}
109
+ </div>
110
+ </>
111
+ );
112
+ }
113
+
114
+ function AutomationReadOnly({ automation }: { automation: AutomationSummary }) {
115
+ return (
116
+ <>
117
+ <p className="mb-6 text-sm text-dashboard-text-muted">
118
+ {automation.ownedByViewer
119
+ ? "This automation has completed and cannot be edited. It will not run again."
120
+ : `Only ${automation.createdBy} can edit this automation.`}
121
+ </p>
122
+ <AutomationFormSection title="What to do" detail="The saved instruction.">
123
+ <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
124
+ {automation.instruction}
125
+ </p>
126
+ </AutomationFormSection>
127
+ <AutomationFormSection title="When to run" detail="The saved trigger.">
128
+ {automation.kind === "scheduled" ? (
129
+ <p className="m-0 text-sm">
130
+ {automation.schedule} · {automation.timezone}
131
+ </p>
132
+ ) : (
133
+ <div className="grid gap-3 text-sm">
134
+ <p className="m-0 break-words">{automation.resource}</p>
135
+ <p className="m-0 break-words">{automation.events.join(", ")}</p>
136
+ {automation.match ? (
137
+ <pre className="whitespace-pre-wrap break-words text-xs">
138
+ {JSON.stringify(automation.match, null, 2)}
139
+ </pre>
140
+ ) : null}
141
+ </div>
142
+ )}
143
+ </AutomationFormSection>
144
+ <AutomationFormSection
145
+ title="Where results go"
146
+ detail="Messages after successful work."
147
+ >
148
+ {automation.outcomes.length ? (
149
+ <ol className="m-0 grid gap-2 pl-5 text-sm">
150
+ {automation.outcomes.map((outcome, index) => (
151
+ <li key={index}>
152
+ {automationOutcomeLabel(outcome, automation.destination)}
153
+ </li>
154
+ ))}
155
+ </ol>
156
+ ) : (
157
+ <p className="m-0 text-sm">No success message</p>
158
+ )}
159
+ </AutomationFormSection>
160
+ <AutomationFormSection
161
+ title="Credentials"
162
+ detail="The accounts Junior can use."
163
+ >
164
+ <p className="m-0 text-sm">
165
+ {automation.credentialMode === "creator"
166
+ ? `Uses ${automation.ownedByViewer ? "your" : `${automation.createdBy}’s`} connected accounts.`
167
+ : "System credentials only."}
168
+ </p>
169
+ </AutomationFormSection>
170
+ </>
171
+ );
172
+ }