@agent-native/dispatch 0.31.14 → 0.31.16

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 (34) hide show
  1. package/dist/actions/provider-api-register.d.ts +7 -7
  2. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  3. package/dist/components/dispatch-control-plane.js +2 -2
  4. package/dist/components/dispatch-control-plane.js.map +1 -1
  5. package/dist/components/layout/Layout.d.ts +17 -4
  6. package/dist/components/layout/Layout.d.ts.map +1 -1
  7. package/dist/components/layout/Layout.js +45 -20
  8. package/dist/components/layout/Layout.js.map +1 -1
  9. package/dist/components/workspace-app-card.d.ts.map +1 -1
  10. package/dist/components/workspace-app-card.js +18 -1
  11. package/dist/components/workspace-app-card.js.map +1 -1
  12. package/dist/lib/catch-all-target.d.ts.map +1 -1
  13. package/dist/lib/catch-all-target.js +4 -2
  14. package/dist/lib/catch-all-target.js.map +1 -1
  15. package/dist/routes/pages/automations.d.ts.map +1 -1
  16. package/dist/routes/pages/automations.js +19 -16
  17. package/dist/routes/pages/automations.js.map +1 -1
  18. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  19. package/dist/server/lib/app-creation-store.js +31 -10
  20. package/dist/server/lib/app-creation-store.js.map +1 -1
  21. package/package.json +3 -3
  22. package/src/components/dispatch-control-plane.spec.tsx +3 -1
  23. package/src/components/dispatch-control-plane.tsx +2 -1
  24. package/src/components/layout/Layout.spec.tsx +143 -25
  25. package/src/components/layout/Layout.tsx +79 -47
  26. package/src/components/workspace-app-card.tsx +27 -1
  27. package/src/lib/catch-all-target.spec.ts +8 -0
  28. package/src/lib/catch-all-target.ts +4 -2
  29. package/src/routes/pages/automations.spec.tsx +240 -0
  30. package/src/routes/pages/automations.tsx +22 -16
  31. package/src/server/lib/app-creation-store.spec.ts +42 -15
  32. package/src/server/lib/app-creation-store.ts +36 -10
  33. package/src/styles/dispatch-css.spec.ts +3 -0
  34. package/src/styles/dispatch.css +5 -0
