@agent-native/dispatch 0.14.3 → 0.14.5

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 (140) hide show
  1. package/README.md +114 -128
  2. package/dist/actions/list_apps.d.ts +1 -0
  3. package/dist/actions/list_apps.js +16 -8
  4. package/dist/actions/list_apps.js.map +1 -1
  5. package/dist/actions/set-mcp-app-access.js +1 -4
  6. package/dist/actions/set-mcp-app-access.js.map +1 -1
  7. package/dist/components/action-query-error.d.ts +8 -0
  8. package/dist/components/action-query-error.d.ts.map +1 -0
  9. package/dist/components/action-query-error.js +10 -0
  10. package/dist/components/action-query-error.js.map +1 -0
  11. package/dist/components/app-keys-popover.d.ts +4 -0
  12. package/dist/components/app-keys-popover.d.ts.map +1 -1
  13. package/dist/components/app-keys-popover.js +26 -14
  14. package/dist/components/app-keys-popover.js.map +1 -1
  15. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  16. package/dist/components/dispatch-control-plane.js +31 -27
  17. package/dist/components/dispatch-control-plane.js.map +1 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.d.ts.map +1 -1
  20. package/dist/components/index.js +1 -0
  21. package/dist/components/index.js.map +1 -1
  22. package/dist/components/layout/Layout.d.ts +1 -0
  23. package/dist/components/layout/Layout.d.ts.map +1 -1
  24. package/dist/components/layout/Layout.js +19 -21
  25. package/dist/components/layout/Layout.js.map +1 -1
  26. package/dist/components/task-queue-health.d.ts.map +1 -1
  27. package/dist/components/task-queue-health.js +9 -1
  28. package/dist/components/task-queue-health.js.map +1 -1
  29. package/dist/components/ui/sheet.js +1 -1
  30. package/dist/components/ui/sheet.js.map +1 -1
  31. package/dist/components/ui/toast.js +1 -1
  32. package/dist/components/ui/toast.js.map +1 -1
  33. package/dist/components/workspace-app-card.d.ts.map +1 -1
  34. package/dist/components/workspace-app-card.js +8 -5
  35. package/dist/components/workspace-app-card.js.map +1 -1
  36. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  37. package/dist/components/workspace-resource-effective-stack.js +6 -1
  38. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  39. package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
  40. package/dist/components/workspace-resource-impact-preview.js +6 -1
  41. package/dist/components/workspace-resource-impact-preview.js.map +1 -1
  42. package/dist/routes/pages/$appId.d.ts.map +1 -1
  43. package/dist/routes/pages/$appId.js +6 -1
  44. package/dist/routes/pages/$appId.js.map +1 -1
  45. package/dist/routes/pages/agents.d.ts.map +1 -1
  46. package/dist/routes/pages/agents.js +5 -8
  47. package/dist/routes/pages/agents.js.map +1 -1
  48. package/dist/routes/pages/approval.d.ts.map +1 -1
  49. package/dist/routes/pages/approval.js +6 -1
  50. package/dist/routes/pages/approval.js.map +1 -1
  51. package/dist/routes/pages/approvals.d.ts.map +1 -1
  52. package/dist/routes/pages/approvals.js +8 -5
  53. package/dist/routes/pages/approvals.js.map +1 -1
  54. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  55. package/dist/routes/pages/apps.$appId.js +4 -2
  56. package/dist/routes/pages/apps.$appId.js.map +1 -1
  57. package/dist/routes/pages/apps.d.ts.map +1 -1
  58. package/dist/routes/pages/apps.js +10 -42
  59. package/dist/routes/pages/apps.js.map +1 -1
  60. package/dist/routes/pages/audit.d.ts.map +1 -1
  61. package/dist/routes/pages/audit.js +4 -2
  62. package/dist/routes/pages/audit.js.map +1 -1
  63. package/dist/routes/pages/automations.d.ts.map +1 -1
  64. package/dist/routes/pages/automations.js +0 -1
  65. package/dist/routes/pages/automations.js.map +1 -1
  66. package/dist/routes/pages/chat.d.ts.map +1 -1
  67. package/dist/routes/pages/chat.js +16 -2
  68. package/dist/routes/pages/chat.js.map +1 -1
  69. package/dist/routes/pages/destinations.d.ts.map +1 -1
  70. package/dist/routes/pages/destinations.js +5 -3
  71. package/dist/routes/pages/destinations.js.map +1 -1
  72. package/dist/routes/pages/identities.d.ts.map +1 -1
  73. package/dist/routes/pages/identities.js +5 -3
  74. package/dist/routes/pages/identities.js.map +1 -1
  75. package/dist/routes/pages/integrations.d.ts.map +1 -1
  76. package/dist/routes/pages/integrations.js +9 -3
  77. package/dist/routes/pages/integrations.js.map +1 -1
  78. package/dist/routes/pages/metrics.d.ts.map +1 -1
  79. package/dist/routes/pages/metrics.js +1 -1
  80. package/dist/routes/pages/metrics.js.map +1 -1
  81. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  82. package/dist/routes/pages/thread-debug.js +7 -6
  83. package/dist/routes/pages/thread-debug.js.map +1 -1
  84. package/dist/routes/pages/vault.d.ts.map +1 -1
  85. package/dist/routes/pages/vault.js +23 -7
  86. package/dist/routes/pages/vault.js.map +1 -1
  87. package/dist/routes/pages/workspace.d.ts.map +1 -1
  88. package/dist/routes/pages/workspace.js +9 -3
  89. package/dist/routes/pages/workspace.js.map +1 -1
  90. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  91. package/dist/server/lib/dispatch-store.js +2 -2
  92. package/dist/server/lib/dispatch-store.js.map +1 -1
  93. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  94. package/dist/server/lib/mcp-access-store.js +30 -2
  95. package/dist/server/lib/mcp-access-store.js.map +1 -1
  96. package/dist/server/plugins/agent-chat.d.ts +1 -1
  97. package/package.json +2 -2
  98. package/src/actions/list_apps.spec.ts +52 -0
  99. package/src/actions/list_apps.ts +16 -8
  100. package/src/actions/set-mcp-app-access.spec.ts +73 -0
  101. package/src/actions/set-mcp-app-access.ts +1 -5
  102. package/src/components/action-query-error.spec.tsx +56 -0
  103. package/src/components/action-query-error.tsx +32 -0
  104. package/src/components/app-keys-popover.spec.tsx +85 -0
  105. package/src/components/app-keys-popover.tsx +76 -50
  106. package/src/components/dispatch-control-plane.spec.tsx +106 -0
  107. package/src/components/dispatch-control-plane.tsx +71 -115
  108. package/src/components/index.ts +1 -0
  109. package/src/components/layout/Layout.spec.tsx +42 -2
  110. package/src/components/layout/Layout.tsx +22 -27
  111. package/src/components/task-queue-health.tsx +9 -1
  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 +13 -177
  124. package/src/routes/pages/audit.tsx +29 -20
  125. package/src/routes/pages/automations.tsx +0 -1
  126. package/src/routes/pages/chat.spec.tsx +94 -0
  127. package/src/routes/pages/chat.tsx +30 -10
  128. package/src/routes/pages/destinations.tsx +75 -62
  129. package/src/routes/pages/identities.tsx +74 -63
  130. package/src/routes/pages/integrations.tsx +17 -10
  131. package/src/routes/pages/metrics.tsx +0 -1
  132. package/src/routes/pages/thread-debug.tsx +18 -12
  133. package/src/routes/pages/vault.tsx +59 -26
  134. package/src/routes/pages/workspace.tsx +69 -55
  135. package/src/server/lib/approval-fencing.spec.ts +113 -2
  136. package/src/server/lib/dispatch-store.ts +2 -0
  137. package/src/server/lib/mcp-access-store.spec.ts +116 -4
  138. package/src/server/lib/mcp-access-store.ts +37 -2
  139. package/src/server/lib/mcp-gateway.spec.ts +79 -3
  140. package/src/styles/dispatch.css +5 -5
