@agent-native/dispatch 0.40.40 → 0.40.41-beta-dispatch-embed-01a15dc9-20261027050558

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 (45) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/actions/create_embed_session.js +1 -0
  3. package/dist/actions/create_embed_session.js.map +1 -1
  4. package/dist/actions/delete-destination.d.ts +12 -12
  5. package/dist/actions/provider-api-register.d.ts +13 -13
  6. package/dist/components/layout/Layout.d.ts.map +1 -1
  7. package/dist/components/layout/Layout.js +8 -11
  8. package/dist/components/layout/Layout.js.map +1 -1
  9. package/dist/components/workspace-app-host.d.ts.map +1 -1
  10. package/dist/components/workspace-app-host.js +23 -10
  11. package/dist/components/workspace-app-host.js.map +1 -1
  12. package/dist/lib/workspace-apps.d.ts +2 -2
  13. package/dist/lib/workspace-apps.d.ts.map +1 -1
  14. package/dist/lib/workspace-apps.js +35 -5
  15. package/dist/lib/workspace-apps.js.map +1 -1
  16. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  17. package/dist/server/lib/app-creation-store.js +173 -48
  18. package/dist/server/lib/app-creation-store.js.map +1 -1
  19. package/dist/server/lib/community-apps.js +1 -1
  20. package/dist/server/lib/community-apps.js.map +1 -1
  21. package/dist/server/lib/mcp-gateway.d.ts +1 -0
  22. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  23. package/dist/server/lib/mcp-gateway.js +170 -38
  24. package/dist/server/lib/mcp-gateway.js.map +1 -1
  25. package/dist/server/lib/provider-api.d.ts +2 -2
  26. package/dist/shared/workspace-app-route.d.ts +2 -0
  27. package/dist/shared/workspace-app-route.d.ts.map +1 -0
  28. package/dist/shared/workspace-app-route.js +4 -0
  29. package/dist/shared/workspace-app-route.js.map +1 -0
  30. package/package.json +4 -4
  31. package/src/actions/create_embed_session.ts +1 -0
  32. package/src/actions/index.spec.ts +3 -0
  33. package/src/components/layout/Layout.spec.tsx +39 -9
  34. package/src/components/layout/Layout.tsx +19 -40
  35. package/src/components/workspace-app-host.spec.tsx +88 -1
  36. package/src/components/workspace-app-host.tsx +30 -9
  37. package/src/lib/workspace-apps.spec.ts +64 -5
  38. package/src/lib/workspace-apps.ts +36 -6
  39. package/src/routes/pages/apps.$appId.spec.tsx +21 -3
  40. package/src/server/lib/app-creation-store.spec.ts +502 -11
  41. package/src/server/lib/app-creation-store.ts +198 -50
  42. package/src/server/lib/community-apps.ts +1 -1
  43. package/src/server/lib/mcp-gateway.spec.ts +396 -9
  44. package/src/server/lib/mcp-gateway.ts +209 -45
  45. package/src/shared/workspace-app-route.ts +3 -0
@@ -68,13 +68,12 @@ import {
68
68
  AppSidebarHeader,
69
69
  openCommandMenu,
70
70
  } from "@agent-native/toolkit/app/shared";
71
+ import { ChatHistoryMenuItem } from "@agent-native/toolkit/chat-history";
71
72
  import { writeClipboardText } from "@agent-native/toolkit/clipboard";
