@agent-native/dispatch 0.27.21 → 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 (36) 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/components/layout/Layout.d.ts.map +1 -1
  4. package/dist/components/layout/Layout.js +2 -7
  5. package/dist/components/layout/Layout.js.map +1 -1
  6. package/dist/components/workspace-app-host.d.ts +17 -0
  7. package/dist/components/workspace-app-host.d.ts.map +1 -1
  8. package/dist/components/workspace-app-host.js +90 -8
  9. package/dist/components/workspace-app-host.js.map +1 -1
  10. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  11. package/dist/server/lib/app-creation-store.js +0 -1
  12. package/dist/server/lib/app-creation-store.js.map +1 -1
  13. package/dist/server/lib/workspace-app-chat-proxy.d.ts +52 -0
  14. package/dist/server/lib/workspace-app-chat-proxy.d.ts.map +1 -0
  15. package/dist/server/lib/workspace-app-chat-proxy.js +306 -0
  16. package/dist/server/lib/workspace-app-chat-proxy.js.map +1 -0
  17. package/dist/server/plugins/core-routes.d.ts +3 -2
  18. package/dist/server/plugins/core-routes.d.ts.map +1 -1
  19. package/dist/server/plugins/core-routes.js +12 -4
  20. package/dist/server/plugins/core-routes.js.map +1 -1
  21. package/dist/shared/workspace-app-chat.d.ts +23 -0
  22. package/dist/shared/workspace-app-chat.d.ts.map +1 -0
  23. package/dist/shared/workspace-app-chat.js +45 -0
  24. package/dist/shared/workspace-app-chat.js.map +1 -0
  25. package/package.json +2 -2
  26. package/src/actions/update-workspace-app-metadata.ts +1 -1
  27. package/src/components/layout/Layout.app-chat.spec.ts +22 -13
  28. package/src/components/layout/Layout.tsx +5 -18
  29. package/src/components/workspace-app-chat-rail.spec.tsx +182 -0
  30. package/src/components/workspace-app-host.tsx +172 -21
  31. package/src/server/lib/app-creation-store.spec.ts +32 -20
  32. package/src/server/lib/app-creation-store.ts +0 -1
  33. package/src/server/lib/workspace-app-chat-proxy.spec.ts +322 -0
  34. package/src/server/lib/workspace-app-chat-proxy.ts +389 -0
  35. package/src/server/plugins/core-routes.ts +21 -4
  36. package/src/shared/workspace-app-chat.ts +45 -0
@@ -125,6 +125,7 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from "../ui/sheet";
125
125
  import { Skeleton } from "../ui/skeleton";
126
126
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
127
127
  import {
128
+ WorkspaceAppChatRail,
128
129
  WorkspaceAppFrame,
129
130
  WorkspaceAppKeepAlive,
130
131
  } from "../workspace-app-host";
@@ -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
+ });
@@ -1,4 +1,5 @@
1
1
  import { AgentSidebar } from "@agent-native/core/client/agent-chat";
