@agent-native/dispatch 0.23.5 → 0.24.0

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 (99) hide show
  1. package/dist/actions/list-connected-agents.d.ts +1 -0
  2. package/dist/actions/list-connected-agents.js +5 -1
  3. package/dist/actions/list-connected-agents.js.map +1 -1
  4. package/dist/actions/list-dispatch-usage-metrics.d.ts +2 -0
  5. package/dist/actions/list-dispatch-usage-metrics.js +11 -1
  6. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
  7. package/dist/actions/remix-workspace-template.js +4 -3
  8. package/dist/actions/remix-workspace-template.js.map +1 -1
  9. package/dist/actions/scaffold-workspace-app.js +3 -2
  10. package/dist/actions/scaffold-workspace-app.js.map +1 -1
  11. package/dist/actions/start-workspace-app-creation.js +3 -2
  12. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  13. package/dist/actions/view-screen.js +11 -1
  14. package/dist/actions/view-screen.js.map +1 -1
  15. package/dist/components/app-icon.js +1 -1
  16. package/dist/components/app-icon.js.map +1 -1
  17. package/dist/components/connected-app-card.d.ts.map +1 -1
  18. package/dist/components/connected-app-card.js +2 -1
  19. package/dist/components/connected-app-card.js.map +1 -1
  20. package/dist/components/layout/Layout.d.ts.map +1 -1
  21. package/dist/components/layout/Layout.js +6 -7
  22. package/dist/components/layout/Layout.js.map +1 -1
  23. package/dist/components/workspace-template-card.d.ts.map +1 -1
  24. package/dist/components/workspace-template-card.js +8 -14
  25. package/dist/components/workspace-template-card.js.map +1 -1
  26. package/dist/hooks/use-navigation-state.d.ts +2 -0
  27. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  28. package/dist/hooks/use-navigation-state.js +10 -0
  29. package/dist/hooks/use-navigation-state.js.map +1 -1
  30. package/dist/lib/embed-session-recovery.d.ts +3 -0
  31. package/dist/lib/embed-session-recovery.d.ts.map +1 -0
  32. package/dist/lib/embed-session-recovery.js +18 -0
  33. package/dist/lib/embed-session-recovery.js.map +1 -0
  34. package/dist/lib/other-apps.d.ts +2 -0
  35. package/dist/lib/other-apps.d.ts.map +1 -1
  36. package/dist/lib/other-apps.js.map +1 -1
  37. package/dist/lib/workspace-apps.d.ts +4 -0
  38. package/dist/lib/workspace-apps.d.ts.map +1 -1
  39. package/dist/lib/workspace-apps.js +7 -0
  40. package/dist/lib/workspace-apps.js.map +1 -1
  41. package/dist/lib/workspace-apps.test.d.ts +2 -0
  42. package/dist/lib/workspace-apps.test.d.ts.map +1 -0
  43. package/dist/lib/workspace-apps.test.js +16 -0
  44. package/dist/lib/workspace-apps.test.js.map +1 -0
  45. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  46. package/dist/routes/pages/apps.$appId.js +10 -11
  47. package/dist/routes/pages/apps.$appId.js.map +1 -1
  48. package/dist/routes/pages/chat.d.ts.map +1 -1
  49. package/dist/routes/pages/chat.js +7 -2
  50. package/dist/routes/pages/chat.js.map +1 -1
  51. package/dist/routes/pages/metrics.d.ts.map +1 -1
  52. package/dist/routes/pages/metrics.js +143 -17
  53. package/dist/routes/pages/metrics.js.map +1 -1
  54. package/dist/routes/pages/settings.d.ts.map +1 -1
  55. package/dist/routes/pages/settings.js +13 -1
  56. package/dist/routes/pages/settings.js.map +1 -1
  57. package/dist/server/lib/usage-metrics-store.d.ts +17 -0
  58. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  59. package/dist/server/lib/usage-metrics-store.js +187 -30
  60. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  61. package/dist/server/plugins/agent-chat.js +2 -0
  62. package/dist/server/plugins/agent-chat.js.map +1 -1
  63. package/dist/server/plugins/integrations.d.ts.map +1 -1
  64. package/dist/server/plugins/integrations.js +1 -0
  65. package/dist/server/plugins/integrations.js.map +1 -1
  66. package/package.json +3 -3
  67. package/src/actions/list-connected-agents.spec.ts +73 -0
  68. package/src/actions/list-connected-agents.ts +6 -2
  69. package/src/actions/list-dispatch-usage-metrics.ts +11 -1
  70. package/src/actions/remix-workspace-template.spec.ts +29 -0
  71. package/src/actions/remix-workspace-template.ts +13 -6
  72. package/src/actions/scaffold-workspace-app.ts +12 -5
  73. package/src/actions/start-workspace-app-creation.ts +8 -2
  74. package/src/actions/view-screen.ts +13 -1
  75. package/src/components/app-icon.spec.tsx +34 -0
  76. package/src/components/app-icon.tsx +1 -1
  77. package/src/components/connected-app-card.tsx +3 -1
  78. package/src/components/dispatch-control-plane.spec.tsx +8 -1
  79. package/src/components/layout/Layout.spec.tsx +3 -0
  80. package/src/components/layout/Layout.tsx +13 -9
  81. package/src/components/workspace-template-card.spec.tsx +1 -1
  82. package/src/components/workspace-template-card.tsx +9 -16
  83. package/src/hooks/use-navigation-state.ts +12 -0
  84. package/src/lib/embed-session-recovery.spec.ts +63 -0
  85. package/src/lib/embed-session-recovery.ts +26 -0
  86. package/src/lib/other-apps.ts +2 -0
  87. package/src/lib/workspace-apps.test.ts +20 -0
  88. package/src/lib/workspace-apps.ts +10 -0
  89. package/src/routes/pages/apps.$appId.tsx +10 -11
  90. package/src/routes/pages/chat.spec.tsx +10 -0
  91. package/src/routes/pages/chat.tsx +8 -0
  92. package/src/routes/pages/metrics.tsx +452 -102
  93. package/src/routes/pages/settings.tsx +13 -1
  94. package/src/server/lib/usage-metrics-store.spec.ts +117 -0
  95. package/src/server/lib/usage-metrics-store.ts +249 -30
  96. package/src/server/plugins/agent-chat.spec.ts +4 -0
  97. package/src/server/plugins/agent-chat.ts +2 -0
  98. package/src/server/plugins/integrations.ts +1 -0
  99. package/src/styles/dispatch.css +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.23.5",
