@agent-native/dispatch 0.11.1 → 0.11.3

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 (33) hide show
  1. package/dist/actions/provider-api-catalog.d.ts +2 -2
  2. package/dist/actions/start-workspace-app-creation.js +1 -1
  3. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  4. package/dist/components/create-app-popover.js +3 -3
  5. package/dist/components/create-app-popover.js.map +1 -1
  6. package/dist/components/dispatch-control-plane.d.ts +2 -0
  7. package/dist/components/dispatch-control-plane.d.ts.map +1 -0
  8. package/dist/components/dispatch-control-plane.js +432 -0
  9. package/dist/components/dispatch-control-plane.js.map +1 -0
  10. package/dist/components/layout/Header.d.ts.map +1 -1
  11. package/dist/components/layout/Header.js +19 -2
  12. package/dist/components/layout/Header.js.map +1 -1
  13. package/dist/lib/automations.d.ts +28 -0
  14. package/dist/lib/automations.d.ts.map +1 -0
  15. package/dist/lib/automations.js +27 -0
  16. package/dist/lib/automations.js.map +1 -0
  17. package/dist/routes/pages/overview.d.ts.map +1 -1
  18. package/dist/routes/pages/overview.js +3 -213
  19. package/dist/routes/pages/overview.js.map +1 -1
  20. package/dist/server/lib/provider-api.d.ts +2 -2
  21. package/dist/server/plugins/agent-chat.js +1 -1
  22. package/dist/server/plugins/agent-chat.js.map +1 -1
  23. package/dist/server/plugins/integrations.js +2 -2
  24. package/dist/server/plugins/integrations.js.map +1 -1
  25. package/package.json +2 -2
  26. package/src/actions/start-workspace-app-creation.ts +1 -1
  27. package/src/components/create-app-popover.tsx +3 -3
  28. package/src/components/dispatch-control-plane.tsx +1282 -0
  29. package/src/components/layout/Header.tsx +22 -1
  30. package/src/lib/automations.ts +60 -0
  31. package/src/routes/pages/overview.tsx +2 -755
  32. package/src/server/plugins/agent-chat.ts +1 -1
  33. package/src/server/plugins/integrations.ts +2 -2
