@agent-native/dispatch 0.23.5 → 0.24.2

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 (224) 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/provider-api-register.d.ts +12 -12
  8. package/dist/actions/remix-workspace-template.js +4 -3
  9. package/dist/actions/remix-workspace-template.js.map +1 -1
  10. package/dist/actions/scaffold-workspace-app.js +3 -2
  11. package/dist/actions/scaffold-workspace-app.js.map +1 -1
  12. package/dist/actions/start-workspace-app-creation.js +3 -2
  13. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  14. package/dist/actions/view-screen.js +11 -1
  15. package/dist/actions/view-screen.js.map +1 -1
  16. package/dist/components/admin-navigation.d.ts.map +1 -1
  17. package/dist/components/admin-navigation.js +21 -5
  18. package/dist/components/admin-navigation.js.map +1 -1
  19. package/dist/components/app-icon.js +1 -1
  20. package/dist/components/app-icon.js.map +1 -1
  21. package/dist/components/app-list-row.d.ts +1 -1
  22. package/dist/components/app-list-row.d.ts.map +1 -1
  23. package/dist/components/app-list-row.js +1 -1
  24. package/dist/components/app-list-row.js.map +1 -1
  25. package/dist/components/app-open-actions.d.ts +1 -17
  26. package/dist/components/app-open-actions.d.ts.map +1 -1
  27. package/dist/components/app-open-actions.js +1 -20
  28. package/dist/components/app-open-actions.js.map +1 -1
  29. package/dist/components/app-row-settings.d.ts +6 -0
  30. package/dist/components/app-row-settings.d.ts.map +1 -0
  31. package/dist/components/app-row-settings.js +11 -0
  32. package/dist/components/app-row-settings.js.map +1 -0
  33. package/dist/components/automation-run-history-dialog.d.ts.map +1 -1
  34. package/dist/components/automation-run-history-dialog.js +3 -2
  35. package/dist/components/automation-run-history-dialog.js.map +1 -1
  36. package/dist/components/connected-app-card.d.ts.map +1 -1
  37. package/dist/components/connected-app-card.js +3 -2
  38. package/dist/components/connected-app-card.js.map +1 -1
  39. package/dist/components/create-app-popover.js +2 -2
  40. package/dist/components/create-app-popover.js.map +1 -1
  41. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  42. package/dist/components/dispatch-control-plane.js +23 -7
  43. package/dist/components/dispatch-control-plane.js.map +1 -1
  44. package/dist/components/dispatch-shell.d.ts.map +1 -1
  45. package/dist/components/dispatch-shell.js +5 -1
  46. package/dist/components/dispatch-shell.js.map +1 -1
  47. package/dist/components/docs-link.d.ts +16 -0
  48. package/dist/components/docs-link.d.ts.map +1 -0
  49. package/dist/components/docs-link.js +34 -0
  50. package/dist/components/docs-link.js.map +1 -0
  51. package/dist/components/layout/Layout.d.ts +1 -0
  52. package/dist/components/layout/Layout.d.ts.map +1 -1
  53. package/dist/components/layout/Layout.js +43 -35
  54. package/dist/components/layout/Layout.js.map +1 -1
  55. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  56. package/dist/components/messaging-setup-panel.js +3 -2
  57. package/dist/components/messaging-setup-panel.js.map +1 -1
  58. package/dist/components/usage-alerts-panel.d.ts +11 -0
  59. package/dist/components/usage-alerts-panel.d.ts.map +1 -0
  60. package/dist/components/usage-alerts-panel.js +125 -0
  61. package/dist/components/usage-alerts-panel.js.map +1 -0
  62. package/dist/components/workspace-app-card.d.ts +3 -1
  63. package/dist/components/workspace-app-card.d.ts.map +1 -1
  64. package/dist/components/workspace-app-card.js +70 -29
  65. package/dist/components/workspace-app-card.js.map +1 -1
  66. package/dist/components/workspace-app-search.d.ts +10 -0
  67. package/dist/components/workspace-app-search.d.ts.map +1 -0
  68. package/dist/components/workspace-app-search.js +27 -0
  69. package/dist/components/workspace-app-search.js.map +1 -0
  70. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  71. package/dist/components/workspace-resource-effective-stack.js +3 -2
  72. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  73. package/dist/components/workspace-template-card.d.ts.map +1 -1
  74. package/dist/components/workspace-template-card.js +10 -15
  75. package/dist/components/workspace-template-card.js.map +1 -1
  76. package/dist/hooks/use-navigation-state.d.ts +2 -0
  77. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  78. package/dist/hooks/use-navigation-state.js +10 -0
  79. package/dist/hooks/use-navigation-state.js.map +1 -1
  80. package/dist/lib/embed-session-recovery.d.ts +3 -0
  81. package/dist/lib/embed-session-recovery.d.ts.map +1 -0
  82. package/dist/lib/embed-session-recovery.js +18 -0
  83. package/dist/lib/embed-session-recovery.js.map +1 -0
  84. package/dist/lib/other-apps.d.ts +2 -0
  85. package/dist/lib/other-apps.d.ts.map +1 -1
  86. package/dist/lib/other-apps.js.map +1 -1
  87. package/dist/lib/workspace-app-layout.d.ts +19 -0
  88. package/dist/lib/workspace-app-layout.d.ts.map +1 -0
  89. package/dist/lib/workspace-app-layout.js +75 -0
  90. package/dist/lib/workspace-app-layout.js.map +1 -0
  91. package/dist/lib/workspace-apps.d.ts +14 -0
  92. package/dist/lib/workspace-apps.d.ts.map +1 -1
  93. package/dist/lib/workspace-apps.js +66 -0
  94. package/dist/lib/workspace-apps.js.map +1 -1
  95. package/dist/lib/workspace-apps.test.d.ts +2 -0
  96. package/dist/lib/workspace-apps.test.d.ts.map +1 -0
  97. package/dist/lib/workspace-apps.test.js +33 -0
  98. package/dist/lib/workspace-apps.test.js.map +1 -0
  99. package/dist/routes/pages/$appId.d.ts.map +1 -1
  100. package/dist/routes/pages/$appId.js +4 -2
  101. package/dist/routes/pages/$appId.js.map +1 -1
  102. package/dist/routes/pages/agents.d.ts.map +1 -1
  103. package/dist/routes/pages/agents.js +2 -3
  104. package/dist/routes/pages/agents.js.map +1 -1
  105. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  106. package/dist/routes/pages/apps.$appId.js +17 -13
  107. package/dist/routes/pages/apps.$appId.js.map +1 -1
  108. package/dist/routes/pages/apps.d.ts.map +1 -1
  109. package/dist/routes/pages/apps.js +31 -16
  110. package/dist/routes/pages/apps.js.map +1 -1
  111. package/dist/routes/pages/audit.d.ts.map +1 -1
  112. package/dist/routes/pages/audit.js +2 -1
  113. package/dist/routes/pages/audit.js.map +1 -1
  114. package/dist/routes/pages/chat.d.ts.map +1 -1
  115. package/dist/routes/pages/chat.js +7 -2
  116. package/dist/routes/pages/chat.js.map +1 -1
  117. package/dist/routes/pages/dreams.d.ts.map +1 -1
  118. package/dist/routes/pages/dreams.js +14 -18
  119. package/dist/routes/pages/dreams.js.map +1 -1
  120. package/dist/routes/pages/integrations.d.ts.map +1 -1
  121. package/dist/routes/pages/integrations.js +51 -14
  122. package/dist/routes/pages/integrations.js.map +1 -1
  123. package/dist/routes/pages/metrics.d.ts.map +1 -1
  124. package/dist/routes/pages/metrics.js +146 -17
  125. package/dist/routes/pages/metrics.js.map +1 -1
  126. package/dist/routes/pages/operations.js +1 -1
  127. package/dist/routes/pages/operations.js.map +1 -1
  128. package/dist/routes/pages/settings.d.ts.map +1 -1
  129. package/dist/routes/pages/settings.js +19 -2
  130. package/dist/routes/pages/settings.js.map +1 -1
  131. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  132. package/dist/routes/pages/thread-debug.js +5 -8
  133. package/dist/routes/pages/thread-debug.js.map +1 -1
  134. package/dist/routes/pages/vault.d.ts.map +1 -1
  135. package/dist/routes/pages/vault.js +1 -1
  136. package/dist/routes/pages/vault.js.map +1 -1
  137. package/dist/routes/pages/workspace.d.ts.map +1 -1
  138. package/dist/routes/pages/workspace.js +13 -10
  139. package/dist/routes/pages/workspace.js.map +1 -1
  140. package/dist/server/index.d.ts.map +1 -1
  141. package/dist/server/index.js +3 -0
  142. package/dist/server/index.js.map +1 -1
  143. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  144. package/dist/server/lib/app-creation-store.js +12 -52
  145. package/dist/server/lib/app-creation-store.js.map +1 -1
  146. package/dist/server/lib/usage-metrics-store.d.ts +17 -0
  147. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  148. package/dist/server/lib/usage-metrics-store.js +187 -30
  149. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  150. package/dist/server/plugins/agent-chat.js +2 -0
  151. package/dist/server/plugins/agent-chat.js.map +1 -1
  152. package/dist/server/plugins/integrations.d.ts.map +1 -1
  153. package/dist/server/plugins/integrations.js +1 -0
  154. package/dist/server/plugins/integrations.js.map +1 -1
  155. package/dist/shared/app-roles.d.ts +9 -0
  156. package/dist/shared/app-roles.d.ts.map +1 -0
  157. package/dist/shared/app-roles.js +7 -0
  158. package/dist/shared/app-roles.js.map +1 -0
  159. package/package.json +4 -3
  160. package/src/actions/list-connected-agents.spec.ts +73 -0
  161. package/src/actions/list-connected-agents.ts +6 -2
  162. package/src/actions/list-dispatch-usage-metrics.ts +11 -1
  163. package/src/actions/remix-workspace-template.spec.ts +29 -0
  164. package/src/actions/remix-workspace-template.ts +13 -6
  165. package/src/actions/scaffold-workspace-app.ts +12 -5
  166. package/src/actions/start-workspace-app-creation.ts +8 -2
  167. package/src/actions/view-screen.ts +13 -1
  168. package/src/components/admin-navigation.tsx +57 -11
  169. package/src/components/app-icon.spec.tsx +34 -0
  170. package/src/components/app-icon.tsx +1 -1
  171. package/src/components/app-list-row.tsx +1 -2
  172. package/src/components/app-open-actions.tsx +5 -114
  173. package/src/components/app-row-settings.tsx +54 -0
  174. package/src/components/automation-run-history-dialog.tsx +8 -4
  175. package/src/components/connected-app-card.tsx +3 -2
  176. package/src/components/create-app-popover.spec.tsx +1 -1
  177. package/src/components/create-app-popover.tsx +2 -2
  178. package/src/components/dispatch-control-plane.spec.tsx +44 -3
  179. package/src/components/dispatch-control-plane.tsx +83 -27
  180. package/src/components/dispatch-shell.tsx +7 -1
  181. package/src/components/docs-link.tsx +52 -0
  182. package/src/components/layout/Layout.spec.tsx +13 -0
  183. package/src/components/layout/Layout.tsx +63 -32
  184. package/src/components/messaging-setup-panel.tsx +10 -3
  185. package/src/components/usage-alerts-panel.tsx +445 -0
  186. package/src/components/workspace-app-card.spec.tsx +130 -9
  187. package/src/components/workspace-app-card.tsx +128 -71
  188. package/src/components/workspace-app-search.tsx +96 -0
  189. package/src/components/workspace-resource-effective-stack.tsx +10 -5
  190. package/src/components/workspace-template-card.spec.tsx +53 -24
  191. package/src/components/workspace-template-card.tsx +16 -26
  192. package/src/hooks/use-navigation-state.ts +12 -0
  193. package/src/lib/embed-session-recovery.spec.ts +63 -0
  194. package/src/lib/embed-session-recovery.ts +26 -0
  195. package/src/lib/other-apps.ts +2 -0
  196. package/src/lib/workspace-app-layout.spec.ts +47 -0
  197. package/src/lib/workspace-app-layout.ts +121 -0
  198. package/src/lib/workspace-apps.test.ts +64 -0
  199. package/src/lib/workspace-apps.ts +82 -0
  200. package/src/routes/pages/$appId.tsx +3 -6
  201. package/src/routes/pages/agents.tsx +6 -3
  202. package/src/routes/pages/apps.$appId.tsx +16 -17
  203. package/src/routes/pages/apps.tsx +116 -62
  204. package/src/routes/pages/audit.tsx +28 -8
  205. package/src/routes/pages/chat.spec.tsx +10 -0
  206. package/src/routes/pages/chat.tsx +8 -0
  207. package/src/routes/pages/dreams.tsx +92 -142
  208. package/src/routes/pages/integrations.tsx +147 -68
  209. package/src/routes/pages/metrics.tsx +460 -102
  210. package/src/routes/pages/operations.tsx +2 -2
  211. package/src/routes/pages/settings.tsx +27 -10
  212. package/src/routes/pages/thread-debug.tsx +18 -36
  213. package/src/routes/pages/vault.tsx +7 -2
  214. package/src/routes/pages/workspace.tsx +92 -56
  215. package/src/server/index.ts +4 -0
  216. package/src/server/lib/app-creation-store.spec.ts +90 -43
  217. package/src/server/lib/app-creation-store.ts +15 -61
  218. package/src/server/lib/usage-metrics-store.spec.ts +117 -0
  219. package/src/server/lib/usage-metrics-store.ts +249 -30
  220. package/src/server/plugins/agent-chat.spec.ts +4 -0
  221. package/src/server/plugins/agent-chat.ts +2 -0
  222. package/src/server/plugins/integrations.ts +1 -0
  223. package/src/shared/app-roles.ts +8 -0
  224. package/src/styles/dispatch.css +9 -0