3
+ "version": "0.24.0",
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.13.10"
87
+ "@agent-native/toolkit": "0.14.0"
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.151.0"
101
+ "@agent-native/core": "0.152.0"
102
102
  },
103
103
  "peerDependencies": {
104
104
  "@agent-native/core": ">=0.8.0",
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ discoverAgents: vi.fn(),
5
+ getBuiltinAgents: vi.fn(),
6
+ getRequestUserEmail: vi.fn(() => "owner@example.test"),
7
+ resourceGet: vi.fn(),
8
+ resourceListAccessible: vi.fn(async () => []),
9
+ }));
10
+
11
+ vi.mock("@agent-native/core", () => ({
12
+ defineAction: (options: unknown) => options,
13
+ }));
14
+
15
+ vi.mock("@agent-native/core/resources/metadata", () => ({
16
+ parseRemoteAgentManifest: vi.fn(),
17
+ REMOTE_AGENT_RESOURCE_PREFIXES: [],
18
+ }));
19
+
20
+ vi.mock("@agent-native/core/resources/store", () => ({
21
+ resourceGet: (...args: unknown[]) => mocks.resourceGet(...args),
22
+ resourceListAccessible: (...args: unknown[]) =>
23
+ mocks.resourceListAccessible(...args),
24
+ SHARED_OWNER: "shared",
25
+ }));
26
+
27
+ vi.mock("@agent-native/core/server", () => ({
28
+ getRequestUserEmail: () => mocks.getRequestUserEmail(),
29
+ }));
30
+
31
+ vi.mock("@agent-native/core/server/agent-discovery", () => ({
32
+ discoverAgents: (...args: unknown[]) => mocks.discoverAgents(...args),
33
+ getBuiltinAgents: (...args: unknown[]) => mocks.getBuiltinAgents(...args),
34
+ normalizeAgentId: (id: string) => id.trim().toLowerCase(),
35
+ shouldIncludeRemoteAgentManifest: vi.fn(),
36
+ }));
37
+
38
+ vi.mock("../server/index.js", () => ({
39
+ getDispatchConfig: vi.fn(async () => ({ hiddenAgentIds: [] })),
40
+ }));
41
+
42
+ describe("list-connected-agents", () => {
43
+ it("keeps a built-in app home URL separate from its A2A endpoint", async () => {
44
+ mocks.getBuiltinAgents.mockReturnValue([
45
+ {
46
+ id: "clips",
47
+ name: "Clips",
48
+ description: "Record and share",
49
+ url: "https://clips.agent-native.com",
50
+ color: "#000000",
51
+ },
52
+ ]);
53
+ mocks.discoverAgents.mockResolvedValue([
54
+ {
55
+ id: "clips",
56
+ name: "Clips",
57
+ description: "Record and share",
58
+ url: "https://clips.agent-native.com/share/WrA8ZQ3oxa2T?ref=clip_share",
59
+ color: "#000000",
60
+ },
61
+ ]);
62
+
63
+ const { default: action } = await import("./list-connected-agents.js");
64
+ const [clips] = await action.run({});
65
+
66
+ expect(clips).toMatchObject({
67
+ id: "clips",
68
+ url: "https://clips.agent-native.com/share/WrA8ZQ3oxa2T?ref=clip_share",
69
+ homeUrl: "https://clips.agent-native.com",
70
+ source: "builtin",
71
+ });
72
+ });
73
+ });
@@ -27,8 +27,10 @@ export default defineAction({
27
27
  (m) => m.getDispatchConfig(),
28
28
  );
29
29
  const discovered = await discoverAgents("dispatch");
30
- const builtinIds = new Set(
31
- getBuiltinAgents("dispatch").map((agent) => agent.id),
30
+ const builtins = getBuiltinAgents("dispatch");
31
+ const builtinIds = new Set(builtins.map((agent) => agent.id));
32
+ const builtinHomeUrls = new Map(
33
+ builtins.map((agent) => [agent.id, agent.url]),
32
34
  );
33
35
  const ownerEmail = getRequestUserEmail();
34
36
  if (!ownerEmail) throw new Error("no authenticated user");
@@ -78,8 +80,10 @@ export default defineAction({
78
80
  const connected = discovered.map((agent) => {
79
81
  const custom = customById.get(agent.id);
80
82
  const isBuiltin = builtinIds.has(agent.id);
83
+ const homeUrl = isBuiltin ? builtinHomeUrls.get(agent.id) : undefined;
81
84
  return {
82
85
  ...agent,
86
+ ...(homeUrl ? { homeUrl } : {}),
83
87
  source: isBuiltin ? "builtin" : custom ? "custom" : "workspace",
84
88
  resourceId: custom?.resourceId,
85
89
  path: custom?.path,
@@ -5,7 +5,7 @@ import { listDispatchUsageMetrics } from "../server/lib/usage-metrics-store.js";
5
5
 
6
6
  export default defineAction({
7
7
  description:
8
- "Get workspace-level LLM usage, spend or Builder.io credit spend, users, app access, and recent activity metrics for Dispatch admins.",
8
+ "Get personal or workspace LLM usage, spend or Builder.io credit spend, app attribution, prompt previews, and recent activity metrics for Dispatch.",
9
9
  schema: z.object({
10
10
  sinceDays: z.coerce
11
11
  .number()
@@ -14,6 +14,16 @@ export default defineAction({
14
14
  .max(365)
15
15
  .default(30)
16
16
  .describe("Lookback window in days. Defaults to 30."),
17
+ scope: z
18
+ .enum(["me", "workspace"])
19
+ .default("workspace")
20
+ .describe("Whether to inspect your account or the whole workspace."),
21
+ userEmail: z
22
+ .string()
23
+ .trim()
24
+ .min(1)
25
+ .optional()
26
+ .describe("Optional workspace member email to filter all usage to."),
17
27
  }),
18
28
  http: { method: "GET" },
19
29
  run: async (args) => listDispatchUsageMetrics(args),
@@ -68,6 +68,35 @@ describe("remix-workspace-template action", () => {
68
68
  );
69
69
  });
70
70
 
71
+ it("normalizes a human-friendly app name before scaffolding", async () => {
72
+ vi.stubEnv("NODE_ENV", "test");
73
+ vi.stubEnv("NETLIFY", "");
74
+ mocks.scaffoldWorkspaceAppFromTemplate.mockResolvedValue({
75
+ appId: "customer-portal",
76
+ template: "mail",
77
+ output: "scaffolded",
78
+ });
79
+
80
+ await action.run({
81
+ templateId: "mail",
82
+ appId: "Customer Portal",
83
+ });
84
+
85
+ expect(mocks.scaffoldWorkspaceAppFromTemplate).toHaveBeenCalledWith({
86
+ template: "mail",
87
+ appId: "customer-portal",
88
+ });
89
+ });
90
+
91
+ it("rejects names that normalize to an empty app id", async () => {
92
+ await expect(
93
+ action.run({ templateId: "mail", appId: "---" }),
94
+ ).rejects.toThrow(
95
+ "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
96
+ );
97
+ expect(mocks.scaffoldWorkspaceAppFromTemplate).not.toHaveBeenCalled();
98
+ });
99
+
71
100
  it("starts a hosted private app without copying data or secrets", async () => {
72
101
  vi.stubEnv("NODE_ENV", "production");
73
102
  mocks.startWorkspaceAppCreation.mockResolvedValue({
@@ -1,5 +1,8 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { getWorkspaceAppIdValidationError } from "@agent-native/core/shared";
2
+ import {
3
+ getWorkspaceAppIdValidationError,
4
+ normalizeWorkspaceAppId,
5
+ } from "@agent-native/core/shared";
3
6
  import { z } from "zod";
4
7
 
5
8
  import {
@@ -42,14 +45,18 @@ export default defineAction({
42
45
  appId: z
43
46
  .string()
44
47
  .max(64)
48
+ .transform(normalizeWorkspaceAppId)
45
49
  .optional()
46
50
  .nullable()
47
- .refine((appId) => !appId || !getWorkspaceAppIdValidationError(appId), {
48
- message:
49
- "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
50
- })
51
+ .refine(
52
+ (appId) => appId == null || !getWorkspaceAppIdValidationError(appId),
53
+ {
54
+ message:
55
+ "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
56
+ },
57
+ )
51
58
  .describe(
52
- "Optional target workspace app id. Defaults to <template>-remix so the source app remains independent.",
59
+ "Optional target workspace app id or human-friendly name. Names are converted to lowercase, hyphenated ids. Defaults to <template>-remix so the source app remains independent.",
53
60
  ),
54
61
  description: z
55
62
  .string()
@@ -1,5 +1,8 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { getWorkspaceAppIdValidationError } from "@agent-native/core/shared";
2
+ import {
3
+ getWorkspaceAppIdValidationError,
4
+ normalizeWorkspaceAppId,
5
+ } from "@agent-native/core/shared";
3
6
  import { z } from "zod";
4
7
 
5
8
  import { scaffoldWorkspaceAppFromTemplate } from "../server/lib/app-creation-store.js";
@@ -27,12 +30,16 @@ export default defineAction({
27
30
  appId: z
28
31
  .string()
29
32
  .max(64)
33
+ .transform(normalizeWorkspaceAppId)
30
34
  .optional()
31
35
  .nullable()
32
- .refine((appId) => !appId || !getWorkspaceAppIdValidationError(appId), {
33
- message:
34
- "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
35
- })
36
+ .refine(
37
+ (appId) => appId == null || !getWorkspaceAppIdValidationError(appId),
38
+ {
39
+ message:
40
+ "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
41
+ },
42
+ )
36
43
  .describe(
37
44
  "Optional override for the apps/<id>/ directory name; defaults to the template name",
38
45
  ),
@@ -1,5 +1,8 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { getWorkspaceAppIdValidationError } from "@agent-native/core/shared";
2
+ import {
3
+ getWorkspaceAppIdValidationError,
4
+ normalizeWorkspaceAppId,
5
+ } from "@agent-native/core/shared";
3
6
  import { z } from "zod";
4
7
 
5
8
  import { startWorkspaceAppCreation } from "../server/lib/app-creation-store.js";
@@ -12,13 +15,16 @@ export default defineAction({
12
15
  appId: z
13
16
  .string()
14
17
  .max(64)
18
+ .transform(normalizeWorkspaceAppId)
15
19
  .refine((appId) => !getWorkspaceAppIdValidationError(appId), {
16
20
  message:
17
21
  "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
18
22
  })
19
23
  .optional()
20
24
  .nullable()
21
- .describe("Desired workspace app id/path"),
25
+ .describe(
26
+ "Desired workspace app id/path or human-friendly name. Names are converted to lowercase, hyphenated ids.",
27
+ ),
22
28
  template: z
23
29
  .string()
24
30
  .optional()
@@ -155,9 +155,21 @@ export default defineAction({
155
155
  }
156
156
  if (navigation?.view === "metrics") {
157
157
  try {
158
- const metrics = await listDispatchUsageMetrics({ sinceDays: 30 });
158
+ const usageScope =
159
+ navigation.usageScope === "workspace" ? "workspace" : "me";
160
+ const usageUserEmail =
161
+ typeof navigation.usageUserEmail === "string"
162
+ ? navigation.usageUserEmail
163
+ : undefined;
164
+ const metrics = await listDispatchUsageMetrics({
165
+ sinceDays: 30,
166
+ scope: usageScope,
167
+ userEmail: usageUserEmail,
168
+ });
159
169
  screen.usageMetrics = {
160
170
  billing: metrics.billing,
171
+ viewScope: metrics.viewScope,
172
+ selectedUserEmail: metrics.selectedUserEmail,
161
173
  totals: metrics.totals,
162
174
  byApp: metrics.byApp.slice(0, 8),
163
175
  byUser: metrics.byUser.slice(0, 8),
@@ -0,0 +1,34 @@
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 } from "vitest";
5
+
6
+ import { AppIcon } from "./app-icon";
7
+
8
+ describe("AppIcon", () => {
9
+ let container: HTMLDivElement;
10
+ let root: Root;
11
+
12
+ beforeEach(() => {
13
+ container = document.createElement("div");
14
+ document.body.appendChild(container);
15
+ root = createRoot(container);
16
+ });
17
+
18
+ afterEach(() => {
19
+ act(() => root.unmount());
20
+ container.remove();
21
+ });
22
+
23
+ it("gives Plan a colored generated accent instead of a neutral gray", () => {
24
+ act(() => {
25
+ root.render(<AppIcon id="plan" name="Plan" />);
26
+ });
27
+
28
+ const icon = container.firstElementChild as HTMLElement;
29
+ expect(icon.style.getPropertyValue("--dispatch-app-icon-color-rgb")).toBe(
30
+ "47 111 237",
31
+ );
32
+ expect(icon.style.color).toBe("rgb(var(--dispatch-app-icon-color-rgb))");
33
+ });
34
+ });
@@ -67,7 +67,7 @@ const APP_VISUALS_BY_ID: Record<
67
67
  forms: { icon: IconStack2, colorRgb: "6 182 212" },
68
68
  macros: { icon: IconCode, colorRgb: "113 113 122" },
69
69
  mail: { icon: IconMail, colorRgb: "59 130 246" },
70
- plan: { icon: IconLayoutBoard, colorRgb: "82 82 91" },
70
+ plan: { icon: IconLayoutBoard, colorRgb: "47 111 237" },
71
71
  slides: { icon: IconPresentation, colorRgb: "236 72 153" },
72
72
  tasks: { icon: IconListCheck, colorRgb: "99 102 241" },
73
73
  };
@@ -12,6 +12,8 @@ export function ConnectedAppCard({
12
12
  app: ConnectedAppSummary;
13
13
  className?: string;
14
14
  }) {
15
+ const launchUrl = app.homeUrl ?? app.url;
16
+
15
17
  return (
16
18
  <AppListRow className={className}>
17
19
  <AppIcon id={app.id} name={app.name} color={app.color} size="sm" />
@@ -25,7 +27,7 @@ export function ConnectedAppCard({
25
27
  </div>
26
28
  <AppOpenActions
27
29
  name={app.name}
28
- href={withBuilderUtmTrackingParams(app.url, {
30
+ href={withBuilderUtmTrackingParams(launchUrl, {
29
31
  campaign: "product",
30
32
  content: "dispatch_app",
31
33
  })}
@@ -231,7 +231,9 @@ describe("DispatchControlPlane", () => {
231
231
  id: "clips",
232
232
  name: "Clips",
233
233
  description: "Record and share",
234
- url: "https://clips.agent-native.com",
234
+ url: "https://clips.agent-native.com/share/WrA8ZQ3oxa2T?ref=clip_share",
235
+ homeUrl: "https://clips.agent-native.com",
236
+ source: "builtin",
235
237
  },
236
238
  {
237
239
  id: "onboarding",
@@ -282,5 +284,10 @@ describe("DispatchControlPlane", () => {
282
284
  anchor.getAttribute("href")?.includes("onboarding"),
283
285
  ),
284
286
  ).toHaveLength(1);
287
+ const clipsHref = Array.from(container.querySelectorAll("a"))
288
+ .map((anchor) => anchor.getAttribute("href"))
289
+ .find((href) => href?.includes("clips.agent-native.com"));
290
+ expect(clipsHref).toContain("https://clips.agent-native.com");
291
+ expect(clipsHref).not.toContain("/share/");
285
292
  });
286
293
  });
@@ -302,6 +302,9 @@ describe("Dispatch NavContent", () => {
302
302
  );
303
303
  expect(sidebarLabel?.textContent?.trim()).toBe("Dispatch");
304
304
  expect(container.textContent).not.toContain("Agent-Native Dispatch");
305
+ expect(
306
+ sidebarLabel?.closest('a[data-dispatch-logo][href="/overview"]'),
307
+ ).not.toBeNull();
305
308
 
306
309
  const settingsLink = container.querySelector('a[href="/settings"]');
307
310
  const adminLink = container.querySelector('a[href="/admin"]');
@@ -102,6 +102,7 @@ import {
102
102
  import { Link, useLocation, useNavigate } from "react-router";
103
103
  import { toast } from "sonner";
104
104
 
105
+ import { isEmbedSessionExpiredMessage } from "../../lib/embed-session-recovery";
105
106
  import { cn } from "../../lib/utils";
106
107
  import {
107
108
  isDispatchWorkspaceAppId,
@@ -1193,7 +1194,7 @@ export function NavContent({
1193
1194
  <div
1194
1195
  className={cn("py-2", collapsed ? "flex justify-center px-1" : "px-3")}
1195
1196
  >
1196
- <OrgSwitcher compact={collapsed} reserveSpace />
1197
+ <OrgSwitcher compact={collapsed} reserveSpace currentAppId="dispatch" />
1197
1198
  </div>
1198
1199
  );
1199
1200
  const sidebarFooterActions = (
@@ -1213,9 +1214,12 @@ export function NavContent({
1213
1214
  collapsed ? "justify-center px-0" : "px-4",
1214
1215
  )}
1215
1216
  >
1216
- <div
1217
+ <Link
1218
+ to={dispatchNavLinkTarget("/overview")}
1219
+ aria-label={`${DISPATCH_SIDEBAR_LABEL} overview`}
1220
+ data-dispatch-logo
1217
1221
  className={cn(
1218
- "flex items-center",
1222
+ "flex items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
1219
1223
  collapsed ? "justify-center" : "gap-2",
1220
1224
  )}
1221
1225
  >
@@ -1251,7 +1255,7 @@ export function NavContent({
1251
1255
  </div>
1252
1256
  </div>
1253
1257
  )}
1254
- </div>
1258
+ </Link>
1255
1259
  </div>
1256
1260
 
1257
1261
  {chatFirstMode ? (
@@ -1586,11 +1590,9 @@ export function Layout({
1586
1590
 
1587
1591
  const handleEmbedSessionExpired = (event: MessageEvent) => {
1588
1592
  if (
1589
- event.data?.type !== "agentNative.embedSessionExpired" ||
1590
- event.source !== chatFirstAppFrameRef.current?.contentWindow
1591
- ) {
1593
+ !isEmbedSessionExpiredMessage(event, chatFirstAppFrameRef.current, null)
1594
+ )
1592
1595
  return;
1593
- }
1594
1596
  setChatFirstEmbedAttempt((attempt) => attempt + 1);
1595
1597
  };
1596
1598
 
@@ -2109,13 +2111,14 @@ export function Layout({
2109
2111
  chatOnly
2110
2112
  dynamicSuggestions={false}
2111
2113
  suggestions={[]}
2114
+ suppressInlineOpenApp={chatFirstMode}
2112
2115
  emptyStateDisplay="hidden"
2113
2116
  composerPlaceholder={t(
2114
2117
  "dispatch.pages.chatFirstSessionMessagePlaceholder",
2115
2118
  )}
2116
2119
  />
2117
2120
  ),
2118
- [t],
2121
+ [chatFirstMode, t],
2119
2122
  );
2120
2123
 
2121
2124
  const renderChatFirstSurfaceTab = useCallback(
@@ -2152,6 +2155,7 @@ export function Layout({
2152
2155
  onRetry={() => setChatFirstEmbedAttempt((value) => value + 1)}
2153
2156
  renderEmbed={({ url, title }: ChatFirstEmbedTarget) => (
2154
2157
  <iframe
2158
+ key={`${url}:${chatFirstEmbedAttempt}`}
2155
2159
  data-dispatch-chat-first-app-frame
2156
2160
  src={url}
2157
2161
  title={title ?? chatFirstCopy("appUnavailable")}
@@ -130,7 +130,7 @@ describe("WorkspaceTemplateCard", () => {
130
130
  Object.getOwnPropertyDescriptor(
131
131
  HTMLInputElement.prototype,
132
132
  "value",
133
- )?.set?.call(input, "sales-ops");
133
+ )?.set?.call(input, "Sales Ops");
134
134
  input.dispatchEvent(new Event("input", { bubbles: true }));
135
135
  });
136
136
 
@@ -1,4 +1,5 @@
1
1
  import { useActionMutation } from "@agent-native/core/client/hooks";
2
+ import { normalizeWorkspaceAppId } from "@agent-native/core/shared";
2
3
  import {
3
4
  IconArrowUpRight,
4
5
  IconCircleCheck,
@@ -94,17 +95,6 @@ export interface WorkspaceTemplateCardProps {
94
95
  ) => void;
95
96
  }
96
97
 
97
- function slugifyAppId(value: string): string {
98
- return (
99
- value
100
- .trim()
101
- .toLowerCase()
102
- .replace(/[^a-z0-9]+/g, "-")
103
- .replace(/^-+|-+$/g, "")
104
- .slice(0, 48) || "new-app"
105
- );
106
- }
107
-
108
98
  function templateIdFor(template: CuratedWorkspaceTemplate): string {
109
99
  return template.templateId || template.id || template.appId || template.name;
110
100
  }
@@ -113,8 +103,9 @@ function defaultAppIdFor(
113
103
  template: CuratedWorkspaceTemplate,
114
104
  defaultAppId?: string,
115
105
  ): string {
116
- if (defaultAppId) return slugifyAppId(defaultAppId);
117
- const sourceId = slugifyAppId(template.appId || template.name);
106
+ if (defaultAppId) return normalizeWorkspaceAppId(defaultAppId) || "new-app";
107
+ const sourceId =
108
+ normalizeWorkspaceAppId(template.appId || template.name) || "new-app";
118
109
  return `${sourceId}-app`;
119
110
  }
120
111
 
@@ -173,15 +164,16 @@ export function WorkspaceTemplateCard({
173
164
  }, [defaultAppId, open, template]);
174
165
 
175
166
  function submitRemix() {
176
- const trimmedAppId = appId.trim();
177
- if (!trimmedAppId) {
167
+ const normalizedAppId = normalizeWorkspaceAppId(appId);
168
+ if (!normalizedAppId) {
178
169
  toast.error(labels.appIdRequired);
179
170
  return;
180
171
  }
172
+ setAppId(normalizedAppId);
181
173
 
182
174
  remix.mutate({
183
175
  templateId: templateIdFor(template),
184
- appId: trimmedAppId,
176
+ appId: normalizedAppId,
185
177
  });
186
178
  }
187
179
 
@@ -280,6 +272,7 @@ export function WorkspaceTemplateCard({
280
272
  value={appId}
281
273
  autoComplete="off"
282
274
  onChange={(event) => setAppId(event.target.value)}
275
+ onBlur={() => setAppId(normalizeWorkspaceAppId(appId))}
283
276
  disabled={remix.isPending}
284
277
  />
285
278
  </div>
@@ -32,6 +32,8 @@ export interface NavigationState {
32
32
  query?: string;
33
33
  runId?: string;
34
34
  threadId?: string;
35
+ usageScope?: "me" | "workspace";
36
+ usageUserEmail?: string;
35
37
  }
36
38
 
37
39
  export function useNavigationState(extensions?: DispatchExtensionConfig) {
@@ -177,6 +179,16 @@ export function buildDispatchNavigationState(
177
179
  if (selectedThreadId) state.threadId = selectedThreadId;
178
180
  }
179
181
 
182
+ if (state.view === "metrics") {
183
+ const params = new URLSearchParams(search);
184
+ const usageScope = params.get("scope");
185
+ const usageUserEmail = params.get("user");
186
+ if (usageScope === "me" || usageScope === "workspace") {
187
+ state.usageScope = usageScope;
188
+ }
189
+ if (usageUserEmail) state.usageUserEmail = usageUserEmail;
190
+ }
191
+
180
192
  return state;
181
193
  }
182
194
 
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ EMBED_SESSION_EXPIRED_MESSAGE,
5
+ isEmbedSessionExpiredMessage,
6
+ } from "./embed-session-recovery";
7
+
8
+ const frameWindow = {} as Window;
9
+ const frame = {
10
+ contentWindow: frameWindow,
11
+ src: "https://mail.example.test/_agent-native/embed/start?ticket=one",
12
+ } as HTMLIFrameElement;
13
+
14
+ describe("isEmbedSessionExpiredMessage", () => {
15
+ it("accepts a message from the active app frame", () => {
16
+ expect(
17
+ isEmbedSessionExpiredMessage(
18
+ { data: { type: EMBED_SESSION_EXPIRED_MESSAGE }, source: frameWindow },
19
+ frame,
20
+ ),
21
+ ).toBe(true);
22
+ });
23
+
24
+ it("rejects messages from another frame", () => {
25
+ expect(
26
+ isEmbedSessionExpiredMessage(
27
+ { data: { type: EMBED_SESSION_EXPIRED_MESSAGE }, source: {} as Window },
28
+ frame,
29
+ ),
30
+ ).toBe(false);
31
+ });
32
+
33
+ it("accepts a matching start URL when the browser omits event.source", () => {
34
+ expect(
35
+ isEmbedSessionExpiredMessage(
36
+ {
37
+ data: {
38
+ type: EMBED_SESSION_EXPIRED_MESSAGE,
39
+ embedStartUrl: frame.src,
40
+ },
41
+ source: null,
42
+ },
43
+ frame,
44
+ ),
45
+ ).toBe(true);
46
+ });
47
+
48
+ it("can match the expected URL before the iframe ref is available", () => {
49
+ expect(
50
+ isEmbedSessionExpiredMessage(
51
+ {
52
+ data: {
53
+ type: EMBED_SESSION_EXPIRED_MESSAGE,
54
+ embedStartUrl: frame.src,
55
+ },
56
+ source: null,
57
+ },
58
+ null,
59
+ frame.src,
60
+ ),
61
+ ).toBe(true);
62
+ });
63
+ });
@@ -0,0 +1,26 @@
1
+ export const EMBED_SESSION_EXPIRED_MESSAGE = "agentNative.embedSessionExpired";
2
+
3
+ interface EmbedSessionExpiredMessage {
4
+ type?: unknown;
5
+ embedStartUrl?: unknown;
6
+ }
7
+
8
+ export function isEmbedSessionExpiredMessage(
9
+ event: Pick<MessageEvent, "data" | "source">,
10
+ frame: Pick<HTMLIFrameElement, "contentWindow" | "src"> | null,
11
+ expectedStartUrl?: string | null,
12
+ ): boolean {
13
+ const data =
14
+ event.data && typeof event.data === "object"
15
+ ? (event.data as EmbedSessionExpiredMessage)
16
+ : null;
17
+ if (data?.type !== EMBED_SESSION_EXPIRED_MESSAGE) return false;
18
+
19
+ const frameWindow = frame?.contentWindow;
20
+ if (frameWindow && event.source === frameWindow) return true;
21
+ if (event.source !== null) return false;
22
+ if (typeof data.embedStartUrl !== "string") return false;
23
+
24
+ const frameUrl = frame?.src || expectedStartUrl;
25
+ return Boolean(frameUrl && data.embedStartUrl === frameUrl);
26
+ }