@checkstack/automation-frontend 0.7.1 → 0.9.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.
@@ -5,6 +5,7 @@ import {
5
5
  usePluginClient,
6
6
  accessApiRef,
7
7
  useApi,
8
+ useQueryClient,
8
9
  wrapInSuspense,
9
10
  } from "@checkstack/frontend-api";
10
11
  import {
@@ -28,6 +29,8 @@ import {
28
29
  TableHead,
29
30
  TableBody,
30
31
  TableCell,
32
+ ResponsiveTable,
33
+ MobileCardList,
31
34
  LoadingSpinner,
32
35
  QueryErrorState,
33
36
  EmptyState,
@@ -37,11 +40,22 @@ import {
37
40
  AccordionTrigger,
38
41
  AccordionContent,
39
42
  useToast,
43
+ toastError,
44
+ cn,
40
45
  } from "@checkstack/ui";
41
- import { extractErrorMessage, resolveRoute } from "@checkstack/common";
46
+ import { resolveRoute } from "@checkstack/common";
42
47
  import { formatDistanceToNow } from "date-fns";
43
48
  import { groupAutomations } from "./automation-grouping";
44
49
 
50
+ /**
51
+ * Left status-accent class for an automation's enabled/disabled state. Reuses
52
+ * the colorblind-safe triad so the stripe encodes status by position + hue,
53
+ * not by the toggle switch alone: an enabled automation reads `ok`, a disabled
54
+ * one reads muted/`unknown`. Spelled out so Tailwind's JIT keeps the classes.
55
+ */
56
+ const automationAccent = (status: Automation["status"]): string =>
57
+ status === "enabled" ? "bg-status-ok" : "bg-status-unknown/60";
58
+
45
59
  /**
46
60
  * Lists every automation the operator can see, with quick enable / disable
47
61
  * toggle and delete. The "Create" button navigates to `/automation/new`
@@ -52,9 +66,18 @@ import { groupAutomations } from "./automation-grouping";
52
66
  * the most common operation here is "find the one I broke", which a
53
67
  * single sorted list of recent activity covers without a pager UX.
54
68
  */
69
+ /**
70
+ * Cache shape of the `listAutomations` loader: the paginated wrapper around the
71
+ * automation rows. Used to type the optimistic snapshot/patch on the toggle.
72
+ */
73
+ type AutomationsQueryData = {
74
+ items: Automation[];
75
+ };
76
+
55
77
  const AutomationListContent: React.FC = () => {
56
78
  const client = usePluginClient(AutomationApi);
57
79
  const accessApi = useApi(accessApiRef);
80
+ const queryClient = useQueryClient();
58
81
  const toast = useToast();
59
82
  const navigate = useNavigate();
60
83
 
@@ -68,19 +91,64 @@ const AutomationListContent: React.FC = () => {
68
91
  >("all");
69
92
  const [deleteId, setDeleteId] = React.useState<string | undefined>();
70
93
 
71
- const query = client.listAutomations.useQuery({
72
- limit: 100,
73
- offset: 0,
74
- ...(statusFilter === "all" ? {} : { status: statusFilter }),
75
- });
94
+ // The loader input depends on the status filter, so the cache key changes
95
+ // when the filter changes. Build the input once and derive the exact oRPC
96
+ // query key from it so the optimistic patch addresses the same cache entry.
97
+ // See `docs/.../frontend/optimistic-updates.md` for the query-key contract.
98
+ const listInput = React.useMemo(
99
+ () => ({
100
+ limit: 100,
101
+ offset: 0,
102
+ ...(statusFilter === "all" ? {} : { status: statusFilter }),
103
+ }),
104
+ [statusFilter],
105
+ );
106
+
107
+ const automationsQueryKey = React.useMemo(
108
+ () =>
109
+ [
110
+ ["automation", "listAutomations"],
111
+ { input: listInput, type: "query" },
112
+ ] as const,
113
+ [listInput],
114
+ );
115
+
116
+ const query = client.listAutomations.useQuery(listInput);
76
117
 
77
- const toggleMutation = client.toggleAutomation.useMutation({
78
- onSuccess: (data) => {
79
- toast.success(
80
- `${data.name} ${data.status === "enabled" ? "enabled" : "disabled"}`,
81
- );
118
+ // Toggle is a cheap, low-risk per-row flip — apply the optimistic pattern so
119
+ // the switch flips on click instead of after the round-trip:
120
+ // 1. onMutate cancels in-flight refetches, snapshots, flips the row status.
121
+ // 2. onError rolls back from the snapshot, then surfaces a toast.
122
+ // 3. onSettled invalidates so server truth settles in either branch.
123
+ // 4. No success toast — the visible switch flip IS the feedback.
124
+ const toggleMutation = client.toggleAutomation.useMutation<{
125
+ previous: AutomationsQueryData | undefined;
126
+ }>({
127
+ onMutate: async ({ id, enabled }) => {
128
+ await queryClient.cancelQueries({ queryKey: automationsQueryKey });
129
+ const previous =
130
+ queryClient.getQueryData<AutomationsQueryData>(automationsQueryKey);
131
+ if (previous) {
132
+ queryClient.setQueryData<AutomationsQueryData>(automationsQueryKey, {
133
+ ...previous,
134
+ items: previous.items.map((automation) =>
135
+ automation.id === id
136
+ ? { ...automation, status: enabled ? "enabled" : "disabled" }
137
+ : automation,
138
+ ),
139
+ });
140
+ }
141
+ return { previous };
142
+ },
143
+ onError: (error, _vars, ctx) => {
144
+ if (ctx?.previous) {
145
+ queryClient.setQueryData(automationsQueryKey, ctx.previous);
146
+ }
147
+ toastError(toast, "Failed to toggle automation", error);
148
+ },
149
+ onSettled: () => {
150
+ void queryClient.invalidateQueries({ queryKey: automationsQueryKey });
82
151
  },
83
- onError: (error) => toast.error(extractErrorMessage(error)),
84
152
  });
85
153
 
86
154
  const deleteMutation = client.deleteAutomation.useMutation({
@@ -88,7 +156,7 @@ const AutomationListContent: React.FC = () => {
88
156
  toast.success("Automation deleted");
89
157
  setDeleteId(undefined);
90
158
  },
91
- onError: (error) => toast.error(extractErrorMessage(error)),
159
+ onError: (error) => toastError(toast, "Failed to delete automation", error),
92
160
  });
93
161
 
94
162
  const items = query.data?.items;
@@ -106,7 +174,7 @@ const AutomationListContent: React.FC = () => {
106
174
  const renderRow = (automation: Automation) => (
107
175
  <TableRow
108
176
  key={automation.id}
109
- className="cursor-pointer hover:bg-accent/40"
177
+ className="relative cursor-pointer transition-colors hover:bg-surface-inset"
110
178
  onClick={() =>
111
179
  navigate(
112
180
  resolveRoute(automationRoutes.routes.edit, {
@@ -115,7 +183,18 @@ const AutomationListContent: React.FC = () => {
115
183
  )
116
184
  }
117
185
  >
118
- <TableCell onClick={(e) => e.stopPropagation()}>
186
+ <TableCell
187
+ onClick={(e) => e.stopPropagation()}
188
+ className="relative pl-4"
189
+ >
190
+ {/* Status accent: enabled/disabled by position + hue, not toggle alone. */}
191
+ <span
192
+ className={cn(
193
+ "absolute inset-y-0 left-0 w-1",
194
+ automationAccent(automation.status),
195
+ )}
196
+ aria-hidden
197
+ />
119
198
  {canManage ? (
120
199
  <Toggle
121
200
  checked={automation.status === "enabled"}
@@ -142,8 +221,10 @@ const AutomationListContent: React.FC = () => {
142
221
  )}
143
222
  </TableCell>
144
223
  <TableCell>
145
- <div className="flex flex-col">
146
- <span className="font-medium">{automation.name}</span>
224
+ <div className="flex flex-col gap-0.5">
225
+ <span className="font-semibold text-foreground">
226
+ {automation.name}
227
+ </span>
147
228
  {automation.description && (
148
229
  <span className="text-xs text-muted-foreground">
149
230
  {automation.description}
@@ -157,13 +238,16 @@ const AutomationListContent: React.FC = () => {
157
238
  <Badge
158
239
  key={`${trigger.event}-${index}`}
159
240
  variant="outline"
160
- className="text-[10px] font-mono"
241
+ className="font-mono text-[10px] font-normal text-muted-foreground"
161
242
  >
162
243
  {trigger.event}
163
244
  </Badge>
164
245
  ))}
165
246
  {automation.definition.triggers.length > 3 && (
166
- <Badge variant="outline" className="text-[10px]">
247
+ <Badge
248
+ variant="outline"
249
+ className="text-[10px] font-normal text-muted-foreground"
250
+ >
167
251
  +{automation.definition.triggers.length - 3}
168
252
  </Badge>
169
253
  )}
@@ -208,6 +292,118 @@ const AutomationListContent: React.FC = () => {
208
292
  </TableRow>
209
293
  );
210
294
 
295
+ const renderMobileCard = (automation: Automation) => (
296
+ <div
297
+ key={automation.id}
298
+ className="group relative cursor-pointer overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-xl"
299
+ onClick={() =>
300
+ navigate(
301
+ resolveRoute(automationRoutes.routes.edit, {
302
+ automationId: automation.id,
303
+ }),
304
+ )
305
+ }
306
+ >
307
+ {/* Status accent: enabled/disabled by position + hue, not toggle alone. */}
308
+ <span
309
+ className={cn(
310
+ "absolute inset-y-0 left-0 w-1",
311
+ automationAccent(automation.status),
312
+ )}
313
+ aria-hidden
314
+ />
315
+ <div className="flex items-start justify-between gap-2 pl-2">
316
+ <div className="flex min-w-0 flex-col gap-0.5">
317
+ <span className="truncate text-sm font-semibold text-foreground">
318
+ {automation.name}
319
+ </span>
320
+ {automation.description && (
321
+ <span className="truncate text-xs text-muted-foreground">
322
+ {automation.description}
323
+ </span>
324
+ )}
325
+ </div>
326
+ <div onClick={(e) => e.stopPropagation()}>
327
+ {canManage ? (
328
+ <Toggle
329
+ checked={automation.status === "enabled"}
330
+ onCheckedChange={(enabled) =>
331
+ toggleMutation.mutate({
332
+ id: automation.id,
333
+ enabled,
334
+ })
335
+ }
336
+ aria-label={
337
+ automation.status === "enabled"
338
+ ? "Disable automation"
339
+ : "Enable automation"
340
+ }
341
+ />
342
+ ) : (
343
+ <Badge
344
+ variant={automation.status === "enabled" ? "success" : "outline"}
345
+ >
346
+ {automation.status}
347
+ </Badge>
348
+ )}
349
+ </div>
350
+ </div>
351
+ <div className="mt-2 flex flex-wrap items-center gap-1 pl-2">
352
+ {automation.definition.triggers.slice(0, 3).map((trigger, index) => (
353
+ <Badge
354
+ key={`${trigger.event}-${index}`}
355
+ variant="outline"
356
+ className="font-mono text-[10px] font-normal text-muted-foreground"
357
+ >
358
+ {trigger.event}
359
+ </Badge>
360
+ ))}
361
+ {automation.definition.triggers.length > 3 && (
362
+ <Badge
363
+ variant="outline"
364
+ className="text-[10px] font-normal text-muted-foreground"
365
+ >
366
+ +{automation.definition.triggers.length - 3}
367
+ </Badge>
368
+ )}
369
+ <Badge variant="secondary" className="text-[10px]">
370
+ {automation.definition.mode}
371
+ </Badge>
372
+ </div>
373
+ <div className="mt-1 pl-2 text-xs text-muted-foreground">
374
+ Updated{" "}
375
+ {formatDistanceToNow(new Date(automation.updatedAt), {
376
+ addSuffix: true,
377
+ })}
378
+ </div>
379
+ <div
380
+ onClick={(e) => e.stopPropagation()}
381
+ className="mt-3 flex justify-end gap-1 pl-2"
382
+ >
383
+ <Link
384
+ to={resolveRoute(automationRoutes.routes.runs, {
385
+ automationId: automation.id,
386
+ })}
387
+ >
388
+ <Button variant="ghost" size="sm">
389
+ Runs
390
+ </Button>
391
+ </Link>
392
+ {canManage && (
393
+ <Button
394
+ variant="ghost"
395
+ size="icon"
396
+ className="h-8 w-8 text-destructive hover:bg-destructive/10"
397
+ onClick={() => setDeleteId(automation.id)}
398
+ aria-label="Delete automation"
399
+ >
400
+ <Trash2 className="h-4 w-4" />
401
+ </Button>
402
+ )}
403
+ </div>
404
+ </div>
405
+ );
406
+
211
407
  return (
212
408
  <PageLayout
213
409
  title="Automations"
@@ -305,25 +501,32 @@ const AutomationListContent: React.FC = () => {
305
501
  </span>
306
502
  </AccordionTrigger>
307
503
  <AccordionContent className="px-0 pb-0">
308
- <Table>
309
- <TableHeader>
310
- <TableRow>
311
- <TableHead className="w-8" />
312
- <TableHead>Name</TableHead>
313
- <TableHead>Triggers</TableHead>
314
- <TableHead>Mode</TableHead>
315
- <TableHead>Updated</TableHead>
316
- <TableHead className="w-24 text-right">
317
- Actions
318
- </TableHead>
319
- </TableRow>
320
- </TableHeader>
321
- <TableBody>
322
- {group.items.map((automation) =>
323
- renderRow(automation),
324
- )}
325
- </TableBody>
326
- </Table>
504
+ <ResponsiveTable>
505
+ <Table>
506
+ <TableHeader>
507
+ <TableRow>
508
+ <TableHead className="w-8" />
509
+ <TableHead>Name</TableHead>
510
+ <TableHead>Triggers</TableHead>
511
+ <TableHead>Mode</TableHead>
512
+ <TableHead>Updated</TableHead>
513
+ <TableHead className="w-24 text-right">
514
+ Actions
515
+ </TableHead>
516
+ </TableRow>
517
+ </TableHeader>
518
+ <TableBody>
519
+ {group.items.map((automation) =>
520
+ renderRow(automation),
521
+ )}
522
+ </TableBody>
523
+ </Table>
524
+ </ResponsiveTable>
525
+ <MobileCardList className="p-2">
526
+ {group.items.map((automation) =>
527
+ renderMobileCard(automation),
528
+ )}
529
+ </MobileCardList>
327
530
  </AccordionContent>
328
531
  </AccordionItem>
329
532
  ))}
@@ -15,10 +15,6 @@ import {
15
15
  } from "@checkstack/automation-common";
16
16
  import {
17
17
  PageLayout,
18
- Card,
19
- CardHeader,
20
- CardTitle,
21
- CardContent,
22
18
  Button,
23
19
  Badge,
24
20
  DynamicIcon,
@@ -75,25 +71,25 @@ const AutomationTemplatePickerContent: React.FC = () => {
75
71
  allowed={canManage}
76
72
  >
77
73
  <div className="space-y-6">
78
- <Card>
79
- <CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
80
- <div className="flex items-center gap-3">
81
- <div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted">
82
- <FilePlus2 className="h-5 w-5 text-muted-foreground" />
74
+ <div className="relative flex flex-col gap-3 overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] sm:flex-row sm:items-center sm:justify-between">
75
+ <div className="flex items-center gap-3">
76
+ <div className="flex h-10 w-10 items-center justify-center rounded-md bg-surface-inset">
77
+ <FilePlus2 className="h-5 w-5 text-muted-foreground" />
78
+ </div>
79
+ <div>
80
+ <div className="font-semibold text-foreground">
81
+ Blank automation
83
82
  </div>
84
- <div>
85
- <div className="font-medium">Blank automation</div>
86
- <div className="text-sm text-muted-foreground">
87
- Start with an empty editor and add triggers and actions yourself.
88
- </div>
83
+ <div className="text-sm text-muted-foreground">
84
+ Start with an empty editor and add triggers and actions yourself.
89
85
  </div>
90
86
  </div>
91
- <Button onClick={startBlank}>
92
- Start blank
93
- <ArrowRight className="ml-1 h-4 w-4" />
94
- </Button>
95
- </CardContent>
96
- </Card>
87
+ </div>
88
+ <Button onClick={startBlank}>
89
+ Start blank
90
+ <ArrowRight className="ml-1 h-4 w-4" />
91
+ </Button>
92
+ </div>
97
93
 
98
94
  {query.isLoading ? (
99
95
  <div className="p-6">
@@ -109,20 +105,20 @@ const AutomationTemplatePickerContent: React.FC = () => {
109
105
  </h2>
110
106
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
111
107
  {items.map((template) => (
112
- <Card
108
+ <div
113
109
  key={template.id}
114
- className="flex h-full cursor-pointer flex-col transition-colors hover:border-primary"
110
+ className="group relative flex h-full cursor-pointer flex-col overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-xl"
115
111
  onClick={() => openTemplate(template)}
116
112
  >
117
- <CardHeader className="flex-row items-start gap-3 space-y-0">
113
+ <div className="flex items-start gap-3">
118
114
  <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
119
115
  <DynamicIcon name={template.icon} className="h-5 w-5" />
120
116
  </div>
121
- <CardTitle className="text-base leading-snug">
117
+ <h3 className="text-base font-semibold leading-snug text-foreground">
122
118
  {template.name}
123
- </CardTitle>
124
- </CardHeader>
125
- <CardContent className="flex flex-1 flex-col justify-between gap-3">
119
+ </h3>
120
+ </div>
121
+ <div className="mt-3 flex flex-1 flex-col justify-between gap-3">
126
122
  <p className="text-sm text-muted-foreground">
127
123
  {template.description}
128
124
  </p>
@@ -130,13 +126,13 @@ const AutomationTemplatePickerContent: React.FC = () => {
130
126
  <Badge variant="secondary" className="font-normal">
131
127
  {template.ownerPluginId}
132
128
  </Badge>
133
- <span className="flex items-center text-sm font-medium text-primary">
129
+ <span className="flex items-center text-sm font-medium text-primary transition-colors group-hover:text-primary">
134
130
  Use template
135
- <ArrowRight className="ml-1 h-4 w-4" />
131
+ <ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
136
132
  </span>
137
133
  </div>
138
- </CardContent>
139
- </Card>
134
+ </div>
135
+ </div>
140
136
  ))}
141
137
  </div>
142
138
  </section>
@@ -37,14 +37,27 @@ import {
37
37
  Alert,
38
38
  AlertTitle,
39
39
  AlertDescription,
40
+ cn,
40
41
  } from "@checkstack/ui";
41
42
  import { resolveRoute } from "@checkstack/common";
42
43
  import { formatDistanceToNow } from "date-fns";
44
+ import {
45
+ RunStatusPill,
46
+ RUN_STATUS_TONE,
47
+ RUN_TONE_STYLES,
48
+ } from "./run-status-pill";
49
+ import { formatDuration } from "./run-duration";
43
50
 
44
51
  const noop = (): void => {
45
52
  return;
46
53
  };
47
54
 
55
+ /**
56
+ * Poll interval for a live (`running`/`waiting`) run. Polling stops the moment
57
+ * the run reaches a terminal status, so a finished run makes no further calls.
58
+ */
59
+ const RUN_POLL_INTERVAL_MS = 2000;
60
+
48
61
  const STEP_STATUS_ICON: Record<StepStatus, React.ComponentType<{ className?: string }>> = {
49
62
  pending: Clock,
50
63
  running: CircleDot,
@@ -54,26 +67,29 @@ const STEP_STATUS_ICON: Record<StepStatus, React.ComponentType<{ className?: str
54
67
  waiting: Hourglass,
55
68
  };
56
69
 
70
+ // Step status icons carry the signal via shape + the status triad hue (never
71
+ // hue alone): terminal outcomes map to the colorblind-safe status colors, while
72
+ // in-flight/neutral lifecycle states stay muted/primary.
57
73
  const STEP_STATUS_COLOR: Record<StepStatus, string> = {
58
74
  pending: "text-muted-foreground",
59
75
  running: "text-primary",
60
- success: "text-emerald-500",
61
- failed: "text-destructive",
76
+ success: "text-status-ok",
77
+ failed: "text-status-down",
62
78
  skipped: "text-muted-foreground",
63
- waiting: "text-amber-500",
79
+ waiting: "text-status-warn",
64
80
  };
65
81
 
66
- const RUN_STATUS_VARIANT: Record<
67
- RunStatus,
68
- "default" | "secondary" | "outline" | "destructive" | "success" | "warning"
69
- > = {
70
- pending: "outline",
71
- running: "secondary",
72
- waiting: "warning",
73
- success: "success",
74
- failed: "destructive",
75
- cancelled: "outline",
76
- skipped: "outline",
82
+ // Left status-accent for each step, mirroring STEP_STATUS_COLOR's tone intent
83
+ // so a failed/waiting step reads down the timeline's left edge by position +
84
+ // hue, not the icon alone. Neutral lifecycle states stay muted. Spelled out as
85
+ // full literal strings so Tailwind's JIT keeps them.
86
+ const STEP_STATUS_ACCENT: Record<StepStatus, string> = {
87
+ pending: "bg-muted-foreground/40",
88
+ running: "bg-primary",
89
+ success: "bg-status-ok",
90
+ failed: "bg-status-down",
91
+ skipped: "bg-muted-foreground/40",
92
+ waiting: "bg-status-warn",
77
93
  };
78
94
 
79
95
  /**
@@ -103,9 +119,24 @@ const RunDetailContent: React.FC = () => {
103
119
 
104
120
  const query = client.getRun.useQuery(
105
121
  { id: runId ?? "" },
106
- { enabled: Boolean(runId) },
122
+ {
123
+ enabled: Boolean(runId),
124
+ // Live runs only: poll every 2s while the run is `running`/`waiting` so a
125
+ // user watching an execution sees steps progress without a manual reload.
126
+ // Returning `false` on a terminal status stops the polling entirely.
127
+ refetchInterval: (query) => {
128
+ const status = query.state.data?.run.status;
129
+ return status === "running" || status === "waiting"
130
+ ? RUN_POLL_INTERVAL_MS
131
+ : false;
132
+ },
133
+ },
107
134
  );
108
135
 
136
+ const isLive =
137
+ query.data?.run.status === "running" ||
138
+ query.data?.run.status === "waiting";
139
+
109
140
  const cancelMutation = client.cancelRun.useMutation();
110
141
 
111
142
  if (!automationId || !runId) {
@@ -133,6 +164,12 @@ const RunDetailContent: React.FC = () => {
133
164
  allowed={allowed}
134
165
  actions={
135
166
  <div className="flex items-center gap-2">
167
+ {isLive && (
168
+ <span className="flex items-center gap-1.5 text-xs font-medium text-primary">
169
+ <CircleDot className="h-3.5 w-3.5" />
170
+ Live
171
+ </span>
172
+ )}
136
173
  <Link
137
174
  to={resolveRoute(automationRoutes.routes.runs, { automationId })}
138
175
  >
@@ -219,7 +256,7 @@ const RunDetailContent: React.FC = () => {
219
256
  query.data.artifacts.map((artifact) => (
220
257
  <details
221
258
  key={artifact.id}
222
- className="rounded border border-border bg-card"
259
+ className="rounded border border-border bg-surface-inset"
223
260
  >
224
261
  <summary className="flex cursor-pointer items-center justify-between px-2 py-1 text-xs">
225
262
  <Badge variant="outline" className="font-mono">
@@ -248,13 +285,27 @@ const RunDetailContent: React.FC = () => {
248
285
 
249
286
  const RunHeader: React.FC<{
250
287
  run: { status: RunStatus; startedAt: Date; finishedAt?: Date };
251
- }> = ({ run }) => (
252
- <Card>
253
- <CardContent className="flex flex-wrap items-center gap-4 p-4">
254
- <Badge variant={RUN_STATUS_VARIANT[run.status]} className="capitalize">
255
- {run.status}
256
- </Badge>
257
- <div className="flex flex-col text-xs text-muted-foreground">
288
+ }> = ({ run }) => {
289
+ const accent = RUN_TONE_STYLES[RUN_STATUS_TONE[run.status]].accent;
290
+ const duration = formatDuration(run.startedAt, run.finishedAt);
291
+
292
+ return (
293
+ <div className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)]">
294
+ {/* Status accent stripe, matching the run-status pill's tone. */}
295
+ <span
296
+ className={cn("absolute inset-y-0 left-0 w-1", accent)}
297
+ aria-hidden
298
+ />
299
+ <div className="flex items-start justify-between gap-4 pl-2">
300
+ <div>
301
+ <p className="text-3xl font-bold leading-none tabular-nums text-foreground">
302
+ {duration}
303
+ </p>
304
+ <p className="mt-1 text-xs text-muted-foreground">duration</p>
305
+ </div>
306
+ <RunStatusPill status={run.status} />
307
+ </div>
308
+ <div className="mt-4 flex flex-col gap-0.5 pl-2 text-xs text-muted-foreground">
258
309
  <span>
259
310
  Started{" "}
260
311
  {formatDistanceToNow(new Date(run.startedAt), { addSuffix: true })}
@@ -266,9 +317,9 @@ const RunHeader: React.FC<{
266
317
  </span>
267
318
  )}
268
319
  </div>
269
- </CardContent>
270
- </Card>
271
- );
320
+ </div>
321
+ );
322
+ };
272
323
 
273
324
  const StepRow: React.FC<{
274
325
  step: {
@@ -285,11 +336,17 @@ const StepRow: React.FC<{
285
336
  }> = ({ step }) => {
286
337
  const Icon = STEP_STATUS_ICON[step.status];
287
338
  const colorClass = STEP_STATUS_COLOR[step.status];
339
+ const accentClass = STEP_STATUS_ACCENT[step.status];
288
340
 
289
341
  return (
290
- <div className="rounded border border-border bg-card">
291
- <div className="flex items-start gap-2 p-2">
292
- <Icon className={`mt-0.5 h-4 w-4 shrink-0 ${colorClass}`} />
342
+ <div className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface">
343
+ {/* Step status accent: failed/waiting read down the left edge by hue. */}
344
+ <span
345
+ className={cn("absolute inset-y-0 left-0 w-1", accentClass)}
346
+ aria-hidden
347
+ />
348
+ <div className="flex items-start gap-2 p-2 pl-4">
349
+ <Icon className={cn("mt-0.5 h-4 w-4 shrink-0", colorClass)} />
293
350
  <div className="flex-1 min-w-0">
294
351
  <div className="flex flex-wrap items-center gap-2">
295
352
  <code className="truncate font-mono text-xs">
@@ -317,11 +374,11 @@ const StepRow: React.FC<{
317
374
  </div>
318
375
  </div>
319
376
  {step.resultPayload && Object.keys(step.resultPayload).length > 0 && (
320
- <details className="border-t border-border">
321
- <summary className="cursor-pointer px-2 py-1 text-[10px] text-muted-foreground">
377
+ <details className="border-t border-border/70">
378
+ <summary className="cursor-pointer px-2 py-1 pl-4 text-[10px] text-muted-foreground">
322
379
  Result payload
323
380
  </summary>
324
- <pre className="overflow-x-auto p-2 text-xs">
381
+ <pre className="overflow-x-auto p-2 pl-4 text-xs">
325
382
  {JSON.stringify(step.resultPayload, null, 2)}
326
383
  </pre>
327
384
  </details>