@agent-native/dispatch 0.31.13 → 0.31.15

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 (74) 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/dispatch-control-plane.d.ts.map +1 -1
  5. package/dist/components/dispatch-control-plane.js +2 -2
  6. package/dist/components/dispatch-control-plane.js.map +1 -1
  7. package/dist/components/layout/Layout.d.ts +17 -4
  8. package/dist/components/layout/Layout.d.ts.map +1 -1
  9. package/dist/components/layout/Layout.js +45 -20
  10. package/dist/components/layout/Layout.js.map +1 -1
  11. package/dist/components/other-apps-section.d.ts +5 -3
  12. package/dist/components/other-apps-section.d.ts.map +1 -1
  13. package/dist/components/other-apps-section.js +15 -3
  14. package/dist/components/other-apps-section.js.map +1 -1
  15. package/dist/components/workspace-app-card.d.ts.map +1 -1
  16. package/dist/components/workspace-app-card.js +18 -1
  17. package/dist/components/workspace-app-card.js.map +1 -1
  18. package/dist/components/workspace-app-host.d.ts +8 -2
  19. package/dist/components/workspace-app-host.d.ts.map +1 -1
  20. package/dist/components/workspace-app-host.js +46 -8
  21. package/dist/components/workspace-app-host.js.map +1 -1
  22. package/dist/lib/catch-all-target.d.ts.map +1 -1
  23. package/dist/lib/catch-all-target.js +4 -2
  24. package/dist/lib/catch-all-target.js.map +1 -1
  25. package/dist/lib/workspace-apps.d.ts +7 -0
  26. package/dist/lib/workspace-apps.d.ts.map +1 -1
  27. package/dist/lib/workspace-apps.js +65 -0
  28. package/dist/lib/workspace-apps.js.map +1 -1
  29. package/dist/routes/index.js +1 -1
  30. package/dist/routes/index.js.map +1 -1
  31. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  32. package/dist/routes/pages/apps.$appId.js +15 -4
  33. package/dist/routes/pages/apps.$appId.js.map +1 -1
  34. package/dist/routes/pages/apps.d.ts.map +1 -1
  35. package/dist/routes/pages/apps.js +16 -4
  36. package/dist/routes/pages/apps.js.map +1 -1
  37. package/dist/routes/pages/automations.d.ts.map +1 -1
  38. package/dist/routes/pages/automations.js +19 -16
  39. package/dist/routes/pages/automations.js.map +1 -1
  40. package/dist/server/lib/usage-metrics-store.d.ts +20 -0
  41. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  42. package/dist/server/lib/usage-metrics-store.js +86 -32
  43. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  44. package/dist/server/plugins/agent-chat.js +3 -0
  45. package/dist/server/plugins/agent-chat.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/actions/index.spec.ts +17 -0
  48. package/src/actions/list-dispatch-usage-metrics.ts +9 -1
  49. package/src/components/dispatch-control-plane.spec.tsx +3 -1
  50. package/src/components/dispatch-control-plane.tsx +2 -1
  51. package/src/components/layout/Layout.spec.tsx +143 -25
  52. package/src/components/layout/Layout.tsx +79 -47
  53. package/src/components/other-apps-section.spec.ts +49 -0
  54. package/src/components/other-apps-section.tsx +31 -6
  55. package/src/components/workspace-app-card.tsx +27 -1
  56. package/src/components/workspace-app-host.spec.tsx +98 -0
  57. package/src/components/workspace-app-host.tsx +70 -4
  58. package/src/lib/catch-all-target.spec.ts +8 -0
  59. package/src/lib/catch-all-target.ts +4 -2
  60. package/src/lib/workspace-apps.spec.ts +40 -0
  61. package/src/lib/workspace-apps.ts +80 -0
  62. package/src/routes/index.spec.ts +10 -0
  63. package/src/routes/index.ts +1 -1
  64. package/src/routes/pages/apps.$appId.spec.tsx +50 -1
  65. package/src/routes/pages/apps.$appId.tsx +26 -3
  66. package/src/routes/pages/apps.tsx +42 -26
  67. package/src/routes/pages/automations.spec.tsx +240 -0
  68. package/src/routes/pages/automations.tsx +22 -16
  69. package/src/server/lib/usage-metrics-store.spec.ts +132 -1
  70. package/src/server/lib/usage-metrics-store.ts +173 -65
  71. package/src/server/plugins/agent-chat.spec.ts +2 -0
  72. package/src/server/plugins/agent-chat.ts +3 -0
  73. package/src/styles/dispatch-css.spec.ts +3 -0
  74. package/src/styles/dispatch.css +5 -0