72
73
  import {
73
74
  IconApps,
74
75
  IconBrandSlack,
75
76
  IconBrandTelegram,
76
- IconCopy,
77
- IconEye,
78
77
  IconHierarchy2,
79
78
  IconMessageQuestion,
80
79
  IconBroadcast,
@@ -833,14 +832,10 @@ function DispatchChatsSection({
833
832
  unpin: t("agentChat.contextXray.unpin"),
834
833
  delete: t("extensions.delete"),
835
834
  }}
836
- renderAdditionalRowActions={(item, closeMenu) => (
835
+ renderAdditionalRowActions={(item) => (
837
836
  <>
838
- <button
839
- type="button"
840
- role="menuitem"
841
- className="an-chat-history-row__menu-item"
842
- onClick={() => {
843
- closeMenu();
837
+ <ChatHistoryMenuItem
838
+ onSelect={() => {
844
839
  void writeClipboardText(item.id).then((copied) => {
845
840
  toast[copied ? "success" : "error"](
846
841
  copied
@@ -850,16 +845,11 @@ function DispatchChatsSection({
850
845
  });
851
846
  }}
852
847
  >
853
- <IconCopy size={13} strokeWidth={1.8} />
854
- <span>{chatFirstCopy("copySessionId")}</span>
855
- </button>
848
+ {chatFirstCopy("copySessionId")}
849
+ </ChatHistoryMenuItem>
856
850
  {!chatFirstEmbedded ? (
857
- <button
858
- type="button"
859
- role="menuitem"
860
- className="an-chat-history-row__menu-item"
861
- onClick={() => {
862
- closeMenu();
851
+ <ChatHistoryMenuItem
852
+ onSelect={() => {
863
853
  emitChatFirstSessionWatch({
864
854
  sessionId: item.id,
865
855
  title: item.titleText,
@@ -868,9 +858,8 @@ function DispatchChatsSection({
868
858
  });
869
859
  }}
870
860
  >
871
- <IconEye size={13} strokeWidth={1.8} />
872
- <span>{chatFirstCopy("watchSession")}</span>
873
- </button>
861
+ {chatFirstCopy("watchSession")}
862
+ </ChatHistoryMenuItem>
874
863
  ) : null}
875
864
  </>
876
865
  )}
@@ -901,14 +890,10 @@ function DispatchChatsSection({
901
890
  unpin: t("agentChat.contextXray.unpin"),
902
891
  delete: t("extensions.delete"),
903
892
  }}
904
- renderAdditionalRowActions={(item, closeMenu) => (
893
+ renderAdditionalRowActions={(item) => (
905
894
  <>
906
- <button
907
- type="button"
908
- role="menuitem"
909
- className="an-chat-history-row__menu-item"
910
- onClick={() => {
911
- closeMenu();
895
+ <ChatHistoryMenuItem
896
+ onSelect={() => {
912
897
  void writeClipboardText(item.id).then((copied) => {
913
898
  toast[copied ? "success" : "error"](
914
899
  copied
@@ -918,16 +903,11 @@ function DispatchChatsSection({
918
903
  });
919
904
  }}
920
905
  >
921
- <IconCopy size={13} strokeWidth={1.8} />
922
- <span>{chatFirstCopy("copySessionId")}</span>
923
- </button>
906
+ {chatFirstCopy("copySessionId")}
907
+ </ChatHistoryMenuItem>
924
908
  {chatFirstMode && !chatFirstEmbedded ? (
925
- <button
926
- type="button"
927
- role="menuitem"
928
- className="an-chat-history-row__menu-item"
929
- onClick={() => {
930
- closeMenu();
909
+ <ChatHistoryMenuItem
910
+ onSelect={() => {
931
911
  emitChatFirstSessionWatch({
932
912
  sessionId: item.id,
933
913
  title: item.titleText,
@@ -936,9 +916,8 @@ function DispatchChatsSection({
936
916
  });
937
917
  }}
938
918
  >
939
- <IconEye size={13} strokeWidth={1.8} />
940
- <span>{chatFirstCopy("watchSession")}</span>
941
- </button>
919
+ {chatFirstCopy("watchSession")}
920
+ </ChatHistoryMenuItem>
942
921
  ) : null}
943
922
  </>
944
923
  )}
@@ -62,6 +62,7 @@ vi.mock("@agent-native/toolkit/app/chat/chat-first/app-pane", () => ({
62
62
  status: string;
63
63
  }) => (
64
64
  <div
65
+ data-chat-first-app-embed-url={embedUrl ?? ""}
65
66
  data-chat-first-app-error={errorMessage ?? ""}
66
67
  data-chat-first-app-status={status}
67
68
  >
@@ -381,6 +382,92 @@ describe("WorkspaceAppKeepAlive", () => {
381
382
  });
382
383
  });
383
384
 
385
+ it.each([
386
+ ["coach", "/", "/"],
387
+ ["onboarding", "/accounts", "/accounts"],
388
+ ])(
389
+ "embeds %s by its own home path when its registry url is this Dispatch's page",
390
+ async (id, homePath, expectedPath) => {
391
+ await act(async () => {
392
+ root.render(
393
+ <WorkspaceAppFrame
394
+ app={{
395
+ id,
396
+ name: id,
397
+ path: `/${id}`,
398
+ homePath,
399
+ url: `${window.location.origin}/dispatch/apps/${id}`,
400
+ }}
401
+ />,
402
+ );
403
+ await Promise.resolve();
404
+ await Promise.resolve();
405
+ });
406
+
407
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
408
+ app: id,
409
+ path: expectedPath,
410
+ chrome: "minimal",
411
+ });
412
+ },
413
+ );
414
+
415
+ it("never falls back to framing this Dispatch's own page for the app", async () => {
416
+ clientState.legacyMutateError = new Error("Embed failed");
417
+ clientState.legacyErrorMutateAsync.mockRejectedValue(
418
+ clientState.legacyMutateError,
419
+ );
420
+ clientState.suppressFrameLoad = true;
421
+
422
+ await act(async () => {
423
+ root.render(
424
+ <WorkspaceAppFrame
425
+ app={{
426
+ id: "onboarding",
427
+ name: "Onboarding",
428
+ path: "/onboarding",
429
+ homePath: "/accounts",
430
+ url: `${window.location.origin}/dispatch/apps/onboarding`,
431
+ }}
432
+ />,
433
+ );
434
+ await Promise.resolve();
435
+ await Promise.resolve();
436
+ });
437
+
438
+ expect(
439
+ container
440
+ .querySelector("[data-chat-first-app-embed-url]")
441
+ ?.getAttribute("data-chat-first-app-embed-url"),
442
+ ).toBe("/onboarding/accounts");
443
+ });
444
+
445
+ it("keeps a Dispatch page url on another origin as the app url", async () => {
446
+ const url = "https://gateway.example.test/dispatch/apps/coach";
447
+
448
+ await act(async () => {
449
+ root.render(
450
+ <WorkspaceAppFrame
451
+ app={{
452
+ id: "coach",
453
+ name: "Coach",
454
+ path: "/coach",
455
+ homePath: "/",
456
+ url,
457
+ }}
458
+ />,
459
+ );
460
+ await Promise.resolve();
461
+ await Promise.resolve();
462
+ });
463
+
464
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
465
+ app: "coach",
466
+ url,
467
+ chrome: "minimal",
468
+ });
469
+ });
470
+
384
471
  it("uses the granted-app session action for mounted apps outside the SSO registry", async () => {
385
472
  clientState.workspaceSsoEnabled = true;
386
473
 
@@ -549,7 +636,7 @@ describe("WorkspaceAppKeepAlive", () => {
549
636
  });
550
637
 
551
638
  expect(onChildRouteChange).toHaveBeenCalledWith(
552
- "/apps/design/foobar?mode=edit#canvas",
639
+ "/apps/design?__workspace_app_path=%2Ffoobar%3Fmode%3Dedit%23canvas",
553
640
  );
554
641
 
555
642
  await act(async () => {
@@ -28,6 +28,7 @@ import { Link } from "react-router";
28
28
 
29
29
  import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
30
30
  import {
31
+ isWorkspaceAppHostPageUrl,
31
32
  isWorkspaceSsoApp,
32
33
  isDispatchWorkspaceAppId,
33
34
  navigateToWorkspaceApp,
@@ -35,6 +36,7 @@ import {
35
36
  workspaceAppRouteForChildPath,
36
37
  workspaceAppDirectHref,
37
38
  workspaceAppHref,
39
+ workspaceAppTargetPath,
38
40
  type WorkspaceAppSummary,
39
41
  } from "../lib/workspace-apps";
40
42
  import { DISPATCH_WORKSPACE_SSO_FLAG } from "../shared/feature-flags";
@@ -284,30 +286,35 @@ export function WorkspaceAppFrame({
284
286
  skipActionQueryInvalidation: true,
285
287
  timeoutMs: EMBED_SESSION_TIMEOUT_MS,
286
288
  });
289
+ const isHostPageUrl = isWorkspaceAppHostPageUrl(
290
+ app,
291
+ typeof window === "undefined" ? undefined : window.location.origin,
292
+ );
293
+ const appUrl = isHostPageUrl ? null : (app.url ?? null);
287
294
  const appHref = workspaceAppHref({
288
295
  id: app.id,
289
296
  name: app.name,
290
297
  path: app.path ?? "",
291
298
  homePath: app.homePath ?? undefined,
292
- url: app.url,
299
+ url: appUrl,
293
300
  isDispatch: app.isDispatch ?? isDispatchWorkspaceAppId(app.id),
294
301
  });
295
302
  const topWindowHref = useMemo(() => {
296
303
  if (embedPath !== undefined) {
297
304
  return workspaceAppDirectHref(
298
- { path: app.path, url: app.url },
305
+ { path: app.path ?? null, url: appUrl },
299
306
  embedPath,
300
307
  );
301
308
  }
302
309
  if (initialPath !== undefined) {
303
310
  return workspaceAppDirectHref(
304
- { path: app.path, url: app.url },
311
+ { path: app.path ?? null, url: appUrl },
305
312
  initialPath,
306
313
  );
307
314
  }
308
315
 
309
316
  return appHref;
310
- }, [appHref, embedPath, initialPath]);
317
+ }, [app.path, appHref, appUrl, embedPath, initialPath]);
311
318
  const openInTopWindow = shouldOpenWorkspaceAppInTopWindow();
312
319
  const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${initialPath ?? ""}\u0000${embedAttempt}`;
313
320
  const topWindowSsoAttemptedRef = useRef<string | null>(null);
@@ -319,12 +326,26 @@ export function WorkspaceAppFrame({
319
326
  return buildChatFirstEmbedSessionInput(app.id, initialPath);
320
327
  }
321
328
  if (!appHref) return null;
329
+ if (isHostPageUrl) {
330
+ return buildChatFirstEmbedSessionInput(
331
+ app.id,
332
+ workspaceAppTargetPath({ homePath: app.homePath ?? null }),
333
+ );
334
+ }
322
335
  return {
323
336
  app: app.id,
324
- ...(app.url?.trim() ? { url: appHref } : { path: appHref }),
337
+ ...(appUrl?.trim() ? { url: appHref } : { path: appHref }),
325
338
  chrome: "minimal",
326
339
  };
327
- }, [app.id, app.path, app.url, appHref, embedPath, initialPath]);
340
+ }, [
341
+ app.homePath,
342
+ app.id,
343
+ appHref,
344
+ appUrl,
345
+ embedPath,
346
+ initialPath,
347
+ isHostPageUrl,
348
+ ]);
328
349
 
329
350
  useEffect(() => {
330
351
  if (openInTopWindow && useWorkspaceSso && embedInput) {
@@ -410,7 +431,7 @@ export function WorkspaceAppFrame({
410
431
  const fallbackHref =
411
432
  initialPath !== undefined || embedPath !== undefined
412
433
  ? workspaceAppDirectHref(
413
- { path: app.path ?? "", url: app.url },
434
+ { path: app.path ?? "", url: appUrl },
414
435
  initialPath ?? embedPath ?? "/",
415
436
  )
416
437
  : appHref;
@@ -485,7 +506,7 @@ export function WorkspaceAppFrame({
485
506
  }
486
507
 
487
508
  const route = workspaceAppRouteForChildPath(
488
- { id: app.id, path: app.path ?? "", url: app.url },
509
+ { id: app.id, path: app.path ?? "", url: appUrl },
489
510
  message.path,
490
511
  );
491
512
  if (route) onChildRouteChange(route);
@@ -493,7 +514,7 @@ export function WorkspaceAppFrame({
493
514
 
494
515
  window.addEventListener("message", handleWorkspaceAppRoute);
495
516
  return () => window.removeEventListener("message", handleWorkspaceAppRoute);
496
- }, [app.id, app.path, app.url, embedUrl, onChildRouteChange]);
517
+ }, [app.id, app.path, appUrl, embedUrl, onChildRouteChange]);
497
518
 
498
519
  useEffect(() => {
499
520
  postThemeToFrame();
@@ -6,6 +6,7 @@ import {
6
6
  isDefaultWorkspaceAppHiddenId,
7
7
  isDispatchWorkspaceAppId,
8
8
  isPathMountedWorkspaceApp,
9
+ isWorkspaceAppHostPageUrl,
9
10
  isWorkspaceAppVisibleInDefaultLaunchers,
10
11
  isWorkspaceSsoApp,
11
12
  mergeChatFirstWorkspaceApps,
@@ -148,6 +149,38 @@ describe("workspace app routes", () => {
148
149
  expect(workspaceAppIdFromRoute("/apps/mail/inbox")).toBe("mail");
149
150
  });
150
151
 
152
+ it("recognizes the Dispatch page that hosts an app only on Dispatch's own origin", () => {
153
+ const origin = "https://beta.agent-workspace.builder.io";
154
+ const hostPage = { id: "coach", url: `${origin}/dispatch/apps/coach` };
155
+
156
+ expect(isWorkspaceAppHostPageUrl(hostPage, origin)).toBe(true);
157
+ expect(
158
+ isWorkspaceAppHostPageUrl(
159
+ { id: "Coach", url: `${origin}/dispatch/apps/coach/` },
160
+ origin,
161
+ ),
162
+ ).toBe(true);
163
+ expect(
164
+ isWorkspaceAppHostPageUrl(hostPage, "https://beta.dispatch.example.test"),
165
+ ).toBe(false);
166
+ expect(isWorkspaceAppHostPageUrl(hostPage, undefined)).toBe(false);
167
+ expect(
168
+ isWorkspaceAppHostPageUrl(
169
+ { id: "coach", url: `${origin}/coach` },
170
+ origin,
171
+ ),
172
+ ).toBe(false);
173
+ expect(
174
+ isWorkspaceAppHostPageUrl(
175
+ { id: "coach", url: `${origin}/dispatch/apps/mycoach` },
176
+ origin,
177
+ ),
178
+ ).toBe(false);
179
+ expect(isWorkspaceAppHostPageUrl({ id: "coach", url: null }, origin)).toBe(
180
+ false,
181
+ );
182
+ });
183
+
151
184
  it("preserves the initial app suffix for a standalone deep link", () => {
152
185
  expect(
153
186
  workspaceAppInitialPathFromSplat("inbox", "?view=unread", "#top"),
@@ -155,13 +188,39 @@ describe("workspace app routes", () => {
155
188
  expect(workspaceAppInitialPathFromSplat(undefined, "", "")).toBeUndefined();
156
189
  });
157
190
 
191
+ it("restores a child route from the flat Workspace app shell query", () => {
192
+ expect(
193
+ workspaceAppInitialPathFromSplat(
194
+ undefined,
195
+ "?__workspace_app_path=%2Ftriage%3Fview%3Dopen%23latest",
196
+ "",
197
+ ),
198
+ ).toBe("/triage?view=open#latest");
199
+ expect(
200
+ workspaceAppInitialPathFromSplat(
201
+ undefined,
202
+ "?__workspace_app_path=%2F%2Fevil.example",
203
+ "",
204
+ ),
205
+ ).toBeUndefined();
206
+ });
207
+
158
208
  it("maps child routes to shareable Dispatch app routes", () => {
209
+ const childPath = "/foobar?mode=edit#canvas";
210
+ const route = workspaceAppRouteForChildPath(
211
+ { id: "design", path: "/", url: "https://design.example.test" },
212
+ childPath,
213
+ );
214
+ expect(route).toBe(
215
+ "/apps/design?__workspace_app_path=%2Ffoobar%3Fmode%3Dedit%23canvas",
216
+ );
159
217
  expect(
160
- workspaceAppRouteForChildPath(
161
- { id: "design", path: "/", url: "https://design.example.test" },
162
- "/foobar?mode=edit#canvas",
218
+ workspaceAppInitialPathFromSplat(
219
+ undefined,
220
+ new URL(route!, "https://dispatch.example.test").search,
221
+ "",
163
222
  ),
164
- ).toBe("/apps/design/foobar?mode=edit#canvas");
223
+ ).toBe(childPath);
165
224
  expect(
166
225
  workspaceAppRouteForChildPath(
167
226
  {
@@ -171,7 +230,7 @@ describe("workspace app routes", () => {
171
230
  },
172
231
  "/design/foobar",
173
232
  ),
174
- ).toBe("/apps/internal-design/foobar");
233
+ ).toBe("/apps/internal-design?__workspace_app_path=%2Ffoobar");
175
234
  expect(
176
235
  workspaceAppRouteForChildPath(
177
236
  { id: "design", path: "/", url: "https://design.example.test" },
@@ -9,11 +9,14 @@ import {
9
9
  withBuilderUtmTrackingParams,
10
10
  } from "@agent-native/core/shared";
11
11
 
12
+ import { workspaceAppRoute } from "../shared/workspace-app-route";
12
13
  import {
13
14
  CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
14
15
  isWorkspaceSsoAppUrl,
15
16
  } from "../shared/workspace-sso";
16
17
 
18
+ export { workspaceAppRoute } from "../shared/workspace-app-route";
19
+
17
20
  export interface WorkspaceAppSummary {
18
21
  id: string;
19
22
  name: string;
@@ -220,10 +223,6 @@ export function defaultWorkspaceAppUrl(rawUrl: string): string {
220
223
  }
221
224
  }
222
225
 
223
- export function workspaceAppRoute(appId: string): string {
224
- return `/apps/${encodeURIComponent(appId)}`;
225
- }
226
-
227
226
  function normalizeWorkspaceAppRoutePath(rawPath: string): URL | null {
228
227
  if (
229
228
  typeof rawPath !== "string" ||
@@ -281,8 +280,10 @@ export function workspaceAppRouteForChildPath(
281
280
  ? parsed.pathname.slice(mountPath.length) || "/"
282
281
  : parsed.pathname;
283
282
  const routePath = workspaceAppRoute(app.id);
284
- const suffix = relativePathname === "/" ? "" : relativePathname;
285
- return `${routePath}${suffix}${parsed.search}${parsed.hash}`;
283
+ const childRoute = `${relativePathname}${parsed.search}${parsed.hash}`;
284
+ if (childRoute === "/") return routePath;
285
+ const search = new URLSearchParams([["__workspace_app_path", childRoute]]);
286
+ return `${routePath}?${search}`;
286
287
  }
287
288
 
288
289
  export function workspaceAppInitialPathFromSplat(
@@ -290,6 +291,14 @@ export function workspaceAppInitialPathFromSplat(
290
291
  search: string,
291
292
  hash: string,
292
293
  ): string | undefined {
294
+ const appPath = new URLSearchParams(search).get("__workspace_app_path");
295
+ if (appPath !== null) {
296
+ const parsed = normalizeWorkspaceAppRoutePath(appPath);
297
+ if (!parsed) return undefined;
298
+ const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
299
+ return path === "/" ? undefined : path;
300
+ }
301
+
293
302
  const pathname = routeSplat?.trim()
294
303
  ? `/${routeSplat.trim().replace(/^[/\\]+/, "")}`
295
304
  : "/";
@@ -311,6 +320,27 @@ export function workspaceAppIdFromRoute(pathname: string): string | null {
311
320
  }
312
321
  }
313
322
 
323
+ // A mounted app's registry `url` is the Dispatch page that hosts it
324
+ // (`<dispatch>/apps/<id>`). Read from that same Dispatch, it is the page doing
325
+ // the framing, so framing it again nests Dispatch inside itself.
326
+ export function isWorkspaceAppHostPageUrl(
327
+ app: Pick<WorkspaceAppSummary, "id" | "url">,
328
+ dispatchOrigin: string | undefined,
329
+ ): boolean {
330
+ const rawUrl = app.url?.trim();
331
+ if (!rawUrl || !dispatchOrigin) return false;
332
+ let url: URL;
333
+ try {
334
+ url = new URL(rawUrl);
335
+ } catch {
336
+ // coercion-ok: a URL that does not parse is not Dispatch's page for the app.
337
+ return false;
338
+ }
339
+ if (url.origin !== dispatchOrigin) return false;
340
+ const pathname = url.pathname.replace(/\/+$/, "").toLowerCase();
341
+ return pathname.endsWith(workspaceAppRoute(app.id.trim()).toLowerCase());
342
+ }
343
+
314
344
  export function workspaceAppHref(app: WorkspaceAppSummary): string | null {
315
345
  if (app.status === "pending") {
316
346
  return app.builderUrl
@@ -36,10 +36,13 @@ vi.mock("../../lib/workspace-apps", () => ({
36
36
  routeSplat: string | undefined,
37
37
  search: string,
38
38
  hash: string,
39
- ) =>
40
- routeSplat || search || hash
39
+ ) => {
40
+ const appPath = new URLSearchParams(search).get("__workspace_app_path");
41
+ if (appPath !== null) return appPath;
42
+ return routeSplat || search || hash
41
43
  ? `/${routeSplat ?? ""}${search}${hash}`
42
- : undefined,
44
+ : undefined;
45
+ },
43
46
  }));
44
47
 
45
48
  import WorkspaceAppRoute from "./apps.$appId";
@@ -112,4 +115,19 @@ describe("WorkspaceAppRoute", () => {
112
115
  replace: true,
113
116
  });
114
117
  });
118
+
119
+ it("passes a flat-shell child path from the workspace route query to the host", async () => {
120
+ routeState.routeSplat = undefined;
121
+ routeState.location = {
122
+ pathname: "/apps/mail",
123
+ search: "?__workspace_app_path=%2Ftriage%3Fview%3Dopen%23latest",
124
+ hash: "",
125
+ };
126
+
127
+ await act(async () => {
128
+ root.render(<WorkspaceAppRoute />);
129
+ });
130
+
131
+ expect(routeState.hostProps?.initialPath).toBe("/triage?view=open#latest");
132
+ });
115
133
  });