@agent-native/dispatch 0.15.29 → 0.16.1

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 (76) hide show
  1. package/dist/actions/create-browser-chat-session.d.ts +15 -0
  2. package/dist/actions/create-browser-chat-session.d.ts.map +1 -0
  3. package/dist/actions/create-browser-chat-session.js +75 -0
  4. package/dist/actions/create-browser-chat-session.js.map +1 -0
  5. package/dist/actions/delete-destination.d.ts +12 -12
  6. package/dist/actions/index.d.ts.map +1 -1
  7. package/dist/actions/index.js +2 -0
  8. package/dist/actions/index.js.map +1 -1
  9. package/dist/actions/provider-api-register.d.ts +14 -14
  10. package/dist/actions/update-workspace-resource.d.ts +13 -13
  11. package/dist/actions/upsert-destination.d.ts +12 -12
  12. package/dist/actions/view-screen.js +4 -2
  13. package/dist/actions/view-screen.js.map +1 -1
  14. package/dist/components/create-app-popover.d.ts.map +1 -1
  15. package/dist/components/create-app-popover.js +7 -0
  16. package/dist/components/create-app-popover.js.map +1 -1
  17. package/dist/components/layout/Layout.d.ts.map +1 -1
  18. package/dist/components/layout/Layout.js +3 -4
  19. package/dist/components/layout/Layout.js.map +1 -1
  20. package/dist/config.d.ts +5 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js.map +1 -1
  23. package/dist/hooks/use-navigation-state.js +2 -0
  24. package/dist/hooks/use-navigation-state.js.map +1 -1
  25. package/dist/lib/automations.d.ts.map +1 -1
  26. package/dist/lib/automations.js +5 -4
  27. package/dist/lib/automations.js.map +1 -1
  28. package/dist/lib/browser-chat-bridge.d.ts +8 -0
  29. package/dist/lib/browser-chat-bridge.d.ts.map +1 -0
  30. package/dist/lib/browser-chat-bridge.js +48 -0
  31. package/dist/lib/browser-chat-bridge.js.map +1 -0
  32. package/dist/lib/browser-chat-protocol.d.ts +33 -0
  33. package/dist/lib/browser-chat-protocol.d.ts.map +1 -0
  34. package/dist/lib/browser-chat-protocol.js +82 -0
  35. package/dist/lib/browser-chat-protocol.js.map +1 -0
  36. package/dist/routes/index.d.ts.map +1 -1
  37. package/dist/routes/index.js +2 -0
  38. package/dist/routes/index.js.map +1 -1
  39. package/dist/routes/pages/browser-chat.d.ts +5 -0
  40. package/dist/routes/pages/browser-chat.d.ts.map +1 -0
  41. package/dist/routes/pages/browser-chat.js +43 -0
  42. package/dist/routes/pages/browser-chat.js.map +1 -0
  43. package/dist/routes/pages/browser-connect.d.ts +5 -0
  44. package/dist/routes/pages/browser-connect.d.ts.map +1 -0
  45. package/dist/routes/pages/browser-connect.js +72 -0
  46. package/dist/routes/pages/browser-connect.js.map +1 -0
  47. package/dist/server/lib/browser-extension-allowlist.d.ts +9 -0
  48. package/dist/server/lib/browser-extension-allowlist.d.ts.map +1 -0
  49. package/dist/server/lib/browser-extension-allowlist.js +39 -0
  50. package/dist/server/lib/browser-extension-allowlist.js.map +1 -0
  51. package/package.json +3 -3
  52. package/src/actions/create-browser-chat-session.spec.ts +122 -0
  53. package/src/actions/create-browser-chat-session.ts +99 -0
  54. package/src/actions/index.ts +2 -0
  55. package/src/actions/view-screen.ts +5 -2
  56. package/src/components/create-app-popover.spec.tsx +53 -0
  57. package/src/components/create-app-popover.tsx +49 -0
  58. package/src/components/layout/Layout.spec.tsx +0 -1
  59. package/src/components/layout/Layout.tsx +2 -6
  60. package/src/config.ts +5 -0
  61. package/src/hooks/use-navigation-state.spec.ts +7 -0
  62. package/src/hooks/use-navigation-state.ts +1 -0
  63. package/src/lib/automations.spec.ts +64 -0
  64. package/src/lib/automations.ts +5 -3
  65. package/src/lib/browser-chat-bridge.spec.ts +151 -0
  66. package/src/lib/browser-chat-bridge.ts +79 -0
  67. package/src/lib/browser-chat-protocol.spec.ts +113 -0
  68. package/src/lib/browser-chat-protocol.ts +124 -0
  69. package/src/routes/index.spec.ts +6 -0
  70. package/src/routes/index.ts +2 -0
  71. package/src/routes/pages/browser-chat.spec.tsx +121 -0
  72. package/src/routes/pages/browser-chat.tsx +89 -0
  73. package/src/routes/pages/browser-connect.spec.tsx +137 -0
  74. package/src/routes/pages/browser-connect.tsx +143 -0
  75. package/src/server/lib/browser-extension-allowlist.spec.ts +76 -0
  76. package/src/server/lib/browser-extension-allowlist.ts +58 -0