@@ -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>
@@ -12,6 +12,14 @@ describe("resolveCatchAllTarget", () => {
12
12
  ).toBe("/todo");
13
13
  });
14
14
 
15
+ it("keeps the legacy mounted path for normalized agent IDs", () => {
16
+ expect(
17
+ resolveCatchAllTarget("clips", {
18
+ workspaceApps: [{ id: "clips", path: "/videos" }],
19
+ }),
20
+ ).toBe("/videos");
21
+ });
22
+
15
23
  it("falls back to the built-in template URL when no workspace manifest exists", () => {
16
24
  expect(
17
25
  resolveCatchAllTarget("forms", {
@@ -116,9 +116,11 @@ export async function resolveServerCatchAllTarget(
116
116
  appId: string,
117
117
  ): Promise<string | null> {
118
118
  if (!import.meta.env.SSR) return null;
119
- const { getBuiltinAgents, loadWorkspaceAppsManifest } =
119
+ const { getBuiltinAgents, loadWorkspaceAppsManifest, normalizeAgentId } =
120
120
  await import("@agent-native/core/server/agent-discovery");
121
- return resolveCatchAllTarget(appId, {
121
+ // Dispatch receives legacy route segments before discovery normalizes the
122
+ // manifest IDs, so normalize the lookup key at this boundary as well.
123
+ return resolveCatchAllTarget(normalizeAgentId(appId), {
122
124
  workspaceApps: loadWorkspaceAppsManifest(),
123
125
  builtinAgents: getBuiltinAgents("dispatch"),
124
126
  });
@@ -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);
@@ -149,6 +149,86 @@ export function workspaceAppRoute(appId: string): string {
149
149
  return `/apps/${encodeURIComponent(appId)}`;
150
150
  }
151
151
 
152
+ function normalizeWorkspaceAppRoutePath(rawPath: string): URL | null {
153
+ if (
154
+ typeof rawPath !== "string" ||
155
+ !rawPath.startsWith("/") ||
156
+ rawPath.startsWith("//") ||
157
+ /[\u0000-\u001f\u007f]/.test(rawPath)
158
+ ) {
159
+ return null;
160
+ }
161
+
162
+ try {
163
+ return new URL(rawPath, "https://agent-native.invalid");
164
+ } catch {
165
+ // coercion-ok: malformed child routes are ignored by the host.
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function normalizedWorkspaceAppMountPath(rawPath: string): string {
171
+ const pathname = rawPath.split(/[?#]/, 1)[0] ?? rawPath;
172
+ const normalized = `/${pathname.replace(/^[/\\]+/, "")}`;
173
+ return normalized.replace(/\/+$/, "") || "/";
174
+ }
175
+
176
+ function workspaceAppMountPath(
177
+ app: Pick<WorkspaceAppSummary, "path" | "url">,
178
+ ): string {
179
+ const rawUrl = app.url?.trim();
180
+ if (rawUrl) {
181
+ try {
182
+ const parsed = new URL(rawUrl);
183
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
184
+ return normalizedWorkspaceAppMountPath(parsed.pathname);
185
+ }
186
+ // coercion-ok: invalid URL falls back to app path.
187
+ } catch {
188
+ // Fall through to the mounted path when optional URL metadata is invalid.
189
+ }
190
+ }
191
+ return normalizedWorkspaceAppMountPath(app.path?.trim() || "/");
192
+ }
193
+
194
+ /**
195
+ * Convert a child app route into Dispatch's shareable workspace-app route.
196
+ * Mounted workspace apps may report their full mount path, while hosted apps
197
+ * normally report only the app-local path, so accept both forms here.
198
+ */
199
+ export function workspaceAppRouteForChildPath(
200
+ app: Pick<WorkspaceAppSummary, "id" | "path" | "url">,
201
+ childPath: string,
202
+ ): string | null {
203
+ const parsed = normalizeWorkspaceAppRoutePath(childPath);
204
+ if (!parsed) return null;
205
+
206
+ const mountPath = workspaceAppMountPath(app);
207
+ const relativePathname =
208
+ mountPath !== "/" &&
209
+ (parsed.pathname === mountPath ||
210
+ parsed.pathname.startsWith(`${mountPath}/`))
211
+ ? parsed.pathname.slice(mountPath.length) || "/"
212
+ : parsed.pathname;
213
+ const routePath = workspaceAppRoute(app.id);
214
+ const suffix = relativePathname === "/" ? "" : relativePathname;
215
+ return `${routePath}${suffix}${parsed.search}${parsed.hash}`;
216
+ }
217
+
218
+ export function workspaceAppInitialPathFromSplat(
219
+ routeSplat: string | undefined,
220
+ search: string,
221
+ hash: string,
222
+ ): string | undefined {
223
+ const pathname = routeSplat?.trim()
224
+ ? `/${routeSplat.trim().replace(/^[/\\]+/, "")}`
225
+ : "/";
226
+ const parsed = normalizeWorkspaceAppRoutePath(`${pathname}${search}${hash}`);
227
+ if (!parsed) return undefined;
228
+ const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
229
+ return path === "/" ? undefined : path;
230
+ }
231
+
152
232
  export function workspaceAppIdFromRoute(pathname: string): string | null {
153
233
  const match = pathname.match(/^\/apps\/([^/]+)(?:\/|$)/);
154
234
  if (!match) return null;
@@ -1,3 +1,4 @@
1
+ import { matchRoutes } from "react-router";
1
2
  import { describe, expect, it } from "vitest";
2
3
 
3
4
  import { dispatchRoutes } from "./index.js";
@@ -19,6 +20,7 @@ describe("Dispatch route registration", () => {
19
20
  expect(paths).toContain("operations");
20
21
  expect(paths).toContain("admin");
21
22
  expect(paths).toContain("agents");
23
+ expect(paths).toContain("apps/:appId/*");
22
24
  expect(paths.indexOf("chat")).toBeLessThan(paths.indexOf(":appId"));
23
25
  expect(paths.indexOf("chat/:threadId")).toBeLessThan(
24
26
  paths.indexOf(":appId"),
@@ -36,4 +38,12 @@ describe("Dispatch route registration", () => {
36
38
  expect(adminPaths).not.toContain("apps/:appId");
37
39
  expect(adminPaths.indexOf("operations")).toBeGreaterThanOrEqual(0);
38
40
  });
41
+
42
+ it("matches both workspace-app roots and nested deep links", () => {
43
+ for (const path of ["/apps/design", "/apps/design/foobar"]) {
44
+ expect(matchRoutes(dispatchRoutes, path)?.at(-1)?.route.path).toBe(
45
+ "apps/:appId/*",
46
+ );
47
+ }
48
+ });
39
49
  });
@@ -62,7 +62,7 @@ export const dispatchRoutes: RouteConfig = [
62
62
  route("metrics", "./pages/metrics.js"),
63
63
  route("operations", "./pages/operations.js"),
64
64
  route("apps", "./pages/apps.js"),
65
- route("apps/:appId", "./pages/apps.$appId.js"),
65
+ route("apps/:appId/*", "./pages/apps.$appId.js"),
66
66
  route("new-app", "./pages/new-app.js"),
67
67
  route("vault", "./pages/vault.js"),
68
68
  route("integrations", "./pages/integrations.js"),
@@ -5,15 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  const routeState = vi.hoisted(() => ({
7
7
  appId: "mail",
8
+ routeSplat: undefined as string | undefined,
9
+ location: { pathname: "/apps/mail", search: "", hash: "" },
8
10
  hostProps: null as {
9
11
  appId?: string;
10
12
  navigateToTopWindow?: (href: string) => boolean | void;
13
+ initialPath?: string;
14
+ onChildRouteChange?: (path: string) => void;
11
15
  } | null,
12
16
  navigateToWorkspaceApp: vi.fn(() => true),
17
+ navigate: vi.fn(),
13
18
  }));
14
19
 
15
20
  vi.mock("react-router", () => ({
16
- useParams: () => ({ appId: routeState.appId }),
21
+ useParams: () => ({ appId: routeState.appId, "*": routeState.routeSplat }),
22
+ useLocation: () => routeState.location,
23
+ useNavigate: () => routeState.navigate,
17
24
  }));
18
25
 
19
26
  vi.mock("../../components/workspace-app-host", () => ({
@@ -25,6 +32,14 @@ vi.mock("../../components/workspace-app-host", () => ({
25
32
 
26
33
  vi.mock("../../lib/workspace-apps", () => ({
27
34
  navigateToWorkspaceApp: routeState.navigateToWorkspaceApp,
35
+ workspaceAppInitialPathFromSplat: (
36
+ routeSplat: string | undefined,
37
+ search: string,
38
+ hash: string,
39
+ ) =>
40
+ routeSplat || search || hash
41
+ ? `/${routeSplat ?? ""}${search}${hash}`
42
+ : undefined,
28
43
  }));
29
44
 
30
45
  import WorkspaceAppRoute from "./apps.$appId";
@@ -36,8 +51,11 @@ describe("WorkspaceAppRoute", () => {
36
51
  beforeEach(() => {
37
52
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
38
53
  routeState.appId = "mail";
54
+ routeState.routeSplat = undefined;
55
+ routeState.location = { pathname: "/apps/mail", search: "", hash: "" };
39
56
  routeState.hostProps = null;
40
57
  routeState.navigateToWorkspaceApp.mockClear();
58
+ routeState.navigate.mockClear();
41
59
  container = document.createElement("div");
42
60
  document.body.appendChild(container);
43
61
  root = createRoot(container);
@@ -63,4 +81,35 @@ describe("WorkspaceAppRoute", () => {
63
81
 
64
82
  expect(routeState.navigateToWorkspaceApp).toHaveBeenCalledWith("/mail");
65
83
  });
84
+
85
+ it("keeps the iframe synchronized with standalone deep-link navigation", async () => {
86
+ routeState.routeSplat = "foobar";
87
+ routeState.location = {
88
+ pathname: "/apps/mail/foobar",
89
+ search: "?view=all",
90
+ hash: "#top",
91
+ };
92
+
93
+ await act(async () => {
94
+ root.render(<WorkspaceAppRoute />);
95
+ });
96
+
97
+ expect(routeState.hostProps?.initialPath).toBe("/foobar?view=all#top");
98
+
99
+ routeState.routeSplat = "sent";
100
+ routeState.location = {
101
+ pathname: "/apps/mail/sent",
102
+ search: "",
103
+ hash: "",
104
+ };
105
+ await act(async () => {
106
+ root.render(<WorkspaceAppRoute />);
107
+ });
108
+
109
+ expect(routeState.hostProps?.initialPath).toBe("/sent");
110
+ routeState.hostProps?.onChildRouteChange?.("/apps/mail/archive");
111
+ expect(routeState.navigate).toHaveBeenCalledWith("/apps/mail/archive", {
112
+ replace: true,
113
+ });
114
+ });
66
115
  });
@@ -1,7 +1,11 @@
1
- import { useParams } from "react-router";
1
+ import { useCallback } from "react";
2
+ import { useLocation, useNavigate, useParams } from "react-router";
2
3
 
3
4
  import { WorkspaceAppHost } from "../../components/workspace-app-host";
4
- import { navigateToWorkspaceApp } from "../../lib/workspace-apps";
5
+ import {
6
+ navigateToWorkspaceApp,
7
+ workspaceAppInitialPathFromSplat,
8
+ } from "../../lib/workspace-apps";
5
9
 
6
10
  export function navigateWorkspaceAppFromRoute(href: string): boolean {
7
11
  return navigateToWorkspaceApp(href);
@@ -12,11 +16,30 @@ export function meta() {
12
16
  }
13
17
 
14
18
  export default function WorkspaceAppRoute() {
15
- const { appId } = useParams();
19
+ const params = useParams();
20
+ const { appId } = params;
21
+ const location = useLocation();
22
+ const navigate = useNavigate();
23
+ const initialPath = workspaceAppInitialPathFromSplat(
24
+ params["*"],
25
+ location.search,
26
+ location.hash,
27
+ );
28
+ const onChildRouteChange = useCallback(
29
+ (path: string) => {
30
+ const currentPath = `${location.pathname}${location.search}${location.hash}`;
31
+ if (path === currentPath) return;
32
+ void navigate(path, { replace: true });
33
+ },
34
+ [location.hash, location.pathname, location.search, navigate],
35
+ );
36
+
16
37
  return (
17
38
  <WorkspaceAppHost
18
39
  appId={appId}
19
40
  navigateToTopWindow={navigateWorkspaceAppFromRoute}
41
+ initialPath={initialPath}
42
+ onChildRouteChange={onChildRouteChange}
20
43
  />
21
44
  );
22
45
  }