@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
@@ -8,9 +8,11 @@ import {
8
8
  import { useMemo, useState, type ReactNode } from "react";
9
9
  import { toast } from "sonner";
10
10
 
11
+ import { ActionQueryError } from "./action-query-error";
11
12
  import { Button } from "./ui/button";
12
13
  import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
13
14
  import { Skeleton } from "./ui/skeleton";
15
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
14
16
 
15
17
  interface VaultSecret {
16
18
  id: string;
@@ -46,23 +48,30 @@ export function AppKeysPopover({
46
48
 
47
49
  return (
48
50
  <Popover open={open} onOpenChange={setOpen}>
49
- <PopoverTrigger asChild>
50
- {trigger ?? (
51
- <button
52
- type="button"
53
- aria-label={`Manage keys for ${appName}`}
54
- onClick={(event) => {
55
- // Keep parent card click handlers from also firing. Do not
56
- // preventDefault here: Radix uses the same click to open the
57
- // popover trigger.
58
- event.stopPropagation();
59
- }}
60
- className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-transparent text-muted-foreground/70 hover:border-border hover:bg-accent/40 hover:text-foreground"
61
- >
62
- <IconSettings size={14} />
63
- </button>
64
- )}
65
- </PopoverTrigger>
51
+ <Tooltip>
52
+ <TooltipTrigger asChild>
53
+ <PopoverTrigger asChild>
54
+ {trigger ?? (
55
+ <Button
56
+ type="button"
57
+ variant="ghost"
58
+ size="sm"
59
+ aria-label={`Manage keys for ${appName}`}
60
+ onClick={(event) => {
61
+ // Keep parent card click handlers from also firing. Do not
62
+ // preventDefault here: Radix uses the same click to open the
63
+ // popover trigger.
64
+ event.stopPropagation();
65
+ }}
66
+ className="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"
67
+ >
68
+ <IconSettings size={14} />
69
+ </Button>
70
+ )}
71
+ </PopoverTrigger>
72
+ </TooltipTrigger>
73
+ <TooltipContent>Manage keys</TooltipContent>
74
+ </Tooltip>
66
75
  <PopoverContent
67
76
  align={align}
68
77
  side={side}
@@ -76,20 +85,23 @@ export function AppKeysPopover({
76
85
  );
77
86
  }
78
87
 
79
- function AppKeysPanel({ appId, appName }: { appId: string; appName: string }) {
80
- const { data: secrets = [], isLoading: secretsLoading } = useActionQuery(
81
- "list-vault-secret-options",
82
- {},
83
- );
88
+ export function AppKeysPanel({
89
+ appId,
90
+ appName,
91
+ }: {
92
+ appId: string;
93
+ appName: string;
94
+ }) {
95
+ const secretsQuery = useActionQuery("list-vault-secret-options", {});
96
+ const grantsQuery = useActionQuery("list-vault-grants", { appId });
97
+ const accessQuery = useActionQuery("get-vault-access-settings", {});
98
+ const { data: secrets = [], isLoading: secretsLoading } = secretsQuery;
84
99
  const {
85
100
  data: grants = [],
86
101
  isLoading: grantsLoading,
87
102
  refetch: refetchGrants,
88
- } = useActionQuery("list-vault-grants", { appId });
89
- const { data: accessSettings, isLoading: accessLoading } = useActionQuery(
90
- "get-vault-access-settings",
91
- {},
92
- );
103
+ } = grantsQuery;
104
+ const { data: accessSettings, isLoading: accessLoading } = accessQuery;
93
105
  const accessMode =
94
106
  (accessSettings as any)?.mode === "manual" ? "manual" : "all-apps";
95
107
 
@@ -139,6 +151,7 @@ function AppKeysPanel({ appId, appName }: { appId: string; appName: string }) {
139
151
  });
140
152
 
141
153
  const isLoading = secretsLoading || grantsLoading || accessLoading;
154
+ const error = secretsQuery.error ?? grantsQuery.error ?? accessQuery.error;
142
155
  const grantedCount = grantBySecretId.size;
143
156
  const typedSecrets = secrets as VaultSecret[];
144
157
  const allApps = accessMode !== "manual";
@@ -164,34 +177,47 @@ function AppKeysPanel({ appId, appName }: { appId: string; appName: string }) {
164
177
  Keys for {appName}
165
178
  </p>
166
179
  <p className="text-[11px] text-muted-foreground">
167
- {allApps
168
- ? `${typedSecrets.length} available`
169
- : `${grantedCount} of ${typedSecrets.length} granted`}
180
+ {error
181
+ ? null
182
+ : allApps
183
+ ? `${typedSecrets.length} available`
184
+ : `${grantedCount} of ${typedSecrets.length} granted`}
170
185
  </p>
171
186
  </div>
172
- <Button
173
- type="button"
174
- variant="outline"
175
- size="sm"
176
- disabled={
177
- syncMutation.isPending ||
178
- typedSecrets.length === 0 ||
179
- (!allApps && grantedCount === 0)
180
- }
181
- onClick={() => syncMutation.mutate({ appId })}
182
- className="h-7 px-2"
183
- >
184
- {syncMutation.isPending ? (
185
- <IconLoader2 className="h-3 w-3 animate-spin" />
186
- ) : (
187
- <IconRefresh className="h-3 w-3" />
188
- )}
189
- <span className="ml-1 text-xs">Sync</span>
190
- </Button>
187
+ {!error ? (
188
+ <Button
189
+ type="button"
190
+ variant="outline"
191
+ size="sm"
192
+ disabled={
193
+ syncMutation.isPending ||
194
+ typedSecrets.length === 0 ||
195
+ (!allApps && grantedCount === 0)
196
+ }
197
+ onClick={() => syncMutation.mutate({ appId })}
198
+ className="h-7 px-2"
199
+ >
200
+ {syncMutation.isPending ? (
201
+ <IconLoader2 className="h-3 w-3 animate-spin" />
202
+ ) : (
203
+ <IconRefresh className="h-3 w-3" />
204
+ )}
205
+ <span className="ml-1 text-xs">Sync</span>
206
+ </Button>
207
+ ) : null}
191
208
  </div>
192
209
 
193
210
  <div className="max-h-[320px] space-y-1.5 overflow-y-auto rounded-md border border-border bg-card p-1.5">
194
- {isLoading ? (
211
+ {error ? (
212
+ <ActionQueryError
213
+ error={error}
214
+ onRetry={() => {
215
+ void secretsQuery.refetch();
216
+ void grantsQuery.refetch();
217
+ void accessQuery.refetch();
218
+ }}
219
+ />
220
+ ) : isLoading ? (
195
221
  <div className="space-y-1.5 p-1.5">
196
222
  {Array.from({ length: 3 }).map((_, index) => (
197
223
  <div
@@ -0,0 +1,106 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { MemoryRouter } from "react-router";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { DispatchControlPlane } from "./dispatch-control-plane";
8
+ import { TooltipProvider } from "./ui/tooltip";
9
+
10
+ const clientState = vi.hoisted(() => ({
11
+ navigateWithTransition: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("@agent-native/core/client", () => ({
15
+ PromptComposer: ({
16
+ onSubmit,
17
+ placeholder,
18
+ }: {
19
+ onSubmit: (value: string) => void;
20
+ placeholder: string;
21
+ }) => (
22
+ <button
23
+ type="button"
24
+ data-placeholder={placeholder}
25
+ onClick={() => onSubmit("Route onboarding work")}
26
+ >
27
+ Composer
28
+ </button>
29
+ ),
30
+ isInBuilderFrame: () => false,
31
+ navigateWithAgentChatViewTransition: (
32
+ navigate: unknown,
33
+ path: string,
34
+ options?: unknown,
35
+ ) => clientState.navigateWithTransition(navigate, path, options),
36
+ useActionQuery: () => ({
37
+ data: [],
38
+ isLoading: false,
39
+ isError: false,
40
+ refetch: vi.fn(),
41
+ }),
42
+ useChatModels: () => ({ selectedModel: "auto" }),
43
+ useT: () => (key: string, values?: { defaultValue?: string }) =>
44
+ values?.defaultValue ?? key,
45
+ }));
46
+
47
+ vi.mock("./create-app-popover", () => ({
48
+ CreateAppPopover: () => <div>Create app</div>,
49
+ }));
50
+
51
+ describe("DispatchControlPlane", () => {
52
+ let container: HTMLDivElement;
53
+ let root: Root;
54
+
55
+ beforeEach(() => {
56
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
57
+ clientState.navigateWithTransition.mockReset();
58
+ container = document.createElement("div");
59
+ document.body.appendChild(container);
60
+ root = createRoot(container);
61
+ });
62
+
63
+ afterEach(() => {
64
+ act(() => root.unmount());
65
+ container.remove();
66
+ vi.unstubAllGlobals();
67
+ });
68
+
69
+ it("renders a minimal Ask surface and transitions submitted prompts into Chat", async () => {
70
+ await act(async () => {
71
+ root.render(
72
+ <MemoryRouter initialEntries={["/overview"]}>
73
+ <TooltipProvider>
74
+ <DispatchControlPlane />
75
+ </TooltipProvider>
76
+ </MemoryRouter>,
77
+ );
78
+ });
79
+
80
+ expect(container.textContent).toContain("Chat across your apps");
81
+ expect(container.textContent).not.toContain("Open chat");
82
+ expect(container.textContent).not.toContain("Also");
83
+ expect(container.textContent).not.toContain("active");
84
+ expect(container.querySelector("nav")).toBeNull();
85
+ expect(
86
+ container.querySelector('[data-placeholder="Ask Dispatch anything..."]'),
87
+ ).not.toBeNull();
88
+
89
+ await act(async () => {
90
+ container.querySelector<HTMLButtonElement>("[data-placeholder]")?.click();
91
+ });
92
+
93
+ expect(clientState.navigateWithTransition).toHaveBeenCalledWith(
94
+ expect.any(Function),
95
+ "/chat",
96
+ expect.objectContaining({
97
+ state: {
98
+ dispatchPrompt: expect.objectContaining({
99
+ message: "Route onboarding work",
100
+ selectedModel: "auto",
101
+ }),
102
+ },
103
+ }),
104
+ );
105
+ });
106
+ });
@@ -1,76 +1,56 @@
1
1
  import {
2
2
  PromptComposer,
3
3
  isInBuilderFrame,
4
+ navigateWithAgentChatViewTransition,
4
5
  useActionQuery,
5
6
  useChatModels,
6
7
  useT,
7
8
  } from "@agent-native/core/client";
8
- import {
9
- IconArrowUpRight,
10
- IconBroadcast,
11
- IconStack3,
12
- type IconProps,
13
- } from "@tabler/icons-react";
9
+ import { IconArrowUpRight } from "@tabler/icons-react";
14
10
  import type { ReactNode } from "react";
15
11
  import { Link, useNavigate } from "react-router";
16
12
 
17
13
  import { submitOverviewPrompt } from "../lib/overview-chat";
18
14
  import type { WorkspaceAppSummary } from "../lib/workspace-apps";
15
+ import { ActionQueryError } from "./action-query-error";
19
16
  import { CreateAppPopover } from "./create-app-popover";
20
- import { DispatchShell } from "./dispatch-shell";
17
+ import { useSetPageTitle } from "./layout/HeaderActions";
21
18
  import { Button } from "./ui/button";
22
19
  import { Skeleton } from "./ui/skeleton";
23
20
  import { WorkspaceAppCard } from "./workspace-app-card";
24
21
 
25
- const PROMPT_SUGGESTIONS = [
26
- "Summarize the current workspace health",
27
- "Create an app for onboarding requests",
28
- "Check which agents can help with analytics",
29
- ];
30
-
31
- const WORKSPACE_LINKS = [
32
- { to: "/automations", labelKey: "dispatch.nav.automations" },
33
- { to: "/approvals", labelKey: "dispatch.nav.approvals" },
34
- { to: "/destinations", labelKey: "dispatch.nav.delivery" },
35
- { to: "/agents", labelKey: "dispatch.nav.agents" },
36
- { to: "/vault", labelKey: "dispatch.nav.vault" },
37
- { to: "/audit", labelKey: "dispatch.nav.audit" },
38
- ] as const;
39
-
40
22
  function SectionHeader({
41
- icon: Icon,
42
23
  title,
43
- detail,
44
24
  action,
45
25
  }: {
46
- icon: React.ComponentType<IconProps>;
47
26
  title: string;
48
- detail?: string;
49
27
  action?: ReactNode;
50
28
  }) {
51
29
  return (
52
30
  <div className="flex min-w-0 items-center justify-between gap-3">
53
- <div className="flex min-w-0 items-center gap-2">
54
- <Icon size={16} className="shrink-0 text-muted-foreground" />
55
- <div className="min-w-0">
56
- <h2 className="truncate text-sm font-semibold text-foreground">
57
- {title}
58
- </h2>
59
- {detail ? (
60
- <p className="mt-0.5 truncate text-xs text-muted-foreground">
61
- {detail}
62
- </p>
63
- ) : null}
64
- </div>
65
- </div>
31
+ <h2 className="truncate text-sm font-semibold text-foreground">
32
+ {title}
33
+ </h2>
66
34
  {action ? <div className="shrink-0">{action}</div> : null}
67
35
  </div>
68
36
  );
69
37
  }
70
38
 
71
39
  function CommandPanel() {
40
+ const t = useT();
72
41
  const { selectedModel } = useChatModels();
73
42
  const navigate = useNavigate();
43
+ const promptSuggestions = [
44
+ t("dispatch.pages.suggestionWorkspaceHealth", {
45
+ defaultValue: "Summarize the current workspace health",
46
+ }),
47
+ t("dispatch.pages.suggestionOnboardingApp", {
48
+ defaultValue: "Create an app for onboarding requests",
49
+ }),
50
+ t("dispatch.pages.suggestionAnalyticsAgents", {
51
+ defaultValue: "Check which agents can help with analytics",
52
+ }),
53
+ ];
74
54
 
75
55
  function send(message: string) {
76
56
  const trimmed = message.trim();
@@ -81,7 +61,7 @@ function CommandPanel() {
81
61
  return;
82
62
  }
83
63
 
84
- navigate("/chat", {
64
+ navigateWithAgentChatViewTransition(navigate, "/chat", {
85
65
  state: {
86
66
  dispatchPrompt: {
87
67
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -93,71 +73,44 @@ function CommandPanel() {
93
73
  }
94
74
 
95
75
  return (
96
- <section className="rounded-lg border bg-card p-4">
97
- <div className="mb-3 flex items-center justify-between gap-3">
98
- <div className="flex items-center gap-2">
99
- <IconBroadcast size={16} className="text-muted-foreground" />
100
- <h2 className="text-sm font-semibold text-foreground">
101
- Ask Dispatch
76
+ <section className="flex flex-col">
77
+ <div className="mx-auto flex w-full max-w-3xl flex-col pt-4 sm:pt-6">
78
+ <div className="mb-5 text-center">
79
+ <h2 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
80
+ {t("dispatch.pages.chatAcrossApps", {
81
+ defaultValue: "Chat across your apps",
82
+ })}
102
83
  </h2>
84
+ <p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
85
+ {t("dispatch.pages.chatAcrossAppsDescription", {
86
+ defaultValue:
87
+ "Route work, inspect status, or create something new from one place.",
88
+ })}
89
+ </p>
90
+ </div>
91
+ <PromptComposer
92
+ placeholder={t("dispatch.pages.overviewPromptPlaceholder", {
93
+ defaultValue: "Ask Dispatch anything...",
94
+ })}
95
+ onSubmit={(text) => send(text)}
96
+ />
97
+ <div className="mt-3 flex flex-wrap justify-center gap-2">
98
+ {promptSuggestions.map((suggestion) => (
99
+ <button
100
+ key={suggestion}
101
+ type="button"
102
+ onClick={() => send(suggestion)}
103
+ className="cursor-pointer rounded-md border border-transparent bg-muted/60 px-2.5 py-1.5 text-xs text-muted-foreground transition-[background-color,color] hover:bg-muted hover:text-foreground"
104
+ >
105
+ {suggestion}
106
+ </button>
107
+ ))}
103
108
  </div>
104
- <Button variant="outline" size="sm" asChild>
105
- <Link to="/chat">
106
- Open chat
107
- <IconArrowUpRight size={14} />
108
- </Link>
109
- </Button>
110
- </div>
111
- <PromptComposer
112
- placeholder="Route work, inspect status, or create an app..."
113
- onSubmit={(text) => send(text)}
114
- />
115
- <div className="mt-3 flex flex-wrap gap-2">
116
- {PROMPT_SUGGESTIONS.map((suggestion) => (
117
- <button
118
- key={suggestion}
119
- type="button"
120
- onClick={() => send(suggestion)}
121
- 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"
122
- >
123
- {suggestion}
124
- </button>
125
- ))}
126
109
  </div>
127
110
  </section>
128
111
  );
129
112
  }
130
113
 
131
- function WorkspaceLinks() {
132
- const t = useT();
133
-
134
- return (
135
- <nav
136
- aria-label={t("dispatch.pages.workspaceShortcutsAria")}
137
- className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
138
- >
139
- <span className="font-medium text-foreground/70">
140
- {t("dispatch.pages.also")}
141
- </span>
142
- {WORKSPACE_LINKS.map((item, index) => (
143
- <span key={item.to} className="inline-flex items-center gap-x-3">
144
- {index > 0 ? (
145
- <span aria-hidden className="text-border">
146
- ·
147
- </span>
148
- ) : null}
149
- <Link
150
- to={item.to}
151
- className="transition hover:text-foreground hover:underline underline-offset-4"
152
- >
153
- {t(item.labelKey)}
154
- </Link>
155
- </span>
156
- ))}
157
- </nav>
158
- );
159
- }
160
-
161
114
  function AppsPanel({
162
115
  apps,
163
116
  isLoading,
@@ -171,11 +124,7 @@ function AppsPanel({
171
124
  return (
172
125
  <section className="flex flex-col gap-3">
173
126
  <SectionHeader
174
- icon={IconStack3}
175
127
  title="Apps"
176
- detail={
177
- visibleApps.length === 1 ? "1 active" : `${visibleApps.length} active`
178
- }
179
128
  action={
180
129
  <Button variant="outline" size="sm" asChild>
181
130
  <Link to="/apps">
@@ -188,7 +137,10 @@ function AppsPanel({
188
137
  {showSkeletons ? (
189
138
  <div className="grid gap-3 md:grid-cols-2">
190
139
  {Array.from({ length: 4 }).map((_, index) => (
191
- <div key={index} className="rounded-lg border bg-card p-4">
140
+ <div
141
+ key={index}
142
+ className="rounded-xl border border-border/60 bg-card/40 p-4"
143
+ >
192
144
  <Skeleton className="h-4 w-32" />
193
145
  <Skeleton className="mt-3 h-3 w-24" />
194
146
  <Skeleton className="mt-3 h-3 w-full" />
@@ -209,20 +161,24 @@ function AppsPanel({
209
161
  }
210
162
 
211
163
  export function DispatchControlPlane() {
212
- const { data: workspaceApps = [], isLoading: appsLoading } = useActionQuery<
213
- WorkspaceAppSummary[]
214
- >("list-workspace-apps", { includeAgentCards: false, includeArchived: true });
164
+ useSetPageTitle(<span aria-hidden />);
165
+ const appsQuery = useActionQuery<WorkspaceAppSummary[]>(
166
+ "list-workspace-apps",
167
+ { includeAgentCards: false, includeArchived: true },
168
+ );
169
+ const { data: workspaceApps = [], isLoading: appsLoading } = appsQuery;
215
170
 
216
171
  return (
217
- <DispatchShell
218
- title="Overview"
219
- description="Ask Dispatch or jump into a workspace app."
220
- >
221
- <div className="flex flex-col gap-6">
222
- <CommandPanel />
223
- <WorkspaceLinks />
172
+ <div className="flex flex-col gap-8">
173
+ <CommandPanel />
174
+ {appsQuery.isError ? (
175
+ <ActionQueryError
176
+ error={appsQuery.error}
177
+ onRetry={() => void appsQuery.refetch()}
178
+ />
179
+ ) : (
224
180
  <AppsPanel apps={workspaceApps ?? []} isLoading={appsLoading} />
225
- </div>
226
- </DispatchShell>
181
+ )}
182
+ </div>
227
183
  );
228
184
  }
@@ -15,3 +15,4 @@ export type {
15
15
  } from "./layout/Layout.js";
16
16
  export { CreateAppPopover, CreateAppFlow } from "./create-app-popover.js";
17
17
  export { AppKeysPopover } from "./app-keys-popover.js";
18
+ export { ActionQueryError } from "./action-query-error.js";
@@ -5,7 +5,7 @@ import { MemoryRouter } from "react-router";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
 
7
7
  import { TooltipProvider } from "../ui/tooltip";
8
- import { NavContent } from "./Layout";
8
+ import { formatThreadAge, NavContent } from "./Layout";
9
9
 
10
10
  const clientState = vi.hoisted(() => ({
11
11
  createThread: vi.fn<() => Promise<string | null>>(),
@@ -66,6 +66,20 @@ vi.mock("@agent-native/core/client/org", () => ({
66
66
  OrgSwitcher: () => <div>Organization</div>,
67
67
  }));
68
68
 
69
+ describe("formatThreadAge", () => {
70
+ const now = 2_000_000_000_000;
71
+
72
+ it.each([
73
+ [0, "now"],
74
+ [2 * 60 * 60_000, "2h"],
75
+ [7 * 24 * 60 * 60_000, "7d"],
76
+ [21 * 24 * 60 * 60_000, "3w"],
77
+ [365 * 24 * 60 * 60_000, "1y"],
78
+ ])("formats %i milliseconds as %s", (elapsed, expected) => {
79
+ expect(formatThreadAge(now - elapsed, now)).toBe(expected);
80
+ });
81
+ });
82
+
69
83
  describe("Dispatch NavContent", () => {
70
84
  let container: HTMLDivElement;
71
85
  let root: Root;
@@ -105,6 +119,25 @@ describe("Dispatch NavContent", () => {
105
119
  vi.unstubAllGlobals();
106
120
  });
107
121
 
122
+ it("puts Overview before Chat in the primary navigation", async () => {
123
+ await act(async () => {
124
+ root.render(
125
+ <MemoryRouter initialEntries={["/overview"]}>
126
+ <TooltipProvider>
127
+ <NavContent />
128
+ </TooltipProvider>
129
+ </MemoryRouter>,
130
+ );
131
+ });
132
+
133
+ const primaryLabels = [...container.querySelectorAll("nav a")].map((link) =>
134
+ link.textContent?.trim(),
135
+ );
136
+ expect(primaryLabels.indexOf("Overview")).toBeLessThan(
137
+ primaryLabels.indexOf("Chat"),
138
+ );
139
+ });
140
+
108
141
  it("uses the quieter Analytics-style chat history and retains thread actions", async () => {
109
142
  await act(async () => {
110
143
  root.render(
@@ -121,6 +154,13 @@ describe("Dispatch NavContent", () => {
121
154
  expect(container.textContent).toContain("Earlier Dispatch work");
122
155
  expect(container.textContent).toContain("New chat");
123
156
  expect(container.textContent).toContain("5m");
157
+ const age = [...container.querySelectorAll("time")].find(
158
+ (element) => element.textContent === "5m",
159
+ );
160
+ expect(age?.className).toContain("w-8");
161
+ expect(age?.className).toContain("shrink-0");
162
+ expect(age?.className).toContain("whitespace-nowrap");
163
+ expect(age?.className).toContain("tabular-nums");
124
164
  expect(
125
165
  [...container.querySelectorAll("div")].some((element) =>
126
166
  element.className.includes("group/item"),
@@ -133,7 +173,7 @@ describe("Dispatch NavContent", () => {
133
173
  expect(container.textContent).not.toContain("Workspace control plane");
134
174
 
135
175
  const threadButton = [...container.querySelectorAll("button")].find(
136
- (button) => button.textContent === "Earlier Dispatch work",
176
+ (button) => button.textContent?.includes("Earlier Dispatch work"),
137
177
  );
138
178
  expect(threadButton).toBeDefined();
139
179
  await act(async () => {