@@ -0,0 +1,56 @@
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 { ActionQueryError } from "./action-query-error";
7
+
8
+ vi.mock("@agent-native/core/client", () => ({
9
+ useT: () => (key: string) =>
10
+ ({
11
+ "dispatch.pages.dataLoadFailed": "Couldn't load data",
12
+ "dispatch.pages.dataLoadFailedDescription":
13
+ "Dispatch couldn't load this data.",
14
+ "dispatch.pages.tryAgain": "Try again",
15
+ })[key] ?? key,
16
+ }));
17
+
18
+ describe("ActionQueryError", () => {
19
+ let container: HTMLDivElement;
20
+ let root: Root;
21
+
22
+ beforeEach(() => {
23
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
24
+ container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ root = createRoot(container);
27
+ });
28
+
29
+ afterEach(() => {
30
+ act(() => root.unmount());
31
+ container.remove();
32
+ vi.unstubAllGlobals();
33
+ });
34
+
35
+ it("shows a safe query error and retries on request", () => {
36
+ const onRetry = vi.fn();
37
+ act(() => {
38
+ root.render(
39
+ <ActionQueryError
40
+ error={new Error("Database unavailable")}
41
+ onRetry={onRetry}
42
+ />,
43
+ );
44
+ });
45
+
46
+ expect(container.textContent).toContain("Couldn't load data");
47
+ expect(container.textContent).toContain(
48
+ "Dispatch couldn't load this data.",
49
+ );
50
+ expect(container.textContent).not.toContain("Database unavailable");
51
+ act(() => {
52
+ container.querySelector("button")?.click();
53
+ });
54
+ expect(onRetry).toHaveBeenCalledOnce();
55
+ });
56
+ });
@@ -0,0 +1,32 @@
1
+ import { useT } from "@agent-native/core/client";
2
+ import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
3
+
4
+ import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
5
+ import { Button } from "./ui/button";
6
+
7
+ interface ActionQueryErrorProps {
8
+ error?: unknown;
9
+ onRetry: () => unknown;
10
+ className?: string;
11
+ }
12
+
13
+ export function ActionQueryError({
14
+ onRetry,
15
+ className,
16
+ }: ActionQueryErrorProps) {
17
+ const t = useT();
18
+
19
+ return (
20
+ <Alert variant="destructive" className={className}>
21
+ <IconAlertTriangle className="size-4" />
22
+ <AlertTitle>{t("dispatch.pages.dataLoadFailed")}</AlertTitle>
23
+ <AlertDescription className="flex flex-wrap items-center justify-between gap-3">
24
+ <span>{t("dispatch.pages.dataLoadFailedDescription")}</span>
25
+ <Button type="button" variant="outline" size="sm" onClick={onRetry}>
26
+ <IconRefresh className="size-4" />
27
+ {t("dispatch.pages.tryAgain")}
28
+ </Button>
29
+ </AlertDescription>
30
+ </Alert>
31
+ );
32
+ }
@@ -0,0 +1,85 @@
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 { AppKeysPanel } from "./app-keys-popover";
7
+
8
+ const queryState = vi.hoisted(() => ({
9
+ refetchSecrets: vi.fn(),
10
+ refetchGrants: vi.fn(),
11
+ refetchAccess: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("@agent-native/core/client", () => ({
15
+ useT: () => (key: string) =>
16
+ ({
17
+ "dispatch.pages.dataLoadFailed": "Couldn't load data",
18
+ "dispatch.pages.dataLoadFailedDescription":
19
+ "Dispatch couldn't load this data.",
20
+ "dispatch.pages.tryAgain": "Try again",
21
+ })[key] ?? key,
22
+ useActionQuery: (name: string) => {
23
+ const queries = {
24
+ "list-vault-secret-options": {
25
+ data: undefined,
26
+ isLoading: false,
27
+ error: new Error("Vault unavailable"),
28
+ refetch: queryState.refetchSecrets,
29
+ },
30
+ "list-vault-grants": {
31
+ data: [],
32
+ isLoading: false,
33
+ error: null,
34
+ refetch: queryState.refetchGrants,
35
+ },
36
+ "get-vault-access-settings": {
37
+ data: undefined,
38
+ isLoading: false,
39
+ error: null,
40
+ refetch: queryState.refetchAccess,
41
+ },
42
+ };
43
+ return queries[name as keyof typeof queries];
44
+ },
45
+ useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
46
+ }));
47
+
48
+ describe("AppKeysPanel", () => {
49
+ let container: HTMLDivElement;
50
+ let root: Root;
51
+
52
+ beforeEach(() => {
53
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
54
+ vi.clearAllMocks();
55
+ container = document.createElement("div");
56
+ document.body.appendChild(container);
57
+ root = createRoot(container);
58
+ });
59
+
60
+ afterEach(() => {
61
+ act(() => root.unmount());
62
+ container.remove();
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ it("shows a retryable error instead of an empty key list", () => {
67
+ act(() => {
68
+ root.render(<AppKeysPanel appId="mail" appName="Mail" />);
69
+ });
70
+
71
+ expect(container.textContent).toContain("Couldn't load data");
72
+ expect(container.textContent).toContain(
73
+ "Dispatch couldn't load this data.",
74
+ );
75
+ expect(container.textContent).not.toContain("Vault unavailable");
76
+ expect(container.textContent).not.toContain("No vault keys yet");
77
+
78
+ act(() => {
79
+ container.querySelector("button")?.click();
80
+ });
81
+ expect(queryState.refetchSecrets).toHaveBeenCalledOnce();
82
+ expect(queryState.refetchGrants).toHaveBeenCalledOnce();
83
+ expect(queryState.refetchAccess).toHaveBeenCalledOnce();
84
+ });
85
+ });
@@ -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
+ });