2
+ import { agentNativePath } from "@agent-native/core/client/api-path";
2
3
  import {
3
4
  ChatFirstAppPane,
4
5
  defaultChatFirstCopy,
@@ -11,9 +12,20 @@ import {
11
12
  } from "@agent-native/core/client/hooks";
12
13
  import { useT } from "@agent-native/core/client/i18n";
13
14
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
14
- import { IconArrowLeft, IconClockHour4 } from "@tabler/icons-react";
15
+ import {
16
+ IconAlertTriangle,
17
+ IconArrowLeft,
18
+ IconClockHour4,
19
+ } from "@tabler/icons-react";
15
20
  import { useTheme } from "next-themes";
16
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
+ import {
22
+ useCallback,
23
+ useEffect,
24
+ useMemo,
25
+ useRef,
26
+ useState,
27
+ type ReactNode,
28
+ } from "react";
17
29
  import { Link } from "react-router";
18
30
 
19
31
  import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
@@ -25,7 +37,9 @@ import {
25
37
  type WorkspaceAppSummary,
26
38
  } from "../lib/workspace-apps";
27
39
  import { DISPATCH_WORKSPACE_SSO_FLAG } from "../shared/feature-flags";
40
+ import { workspaceAppChatProxyPath } from "../shared/workspace-app-chat";
28
41
  import { ActionQueryError } from "./action-query-error";
42
+ import { Alert, AlertDescription } from "./ui/alert";
29
43
  import { Badge } from "./ui/badge";
30
44
  import { Button } from "./ui/button";
31
45
  import { Skeleton } from "./ui/skeleton";
@@ -67,6 +81,160 @@ export function buildChatFirstEmbedSessionInput(
67
81
  return { app: appId, path, chrome: "minimal" };
68
82
  }
69
83
 
84
+ async function readWorkspaceAppChatProxyError(
85
+ response: Response,
86
+ ): Promise<string> {
87
+ let body: string;
88
+ try {
89
+ body = await response.text();
90
+ } catch {
91
+ // coercion-ok: an unreadable body is reported as such, not as an empty error.
92
+ return `Agent chat proxy returned ${response.status} with an unreadable body.`;
93
+ }
94
+ try {
95
+ const parsed = JSON.parse(body) as { error?: unknown };
96
+ if (typeof parsed.error === "string" && parsed.error) return parsed.error;
97
+ } catch {
98
+ // coercion-ok: a non-JSON body is still reportable as the status line.
99
+ }
100
+ return body.trim() || `Agent chat proxy returned ${response.status}.`;
101
+ }
102
+
103
+ /**
104
+ * Point the app pane's chat rail at the app's OWN agent through the Dispatch
105
+ * proxy, and prove the proxy answers before claiming it works. A rail that
106
+ * quietly fell back to Dispatch's agent would look identical while running the
107
+ * wrong tools, instructions, and app resources, so a failed probe is a visible
108
+ * error state instead.
109
+ */
110
+ function useWorkspaceAppChatApi(appId: string) {
111
+ const apiUrl = useMemo(
112
+ () => agentNativePath(workspaceAppChatProxyPath(appId)),
113
+ [appId],
114
+ );
115
+ const [attempt, setAttempt] = useState(0);
116
+ const [unavailable, setUnavailable] = useState(false);
117
+
118
+ useEffect(() => {
119
+ let cancelled = false;
120
+ setUnavailable(false);
121
+ // `/mode` is the app's own dev-mode surface: reaching it proves the proxy
122
+ // minted an app session and the app's agent-chat routes answer.
123
+ void fetch(`${apiUrl}/mode`, { credentials: "include" })
124
+ .then(async (response) => {
125
+ if (response.ok) return;
126
+ throw new Error(await readWorkspaceAppChatProxyError(response));
127
+ })
128
+ .catch((cause: unknown) => {
129
+ if (cancelled) return;
130
+ console.warn(
131
+ `[dispatch] app chat proxy unavailable for ${appId}`,
132
+ cause,
133
+ );
134
+ setUnavailable(true);
135
+ });
136
+ return () => {
137
+ cancelled = true;
138
+ };
139
+ }, [apiUrl, appId, attempt]);
140
+
141
+ return {
142
+ apiUrl,
143
+ unavailable,
144
+ retry: useCallback(() => setAttempt((value) => value + 1), []),
145
+ };
146
+ }
147
+
148
+ export interface WorkspaceAppChatRailProps {
149
+ appId: string;
150
+ appName: string;
151
+ children: ReactNode;
152
+ copy?: ChatFirstCopy;
153
+ agentPageHref?: string;
154
+ onFullscreenRequest?: () => void;
155
+ }
156
+
157
+ /**
158
+ * The chat beside an open workspace app. Every surface that hosts an app pane
159
+ * must go through here so the rail is always the app's own agent — same tools,
160
+ * AGENTS.md, skills, app-scoped resources, and dev-mode surface as the app's
161
+ * native chat — and so an unreachable app is one visible error state rather
162
+ * than a per-surface silent handoff back to Dispatch's agent.
163
+ */
164
+ export function WorkspaceAppChatRail({
165
+ appId,
166
+ appName,
167
+ children,
168
+ copy = defaultChatFirstCopy,
169
+ agentPageHref,
170
+ onFullscreenRequest,
171
+ }: WorkspaceAppChatRailProps) {
172
+ const t = useT();
173
+ const appChat = useWorkspaceAppChatApi(appId);
174
+
175
+ if (appChat.unavailable) {
176
+ return (
177
+ <div className="flex h-full min-h-0">
178
+ <div
179
+ data-dispatch-app-chat-unavailable
180
+ className="w-88 shrink-0 overflow-auto border-r p-4"
181
+ >
182
+ <Alert variant="destructive">
183
+ <IconAlertTriangle className="size-4" />
184
+ <AlertDescription className="flex flex-wrap items-center justify-between gap-3">
185
+ <span>
186
+ {t("dispatch.pages.appChatUnavailable", {
187
+ defaultValue:
188
+ "Dispatch could not connect to {{name}}'s agent, so its chat is unavailable here.",
189
+ name: appName,
190
+ })}
191
+ </span>
192
+ <Button
193
+ type="button"
194
+ variant="outline"
195
+ size="sm"
196
+ onClick={appChat.retry}
197
+ >
198
+ {copy("retry")}
199
+ </Button>
200
+ </AlertDescription>
201
+ </Alert>
202
+ </div>
203
+ <div className="min-w-0 flex-1">{children}</div>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ return (
209
+ <AgentSidebar
210
+ position="left"
211
+ defaultOpen
212
+ openStorageKey="dispatch-app-chat"
213
+ storageKey={`dispatch-app-chat:${appId}`}
214
+ scope={{
215
+ type: "workspace-app",
216
+ id: appId,
217
+ label: appName,
218
+ contextKey: `workspace-app:${appId}`,
219
+ }}
220
+ // The app's own server answers this chat, so its tools, AGENTS.md,
221
+ // skills, app-scoped resources, and dev-mode surface are the real ones
222
+ // rather than a copy maintained inside Dispatch.
223
+ apiUrl={appChat.apiUrl}
224
+ agentChatSurface="app"
225
+ showTabBar
226
+ suppressInlineOpenApp
227
+ dynamicSuggestions={false}
228
+ suggestions={[]}
229
+ emptyStateText={`Ask about ${appName}`}
230
+ {...(agentPageHref ? { agentPageHref } : {})}
231
+ {...(onFullscreenRequest ? { onFullscreenRequest } : {})}
232
+ >
233
+ {children}
234
+ </AgentSidebar>
235
+ );
236
+ }
237
+
70
238
  export interface WorkspaceAppFrameApp {
71
239
  id: string;
72
240
  name: string;
@@ -250,26 +418,9 @@ export function WorkspaceAppFrame({
250
418
  if (!chatSidebar) return appPane;
251
419
 
252
420
  return (
253
- <AgentSidebar
254
- position="left"
255
- defaultOpen
256
- openStorageKey="dispatch-app-chat"
257
- storageKey={`dispatch-app-chat:${app.id}`}
258
- scope={{
259
- type: "workspace-app",
260
- id: app.id,
261
- label: app.name,
262
- contextKey: `workspace-app:${app.id}`,
263
- }}
264
- agentChatSurface="app"
265
- showTabBar
266
- suppressInlineOpenApp
267
- dynamicSuggestions={false}
268
- suggestions={[]}
269
- emptyStateText={`Ask about ${app.name}`}
270
- >
421
+ <WorkspaceAppChatRail appId={app.id} appName={app.name} copy={copy}>
271
422
  {appPane}
272
- </AgentSidebar>
423
+ </WorkspaceAppChatRail>
273
424
  );
274
425
  }
275
426
 
@@ -504,31 +504,43 @@ describe("listWorkspaceApps", () => {
504
504
  }),
505
505
  },
506
506
  });
507
- expect(mocks.getDbExec).toHaveBeenCalled();
508
507
  });
