@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.
- package/dist/actions/update-workspace-app-metadata.js +1 -1
- package/dist/actions/update-workspace-app-metadata.js.map +1 -1
- package/dist/actions/view-screen.js +50 -1
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +3 -8
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-host.d.ts +17 -0
- package/dist/components/workspace-app-host.d.ts.map +1 -1
- package/dist/components/workspace-app-host.js +90 -8
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/hooks/use-navigation-state.d.ts +2 -0
- package/dist/hooks/use-navigation-state.d.ts.map +1 -1
- package/dist/hooks/use-navigation-state.js +18 -0
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +0 -1
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/workspace-app-chat-proxy.d.ts +52 -0
- package/dist/server/lib/workspace-app-chat-proxy.d.ts.map +1 -0
- package/dist/server/lib/workspace-app-chat-proxy.js +306 -0
- package/dist/server/lib/workspace-app-chat-proxy.js.map +1 -0
- package/dist/server/plugins/core-routes.d.ts +3 -2
- package/dist/server/plugins/core-routes.d.ts.map +1 -1
- package/dist/server/plugins/core-routes.js +12 -4
- package/dist/server/plugins/core-routes.js.map +1 -1
- package/dist/shared/chat-first-pane.d.ts +8 -0
- package/dist/shared/chat-first-pane.d.ts.map +1 -0
- package/dist/shared/chat-first-pane.js +8 -0
- package/dist/shared/chat-first-pane.js.map +1 -0
- package/dist/shared/workspace-app-chat.d.ts +23 -0
- package/dist/shared/workspace-app-chat.d.ts.map +1 -0
- package/dist/shared/workspace-app-chat.js +45 -0
- package/dist/shared/workspace-app-chat.js.map +1 -0
- package/package.json +2 -2
- package/src/actions/update-workspace-app-metadata.ts +1 -1
- package/src/actions/view-screen.spec.ts +93 -1
- package/src/actions/view-screen.ts +74 -1
- package/src/components/layout/Layout.app-chat.spec.ts +22 -13
- package/src/components/layout/Layout.tsx +6 -19
- package/src/components/workspace-app-chat-rail.spec.tsx +182 -0
- package/src/components/workspace-app-host.tsx +172 -21
- package/src/hooks/use-navigation-state.spec.ts +33 -0
- package/src/hooks/use-navigation-state.ts +28 -1
- package/src/server/lib/app-creation-store.spec.ts +32 -20
- package/src/server/lib/app-creation-store.ts +0 -1
- package/src/server/lib/workspace-app-chat-proxy.spec.ts +322 -0
- package/src/server/lib/workspace-app-chat-proxy.ts +389 -0
- package/src/server/plugins/core-routes.ts +21 -4
- package/src/shared/chat-first-pane.ts +7 -0
- 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 () =>
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
expect(
|
|
17
|
-
expect(
|
|
18
|
-
expect(
|
|
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
|
-
<
|
|
2353
|
-
|
|
2354
|
-
|
|
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
|
-
</
|
|
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
|
+
});
|