@agent-native/dispatch 0.14.2 → 0.14.4

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 (138) hide show
  1. package/README.md +114 -122
  2. package/dist/actions/approve-dispatch-change.d.ts +10 -10
  3. package/dist/actions/list_apps.d.ts +1 -0
  4. package/dist/actions/list_apps.js +16 -8
  5. package/dist/actions/list_apps.js.map +1 -1
  6. package/dist/actions/provider-api-register.js +64 -2
  7. package/dist/actions/provider-api-register.js.map +1 -1
  8. package/dist/actions/reject-dispatch-change.d.ts +10 -10
  9. package/dist/actions/set-mcp-app-access.js +1 -4
  10. package/dist/actions/set-mcp-app-access.js.map +1 -1
  11. package/dist/components/action-query-error.d.ts +8 -0
  12. package/dist/components/action-query-error.d.ts.map +1 -0
  13. package/dist/components/action-query-error.js +10 -0
  14. package/dist/components/action-query-error.js.map +1 -0
  15. package/dist/components/app-keys-popover.d.ts +4 -0
  16. package/dist/components/app-keys-popover.d.ts.map +1 -1
  17. package/dist/components/app-keys-popover.js +26 -14
  18. package/dist/components/app-keys-popover.js.map +1 -1
  19. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  20. package/dist/components/dispatch-control-plane.js +31 -27
  21. package/dist/components/dispatch-control-plane.js.map +1 -1
  22. package/dist/components/index.d.ts +1 -0
  23. package/dist/components/index.d.ts.map +1 -1
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/index.js.map +1 -1
  26. package/dist/components/layout/Layout.d.ts +1 -0
  27. package/dist/components/layout/Layout.d.ts.map +1 -1
  28. package/dist/components/layout/Layout.js +19 -21
  29. package/dist/components/layout/Layout.js.map +1 -1
  30. package/dist/components/ui/sheet.js +1 -1
  31. package/dist/components/ui/sheet.js.map +1 -1
  32. package/dist/components/ui/toast.js +1 -1
  33. package/dist/components/ui/toast.js.map +1 -1
  34. package/dist/components/workspace-app-card.d.ts.map +1 -1
  35. package/dist/components/workspace-app-card.js +8 -5
  36. package/dist/components/workspace-app-card.js.map +1 -1
  37. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  38. package/dist/components/workspace-resource-effective-stack.js +6 -1
  39. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  40. package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
  41. package/dist/components/workspace-resource-impact-preview.js +6 -1
  42. package/dist/components/workspace-resource-impact-preview.js.map +1 -1
  43. package/dist/routes/pages/$appId.d.ts.map +1 -1
  44. package/dist/routes/pages/$appId.js +6 -1
  45. package/dist/routes/pages/$appId.js.map +1 -1
  46. package/dist/routes/pages/agents.d.ts.map +1 -1
  47. package/dist/routes/pages/agents.js +5 -8
  48. package/dist/routes/pages/agents.js.map +1 -1
  49. package/dist/routes/pages/approval.d.ts.map +1 -1
  50. package/dist/routes/pages/approval.js +6 -1
  51. package/dist/routes/pages/approval.js.map +1 -1
  52. package/dist/routes/pages/approvals.d.ts.map +1 -1
  53. package/dist/routes/pages/approvals.js +8 -5
  54. package/dist/routes/pages/approvals.js.map +1 -1
  55. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  56. package/dist/routes/pages/apps.$appId.js +4 -2
  57. package/dist/routes/pages/apps.$appId.js.map +1 -1
  58. package/dist/routes/pages/apps.d.ts.map +1 -1
  59. package/dist/routes/pages/apps.js +9 -3
  60. package/dist/routes/pages/apps.js.map +1 -1
  61. package/dist/routes/pages/audit.d.ts.map +1 -1
  62. package/dist/routes/pages/audit.js +4 -2
  63. package/dist/routes/pages/audit.js.map +1 -1
  64. package/dist/routes/pages/chat.d.ts.map +1 -1
  65. package/dist/routes/pages/chat.js +16 -2
  66. package/dist/routes/pages/chat.js.map +1 -1
  67. package/dist/routes/pages/destinations.d.ts.map +1 -1
  68. package/dist/routes/pages/destinations.js +5 -3
  69. package/dist/routes/pages/destinations.js.map +1 -1
  70. package/dist/routes/pages/identities.d.ts.map +1 -1
  71. package/dist/routes/pages/identities.js +5 -3
  72. package/dist/routes/pages/identities.js.map +1 -1
  73. package/dist/routes/pages/integrations.d.ts.map +1 -1
  74. package/dist/routes/pages/integrations.js +9 -3
  75. package/dist/routes/pages/integrations.js.map +1 -1
  76. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  77. package/dist/routes/pages/thread-debug.js +7 -6
  78. package/dist/routes/pages/thread-debug.js.map +1 -1
  79. package/dist/routes/pages/vault.d.ts.map +1 -1
  80. package/dist/routes/pages/vault.js +23 -7
  81. package/dist/routes/pages/vault.js.map +1 -1
  82. package/dist/routes/pages/workspace.d.ts.map +1 -1
  83. package/dist/routes/pages/workspace.js +9 -3
  84. package/dist/routes/pages/workspace.js.map +1 -1
  85. package/dist/server/lib/dispatch-store.d.ts +24 -21
  86. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  87. package/dist/server/lib/dispatch-store.js +59 -23
  88. package/dist/server/lib/dispatch-store.js.map +1 -1
  89. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  90. package/dist/server/lib/mcp-access-store.js +30 -2
  91. package/dist/server/lib/mcp-access-store.js.map +1 -1
  92. package/dist/server/lib/vault-store.d.ts.map +1 -1
  93. package/dist/server/lib/vault-store.js +102 -37
  94. package/dist/server/lib/vault-store.js.map +1 -1
  95. package/dist/server/plugins/agent-chat.d.ts +1 -1
  96. package/package.json +2 -3
  97. package/src/actions/list_apps.spec.ts +52 -0
  98. package/src/actions/list_apps.ts +16 -8
  99. package/src/actions/provider-api-register.spec.ts +193 -0
  100. package/src/actions/provider-api-register.ts +66 -1
  101. package/src/actions/set-mcp-app-access.spec.ts +73 -0
  102. package/src/actions/set-mcp-app-access.ts +1 -5
  103. package/src/components/action-query-error.spec.tsx +56 -0
  104. package/src/components/action-query-error.tsx +32 -0
  105. package/src/components/app-keys-popover.spec.tsx +85 -0
  106. package/src/components/app-keys-popover.tsx +76 -50
  107. package/src/components/dispatch-control-plane.spec.tsx +106 -0
  108. package/src/components/dispatch-control-plane.tsx +71 -115
  109. package/src/components/index.ts +1 -0
  110. package/src/components/layout/Layout.spec.tsx +42 -2
  111. package/src/components/layout/Layout.tsx +22 -27
  112. package/src/components/ui/sheet.tsx +1 -1
  113. package/src/components/ui/toast.tsx +1 -1
  114. package/src/components/workspace-app-card.spec.tsx +80 -0
  115. package/src/components/workspace-app-card.tsx +36 -25
  116. package/src/components/workspace-resource-effective-stack.tsx +13 -1
  117. package/src/components/workspace-resource-impact-preview.tsx +12 -1
  118. package/src/routes/pages/$appId.tsx +17 -1
  119. package/src/routes/pages/agents.tsx +24 -11
  120. package/src/routes/pages/approval.tsx +16 -4
  121. package/src/routes/pages/approvals.tsx +108 -89
  122. package/src/routes/pages/apps.$appId.tsx +9 -2
  123. package/src/routes/pages/apps.tsx +20 -7
  124. package/src/routes/pages/audit.tsx +29 -20
  125. package/src/routes/pages/chat.spec.tsx +94 -0
  126. package/src/routes/pages/chat.tsx +30 -10
  127. package/src/routes/pages/destinations.tsx +75 -62
  128. package/src/routes/pages/identities.tsx +74 -63
  129. package/src/routes/pages/integrations.tsx +17 -10
  130. package/src/routes/pages/thread-debug.tsx +18 -12
  131. package/src/routes/pages/vault.tsx +59 -26
  132. package/src/routes/pages/workspace.tsx +69 -55
  133. package/src/server/lib/approval-fencing.spec.ts +420 -0
  134. package/src/server/lib/dispatch-store.ts +94 -21
  135. package/src/server/lib/mcp-access-store.spec.ts +116 -4
  136. package/src/server/lib/mcp-access-store.ts +37 -2
  137. package/src/server/lib/mcp-gateway.spec.ts +79 -3
  138. package/src/server/lib/vault-store.ts +151 -45