509
508
 
510
- it("blocks non-admin workspace members from updating app display metadata", async () => {
509
+ it("lets workspace members update app display metadata", async () => {
511
510
  mocks.state.orgRole = "member";
512
511
  stubNoPendingContext();
513
- stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
514
-
515
- await expect(
516
- runWithRequestContext(
517
- { userEmail: "dev@example.test", orgId: "org-123" },
518
- () =>
519
- updateWorkspaceAppMetadata({
520
- appId: "todo",
521
- name: "Todo Board",
522
- }),
523
- ),
524
- ).rejects.toMatchObject({
525
- message:
526
- "Only organization owners and admins can update app creation settings.",
527
- statusCode: 403,
512
+ stubManifest([
513
+ {
514
+ id: "todo",
515
+ name: "Todo",
516
+ description: "Original description",
517
+ path: "/todo",
518
+ },
519
+ ]);
520
+
521
+ const updated = await runWithRequestContext(
522
+ { userEmail: "dev@example.test", orgId: "org-123" },
523
+ () =>
524
+ updateWorkspaceAppMetadata({
525
+ appId: "todo",
526
+ name: "Todo Board",
527
+ description: "Tracks team work.",
528
+ }),
529
+ );
530
+
531
+ expect(updated).toMatchObject({
532
+ name: "Todo Board",
533
+ description: "Tracks team work.",
534
+ });
535
+ expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
536
+ apps: {
537
+ todo: expect.objectContaining({
538
+ name: "Todo Board",
539
+ description: "Tracks team work.",
540
+ updatedBy: "dev@example.test",
541
+ }),
542
+ },
528
543
  });
529
- expect(
530
- mocks.settings.get("workspace-app-metadata:org:org-123"),
531
- ).toBeUndefined();
532
544
  });
533
545
 
534
546
  it("generates a concise seed description from an app prompt", () => {
@@ -1355,7 +1355,6 @@ export async function updateWorkspaceAppMetadata(input: {
1355
1355
  name?: string | null;
1356
1356
  description?: string | null;
1357
1357
  }): Promise<WorkspaceAppSummary> {
1358
- await assertCanManageAppCreationSettings();
1359
1358
  const appId = input.appId.trim();
1360
1359
  assertValidWorkspaceAppId(appId);
1361
1360