@agent-native/dispatch 0.16.7 → 0.17.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 (35) hide show
  1. package/dist/actions/provider-api-register.d.ts +6 -6
  2. package/dist/components/automation-run-history-dialog.d.ts +9 -0
  3. package/dist/components/automation-run-history-dialog.d.ts.map +1 -0
  4. package/dist/components/automation-run-history-dialog.js +61 -0
  5. package/dist/components/automation-run-history-dialog.js.map +1 -0
  6. package/dist/components/connected-app-card.d.ts +5 -0
  7. package/dist/components/connected-app-card.d.ts.map +1 -0
  8. package/dist/components/connected-app-card.js +12 -0
  9. package/dist/components/connected-app-card.js.map +1 -0
  10. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  11. package/dist/components/dispatch-control-plane.js +12 -4
  12. package/dist/components/dispatch-control-plane.js.map +1 -1
  13. package/dist/lib/automation-display.d.ts +2 -0
  14. package/dist/lib/automation-display.d.ts.map +1 -1
  15. package/dist/lib/automation-display.js +17 -3
  16. package/dist/lib/automation-display.js.map +1 -1
  17. package/dist/lib/automations.d.ts +3 -0
  18. package/dist/lib/automations.d.ts.map +1 -1
  19. package/dist/lib/automations.js.map +1 -1
  20. package/dist/routes/pages/apps.d.ts.map +1 -1
  21. package/dist/routes/pages/apps.js +3 -10
  22. package/dist/routes/pages/apps.js.map +1 -1
  23. package/dist/routes/pages/automations.d.ts.map +1 -1
  24. package/dist/routes/pages/automations.js +19 -13
  25. package/dist/routes/pages/automations.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/components/automation-run-history-dialog.tsx +242 -0
  28. package/src/components/connected-app-card.tsx +42 -0
  29. package/src/components/dispatch-control-plane.spec.tsx +79 -2
  30. package/src/components/dispatch-control-plane.tsx +38 -3
  31. package/src/lib/automation-display.spec.ts +40 -0
  32. package/src/lib/automation-display.ts +23 -3
  33. package/src/lib/automations.ts +3 -0
  34. package/src/routes/pages/apps.tsx +2 -39
  35. package/src/routes/pages/automations.tsx +41 -10