@@ -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,
@@ -12,6 +13,7 @@ import { cn } from "../lib/utils";
12
13
  import { AppIcon } from "./app-icon";
13
14
  import { AppListRow } from "./app-list-row";
14
15
  import { AppOpenActions } from "./app-open-actions";
16
+ import { AppRowSettings } from "./app-row-settings";
15
17
  import { Button } from "./ui/button";
16
18
  import {
17
19
  Dialog,
@@ -94,17 +96,6 @@ export interface WorkspaceTemplateCardProps {
94
96
  ) => void;
95
97
  }
96
98
 
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
99
  function templateIdFor(template: CuratedWorkspaceTemplate): string {
109
100
  return template.templateId || template.id || template.appId || template.name;
110
101
  }
@@ -113,8 +104,9 @@ function defaultAppIdFor(
113
104
  template: CuratedWorkspaceTemplate,
114
105
  defaultAppId?: string,
115
106
  ): string {
116
- if (defaultAppId) return slugifyAppId(defaultAppId);
117
- const sourceId = slugifyAppId(template.appId || template.name);
107
+ if (defaultAppId) return normalizeWorkspaceAppId(defaultAppId) || "new-app";
108
+ const sourceId =
109
+ normalizeWorkspaceAppId(template.appId || template.name) || "new-app";
118
110
  return `${sourceId}-app`;
119
111
  }