@@ -0,0 +1,1282 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
+ import { Link, useNavigate } from "react-router";
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import {
5
+ PromptComposer,
6
+ agentNativePath,
7
+ isInBuilderFrame,
8
+ useActionQuery,
9
+ useChangeVersions,
10
+ useChatModels,
11
+ useChatThreads,
12
+ type ChatThreadSummary,
13
+ } from "@agent-native/core/client";
14
+ import { RunsTray } from "@agent-native/core/client/progress";
15
+ import {
16
+ IconActivity,
17
+ IconAlertTriangle,
18
+ IconArrowUpRight,
19
+ IconBolt,
20
+ IconBroadcast,
21
+ IconCheck,
22
+ IconClockHour4,
23
+ IconListCheck,
24
+ IconMessages,
25
+ IconPlayerPlay,
26
+ IconPlugConnected,
27
+ IconPlus,
28
+ IconRobot,
29
+ IconRocket,
30
+ IconSettingsAutomation,
31
+ IconShieldCheck,
32
+ IconStack3,
33
+ IconUsersGroup,
34
+ type IconProps,
35
+ } from "@tabler/icons-react";
36
+ import { toast } from "sonner";
37
+ import { CreateAppPopover } from "@/components/create-app-popover";
38
+ import { DispatchShell } from "@/components/dispatch-shell";
39
+ import { WorkspaceAppCard } from "@/components/workspace-app-card";
40
+ import { Badge } from "@/components/ui/badge";
41
+ import { Button } from "@/components/ui/button";
42
+ import { Skeleton } from "@/components/ui/skeleton";
43
+ import { Switch } from "@/components/ui/switch";
44
+ import { cn } from "@/lib/utils";
45
+ import {
46
+ listDispatchAutomations,
47
+ setDispatchAutomationEnabled,
48
+ type DispatchAutomationItem,
49
+ type SetDispatchAutomationEnabledInput,
50
+ } from "@/lib/automations";
51
+ import { submitOverviewPrompt } from "@/lib/overview-chat";
52
+ import type { ConnectedAgent } from "@/components/agents-panel";
53
+ import type { WorkspaceAppSummary } from "@/lib/workspace-apps";
54
+
55
+ interface DispatchOverview {
56
+ counts?: {
57
+ destinations?: number;
58
+ pendingApprovals?: number;
59
+ linkedIdentities?: number;
60
+ activeTokens?: number;
61
+ };
62
+ recentApprovals?: Array<{
63
+ id: string;
64
+ summary: string;
65
+ status: string;
66
+ requestedBy?: string;
67
+ }>;
68
+ recentAudit?: RecentAuditEvent[];
69
+ settings?: {
70
+ enabled?: boolean;
71
+ };
72
+ vault?: {
73
+ secretCount?: number;
74
+ activeGrantCount?: number;
75
+ accessMode?: string;
76
+ };
77
+ }
78
+
79
+ interface RecentAuditEvent {
80
+ id: string;
81
+ summary: string;
82
+ actor: string;
83
+ action?: string;
84
+ createdAt: string;
85
+ }
86
+
87
+ interface TaskQueueRecentFailure {
88
+ id: string;
89
+ platform: string;
90
+ error: string;
91
+ attempts: number;
92
+ }
93
+
94
+ interface TaskQueueStats {
95
+ pending: number;
96
+ processing: number;
97
+ completed_last_hour: number;
98
+ failed_last_hour: number;
99
+ oldest_pending_age_seconds: number;
100
+ recent_failures: TaskQueueRecentFailure[];
101
+ }
102
+
103
+ type AutomationItem = DispatchAutomationItem;
104
+
105
+ const ZERO_TASK_QUEUE_STATS: TaskQueueStats = {
106
+ pending: 0,
107
+ processing: 0,
108
+ completed_last_hour: 0,
109
+ failed_last_hour: 0,
110
+ oldest_pending_age_seconds: 0,
111
+ recent_failures: [],
112
+ };
113
+
114
+ const PROMPT_SUGGESTIONS = [
115
+ "Summarize the current workspace health",
116
+ "Create an app for onboarding requests",
117
+ "Check which agents can help with analytics",
118
+ ];
119
+
120
+ const AUTOMATIONS_QUERY_KEY = [
121
+ "dispatch-control-plane",
122
+ "automations",
123
+ ] as const;
124
+
125
+ function formatNumber(value: number): string {
126
+ return new Intl.NumberFormat(undefined, {
127
+ notation: value >= 10_000 ? "compact" : "standard",
128
+ maximumFractionDigits: value >= 10_000 ? 1 : 0,
129
+ }).format(value);
130
+ }
131
+
132
+ function formatAgeSeconds(seconds: number): string {
133
+ if (!seconds || seconds < 0) return "0s";
134
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
135
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
136
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
137
+ }
138
+
139
+ function timeAgo(value: number | string | null | undefined): string {
140
+ if (value == null || value === "") return "never";
141
+ const timestamp =
142
+ typeof value === "number" ? value : new Date(value).getTime();
143
+ if (!Number.isFinite(timestamp)) return "never";
144
+ const diff = Math.max(0, Date.now() - timestamp);
145
+ if (diff < 60_000) return "now";
146
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
147
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
148
+ return `${Math.floor(diff / 86_400_000)}d`;
149
+ }
150
+
151
+ function relativeRunTime(value: string | null | undefined): string {
152
+ if (!value) return "never";
153
+ const timestamp = new Date(value).getTime();
154
+ if (!Number.isFinite(timestamp)) return "never";
155
+ const diff = timestamp - Date.now();
156
+ const abs = Math.abs(diff);
157
+ const suffix = diff >= 0 ? "from now" : "ago";
158
+ if (abs < 60_000) return diff >= 0 ? "soon" : "now";
159
+ if (abs < 3_600_000) return `${Math.floor(abs / 60_000)}m ${suffix}`;
160
+ if (abs < 86_400_000) return `${Math.floor(abs / 3_600_000)}h ${suffix}`;
161
+ return `${Math.floor(abs / 86_400_000)}d ${suffix}`;
162
+ }
163
+
164
+ function dateTimeTitle(value: string | null | undefined): string | undefined {
165
+ if (!value) return undefined;
166
+ const timestamp = new Date(value).getTime();
167
+ if (!Number.isFinite(timestamp)) return undefined;
168
+ return new Intl.DateTimeFormat(undefined, {
169
+ dateStyle: "medium",
170
+ timeStyle: "short",
171
+ }).format(timestamp);
172
+ }
173
+
174
+ function threadUpdatedAt(thread: ChatThreadSummary): number {
175
+ return Number.isFinite(thread.updatedAt)
176
+ ? thread.updatedAt
177
+ : Number.isFinite(thread.createdAt)
178
+ ? thread.createdAt
179
+ : 0;
180
+ }
181
+
182
+ function threadTitle(thread: ChatThreadSummary): string {
183
+ return thread.title || thread.preview || "New chat";
184
+ }
185
+
186
+ function automationTarget(item: AutomationItem): string {
187
+ if (item.triggerType === "event" && item.event) return item.event;
188
+ if (item.scheduleDescription) return item.scheduleDescription;
189
+ if (item.schedule) return item.schedule;
190
+ return item.triggerType || "schedule";
191
+ }
192
+
193
+ function automationLastRun(item: AutomationItem): string {
194
+ return item.lastRun ? relativeRunTime(item.lastRun) : "never";
195
+ }
196
+
197
+ function automationNextRun(item: AutomationItem): string {
198
+ if (!item.enabled) return "paused";
199
+ if (item.triggerType === "event") return "on event";
200
+ return item.nextRun ? relativeRunTime(item.nextRun) : "not scheduled";
201
+ }
202
+
203
+ function automationStatus(item: AutomationItem): {
204
+ label: string;
205
+ tone: "default" | "success" | "warning" | "danger" | "muted";
206
+ } {
207
+ if (!item.enabled) return { label: "Paused", tone: "muted" };
208
+ if (item.lastStatus === "error") return { label: "Error", tone: "danger" };
209
+ if (item.lastStatus === "running")
210
+ return { label: "Running", tone: "warning" };
211
+ if (item.lastStatus === "skipped")
212
+ return { label: "Skipped", tone: "warning" };
213
+ if (item.lastStatus === "success")
214
+ return { label: "Healthy", tone: "success" };
215
+ return { label: "Ready", tone: "default" };
216
+ }
217
+
218
+ function automationIdentity(
219
+ item: Pick<AutomationItem, "owner" | "path">,
220
+ ): string {
221
+ return `${item.owner}:${item.path}`;
222
+ }
223
+
224
+ function StatusDot({
225
+ tone = "default",
226
+ }: {
227
+ tone?: "default" | "success" | "warning" | "danger" | "muted";
228
+ }) {
229
+ return (
230
+ <span
231
+ className={cn(
232
+ "size-2 rounded-full",
233
+ tone === "success" && "bg-emerald-500",
234
+ tone === "warning" && "bg-amber-500",
235
+ tone === "danger" && "bg-destructive",
236
+ tone === "muted" && "bg-muted-foreground/35",
237
+ tone === "default" && "bg-primary",
238
+ )}
239
+ />
240
+ );
241
+ }
242
+
243
+ function useAutomationsStatus() {
244
+ const version = useChangeVersions(["action", "screen-refresh"]);
245
+ return useQuery<AutomationItem[]>({
246
+ queryKey: [...AUTOMATIONS_QUERY_KEY, version],
247
+ queryFn: listDispatchAutomations,
248
+ placeholderData: (prev) => prev,
249
+ refetchInterval: 15_000,
250
+ staleTime: 5_000,
251
+ });
252
+ }
253
+
254
+ function useToggleAutomation() {
255
+ const queryClient = useQueryClient();
256
+
257
+ return useMutation({
258
+ mutationFn: setDispatchAutomationEnabled,
259
+ onMutate: async (input: SetDispatchAutomationEnabledInput) => {
260
+ await queryClient.cancelQueries({ queryKey: AUTOMATIONS_QUERY_KEY });
261
+ const snapshots = queryClient.getQueriesData<AutomationItem[]>({
262
+ queryKey: AUTOMATIONS_QUERY_KEY,
263
+ });
264
+
265
+ queryClient.setQueriesData<AutomationItem[]>(
266
+ { queryKey: AUTOMATIONS_QUERY_KEY },
267
+ (rows) =>
268
+ rows?.map((item) =>
269
+ automationIdentity(item) === automationIdentity(input)
270
+ ? { ...item, enabled: input.enabled }
271
+ : item,
272
+ ),
273
+ );
274
+
275
+ return { snapshots };
276
+ },
277
+ onError: (err, _input, context) => {
278
+ for (const [queryKey, data] of context?.snapshots ?? []) {
279
+ queryClient.setQueryData(queryKey, data);
280
+ }
281
+ toast.error(
282
+ `Could not update automation: ${
283
+ err instanceof Error ? err.message : String(err)
284
+ }`,
285
+ );
286
+ },
287
+ onSuccess: (updated) => {
288
+ queryClient.setQueriesData<AutomationItem[]>(
289
+ { queryKey: AUTOMATIONS_QUERY_KEY },
290
+ (rows) =>
291
+ rows?.map((item) =>
292
+ automationIdentity(item) === automationIdentity(updated)
293
+ ? updated
294
+ : item,
295
+ ),
296
+ );
297
+ },
298
+ onSettled: () => {
299
+ void queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY });
300
+ },
301
+ });
302
+ }
303
+
304
+ function useTaskQueueStats() {
305
+ return useQuery<TaskQueueStats>({
306
+ queryKey: ["dispatch-control-plane", "task-queue"],
307
+ queryFn: async () => {
308
+ const res = await fetch(
309
+ agentNativePath("/_agent-native/integrations/task-queue/status"),
310
+ );
311
+ if (!res.ok) return ZERO_TASK_QUEUE_STATS;
312
+ const stats = await res.json();
313
+ if (!stats || typeof stats !== "object") return ZERO_TASK_QUEUE_STATS;
314
+ return {
315
+ pending: Number(stats.pending ?? 0),
316
+ processing: Number(stats.processing ?? 0),
317
+ completed_last_hour: Number(stats.completed_last_hour ?? 0),
318
+ failed_last_hour: Number(stats.failed_last_hour ?? 0),
319
+ oldest_pending_age_seconds: Number(
320
+ stats.oldest_pending_age_seconds ?? 0,
321
+ ),
322
+ recent_failures: Array.isArray(stats.recent_failures)
323
+ ? stats.recent_failures
324
+ : [],
325
+ };
326
+ },
327
+ placeholderData: (prev) => prev,
328
+ refetchInterval: 15_000,
329
+ staleTime: 5_000,
330
+ });
331
+ }
332
+
333
+ function CommandPanel() {
334
+ const { selectedModel } = useChatModels();
335
+ const navigate = useNavigate();
336
+
337
+ function send(message: string) {
338
+ const trimmed = message.trim();
339
+ if (!trimmed) return;
340
+
341
+ if (isInBuilderFrame()) {
342
+ submitOverviewPrompt(trimmed, selectedModel);
343
+ return;
344
+ }
345
+
346
+ navigate("/chat", {
347
+ state: {
348
+ dispatchPrompt: {
349
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
350
+ message: trimmed,
351
+ selectedModel,
352
+ },
353
+ },
354
+ });
355
+ }
356
+
357
+ return (
358
+ <section className="rounded-lg border bg-card p-4">
359
+ <div className="mb-3 flex items-center justify-between gap-3">
360
+ <div className="flex items-center gap-2">
361
+ <IconBroadcast size={16} className="text-muted-foreground" />
362
+ <h2 className="text-sm font-semibold text-foreground">
363
+ Ask Dispatch
364
+ </h2>
365
+ </div>
366
+ <Button variant="outline" size="sm" asChild>
367
+ <Link to="/chat">
368
+ Open chat
369
+ <IconArrowUpRight size={14} />
370
+ </Link>
371
+ </Button>
372
+ </div>
373
+ <PromptComposer
374
+ placeholder="Route work, inspect status, or create an app..."
375
+ onSubmit={(text) => send(text)}
376
+ />
377
+ <div className="mt-3 flex flex-wrap gap-2">
378
+ {PROMPT_SUGGESTIONS.map((suggestion) => (
379
+ <button
380
+ key={suggestion}
381
+ type="button"
382
+ onClick={() => send(suggestion)}
383
+ className="cursor-pointer rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-muted-foreground transition hover:border-foreground/30 hover:text-foreground"
384
+ >
385
+ {suggestion}
386
+ </button>
387
+ ))}
388
+ </div>
389
+ </section>
390
+ );
391
+ }
392
+
393
+ function MetricCard({
394
+ label,
395
+ value,
396
+ detail,
397
+ icon: Icon,
398
+ to,
399
+ tone = "default",
400
+ }: {
401
+ label: string;
402
+ value: ReactNode;
403
+ detail: string;
404
+ icon: React.ComponentType<IconProps>;
405
+ to?: string;
406
+ tone?: "default" | "success" | "warning" | "danger";
407
+ }) {
408
+ const body = (
409
+ <>
410
+ <div className="flex items-center justify-between gap-3">
411
+ <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
412
+ <Icon size={15} />
413
+ <span className="truncate">{label}</span>
414
+ </div>
415
+ <StatusDot
416
+ tone={
417
+ tone === "success"
418
+ ? "success"
419
+ : tone === "warning"
420
+ ? "warning"
421
+ : tone === "danger"
422
+ ? "danger"
423
+ : "default"
424
+ }
425
+ />
426
+ </div>
427
+ <div className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
428
+ {value}
429
+ </div>
430
+ <div className="mt-1 truncate text-xs text-muted-foreground">
431
+ {detail}
432
+ </div>
433
+ </>
434
+ );
435
+
436
+ const className = cn(
437
+ "block min-w-0 rounded-lg border bg-card p-4 transition",
438
+ to && "hover:border-foreground/30 hover:bg-muted/20",
439
+ );
440
+
441
+ if (to) {
442
+ return (
443
+ <Link to={to} className={className}>
444
+ {body}
445
+ </Link>
446
+ );
447
+ }
448
+
449
+ return <div className={className}>{body}</div>;
450
+ }
451
+
452
+ function ControlPlaneMetrics({
453
+ overview,
454
+ apps,
455
+ agents,
456
+ automations,
457
+ threads,
458
+ taskQueue,
459
+ }: {
460
+ overview?: DispatchOverview;
461
+ apps: WorkspaceAppSummary[];
462
+ agents: ConnectedAgent[];
463
+ automations: AutomationItem[];
464
+ threads: ChatThreadSummary[];
465
+ taskQueue: TaskQueueStats;
466
+ }) {
467
+ const activeApps = apps.filter((app) => !app.isDispatch && !app.archived);
468
+ const pendingApps = activeApps.filter((app) => app.status === "pending");
469
+ const enabledAutomations = automations.filter((item) => item.enabled);
470
+ const automationErrors = automations.filter(
471
+ (item) => item.enabled && item.lastStatus === "error",
472
+ );
473
+ const pendingApprovals = overview?.counts?.pendingApprovals ?? 0;
474
+ const activeRuns = taskQueue.processing;
475
+
476
+ return (
477
+ <section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
478
+ <MetricCard
479
+ label="Chats"
480
+ value={formatNumber(threads.length)}
481
+ detail={`${threads.filter((thread) => thread.messageCount > 0).length} with messages`}
482
+ icon={IconMessages}
483
+ to="/chat"
484
+ />
485
+ <MetricCard
486
+ label="Runs"
487
+ value={formatNumber(activeRuns)}
488
+ detail={`${formatNumber(taskQueue.pending)} queued`}
489
+ icon={IconPlayerPlay}
490
+ tone={
491
+ taskQueue.failed_last_hour > 0
492
+ ? "danger"
493
+ : taskQueue.pending > 5
494
+ ? "warning"
495
+ : "default"
496
+ }
497
+ />
498
+ <MetricCard
499
+ label="Apps"
500
+ value={formatNumber(activeApps.length)}
501
+ detail={`${formatNumber(pendingApps.length)} building`}
502
+ icon={IconStack3}
503
+ to="/apps"
504
+ tone={pendingApps.length > 0 ? "warning" : "default"}
505
+ />
506
+ <MetricCard
507
+ label="Agents"
508
+ value={formatNumber(agents.length)}
509
+ detail={`${formatNumber(agents.filter((agent) => agent.source === "custom").length)} custom`}
510
+ icon={IconRobot}
511
+ to="/agents"
512
+ />
513
+ <MetricCard
514
+ label="Automations"
515
+ value={formatNumber(enabledAutomations.length)}
516
+ detail={`${formatNumber(automationErrors.length)} need attention`}
517
+ icon={IconSettingsAutomation}
518
+ tone={automationErrors.length > 0 ? "danger" : "success"}
519
+ />
520
+ <MetricCard
521
+ label="Approvals"
522
+ value={formatNumber(pendingApprovals)}
523
+ detail={
524
+ overview?.settings?.enabled ? "review mode" : "immediate changes"
525
+ }
526
+ icon={IconShieldCheck}
527
+ to="/approvals"
528
+ tone={pendingApprovals > 0 ? "warning" : "default"}
529
+ />
530
+ </section>
531
+ );
532
+ }
533
+
534
+ function SectionHeader({
535
+ icon: Icon,
536
+ title,
537
+ detail,
538
+ action,
539
+ }: {
540
+ icon: React.ComponentType<IconProps>;
541
+ title: string;
542
+ detail?: string;
543
+ action?: ReactNode;
544
+ }) {
545
+ return (
546
+ <div className="flex min-w-0 items-center justify-between gap-3">
547
+ <div className="flex min-w-0 items-center gap-2">
548
+ <Icon size={16} className="shrink-0 text-muted-foreground" />
549
+ <div className="min-w-0">
550
+ <h2 className="truncate text-sm font-semibold text-foreground">
551
+ {title}
552
+ </h2>
553
+ {detail ? (
554
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
555
+ {detail}
556
+ </p>
557
+ ) : null}
558
+ </div>
559
+ </div>
560
+ {action ? <div className="shrink-0">{action}</div> : null}
561
+ </div>
562
+ );
563
+ }
564
+
565
+ function RecentChatsPanel() {
566
+ const navigate = useNavigate();
567
+ const { threads, activeThreadId, createThread, switchThread } =
568
+ useChatThreads(undefined, undefined, undefined, { autoCreate: false });
569
+ const [creating, setCreating] = useState(false);
570
+
571
+ const visibleThreads = useMemo(
572
+ () =>
573
+ threads
574
+ .filter(
575
+ (thread) => thread.messageCount > 0 || thread.id === activeThreadId,
576
+ )
577
+ .sort((a, b) => threadUpdatedAt(b) - threadUpdatedAt(a))
578
+ .slice(0, 5),
579
+ [activeThreadId, threads],
580
+ );
581
+
582
+ function openThread(threadId: string, isNew = false) {
583
+ switchThread(threadId);
584
+ navigate("/chat", {
585
+ state: {
586
+ dispatchThread: {
587
+ id: `${Date.now()}-${threadId}`,
588
+ threadId,
589
+ },
590
+ },
591
+ });
592
+ window.requestAnimationFrame(() => {
593
+ window.dispatchEvent(
594
+ new CustomEvent("agent-chat:open-thread", {
595
+ detail: { threadId, newThread: isNew },
596
+ }),
597
+ );
598
+ });
599
+ }
600
+
601
+ async function handleNewChat() {
602
+ setCreating(true);
603
+ try {
604
+ const threadId = await createThread();
605
+ if (threadId) openThread(threadId, true);
606
+ } finally {
607
+ setCreating(false);
608
+ }
609
+ }
610
+
611
+ return (
612
+ <section className="rounded-lg border bg-card p-4">
613
+ <SectionHeader
614
+ icon={IconMessages}
615
+ title="Chats"
616
+ detail={`${visibleThreads.length} recent`}
617
+ action={
618
+ <Button
619
+ variant="outline"
620
+ size="sm"
621
+ onClick={handleNewChat}
622
+ disabled={creating}
623
+ >
624
+ <IconPlus size={14} />
625
+ New
626
+ </Button>
627
+ }
628
+ />
629
+ <div className="mt-3 divide-y rounded-md border">
630
+ {visibleThreads.length > 0 ? (
631
+ visibleThreads.map((thread) => (
632
+ <button
633
+ key={thread.id}
634
+ type="button"
635
+ onClick={() => openThread(thread.id)}
636
+ className="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 px-3 py-2.5 text-left transition hover:bg-muted/40"
637
+ >
638
+ <span className="min-w-0">
639
+ <span className="block truncate text-sm font-medium text-foreground">
640
+ {threadTitle(thread)}
641
+ </span>
642
+ <span className="mt-0.5 block truncate text-xs text-muted-foreground">
643
+ {thread.preview || `${thread.messageCount} messages`}
644
+ </span>
645
+ </span>
646
+ <span className="text-[11px] text-muted-foreground">
647
+ {timeAgo(threadUpdatedAt(thread))}
648
+ </span>
649
+ </button>
650
+ ))
651
+ ) : (
652
+ <button
653
+ type="button"
654
+ onClick={handleNewChat}
655
+ className="block w-full px-3 py-8 text-center text-sm text-muted-foreground transition hover:bg-muted/40"
656
+ >
657
+ Start a Dispatch chat
658
+ </button>
659
+ )}
660
+ </div>
661
+ </section>
662
+ );
663
+ }
664
+
665
+ function RunsPanel({ taskQueue }: { taskQueue: TaskQueueStats }) {
666
+ const hasFailure = taskQueue.failed_last_hour > 0;
667
+ const hasBacklog =
668
+ taskQueue.pending > 5 || taskQueue.oldest_pending_age_seconds > 300;
669
+
670
+ return (
671
+ <section className="rounded-lg border bg-card p-4">
672
+ <SectionHeader
673
+ icon={IconPlayerPlay}
674
+ title="Runs"
675
+ detail={
676
+ hasFailure
677
+ ? `${taskQueue.failed_last_hour} failed in the last hour`
678
+ : `${taskQueue.processing} processing`
679
+ }
680
+ action={
681
+ <RunsTray
682
+ triggerVariant="pill"
683
+ hideWhenIdle={false}
684
+ showRecent
685
+ limit={8}
686
+ />
687
+ }
688
+ />
689
+ <div className="mt-3 grid grid-cols-4 gap-2">
690
+ <QueueCell label="Queued" value={taskQueue.pending} />
691
+ <QueueCell label="Active" value={taskQueue.processing} />
692
+ <QueueCell label="Done 1h" value={taskQueue.completed_last_hour} />
693
+ <QueueCell
694
+ label="Failed 1h"
695
+ value={taskQueue.failed_last_hour}
696
+ danger={hasFailure}
697
+ />
698
+ </div>
699
+ <div
700
+ className={cn(
701
+ "mt-3 rounded-md border px-3 py-2 text-xs",
702
+ hasFailure
703
+ ? "border-destructive/30 bg-destructive/5 text-destructive"
704
+ : hasBacklog
705
+ ? "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
706
+ : "bg-muted/20 text-muted-foreground",
707
+ )}
708
+ >
709
+ Oldest queued: {formatAgeSeconds(taskQueue.oldest_pending_age_seconds)}
710
+ </div>
711
+ {taskQueue.recent_failures.length > 0 ? (
712
+ <div className="mt-3 divide-y rounded-md border">
713
+ {taskQueue.recent_failures.slice(0, 3).map((failure) => (
714
+ <div key={failure.id} className="px-3 py-2">
715
+ <div className="flex items-center justify-between gap-3 text-xs">
716
+ <span className="font-medium text-foreground">
717
+ {failure.platform}
718
+ </span>
719
+ <span className="text-muted-foreground">
720
+ {failure.attempts} attempts
721
+ </span>
722
+ </div>
723
+ <div className="mt-1 truncate text-xs text-muted-foreground">
724
+ {failure.error || "(no error message)"}
725
+ </div>
726
+ </div>
727
+ ))}
728
+ </div>
729
+ ) : null}
730
+ </section>
731
+ );
732
+ }
733
+
734
+ function QueueCell({
735
+ label,
736
+ value,
737
+ danger,
738
+ }: {
739
+ label: string;
740
+ value: number;
741
+ danger?: boolean;
742
+ }) {
743
+ return (
744
+ <div className="rounded-md border bg-background px-2 py-2">
745
+ <div
746
+ className={cn(
747
+ "text-lg font-semibold text-foreground",
748
+ danger && "text-destructive",
749
+ )}
750
+ >
751
+ {formatNumber(value)}
752
+ </div>
753
+ <div className="truncate text-[11px] text-muted-foreground">{label}</div>
754
+ </div>
755
+ );
756
+ }
757
+
758
+ function AppsPanel({
759
+ apps,
760
+ isLoading,
761
+ }: {
762
+ apps: WorkspaceAppSummary[];
763
+ isLoading: boolean;
764
+ }) {
765
+ const visibleApps = apps
766
+ .filter((app) => !app.isDispatch && !app.archived)
767
+ .slice(0, 4);
768
+ const showSkeletons = isLoading && visibleApps.length === 0;
769
+
770
+ return (
771
+ <section className="space-y-3">
772
+ <SectionHeader
773
+ icon={IconStack3}
774
+ title="Projects and apps"
775
+ detail={`${apps.filter((app) => !app.isDispatch && !app.archived).length} active`}
776
+ action={
777
+ <Button variant="outline" size="sm" asChild>
778
+ <Link to="/apps">
779
+ View all
780
+ <IconArrowUpRight size={14} />
781
+ </Link>
782
+ </Button>
783
+ }
784
+ />
785
+ {showSkeletons ? (
786
+ <div className="grid gap-3 md:grid-cols-2">
787
+ {Array.from({ length: 4 }).map((_, index) => (
788
+ <div key={index} className="rounded-lg border bg-card p-4">
789
+ <Skeleton className="h-4 w-32" />
790
+ <Skeleton className="mt-3 h-3 w-24" />
791
+ <Skeleton className="mt-3 h-3 w-full" />
792
+ </div>
793
+ ))}
794
+ </div>
795
+ ) : visibleApps.length > 0 ? (
796
+ <div className="grid gap-3 md:grid-cols-2">
797
+ {visibleApps.map((app) => (
798
+ <WorkspaceAppCard key={app.id} app={app} className="min-h-32" />
799
+ ))}
800
+ </div>
801
+ ) : (
802
+ <CreateAppPopover />
803
+ )}
804
+ </section>
805
+ );
806
+ }
807
+
808
+ function AutomationsPanel({
809
+ automations,
810
+ isLoading,
811
+ }: {
812
+ automations: AutomationItem[];
813
+ isLoading: boolean;
814
+ }) {
815
+ const toggleAutomation = useToggleAutomation();
816
+ const ordered = useMemo(
817
+ () =>
818
+ [...automations].sort((a, b) => {
819
+ const aError = a.enabled && a.lastStatus === "error" ? 1 : 0;
820
+ const bError = b.enabled && b.lastStatus === "error" ? 1 : 0;
821
+ if (aError !== bError) return bError - aError;
822
+ return (b.lastRun || "").localeCompare(a.lastRun || "");
823
+ }),
824
+ [automations],
825
+ );
826
+ const enabled = automations.filter((item) => item.enabled).length;
827
+ const errors = automations.filter(
828
+ (item) => item.enabled && item.lastStatus === "error",
829
+ ).length;
830
+ const pendingToggleIdentity = toggleAutomation.isPending
831
+ ? toggleAutomation.variables
832
+ ? automationIdentity(toggleAutomation.variables)
833
+ : null
834
+ : null;
835
+
836
+ function handleToggle(item: AutomationItem, enabled: boolean) {
837
+ toggleAutomation.mutate({
838
+ owner: item.owner,
839
+ path: item.path,
840
+ enabled,
841
+ });
842
+ }
843
+
844
+ return (
845
+ <section className="rounded-lg border bg-card p-4">
846
+ <SectionHeader
847
+ icon={IconSettingsAutomation}
848
+ title="Automations"
849
+ detail={`${enabled} enabled · ${errors} errors`}
850
+ action={
851
+ <Button variant="outline" size="sm" asChild>
852
+ <Link to="/chat">
853
+ Create
854
+ <IconArrowUpRight size={14} />
855
+ </Link>
856
+ </Button>
857
+ }
858
+ />
859
+ <div className="mt-3 divide-y rounded-md border">
860
+ {isLoading && ordered.length === 0 ? (
861
+ Array.from({ length: 4 }).map((_, index) => (
862
+ <div key={index} className="px-3 py-2.5">
863
+ <Skeleton className="h-4 w-40" />
864
+ <Skeleton className="mt-2 h-3 w-28" />
865
+ </div>
866
+ ))
867
+ ) : ordered.length > 0 ? (
868
+ ordered.slice(0, 6).map((item) => {
869
+ const status = automationStatus(item);
870
+ const canUpdate = item.canUpdate !== false;
871
+ const isToggling =
872
+ pendingToggleIdentity === automationIdentity(item);
873
+ return (
874
+ <div
875
+ key={item.id}
876
+ className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-3 py-2.5"
877
+ >
878
+ <div className="min-w-0">
879
+ <div className="flex min-w-0 items-center gap-2">
880
+ <StatusDot tone={status.tone} />
881
+ <span className="truncate text-sm font-medium text-foreground">
882
+ {item.name}
883
+ </span>
884
+ </div>
885
+ <div className="mt-1 truncate text-xs text-muted-foreground">
886
+ {automationTarget(item)}
887
+ </div>
888
+ <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
889
+ <span title={dateTimeTitle(item.lastRun)}>
890
+ Last {automationLastRun(item)}
891
+ </span>
892
+ <span title={dateTimeTitle(item.nextRun)}>
893
+ Next {automationNextRun(item)}
894
+ </span>
895
+ </div>
896
+ {item.lastError ? (
897
+ <div className="mt-1 truncate text-xs text-destructive">
898
+ {item.lastError}
899
+ </div>
900
+ ) : null}
901
+ </div>
902
+ <div className="flex flex-col items-end gap-1">
903
+ <Badge
904
+ variant={
905
+ status.tone === "danger" ? "destructive" : "outline"
906
+ }
907
+ className="h-5"
908
+ >
909
+ {status.label}
910
+ </Badge>
911
+ <Switch
912
+ checked={!!item.enabled}
913
+ disabled={!canUpdate || isToggling}
914
+ aria-label={`${item.enabled ? "Disable" : "Enable"} automation ${item.name}`}
915
+ onCheckedChange={(checked) => handleToggle(item, checked)}
916
+ />
917
+ </div>
918
+ </div>
919
+ );
920
+ })
921
+ ) : (
922
+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">
923
+ No automations yet
924
+ </div>
925
+ )}
926
+ </div>
927
+ </section>
928
+ );
929
+ }
930
+
931
+ function AgentsPanelSummary({ agents }: { agents: ConnectedAgent[] }) {
932
+ const builtin = agents.filter((agent) => agent.source === "builtin");
933
+ const extra = agents.filter((agent) => agent.source !== "builtin");
934
+
935
+ return (
936
+ <section className="rounded-lg border bg-card p-4">
937
+ <SectionHeader
938
+ icon={IconPlugConnected}
939
+ title="Agents"
940
+ detail={`${builtin.length} built in · ${extra.length} added`}
941
+ action={
942
+ <Button variant="outline" size="sm" asChild>
943
+ <Link to="/agents">
944
+ Manage
945
+ <IconArrowUpRight size={14} />
946
+ </Link>
947
+ </Button>
948
+ }
949
+ />
950
+ <div className="mt-3 flex flex-wrap gap-2">
951
+ {agents.slice(0, 12).map((agent) => (
952
+ <span
953
+ key={agent.id}
954
+ className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2.5 py-1.5 text-xs text-muted-foreground"
955
+ >
956
+ <span
957
+ className="size-2 shrink-0 rounded-full"
958
+ style={{ backgroundColor: agent.color || "hsl(var(--primary))" }}
959
+ />
960
+ <span className="truncate">{agent.name}</span>
961
+ </span>
962
+ ))}
963
+ {agents.length === 0 ? (
964
+ <div className="w-full rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
965
+ No agents detected
966
+ </div>
967
+ ) : null}
968
+ </div>
969
+ </section>
970
+ );
971
+ }
972
+
973
+ function ApprovalsAndAuditPanel({
974
+ overview,
975
+ isLoading,
976
+ }: {
977
+ overview?: DispatchOverview;
978
+ isLoading: boolean;
979
+ }) {
980
+ const approvals = overview?.recentApprovals ?? [];
981
+ const audit = overview?.recentAudit ?? [];
982
+
983
+ return (
984
+ <section className="rounded-lg border bg-card p-4">
985
+ <SectionHeader
986
+ icon={IconActivity}
987
+ title="Activity"
988
+ detail={`${approvals.length} approvals · ${audit.length} audit rows`}
989
+ action={
990
+ <Button variant="outline" size="sm" asChild>
991
+ <Link to="/audit">
992
+ Audit
993
+ <IconArrowUpRight size={14} />
994
+ </Link>
995
+ </Button>
996
+ }
997
+ />
998
+ <div className="mt-3 grid gap-3 lg:grid-cols-2">
999
+ <div className="rounded-md border">
1000
+ <div className="flex items-center justify-between gap-3 border-b px-3 py-2">
1001
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1002
+ Approvals
1003
+ </div>
1004
+ <Button variant="ghost" size="sm" asChild>
1005
+ <Link to="/approvals">Open</Link>
1006
+ </Button>
1007
+ </div>
1008
+ <div className="divide-y">
1009
+ {isLoading && approvals.length === 0 ? (
1010
+ Array.from({ length: 3 }).map((_, index) => (
1011
+ <div key={index} className="px-3 py-2.5">
1012
+ <Skeleton className="h-4 w-40" />
1013
+ <Skeleton className="mt-2 h-3 w-24" />
1014
+ </div>
1015
+ ))
1016
+ ) : approvals.length > 0 ? (
1017
+ approvals.slice(0, 4).map((approval) => (
1018
+ <div key={approval.id} className="px-3 py-2.5">
1019
+ <div className="truncate text-sm font-medium text-foreground">
1020
+ {approval.summary}
1021
+ </div>
1022
+ <div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
1023
+ <StatusDot
1024
+ tone={approval.status === "pending" ? "warning" : "muted"}
1025
+ />
1026
+ <span>{approval.status}</span>
1027
+ </div>
1028
+ </div>
1029
+ ))
1030
+ ) : (
1031
+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">
1032
+ No approval requests
1033
+ </div>
1034
+ )}
1035
+ </div>
1036
+ </div>
1037
+
1038
+ <div className="rounded-md border">
1039
+ <div className="border-b px-3 py-2 text-xs font-medium uppercase text-muted-foreground">
1040
+ Recent audit
1041
+ </div>
1042
+ <div className="divide-y">
1043
+ {isLoading && audit.length === 0 ? (
1044
+ Array.from({ length: 3 }).map((_, index) => (
1045
+ <div key={index} className="px-3 py-2.5">
1046
+ <Skeleton className="h-4 w-44" />
1047
+ <Skeleton className="mt-2 h-3 w-28" />
1048
+ </div>
1049
+ ))
1050
+ ) : audit.length > 0 ? (
1051
+ audit.slice(0, 4).map((event) => (
1052
+ <div key={event.id} className="px-3 py-2.5">
1053
+ <div className="truncate text-sm font-medium text-foreground">
1054
+ {event.summary}
1055
+ </div>
1056
+ <div className="mt-1 truncate text-xs text-muted-foreground">
1057
+ {event.actor} · {timeAgo(event.createdAt)}
1058
+ </div>
1059
+ </div>
1060
+ ))
1061
+ ) : (
1062
+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">
1063
+ No audit entries
1064
+ </div>
1065
+ )}
1066
+ </div>
1067
+ </div>
1068
+ </div>
1069
+ </section>
1070
+ );
1071
+ }
1072
+
1073
+ function ReadinessPanel({
1074
+ overview,
1075
+ apps,
1076
+ agents,
1077
+ automations,
1078
+ }: {
1079
+ overview?: DispatchOverview;
1080
+ apps: WorkspaceAppSummary[];
1081
+ agents: ConnectedAgent[];
1082
+ automations: AutomationItem[];
1083
+ }) {
1084
+ const rows = [
1085
+ {
1086
+ label: "Vault",
1087
+ detail:
1088
+ (overview?.vault?.secretCount ?? 0) > 0
1089
+ ? `${overview?.vault?.secretCount ?? 0} secrets`
1090
+ : "no secrets",
1091
+ ok: (overview?.vault?.secretCount ?? 0) > 0,
1092
+ to: "/vault",
1093
+ icon: IconShieldCheck,
1094
+ },
1095
+ {
1096
+ label: "Apps",
1097
+ detail: `${apps.filter((app) => !app.isDispatch && !app.archived).length} active`,
1098
+ ok: apps.some((app) => !app.isDispatch && !app.archived),
1099
+ to: "/apps",
1100
+ icon: IconRocket,
1101
+ },
1102
+ {
1103
+ label: "Agents",
1104
+ detail: `${agents.length} available`,
1105
+ ok: agents.length > 0,
1106
+ to: "/agents",
1107
+ icon: IconRobot,
1108
+ },
1109
+ {
1110
+ label: "Automations",
1111
+ detail: `${automations.filter((item) => item.enabled).length} enabled`,
1112
+ ok: automations.every(
1113
+ (item) => !item.enabled || item.lastStatus !== "error",
1114
+ ),
1115
+ to: "/chat",
1116
+ icon: IconBolt,
1117
+ },
1118
+ {
1119
+ label: "Team",
1120
+ detail: `${overview?.counts?.linkedIdentities ?? 0} linked identities`,
1121
+ ok: (overview?.counts?.linkedIdentities ?? 0) > 0,
1122
+ to: "/team",
1123
+ icon: IconUsersGroup,
1124
+ },
1125
+ ];
1126
+
1127
+ return (
1128
+ <section className="rounded-lg border bg-card p-4">
1129
+ <SectionHeader icon={IconListCheck} title="Readiness" />
1130
+ <div className="mt-3 divide-y rounded-md border">
1131
+ {rows.map((row) => {
1132
+ const Icon = row.icon;
1133
+ return (
1134
+ <Link
1135
+ key={row.label}
1136
+ to={row.to}
1137
+ className="flex items-center gap-3 px-3 py-2.5 transition hover:bg-muted/40"
1138
+ >
1139
+ <span
1140
+ className={cn(
1141
+ "flex size-7 shrink-0 items-center justify-center rounded-md border",
1142
+ row.ok
1143
+ ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
1144
+ : "bg-background text-muted-foreground",
1145
+ )}
1146
+ >
1147
+ {row.ok ? <IconCheck size={14} /> : <Icon size={14} />}
1148
+ </span>
1149
+ <span className="min-w-0 flex-1">
1150
+ <span className="block truncate text-sm font-medium text-foreground">
1151
+ {row.label}
1152
+ </span>
1153
+ <span className="block truncate text-xs text-muted-foreground">
1154
+ {row.detail}
1155
+ </span>
1156
+ </span>
1157
+ <IconArrowUpRight size={14} className="text-muted-foreground" />
1158
+ </Link>
1159
+ );
1160
+ })}
1161
+ </div>
1162
+ </section>
1163
+ );
1164
+ }
1165
+
1166
+ export function DispatchControlPlane() {
1167
+ const { data: overviewData, isLoading: overviewLoading } =
1168
+ useActionQuery<DispatchOverview>("list-dispatch-overview", {});
1169
+ const { data: connectedAgents = [] } = useActionQuery<ConnectedAgent[]>(
1170
+ "list-connected-agents",
1171
+ {},
1172
+ );
1173
+ const { data: workspaceApps = [], isLoading: appsLoading } = useActionQuery<
1174
+ WorkspaceAppSummary[]
1175
+ >(
1176
+ "list-workspace-apps",
1177
+ { includeAgentCards: false, includeArchived: true },
1178
+ { refetchInterval: 2_000 },
1179
+ );
1180
+ const { threads } = useChatThreads(undefined, undefined, undefined, {
1181
+ autoCreate: false,
1182
+ });
1183
+ const automationsQuery = useAutomationsStatus();
1184
+ const taskQueueQuery = useTaskQueueStats();
1185
+
1186
+ const overview = overviewData;
1187
+ const apps = workspaceApps ?? [];
1188
+ const agents = connectedAgents ?? [];
1189
+ const automations = automationsQuery.data ?? [];
1190
+ const taskQueue = taskQueueQuery.data ?? ZERO_TASK_QUEUE_STATS;
1191
+ const visibleThreads = threads.filter((thread) => thread.messageCount > 0);
1192
+
1193
+ useEffect(() => {
1194
+ const handleRunning = () => {
1195
+ void taskQueueQuery.refetch();
1196
+ };
1197
+ window.addEventListener("agentNative.chatRunning", handleRunning);
1198
+ return () => {
1199
+ window.removeEventListener("agentNative.chatRunning", handleRunning);
1200
+ };
1201
+ }, [taskQueueQuery]);
1202
+
1203
+ return (
1204
+ <DispatchShell
1205
+ title="Control plane"
1206
+ description="Dispatch is the workspace shell for chats, runs, apps, agents, automations, approvals, and resources."
1207
+ >
1208
+ <div className="space-y-6">
1209
+ <div className="grid gap-4 xl:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)]">
1210
+ <CommandPanel />
1211
+ <div className="grid gap-3 sm:grid-cols-2">
1212
+ <MetricCard
1213
+ label="Vault"
1214
+ value={formatNumber(overview?.vault?.secretCount ?? 0)}
1215
+ detail={`${formatNumber(overview?.vault?.activeGrantCount ?? 0)} active grants`}
1216
+ icon={IconShieldCheck}
1217
+ to="/vault"
1218
+ tone={
1219
+ (overview?.vault?.secretCount ?? 0) > 0 ? "success" : "default"
1220
+ }
1221
+ />
1222
+ <MetricCard
1223
+ label="Resources"
1224
+ value={formatNumber(overview?.counts?.destinations ?? 0)}
1225
+ detail="destinations"
1226
+ icon={IconArrowUpRight}
1227
+ to="/destinations"
1228
+ />
1229
+ </div>
1230
+ </div>
1231
+
1232
+ <ControlPlaneMetrics
1233
+ overview={overview}
1234
+ apps={apps}
1235
+ agents={agents}
1236
+ automations={automations}
1237
+ threads={visibleThreads}
1238
+ taskQueue={taskQueue}
1239
+ />
1240
+
1241
+ <div className="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(340px,0.85fr)]">
1242
+ <div className="space-y-4">
1243
+ <div className="grid gap-4 lg:grid-cols-2">
1244
+ <RecentChatsPanel />
1245
+ <RunsPanel taskQueue={taskQueue} />
1246
+ </div>
1247
+ <AppsPanel apps={apps} isLoading={appsLoading} />
1248
+ <ApprovalsAndAuditPanel
1249
+ overview={overview}
1250
+ isLoading={overviewLoading}
1251
+ />
1252
+ </div>
1253
+
1254
+ <div className="space-y-4">
1255
+ <AutomationsPanel
1256
+ automations={automations}
1257
+ isLoading={automationsQuery.isLoading}
1258
+ />
1259
+ <AgentsPanelSummary agents={agents} />
1260
+ <ReadinessPanel
1261
+ overview={overview}
1262
+ apps={apps}
1263
+ agents={agents}
1264
+ automations={automations}
1265
+ />
1266
+ {taskQueue.failed_last_hour > 0 ? (
1267
+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
1268
+ <div className="flex items-center gap-2 font-medium">
1269
+ <IconAlertTriangle size={16} />
1270
+ Integration failures detected
1271
+ </div>
1272
+ <div className="mt-1 text-xs">
1273
+ Check credentials, destinations, and recent queue errors.
1274
+ </div>
1275
+ </div>
1276
+ ) : null}
1277
+ </div>
1278
+ </div>
1279
+ </div>
1280
+ </DispatchShell>
1281
+ );
1282
+ }