@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
@@ -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
 
@@ -42,6 +42,39 @@ describe("buildDispatchNavigationState", () => {
42
42
  });
43
43
  });
44
44
 
45
+ it("keeps the embedded app identity on workspace app routes", () => {
46
+ expect(buildDispatchNavigationState("/apps/mail")).toEqual({
47
+ view: "workspace-app",
48
+ path: "/apps/mail",
49
+ workspaceAppId: "mail",
50
+ workspaceAppPath: "/",
51
+ });
52
+ expect(buildDispatchNavigationState("/apps/mail/inbox")).toEqual({
53
+ view: "workspace-app",
54
+ path: "/apps/mail/inbox",
55
+ workspaceAppId: "mail",
56
+ workspaceAppPath: "/inbox",
57
+ });
58
+ });
59
+
60
+ it("still treats the apps index as the list", () => {
61
+ expect(buildDispatchNavigationState("/apps")).toEqual({
62
+ view: "apps",
63
+ path: "/apps",
64
+ });
65
+ expect(buildDispatchNavigationState("/apps/")).toEqual({
66
+ view: "apps",
67
+ path: "/apps/",
68
+ });
69
+ });
70
+
71
+ it("reports an undecodable app route as a workspace app with no id", () => {
72
+ expect(buildDispatchNavigationState("/apps/%E0%A4%A")).toEqual({
73
+ view: "workspace-app",
74
+ path: "/apps/%E0%A4%A",
75
+ });
76
+ });
77
+
45
78
  it("preserves dreams query context", () => {
46
79
  expect(
47
80
  buildDispatchNavigationState(
@@ -16,10 +16,16 @@ import type {
16
16
  DispatchExtensionConfig,
17
17
  DispatchNavItem,
18
18
  } from "../components/index.js";
19
+ import {
20
+ workspaceAppIdFromRoute,
21
+ workspaceAppRoute,
22
+ } from "../lib/workspace-apps.js";
19
23
 
20
24
  export interface NavigationState {
21
25
  view: string;
22
26
  path?: string;
27
+ workspaceAppId?: string;
28
+ workspaceAppPath?: string;
23
29
  extensionId?: string;
24
30
  extensionSlug?: string;
25
31
  dreamId?: string;
@@ -153,6 +159,14 @@ export function buildDispatchNavigationState(
153
159
  return state;
154
160
  }
155
161
 
162
+ if (state.view === "workspace-app") {
163
+ const workspaceAppId = workspaceAppIdFromRoute(pathname);
164
+ if (workspaceAppId) {
165
+ state.workspaceAppId = workspaceAppId;
166
+ state.workspaceAppPath = pathname.replace(/^\/apps\/[^/]+/, "") || "/";
167
+ }
168
+ }
169
+
156
170
  if (state.view === "dreams") {
157
171
  const params = new URLSearchParams(search);
158
172
  const dreamId = params.get("dreamId");
@@ -265,6 +279,12 @@ function resolveView(
265
279
  }
266
280
  if (pathname.startsWith("/browser-chat")) return "browser-chat";
267
281
  if (pathname.startsWith("/chat")) return "chat";
282
+ // A route below /apps/ always embeds one app, so it must not collapse to the
283
+ // apps list. An id that fails to decode still resolves here without a
284
+ // `workspaceAppId`, which view-screen reports as an unknown app.
285
+ if (pathname.startsWith("/apps/") && pathname !== "/apps/") {
286
+ return "workspace-app";
287
+ }
268
288
  if (pathname.startsWith("/apps")) return "apps";
269
289
  if (pathname.startsWith("/operations")) return "operations";
270
290
  if (pathname.startsWith("/metrics")) return "metrics";
@@ -291,7 +311,10 @@ function resolveView(
291
311
  function resolvePath(
292
312
  view?: string,
293
313
  extensions?: DispatchExtensionConfig,
294
- command?: Pick<NavigationState, "extensionId" | "threadId">,
314
+ command?: Pick<
315
+ NavigationState,
316
+ "extensionId" | "threadId" | "workspaceAppId"
317
+ >,
295
318
  ): string | undefined {
296
319
  switch (view) {
297
320
  case "admin":
@@ -305,6 +328,10 @@ function resolvePath(
305
328
  return "/overview";
306
329
  case "apps":
307
330
  return "/apps";
331
+ case "workspace-app":
332
+ return command?.workspaceAppId
333
+ ? workspaceAppRoute(command.workspaceAppId)
334
+ : "/apps";
308
335
  case "operations":
309
336
  case "monitoring":
310
337
  case "observability":
@@ -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