@@ -9,7 +9,6 @@ import {
9
9
  useAgentChatHomeHandoff,
10
10
  useAgentChatHomeHandoffLinks,
11
11
  useChatThreads,
12
- useFormatters,
13
12
  useT,
14
13
  type ChatThreadSummary,
15
14
  } from "@agent-native/core/client";
@@ -94,13 +93,6 @@ export interface DispatchExtensionConfig {
94
93
  }
95
94
 
96
95
  const PRIMARY_NAV_ITEMS = [
97
- {
98
- id: "chat",
99
- to: "/chat",
100
- label: "Chat",
101
- icon: IconMessageQuestion,
102
- section: "primary",
103
- },
104
96
  {
105
97
  id: "overview",
106
98
  to: "/overview",
@@ -108,6 +100,13 @@ const PRIMARY_NAV_ITEMS = [
108
100
  icon: IconBroadcast,
109
101
  section: "primary",
110
102
  },
103
+ {
104
+ id: "chat",
105
+ to: "/chat",
106
+ label: "Chat",
107
+ icon: IconMessageQuestion,
108
+ section: "primary",
109
+ },
111
110
  {
112
111
  id: "apps",
113
112
  to: "/apps",
@@ -312,22 +311,17 @@ function threadIdFromPath(pathname: string): string | null {
312
311
  }
313
312
  }
314
313
 
315
- function formatThreadAge(
316
- updatedAt: number,
317
- formatDate: ReturnType<typeof useFormatters>["formatDate"],
318
- ) {
319
- const diffMs = Math.max(0, Date.now() - updatedAt);
314
+ export function formatThreadAge(updatedAt: number, now = Date.now()) {
315
+ const diffMs = Math.max(0, now - updatedAt);
320
316
  const minutes = Math.floor(diffMs / 60_000);
321
317
  if (minutes < 1) return "now";
322
318
  if (minutes < 60) return `${minutes}m`;
323
319
  const hours = Math.floor(minutes / 60);
324
320
  if (hours < 24) return `${hours}h`;
325
321
  const days = Math.floor(hours / 24);
326
- if (days < 7) return `${days}d`;
327
- return formatDate(updatedAt, {
328
- month: "short",
329
- day: "numeric",
330
- });
322
+ if (days < 14) return `${days}d`;
323
+ if (days < 365) return `${Math.floor(days / 7)}w`;
324
+ return `${Math.floor(days / 365)}y`;
331
325
  }
332
326
 
333
327
  function threadTitle(thread: ChatThreadSummary, fallback: string) {
@@ -344,7 +338,6 @@ function threadUpdatedAt(thread: ChatThreadSummary) {
344
338
 
345
339
  function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
346
340
  const t = useT();
347
- const { formatDate } = useFormatters();
348
341
  const navigate = useNavigate();
349
342
  const location = useLocation();
350
343
  const {
@@ -510,19 +503,21 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
510
503
  <button
511
504
  type="button"
512
505
  onClick={() => openThread(thread.id)}
513
- className="min-w-0 flex-1 cursor-pointer px-2 py-1.5 pe-12 text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
506
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2 py-1.5 pe-1 text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
514
507
  >
515
- <span className="block min-w-0 truncate">{title}</span>
508
+ <span className="block min-w-0 flex-1 truncate">
509
+ {title}
510
+ </span>
511
+ <time className="w-8 shrink-0 whitespace-nowrap text-end text-[11px] tabular-nums text-muted-foreground/60 transition-opacity group-hover/item:opacity-0 group-focus-within/item:opacity-0">
512
+ {isActive
513
+ ? ""
514
+ : formatThreadAge(threadUpdatedAt(thread))}
515
+ </time>
516
516
  </button>
517
517
  </TooltipTrigger>
518
518
  <TooltipContent side="right">{title}</TooltipContent>
519
519
  </Tooltip>
520
520
  <div className="pointer-events-none absolute end-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
521
- <span className="pointer-events-none pe-1 text-[11px] text-muted-foreground/60 transition-opacity group-hover/item:opacity-0 group-focus-within/item:opacity-0">
522
- {isActive
523
- ? ""
524
- : formatThreadAge(threadUpdatedAt(thread), formatDate)}
525
- </span>
526
521
  <DropdownMenu>
527
522
  <Tooltip>
528
523
  <TooltipTrigger asChild>
@@ -532,7 +527,7 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
532
527
  aria-label={t("dispatch.sidebar.chatOptions", {
533
528
  title,
534
529
  })}
535
- className="pointer-events-auto rounded p-0.5 text-muted-foreground/50 opacity-0 transition-all hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 group-focus-within/item:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground"
530
+ className="pointer-events-auto rounded p-0.5 text-muted-foreground/50 opacity-0 transition-[color,opacity] hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 group-focus-within/item:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground"
536
531
  >
537
532
  <IconDots className="size-3" />
538
533
  </button>
@@ -29,7 +29,7 @@ const SheetOverlay = React.forwardRef<
29
29
  SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
30
30
 
31
31
  const sheetVariants = cva(
32
- "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
32
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-200",
33
33
  {
34
34
  variants: {
35
35
  side: {
@@ -23,7 +23,7 @@ const ToastViewport = React.forwardRef<
23
23
  ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24
24
 
25
25
  const toastVariants = cva(
26
- "group pointer-events-auto relative flex w-full items-center justify-between gap-3 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
26
+ "group pointer-events-auto relative flex w-full items-center justify-between gap-3 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-transform motion-reduce:animate-none motion-reduce:transition-none data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27
27
  {
28
28
  variants: {
29
29
  variant: {
@@ -0,0 +1,80 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { TooltipProvider } from "./ui/tooltip";
7
+ import { WorkspaceAppCard } from "./workspace-app-card";
8
+
9
+ vi.mock("@agent-native/core/client", () => ({
10
+ useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
11
+ useActionQuery: () => ({
12
+ data: { resources: [], counts: {} },
13
+ isLoading: false,
14
+ isError: false,
15
+ error: null,
16
+ refetch: vi.fn(),
17
+ }),
18
+ }));
19
+
20
+ describe("WorkspaceAppCard", () => {
21
+ let container: HTMLDivElement;
22
+ let root: Root;
23
+
24
+ beforeEach(() => {
25
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
26
+ container = document.createElement("div");
27
+ document.body.appendChild(container);
28
+ root = createRoot(container);
29
+ });
30
+
31
+ afterEach(() => {
32
+ act(() => root.unmount());
33
+ container.remove();
34
+ vi.unstubAllGlobals();
35
+ });
36
+
37
+ it("uses one visible, consistent action treatment for context, keys, and more", async () => {
38
+ await act(async () => {
39
+ root.render(
40
+ <TooltipProvider>
41
+ <WorkspaceAppCard
42
+ app={{
43
+ id: "analytics",
44
+ name: "Analytics",
45
+ path: "/analytics",
46
+ description: "Explore product and growth performance.",
47
+ status: "ready",
48
+ }}
49
+ />
50
+ </TooltipProvider>,
51
+ );
52
+ });
53
+
54
+ expect(
55
+ container.querySelector('a[aria-label="Open Analytics"]')?.className,
56
+ ).toContain("focus-visible:ring-2");
57
+
58
+ const actions = [
59
+ container.querySelector<HTMLButtonElement>(
60
+ 'button[aria-label="View context resources for Analytics"]',
61
+ ),
62
+ container.querySelector<HTMLButtonElement>(
63
+ 'button[aria-label="Manage keys for Analytics"]',
64
+ ),
65
+ container.querySelector<HTMLButtonElement>(
66
+ 'button[aria-label="More actions for Analytics"]',
67
+ ),
68
+ ];
69
+
70
+ for (const action of actions) {
71
+ expect(action).not.toBeNull();
72
+ expect(action?.className).toContain("size-7");
73
+ expect(action?.className).toContain("text-muted-foreground");
74
+ expect(action?.className).toContain(
75
+ "transition-[background-color,color]",
76
+ );
77
+ expect(action?.className).not.toContain("opacity-0");
78
+ }
79
+ });
80
+ });
@@ -1,6 +1,5 @@
1
1
  import { useActionMutation, useActionQuery } from "@agent-native/core/client";
2
2
  import {
3
- IconArrowUpRight,
4
3
  IconChevronDown,
5
4
  IconChevronRight,
6
5
  IconClockHour4,
@@ -21,6 +20,7 @@ import {
21
20
  workspaceAppHref,
22
21
  type WorkspaceAppSummary,
23
22
  } from "../lib/workspace-apps";
23
+ import { ActionQueryError } from "./action-query-error";
24
24
  import { AppKeysPopover } from "./app-keys-popover";
25
25
  import { Badge } from "./ui/badge";
26
26
  import { Button } from "./ui/button";
@@ -45,6 +45,9 @@ import { Textarea } from "./ui/textarea";
45
45
  import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
46
46
  import { AppResourceEffectiveStack } from "./workspace-resource-effective-stack";
47
47
 
48
+ const APP_CARD_ACTION_CLASS =
49
+ "size-7 rounded-md p-0 text-muted-foreground transition-[background-color,color] hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-foreground";
50
+
48
51
  export function WorkspaceAppCard({
49
52
  app,
50
53
  className,
@@ -121,7 +124,7 @@ export function WorkspaceAppCard({
121
124
  <div
122
125
  aria-disabled={!href}
123
126
  className={cn(
124
- "group relative rounded-lg border bg-card p-4 transition hover:border-foreground/30 aria-disabled:opacity-60",
127
+ "group relative rounded-xl border border-border/60 bg-card/40 p-4 transition-[background-color,border-color] hover:border-foreground/20 hover:bg-accent/15 focus-within:border-foreground/20 focus-within:bg-accent/15 aria-disabled:opacity-60",
125
128
  isArchived && "opacity-70",
126
129
  className,
127
130
  )}
@@ -132,7 +135,7 @@ export function WorkspaceAppCard({
132
135
  target={openInNewTab ? "_blank" : undefined}
133
136
  rel={openInNewTab ? "noreferrer" : undefined}
134
137
  aria-label={`Open ${app.name}`}
135
- className="absolute inset-0 z-0 rounded-lg"
138
+ className="absolute inset-0 z-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
136
139
  />
137
140
  ) : null}
138
141
 
@@ -164,7 +167,7 @@ export function WorkspaceAppCard({
164
167
  </Badge>
165
168
  ) : null}
166
169
  </div>
167
- <p className="mt-1 truncate font-mono text-xs text-muted-foreground">
170
+ <p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
168
171
  {app.path}
169
172
  </p>
170
173
  {isPending && app.branchName ? (
@@ -173,7 +176,7 @@ export function WorkspaceAppCard({
173
176
  </p>
174
177
  ) : null}
175
178
  {app.description ? (
176
- <p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
179
+ <p className="mt-2 min-h-10 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
177
180
  {app.description}
178
181
  </p>
179
182
  ) : null}
@@ -191,16 +194,24 @@ export function WorkspaceAppCard({
191
194
  ) : null}
192
195
  <div className="pointer-events-auto">
193
196
  <DropdownMenu>
194
- <DropdownMenuTrigger asChild>
195
- <button
196
- type="button"
197
- aria-label={`More actions for ${app.name}`}
198
- className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
199
- onClick={(e) => e.stopPropagation()}
200
- >
201
- <IconDots size={15} />
202
- </button>
203
- </DropdownMenuTrigger>
197
+ <Tooltip>
198
+ <TooltipTrigger asChild>
199
+ <DropdownMenuTrigger asChild>
200
+ <button
201
+ type="button"
202
+ aria-label={`More actions for ${app.name}`}
203
+ className={cn(
204
+ APP_CARD_ACTION_CLASS,
205
+ "inline-flex cursor-pointer items-center justify-center",
206
+ )}
207
+ onClick={(e) => e.stopPropagation()}
208
+ >
209
+ <IconDots size={15} />
210
+ </button>
211
+ </DropdownMenuTrigger>
212
+ </TooltipTrigger>
213
+ <TooltipContent>More actions</TooltipContent>
214
+ </Tooltip>
204
215
  <DropdownMenuContent align="end" className="w-44">
205
216
  <DropdownMenuItem
206
217
  onSelect={(event) => {
@@ -233,12 +244,6 @@ export function WorkspaceAppCard({
233
244
  </DropdownMenuContent>
234
245
  </DropdownMenu>
235
246
  </div>
236
- {href && !isArchived ? (
237
- <IconArrowUpRight
238
- size={16}
239
- className="text-muted-foreground transition group-hover:text-foreground"
240
- />
241
- ) : null}
242
247
  </div>
243
248
  </div>
244
249
  <Dialog open={editOpen} onOpenChange={setEditOpen}>
@@ -290,11 +295,12 @@ function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
290
295
  const [inspectedResourceId, setInspectedResourceId] = useState<string | null>(
291
296
  null,
292
297
  );
293
- const { data, isLoading } = useActionQuery(
298
+ const query = useActionQuery(
294
299
  "list-workspace-resources-for-app",
295
300
  { appId: app.id },
296
301
  { enabled: open },
297
302
  );
303
+ const { data, isLoading } = query;
298
304
 
299
305
  const resources = ((data as any)?.resources ?? []) as any[];
300
306
  const counts = (data as any)?.counts;
@@ -315,14 +321,14 @@ function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
315
321
  variant="ghost"
316
322
  size="sm"
317
323
  aria-label={`View context resources for ${app.name}`}
318
- className="h-7 w-7 p-0"
324
+ className={APP_CARD_ACTION_CLASS}
319
325
  onClick={(e) => e.stopPropagation()}
320
326
  >
321
327
  <IconFileText size={14} />
322
328
  </Button>
323
329
  </DialogTrigger>
324
330
  </TooltipTrigger>
325
- <TooltipContent>Context</TooltipContent>
331
+ <TooltipContent>View context</TooltipContent>
326
332
  </Tooltip>
327
333
  <DialogContent className="max-w-2xl">
328
334
  <DialogHeader>
@@ -349,7 +355,12 @@ function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
349
355
  </Badge>
350
356
  </div>
351
357
 
352
- {isLoading ? (
358
+ {query.isError ? (
359
+ <ActionQueryError
360
+ error={query.error}
361
+ onRetry={() => void query.refetch()}
362
+ />
363
+ ) : isLoading ? (
353
364
  <div className="space-y-2">
354
365
  <div className="h-14 rounded-lg border bg-muted/30" />
355
366
  <div className="h-14 rounded-lg border bg-muted/30" />
@@ -1,6 +1,7 @@
1
1
  import { useActionQuery } from "@agent-native/core/client";
2
2
 
3
3
  import { cn } from "../lib/utils";
4
+ import { ActionQueryError } from "./action-query-error";
4
5
  import { Badge } from "./ui/badge";
5
6
 
6
7
  export function appAvailabilityLabel(value?: string) {
@@ -54,11 +55,12 @@ export function AppResourceEffectiveStack({
54
55
  appId: string;
55
56
  resource: any;
56
57
  }) {
57
- const { data: context, isLoading } = useActionQuery(
58
+ const query = useActionQuery(
58
59
  "get-workspace-resource-effective-context",
59
60
  { resourceId: resource.id, appId },
60
61
  { enabled: !!resource.id },
61
62
  );
63
+ const { data: context, isLoading } = query;
62
64
  const layers = ((context as any)?.layers ?? []) as any[];
63
65
  const active = (context as any)?.effectiveResource;
64
66
  const availability = (context as any)?.availability;
@@ -76,6 +78,16 @@ export function AppResourceEffectiveStack({
76
78
  );
77
79
  }
78
80
 
81
+ if (query.isError) {
82
+ return (
83
+ <ActionQueryError
84
+ className="mt-3"
85
+ error={query.error}
86
+ onRetry={() => void query.refetch()}
87
+ />
88
+ );
89
+ }
90
+
79
91
  return (
80
92
  <div className="mt-3 rounded-lg border bg-muted/20 p-3">
81
93
  <div className="flex flex-wrap items-start justify-between gap-2">
@@ -1,5 +1,6 @@
1
1
  import { useActionQuery } from "@agent-native/core/client";
2
2
 
3
+ import { ActionQueryError } from "./action-query-error";
3
4
  import { Badge } from "./ui/badge";
4
5
  import { Skeleton } from "./ui/skeleton";
5
6
  import { formatResourceTimestamp } from "./workspace-resource-effective-stack";
@@ -32,7 +33,7 @@ export function ImpactPreview({
32
33
  scope?: "all" | "selected";
33
34
  enabled?: boolean;
34
35
  }) {
35
- const { data: impact, isLoading } = useActionQuery(
36
+ const query = useActionQuery(
36
37
  "preview-workspace-resource-change",
37
38
  {
38
39
  operation,
@@ -42,6 +43,7 @@ export function ImpactPreview({
42
43
  },
43
44
  { enabled: enabled && Boolean(resourceId || path) },
44
45
  );
46
+ const { data: impact, isLoading } = query;
45
47
 
46
48
  if (!enabled || (!resourceId && !path)) return null;
47
49
 
@@ -54,6 +56,15 @@ export function ImpactPreview({
54
56
  );
55
57
  }
56
58
 
59
+ if (query.isError) {
60
+ return (
61
+ <ActionQueryError
62
+ error={query.error}
63
+ onRetry={() => void query.refetch()}
64
+ />
65
+ );
66
+ }
67
+
57
68
  const data = impact as any;
58
69
  if (!data) return null;
59
70
  const affectsAllApps = data.affectsAllApps === true;
@@ -14,6 +14,7 @@ import {
14
14
  type LoaderFunctionArgs,
15
15
  } from "react-router";
16
16
 
17
+ import { ActionQueryError } from "../../components/action-query-error";
17
18
  import { DispatchShell } from "../../components/dispatch-shell";
18
19
  import { Badge } from "../../components/ui/badge";
19
20
  import { Button } from "../../components/ui/button";
@@ -96,9 +97,10 @@ export async function clientLoader({
96
97
  export default function WorkspaceAppCatchAllRoute() {
97
98
  const t = useT();
98
99
  const { appId } = useParams();
99
- const { data: apps = [], isLoading } = useActionQuery("list-workspace-apps", {
100
+ const appsQuery = useActionQuery("list-workspace-apps", {
100
101
  includeAgentCards: false,
101
102
  });
103
+ const { data: apps = [], isLoading } = appsQuery;
102
104
  const app = useMemo(
103
105
  () =>
104
106
  (apps as WorkspaceAppSummary[]).find((item) => item.id === appId) ?? null,
@@ -117,6 +119,20 @@ export default function WorkspaceAppCatchAllRoute() {
117
119
  return <Navigate to={appPath("/overview")} replace />;
118
120
  }
119
121
 
122
+ if (appsQuery.isError) {
123
+ return (
124
+ <DispatchShell
125
+ title={t("dispatch.pages.dataLoadFailed")}
126
+ description={t("dispatch.pages.pageNotFoundDescription")}
127
+ >
128
+ <ActionQueryError
129
+ error={appsQuery.error}
130
+ onRetry={() => void appsQuery.refetch()}
131
+ />
132
+ </DispatchShell>
133
+ );
134
+ }
135
+
120
136
  if ((isLoading && !app) || (app && app.status !== "pending" && href)) {
121
137
  return (
122
138
  <div className="flex h-screen w-full items-center justify-center">
@@ -8,6 +8,7 @@ import { IconCheck, IconCopy, IconPlugConnected } from "@tabler/icons-react";
8
8
  import { useMemo, useState } from "react";
9
9
  import { toast } from "sonner";
10
10
 
11
+ import { ActionQueryError } from "../../components/action-query-error";
11
12
  import {
12
13
  AgentsPanel,
13
14
  type ConnectedAgent,
@@ -45,7 +46,10 @@ function dispatchMcpUrl(): string {
45
46
 
46
47
  function DispatchMcpAccessPanel() {
47
48
  const t = useT();
48
- const { data, isLoading } = useActionQuery("list-mcp-app-access", {});
49
+ const { data, isLoading, isError, error, refetch } = useActionQuery(
50
+ "list-mcp-app-access",
51
+ {},
52
+ );
49
53
  const [optimistic, setOptimistic] = useState<McpAccessState | null>(null);
50
54
  const saveAccess = useActionMutation("set-mcp-app-access", {
51
55
  onSuccess: () => {
@@ -78,10 +82,6 @@ function DispatchMcpAccessPanel() {
78
82
  const mcpUrl = dispatchMcpUrl();
79
83
 
80
84
  function persist(next: McpAccessState) {
81
- if (next.mode === "selected-apps" && next.selectedAppIds.length === 0) {
82
- toast.error(t("dispatch.pages.selectAppForMcp"));
83
- return;
84
- }
85
85
  setOptimistic(next);
86
86
  saveAccess.mutate(next);
87
87
  }
@@ -153,7 +153,13 @@ function DispatchMcpAccessPanel() {
153
153
  </Button>
154
154
  </div>
155
155
 
156
- {access.mode === "selected-apps" ? (
156
+ {isError ? (
157
+ <ActionQueryError
158
+ className="mt-4"
159
+ error={error}
160
+ onRetry={() => void refetch()}
161
+ />
162
+ ) : access.mode === "selected-apps" ? (
157
163
  <div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
158
164
  {apps.map((app) => {
159
165
  const isSelected = selected.has(app.id);
@@ -197,7 +203,7 @@ function DispatchMcpAccessPanel() {
197
203
 
198
204
  export default function AgentsRoute() {
199
205
  const t = useT();
200
- const { data, refetch } = useActionQuery("list-connected-agents", {});
206
+ const agentsQuery = useActionQuery("list-connected-agents", {});
201
207
 
202
208
  return (
203
209
  <DispatchShell
@@ -206,10 +212,17 @@ export default function AgentsRoute() {
206
212
  >
207
213
  <div className="space-y-4">
208
214
  <DispatchMcpAccessPanel />
209
- <AgentsPanel
210
- agents={(data || []) as ConnectedAgent[]}
211
- onRefresh={refetch}
212
- />
215
+ {agentsQuery.isError ? (
216
+ <ActionQueryError
217
+ error={agentsQuery.error}
218
+ onRetry={() => void agentsQuery.refetch()}
219
+ />
220
+ ) : (
221
+ <AgentsPanel
222
+ agents={(agentsQuery.data || []) as ConnectedAgent[]}
223
+ onRefresh={agentsQuery.refetch}
224
+ />
225
+ )}
213
226
  </div>
214
227
  </DispatchShell>
215
228
  );
@@ -16,6 +16,7 @@ import {
16
16
  import { useSearchParams } from "react-router";
17
17
  import { toast } from "sonner";
18
18
 
19
+ import { ActionQueryError } from "../../components/action-query-error";
19
20
  import {
20
21
  ApprovalValueBlock,
21
22
  parseApprovalValue,
@@ -66,10 +67,8 @@ export default function ApprovalPreviewRoute() {
66
67
  const [searchParams] = useSearchParams();
67
68
  const id = searchParams.get("id") ?? "";
68
69
 
69
- const { data: approvals, isLoading } = useActionQuery(
70
- "list-dispatch-approvals",
71
- {},
72
- );
70
+ const approvalsQuery = useActionQuery("list-dispatch-approvals", {});
71
+ const { data: approvals, isLoading } = approvalsQuery;
73
72
 
74
73
  const approve = useActionMutation("approve-dispatch-change", {
75
74
  onSuccess: () => toast.success("Change approved"),
@@ -141,6 +140,19 @@ export default function ApprovalPreviewRoute() {
141
140
  );
142
141
  }
143
142
 
143
+ if (approvalsQuery.isError) {
144
+ return (
145
+ <div className="flex min-h-screen items-start justify-center bg-background p-6">
146
+ <div className="w-full max-w-md">
147
+ <ActionQueryError
148
+ error={approvalsQuery.error}
149
+ onRetry={() => void approvalsQuery.refetch()}
150
+ />
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+
144
156
  if (!approval) {
145
157
  return (
146
158
  <div className="flex min-h-screen items-center justify-center bg-background p-6">