@@ -0,0 +1,42 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
3
+ import { IconArrowUpRight } from "@tabler/icons-react";
4
+
5
+ import type { ConnectedAppSummary } from "../lib/other-apps";
6
+
7
+ export function ConnectedAppCard({ app }: { app: ConnectedAppSummary }) {
8
+ const t = useT();
9
+
10
+ return (
11
+ <a
12
+ href={withBuilderUtmTrackingParams(app.url, {
13
+ campaign: "product",
14
+ content: "dispatch_app",
15
+ })}
16
+ target="_blank"
17
+ rel="noopener noreferrer"
18
+ className="group flex min-h-[116px] items-start gap-3 rounded-xl bg-card/40 p-4 transition-[background-color] hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
19
+ >
20
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
21
+ {app.name.charAt(0).toUpperCase()}
22
+ </span>
23
+ <span className="min-w-0 flex-1">
24
+ <span className="flex items-start justify-between gap-3">
25
+ <span className="truncate text-sm font-semibold text-foreground">
26
+ {app.name}
27
+ </span>
28
+ <IconArrowUpRight
29
+ size={15}
30
+ className="shrink-0 text-muted-foreground transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
31
+ />
32
+ </span>
33
+ <span className="mt-2 line-clamp-2 block text-[13px] leading-5 text-muted-foreground">
34
+ {app.description || app.url}
35
+ </span>
36
+ <span className="mt-3 block text-xs font-medium text-foreground">
37
+ {t("dispatch.pages.openApp")}
38
+ </span>
39
+ </span>
40
+ </a>
41
+ );
42
+ }
@@ -10,6 +10,8 @@ import { TooltipProvider } from "./ui/tooltip";
10
10
  const clientState = vi.hoisted(() => ({
11
11
  navigateWithTransition: vi.fn(),
12
12
  promptComposerProps: null as Record<string, unknown> | null,
13
+ workspaceApps: [] as Array<Record<string, unknown>>,
14
+ connectedApps: [] as Array<Record<string, unknown>>,
13
15
  useChatModels: vi.fn(() => ({
14
16
  availableModels: [],
15
17
  defaultModel: "auto",
@@ -50,12 +52,17 @@ vi.mock("@agent-native/core/client/composer", () => ({
50
52
  }));
51
53
 
52
54
  vi.mock("@agent-native/core/client/hooks", () => ({
53
- useActionQuery: () => ({
54
- data: [],
55
+ useActionQuery: (name: string) => ({
56
+ data:
57
+ name === "list-connected-agents"
58
+ ? clientState.connectedApps
59
+ : clientState.workspaceApps,
55
60
  isLoading: false,
56
61
  isError: false,
62
+ error: null,
57
63
  refetch: vi.fn(),
58
64
  }),
65
+ useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
59
66
  }));
60
67
 
61
68
  vi.mock("@agent-native/core/client/host", () => ({
@@ -65,6 +72,7 @@ vi.mock("@agent-native/core/client/host", () => ({
65
72
  vi.mock("@agent-native/core/client/i18n", () => ({
66
73
  useT: () => (key: string, values?: { defaultValue?: string }) =>
67
74
  values?.defaultValue ?? key,
75
+ useFormatters: () => ({ formatDate: (value: string) => value }),
68
76
  }));
69
77
 
70
78
  vi.mock("./create-app-popover", () => ({
@@ -79,6 +87,8 @@ describe("DispatchControlPlane", () => {
79
87
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
80
88
  clientState.navigateWithTransition.mockReset();
81
89
  clientState.promptComposerProps = null;
90
+ clientState.workspaceApps = [];
91
+ clientState.connectedApps = [];
82
92
  clientState.useChatModels.mockClear();
83
93
  container = document.createElement("div");
84
94
  document.body.appendChild(container);
@@ -140,4 +150,71 @@ describe("DispatchControlPlane", () => {
140
150
  }),
141
151
  );
142
152
  });
153
+
154
+ it("shows mounted and connected apps together without duplicates", async () => {
155
+ clientState.workspaceApps = [
156
+ {
157
+ id: "onboarding",
158
+ name: "Onboarding",
159
+ path: "/onboarding",
160
+ status: "ready",
161
+ isDispatch: false,
162
+ },
163
+ {
164
+ id: "dispatch",
165
+ name: "Dispatch",
166
+ path: "/dispatch",
167
+ status: "ready",
168
+ isDispatch: true,
169
+ },
170
+ {
171
+ id: "archived-app",
172
+ name: "Archived app",
173
+ path: "/archived-app",
174
+ status: "ready",
175
+ isDispatch: false,
176
+ archived: true,
177
+ },
178
+ ];
179
+ clientState.connectedApps = [
180
+ {
181
+ id: "mail",
182
+ name: "Mail",
183
+ description: "Email client",
184
+ url: "https://mail.agent-native.com",
185
+ },
186
+ {
187
+ id: "clips",
188
+ name: "Clips",
189
+ description: "Record and share",
190
+ url: "https://clips.agent-native.com",
191
+ },
192
+ {
193
+ id: "onboarding",
194
+ name: "Duplicate onboarding",
195
+ url: "https://duplicate.example.com",
196
+ },
197
+ ];
198
+
199
+ await act(async () => {
200
+ root.render(
201
+ <MemoryRouter initialEntries={["/overview"]}>
202
+ <TooltipProvider>
203
+ <DispatchControlPlane />
204
+ </TooltipProvider>
205
+ </MemoryRouter>,
206
+ );
207
+ });
208
+
209
+ expect(container.textContent).toContain("Onboarding");
210
+ expect(container.textContent).toContain("Mail");
211
+ expect(container.textContent).toContain("Clips");
212
+ expect(container.textContent).not.toContain("Archived app");
213
+ expect(container.textContent).not.toContain("Duplicate onboarding");
214
+ expect(
215
+ Array.from(container.querySelectorAll("a")).filter((anchor) =>
216
+ anchor.getAttribute("href")?.includes("onboarding"),
217
+ ),
218
+ ).toHaveLength(1);
219
+ });
143
220
  });
@@ -15,9 +15,11 @@ import type { ReactNode } from "react";
15
15
  import { useState } from "react";
16
16
  import { Link, useNavigate } from "react-router";
17
17
 
18
+ import { filterOtherApps, type ConnectedAppSummary } from "../lib/other-apps";
18
19
  import { submitOverviewPrompt } from "../lib/overview-chat";
19
20
  import type { WorkspaceAppSummary } from "../lib/workspace-apps";
20
21
  import { ActionQueryError } from "./action-query-error";
22
+ import { ConnectedAppCard } from "./connected-app-card";
21
23
  import { CreateAppPopover } from "./create-app-popover";
22
24
  import { useSetPageTitle } from "./layout/HeaderActions";
23
25
  import { Button } from "./ui/button";
@@ -141,17 +143,29 @@ function CommandPanel() {
141
143
  function AppsPanel({
142
144
  apps,
143
145
  isLoading,
146
+ otherApps,
147
+ otherAppsError,
148
+ otherAppsLoading,
149
+ onRetryOtherApps,
144
150
  }: {
145
151
  apps: WorkspaceAppSummary[];
146
152
  isLoading: boolean;
153
+ otherApps: ConnectedAppSummary[];
154
+ otherAppsError?: Error | null;
155
+ otherAppsLoading: boolean;
156
+ onRetryOtherApps: () => void;
147
157
  }) {
148
158
  const t = useT();
149
159
  const [showPending, setShowPending] = useState(false);
150
160
  const visibleApps = apps.filter((app) => !app.isDispatch && !app.archived);
151
161
  const activeApps = visibleApps.filter((app) => app.status !== "pending");
152
162
  const pendingApps = visibleApps.filter((app) => app.status === "pending");
163
+ const hasActiveApps = activeApps.length > 0 || otherApps.length > 0;
153
164
  const showSkeletons =
154
- isLoading && activeApps.length === 0 && pendingApps.length === 0;
165
+ (isLoading || otherAppsLoading) &&
166
+ activeApps.length === 0 &&
167
+ otherApps.length === 0 &&
168
+ pendingApps.length === 0;
155
169
 
156
170
  return (
157
171
  <section className="flex flex-col gap-3">
@@ -176,15 +190,21 @@ function AppsPanel({
176
190
  </div>
177
191
  ))}
178
192
  </div>
179
- ) : activeApps.length > 0 ? (
193
+ ) : hasActiveApps ? (
180
194
  <div className="grid gap-3 md:grid-cols-2">
181
195
  {activeApps.map((app) => (
182
196
  <WorkspaceAppCard key={app.id} app={app} className="min-h-32" />
183
197
  ))}
198
+ {otherApps.map((app) => (
199
+ <ConnectedAppCard key={app.id} app={app} />
200
+ ))}
184
201
  </div>
185
202
  ) : (
186
203
  <CreateAppPopover />
187
204
  )}
205
+ {otherAppsError ? (
206
+ <ActionQueryError error={otherAppsError} onRetry={onRetryOtherApps} />
207
+ ) : null}
188
208
  {pendingApps.length > 0 ? (
189
209
  <Collapsible open={showPending} onOpenChange={setShowPending}>
190
210
  <div className="space-y-3 border-t pt-3">
@@ -246,7 +266,15 @@ export function DispatchControlPlane() {
246
266
  "list-workspace-apps",
247
267
  { includeAgentCards: false, includeArchived: true },
248
268
  );
269
+ const connectedAppsQuery = useActionQuery<ConnectedAppSummary[]>(
270
+ "list-connected-agents",
271
+ {},
272
+ );
249
273
  const { data: workspaceApps = [], isLoading: appsLoading } = appsQuery;
274
+ const otherApps = filterOtherApps(
275
+ connectedAppsQuery.data ?? [],
276
+ workspaceApps ?? [],
277
+ );
250
278
 
251
279
  return (
252
280
  <div className="flex flex-col gap-8">
@@ -257,7 +285,14 @@ export function DispatchControlPlane() {
257
285
  onRetry={() => void appsQuery.refetch()}
258
286
  />
259
287
  ) : (
260
- <AppsPanel apps={workspaceApps ?? []} isLoading={appsLoading} />
288
+ <AppsPanel
289
+ apps={workspaceApps ?? []}
290
+ isLoading={appsLoading}
291
+ otherApps={otherApps}
292
+ otherAppsError={connectedAppsQuery.error}
293
+ otherAppsLoading={connectedAppsQuery.isLoading}
294
+ onRetryOtherApps={() => void connectedAppsQuery.refetch()}
295
+ />
261
296
  )}
262
297
  </div>
263
298
  );
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  automationIdentity,
5
5
  automationNextRun,
6
+ automationScopeLabel,
6
7
  automationStatus,
7
8
  automationTarget,
8
9
  automationTroubleshootPath,
@@ -77,6 +78,45 @@ describe("automation-display", () => {
77
78
  });
78
79
  });
79
80
 
81
+ it("labels blocked scheduler checks separately from execution failures", () => {
82
+ const blocked = item({
83
+ id: "blocked",
84
+ name: "Blocked",
85
+ path: "jobs/blocked.md",
86
+ owner: "user@example.com",
87
+ lastStatus: "skipped",
88
+ lastError: "Condition was false",
89
+ });
90
+
91
+ expect(automationStatus(blocked)).toEqual({
92
+ label: "Blocked",
93
+ tone: "warning",
94
+ });
95
+ });
96
+
97
+ it("identifies personal and organization ownership in the UI", () => {
98
+ expect(
99
+ automationScopeLabel(
100
+ item({
101
+ id: "personal",
102
+ name: "Personal",
103
+ path: "jobs/personal.md",
104
+ owner: "user@example.com",
105
+ }),
106
+ ),
107
+ ).toBe("Personal");
108
+ expect(
109
+ automationScopeLabel(
110
+ item({
111
+ id: "organization",
112
+ name: "Organization",
113
+ path: "jobs/organization.md",
114
+ owner: "__organization__:org-1",
115
+ }),
116
+ ),
117
+ ).toBe("Organization");
118
+ });
119
+
80
120
  it("sorts enabled errors ahead of healthy runs", () => {
81
121
  const healthy = item({
82
122
  id: "healthy",
@@ -27,6 +27,16 @@ export function automationTarget(item: DispatchAutomationItem): string {
27
27
  return item.triggerType || "schedule";
28
28
  }
29
29
 
30
+ export function automationScopeLabel(
31
+ item: Pick<DispatchAutomationItem, "owner" | "scope">,
32
+ ): string {
33
+ return item.scope === "organization" ||
34
+ item.owner.startsWith("__organization__:") ||
35
+ item.owner === "__shared__"
36
+ ? "Organization"
37
+ : "Personal";
38
+ }
39
+
30
40
  export function relativeRunTime(value: string | null | undefined): string {
31
41
  if (!value) return "never";
32
42
  const timestamp = new Date(value).getTime();
@@ -44,6 +54,10 @@ export function automationLastRun(item: DispatchAutomationItem): string {
44
54
  return item.lastRun ? relativeRunTime(item.lastRun) : "never";
45
55
  }
46
56
 
57
+ export function automationLastCheck(item: DispatchAutomationItem): string {
58
+ return item.lastCheck ? relativeRunTime(item.lastCheck) : "never";
59
+ }
60
+
47
61
  export function automationNextRun(item: DispatchAutomationItem): string {
48
62
  if (!item.enabled) return "paused";
49
63
  if (item.triggerType === "event") return "on event";
@@ -59,7 +73,7 @@ export function automationStatus(item: DispatchAutomationItem): {
59
73
  if (item.lastStatus === "running")
60
74
  return { label: "Running", tone: "warning" };
61
75
  if (item.lastStatus === "skipped")
62
- return { label: "Skipped", tone: "warning" };
76
+ return { label: "Blocked", tone: "warning" };
63
77
  if (item.lastStatus === "success")
64
78
  return { label: "Healthy", tone: "success" };
65
79
  return { label: "Ready", tone: "default" };
@@ -69,8 +83,14 @@ export function sortAutomations(
69
83
  automations: DispatchAutomationItem[],
70
84
  ): DispatchAutomationItem[] {
71
85
  return [...automations].sort((a, b) => {
72
- const aError = a.enabled && a.lastStatus === "error" ? 1 : 0;
73
- const bError = b.enabled && b.lastStatus === "error" ? 1 : 0;
86
+ const aError =
87
+ a.enabled && (a.lastStatus === "error" || a.lastStatus === "skipped")
88
+ ? 1
89
+ : 0;
90
+ const bError =
91
+ b.enabled && (b.lastStatus === "error" || b.lastStatus === "skipped")
92
+ ? 1
93
+ : 0;
74
94
  if (aError !== bError) return bError - aError;
75
95
  return (b.lastRun || "").localeCompare(a.lastRun || "");
76
96
  });
@@ -5,6 +5,7 @@ export interface DispatchAutomationItem {
5
5
  name: string;
6
6
  path: string;
7
7
  owner: string;
8
+ scope?: "personal" | "organization";
8
9
  canUpdate?: boolean;
9
10
  triggerType?: "schedule" | "event" | string;
10
11
  event?: string;
@@ -14,8 +15,10 @@ export interface DispatchAutomationItem {
14
15
  mode?: string;
15
16
  domain?: string;
16
17
  enabled?: boolean;
18
+ timezone?: string;
17
19
  lastStatus?: string;
18
20
  lastRun?: string;
21
+ lastCheck?: string;
19
22
  lastError?: string;
20
23
  nextRun?: string;
21
24
  createdBy?: string;
@@ -1,9 +1,7 @@
1
1
  import { useActionQuery } from "@agent-native/core/client/hooks";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
- import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
4
3
  import {
5
4
  IconApps,
6
- IconArrowUpRight,
7
5
  IconChevronDown,
8
6
  IconClockHour4,
9
7
  IconEyeOff,
@@ -13,6 +11,7 @@ import {
13
11
  import { useState } from "react";
14
12
 
15
13
  import { ActionQueryError } from "../../components/action-query-error";
14
+ import { ConnectedAppCard } from "../../components/connected-app-card";
16
15
  import { CreateAppPopover } from "../../components/create-app-popover";
17
16
  import { DispatchShell } from "../../components/dispatch-shell";
18
17
  import { Button } from "../../components/ui/button";
@@ -277,7 +276,7 @@ export default function AppsRoute() {
277
276
  ) : (
278
277
  <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
279
278
  {otherApps.map((app) => (
280
- <OtherAppCard key={app.id} app={app} />
279
+ <ConnectedAppCard key={app.id} app={app} />
281
280
  ))}
282
281
  </div>
283
282
  )}
@@ -382,42 +381,6 @@ function OtherAppsSkeletonGrid() {
382
381
  );
383
382
  }
384
383
 
385
- function OtherAppCard({ app }: { app: ConnectedAppSummary }) {
386
- const t = useT();
387
- return (
388
- <a
389
- href={withBuilderUtmTrackingParams(app.url, {
390
- campaign: "product",
391
- content: "dispatch_app",
392
- })}
393
- target="_blank"
394
- rel="noopener noreferrer"
395
- className="group flex min-h-[116px] items-start gap-3 rounded-xl bg-card/40 p-4 transition-[background-color] hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
396
- >
397
- <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
398
- {app.name.charAt(0).toUpperCase()}
399
- </span>
400
- <span className="min-w-0 flex-1">
401
- <span className="flex items-start justify-between gap-3">
402
- <span className="truncate text-sm font-semibold text-foreground">
403
- {app.name}
404
- </span>
405
- <IconArrowUpRight
406
- size={15}
407
- className="shrink-0 text-muted-foreground transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
408
- />
409
- </span>
410
- <span className="mt-2 line-clamp-2 block text-[13px] leading-5 text-muted-foreground">
411
- {app.description || app.url}
412
- </span>
413
- <span className="mt-3 block text-xs font-medium text-foreground">
414
- {t("dispatch.pages.openApp")}
415
- </span>
416
- </span>
417
- </a>
418
- );
419
- }
420
-
421
384
  function AppsSkeletonGrid() {
422
385
  return (
423
386
  <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
@@ -3,6 +3,7 @@ import { PromptComposer } from "@agent-native/core/client/composer";
3
3
  import { useChangeVersions } from "@agent-native/core/client/hooks";
4
4
  import {
5
5
  IconFileSearch,
6
+ IconListDetails,
6
7
  IconPlus,
7
8
  IconSettingsAutomation,
8
9
  } from "@tabler/icons-react";
@@ -11,6 +12,7 @@ import { useMemo, useState } from "react";
11
12
  import { Link } from "react-router";
12
13
  import { toast } from "sonner";
13
14
 
15
+ import { AutomationRunHistoryDialog } from "../../components/automation-run-history-dialog";
14
16
  import { DispatchShell } from "../../components/dispatch-shell";
15
17
  import { Badge } from "../../components/ui/badge";
16
18
  import { Button } from "../../components/ui/button";
@@ -23,8 +25,10 @@ import { Skeleton } from "../../components/ui/skeleton";
23
25
  import { Switch } from "../../components/ui/switch";
24
26
  import {
25
27
  automationIdentity,
28
+ automationLastCheck,
26
29
  automationLastRun,
27
30
  automationNextRun,
31
+ automationScopeLabel,
28
32
  automationStatus,
29
33
  automationTarget,
30
34
  automationTroubleshootPath,
@@ -175,13 +179,17 @@ function CreateAutomationButton() {
175
179
  }
176
180
 
177
181
  export default function AutomationsRoute() {
182
+ const [detailsTarget, setDetailsTarget] =
183
+ useState<DispatchAutomationItem | null>(null);
178
184
  const automationsQuery = useAutomations();
179
185
  const toggleAutomation = useToggleAutomation();
180
186
  const automations = automationsQuery.data ?? [];
181
187
  const ordered = useMemo(() => sortAutomations(automations), [automations]);
182
188
  const enabledCount = automations.filter((item) => item.enabled).length;
183
189
  const errorCount = automations.filter(
184
- (item) => item.enabled && item.lastStatus === "error",
190
+ (item) =>
191
+ item.enabled &&
192
+ (item.lastStatus === "error" || item.lastStatus === "skipped"),
185
193
  ).length;
186
194
  const pendingToggleIdentity = toggleAutomation.isPending
187
195
  ? toggleAutomation.variables
@@ -192,7 +200,7 @@ export default function AutomationsRoute() {
192
200
  return (
193
201
  <DispatchShell
194
202
  title="Automations"
195
- description="See scheduled and event-triggered jobs, pause them, or ask the agent to create one."
203
+ description="See scheduled and event-triggered jobs, inspect their checks and past runs, pause them, or ask the agent to create one."
196
204
  >
197
205
  <section className="flex flex-col gap-4">
198
206
  <div className="flex flex-wrap items-center justify-between gap-3">
@@ -238,6 +246,10 @@ export default function AutomationsRoute() {
238
246
  <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
239
247
  <span>Last {automationLastRun(item)}</span>
240
248
  <span>Next {automationNextRun(item)}</span>
249
+ <span>{automationScopeLabel(item)}</span>
250
+ {item.lastCheck ? (
251
+ <span>Checked {automationLastCheck(item)}</span>
252
+ ) : null}
241
253
  </div>
242
254
  {item.lastError ? (
243
255
  <div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-xs text-destructive">
@@ -256,14 +268,26 @@ export default function AutomationsRoute() {
256
268
  ) : null}
257
269
  </div>
258
270
  <div className="flex flex-col items-end gap-2">
259
- <Badge
260
- variant={
261
- status.tone === "danger" ? "destructive" : "outline"
262
- }
263
- className="h-5"
264
- >
265
- {status.label}
266
- </Badge>
271
+ <div className="flex items-center gap-2">
272
+ <Badge
273
+ variant={
274
+ status.tone === "danger" ? "destructive" : "outline"
275
+ }
276
+ className="h-5"
277
+ >
278
+ {status.label}
279
+ </Badge>
280
+ <Button
281
+ type="button"
282
+ variant="ghost"
283
+ size="sm"
284
+ className="h-7 px-2 text-xs"
285
+ onClick={() => setDetailsTarget(item)}
286
+ >
287
+ <IconListDetails size={14} />
288
+ Details
289
+ </Button>
290
+ </div>
267
291
  <Switch
268
292
  checked={!!item.enabled}
269
293
  disabled={!canUpdate || isToggling}
@@ -288,6 +312,13 @@ export default function AutomationsRoute() {
288
312
  )}
289
313
  </div>
290
314
  </section>
315
+ <AutomationRunHistoryDialog
316
+ automation={detailsTarget}
317
+ open={detailsTarget !== null}
318
+ onOpenChange={(open) => {
319
+ if (!open) setDetailsTarget(null);
320
+ }}
321
+ />
291
322
  </DispatchShell>
292
323
  );
293
324
  }