@@ -0,0 +1,240 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, type ReactNode } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { MemoryRouter, useLocation, useNavigate } from "react-router";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { automationIdentity } from "../../lib/automation-display";
8
+ import type { DispatchAutomationItem } from "../../lib/automations";
9
+ import AutomationsRoute from "./automations";
10
+
11
+ const automationA: DispatchAutomationItem = {
12
+ id: "auto-a",
13
+ name: "Nightly digest",
14
+ path: "jobs/nightly-digest.md",
15
+ owner: "user@example.com",
16
+ appId: "dispatch",
17
+ scope: "personal",
18
+ enabled: true,
19
+ triggerType: "schedule",
20
+ schedule: "0 6 * * *",
21
+ scheduleDescription: "Every day at 6am",
22
+ lastStatus: "success",
23
+ };
24
+
25
+ const automationB: DispatchAutomationItem = {
26
+ id: "auto-b",
27
+ name: "Error triage",
28
+ path: "jobs/error-triage.md",
29
+ owner: "user@example.com",
30
+ appId: "dispatch",
31
+ scope: "personal",
32
+ enabled: true,
33
+ triggerType: "event",
34
+ event: "issue.created",
35
+ lastStatus: "success",
36
+ };
37
+
38
+ // Real @tanstack/react-query is not exercised here — the route reads its
39
+ // list straight from useQuery, so the hook itself is stubbed with a fixed
40
+ // two-item list rather than standing up a QueryClient + network mock.
41
+ vi.mock("@tanstack/react-query", () => ({
42
+ useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
43
+ if (String(queryKey[0]).includes("automations")) {
44
+ return {
45
+ data: [automationA, automationB],
46
+ isLoading: false,
47
+ isError: false,
48
+ error: null,
49
+ };
50
+ }
51
+ return { data: undefined, isLoading: false, isError: false, error: null };
52
+ },
53
+ useMutation: () => ({
54
+ mutate: vi.fn(),
55
+ isPending: false,
56
+ variables: undefined,
57
+ }),
58
+ useQueryClient: () => ({
59
+ cancelQueries: vi.fn(),
60
+ getQueriesData: vi.fn(() => []),
61
+ setQueriesData: vi.fn(),
62
+ invalidateQueries: vi.fn(),
63
+ }),
64
+ }));
65
+
66
+ vi.mock("@agent-native/core/client/hooks", () => ({
67
+ useChangeVersions: () => 0,
68
+ }));
69
+
70
+ vi.mock("@agent-native/core/client/agent-chat", () => ({
71
+ sendToAgentChat: vi.fn(),
72
+ }));
73
+
74
+ vi.mock("@agent-native/core/client/composer", () => ({
75
+ PromptComposer: () => null,
76
+ }));
77
+
78
+ vi.mock("../../components/dispatch-shell", () => ({
79
+ DispatchShell: ({ children }: { children: ReactNode }) => (
80
+ <main>{children}</main>
81
+ ),
82
+ }));
83
+
84
+ vi.mock("../../components/automation-details-panel", () => ({
85
+ AutomationDetailsPanel: ({
86
+ automation,
87
+ }: {
88
+ automation: DispatchAutomationItem;
89
+ }) => <div data-testid="details-panel">{automation.name}</div>,
90
+ }));
91
+
92
+ function LocationProbe() {
93
+ const location = useLocation();
94
+ return (
95
+ <div data-testid="location">{location.pathname + location.search}</div>
96
+ );
97
+ }
98
+
99
+ function GoBackProbe() {
100
+ const navigate = useNavigate();
101
+ return (
102
+ <button type="button" data-testid="go-back" onClick={() => navigate(-1)}>
103
+ Back
104
+ </button>
105
+ );
106
+ }
107
+
108
+ describe("AutomationsRoute", () => {
109
+ let container: HTMLDivElement;
110
+ let root: Root;
111
+
112
+ beforeEach(() => {
113
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
114
+ container = document.createElement("div");
115
+ document.body.appendChild(container);
116
+ root = createRoot(container);
117
+ });
118
+
119
+ afterEach(() => {
120
+ act(() => root.unmount());
121
+ container.remove();
122
+ vi.unstubAllGlobals();
123
+ });
124
+
125
+ function locationText() {
126
+ return container.querySelector('[data-testid="location"]')?.textContent;
127
+ }
128
+
129
+ function findRowButton(name: string) {
130
+ return [...container.querySelectorAll("button")].find((button) =>
131
+ button.textContent?.includes(name),
132
+ );
133
+ }
134
+
135
+ it("writes the selected automation into the URL so it survives reload and back navigation", async () => {
136
+ await act(async () => {
137
+ root.render(
138
+ <MemoryRouter initialEntries={["/automations"]}>
139
+ <AutomationsRoute />
140
+ <LocationProbe />
141
+ </MemoryRouter>,
142
+ );
143
+ });
144
+
145
+ expect(locationText()).toBe("/automations");
146
+
147
+ const row = findRowButton(automationA.name);
148
+ expect(row).toBeTruthy();
149
+
150
+ await act(async () => {
151
+ row?.click();
152
+ });
153
+
154
+ expect(
155
+ container.querySelector('[data-testid="details-panel"]')?.textContent,
156
+ ).toBe(automationA.name);
157
+ // Selecting a row must push the identity into the address bar — a plain
158
+ // local-state selection leaves the URL unchanged, which is exactly the
159
+ // bug this test guards against (no way to link/reload/Back to a row).
160
+ expect(locationText()).toBe(
161
+ `/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`,
162
+ );
163
+
164
+ const otherRow = findRowButton(automationB.name);
165
+ await act(async () => {
166
+ otherRow?.click();
167
+ });
168
+
169
+ expect(locationText()).toBe(
170
+ `/automations?automationId=${encodeURIComponent(automationIdentity(automationB))}`,
171
+ );
172
+ });
173
+
174
+ it("pushes a history entry per row selection so Back steps through prior selections", async () => {
175
+ await act(async () => {
176
+ root.render(
177
+ <MemoryRouter initialEntries={["/automations"]}>
178
+ <AutomationsRoute />
179
+ <LocationProbe />
180
+ <GoBackProbe />
181
+ </MemoryRouter>,
182
+ );
183
+ });
184
+
185
+ const goBack = container.querySelector<HTMLButtonElement>(
186
+ '[data-testid="go-back"]',
187
+ );
188
+ expect(goBack).toBeTruthy();
189
+
190
+ await act(async () => {
191
+ findRowButton(automationA.name)?.click();
192
+ });
193
+ expect(locationText()).toBe(
194
+ `/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`,
195
+ );
196
+
197
+ await act(async () => {
198
+ findRowButton(automationB.name)?.click();
199
+ });
200
+ expect(locationText()).toBe(
201
+ `/automations?automationId=${encodeURIComponent(automationIdentity(automationB))}`,
202
+ );
203
+
204
+ // A row click is an explicit user selection, not URL canonicalization —
205
+ // it must push a new history entry so Back steps back through the prior
206
+ // selection (A) instead of skipping past the whole page's history in one
207
+ // jump. Replacing on every click (the bug this guards against) collapses
208
+ // all selections into a single entry.
209
+ await act(async () => {
210
+ goBack?.click();
211
+ });
212
+ expect(locationText()).toBe(
213
+ `/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`,
214
+ );
215
+
216
+ await act(async () => {
217
+ goBack?.click();
218
+ });
219
+ expect(locationText()).toBe("/automations");
220
+ });
221
+
222
+ it("reopens the matching detail panel when automationId is already in the URL", async () => {
223
+ const targetId = automationIdentity(automationB);
224
+ await act(async () => {
225
+ root.render(
226
+ <MemoryRouter
227
+ initialEntries={[
228
+ `/automations?automationId=${encodeURIComponent(targetId)}`,
229
+ ]}
230
+ >
231
+ <AutomationsRoute />
232
+ </MemoryRouter>,
233
+ );
234
+ });
235
+
236
+ expect(
237
+ container.querySelector('[data-testid="details-panel"]')?.textContent,
238
+ ).toBe(automationB.name);
239
+ });
240
+ });
@@ -9,8 +9,8 @@ import {
9
9
  IconSettingsAutomation,
10
10
  } from "@tabler/icons-react";
