@agent-native/dispatch 0.31.12 → 0.31.14

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 (53) hide show
  1. package/dist/actions/list-dispatch-usage-metrics.js +9 -1
  2. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
  3. package/dist/actions/provider-api-register.d.ts +12 -12
  4. package/dist/components/layout/Layout.d.ts +2 -1
  5. package/dist/components/layout/Layout.d.ts.map +1 -1
  6. package/dist/components/layout/Layout.js +3 -3
  7. package/dist/components/layout/Layout.js.map +1 -1
  8. package/dist/components/other-apps-section.d.ts +5 -3
  9. package/dist/components/other-apps-section.d.ts.map +1 -1
  10. package/dist/components/other-apps-section.js +15 -3
  11. package/dist/components/other-apps-section.js.map +1 -1
  12. package/dist/components/workspace-app-host.d.ts +8 -2
  13. package/dist/components/workspace-app-host.d.ts.map +1 -1
  14. package/dist/components/workspace-app-host.js +46 -8
  15. package/dist/components/workspace-app-host.js.map +1 -1
  16. package/dist/lib/workspace-apps.d.ts +7 -0
  17. package/dist/lib/workspace-apps.d.ts.map +1 -1
  18. package/dist/lib/workspace-apps.js +65 -0
  19. package/dist/lib/workspace-apps.js.map +1 -1
  20. package/dist/routes/index.js +1 -1
  21. package/dist/routes/index.js.map +1 -1
  22. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  23. package/dist/routes/pages/apps.$appId.js +15 -4
  24. package/dist/routes/pages/apps.$appId.js.map +1 -1
  25. package/dist/routes/pages/apps.d.ts.map +1 -1
  26. package/dist/routes/pages/apps.js +16 -4
  27. package/dist/routes/pages/apps.js.map +1 -1
  28. package/dist/server/lib/usage-metrics-store.d.ts +20 -0
  29. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  30. package/dist/server/lib/usage-metrics-store.js +86 -32
  31. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  32. package/dist/server/plugins/agent-chat.js +3 -0
  33. package/dist/server/plugins/agent-chat.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/actions/index.spec.ts +17 -0
  36. package/src/actions/list-dispatch-usage-metrics.ts +9 -1
  37. package/src/components/layout/Layout.spec.tsx +75 -0
  38. package/src/components/layout/Layout.tsx +22 -23
  39. package/src/components/other-apps-section.spec.ts +49 -0
  40. package/src/components/other-apps-section.tsx +31 -6
  41. package/src/components/workspace-app-host.spec.tsx +98 -0
  42. package/src/components/workspace-app-host.tsx +70 -4
  43. package/src/lib/workspace-apps.spec.ts +40 -0
  44. package/src/lib/workspace-apps.ts +80 -0
  45. package/src/routes/index.spec.ts +10 -0
  46. package/src/routes/index.ts +1 -1
  47. package/src/routes/pages/apps.$appId.spec.tsx +50 -1
  48. package/src/routes/pages/apps.$appId.tsx +26 -3
  49. package/src/routes/pages/apps.tsx +42 -26
  50. package/src/server/lib/usage-metrics-store.spec.ts +132 -1
  51. package/src/server/lib/usage-metrics-store.ts +173 -65
  52. package/src/server/plugins/agent-chat.spec.ts +2 -0
  53. package/src/server/plugins/agent-chat.ts +3 -0
@@ -220,6 +220,81 @@ describe("Dispatch NavContent", () => {
220
220
  expect(lists[0].querySelector("a")?.className).toContain("size-9");
221
221
  });
222
222
 
