@agent-native/dispatch 0.40.13 → 0.40.14-nightly-20261006010750

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/CHANGELOG.md +12 -0
  2. package/dist/components/connected-app-card.d.ts.map +1 -1
  3. package/dist/components/connected-app-card.js +23 -4
  4. package/dist/components/connected-app-card.js.map +1 -1
  5. package/dist/components/layout/Layout.d.ts.map +1 -1
  6. package/dist/components/layout/Layout.js +116 -60
  7. package/dist/components/layout/Layout.js.map +1 -1
  8. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  9. package/dist/components/layout/workspace-apps-rail.js +35 -9
  10. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  11. package/dist/lib/other-apps.d.ts +1 -0
  12. package/dist/lib/other-apps.d.ts.map +1 -1
  13. package/dist/lib/other-apps.js +29 -1
  14. package/dist/lib/other-apps.js.map +1 -1
  15. package/dist/lib/workspace-apps.d.ts +2 -1
  16. package/dist/lib/workspace-apps.d.ts.map +1 -1
  17. package/dist/lib/workspace-apps.js +15 -5
  18. package/dist/lib/workspace-apps.js.map +1 -1
  19. package/dist/routes/pages/apps.d.ts.map +1 -1
  20. package/dist/routes/pages/apps.js +17 -4
  21. package/dist/routes/pages/apps.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/components/connected-app-card.spec.tsx +100 -0
  24. package/src/components/connected-app-card.tsx +38 -9
  25. package/src/components/layout/Layout.spec.tsx +397 -6
  26. package/src/components/layout/Layout.tsx +173 -85
  27. package/src/components/layout/workspace-apps-rail.spec.tsx +69 -7
  28. package/src/components/layout/workspace-apps-rail.tsx +49 -7
  29. package/src/lib/other-apps.spec.ts +84 -2
  30. package/src/lib/other-apps.ts +39 -1
  31. package/src/lib/workspace-apps.spec.ts +53 -0
  32. package/src/lib/workspace-apps.ts +15 -7
  33. package/src/routes/pages/apps.spec.tsx +132 -0
  34. package/src/routes/pages/apps.tsx +46 -10
@@ -1,6 +1,88 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
 
3
- import { filterOtherApps } from "./other-apps.js";
3
+ import { filterBuiltInApps, filterOtherApps } from "./other-apps.js";
4
+
5
+ describe("filterBuiltInApps", () => {
6
+ it("keeps configured first-party apps not mounted in the workspace", () => {
7
+ expect(
8
+ filterBuiltInApps(
9
+ [
10
+ {
11
+ id: "slides",
12
+ name: "Slides",
13
+ url: "https://slides.agent-native.com",
14
+ source: "builtin",
15
+ },
16
+ {
17
+ id: "clips",
18
+ name: "Clips",
19
+ url: "https://clips.agent-native.com",
20
+ source: "builtin",
21
+ },
22
+ {
23
+ id: "custom-agent",
24
+ name: "Custom agent",
25
+ url: "https://agent.example.test",
26
+ source: "custom",
27
+ },
28
+ {
29
+ id: "workspace-agent",
30
+ name: "Workspace agent",
31
+ url: "https://workspace.example.test",
32
+ source: "workspace",
33
+ },
34
+ {
35
+ id: "invalid",
36
+ name: "Invalid",
37
+ url: "/invalid",
38
+ source: "builtin",
39
+ },
40
+ ],
41
+ [{ id: "clips" }],
42
+ ),
43
+ ).toEqual([
44
+ {
45
+ id: "slides",
46
+ name: "Slides",
47
+ url: "https://slides.agent-native.com",
48
+ source: "builtin",
49
+ },
50
+ ]);
51
+ });
52
+
53
+ it("keeps built-in app launches on the current beta lane", () => {
54
+ vi.stubGlobal("window", {
55
+ location: { hostname: "beta.agent-workspace.builder.io" },
56
+ });
57
+
58
+ try {
59
+ expect(
60
+ filterBuiltInApps(
61
+ [
62
+ {
63
+ id: "clips",
64
+ name: "Clips",
65
+ url: "https://clips.agent-native.com/",
66
+ homeUrl: "https://clips.agent-native.com/recordings",
67
+ source: "builtin",
68
+ },
69
+ ],
70
+ [],
71
+ ),
72
+ ).toEqual([
73
+ {
74
+ id: "clips",
75
+ name: "Clips",
76
+ url: "https://beta.clips.agent-native.com/",
77
+ homeUrl: "https://beta.clips.agent-native.com/recordings",
78
+ source: "builtin",
79
+ },
80
+ ]);
81
+ } finally {
82
+ vi.unstubAllGlobals();
83
+ }
84
+ });
85
+ });
4
86
 