11
11
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
12
- import { useEffect, useMemo, useState } from "react";
13
- import { Link } from "react-router";
12
+ import { useMemo, useState } from "react";
13
+ import { Link, useSearchParams } from "react-router";
14
14
  import { toast } from "sonner";
15
15
 
16
16
  import { AutomationDetailsPanel } from "../../components/automation-details-panel";
@@ -189,10 +189,9 @@ function CreateAutomationButton() {
189
189
  }
190
190
 
191
191
  export default function AutomationsRoute() {
192
+ const [searchParams, setSearchParams] = useSearchParams();
192
193
  const [view, setView] = useState<"dispatch" | "all">("dispatch");
193
194
  const [query, setQuery] = useState("");
194
- const [detailsTarget, setDetailsTarget] =
195
- useState<DispatchAutomationItem | null>(null);
196
195
  const automationsQuery = useAutomations();
197
196
  const toggleAutomation = useToggleAutomation();
198
197
  const automations = automationsQuery.data ?? [];
@@ -238,17 +237,24 @@ export default function AutomationsRoute() {
238
237
  : null
239
238
  : null;
240
239
 
241
- useEffect(() => {
242
- if (!detailsTarget) return;
243
- const current = filtered.find(
244
- (item) => automationIdentity(item) === automationIdentity(detailsTarget),
245
- );
246
- if (current) {
247
- if (current !== detailsTarget) setDetailsTarget(current);
248
- } else {
249
- setDetailsTarget(null);
250
- }
251
- }, [detailsTarget, filtered]);
240
+ // URL-backed selection (mirrors dreams.tsx's `?dreamId=`): the currently
241
+ // open automation lives in `automationId` so it survives reload, Back, and
242
+ // sharing a link, instead of vanishing local state.
243
+ const selectedAutomationId = searchParams.get("automationId");
244
+ const detailsTarget = selectedAutomationId
245
+ ? (filtered.find(
246
+ (item) => automationIdentity(item) === selectedAutomationId,
247
+ ) ?? null)
248
+ : null;
249
+
250
+ function selectAutomation(item: DispatchAutomationItem) {
251
+ // Push, don't replace: each row click is an explicit selection the user
252
+ // should be able to Back out of one step at a time, not a URL
253
+ // canonicalization that should collapse into the current entry.
254
+ const next = new URLSearchParams(searchParams);
255
+ next.set("automationId", automationIdentity(item));
256
+ setSearchParams(next);
257
+ }
252
258
 
253
259
  return (
254
260
  <DispatchShell
@@ -334,7 +340,7 @@ export default function AutomationsRoute() {
334
340
  type="button"
335
341
  className="w-full min-w-0 cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
336
342
  aria-pressed={isSelected}
337
- onClick={() => setDetailsTarget(item)}
343
+ onClick={() => selectAutomation(item)}
338
344
  >
339
345
  <div className="flex min-w-0 items-center gap-2">
340
346
  <StatusDot tone={status.tone} />
@@ -321,23 +321,32 @@ describe("listWorkspaceApps", () => {
321
321
  ]);
322
322
  });
323
323
 
324
- it("falls back to the authenticated workspace action for hosted gateways", async () => {
325
- const fetchMock = vi
326
- .fn()
327
- .mockResolvedValueOnce(new Response("not found", { status: 404 }))
328
- .mockResolvedValueOnce(
329
- new Response(
324
+ it("uses the authenticated workspace action for hosted gateways", async () => {
325
+ const fetchMock = vi.fn(async (url: string | URL) => {
326
+ if (String(url).includes("/_workspace/apps")) {
327
+ return new Response(
330
328
  JSON.stringify([
331
329
  {
332
- id: "atlas",
333
- name: "Atlas",
334
- path: "/atlas",
335
- url: "https://agent-workspace.builder.io/atlas",
330
+ id: "private-app",
331
+ name: "Private app",
332
+ path: "/private-app",
336
333
  },
337
334
  ]),
338
335
  { headers: { "content-type": "application/json" } },
339
- ),
336
+ );
337
+ }
338
+ return new Response(
339
+ JSON.stringify([
340
+ {
341
+ id: "atlas",
342
+ name: "Atlas",
343
+ path: "/atlas",
344
+ url: "https://agent-workspace.builder.io/atlas",
345
+ },
346
+ ]),
347
+ { headers: { "content-type": "application/json" } },
340
348
  );
349
+ });
341
350
  vi.stubGlobal("fetch", fetchMock);
342
351
  vi.stubEnv("A2A_SECRET", "test-a2a-secret");
343
352
  vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
@@ -347,11 +356,11 @@ describe("listWorkspaceApps", () => {
347
356
  () => listWorkspaceApps({ includeAgentCards: false }),
348
357
  );
349
358
 
350
- expect(fetchMock).toHaveBeenCalledTimes(2);
351
- expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
359
+ expect(fetchMock).toHaveBeenCalledTimes(1);
360
+ expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
352
361
  "https://agent-workspace.builder.io/_agent-native/actions/list-workspace-apps?includeAgentCards=false&audience=all",
353
362
  );
354
- expect(fetchMock.mock.calls[1]?.[1]).toEqual(
363
+ expect(fetchMock.mock.calls[0]?.[1]).toEqual(
355
364
  expect.objectContaining({
356
365
  headers: expect.objectContaining({
357
366
  accept: "application/json",
@@ -363,6 +372,24 @@ describe("listWorkspaceApps", () => {
363
372
  expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
364
373
  });
365
374
 
375
+ it("does not recursively call the hosted registry action", async () => {
376
+ const fetchMock = vi.fn();
377
+ vi.stubGlobal("fetch", fetchMock);
378
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
379
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
380
+
381
+ const apps = await runWithRequestContext(
382
+ {
383
+ userEmail: "dev@example.test",
384
+ requestOrigin: "https://agent-workspace.builder.io",
385
+ },
386
+ () => listWorkspaceApps({ includeAgentCards: false }),
387
+ );
388
+
389
+ expect(fetchMock).not.toHaveBeenCalled();
390
+ expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
391
+ });
392
+
366
393
  it("keeps the exact request org in hosted registry tokens", async () => {
367
394
  const fetchMock = vi
368
395
  .fn()
@@ -381,7 +408,7 @@ describe("listWorkspaceApps", () => {
381
408
  () => listWorkspaceApps({ includeAgentCards: false }),
382
409
  );
383
410
 
384
- const authorization = fetchMock.mock.calls[1]?.[1]?.headers
411
+ const authorization = fetchMock.mock.calls[0]?.[1]?.headers
385
412
  ?.Authorization as string;
386
413
  const tokenPayload = JSON.parse(
387
414
  Buffer.from(
@@ -475,6 +475,16 @@ function workspaceAppUrl(appPath: string): string | null {
475
475
  }
476
476
  }
477
477
 
478
+ function isLocalWorkspaceGateway(url: URL): boolean {
479
+ const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
480
+ return (
481
+ hostname === "localhost" ||
482
+ hostname === "127.0.0.1" ||
483
+ hostname === "0.0.0.0" ||
484
+ hostname === "::1"
485
+ );
486
+ }
487
+
478
488
  function workspaceAppLink(
479
489
  appPath: string,
480
490
  explicitUrl?: unknown,
@@ -1530,19 +1540,35 @@ async function readWorkspaceAppsFromGateway(): Promise<
1530
1540
  };
1531
1541
 
1532
1542
  try {
1533
- const localResponse = await fetch(gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH), {
1534
- headers,
1535
- signal: controller.signal,
1536
- });
1537
- if (localResponse.ok) {
1538
- return parseWorkspaceAppsManifest(
1539
- // coercion-ok: malformed gateway JSON is an unavailable registry and
1540
- // must fall through to the local manifest sources.
1541
- await localResponse.json().catch(() => null),
1543
+ if (isLocalWorkspaceGateway(baseUrl)) {
1544
+ const localResponse = await fetch(
1545
+ gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH),
1546
+ {
1547
+ headers,
1548
+ signal: controller.signal,
1549
+ },
1542
1550
  );
1551
+ if (localResponse.ok) {
1552
+ return parseWorkspaceAppsManifest(
1553
+ // coercion-ok: malformed gateway JSON is an unavailable registry and
1554
+ // must fall through to the local manifest sources.
1555
+ await localResponse.json().catch(() => null),
1556
+ );
1557
+ }
1543
1558
  }
1544
1559
 
1545
- if (!authHeaders.Authorization) return null;
1560
+ const requestOrigin = requestContext?.requestOrigin;
1561
+ const isSameOriginGateway = requestOrigin
1562
+ ? (() => {
1563
+ try {
1564
+ return new URL(requestOrigin).origin === baseUrl.origin;
1565
+ } catch {
1566
+ // coercion-ok: an invalid request origin cannot prove a self-fetch.
1567
+ return false;
1568
+ }
1569
+ })()
1570
+ : false;
1571
+ if (isSameOriginGateway || !authHeaders.Authorization) return null;
1546
1572
  const actionUrl = gatewayUrl(WORKSPACE_APPS_ACTION_PATH);
1547
1573
  actionUrl.searchParams.set("includeAgentCards", "false");
1548
1574
  actionUrl.searchParams.set("audience", "all");
@@ -27,6 +27,9 @@ describe("dispatch Tailwind styles", () => {
27
27
  '@source "../components/**/*.{js,mjs,ts,tsx}"',
28
28
  );
29
29
  expect(stylesheet).toContain('@source "../routes/**/*.{js,mjs,ts,tsx}"');
30
+ expect(stylesheet).toContain(
31
+ "body:has(.fixed.bottom-3.left-3) [data-dispatch-sidebar-footer]",
32
+ );
30
33
  });
31
34
 
32
35
  it("imports package source directives from the Dispatch template", () => {
@@ -40,6 +40,11 @@
40
40
  font-weight: 600;
41
41
  }
42
42
 
43
+ body:has(.fixed.bottom-3.left-3) [data-dispatch-sidebar-footer] {
44
+ /* The fixed environment badge only needs this space when it is rendered. */
45
+ padding-bottom: 2.5rem;
46
+ }
47
+
43
48
  @media (max-width: 767px) {
44
49
  .dispatch-chat-first-surface {
45
50
  position: relative;