223
+ it.each([
224
+ { mode: "standard", chatFirstMode: false, collapsed: false },
225
+ { mode: "standard", chatFirstMode: false, collapsed: true },
226
+ { mode: "chat-first", chatFirstMode: true, collapsed: false },
227
+ { mode: "chat-first", chatFirstMode: true, collapsed: true },
228
+ ])(
229
+ "reserves environment pill space in the $mode desktop footer when collapsed is $collapsed",
230
+ async ({ mode, chatFirstMode, collapsed }) => {
231
+ await act(async () => {
232
+ root.render(
233
+ <MemoryRouter
234
+ initialEntries={[chatFirstMode ? "/chat" : "/overview"]}
235
+ >
236
+ <TooltipProvider>
237
+ <NavContent
238
+ chatFirstMode={chatFirstMode}
239
+ collapsed={collapsed}
240
+ reserveEnvironmentBadgeSpace
241
+ />
242
+ </TooltipProvider>
243
+ </MemoryRouter>,
244
+ );
245
+ });
246
+
247
+ const footer = container.querySelector(
248
+ `[data-dispatch-sidebar-footer="${mode}"]`,
249
+ );
250
+ const adminLink = footer?.querySelector('a[href="/admin"]');
251
+ const settingsLink = footer?.querySelector('a[href="/settings"]');
252
+ const organization = [...(footer?.querySelectorAll("div") ?? [])].find(
253
+ (element) => element.textContent?.trim() === "Organization",
254
+ );
255
+ const footerActions = footer?.querySelector(
256
+ "[data-sidebar-footer-actions]",
257
+ );
258
+
259
+ expect(footer?.className).toContain("mt-auto");
260
+ expect(footer?.className).toContain("shrink-0");
261
+ expect(footer?.className).toContain("pb-10");
262
+ if (mode === "standard") {
263
+ expect(footer?.closest(".overflow-y-auto")).toBeNull();
264
+ }
265
+ expect(adminLink).not.toBeNull();
266
+ expect(settingsLink).not.toBeNull();
267
+ expect(organization).toBeDefined();
268
+ expect(footerActions).not.toBeNull();
269
+ expect(adminLink!.compareDocumentPosition(settingsLink!)).toBe(
270
+ Node.DOCUMENT_POSITION_FOLLOWING,
271
+ );
272
+ expect(settingsLink!.compareDocumentPosition(organization!)).toBe(
273
+ Node.DOCUMENT_POSITION_FOLLOWING,
274
+ );
275
+ expect(organization!.compareDocumentPosition(footerActions!)).toBe(
276
+ Node.DOCUMENT_POSITION_FOLLOWING,
277
+ );
278
+ },
279
+ );
280
+
281
+ it("reserves environment pill space in the mobile footer", async () => {
282
+ await act(async () => {
283
+ root.render(
284
+ <MemoryRouter initialEntries={["/overview"]}>
285
+ <TooltipProvider>
286
+ <NavContent reserveEnvironmentBadgeSpace />
287
+ </TooltipProvider>
288
+ </MemoryRouter>,
289
+ );
290
+ });
291
+
292
+ expect(
293
+ container.querySelector('[data-dispatch-sidebar-footer="standard"]')
294
+ ?.className,
295
+ ).toContain("pb-10");
296
+ });
297
+
223
298
  it("keeps chat-first primary actions in the collapsed sidebar", async () => {
224
299
  await act(async () => {
225
300
  root.render(
@@ -953,6 +953,7 @@ export function NavContent({
953
953
  chatFirstEmbedded = false,
954
954
  collapsed = false,
955
955
  collapsible = false,
956
+ reserveEnvironmentBadgeSpace = false,
956
957
  onCollapsedChange,
957
958
  chatFirstAppLayout,
958
959
  onChatFirstAppLayoutChange,
@@ -971,6 +972,7 @@ export function NavContent({
971
972
  chatFirstEmbedded?: boolean;
972
973
  collapsed?: boolean;
973
974
  collapsible?: boolean;
975
+ reserveEnvironmentBadgeSpace?: boolean;
974
976
  onCollapsedChange?: (collapsed: boolean) => void;
975
977
  chatFirstAppLayout?: ChatFirstAppLayoutPreference;
976
978
  onChatFirstAppLayoutChange?: (layout: ChatFirstAppLayoutPreference) => void;
@@ -1298,35 +1300,30 @@ export function NavContent({
1298
1300
  />
1299
1301
  </div>
1300
1302
  ) : null}
1303
+ {!chatFirstMode ? (
1304
+ <div className="min-h-0 flex-1 overflow-y-auto">
1305
+ <nav className={cn("py-2", collapsed ? "px-1.5" : "px-2")}>
1306
+ <ul
1307
+ className={cn(
1308
+ collapsed ? "flex flex-col items-center gap-1" : "space-y-0.5",
1309
+ )}
1310
+ >
1311
+ {primaryNavItems.map(renderNavItem)}
1312
+ </ul>
1313
+ </nav>
1314
+ </div>
1315
+ ) : null}
1301
1316
  <div
1302
1317
  className={cn(
1303
- "flex min-h-0 flex-1 flex-col overflow-y-auto",
1304
- chatFirstMode && "hidden",
1318
+ "mt-auto shrink-0",
1319
+ reserveEnvironmentBadgeSpace && "pb-10",
1305
1320
  )}
1321
+ data-dispatch-sidebar-footer={chatFirstMode ? "chat-first" : "standard"}
1306
1322
  >
1307
- <nav className={cn("py-2", collapsed ? "px-1.5" : "px-2")}>
1308
- <ul
1309
- className={cn(
1310
- collapsed ? "flex flex-col items-center gap-1" : "space-y-0.5",
1311
- )}
1312
- >
1313
- {primaryNavItems.map(renderNavItem)}
1314
- </ul>
1315
- </nav>
1316
-
1317
- <div className="mt-auto shrink-0">
1318
- {bottomNavigation}
1319
- {organizationPicker}
1320
- </div>
1323
+ {bottomNavigation}
1324
+ {organizationPicker}
1321
1325
  {sidebarFooterActions}
1322
1326
  </div>
1323
- {chatFirstMode ? (
1324
- <div className="mt-auto shrink-0">
1325
- {bottomNavigation}
1326
- {organizationPicker}
1327
- {sidebarFooterActions}
1328
- </div>
1329
- ) : null}
1330
1327
  </>
1331
1328
  );
1332
1329
  }
@@ -2466,6 +2463,7 @@ export function Layout({
2466
2463
  }}
2467
2464
  onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
2468
2465
  collapsible
2466
+ reserveEnvironmentBadgeSpace
2469
2467
  onCollapsedChange={setSidebarCollapsed}
2470
2468
  />
2471
2469
  </aside>
@@ -2487,6 +2485,7 @@ export function Layout({
2487
2485
  chatFirstMode={chatFirstMode}
2488
2486
  chatFirstEmbedded={chatFirstEmbedded}
2489
2487
  collapsed={false}
2488
+ reserveEnvironmentBadgeSpace
2490
2489
  chatFirstAppLayout={chatFirstAppLayout}
2491
2490
  onChatFirstAppLayoutChange={persistChatFirstAppLayout}
2492
2491
  chatFirstApps={chatFirstAppItems}
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ filterOtherAppEntries,
5
+ mergeOtherAppEntries,
6
+ otherAppEntryMatchesQuery,
7
+ } from "./other-apps-section.js";
8
+
9
+ describe("other app search", () => {
10
+ const entries = mergeOtherAppEntries({
11
+ templates: {
12
+ templates: [
13
+ {
14
+ id: "brain",
15
+ name: "Brain",
16
+ description: "Search cited company knowledge",
17
+ template: "brain",
18
+ liveUrl: "https://brain.agent-native.com",
19
+ },
20
+ ],
21
+ },
22
+ connectedApps: [
23
+ {
24
+ id: "slides",
25
+ name: "Slides",
26
+ description: "Create presentations",
27
+ url: "https://slides.agent-native.com",
28
+ },
29
+ ],
30
+ workspaceApps: [],
31
+ });
32
+
33
+ it("matches linked and curated apps by name or description", () => {
34
+ expect(
35
+ entries.filter((entry) => otherAppEntryMatchesQuery(entry, "brain")),
36
+ ).toHaveLength(1);
37
+ expect(
38
+ entries.filter((entry) =>
39
+ otherAppEntryMatchesQuery(entry, "presentations"),
40
+ ),
41
+ ).toHaveLength(1);
42
+ });
43
+
44
+ it("filters the Other apps entries used by the page search", () => {
45
+ expect(filterOtherAppEntries(entries, "brain")).toEqual([
46
+ expect.objectContaining({ kind: "template" }),
47
+ ]);
48
+ });
49
+ });
@@ -19,7 +19,7 @@ import {
19
19
  type WorkspaceTemplateLabels,
20
20
  } from "./workspace-template-card";
21
21
 
22
- type OtherAppEntry =
22
+ export type OtherAppEntry =
23
23
  | { kind: "template"; template: CuratedWorkspaceTemplate }
24
24
  | { kind: "connected"; app: ConnectedAppSummary };
25
25
 
@@ -36,6 +36,26 @@ function getTemplateItems(
36
36
  return Array.isArray(result) ? result : result.templates;
37
37
  }
38
38
 
39
+ export function otherAppEntryMatchesQuery(
40
+ entry: OtherAppEntry,
41
+ query: string,
42
+ ): boolean {
43
+ const normalizedQuery = query.trim().toLowerCase();
44
+ if (!normalizedQuery) return true;
45
+ const searchable =
46
+ entry.kind === "template"
47
+ ? `${entry.template.name} ${entry.template.description ?? ""}`
48
+ : `${entry.app.name} ${entry.app.description ?? ""}`;
49
+ return searchable.toLowerCase().includes(normalizedQuery);
50
+ }
51
+
52
+ export function filterOtherAppEntries(
53
+ entries: OtherAppEntry[],
54
+ query: string,
55
+ ): OtherAppEntry[] {
56
+ return entries.filter((entry) => otherAppEntryMatchesQuery(entry, query));
57
+ }
58
+
39
59
  export function mergeOtherAppEntries({
40
60
  templates,
41
61
  connectedApps,
@@ -86,6 +106,7 @@ export function OtherAppsSection({
86
106
  onRetryConnectedApps,
87
107
  templateLabels,
88
108
  onRemixSuccess,
109
+ query = "",
89
110
  heading = "Other apps",
90
111
  embeddedInList = false,
91
112
  className,
@@ -104,16 +125,20 @@ export function OtherAppsSection({
104
125
  result: unknown,
105
126
  template: CuratedWorkspaceTemplate,
106
127
  ) => void;
128
+ query?: string;
107
129
  heading?: string | null;
108
130
  embeddedInList?: boolean;
109
131
  className?: string;
110
132
  }) {
111
133
  const t = useT();
112
- const entries = mergeOtherAppEntries({
113
- templates,
114
- connectedApps,
115
- workspaceApps,
116
- });
134
+ const entries = filterOtherAppEntries(
135
+ mergeOtherAppEntries({
136
+ templates,
137
+ connectedApps,
138
+ workspaceApps,
139
+ }),
140
+ query,
141
+ );
117
142
  const isLoading = templatesLoading || connectedAppsLoading;
118
143
  const hasError = Boolean(templatesError || connectedAppsError);
119
144
 
@@ -183,6 +183,9 @@ describe("WorkspaceAppKeepAlive", () => {
183
183
  clientState.actionQueryParams.length = 0;
184
184
  clientState.actionQueryOptions.length = 0;
185
185
  clientState.workspaceApps = null;
186
+ clientState.legacyMutateAsync.mockImplementation(async () => ({
187
+ startUrl: "about:blank",
188
+ }));
186
189
  clientState.legacyMutateAsync.mockClear();
187
190
  clientState.legacyMutateError = null;
188
191
  clientState.legacyErrorMutateAsync.mockReset();
@@ -469,6 +472,101 @@ describe("WorkspaceAppKeepAlive", () => {
469
472
  );
470
473
  });
471
474
 
475
+ it("reports trusted child routes to the standalone Dispatch host", async () => {
476
+ clientState.legacyMutateAsync.mockResolvedValue({
477
+ startUrl: "https://design.example.test/_agent-native/embed",
478
+ });
479
+ clientState.suppressFrameLoad = true;
480
+ const onChildRouteChange = vi.fn();
481
+
482
+ await act(async () => {
483
+ root.render(
484
+ <WorkspaceAppFrame
485
+ app={{
486
+ id: "design",
487
+ name: "Design",
488
+ path: "/",
489
+ url: "https://design.example.test",
490
+ }}
491
+ onChildRouteChange={onChildRouteChange}
492
+ />,
493
+ );
494
+ await Promise.resolve();
495
+ await Promise.resolve();
496
+ });
497
+
498
+ const iframe = container.querySelector<HTMLIFrameElement>("iframe");
499
+ expect(iframe).not.toBeNull();
500
+ if (!iframe) throw new Error("Workspace app iframe was not rendered");
501
+
502
+ const frameWindow = iframe.contentWindow;
503
+ expect(frameWindow).not.toBeNull();
504
+ if (!frameWindow) throw new Error("Workspace app frame window is missing");
505
+
506
+ await act(async () => {
507
+ window.dispatchEvent(
508
+ new MessageEvent("message", {
509
+ data: {
510
+ type: "agent-native:workspace-app-route",
511
+ path: "/foobar?mode=edit#canvas",
512
+ },
513
+ origin: "https://design.example.test",
514
+ source: frameWindow,
515
+ }),
516
+ );
517
+ await Promise.resolve();
518
+ });
519
+
520
+ expect(onChildRouteChange).toHaveBeenCalledWith(
521
+ "/apps/design/foobar?mode=edit#canvas",
522
+ );
523
+
524
+ await act(async () => {
525
+ window.dispatchEvent(
526
+ new MessageEvent("message", {
527
+ data: {
528
+ type: "agent-native:workspace-app-route",
529
+ path: "/ignored",
530
+ },
531
+ origin: "https://evil.example.test",
532
+ source: frameWindow,
533
+ }),
534
+ );
535
+ window.dispatchEvent(
536
+ new MessageEvent("message", {
537
+ data: {
538
+ type: "agent-native:workspace-app-route",
539
+ path: "/ignored",
540
+ },
541
+ origin: "https://design.example.test",
542
+ source: {} as Window,
543
+ }),
544
+ );
545
+ await Promise.resolve();
546
+ });
547
+
548
+ expect(onChildRouteChange).toHaveBeenCalledTimes(1);
549
+ });
550
+
551
+ it("seeds a standalone iframe from its initial Dispatch route", async () => {
552
+ await act(async () => {
553
+ root.render(
554
+ <WorkspaceAppFrame
555
+ app={{ id: "design", name: "Design", path: "/design" }}
556
+ initialPath="/foobar?mode=edit#canvas"
557
+ />,
558
+ );
559
+ await Promise.resolve();
560
+ await Promise.resolve();
561
+ });
562
+
563
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
564
+ app: "design",
565
+ path: "/foobar?mode=edit#canvas",
566
+ chrome: "minimal",
567
+ });
568
+ });
569
+
472
570
  it("clears the embed error after a direct fallback iframe loads", async () => {
473
571
  clientState.legacyMutateError = new Error("Restore request failed");
474
572
  clientState.legacyErrorMutateAsync.mockRejectedValue(
@@ -11,6 +11,7 @@ import {
11
11
  useActionQuery,
12
12
  } from "@agent-native/core/client/hooks";
13
13
  import { useT } from "@agent-native/core/client/i18n";
14
+ import { AGENT_NATIVE_WORKSPACE_APP_ROUTE_MESSAGE_TYPE } from "@agent-native/core/client/navigation";
14
15
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
15
16
  import {
16
17
  IconAlertTriangle,
@@ -34,6 +35,7 @@ import {
34
35
  isWorkspaceSsoApp,
35
36
  navigateToWorkspaceApp,
36
37
  shouldOpenWorkspaceAppInTopWindow,
38
+ workspaceAppRouteForChildPath,
37
39
  workspaceAppDirectHref,
38
40
  workspaceAppEmbedTarget,
39
41
  workspaceAppHref,
@@ -251,6 +253,10 @@ interface WorkspaceAppFrameProps {
251
253
  navigateToTopWindow?: (href: string) => boolean | void;
252
254
  /** Chat-first app tabs use their own route while standalone hosts use app metadata. */
253
255
  embedPath?: string;
256
+ /** Standalone Dispatch routes seed the iframe once from their initial suffix. */
257
+ initialPath?: string;
258
+ /** Standalone Dispatch hosts mirror child route changes into the shell URL. */
259
+ onChildRouteChange?: (path: string) => void;
254
260
  /** Chat-first app surfaces own the parent chat rail around the iframe. */
255
261
  chatSidebar?: boolean;
256
262
  copy?: ChatFirstCopy;
@@ -260,6 +266,8 @@ export function WorkspaceAppFrame({
260
266
  app,
261
267
  navigateToTopWindow = navigateToWorkspaceApp,
262
268
  embedPath,
269
+ initialPath,
270
+ onChildRouteChange,
263
271
  chatSidebar = false,
264
272
  copy = defaultChatFirstCopy,
265
273
  }: WorkspaceAppFrameProps) {
@@ -315,27 +323,36 @@ export function WorkspaceAppFrame({
315
323
  embedPath,
316
324
  );
317
325
  }
326
+ if (initialPath !== undefined) {
327
+ return workspaceAppDirectHref(
328
+ { path: app.path, url: app.url },
329
+ initialPath,
330
+ );
331
+ }
318
332
 
319
333
  const target = workspaceAppEmbedTarget({
320
334
  path: app.path ?? "",
321
335
  url: app.url,
322
336
  });
323
337
  return target.url ?? target.path ?? null;
324
- }, [app.path, app.url, embedPath]);
338
+ }, [app.path, app.url, embedPath, initialPath]);
325
339
  const openInTopWindow = shouldOpenWorkspaceAppInTopWindow();
326
- const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${embedAttempt}`;
340
+ const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${initialPath ?? ""}\u0000${embedAttempt}`;
327
341
  const topWindowSsoAttemptedRef = useRef<string | null>(null);
328
342
  const embedInput = useMemo<EmbedSessionInput | null>(() => {
329
343
  if (embedPath !== undefined) {
330
344
  return buildChatFirstEmbedSessionInput(app.id, embedPath);
331
345
  }
346
+ if (initialPath !== undefined) {
347
+ return buildChatFirstEmbedSessionInput(app.id, initialPath);
348
+ }
332
349
  if (!appHref) return null;
333
350
  return {
334
351
  app: app.id,
335
352
  ...workspaceAppEmbedTarget({ path: app.path ?? "", url: app.url }),
336
353
  chrome: "minimal",
337
354
  };
338
- }, [app.id, app.path, app.url, appHref, embedPath]);
355
+ }, [app.id, app.path, app.url, appHref, embedPath, initialPath]);
339
356
 
340
357
  useEffect(() => {
341
358
  if (openInTopWindow && useWorkspaceSso && embedInput) {
@@ -425,7 +442,7 @@ export function WorkspaceAppFrame({
425
442
  setEmbedUrl(
426
443
  workspaceAppDirectHref(
427
444
  { path: app.path ?? "", url: app.url },
428
- embedPath ?? "/",
445
+ initialPath ?? embedPath ?? "/",
429
446
  ),
430
447
  );
431
448
  setEmbedError(error);
@@ -441,6 +458,7 @@ export function WorkspaceAppFrame({
441
458
  createWorkspaceSsoEmbedSession.mutateAsync,
442
459
  embedInput,
443
460
  embedPath,
461
+ initialPath,
444
462
  embedAttempt,
445
463
  openInTopWindow,
446
464
  navigateToTopWindow,
@@ -464,6 +482,48 @@ export function WorkspaceAppFrame({
464
482
  window.removeEventListener("message", handleEmbedSessionExpired);
465
483
  }, [embedUrl]);
466
484
 
485
+ useEffect(() => {
486
+ if (!onChildRouteChange || !embedUrl) return;
487
+
488
+ const frame = embedFrameRef.current;
489
+ if (!frame) return;
490
+
491
+ let expectedOrigin: string;
492
+ try {
493
+ expectedOrigin = new URL(embedUrl, window.location.href).origin;
494
+ } catch {
495
+ return;
496
+ }
497
+
498
+ const handleWorkspaceAppRoute = (event: MessageEvent) => {
499
+ if (
500
+ event.source !== frame.contentWindow ||
501
+ event.origin !== expectedOrigin
502
+ ) {
503
+ return;
504
+ }
505
+ const message = event.data as {
506
+ type?: unknown;
507
+ path?: unknown;
508
+ } | null;
509
+ if (
510
+ message?.type !== AGENT_NATIVE_WORKSPACE_APP_ROUTE_MESSAGE_TYPE ||
511
+ typeof message.path !== "string"
512
+ ) {
513
+ return;
514
+ }
515
+
516
+ const route = workspaceAppRouteForChildPath(
517
+ { id: app.id, path: app.path ?? "", url: app.url },
518
+ message.path,
519
+ );
520
+ if (route) onChildRouteChange(route);
521
+ };
522
+
523
+ window.addEventListener("message", handleWorkspaceAppRoute);
524
+ return () => window.removeEventListener("message", handleWorkspaceAppRoute);
525
+ }, [app.id, app.path, app.url, embedUrl, onChildRouteChange]);
526
+
467
527
  useEffect(() => {
468
528
  postThemeToFrame();
469
529
  }, [embedUrl, postThemeToFrame]);
@@ -514,9 +574,13 @@ export function WorkspaceAppFrame({
514
574
  export function WorkspaceAppHost({
515
575
  appId,
516
576
  navigateToTopWindow = navigateToWorkspaceApp,
577
+ initialPath,
578
+ onChildRouteChange,
517
579
  }: {
518
580
  appId?: string;
519
581
  navigateToTopWindow?: (href: string) => boolean | void;
582
+ initialPath?: string;
583
+ onChildRouteChange?: (path: string) => void;
520
584
  }) {
521
585
  const t = useT();
522
586
  const workspaceAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
@@ -702,6 +766,8 @@ export function WorkspaceAppHost({
702
766
  <WorkspaceAppFrame
703
767
  app={app}
704
768
  navigateToTopWindow={navigateToTopWindow}
769
+ initialPath={initialPath}
770
+ onChildRouteChange={onChildRouteChange}
705
771
  />
706
772
  </div>
707
773
  </div>
@@ -8,6 +8,8 @@ import {
8
8
  isWorkspaceSsoApp,
9
9
  mergeChatFirstWorkspaceApps,
10
10
  workspaceAppIdFromRoute,
11
+ workspaceAppInitialPathFromSplat,
12
+ workspaceAppRouteForChildPath,
11
13
  workspaceAppDirectHref,
12
14
  workspaceAppRoute,
13
15
  } from "./workspace-apps";
@@ -29,6 +31,44 @@ describe("workspace app routes", () => {
29
31
  expect(workspaceAppIdFromRoute("/apps/mail/inbox")).toBe("mail");
30
32
  });
31
33
 
34
+ it("preserves the initial app suffix for a standalone deep link", () => {
35
+ expect(
36
+ workspaceAppInitialPathFromSplat("inbox", "?view=unread", "#top"),
37
+ ).toBe("/inbox?view=unread#top");
38
+ expect(workspaceAppInitialPathFromSplat(undefined, "", "")).toBeUndefined();
39
+ });
40
+
41
+ it("maps child routes to shareable Dispatch app routes", () => {
42
+ expect(
43
+ workspaceAppRouteForChildPath(
44
+ { id: "design", path: "/", url: "https://design.example.test" },
45
+ "/foobar?mode=edit#canvas",
46
+ ),
47
+ ).toBe("/apps/design/foobar?mode=edit#canvas");
48
+ expect(
49
+ workspaceAppRouteForChildPath(
50
+ {
51
+ id: "internal-design",
52
+ path: "/design",
53
+ url: "https://workspace.example.test/design",
54
+ },
55
+ "/design/foobar",
56
+ ),
57
+ ).toBe("/apps/internal-design/foobar");
58
+ expect(
59
+ workspaceAppRouteForChildPath(
60
+ { id: "design", path: "/", url: "https://design.example.test" },
61
+ "//evil.example/route",
62
+ ),
63
+ ).toBeNull();
64
+ expect(
65
+ workspaceAppDirectHref(
66
+ { path: "/", url: "https://design.example.test" },
67
+ "/foobar?mode=edit#canvas",
68
+ ),
69
+ ).toBe("https://design.example.test/foobar?mode=edit#canvas");
70
+ });
71
+
32
72
  it("identifies Dispatch regardless of casing or surrounding whitespace", () => {
33
73
  expect(isDispatchWorkspaceAppId(" Dispatch ")).toBe(true);
34
74
  expect(isDispatchWorkspaceAppId("dispatch-tools")).toBe(false);