5
87
  describe("filterOtherApps", () => {
6
88
  it("keeps available linked apps while excluding workspace apps", () => {
@@ -1,4 +1,7 @@
1
- import { isDefaultWorkspaceAppHiddenId } from "./workspace-apps";
1
+ import {
2
+ defaultWorkspaceAppUrl,
3
+ isDefaultWorkspaceAppHiddenId,
4
+ } from "./workspace-apps";
2
5
 
3
6
  export interface ConnectedAppSummary {
4
7
  id: string;
@@ -54,3 +57,38 @@ export function filterOtherApps(
54
57
  })
55
58
  .sort((a, b) => a.name.localeCompare(b.name));
56
59
  }
60
+
61
+ export function filterBuiltInApps(
62
+ connectedApps: ConnectedAppSummary[],
63
+ workspaceApps: WorkspaceAppId[],
64
+ ): ConnectedAppSummary[] {
65
+ const workspaceAppIds = new Set([
66
+ "dispatch",
67
+ ...workspaceApps.map((app) => app.id.trim().toLowerCase()),
68
+ ]);
69
+ const seen = new Set<string>();
70
+
71
+ return connectedApps
72
+ .filter((app) => {
73
+ const id = app.id.trim().toLowerCase();
74
+ const url = app.homeUrl?.trim() || app.url;
75
+ if (
76
+ !id ||
77
+ app.source !== "builtin" ||
78
+ isDefaultWorkspaceAppHiddenId(id) ||
79
+ workspaceAppIds.has(id) ||
80
+ seen.has(id) ||
81
+ !isHttpUrl(url)
82
+ ) {
83
+ return false;
84
+ }
85
+ seen.add(id);
86
+ return true;
87
+ })
88
+ .map((app) => ({
89
+ ...app,
90
+ url: defaultWorkspaceAppUrl(app.url),
91
+ ...(app.homeUrl ? { homeUrl: defaultWorkspaceAppUrl(app.homeUrl) } : {}),
92
+ }))
93
+ .sort((a, b) => a.name.localeCompare(b.name));
94
+ }
@@ -352,6 +352,25 @@ describe("workspace app routes", () => {
352
352
  }
353
353
  });
354
354
 
355
+ it("preserves configured built-in launch paths over canonical defaults", () => {
356
+ const apps = mergeChatFirstWorkspaceApps(
357
+ undefined,
358
+ ["clips"],
359
+ [
360
+ {
361
+ id: "clips",
362
+ name: "Clips",
363
+ url: "https://clips.agent-native.com/recordings",
364
+ source: "builtin",
365
+ },
366
+ ],
367
+ );
368
+
369
+ expect(apps.find((app) => app.id === "clips")?.url).toBe(
370
+ "https://clips.agent-native.com/recordings",
371
+ );
372
+ });
373
+
355
374
  it("lets a mounted workspace app override a default row", () => {
356
375
  const apps = mergeChatFirstWorkspaceApps(
357
376
  [
@@ -385,6 +404,40 @@ describe("workspace app routes", () => {
385
404
  ]);
386
405
  });
387
406
 
407
+ it("adds every configured first-party app to the launcher", () => {
408
+ const apps = mergeChatFirstWorkspaceApps(
409
+ [],
410
+ ["mail", "clips", "slides"],
411
+ [
412
+ {
413
+ id: "mail",
414
+ name: "Mail",
415
+ url: "https://mail.agent-native.com",
416
+ source: "builtin",
417
+ },
418
+ {
419
+ id: "clips",
420
+ name: "Clips",
421
+ url: "https://clips.agent-native.com",
422
+ source: "builtin",
423
+ },
424
+ {
425
+ id: "slides",
426
+ name: "Slides",
427
+ url: "https://slides.agent-native.com",
428
+ source: "builtin",
429
+ },
430
+ ],
431
+ );
432
+
433
+ expect(apps.map((app) => app.id)).toEqual(["mail", "clips", "slides"]);
434
+ expect(apps.map((app) => app.source)).toEqual([
435
+ "builtin",
436
+ "builtin",
437
+ "builtin",
438
+ ]);
439
+ });
440
+
388
441
  it("adds no defaults when built-ins are off or not yet resolved", () => {
389
442
  const workspace = [{ id: "crm", name: "CRM", path: "/crm" }];
390
443
  expect(
@@ -195,7 +195,7 @@ const DEFAULT_WORKSPACE_APP_DESCRIPTIONS: Record<
195
195
  },
196
196
  };
197
197
 
198
- function defaultWorkspaceAppUrl(rawUrl: string): string {
198
+ export function defaultWorkspaceAppUrl(rawUrl: string): string {
199
199
  if (typeof window === "undefined") return rawUrl;
200
200
 
201
201
  const hostname = window.location.hostname
@@ -464,7 +464,7 @@ export function navigateToWorkspaceApp(href: string): boolean {
464
464
  * of this workspace. `enabledBuiltinAppIds` must come from the server
465
465
  * (list-connected-agents entries with source "builtin"), which already applies
466
466
  * the builder's `agent-native.builtinAgents` config. While it is unknown, no
467
- * default is added. `extraApps` are appended only when no entry has that id.
467
+ * default is added. `extraApps` fill missing rows and configured built-in URLs.
468
468
  */
469
469
  export function mergeChatFirstWorkspaceApps(
470
470
  apps: readonly WorkspaceAppSummary[] | undefined,
@@ -512,13 +512,21 @@ export function mergeChatFirstWorkspaceApps(
512
512
  });
513
513
  }
514
514
 
515
- const existingIds = new Set(
516
- [...merged.values()].map((app) => app.id.trim().toLowerCase()),
517
- );
518
515
  for (const app of extraApps) {
519
516
  const id = app.id.trim().toLowerCase();
520
- if (!id || existingIds.has(id)) continue;
521
- existingIds.add(id);
517
+ if (!id) continue;
518
+ const existing = merged.get(id);
519
+ if (existing) {
520
+ if (existing.source === "builtin" && app.source === "builtin") {
521
+ merged.set(id, {
522
+ ...existing,
523
+ name: app.name.trim() || existing.name,
524
+ description: app.description ?? existing.description,
525
+ url: app.url?.trim() || existing.url,
526
+ });
527
+ }
528
+ continue;
529
+ }
522
530
  merged.set(id, {
523
531
  id,
524
532
  name: app.name.trim() || id,
@@ -0,0 +1,132 @@
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
+ const queryState = vi.hoisted(() => ({
8
+ workspaceApps: [] as Array<Record<string, unknown>>,
9
+ workspaceAppsError: null as Error | null,
10
+ workspaceAppsLoading: false,
11
+ connectedApps: [] as Array<Record<string, unknown>>,
12
+ connectedAppsError: null as Error | null,
13
+ connectedAppsLoading: false,
14
+ }));
15
+
16
+ vi.mock("@agent-native/core/client/hooks", () => ({
17
+ useActionQuery: (action: string) => {
18
+ const workspaceApps = action === "list-workspace-apps";
19
+ const connectedApps = action === "list-connected-agents";
20
+ const error = workspaceApps
21
+ ? queryState.workspaceAppsError
22
+ : connectedApps
23
+ ? queryState.connectedAppsError
24
+ : null;
25
+ return {
26
+ data: workspaceApps
27
+ ? queryState.workspaceApps
28
+ : connectedApps
29
+ ? queryState.connectedApps
30
+ : { name: null, displayName: null, appCount: 0 },
31
+ error,
32
+ isError: Boolean(error),
33
+ isLoading: workspaceApps
34
+ ? queryState.workspaceAppsLoading
35
+ : connectedApps
36
+ ? queryState.connectedAppsLoading
37
+ : false,
38
+ refetch: vi.fn(),
39
+ };
40
+ },
41
+ }));
42
+
43
+ vi.mock("@agent-native/core/client/i18n", () => ({
44
+ useT: () => (key: string, values?: Record<string, unknown>) =>
45
+ key === "dispatch.pages.noWorkspaceApps"
46
+ ? "No workspace apps"
47
+ : String(values?.defaultValue ?? key),
48
+ }));
49
+
50
+ vi.mock("../../components/action-query-error", () => ({
51
+ ActionQueryError: ({ error }: { error: Error }) => (
52
+ <div role="alert">{error.message}</div>
53
+ ),
54
+ }));
55
+
56
+ vi.mock("../../components/create-app-popover", () => ({
57
+ CreateAppPopover: () => null,
58
+ }));
59
+
60
+ vi.mock("../../components/dispatch-shell", () => ({
61
+ DispatchShell: ({ children }: { children: React.ReactNode }) => (
62
+ <div>{children}</div>
63
+ ),
64
+ }));
65
+
66
+ vi.mock("../../components/workspace-app-search", () => ({
67
+ WorkspaceAppSearch: () => null,
68
+ WorkspaceAppSearchEmpty: () => <div>No search results</div>,
69
+ }));
70
+
71
+ vi.mock("../../lib/workspace-app-layout", () => ({
72
+ orderWorkspaceApps: (apps: unknown[]) => apps,
73
+ useWorkspaceAppLayout: () => ({
74
+ layout: { pinnedIds: [], orderedIds: [] },
75
+ persistenceError: null,
76
+ togglePinned: vi.fn(),
77
+ }),
78
+ workspaceAppMatchesQuery: () => true,
79
+ }));
80
+
81
+ import AppsRouteEntry from "./apps";
82
+
83
+ describe("Dispatch apps route", () => {
84
+ let container: HTMLDivElement;
85
+ let root: Root;
86
+
87
+ beforeEach(() => {
88
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
89
+ queryState.workspaceApps = [];
90
+ queryState.workspaceAppsError = null;
91
+ queryState.workspaceAppsLoading = false;
92
+ queryState.connectedApps = [];
93
+ queryState.connectedAppsError = null;
94
+ queryState.connectedAppsLoading = false;
95
+ container = document.createElement("div");
96
+ document.body.appendChild(container);
97
+ root = createRoot(container);
98
+ });
99
+
100
+ afterEach(() => {
101
+ act(() => root.unmount());
102
+ container.remove();
103
+ vi.unstubAllGlobals();
104
+ });
105
+
106
+ async function renderAppsRoute() {
107
+ await act(async () => {
108
+ root.render(
109
+ <MemoryRouter initialEntries={["/apps"]}>
110
+ <AppsRouteEntry />
111
+ </MemoryRouter>,
112
+ );
113
+ });
114
+ }
115
+
116
+ it("does not show the empty state when connected-app discovery fails", async () => {
117
+ queryState.connectedAppsError = new Error("Connected apps unavailable");
118
+
119
+ await renderAppsRoute();
120
+
121
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
122
+ "Connected apps unavailable",
123
+ );
124
+ expect(container.textContent).not.toContain("No workspace apps");
125
+ });
126
+
127
+ it("shows the empty state after successful app discovery", async () => {
128
+ await renderAppsRoute();
129
+
130
+ expect(container.textContent).toContain("No workspace apps");
131
+ });
132
+ });
@@ -16,6 +16,7 @@ import {
16
16
  APP_LIST_GRID_ROW_CLASS,
17
17
  AppList,
18
18
  } from "../../components/app-list-row";
19
+ import { ConnectedAppCard } from "../../components/connected-app-card";
19
20
  import { CreateAppPopover } from "../../components/create-app-popover";
20
21
  import { DispatchShell } from "../../components/dispatch-shell";
21
22
  import { Button } from "../../components/ui/button";
@@ -30,6 +31,10 @@ import {
30
31
  WorkspaceAppSearch,
31
32
  WorkspaceAppSearchEmpty,
32
33
  } from "../../components/workspace-app-search";
34
+ import {
35
+ filterBuiltInApps,
36
+ type ConnectedAppSummary,
37
+ } from "../../lib/other-apps";
33
38
  import { cn } from "../../lib/utils";
34
39
  import {
35
40
  orderWorkspaceApps,
@@ -66,6 +71,10 @@ function AppsRoute() {
66
71
  includeAgentCards: false,
67
72
  includeArchived: true,
68
73
  });
74
+ const connectedAppsQuery = useActionQuery<ConnectedAppSummary[]>(
75
+ "list-connected-agents",
76
+ {},
77
+ );
69
78
  const { data: apps = [], isLoading: appsLoading } = appsQuery;
70
79
  const { data: workspace } = useActionQuery(
71
80
  "get-workspace-info",
@@ -81,6 +90,7 @@ function AppsRoute() {
81
90
  const activeApps = visibleApps.filter((app) => app.status !== "pending");
82
91
  const pendingApps = visibleApps.filter((app) => app.status === "pending");
83
92
  const archivedApps = allApps.filter((app) => app.archived);
93
+ const defaultApps = filterBuiltInApps(connectedAppsQuery.data ?? [], allApps);
84
94
  const orderedActiveApps = orderWorkspaceApps(activeApps, layout);
85
95
  const orderedPendingApps = orderWorkspaceApps(pendingApps, layout);
86
96
  const orderedArchivedApps = orderWorkspaceApps(archivedApps, layout);
@@ -93,11 +103,19 @@ function AppsRoute() {
93
103
  const filteredArchivedApps = orderedArchivedApps.filter((app) =>
94
104
  workspaceAppMatchesQuery(app, searchQuery),
95
105
  );
106
+ const filteredDefaultApps = defaultApps.filter((app) =>
107
+ workspaceAppMatchesQuery(app, searchQuery),
108
+ );
96
109
  const hasSearchResults =
97
110
  filteredActiveApps.length > 0 ||
98
111
  filteredPendingApps.length > 0 ||
99
- filteredArchivedApps.length > 0;
100
- const showAppSkeletons = appsLoading && allApps.length === 0;
112
+ filteredArchivedApps.length > 0 ||
113
+ filteredDefaultApps.length > 0;
114
+ const showAppSkeletons =
115
+ (appsLoading || connectedAppsQuery.isLoading) &&
116
+ allApps.length === 0 &&
117
+ defaultApps.length === 0;
118
+ const appDiscoveryFailed = appsQuery.isError || connectedAppsQuery.isError;
101
119
 
102
120
  return (
103
121
  <DispatchShell
@@ -132,7 +150,9 @@ function AppsRoute() {
132
150
  onQueryChange={setSearchQuery}
133
151
  />
134
152
  ) : null}
135
- {activeApps.length > 0 || pendingApps.length > 0 ? (
153
+ {activeApps.length > 0 ||
154
+ pendingApps.length > 0 ||
155
+ defaultApps.length > 0 ? (
136
156
  <CreateAppPopover
137
157
  align="end"
138
158
  trigger={
@@ -165,14 +185,17 @@ function AppsRoute() {
165
185
  error={appsQuery.error}
166
186
  onRetry={() => void appsQuery.refetch()}
167
187
  />
168
- ) : showAppSkeletons ? (
169
- <AppsSkeletonGrid />
170
- ) : !hasSearchResults && searchQuery.trim() ? (
171
- <WorkspaceAppSearchEmpty
172
- query={searchQuery}
173
- onClear={() => setSearchQuery("")}
188
+ ) : null}
189
+ {connectedAppsQuery.isError ? (
190
+ <ActionQueryError
191
+ error={connectedAppsQuery.error}
192
+ onRetry={() => void connectedAppsQuery.refetch()}
174
193
  />
175
- ) : filteredActiveApps.length > 0 ? (
194
+ ) : null}
195
+ {showAppSkeletons ? (
196
+ <AppsSkeletonGrid />
197
+ ) : filteredActiveApps.length > 0 ||
198
+ filteredDefaultApps.length > 0 ? (
176
199
  <AppList className={APP_LIST_GRID_CLASS}>
177
200
  {filteredActiveApps.map((app) => (
178
201
  <WorkspaceAppCard
@@ -183,7 +206,20 @@ function AppsRoute() {
183
206
  onTogglePinned={() => togglePinned(app.id)}
184
207
  />
185
208
  ))}
209
+ {filteredDefaultApps.map((app) => (
210
+ <ConnectedAppCard
211
+ key={app.id}
212
+ app={app}
213
+ className={APP_LIST_GRID_ROW_CLASS}
214
+ />
215
+ ))}
186
216
  </AppList>
217
+ ) : appDiscoveryFailed ? null : !hasSearchResults &&
218
+ searchQuery.trim() ? (
219
+ <WorkspaceAppSearchEmpty
220
+ query={searchQuery}
221
+ onClear={() => setSearchQuery("")}
222
+ />
187
223
  ) : searchQuery.trim() ? null : pendingApps.length > 0 ? (
188
224
  <EmptyActiveAppsState />
189
225
  ) : (