@agent-native/dispatch 0.33.1-nightly-20260902192636 → 0.33.1

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.
@@ -2,7 +2,7 @@
2
2
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { MemoryRouter } from "react-router";
5
+ import { MemoryRouter, useLocation } from "react-router";
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
 
8
8
  import { TooltipProvider } from "./ui/tooltip";
@@ -54,7 +54,13 @@ vi.mock("@agent-native/core/client/i18n", () => ({
54
54
 
55
55
  const frameState = vi.hoisted(() => ({ inBuilderFrame: false }));
56
56
 
57
+ function LocationProbe() {
58
+ const location = useLocation();
59
+ return <output data-location-path>{location.pathname}</output>;
60
+ }
61
+
57
62
  vi.mock("@agent-native/core/client/host", () => ({
63
+ getClientSurface: () => "web",
58
64
  isInBuilderFrame: () => frameState.inBuilderFrame,
59
65
  }));
60
66
 
@@ -259,6 +265,68 @@ describe("WorkspaceAppCard", () => {
259
265
  }
260
266
  });
261
267
 
268
+ it("falls back to the Dispatch route when top-window navigation is blocked", async () => {
269
+ frameState.inBuilderFrame = true;
270
+ const originalParent = window.parent;
271
+ const originalTop = window.top;
272
+ const location = {} as Location;
273
+ Object.defineProperty(location, "href", {
274
+ configurable: true,
275
+ set: () => {
276
+ throw new Error("Top navigation is blocked");
277
+ },
278
+ });
279
+ const topWindow = { location } as unknown as Window;
280
+ Object.defineProperty(window, "parent", {
281
+ configurable: true,
282
+ value: {},
283
+ });
284
+ Object.defineProperty(window, "top", {
285
+ configurable: true,
286
+ value: topWindow,
287
+ });
288
+
289
+ try {
290
+ await act(async () => {
291
+ root.render(
292
+ <MemoryRouter>
293
+ <LocationProbe />
294
+ <TooltipProvider>
295
+ <WorkspaceAppCard
296
+ app={{
297
+ id: "feedback-leaderboard",
298
+ name: "Feedback leaderboard",
299
+ path: "/feedback-leaderboard",
300
+ url: "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
301
+ status: "ready",
302
+ }}
303
+ />
304
+ </TooltipProvider>
305
+ </MemoryRouter>,
306
+ );
307
+ });
308
+
309
+ await act(async () =>
310
+ container
311
+ .querySelector<HTMLButtonElement>(".app-open-actions__primary")
312
+ ?.click(),
313
+ );
314
+
315
+ expect(container.querySelector("[data-location-path]")?.textContent).toBe(
316
+ "/apps/feedback-leaderboard",
317
+ );
318
+ } finally {
319
+ Object.defineProperty(window, "parent", {
320
+ configurable: true,
321
+ value: originalParent,
322
+ });
323
+ Object.defineProperty(window, "top", {
324
+ configurable: true,
325
+ value: originalTop,
326
+ });
327
+ }
328
+ });
329
+
262
330
  it("opens Share from the app settings menu instead of the card actions", async () => {
263
331
  const animationFrameCallbacks: FrameRequestCallback[] = [];
264
332
  vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
@@ -380,9 +380,10 @@ function WorkspaceAppOpenActions({
380
380
  name={app.name}
381
381
  href={href}
382
382
  showNewTabOption
383
- onOpen={() =>
384
- openDirectly ? navigateToWorkspaceApp(href) : navigate(appRoute)
385
- }
383
+ onOpen={() => {
384
+ if (!openDirectly || navigateToWorkspaceApp(href)) return;
385
+ void navigate(appRoute);
386
+ }}
386
387
  menuItems={
387
388
  onTogglePinned
388
389
  ? [
@@ -38,6 +38,7 @@ const clientState = vi.hoisted(() => {
38
38
  legacyMutateError: null as Error | null,
39
39
  legacyErrorMutateAsync,
40
40
  inBuilderFrame: false,
41
+ clientSurface: "web" as "web" | "electron" | "tauri",
41
42
  frameLoadHandler: null as (() => void) | null,
42
43
  suppressFrameLoad: false,
43
44
  theme: "dark" as "dark" | "light",
@@ -95,6 +96,7 @@ vi.mock("@agent-native/core/client/feature-flags", () => ({
95
96
  }));
96
97
 
97
98
  vi.mock("@agent-native/core/client/host", () => ({
99
+ getClientSurface: () => clientState.clientSurface,
98
100
  isInBuilderFrame: () => clientState.inBuilderFrame,
99
101
  }));
100
102
 
@@ -190,6 +192,7 @@ describe("WorkspaceAppKeepAlive", () => {
190
192
  clientState.legacyMutateError = null;
191
193
  clientState.legacyErrorMutateAsync.mockReset();
192
194
  clientState.inBuilderFrame = false;
195
+ clientState.clientSurface = "web";
193
196
  clientState.frameLoadHandler = null;
194
197
  clientState.suppressFrameLoad = false;
195
198
  clientState.workspaceSsoMutateAsync.mockClear();
@@ -663,6 +666,27 @@ describe("WorkspaceAppKeepAlive", () => {
663
666
  expect(container.querySelector("iframe")).not.toBeNull();
664
667
  });
665
668
 
669
+ it("opens the app in the top window for a native desktop host", async () => {
670
+ const topWindow = { location: { href: "" } } as unknown as Window;
671
+ const expectedUrl = new URL("/mail", window.location.href).href;
672
+ clientState.clientSurface = "electron";
673
+ Object.defineProperty(window, "top", {
674
+ configurable: true,
675
+ value: topWindow,
676
+ });
677
+
678
+ await act(async () => {
679
+ root.render(
680
+ <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
681
+ );
682
+ await Promise.resolve();
683
+ });
684
+
685
+ expect(topWindow.location.href).toBe(expectedUrl);
686
+ expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
687
+ expect(container.querySelector("iframe")).toBeNull();
688
+ });
689
+
666
690
  it("falls back to the embedded app when top-window navigation is blocked", async () => {
667
691
  const navigateToTopWindow = vi.fn(() => false);
668
692
  clientState.inBuilderFrame = true;
@@ -8,6 +8,15 @@ import {
8
8
  workspaceAppEmbedTarget,
9
9
  } from "./workspace-apps";
10
10
 
11
+ const defaultUserAgent = navigator.userAgent;
12
+
13
+ function setUserAgent(userAgent: string) {
14
+ Object.defineProperty(navigator, "userAgent", {
15
+ configurable: true,
16
+ value: userAgent,
17
+ });
18
+ }
19
+
11
20
  function setAncestorOrigin(origin: string | null) {
12
21
  Object.defineProperty(window.location, "ancestorOrigins", {
13
22
  configurable: true,
@@ -88,6 +97,7 @@ describe("navigateToWorkspaceApp", () => {
88
97
  afterEach(() => {
89
98
  window.history.replaceState({}, "", "/");
90
99
  setAncestorOrigin(null);
100
+ setUserAgent(defaultUserAgent);
91
101
  Object.defineProperty(window, "parent", {
92
102
  configurable: true,
93
103
  value: window,
@@ -107,6 +117,32 @@ describe("navigateToWorkspaceApp", () => {
107
117
  expect(shouldOpenWorkspaceAppInTopWindow()).toBe(false);
108
118
  });
109
119
 
120
+ it("keeps unrelated Electron webviews inline", () => {
121
+ setUserAgent("Mozilla/5.0 Electron/38.0");
122
+
123
+ expect(shouldOpenWorkspaceAppInTopWindow()).toBe(false);
124
+ });
125
+
126
+ it("ignores a generic Electron preload global", () => {
127
+ setUserAgent("Mozilla/5.0 Electron/38.0");
128
+ Object.defineProperty(window, "electronAPI", {
129
+ configurable: true,
130
+ value: {},
131
+ });
132
+
133
+ try {
134
+ expect(shouldOpenWorkspaceAppInTopWindow()).toBe(false);
135
+ } finally {
136
+ delete (window as unknown as { electronAPI?: unknown }).electronAPI;
137
+ }
138
+ });
139
+
140
+ it("uses the top window in known native desktop hosts", () => {
141
+ setUserAgent("Mozilla/5.0 Electron/38.0 ChatGPT/1.0");
142
+
143
+ expect(shouldOpenWorkspaceAppInTopWindow()).toBe(true);
144
+ });
145
+
110
146
  it("keeps the hosted Dispatch shell inline", () => {
111
147
  Object.defineProperty(window, "parent", {
112
148
  configurable: true,
@@ -1,5 +1,8 @@
1
1
  import { CHAT_FIRST_DEFAULT_APP_IDS } from "@agent-native/core/client/chat-first";
2
- import { isInBuilderFrame } from "@agent-native/core/client/host";
2
+ import {
3
+ getClientSurface,
4
+ isInBuilderFrame,
5
+ } from "@agent-native/core/client/host";
3
6
  import {
4
7
  resolveEnvironmentTargets,
5
8
  withBuilderUtmTrackingParams,
@@ -376,9 +379,9 @@ export function isPendingBuilderHref(app: WorkspaceAppSummary): boolean {
376
379
 
377
380
  export function shouldOpenWorkspaceAppInTopWindow(): boolean {
378
381
  if (typeof window === "undefined") return false;
379
- // A generic iframe is an inline host by design. Only Builder owns the
380
- // parent navigation contract for workspace apps.
381
- return isInBuilderFrame();
382
+ // Standard browser iframes stay inline; Builder and native shells need the
383
+ // app as the top-level document so browser APIs such as WebMCP bind to it.
384
+ return isInBuilderFrame() || getClientSurface() !== "web";
382
385
  }
383
386
 
384
387
  export function navigateToWorkspaceApp(href: string): boolean {