@@ -0,0 +1,39 @@
1
+ const CHROME_EXTENSION_ID = /^[a-p]{32}$/;
2
+ const MAX_BROWSER_EXTENSION_IDS = 64;
3
+ export function resolveAllowedBrowserExtensionIds(configIds = [], envValue = process.env.AGENT_NATIVE_BROWSER_EXTENSION_IDS) {
4
+ const ids = [...configIds, ...(envValue?.split(",") ?? [])]
5
+ .map((value) => value.trim())
6
+ .filter(Boolean);
7
+ const invalid = ids.find((value) => !CHROME_EXTENSION_ID.test(value));
8
+ if (invalid) {
9
+ throw new Error("Browser extension allowlist contains an invalid Chrome extension id.");
10
+ }
11
+ const uniqueIds = new Set(ids);
12
+ if (uniqueIds.size > MAX_BROWSER_EXTENSION_IDS) {
13
+ throw new Error(`Browser extension allowlist cannot exceed ${MAX_BROWSER_EXTENSION_IDS} ids.`);
14
+ }
15
+ return uniqueIds;
16
+ }
17
+ function isLoopbackOrigin(value) {
18
+ if (!value)
19
+ return false;
20
+ try {
21
+ const url = new URL(value);
22
+ return ((url.protocol === "http:" || url.protocol === "https:") &&
23
+ (url.hostname === "localhost" ||
24
+ url.hostname === "127.0.0.1" ||
25
+ url.hostname === "[::1]"));
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export function isBrowserExtensionIdAllowed(options) {
32
+ if (!CHROME_EXTENSION_ID.test(options.extensionId))
33
+ return false;
34
+ const configured = resolveAllowedBrowserExtensionIds(options.configIds, options.envValue);
35
+ if (configured.has(options.extensionId))
36
+ return true;
37
+ return (options.nodeEnv !== "production" && isLoopbackOrigin(options.requestOrigin));
38
+ }
39
+ //# sourceMappingURL=browser-extension-allowlist.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-extension-allowlist.js","sourceRoot":"","sources":["../../../src/server/lib/browser-extension-allowlist.ts"],"names":[],"mappings":"AAAA,MAAM,mBAAmB,GAAG,aAAa,CAAC;AAC1C,MAAM,yBAAyB,GAAG,EAAE,CAAC;AAErC,MAAM,UAAU,iCAAiC,CAC/C,SAAS,GAAsB,EAAE,EACjC,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kCAAkC;IAEzD,MAAM,GAAG,GAAG,CAAC,GAAG,SAAS,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;SACxD,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;SAC5B,MAAM,CAAC,OAAO,CAAC,CAAC;IACnB,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IACtE,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CACb,sEAAsE,CACvE,CAAC;IACJ,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,SAAS,CAAC,IAAI,GAAG,yBAAyB,EAAE,CAAC;QAC/C,MAAM,IAAI,KAAK,CACb,6CAA6C,yBAAyB,OAAO,CAC9E,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAyB;IACjD,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACzB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,CACL,CAAC,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC;YACvD,CAAC,GAAG,CAAC,QAAQ,KAAK,WAAW;gBAC3B,GAAG,CAAC,QAAQ,KAAK,WAAW;gBAC5B,GAAG,CAAC,QAAQ,KAAK,OAAO,CAAC,CAC5B,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,UAAU,2BAA2B,CAAC,OAM3C;IACC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC;QAAE,OAAO,KAAK,CAAC;IACjE,MAAM,UAAU,GAAG,iCAAiC,CAClD,OAAO,CAAC,SAAS,EACjB,OAAO,CAAC,QAAQ,CACjB,CAAC;IACF,IAAI,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,WAAW,CAAC;QAAE,OAAO,IAAI,CAAC;IAErD,OAAO,CACL,OAAO,CAAC,OAAO,KAAK,YAAY,IAAI,gBAAgB,CAAC,OAAO,CAAC,aAAa,CAAC,CAC5E,CAAC;AACJ,CAAC","sourcesContent":["const CHROME_EXTENSION_ID = /^[a-p]{32}$/;\nconst MAX_BROWSER_EXTENSION_IDS = 64;\n\nexport function resolveAllowedBrowserExtensionIds(\n configIds: readonly string[] = [],\n envValue = process.env.AGENT_NATIVE_BROWSER_EXTENSION_IDS,\n): Set<string> {\n const ids = [...configIds, ...(envValue?.split(\",\") ?? [])]\n .map((value) => value.trim())\n .filter(Boolean);\n const invalid = ids.find((value) => !CHROME_EXTENSION_ID.test(value));\n if (invalid) {\n throw new Error(\n \"Browser extension allowlist contains an invalid Chrome extension id.\",\n );\n }\n const uniqueIds = new Set(ids);\n if (uniqueIds.size > MAX_BROWSER_EXTENSION_IDS) {\n throw new Error(\n `Browser extension allowlist cannot exceed ${MAX_BROWSER_EXTENSION_IDS} ids.`,\n );\n }\n return uniqueIds;\n}\n\nfunction isLoopbackOrigin(value: string | undefined): boolean {\n if (!value) return false;\n try {\n const url = new URL(value);\n return (\n (url.protocol === \"http:\" || url.protocol === \"https:\") &&\n (url.hostname === \"localhost\" ||\n url.hostname === \"127.0.0.1\" ||\n url.hostname === \"[::1]\")\n );\n } catch {\n return false;\n }\n}\n\nexport function isBrowserExtensionIdAllowed(options: {\n extensionId: string;\n configIds?: readonly string[];\n envValue?: string;\n nodeEnv?: string;\n requestOrigin?: string;\n}): boolean {\n if (!CHROME_EXTENSION_ID.test(options.extensionId)) return false;\n const configured = resolveAllowedBrowserExtensionIds(\n options.configIds,\n options.envValue,\n );\n if (configured.has(options.extensionId)) return true;\n\n return (\n options.nodeEnv !== \"production\" && isLoopbackOrigin(options.requestOrigin)\n );\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.15.29",
3
+ "version": "0.16.1",
4
4
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -84,7 +84,7 @@
84
84
  "tailwind-merge": "^3.5.0",
85
85
  "vaul": "^1.1.2",
86
86
  "zod": "^4.3.6",
87
- "@agent-native/toolkit": "0.10.12"
87
+ "@agent-native/toolkit": "0.11.1"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@react-router/dev": "^8.1.0",
@@ -98,7 +98,7 @@
98
98
  "typescript-7": "npm:typescript@^7.0.2",
99
99
  "vite": "8.1.0",
100
100
  "vitest": "^4.1.5",
101
- "@agent-native/core": "0.130.0"
101
+ "@agent-native/core": "0.131.2"
102
102
  },
103
103
  "peerDependencies": {
104
104
  "@agent-native/core": ">=0.8.0",
@@ -0,0 +1,122 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const server = vi.hoisted(() => ({
4
+ buildStartPath: vi.fn(
5
+ (ticket: string) => `/_agent-native/embed/start?ticket=${ticket}`,
6
+ ),
7
+ createTicket: vi.fn(),
8
+ getContext: vi.fn(),
9
+ getEmail: vi.fn(),
10
+ }));
11
+ const dispatch = vi.hoisted(() => ({
12
+ getConfig: vi.fn(),
13
+ }));
14
+ const integrations = vi.hoisted(() => ({
15
+ createRemoteDevice: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("@agent-native/core/server", () => ({
19
+ buildEmbedStartPath: server.buildStartPath,
20
+ createEmbedSessionTicket: server.createTicket,
21
+ getRequestContext: server.getContext,
22
+ getRequestUserEmail: server.getEmail,
23
+ withConfiguredAppBasePath: (origin: string) => origin,
24
+ }));
25
+ vi.mock("@agent-native/core/integrations", () => ({
26
+ createRemoteDevice: integrations.createRemoteDevice,
27
+ }));
28
+ vi.mock("../server/index.js", () => ({
29
+ getDispatchConfig: dispatch.getConfig,
30
+ }));
31
+
32
+ import action from "./create-browser-chat-session.js";
33
+
34
+ const nonce = "browser-chat-nonce-1234567890";
35
+ const extensionId = "abcdefghijklmnopabcdefghijklmnop";
36
+ const parentOrigin = `chrome-extension://${extensionId}`;
37
+
38
+ describe("create-browser-chat-session", () => {
39
+ beforeEach(() => {
40
+ server.createTicket.mockReset();
41
+ server.getContext.mockReturnValue({
42
+ orgId: "org-example",
43
+ requestOrigin: "https://dispatch.example.com",
44
+ });
45
+ server.getEmail.mockReturnValue("user@example.com");
46
+ dispatch.getConfig.mockReturnValue({
47
+ browserExtensionIds: [extensionId],
48
+ });
49
+ integrations.createRemoteDevice.mockResolvedValue({
50
+ device: { id: "remote-device-example" },
51
+ token: "anr_example",
52
+ });
53
+ });
54
+
55
+ it("mints a one-time ticket bound to the bridge nonce and parent origin", async () => {
56
+ server.createTicket.mockResolvedValue({
57
+ ticket: "ticket-example",
58
+ ticketHash: "hash-example",
59
+ expiresAt: 12345,
60
+ });
61
+
62
+ await expect(action.run({ nonce, extensionId })).resolves.toEqual({
63
+ startPath: "/_agent-native/embed/start?ticket=ticket-example",
64
+ expiresAt: 12345,
65
+ parentOrigin,
66
+ remoteDevice: {
67
+ id: "remote-device-example",
68
+ token: "anr_example",
69
+ },
70
+ relayBaseUrl: "https://dispatch.example.com",
71
+ });
72
+ expect(server.createTicket).toHaveBeenCalledWith({
73
+ ownerEmail: "user@example.com",
74
+ orgId: "org-example",
75
+ targetPath:
76
+ "/browser-chat?browserChatNonce=browser-chat-nonce-1234567890&browserChatParentOrigin=chrome-extension%3A%2F%2Fabcdefghijklmnopabcdefghijklmnop",
77
+ scope: "browser-chat",
78
+ ttlSeconds: 60,
79
+ });
80
+ expect(action.agentTool).toBe(false);
81
+ expect(action.toolCallable).toBe(false);
82
+ expect(integrations.createRemoteDevice).toHaveBeenCalledWith({
83
+ ownerEmail: "user@example.com",
84
+ orgId: "org-example",
85
+ label: "Agent Native for Chrome",
86
+ platform: "chrome-extension",
87
+ metadata: {
88
+ browserExtension: { extensionId },
89
+ computerCapabilities: {
90
+ browser: {
91
+ observe: true,
92
+ control: true,
93
+ provider: "agent-native-chrome-extension",
94
+ },
95
+ },
96
+ },
97
+ });
98
+ });
99
+
100
+ it("fails closed without an authenticated Dispatch identity", async () => {
101
+ server.getEmail.mockReturnValue(undefined);
102
+
103
+ await expect(action.run({ nonce, extensionId })).rejects.toThrow(
104
+ "Sign in to Dispatch",
105
+ );
106
+ expect(server.createTicket).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it("rejects an extension id that is not configured in production", async () => {
110
+ const previousNodeEnv = process.env.NODE_ENV;
111
+ process.env.NODE_ENV = "production";
112
+ dispatch.getConfig.mockReturnValue({ browserExtensionIds: [] });
113
+ try {
114
+ await expect(action.run({ nonce, extensionId })).rejects.toThrow(
115
+ "not allowed",
116
+ );
117
+ } finally {
118
+ process.env.NODE_ENV = previousNodeEnv;
119
+ }
120
+ expect(server.createTicket).not.toHaveBeenCalled();
121
+ });
122
+ });
@@ -0,0 +1,99 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { createRemoteDevice } from "@agent-native/core/integrations";
3
+ import {
4
+ buildEmbedStartPath,
5
+ createEmbedSessionTicket,
6
+ getRequestContext,
7
+ getRequestUserEmail,
8
+ withConfiguredAppBasePath,
9
+ } from "@agent-native/core/server";
10
+ import { z } from "zod";
11
+
12
+ import {
13
+ BROWSER_CHAT_NONCE_QUERY_PARAM,
14
+ BROWSER_CHAT_PARENT_ORIGIN_QUERY_PARAM,
15
+ browserChatExtensionIdSchema,
16
+ browserChatNonceSchema,
17
+ } from "../lib/browser-chat-protocol.js";
18
+ import { isBrowserExtensionIdAllowed } from "../server/lib/browser-extension-allowlist.js";
19
+
20
+ export default defineAction({
21
+ description:
22
+ "Create a one-time authenticated Dispatch browser-chat embed session for an approved Agent-Native Chrome extension.",
23
+ schema: z
24
+ .object({
25
+ nonce: browserChatNonceSchema,
26
+ extensionId: browserChatExtensionIdSchema,
27
+ })
28
+ .strict(),
29
+ readOnly: false,
30
+ parallelSafe: true,
31
+ agentTool: false,
32
+ toolCallable: false,
33
+ run: async ({ nonce, extensionId }) => {
34
+ const ownerEmail = getRequestUserEmail()?.trim();
35
+ if (!ownerEmail) {
36
+ throw new Error(
37
+ "Sign in to Dispatch before connecting the browser extension.",
38
+ );
39
+ }
40
+
41
+ const requestContext = getRequestContext();
42
+ const { browserExtensionIds } = await import("../server/index.js").then(
43
+ (module) => module.getDispatchConfig(),
44
+ );
45
+ if (
46
+ !isBrowserExtensionIdAllowed({
47
+ extensionId,
48
+ configIds: browserExtensionIds,
49
+ nodeEnv: process.env.NODE_ENV,
50
+ requestOrigin: requestContext?.requestOrigin,
51
+ })
52
+ ) {
53
+ throw new Error(
54
+ "This browser extension is not allowed to connect to Dispatch.",
55
+ );
56
+ }
57
+
58
+ const parentOrigin = `chrome-extension://${extensionId}`;
59
+ const requestOrigin = requestContext?.requestOrigin;
60
+ if (!requestOrigin) {
61
+ throw new Error("Dispatch could not resolve its browser relay URL.");
62
+ }
63
+ const query = new URLSearchParams({
64
+ [BROWSER_CHAT_NONCE_QUERY_PARAM]: nonce,
65
+ [BROWSER_CHAT_PARENT_ORIGIN_QUERY_PARAM]: parentOrigin,
66
+ });
67
+ const session = await createEmbedSessionTicket({
68
+ ownerEmail,
69
+ orgId: requestContext?.orgId ?? null,
70
+ targetPath: `/browser-chat?${query}`,
71
+ scope: "browser-chat",
72
+ ttlSeconds: 60,
73
+ });
74
+ const remote = await createRemoteDevice({
75
+ ownerEmail,
76
+ orgId: requestContext?.orgId ?? null,
77
+ label: "Agent Native for Chrome",
78
+ platform: "chrome-extension",
79
+ metadata: {
80
+ browserExtension: { extensionId },
81
+ computerCapabilities: {
82
+ browser: {
83
+ observe: true,
84
+ control: true,
85
+ provider: "agent-native-chrome-extension",
86
+ },
87
+ },
88
+ },
89
+ });
90
+
91
+ return {
92
+ startPath: buildEmbedStartPath(session.ticket),
93
+ expiresAt: session.expiresAt,
94
+ parentOrigin,
95
+ remoteDevice: { id: remote.device.id, token: remote.token },
96
+ relayBaseUrl: withConfiguredAppBasePath(requestOrigin),
97
+ };
98
+ },
99
+ });
@@ -6,6 +6,7 @@ import approveVaultRequest from "./approve-vault-request.js";
6
6
  import archiveWorkspaceApp from "./archive-workspace-app.js";
7
7
  import askApp from "./ask_app.js";
8
8
  import askAppStatus from "./ask_app_status.js";
9
+ import createBrowserChatSession from "./create-browser-chat-session.js";
9
10
  import createDreamReport from "./create-dream-report.js";
10
11
  import createLinkToken from "./create-link-token.js";
11
12
  import createPylonTicket from "./create-pylon-ticket.js";
@@ -111,6 +112,7 @@ export const dispatchActions: Record<string, ActionEntry> = {
111
112
  "create-workspace-resource-grant": createWorkspaceResourceGrant,
112
113
  "create-workspace-resource": createWorkspaceResource,
113
114
  "create-dream-report": createDreamReport,
115
+ "create-browser-chat-session": createBrowserChatSession,
114
116
  create_embed_session: createEmbedSession,
115
117
  "delete-staged-dataset": deleteStagedDataset,
116
118
  "delete-destination": deleteDestination,
@@ -68,9 +68,12 @@ export default defineAction({
68
68
  approvalPolicy: overview.settings,
69
69
  };
70
70
  if (navigation) screen.navigation = navigation;
71
- if (navigation?.view === "chat") {
71
+ if (navigation?.view === "chat" || navigation?.view === "browser-chat") {
72
72
  screen.chatSurface = {
73
- view: "full-page Dispatch chat",
73
+ view:
74
+ navigation.view === "browser-chat"
75
+ ? "embedded browser chat"
76
+ : "full-page Dispatch chat",
74
77
  purpose:
75
78
  "Create apps, manage workspace resources, route work to connected agents, and continue Dispatch conversations.",
76
79
  };
@@ -246,4 +246,57 @@ describe("CreateAppFlow", () => {
246
246
  expect(() => findButton(container, "Connect Builder")).toThrow();
247
247
  expect(() => findButton(container, "Try again")).toThrow();
248
248
  });
249
+
250
+ it("replaces the composer with Builder branch progress and success states", async () => {
251
+ let resolveBuilderRequest: ((response: Response) => void) | undefined;
252
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
253
+ const url = String(input);
254
+ if (url.includes("get-vault-access-settings")) {
255
+ return jsonResponse({ mode: "all-apps" });
256
+ }
257
+ if (
258
+ url.includes("list-vault-secret-options") ||
259
+ url.includes("list-workspace-resource-options")
260
+ ) {
261
+ return jsonResponse([]);
262
+ }
263
+ if (url.includes("start-workspace-app-creation")) {
264
+ return new Promise<Response>((resolve) => {
265
+ resolveBuilderRequest = resolve;
266
+ });
267
+ }
268
+ return jsonResponse({ error: `Unexpected URL: ${url}` }, 404);
269
+ });
270
+
271
+ await renderAndSubmit("Build a quality dashboard");
272
+
273
+ await act(async () => {
274
+ await vi.waitFor(() => {
275
+ expect(container.textContent).toContain("Creating your Builder branch");
276
+ });
277
+ });
278
+ expect(container.textContent).not.toContain("Dispatch keys");
279
+
280
+ await act(async () => {
281
+ resolveBuilderRequest?.(
282
+ jsonResponse({
283
+ mode: "builder",
284
+ appId: "quality-dashboard",
285
+ url: "https://branch.example.test",
286
+ }),
287
+ );
288
+ });
289
+
290
+ await act(async () => {
291
+ await vi.waitFor(() => {
292
+ expect(container.textContent).toContain("Your Builder branch is ready");
293
+ });
294
+ });
295
+ const branchLink = Array.from(container.querySelectorAll("a")).find(
296
+ (candidate) => candidate.textContent?.includes("Open Builder branch"),
297
+ );
298
+ expect(branchLink?.getAttribute("href")).toBe(
299
+ "https://branch.example.test",
300
+ );
301
+ });
249
302
  });
@@ -362,6 +362,55 @@ export function CreateAppFlow({
362
362
  }
363
363
 
364
364
  const submitWithSelectedAccess = () => submit(prompt);
365
+ const isCreatingBuilderBranch =
366
+ isSubmitting && !isInBuilderFrame() && !isDevMode;
367
+
368
+ if (branchUrl) {
369
+ return (
370
+ <div
371
+ className={`flex min-h-[260px] flex-col items-center justify-center gap-5 px-6 py-8 text-center ${className}`}
372
+ >
373
+ <span className="flex size-11 items-center justify-center rounded-full bg-primary/10 text-primary">
374
+ <IconCheck className="size-5" aria-hidden="true" />
375
+ </span>
376
+ <div className="space-y-2">
377
+ <h2 className="text-base font-semibold text-foreground">
378
+ Your Builder branch is ready
379
+ </h2>
380
+ <p className="text-sm leading-6 text-muted-foreground">
381
+ Continue building and editing your app in Builder.
382
+ </p>
383
+ </div>
384
+ <Button asChild className="w-full sm:w-auto">
385
+ <a href={branchUrl} target="_blank" rel="noreferrer">
386
+ Open Builder branch <IconArrowUpRight aria-hidden="true" />
387
+ </a>
388
+ </Button>
389
+ </div>
390
+ );
391
+ }
392
+
393
+ if (isCreatingBuilderBranch) {
394
+ return (
395
+ <div
396
+ className={`flex min-h-[260px] flex-col items-center justify-center gap-4 px-6 py-8 text-center ${className}`}
397
+ aria-live="polite"
398
+ >
399
+ <IconLoader2
400
+ className="size-7 animate-spin text-muted-foreground"
401
+ aria-hidden="true"
402
+ />
403
+ <div className="space-y-2">
404
+ <h2 className="text-base font-semibold text-foreground">
405
+ Creating your Builder branch
406
+ </h2>
407
+ <p className="text-sm leading-6 text-muted-foreground">
408
+ This usually takes a few seconds.
409
+ </p>
410
+ </div>
411
+ </div>
412
+ );
413
+ }
365
414
 
366
415
  return (
367
416
  <div className={`flex flex-col gap-3 ${className}`}>
@@ -43,7 +43,6 @@ vi.mock("@agent-native/core/client/hooks", () => ({
43
43
  }));
44
44
 
45
45
  vi.mock("@agent-native/core/client/i18n", () => ({
46
- LanguagePicker: () => <div>Language</div>,
47
46
  useT: () => (key: string, values?: Record<string, unknown>) => {
48
47
  const messages: Record<string, string> = {
49
48
  "dispatch.nav.chat": "Chat",
@@ -8,7 +8,7 @@ import {
8
8
  type ChatThreadSummary,
9
9
  } from "@agent-native/core/client/agent-chat";
10
10
  import { appBasePath, appPath } from "@agent-native/core/client/api-path";
11
- import { LanguagePicker, useT } from "@agent-native/core/client/i18n";
11
+ import { useT } from "@agent-native/core/client/i18n";
12
12
  import { openCommandMenu } from "@agent-native/core/client/navigation";
13
13
  import { InvitationBanner, OrgSwitcher } from "@agent-native/core/client/org";
14
14
  import { FeedbackButton } from "@agent-native/core/client/ui";
@@ -229,7 +229,7 @@ const ADVANCED_NAV_ITEMS = [
229
229
 
230
230
  const EMPTY_NAV_ITEMS: readonly DispatchNavItem[] = [];
231
231
 
232
- const CHROMELESS_PATHS = ["/approval"];
232
+ const CHROMELESS_PATHS = ["/approval", "/browser-chat", "/browser-connect"];
233
233
  const SIDEBAR_COLLAPSE_KEY = "dispatch.sidebar.collapsed";
234
234
 
235
235
  // Routes whose page renders its own toolbar.
@@ -534,9 +534,6 @@ export function NavContent({
534
534
  <TooltipContent side="right">{t("sidebar.search")}</TooltipContent>
535
535
  </Tooltip>
536
536
  );
537
- const translateButton = (
538
- <LanguagePicker variant="ghost-icon" label={t("settings.languageLabel")} />
539
- );
540
537
  const feedbackButton = (
541
538
  <FeedbackButton
542
539
  variant={collapsed ? "icon" : "sidebar"}
@@ -743,7 +740,6 @@ export function NavContent({
743
740
  <SidebarFooterActions
744
741
  collapsed={collapsed}
745
742
  feedback={feedbackButton}
746
- translate={translateButton}
747
743
  search={searchButton}
748
744
  collapse={collapseButton}
749
745
  />
package/src/config.ts CHANGED
@@ -25,6 +25,11 @@ export interface DispatchIntegrationsConfig {
25
25
 
26
26
  export interface DispatchConfig {
27
27
  auth?: DispatchAuthConfig;
28
+ /**
29
+ * Exact Chrome Web Store or managed-install extension ids allowed to pair
30
+ * with Dispatch browser chat. Wildcards are not supported.
31
+ */
32
+ browserExtensionIds?: readonly string[];
28
33
  /**
29
34
  * App IDs to hide from `list-connected-agents` results. Used to filter
30
35
  * out first-party Builder apps (calls, issues, macros, …) from the
@@ -10,6 +10,13 @@ describe("buildDispatchNavigationState", () => {
10
10
  });
11
11
  });
12
12
 
13
+ it("recognizes the embedded browser chat route", () => {
14
+ expect(buildDispatchNavigationState("/browser-chat")).toEqual({
15
+ view: "browser-chat",
16
+ path: "/browser-chat",
17
+ });
18
+ });
19
+
13
20
  it("exposes the current extension id from extension routes", () => {
14
21
  expect(
15
22
  buildDispatchNavigationState("/extensions/ext-1/github-stars-over-time"),
@@ -208,6 +208,7 @@ function resolveView(
208
208
  if (pathname === "/extensions" || pathname.startsWith("/extensions/")) {
209
209
  return "extensions";
210
210
  }
211
+ if (pathname.startsWith("/browser-chat")) return "browser-chat";
211
212
  if (pathname.startsWith("/chat")) return "chat";
212
213
  if (pathname.startsWith("/apps")) return "apps";
213
214
  if (pathname.startsWith("/metrics")) return "metrics";
@@ -0,0 +1,64 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { listDispatchAutomations } from "./automations.js";
4
+
5
+ describe("listDispatchAutomations", () => {
6
+ afterEach(() => {
7
+ vi.unstubAllGlobals();
8
+ });
9
+
10
+ it("returns the automation list", async () => {
11
+ vi.stubGlobal(
12
+ "fetch",
13
+ vi.fn().mockResolvedValue(
14
+ new Response(
15
+ JSON.stringify([
16
+ {
17
+ id: "automation-1",
18
+ name: "daily-digest",
19
+ path: "jobs/daily-digest.md",
20
+ owner: "alice@example.com",
21
+ },
22
+ ]),
23
+ { status: 200, headers: { "content-type": "application/json" } },
24
+ ),
25
+ ),
26
+ );
27
+
28
+ await expect(listDispatchAutomations()).resolves.toEqual([
29
+ expect.objectContaining({ name: "daily-digest" }),
30
+ ]);
31
+ });
32
+
33
+ it("surfaces request failures instead of presenting an empty list", async () => {
34
+ vi.stubGlobal(
35
+ "fetch",
36
+ vi.fn().mockResolvedValue(
37
+ new Response(JSON.stringify({ error: "Database unavailable" }), {
38
+ status: 503,
39
+ headers: { "content-type": "application/json" },
40
+ }),
41
+ ),
42
+ );
43
+
44
+ await expect(listDispatchAutomations()).rejects.toThrow(
45
+ "Database unavailable",
46
+ );
47
+ });
48
+
49
+ it("rejects malformed successful responses", async () => {
50
+ vi.stubGlobal(
51
+ "fetch",
52
+ vi.fn().mockResolvedValue(
53
+ new Response(JSON.stringify({ automations: [] }), {
54
+ status: 200,
55
+ headers: { "content-type": "application/json" },
56
+ }),
57
+ ),
58
+ );
59
+
60
+ await expect(listDispatchAutomations()).rejects.toThrow(
61
+ "Automation list returned an invalid response",
62
+ );
63
+ });
64
+ });
@@ -43,9 +43,11 @@ export async function listDispatchAutomations(): Promise<
43
43
  DispatchAutomationItem[]
44
44
  > {
45
45
  const response = await fetch(agentNativePath("/_agent-native/automations"));
46
- if (!response.ok) return [];
47
- const rows = await response.json().catch(() => []);
48
- return Array.isArray(rows) ? rows : [];
46
+ const rows = await readAutomationResponse<unknown>(response);
47
+ if (!Array.isArray(rows)) {
48
+ throw new Error("Automation list returned an invalid response");
49
+ }
50
+ return rows as DispatchAutomationItem[];
49
51
  }
50
52
 
51
53
  export async function setDispatchAutomationEnabled(