@agent-native/dispatch 0.27.19 → 0.28.0

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 (51) hide show
  1. package/dist/actions/update-workspace-app-metadata.js +1 -1
  2. package/dist/actions/update-workspace-app-metadata.js.map +1 -1
  3. package/dist/actions/view-screen.js +50 -1
  4. package/dist/actions/view-screen.js.map +1 -1
  5. package/dist/components/layout/Layout.d.ts.map +1 -1
  6. package/dist/components/layout/Layout.js +3 -8
  7. package/dist/components/layout/Layout.js.map +1 -1
  8. package/dist/components/workspace-app-host.d.ts +17 -0
  9. package/dist/components/workspace-app-host.d.ts.map +1 -1
  10. package/dist/components/workspace-app-host.js +90 -8
  11. package/dist/components/workspace-app-host.js.map +1 -1
  12. package/dist/hooks/use-navigation-state.d.ts +2 -0
  13. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  14. package/dist/hooks/use-navigation-state.js +18 -0
  15. package/dist/hooks/use-navigation-state.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 +0 -1
  18. package/dist/server/lib/app-creation-store.js.map +1 -1
  19. package/dist/server/lib/workspace-app-chat-proxy.d.ts +52 -0
  20. package/dist/server/lib/workspace-app-chat-proxy.d.ts.map +1 -0
  21. package/dist/server/lib/workspace-app-chat-proxy.js +306 -0
  22. package/dist/server/lib/workspace-app-chat-proxy.js.map +1 -0
  23. package/dist/server/plugins/core-routes.d.ts +3 -2
  24. package/dist/server/plugins/core-routes.d.ts.map +1 -1
  25. package/dist/server/plugins/core-routes.js +12 -4
  26. package/dist/server/plugins/core-routes.js.map +1 -1
  27. package/dist/shared/chat-first-pane.d.ts +8 -0
  28. package/dist/shared/chat-first-pane.d.ts.map +1 -0
  29. package/dist/shared/chat-first-pane.js +8 -0
  30. package/dist/shared/chat-first-pane.js.map +1 -0
  31. package/dist/shared/workspace-app-chat.d.ts +23 -0
  32. package/dist/shared/workspace-app-chat.d.ts.map +1 -0
  33. package/dist/shared/workspace-app-chat.js +45 -0
  34. package/dist/shared/workspace-app-chat.js.map +1 -0
  35. package/package.json +2 -2
  36. package/src/actions/update-workspace-app-metadata.ts +1 -1
  37. package/src/actions/view-screen.spec.ts +93 -1
  38. package/src/actions/view-screen.ts +74 -1
  39. package/src/components/layout/Layout.app-chat.spec.ts +22 -13
  40. package/src/components/layout/Layout.tsx +6 -19
  41. package/src/components/workspace-app-chat-rail.spec.tsx +182 -0
  42. package/src/components/workspace-app-host.tsx +172 -21
  43. package/src/hooks/use-navigation-state.spec.ts +33 -0
  44. package/src/hooks/use-navigation-state.ts +28 -1
  45. package/src/server/lib/app-creation-store.spec.ts +32 -20
  46. package/src/server/lib/app-creation-store.ts +0 -1
  47. package/src/server/lib/workspace-app-chat-proxy.spec.ts +322 -0
  48. package/src/server/lib/workspace-app-chat-proxy.ts +389 -0
  49. package/src/server/plugins/core-routes.ts +21 -4
  50. package/src/shared/chat-first-pane.ts +7 -0
  51. package/src/shared/workspace-app-chat.ts +45 -0
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  const mocks = vi.hoisted(() => ({
4
4
  navigation: {} as Record<string, unknown>,
5
+ appState: {} as Record<string, unknown>,
5
6
  listAgentRunFailures: vi.fn(),
6
7
  listThreadDebugSources: vi.fn(),
7
8
  listWorkspaceResourceOptions: vi.fn(),
@@ -12,7 +13,9 @@ vi.mock("@agent-native/core", () => ({
12
13
  }));
13
14
 
14
15
  vi.mock("@agent-native/core/application-state", () => ({
15
- readAppState: vi.fn(async () => mocks.navigation),
16
+ readAppState: vi.fn(async (key: string) =>
17
+ key === "navigation" ? mocks.navigation : (mocks.appState[key] ?? null),
18
+ ),
16
19
  }));
17
20
 
18
21
  vi.mock("../server/lib/app-creation-store.js", () => ({
@@ -56,6 +59,7 @@ import viewScreen from "./view-screen.js";
56
59
  describe("view-screen Thread Debug summary", () => {
57
60
  beforeEach(() => {
58
61
  mocks.navigation = { view: "thread-debug" };
62
+ mocks.appState = {};
59
63
  mocks.listAgentRunFailures.mockReset();
60
64
  mocks.listAgentRunFailures.mockResolvedValue({ failures: [] });
61
65
  mocks.listThreadDebugSources.mockReset();
@@ -118,3 +122,91 @@ describe("view-screen Thread Debug summary", () => {
118
122
  });
119
123
  });
120
124
  });
125
+
126
+ describe("view-screen embedded workspace app", () => {
127
+ beforeEach(() => {
128
+ mocks.navigation = { view: "chat" };
129
+ mocks.appState = {};
130
+ mocks.listWorkspaceResourceOptions.mockReset();
131
+ mocks.listWorkspaceResourceOptions.mockResolvedValue([]);
132
+ });
133
+
134
+ it("names the app opened through an /apps/<id> route", async () => {
135
+ mocks.navigation = {
136
+ view: "workspace-app",
137
+ path: "/apps/mail/inbox",
138
+ workspaceAppId: "mail",
139
+ workspaceAppPath: "/inbox",
140
+ };
141
+
142
+ const result = JSON.parse(await viewScreen.run({}));
143
+
144
+ expect(result.embeddedApp).toEqual({
145
+ status: "open",
146
+ id: "mail",
147
+ path: "/inbox",
148
+ source: "route",
149
+ });
150
+ });
151
+
152
+ it("names the chat-first pane app while the route stays on /chat", async () => {
153
+ mocks.appState["chat-first-pane"] = {
154
+ appId: "mail",
155
+ path: "/inbox",
156
+ placement: "side",
157
+ };
158
+
159
+ const result = JSON.parse(await viewScreen.run({}));
160
+
161
+ expect(result.embeddedApp).toEqual({
162
+ status: "open",
163
+ id: "mail",
164
+ path: "/inbox",
165
+ source: "chat-first-pane",
166
+ });
167
+ });
168
+
169
+ it("keeps a pane's named screen when it carries no path", async () => {
170
+ mocks.appState["chat-first-pane"] = { appId: "mail", view: "inbox" };
171
+
172
+ const result = JSON.parse(await viewScreen.run({}));
173
+
174
+ expect(result.embeddedApp).toEqual({
175
+ status: "open",
176
+ id: "mail",
177
+ path: "/",
178
+ view: "inbox",
179
+ source: "chat-first-pane",
180
+ });
181
+ });
182
+
183
+ it("omits the block when no app is open", async () => {
184
+ const result = JSON.parse(await viewScreen.run({}));
185
+
186
+ expect(result.embeddedApp).toBeUndefined();
187
+ expect(result.chatSurface).toBeDefined();
188
+ });
189
+
190
+ it("reports an unreadable route app as unknown instead of the apps list", async () => {
191
+ mocks.navigation = { view: "workspace-app", path: "/apps/%E0%A4%A" };
192
+
193
+ const result = JSON.parse(await viewScreen.run({}));
194
+
195
+ expect(result.embeddedApp).toMatchObject({
196
+ status: "unknown",
197
+ source: "route",
198
+ });
199
+ expect(result.embeddedApp.reason).toContain("/apps/%E0%A4%A");
200
+ });
201
+
202
+ it("reports a pane that names no app as unknown, not as no app open", async () => {
203
+ mocks.appState["chat-first-pane"] = { placement: "side" };
204
+
205
+ const result = JSON.parse(await viewScreen.run({}));
206
+
207
+ expect(result.embeddedApp).toMatchObject({
208
+ status: "unknown",
209
+ source: "chat-first-pane",
210
+ });
211
+ });
212
+ });
@@ -32,6 +32,7 @@ import {
32
32
  listWorkspaceResourceOptions,
33
33
  listWorkspaceResourcesForApp,
34
34
  } from "../server/lib/workspace-resources-store.js";
35
+ import { CHAT_FIRST_PANE_STATE_KEY } from "../shared/chat-first-pane.js";
35
36
 
36
37
  async function runLocalDispatchAction(
37
38
  name: string,
@@ -67,9 +68,76 @@ function threadDebugFailureStatus(
67
68
  : "all";
68
69
  }
69
70
 
71
+ /**
72
+ * The workspace app Dispatch has embedded, or `null` when none is open. A
73
+ * surface that is showing an app whose identity could not be read reports
74
+ * `status: "unknown"` — never a plausible-looking id and never silence, so the
75
+ * agent can tell "no app open" from "an app is open and I cannot name it".
76
+ */
77
+ type EmbeddedApp =
78
+ | {
79
+ status: "open";
80
+ id: string;
81
+ /** Path inside the embedded app, not the Dispatch route. */
82
+ path: string;
83
+ /** Named screen the pane was opened at, when it carries one instead of a path. */
84
+ view?: string;
85
+ source: "route" | "chat-first-pane";
86
+ }
87
+ | { status: "unknown"; source: "route" | "chat-first-pane"; reason: string };
88
+
89
+ async function resolveEmbeddedApp(
90
+ navigation: Record<string, any> | null,
91
+ ): Promise<EmbeddedApp | null> {
92
+ if (navigation?.view === "workspace-app") {
93
+ const id =
94
+ typeof navigation.workspaceAppId === "string"
95
+ ? navigation.workspaceAppId.trim()
96
+ : "";
97
+ if (!id) {
98
+ return {
99
+ status: "unknown",
100
+ source: "route",
101
+ reason: `The route ${navigation.path ?? "/apps/…"} embeds a workspace app, but its id could not be read from the URL.`,
102
+ };
103
+ }
104
+ return {
105
+ status: "open",
106
+ id,
107
+ path:
108
+ typeof navigation.workspaceAppPath === "string"
109
+ ? navigation.workspaceAppPath
110
+ : "/",
111
+ source: "route",
112
+ };
113
+ }
114
+
115
+ // Chat-first mode keeps the route on /chat and opens the app as a surface
116
+ // tab, so the pane state is the only place the open app is named.
117
+ if (navigation?.view !== "chat") return null;
118
+ const pane = await readAppState(CHAT_FIRST_PANE_STATE_KEY);
119
+ if (pane === null) return null;
120
+ const appId = typeof pane.appId === "string" ? pane.appId.trim() : "";
121
+ if (!appId) {
122
+ return {
123
+ status: "unknown",
124
+ source: "chat-first-pane",
125
+ reason:
126
+ "A chat-first app pane is recorded, but its stored state does not name an app.",
127
+ };
128
+ }
129
+ return {
130
+ status: "open",
131
+ id: appId,
132
+ path: typeof pane.path === "string" && pane.path ? pane.path : "/",
133
+ ...(typeof pane.view === "string" && pane.view ? { view: pane.view } : {}),
134
+ source: "chat-first-pane",
135
+ };
136
+ }
137
+
70
138
  export default defineAction({
71
139
  description:
72
- "See what the user is currently looking at in the dispatch UI, including navigation state and a compact operational summary.",
140
+ "See what the user is currently looking at in the dispatch UI, including navigation state, any embedded workspace app, and a compact operational summary.",
73
141
  schema: z.object({}),
74
142
  http: false,
75
143
  run: async () => {
@@ -84,6 +152,10 @@ export default defineAction({
84
152
  approvalPolicy: overview.settings,
85
153
  };
86
154
  if (navigation) screen.navigation = navigation;
155
+
156
+ const embeddedApp = await resolveEmbeddedApp(navigation);
157
+ if (embeddedApp) screen.embeddedApp = embeddedApp;
158
+
87
159
  if (navigation?.view === "chat" || navigation?.view === "browser-chat") {
88
160
  screen.chatSurface = {
89
161
  view:
@@ -142,6 +214,7 @@ export default defineAction({
142
214
  navigation?.view === "overview" ||
143
215
  navigation?.view === "metrics" ||
144
216
  navigation?.view === "apps" ||
217
+ navigation?.view === "workspace-app" ||
145
218
  navigation?.view === "new-app"
146
219
  ) {
147
220
  const workspaceApps = await listWorkspaceApps({
@@ -2,21 +2,30 @@ import { readFileSync } from "node:fs";
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
4
 
5
- describe("Dispatch workspace app chat rail", () => {
6
- it("owns the iframe chat toggle and persists app-specific threads", () => {
7
- const source = readFileSync(
8
- new URL("./Layout.tsx", import.meta.url),
9
- "utf8",
10
- );
5
+ const layoutSource = readFileSync(
6
+ new URL("./Layout.tsx", import.meta.url),
7
+ "utf8",
8
+ );
9
+ const railSource = readFileSync(
10
+ new URL("../workspace-app-host.tsx", import.meta.url),
11
+ "utf8",
12
+ );
11
13
 
12
- expect(source).toContain('openStorageKey="dispatch-app-chat"');
13
- expect(source).toContain(
14
- "storageKey={`dispatch-app-chat:${workspaceAppId}`}",
15
- );
16
- expect(source).toContain("data-dispatch-workspace-app-frame");
17
- expect(source).toContain("chatSidebar");
18
- expect(source).toContain(
14
+ describe("Dispatch workspace app chat rail", () => {
15
+ it("routes the open-app rail through the shared app-chat component", () => {
16
+ // Both app surfaces must share one rail: a second inlined AgentSidebar is
17
+ // how one of them silently keeps talking to Dispatch's own agent.
18
+ expect(layoutSource).toContain("<WorkspaceAppChatRail");
19
+ expect(layoutSource).toContain("data-dispatch-workspace-app-frame");
20
+ expect(layoutSource).toContain(
19
21
  'window.dispatchEvent(new Event("agent-panel:toggle"))',
20
22
  );
23
+ expect(layoutSource).not.toContain('openStorageKey="dispatch-app-chat"');
24
+ });
25
+
26
+ it("persists app-specific threads and points the rail at the app's own agent", () => {
27
+ expect(railSource).toContain('openStorageKey="dispatch-app-chat"');
28
+ expect(railSource).toContain("storageKey={`dispatch-app-chat:${appId}`}");
29
+ expect(railSource).toContain("apiUrl={appChat.apiUrl}");
21
30
  });
22
31
  });
@@ -112,6 +112,7 @@ import {
112
112
  workspaceAppRoute,
113
113
  type WorkspaceAppSummary,
114
114
  } from "../../lib/workspace-apps";
115
+ import { CHAT_FIRST_PANE_STATE_KEY } from "../../shared/chat-first-pane";
115
116
  import { AppIcon } from "../app-icon";
116
117
  import { CreateAppPopover } from "../create-app-popover";
117
118
  import {
@@ -124,6 +125,7 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from "../ui/sheet";
124
125
  import { Skeleton } from "../ui/skeleton";
125
126
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
126
127
  import {
128
+ WorkspaceAppChatRail,
127
129
  WorkspaceAppFrame,
128
130
  WorkspaceAppKeepAlive,
129
131
  } from "../workspace-app-host";
@@ -215,7 +217,6 @@ const DISPATCH_SIDEBAR_LABEL = "Dispatch";
215
217
  const CHROMELESS_PATHS = ["/approval", "/browser-chat", "/browser-connect"];
216
218
  const SIDEBAR_COLLAPSE_KEY = "dispatch.sidebar.collapsed";
217
219
  const CHAT_HISTORY_SOURCE_KEY = "dispatch.chat-history.source";
218
- const CHAT_FIRST_PANE_STATE_KEY = "chat-first-pane";
219
220
 
220
221
  interface DispatchChatFirstPane {
221
222
  appId: string;
@@ -2349,28 +2350,14 @@ export function Layout({
2349
2350
  "Workspace app";
2350
2351
  const workspaceAppContent =
2351
2352
  workspaceAppRouteActive && workspaceAppId ? (
2352
- <AgentSidebar
2353
- position="left"
2354
- defaultOpen
2355
- openStorageKey="dispatch-app-chat"
2356
- storageKey={`dispatch-app-chat:${workspaceAppId}`}
2357
- scope={{
2358
- type: "workspace-app",
2359
- id: workspaceAppId,
2360
- label: workspaceAppChatName,
2361
- contextKey: `workspace-app:${workspaceAppId}`,
2362
- }}
2363
- agentChatSurface="app"
2364
- showTabBar
2365
- suppressInlineOpenApp
2366
- dynamicSuggestions={false}
2367
- suggestions={[]}
2368
- emptyStateText={`Ask about ${workspaceAppChatName}`}
2353
+ <WorkspaceAppChatRail
2354
+ appId={workspaceAppId}
2355
+ appName={workspaceAppChatName}
2369
2356
  agentPageHref={agentPageHref}
2370
2357
  onFullscreenRequest={openAskAgentFullscreen}
2371
2358
  >
2372
2359
  <WorkspaceAppKeepAlive activeAppId={workspaceAppId} />
2373
- </AgentSidebar>
2360
+ </WorkspaceAppChatRail>
2374
2361
  ) : (
2375
2362
  <WorkspaceAppKeepAlive
2376
2363
  activeAppId={workspaceAppRouteActive ? workspaceAppId : null}
@@ -0,0 +1,182 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const railState = vi.hoisted(() => ({
8
+ sidebarProps: [] as Array<Record<string, unknown>>,
9
+ fetchImpl: vi.fn(),
10
+ // Stable identity: the embed effect depends on it, so a fresh mock per
11
+ // render would re-run the effect forever.
12
+ mutateAsync: vi.fn().mockResolvedValue({ startUrl: "about:blank" }),
13
+ }));
14
+
15
+ vi.mock("@agent-native/core/client/agent-chat", () => ({
16
+ AgentSidebar: (props: Record<string, unknown>) => {
17
+ railState.sidebarProps.push(props);
18
+ return (
19
+ <div data-agent-sidebar data-api-url={String(props.apiUrl ?? "")}>
20
+ {props.children as React.ReactNode}
21
+ </div>
22
+ );
23
+ },
24
+ }));
25
+
26
+ vi.mock("@agent-native/core/client/api-path", () => ({
27
+ agentNativePath: (path: string) => path,
28
+ }));
29
+
30
+ vi.mock("@agent-native/core/client/chat-first", () => ({
31
+ ChatFirstAppPane: ({ app }: { app: { name: string } | null }) => (
32
+ <div data-app-pane>{app?.name}</div>
33
+ ),
34
+ defaultChatFirstCopy: (key: string) => key,
35
+ }));
36
+
37
+ vi.mock("@agent-native/core/client/feature-flags", () => ({
38
+ useFeatureFlag: () => false,
39
+ }));
40
+
41
+ vi.mock("@agent-native/core/client/hooks", () => ({
42
+ useActionMutation: () => ({ mutateAsync: railState.mutateAsync }),
43
+ useActionQuery: () => ({
44
+ data: [],
45
+ isError: false,
46
+ isLoading: false,
47
+ refetch: vi.fn(),
48
+ }),
49
+ }));
50
+
51
+ vi.mock("@agent-native/core/client/i18n", () => ({
52
+ useT: () => (key: string, options?: { defaultValue?: string }) =>
53
+ options?.defaultValue ?? key,
54
+ }));
55
+
56
+ vi.mock("next-themes", () => ({
57
+ useTheme: () => ({ resolvedTheme: "light" }),
58
+ }));
59
+
60
+ import { WorkspaceAppChatRail, WorkspaceAppFrame } from "./workspace-app-host";
61
+
62
+ function jsonResponse(status: number, body: unknown): Response {
63
+ return new Response(JSON.stringify(body), {
64
+ status,
65
+ headers: { "content-type": "application/json" },
66
+ });
67
+ }
68
+
69
+ describe("WorkspaceAppChatRail", () => {
70
+ let container: HTMLDivElement;
71
+ let root: Root;
72
+
73
+ beforeEach(() => {
74
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
75
+ railState.sidebarProps.length = 0;
76
+ railState.fetchImpl.mockReset();
77
+ vi.stubGlobal("fetch", railState.fetchImpl);
78
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
79
+ container = document.createElement("div");
80
+ document.body.appendChild(container);
81
+ root = createRoot(container);
82
+ });
83
+
84
+ afterEach(() => {
85
+ act(() => root.unmount());
86
+ container.remove();
87
+ vi.unstubAllGlobals();
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ async function render(node: React.ReactNode) {
92
+ await act(async () => {
93
+ root.render(node);
94
+ await Promise.resolve();
95
+ await Promise.resolve();
96
+ await Promise.resolve();
97
+ });
98
+ }
99
+
100
+ it("points the rail at the app's own agent through the Dispatch proxy", async () => {
101
+ railState.fetchImpl.mockResolvedValue(
102
+ jsonResponse(200, { devMode: false, canToggle: false }),
103
+ );
104
+
105
+ await render(
106
+ <WorkspaceAppChatRail appId="mail" appName="Mail">
107
+ <div data-app-surface />
108
+ </WorkspaceAppChatRail>,
109
+ );
110
+
111
+ expect(railState.fetchImpl).toHaveBeenCalledWith(
112
+ "/_agent-native/workspace-app-chat/mail/mode",
113
+ { credentials: "include" },
114
+ );
115
+ const sidebar = container.querySelector("[data-agent-sidebar]");
116
+ expect(sidebar?.getAttribute("data-api-url")).toBe(
117
+ "/_agent-native/workspace-app-chat/mail",
118
+ );
119
+ expect(container.querySelector("[data-app-surface]")).not.toBeNull();
120
+ });
121
+
122
+ it("shows an error instead of quietly answering from Dispatch's own agent", async () => {
123
+ railState.fetchImpl.mockResolvedValue(
124
+ jsonResponse(502, { error: "mail chat is unavailable: no app session" }),
125
+ );
126
+
127
+ await render(
128
+ <WorkspaceAppChatRail appId="mail" appName="Mail">
129
+ <div data-app-surface />
130
+ </WorkspaceAppChatRail>,
131
+ );
132
+
133
+ expect(
134
+ container.querySelector("[data-dispatch-app-chat-unavailable]"),
135
+ ).not.toBeNull();
136
+ // The rail is absent rather than falling back to a Dispatch-scoped chat,
137
+ // which would run the wrong tools and instructions while looking healthy.
138
+ expect(container.querySelector("[data-agent-sidebar]")).toBeNull();
139
+ expect(container.querySelector("[data-app-surface]")).not.toBeNull();
140
+ });
141
+
142
+ it("re-probes the proxy when the error state is retried", async () => {
143
+ railState.fetchImpl.mockResolvedValueOnce(
144
+ jsonResponse(502, { error: "temporarily unavailable" }),
145
+ );
146
+ railState.fetchImpl.mockResolvedValue(
147
+ jsonResponse(200, { devMode: false }),
148
+ );
149
+
150
+ await render(
151
+ <WorkspaceAppChatRail appId="mail" appName="Mail">
152
+ <div data-app-surface />
153
+ </WorkspaceAppChatRail>,
154
+ );
155
+ const retry = container.querySelector<HTMLButtonElement>(
156
+ "[data-dispatch-app-chat-unavailable] button",
157
+ );
158
+ expect(retry).not.toBeNull();
159
+
160
+ await act(async () => {
161
+ retry?.click();
162
+ await Promise.resolve();
163
+ await Promise.resolve();
164
+ await Promise.resolve();
165
+ });
166
+
167
+ expect(railState.fetchImpl).toHaveBeenCalledTimes(2);
168
+ expect(container.querySelector("[data-agent-sidebar]")).not.toBeNull();
169
+ });
170
+
171
+ it("leaves surfaces with no open app alone — no app rail and no proxy probe", async () => {
172
+ await render(
173
+ <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
174
+ );
175
+
176
+ expect(container.querySelector("[data-agent-sidebar]")).toBeNull();
177
+ expect(railState.fetchImpl).not.toHaveBeenCalledWith(
178
+ expect.stringContaining("workspace-app-chat"),
179
+ expect.anything(),
180
+ );
181
+ });
182
+ });