120
112
 
@@ -173,15 +165,16 @@ export function WorkspaceTemplateCard({
173
165
  }, [defaultAppId, open, template]);
174
166
 
175
167
  function submitRemix() {
176
- const trimmedAppId = appId.trim();
177
- if (!trimmedAppId) {
168
+ const normalizedAppId = normalizeWorkspaceAppId(appId);
169
+ if (!normalizedAppId) {
178
170
  toast.error(labels.appIdRequired);
179
171
  return;
180
172
  }
173
+ setAppId(normalizedAppId);
181
174
 
182
175
  remix.mutate({
183
176
  templateId: templateIdFor(template),
184
- appId: trimmedAppId,
177
+ appId: normalizedAppId,
185
178
  });
186
179
  }
187
180
 
@@ -219,20 +212,16 @@ export function WorkspaceTemplateCard({
219
212
  <AppOpenActions
220
213
  name={template.name}
221
214
  href={liveUrl}
222
- target="_blank"
223
215
  rel="noreferrer"
224
216
  labels={{ openApp: labels.openApp }}
217
+ showNewTabOption
225
218
  />
226
219
  ) : null}
227
- <Button
228
- type="button"
229
- variant="outline"
230
- size="sm"
231
- onClick={() => setOpen(true)}
232
- >
233
- <IconPlus />
234
- {labels.remix}
235
- </Button>
220
+ <AppRowSettings
221
+ name={template.name}
222
+ addAppLabel={labels.remix}
223
+ onAddApp={() => setOpen(true)}
224
+ />
236
225
  </>
237
226
  ) : liveUrl ? (
238
227
  <Button variant="ghost" size="sm" className="shrink-0" asChild>
@@ -280,6 +269,7 @@ export function WorkspaceTemplateCard({
280
269
  value={appId}
281
270
  autoComplete="off"
282
271
  onChange={(event) => setAppId(event.target.value)}
272
+ onBlur={() => setAppId(normalizeWorkspaceAppId(appId))}
283
273
  disabled={remix.isPending}
284
274
  />
285
275
  </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
+ }
@@ -5,6 +5,8 @@ export interface ConnectedAppSummary {
5
5
  name: string;
6
6
  description?: string;
7
7
  url: string;
8
+ /** Canonical app-home URL used for launchers; `url` remains the A2A endpoint. */
9
+ homeUrl?: string;
8
10
  color?: string;
9
11
  source?: "builtin" | "custom" | "workspace";
10
12
  }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ normalizeWorkspaceAppLayout,
5
+ orderWorkspaceApps,
6
+ toggleWorkspaceAppPinned,
7
+ workspaceAppMatchesQuery,
8
+ } from "./workspace-app-layout";
9
+
10
+ describe("workspace app layout", () => {
11
+ it("keeps pinned apps first without changing the remaining catalog order", () => {
12
+ const apps = [
13
+ { id: "mail", name: "Mail" },
14
+ { id: "analytics", name: "Analytics" },
15
+ { id: "calendar", name: "Calendar" },
16
+ ];
17
+
18
+ expect(
19
+ orderWorkspaceApps(apps, {
20
+ pinnedIds: ["calendar", "calendar"],
21
+ orderedIds: ["mail", "analytics", "calendar"],
22
+ }).map((app) => app.id),
23
+ ).toEqual(["calendar", "mail", "analytics"]);
24
+ });
25
+
26
+ it("matches app names and descriptions case-insensitively", () => {
27
+ expect(
28
+ workspaceAppMatchesQuery(
29
+ { name: "Analytics", description: "Explore product health" },
30
+ "PRODUCT",
31
+ ),
32
+ ).toBe(true);
33
+ expect(workspaceAppMatchesQuery({ name: "Mail" }, "calendar")).toBe(false);
34
+ });
35
+
36
+ it("normalizes persisted layout ids and toggles pin state", () => {
37
+ const layout = normalizeWorkspaceAppLayout({
38
+ pinnedIds: ["mail", "mail", 42],
39
+ orderedIds: ["calendar", "", "calendar"],
40
+ });
41
+
42
+ expect(toggleWorkspaceAppPinned(layout, "calendar")).toEqual({
43
+ pinnedIds: ["calendar", "mail"],
44
+ orderedIds: ["calendar"],
45
+ });
46
+ });
47
+ });
@@ -0,0 +1,121 @@
1
+ import {
2
+ orderChatFirstAppIds,
3
+ readChatFirstAppLayout,
4
+ writeChatFirstAppLayout,
5
+ type ChatFirstAppLayoutPreference,
6
+ } from "@agent-native/core/client/agent-chat";
7
+ import {
8
+ readClientAppState,
9
+ writeClientAppState,
10
+ } from "@agent-native/core/client/application-state";
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+
13
+ export const WORKSPACE_APP_LAYOUT_STATE_KEY = "chat-first-app-layout";
14
+
15
+ export type WorkspaceAppLayoutPersistenceError =
16
+ | "device"
17
+ | "workspace"
18
+ | "both";
19
+
20
+ export function normalizeWorkspaceAppLayout(
21
+ value: unknown,
22
+ ): ChatFirstAppLayoutPreference {
23
+ if (!value || typeof value !== "object") {
24
+ return { pinnedIds: [], orderedIds: [] };
25
+ }
26
+
27
+ const candidate = value as Partial<ChatFirstAppLayoutPreference>;
28
+ const ids = (input: unknown): string[] =>
29
+ Array.isArray(input)
30
+ ? input.filter(
31
+ (id): id is string => typeof id === "string" && id.trim().length > 0,
32
+ )
33
+ : [];
34
+
35
+ return {
36
+ pinnedIds: [...new Set(ids(candidate.pinnedIds))],
37
+ orderedIds: [...new Set(ids(candidate.orderedIds))],
38
+ };
39
+ }
40
+
41
+ export function orderWorkspaceApps<T extends { id: string }>(
42
+ apps: readonly T[],
43
+ layout: ChatFirstAppLayoutPreference,
44
+ ): T[] {
45
+ const appsById = new Map(apps.map((app) => [app.id, app]));
46
+ return orderChatFirstAppIds(
47
+ apps.map((app) => app.id),
48
+ layout,
49
+ )
50
+ .map((id) => appsById.get(id))
51
+ .filter((app): app is T => Boolean(app));
52
+ }
53
+
54
+ export function workspaceAppMatchesQuery(
55
+ app: { name: string; description?: string },
56
+ query: string,
57
+ ): boolean {
58
+ const normalizedQuery = query.trim().toLowerCase();
59
+ if (!normalizedQuery) return true;
60
+ return `${app.name} ${app.description ?? ""}`
61
+ .toLowerCase()
62
+ .includes(normalizedQuery);
63
+ }
64
+
65
+ export function toggleWorkspaceAppPinned(
66
+ layout: ChatFirstAppLayoutPreference,
67
+ appId: string,
68
+ ): ChatFirstAppLayoutPreference {
69
+ const pinnedIds = layout.pinnedIds.includes(appId)
70
+ ? layout.pinnedIds.filter((id) => id !== appId)
71
+ : [appId, ...layout.pinnedIds];
72
+ return { ...layout, pinnedIds };
73
+ }
74
+
75
+ export function useWorkspaceAppLayout() {
76
+ const [layout, setLayout] = useState<ChatFirstAppLayoutPreference>(() =>
77
+ readChatFirstAppLayout(),
78
+ );
79
+ const [persistenceError, setPersistenceError] =
80
+ useState<WorkspaceAppLayoutPersistenceError | null>(null);
81
+ const hydratedRef = useRef(false);
82
+ const localChangeRef = useRef(false);
83
+
84
+ useEffect(() => {
85
+ if (hydratedRef.current) return;
86
+ hydratedRef.current = true;
87
+ void readClientAppState<unknown>(WORKSPACE_APP_LAYOUT_STATE_KEY)
88
+ .then((value) => {
89
+ if (value !== null && !localChangeRef.current) {
90
+ setLayout(normalizeWorkspaceAppLayout(value));
91
+ }
92
+ })
93
+ .catch(() => {
94
+ // Device-local preferences remain usable when workspace state is unavailable.
95
+ });
96
+ }, []);
97
+
98
+ const persistLayout = useCallback((next: ChatFirstAppLayoutPreference) => {
99
+ localChangeRef.current = true;
100
+ setLayout(next);
101
+ const deviceResult = writeChatFirstAppLayout(next);
102
+ const deviceFailed = !deviceResult.ok;
103
+
104
+ void writeClientAppState(WORKSPACE_APP_LAYOUT_STATE_KEY, next)
105
+ .then(() => {
106
+ setPersistenceError(deviceFailed ? "device" : null);
107
+ })
108
+ .catch(() => {
109
+ setPersistenceError(deviceFailed ? "both" : "workspace");
110
+ });
111
+ }, []);
112
+
113
+ const togglePinned = useCallback(
114
+ (appId: string) => {
115
+ persistLayout(toggleWorkspaceAppPinned(layout, appId));
116
+ },
117
+ [layout, persistLayout],
118
+ );
119
+
120
+ return { layout, persistenceError, persistLayout, togglePinned };
121
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ workspaceAppDirectHref,
5
+ workspaceAppEmbedTarget,
6
+ } from "./workspace-apps";
7
+
8
+ describe("workspaceAppEmbedTarget", () => {
9
+ it("uses the app URL as the embed root when a mount path is also present", () => {
10
+ expect(
11
+ workspaceAppEmbedTarget({
12
+ path: "/content",
13
+ url: "https://workspace.example.test/content",
14
+ }),
15
+ ).toEqual({ url: "https://workspace.example.test/content" });
16
+ });
17
+
18
+ it("falls back to the mounted path when no app URL is available", () => {
19
+ expect(workspaceAppEmbedTarget({ path: "/content", url: null })).toEqual({
20
+ path: "/content",
21
+ });
22
+ });
23
+ });
24
+
25
+ describe("workspaceAppDirectHref", () => {
26
+ it("joins an app-relative route onto an absolute app URL", () => {
27
+ expect(
28
+ workspaceAppDirectHref(
29
+ { path: "/atlas", url: "https://workspace.example.test/atlas" },
30
+ "/emails?status=failed#latest",
31
+ ),
32
+ ).toBe("https://workspace.example.test/atlas/emails?status=failed#latest");
33
+ });
34
+
35
+ it("does not duplicate a mount path already present in the target", () => {
36
+ expect(
37
+ workspaceAppDirectHref(
38
+ { path: "/atlas", url: "https://workspace.example.test/atlas" },
39
+ "/atlas",
40
+ ),
41
+ ).toBe("https://workspace.example.test/atlas");
42
+ });
43
+
44
+ it("resolves a mounted relative app path", () => {
45
+ expect(workspaceAppDirectHref({ path: "/atlas" }, "/emails")).toBe(
46
+ "/atlas/emails",
47
+ );
48
+ });
49
+
50
+ it("falls back to a safe mount path when the app URL is invalid", () => {
51
+ expect(
52
+ workspaceAppDirectHref(
53
+ { path: "/atlas", url: "javascript:alert(1)" },
54
+ "/",
55
+ ),
56
+ ).toBe("/atlas");
57
+ });
58
+
59
+ it("rejects an unsafe target path", () => {
60
+ expect(workspaceAppDirectHref({ path: "/atlas" }, "//evil.example")).toBe(
61
+ null,
62
+ );
63
+ });
64
+ });
@@ -27,6 +27,11 @@ export interface WorkspaceAppSummary {
27
27
  archived?: boolean;
28
28
  }
29
29
 
30
+ interface WorkspaceAppHrefSource {
31
+ path?: string | null;
32
+ url?: string | null;
33
+ }
34
+
30
35
  export function isDispatchWorkspaceAppId(appId: string): boolean {
31
36
  return appId.trim().toLowerCase() === "dispatch";
32
37
  }
@@ -70,6 +75,83 @@ export function workspaceAppHref(app: WorkspaceAppSummary): string | null {
70
75
  return app.path || app.url || null;
71
76
  }
72
77
 
78
+ export function workspaceAppEmbedTarget(
79
+ app: Pick<WorkspaceAppSummary, "path" | "url">,
80
+ ): { path?: string; url?: string } {
81
+ const url = app.url?.trim();
82
+ if (url) return { url };
83
+
84
+ const path = app.path.trim();
85
+ return path.startsWith("/") ? { path } : path ? { url: path } : {};
86
+ }
87
+
88
+ /**
89
+ * Resolve an app route without an embed ticket so the target can render its
90
+ * own error document when session setup fails.
91
+ */
92
+ export function workspaceAppDirectHref(
93
+ app: WorkspaceAppHrefSource,
94
+ targetPath: string,
95
+ ): string | null {
96
+ const target = targetPath.trim();
97
+ if (!target || !target.startsWith("/") || target.startsWith("//")) {
98
+ return null;
99
+ }
100
+
101
+ let targetUrl: URL;
102
+ try {
103
+ targetUrl = new URL(target, "https://agent-native.invalid");
104
+ } catch {
105
+ // coercion-ok: invalid relative target input has no safe href.
106
+ return null;
107
+ }
108
+ const targetPathname = targetUrl.pathname || "/";
109
+
110
+ let absoluteBase: URL | null = null;
111
+ const rawUrl = app.url?.trim();
112
+ if (rawUrl) {
113
+ try {
114
+ const parsedBase = new URL(rawUrl);
115
+ if (parsedBase.protocol === "http:" || parsedBase.protocol === "https:") {
116
+ absoluteBase = parsedBase;
117
+ }
118
+ // coercion-ok: invalid app URLs use the mounted path fallback below.
119
+ } catch {
120
+ absoluteBase = null;
121
+ }
122
+ }
123
+
124
+ const mountedPath = app.path?.trim();
125
+ const basePath = absoluteBase
126
+ ? absoluteBase.pathname
127
+ : mountedPath
128
+ ? `/${mountedPath.replace(/^[/\\]+/, "").split(/[?#]/, 1)[0]}`
129
+ : null;
130
+ if (!basePath) return null;
131
+
132
+ const normalizedBasePath = basePath.replace(/\/+$/, "") || "/";
133
+ const targetIsMountedPath =
134
+ normalizedBasePath !== "/" &&
135
+ (targetPathname === normalizedBasePath ||
136
+ targetPathname.startsWith(`${normalizedBasePath}/`));
137
+ const resolvedPath = targetIsMountedPath
138
+ ? targetPathname
139
+ : normalizedBasePath === "/"
140
+ ? targetPathname
141
+ : targetPathname === "/"
142
+ ? normalizedBasePath
143
+ : `${normalizedBasePath}/${targetPathname.replace(/^\/+/, "")}`;
144
+
145
+ if (absoluteBase) {
146
+ absoluteBase.pathname = resolvedPath;
147
+ absoluteBase.search = targetUrl.search;
148
+ absoluteBase.hash = targetUrl.hash;
149
+ return absoluteBase.toString();
150
+ }
151
+
152
+ return `${resolvedPath}${targetUrl.search}${targetUrl.hash}`;
153
+ }
154
+
73
155
  export function isPendingBuilderHref(app: WorkspaceAppSummary): boolean {
74
156
  return app.status === "pending" && !!app.builderUrl;
75
157
  }
@@ -176,11 +176,6 @@ export default function WorkspaceAppCatchAllRoute() {
176
176
  <span className="font-mono text-foreground">{app.path}</span>{" "}
177
177
  {t("dispatch.pages.appBuildingSuffix")}
178
178
  </p>
179
- {app.branchName ? (
180
- <p className="text-xs text-muted-foreground">
181
- {t("dispatch.pages.branch", { branch: app.branchName })}
182
- </p>
183
- ) : null}
184
179
  {app.builderUrl ? (
185
180
  <Button asChild>
186
181
  <a
@@ -191,7 +186,9 @@ export default function WorkspaceAppCatchAllRoute() {
191
186
  target="_blank"
192
187
  rel="noreferrer"
193
188
  >
194
- {t("dispatch.pages.openBuilderBranch")}
189
+ {t("dispatch.pages.openBuilderBranch", {
190
+ defaultValue: "Open in Builder",
191
+ })}
195
192
  <IconArrowUpRight size={15} className="ml-1.5" />
196
193
  </a>
197
194
  </Button>
@@ -15,6 +15,7 @@ import {
15
15
  import { DispatchShell } from "../../components/dispatch-shell";
16
16
  import { Button } from "../../components/ui/button";
17
17
  import { Input } from "../../components/ui/input";
18
+ import { Skeleton } from "../../components/ui/skeleton";
18
19
  import { Switch } from "../../components/ui/switch";
19
20
 
20
21
  export function meta() {
@@ -123,9 +124,11 @@ function DispatchMcpAccessPanel() {
123
124
  : t("dispatch.pages.selectedApps")}
124
125
  </div>
125
126
  <div className="text-xs text-muted-foreground">
126
- {isLoading
127
- ? t("dispatch.pages.loading")
128
- : t("dispatch.pages.grantedCount", { count: grantedCount })}
127
+ {isLoading ? (
128
+ <Skeleton className="h-3 w-16" />
129
+ ) : (
130
+ t("dispatch.pages.grantedCount", { count: grantedCount })
131
+ )}
129
132
  </div>
130
133
  </div>
131
134
  <Switch
@@ -13,7 +13,10 @@ import { Badge } from "../../components/ui/badge";
13
13
  import { Button } from "../../components/ui/button";
14
14
  import { Skeleton } from "../../components/ui/skeleton";
15
15
  import { Spinner } from "../../components/ui/spinner";
16
+ import { isEmbedSessionExpiredMessage } from "../../lib/embed-session-recovery";
16
17
  import {
18
+ workspaceAppDirectHref,
19
+ workspaceAppEmbedTarget,
17
20
  workspaceAppHref,
18
21
  type WorkspaceAppSummary,
19
22
  } from "../../lib/workspace-apps";
@@ -61,12 +64,12 @@ export default function WorkspaceAppRoute() {
61
64
  });
62
65
  const embedInput = useMemo<EmbedSessionInput | null>(() => {
63
66
  if (!app || !href) return null;
64
- const path = app.path.trim();
65
- if (path.startsWith("/")) {
66
- return { app: app.id, path, chrome: "minimal" };
67
- }
68
- return { app: app.id, url: href, chrome: "minimal" };
69
- }, [app?.id, app?.path, href]);
67
+ return {
68
+ app: app.id,
69
+ ...workspaceAppEmbedTarget(app),
70
+ chrome: "minimal",
71
+ };
72
+ }, [app?.id, app?.path, app?.url, href]);
70
73
 
71
74
  useEffect(() => {
72
75
  if (!app || app.status === "pending" || !embedInput) return;
@@ -80,6 +83,8 @@ export default function WorkspaceAppRoute() {
80
83
  })
81
84
  .catch((cause: unknown) => {
82
85
  if (!cancelled) {
86
+ const directAppUrl = workspaceAppDirectHref(app, "/");
87
+ if (directAppUrl) setEmbedUrl(directAppUrl);
83
88
  setEmbedError(
84
89
  cause instanceof Error ? cause : new Error(String(cause)),
85
90
  );
@@ -99,12 +104,8 @@ export default function WorkspaceAppRoute() {
99
104
 
100
105
  useEffect(() => {
101
106
  const handleEmbedSessionExpired = (event: MessageEvent) => {
102
- if (
103
- event.data?.type !== "agentNative.embedSessionExpired" ||
104
- event.source !== embedFrameRef.current?.contentWindow
105
- ) {
107
+ if (!isEmbedSessionExpiredMessage(event, embedFrameRef.current, null))
106
108
  return;
107
- }
108
109
  setEmbedAttempt((attempt) => attempt + 1);
109
110
  };
110
111
 
@@ -189,11 +190,6 @@ export default function WorkspaceAppRoute() {
189
190
  <span className="font-mono text-foreground">{app.path}</span>{" "}
190
191
  {t("dispatch.pages.appBuildingSuffix")}
191
192
  </p>
192
- {app.branchName ? (
193
- <p className="text-xs text-muted-foreground">
194
- {t("dispatch.pages.branch", { branch: app.branchName })}
195
- </p>
196
- ) : null}
197
193
  {app.builderUrl ? (
198
194
  <Button asChild>
199
195
  <a
@@ -204,7 +200,9 @@ export default function WorkspaceAppRoute() {
204
200
  target="_blank"
205
201
  rel="noreferrer"
206
202
  >
207
- {t("dispatch.pages.openBuilderBranch")}
203
+ {t("dispatch.pages.openBuilderBranch", {
204
+ defaultValue: "Open in Builder",
205
+ })}
208
206
  </a>
209
207
  </Button>
210
208
  ) : null}
@@ -222,6 +220,7 @@ export default function WorkspaceAppRoute() {
222
220
  <div className="min-h-0 flex-1 bg-muted/20">
223
221
  {embedUrl ? (
224
222
  <iframe
223
+ key={`${embedUrl}:${embedAttempt}`}
225
224
  data-dispatch-workspace-app-frame
226
225
  src={embedUrl}
227
226
  title={app.name}