@agent-native/dispatch 0.24.5 → 0.25.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 (151) hide show
  1. package/README.md +12 -1
  2. package/dist/actions/connect-external-agent.d.ts +18 -0
  3. package/dist/actions/connect-external-agent.d.ts.map +1 -0
  4. package/dist/actions/connect-external-agent.js +68 -0
  5. package/dist/actions/connect-external-agent.js.map +1 -0
  6. package/dist/actions/create-workspace-resource.d.ts +14 -14
  7. package/dist/actions/create-workspace-resource.js +9 -2
  8. package/dist/actions/create-workspace-resource.js.map +1 -1
  9. package/dist/actions/create_workspace_app_embed_session.d.ts +13 -0
  10. package/dist/actions/create_workspace_app_embed_session.d.ts.map +1 -0
  11. package/dist/actions/create_workspace_app_embed_session.js +30 -0
  12. package/dist/actions/create_workspace_app_embed_session.js.map +1 -0
  13. package/dist/actions/import-agent-pack.d.ts +96 -0
  14. package/dist/actions/import-agent-pack.d.ts.map +1 -0
  15. package/dist/actions/import-agent-pack.js +116 -0
  16. package/dist/actions/import-agent-pack.js.map +1 -0
  17. package/dist/actions/import-agent.d.ts +60 -0
  18. package/dist/actions/import-agent.d.ts.map +1 -0
  19. package/dist/actions/import-agent.js +82 -0
  20. package/dist/actions/import-agent.js.map +1 -0
  21. package/dist/actions/index.d.ts.map +1 -1
  22. package/dist/actions/index.js +10 -0
  23. package/dist/actions/index.js.map +1 -1
  24. package/dist/actions/list-agent-pack.d.ts +36 -0
  25. package/dist/actions/list-agent-pack.d.ts.map +1 -0
  26. package/dist/actions/list-agent-pack.js +32 -0
  27. package/dist/actions/list-agent-pack.js.map +1 -0
  28. package/dist/actions/list-workspace-resource-options.d.ts +1 -1
  29. package/dist/actions/list-workspace-resource-options.js +8 -1
  30. package/dist/actions/list-workspace-resource-options.js.map +1 -1
  31. package/dist/actions/list-workspace-resources.d.ts +1 -1
  32. package/dist/actions/list-workspace-resources.js +8 -1
  33. package/dist/actions/list-workspace-resources.js.map +1 -1
  34. package/dist/actions/navigate.js +1 -1
  35. package/dist/actions/navigate.js.map +1 -1
  36. package/dist/actions/provider-api-register.d.ts +10 -10
  37. package/dist/actions/update-workspace-resource.d.ts +13 -13
  38. package/dist/actions/view-screen.js +18 -1
  39. package/dist/actions/view-screen.js.map +1 -1
  40. package/dist/components/index.d.ts +1 -0
  41. package/dist/components/index.d.ts.map +1 -1
  42. package/dist/components/index.js +1 -0
  43. package/dist/components/index.js.map +1 -1
  44. package/dist/components/layout/Layout.d.ts +1 -8
  45. package/dist/components/layout/Layout.d.ts.map +1 -1
  46. package/dist/components/layout/Layout.js +33 -106
  47. package/dist/components/layout/Layout.js.map +1 -1
  48. package/dist/components/simple-agents-panel.d.ts +9 -0
  49. package/dist/components/simple-agents-panel.d.ts.map +1 -0
  50. package/dist/components/simple-agents-panel.js +405 -0
  51. package/dist/components/simple-agents-panel.js.map +1 -0
  52. package/dist/components/workspace-app-host.d.ts +29 -0
  53. package/dist/components/workspace-app-host.d.ts.map +1 -0
  54. package/dist/components/workspace-app-host.js +145 -0
  55. package/dist/components/workspace-app-host.js.map +1 -0
  56. package/dist/db/migrations.d.ts +2 -4
  57. package/dist/db/migrations.d.ts.map +1 -1
  58. package/dist/db/migrations.js +25 -0
  59. package/dist/db/migrations.js.map +1 -1
  60. package/dist/db/schema.js +1 -1
  61. package/dist/db/schema.js.map +1 -1
  62. package/dist/hooks/use-navigation-state.d.ts +1 -0
  63. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  64. package/dist/hooks/use-navigation-state.js +10 -0
  65. package/dist/hooks/use-navigation-state.js.map +1 -1
  66. package/dist/lib/agent-pack.d.ts +31 -0
  67. package/dist/lib/agent-pack.d.ts.map +1 -0
  68. package/dist/lib/agent-pack.js +149 -0
  69. package/dist/lib/agent-pack.js.map +1 -0
  70. package/dist/lib/simple-agent-profile.d.ts +36 -0
  71. package/dist/lib/simple-agent-profile.d.ts.map +1 -0
  72. package/dist/lib/simple-agent-profile.js +183 -0
  73. package/dist/lib/simple-agent-profile.js.map +1 -0
  74. package/dist/routes/pages/agents.d.ts.map +1 -1
  75. package/dist/routes/pages/agents.js +11 -1
  76. package/dist/routes/pages/agents.js.map +1 -1
  77. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  78. package/dist/routes/pages/apps.$appId.js +4 -95
  79. package/dist/routes/pages/apps.$appId.js.map +1 -1
  80. package/dist/routes/pages/chat.d.ts.map +1 -1
  81. package/dist/routes/pages/chat.js +62 -10
  82. package/dist/routes/pages/chat.js.map +1 -1
  83. package/dist/server/lib/agent-pack-store.d.ts +24 -0
  84. package/dist/server/lib/agent-pack-store.d.ts.map +1 -0
  85. package/dist/server/lib/agent-pack-store.js +22 -0
  86. package/dist/server/lib/agent-pack-store.js.map +1 -0
  87. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  88. package/dist/server/lib/dispatch-store.js +4 -0
  89. package/dist/server/lib/dispatch-store.js.map +1 -1
  90. package/dist/server/lib/mcp-gateway.d.ts +11 -0
  91. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  92. package/dist/server/lib/mcp-gateway.js +155 -9
  93. package/dist/server/lib/mcp-gateway.js.map +1 -1
  94. package/dist/server/lib/workspace-resources-store.d.ts +15 -1
  95. package/dist/server/lib/workspace-resources-store.d.ts.map +1 -1
  96. package/dist/server/lib/workspace-resources-store.js +11 -2
  97. package/dist/server/lib/workspace-resources-store.js.map +1 -1
  98. package/dist/server/plugins/agent-chat.js +13 -0
  99. package/dist/server/plugins/agent-chat.js.map +1 -1
  100. package/dist/shared/feature-flags.d.ts +7 -0
  101. package/dist/shared/feature-flags.d.ts.map +1 -0
  102. package/dist/shared/feature-flags.js +12 -0
  103. package/dist/shared/feature-flags.js.map +1 -0
  104. package/dist/shared/workspace-sso.d.ts +36 -0
  105. package/dist/shared/workspace-sso.d.ts.map +1 -0
  106. package/dist/shared/workspace-sso.js +99 -0
  107. package/dist/shared/workspace-sso.js.map +1 -0
  108. package/package.json +5 -3
  109. package/src/actions/connect-external-agent.ts +92 -0
  110. package/src/actions/create-workspace-resource.ts +9 -2
  111. package/src/actions/create_workspace_app_embed_session.ts +32 -0
  112. package/src/actions/import-agent-pack.ts +146 -0
  113. package/src/actions/import-agent.ts +102 -0
  114. package/src/actions/index.spec.ts +13 -0
  115. package/src/actions/index.ts +10 -0
  116. package/src/actions/list-agent-pack.ts +43 -0
  117. package/src/actions/list-workspace-resource-options.ts +8 -1
  118. package/src/actions/list-workspace-resources.ts +8 -1
  119. package/src/actions/navigate.ts +1 -1
  120. package/src/actions/view-screen.spec.ts +29 -1
  121. package/src/actions/view-screen.ts +19 -1
  122. package/src/components/app-icon.spec.tsx +10 -0
  123. package/src/components/index.ts +1 -0
  124. package/src/components/layout/Layout.spec.tsx +2 -0
  125. package/src/components/layout/Layout.tsx +59 -171
  126. package/src/components/simple-agents-panel.spec.tsx +73 -0
  127. package/src/components/simple-agents-panel.tsx +1226 -0
  128. package/src/components/workspace-app-host.spec.tsx +204 -0
  129. package/src/components/workspace-app-host.tsx +359 -0
  130. package/src/db/migrations.spec.ts +5 -1
  131. package/src/db/migrations.ts +28 -1
  132. package/src/db/schema.ts +1 -1
  133. package/src/hooks/use-navigation-state.spec.ts +25 -0
  134. package/src/hooks/use-navigation-state.ts +11 -0
  135. package/src/lib/agent-pack.spec.ts +47 -0
  136. package/src/lib/agent-pack.ts +226 -0
  137. package/src/lib/simple-agent-profile.spec.ts +77 -0
  138. package/src/lib/simple-agent-profile.ts +247 -0
  139. package/src/routes/index.spec.ts +1 -0
  140. package/src/routes/pages/agents.tsx +21 -1
  141. package/src/routes/pages/apps.$appId.tsx +3 -240
  142. package/src/routes/pages/chat.spec.tsx +62 -0
  143. package/src/routes/pages/chat.tsx +117 -9
  144. package/src/server/lib/agent-pack-store.ts +35 -0
  145. package/src/server/lib/dispatch-store.ts +8 -0
  146. package/src/server/lib/mcp-gateway.spec.ts +141 -0
  147. package/src/server/lib/mcp-gateway.ts +227 -15
  148. package/src/server/lib/workspace-resources-store.ts +9 -2
  149. package/src/server/plugins/agent-chat.ts +13 -0
  150. package/src/shared/feature-flags.ts +13 -0
  151. package/src/shared/workspace-sso.ts +118 -0
@@ -0,0 +1,204 @@
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 clientState = vi.hoisted(() => {
8
+ const legacyMutateAsync = vi.fn().mockImplementation(async () => ({
9
+ startUrl: "about:blank",
10
+ }));
11
+ const workspaceSsoMutateAsync = vi.fn().mockImplementation(async () => ({
12
+ startUrl: "about:blank",
13
+ }));
14
+ const actionNames: string[] = [];
15
+ return {
16
+ actionNames,
17
+ legacyMutateAsync,
18
+ workspaceSsoEnabled: false,
19
+ workspaceSsoMutateAsync,
20
+ };
21
+ });
22
+
23
+ vi.mock("@agent-native/core/client/chat-first", () => ({
24
+ ChatFirstAppPane: ({
25
+ app,
26
+ embedUrl,
27
+ renderEmbed,
28
+ status,
29
+ }: {
30
+ app: { name: string } | null;
31
+ embedUrl?: string | null;
32
+ renderEmbed: (target: { url: string; title?: string }) => React.ReactNode;
33
+ status: string;
34
+ }) => (
35
+ <div data-chat-first-app-status={status}>
36
+ {status === "ready" && embedUrl
37
+ ? renderEmbed({ url: embedUrl, title: app?.name })
38
+ : null}
39
+ </div>
40
+ ),
41
+ defaultChatFirstCopy: (key: string) => key,
42
+ }));
43
+
44
+ vi.mock("@agent-native/core/client/feature-flags", () => ({
45
+ useFeatureFlag: () => clientState.workspaceSsoEnabled,
46
+ }));
47
+
48
+ vi.mock("@agent-native/core/client/hooks", () => ({
49
+ useActionMutation: (name: string) => {
50
+ clientState.actionNames.push(name);
51
+ return {
52
+ mutateAsync:
53
+ name === "create-workspace-app-embed-session"
54
+ ? clientState.workspaceSsoMutateAsync
55
+ : clientState.legacyMutateAsync,
56
+ };
57
+ },
58
+ useActionQuery: () => ({
59
+ data: [
60
+ { id: "mail", name: "Mail", path: "/mail", url: null, status: "ready" },
61
+ {
62
+ id: "calendar",
63
+ name: "Calendar",
64
+ path: "/calendar",
65
+ url: null,
66
+ status: "ready",
67
+ },
68
+ {
69
+ id: "documents",
70
+ name: "Documents",
71
+ path: "/documents",
72
+ url: null,
73
+ status: "ready",
74
+ },
75
+ {
76
+ id: "settings",
77
+ name: "Settings",
78
+ path: "/settings",
79
+ url: null,
80
+ status: "ready",
81
+ },
82
+ ],
83
+ isError: false,
84
+ isLoading: false,
85
+ refetch: vi.fn(),
86
+ }),
87
+ }));
88
+
89
+ vi.mock("@agent-native/core/client/i18n", () => ({
90
+ useT: () => (key: string) => key,
91
+ }));
92
+
93
+ import { WorkspaceAppFrame, WorkspaceAppKeepAlive } from "./workspace-app-host";
94
+
95
+ describe("WorkspaceAppKeepAlive", () => {
96
+ let container: HTMLDivElement;
97
+ let root: Root;
98
+
99
+ beforeEach(() => {
100
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
101
+ container = document.createElement("div");
102
+ document.body.appendChild(container);
103
+ root = createRoot(container);
104
+ clientState.actionNames.length = 0;
105
+ clientState.legacyMutateAsync.mockClear();
106
+ clientState.workspaceSsoMutateAsync.mockClear();
107
+ clientState.workspaceSsoEnabled = false;
108
+ });
109
+
110
+ afterEach(() => {
111
+ act(() => root.unmount());
112
+ container.remove();
113
+ vi.unstubAllGlobals();
114
+ });
115
+
116
+ it("keeps visited app frames in the DOM while hiding inactive apps", async () => {
117
+ await act(async () => {
118
+ root.render(<WorkspaceAppKeepAlive activeAppId="mail" />);
119
+ await Promise.resolve();
120
+ });
121
+
122
+ await act(async () => {
123
+ await Promise.resolve();
124
+ await Promise.resolve();
125
+ });
126
+
127
+ await act(async () => {
128
+ root.render(<WorkspaceAppKeepAlive activeAppId="calendar" />);
129
+ await Promise.resolve();
130
+ });
131
+
132
+ const mailEntry = container.querySelector<HTMLElement>(
133
+ '[data-dispatch-workspace-app-cache-entry="mail"]',
134
+ );
135
+ const calendarEntry = container.querySelector<HTMLElement>(
136
+ '[data-dispatch-workspace-app-cache-entry="calendar"]',
137
+ );
138
+
139
+ expect(mailEntry).not.toBeNull();
140
+ expect(mailEntry?.classList.contains("hidden")).toBe(true);
141
+ expect(mailEntry?.querySelector("iframe")).not.toBeNull();
142
+ expect(calendarEntry?.classList.contains("hidden")).toBe(false);
143
+ expect(calendarEntry?.querySelector("iframe")).not.toBeNull();
144
+ expect(container.querySelectorAll("iframe")).toHaveLength(2);
145
+ });
146
+
147
+ it("uses the app-scoped workspace session action when the rollout is enabled", async () => {
148
+ clientState.workspaceSsoEnabled = true;
149
+
150
+ await act(async () => {
151
+ root.render(
152
+ <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
153
+ );
154
+ await Promise.resolve();
155
+ await Promise.resolve();
156
+ });
157
+
158
+ expect(clientState.workspaceSsoMutateAsync).toHaveBeenCalledWith({
159
+ app: "mail",
160
+ path: "/mail",
161
+ chrome: "minimal",
162
+ });
163
+ expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it("evicts the oldest inactive app after reaching the keep-alive limit", async () => {
167
+ await act(async () => {
168
+ root.render(<WorkspaceAppKeepAlive activeAppId="mail" />);
169
+ await Promise.resolve();
170
+ });
171
+
172
+ for (const activeAppId of ["calendar", "documents", "settings"]) {
173
+ await act(async () => {
174
+ root.render(<WorkspaceAppKeepAlive activeAppId={activeAppId} />);
175
+ await Promise.resolve();
176
+ });
177
+ }
178
+
179
+ expect(
180
+ container.querySelector(
181
+ '[data-dispatch-workspace-app-cache-entry="mail"]',
182
+ ),
183
+ ).toBeNull();
184
+ expect(
185
+ container.querySelector(
186
+ '[data-dispatch-workspace-app-cache-entry="calendar"]',
187
+ ),
188
+ ).not.toBeNull();
189
+ expect(
190
+ container.querySelector(
191
+ '[data-dispatch-workspace-app-cache-entry="documents"]',
192
+ ),
193
+ ).not.toBeNull();
194
+ expect(
195
+ container.querySelector(
196
+ '[data-dispatch-workspace-app-cache-entry="settings"]',
197
+ ),
198
+ ).not.toBeNull();
199
+ expect(
200
+ container.querySelectorAll("[data-dispatch-workspace-app-cache-entry]"),
201
+ ).toHaveLength(3);
202
+ expect(container.querySelectorAll("iframe")).toHaveLength(3);
203
+ });
204
+ });
@@ -0,0 +1,359 @@
1
+ import {
2
+ ChatFirstAppPane,
3
+ defaultChatFirstCopy,
4
+ type ChatFirstCopy,
5
+ } from "@agent-native/core/client/chat-first";
6
+ import { useFeatureFlag } from "@agent-native/core/client/feature-flags";
7
+ import {
8
+ useActionMutation,
9
+ useActionQuery,
10
+ } from "@agent-native/core/client/hooks";
11
+ import { useT } from "@agent-native/core/client/i18n";
12
+ import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
13
+ import { IconArrowLeft, IconClockHour4 } from "@tabler/icons-react";
14
+ import { useEffect, useMemo, useRef, useState } from "react";
15
+ import { Link } from "react-router";
16
+
17
+ import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
18
+ import {
19
+ workspaceAppDirectHref,
20
+ workspaceAppEmbedTarget,
21
+ workspaceAppHref,
22
+ type WorkspaceAppSummary,
23
+ } from "../lib/workspace-apps";
24
+ import { DISPATCH_WORKSPACE_SSO_FLAG } from "../shared/feature-flags";
25
+ import { ActionQueryError } from "./action-query-error";
26
+ import { Badge } from "./ui/badge";
27
+ import { Button } from "./ui/button";
28
+ import { Skeleton } from "./ui/skeleton";
29
+
30
+ interface EmbedSessionResult {
31
+ startUrl: string;
32
+ }
33
+
34
+ interface EmbedSessionInput {
35
+ app?: string;
36
+ path?: string;
37
+ url?: string;
38
+ chrome: "minimal";
39
+ }
40
+
41
+ export function buildChatFirstEmbedSessionInput(
42
+ appId: string,
43
+ path: string,
44
+ ): EmbedSessionInput {
45
+ return { app: appId, path, chrome: "minimal" };
46
+ }
47
+
48
+ export interface WorkspaceAppFrameApp {
49
+ id: string;
50
+ name: string;
51
+ path?: string | null;
52
+ url?: string | null;
53
+ }
54
+
55
+ interface WorkspaceAppFrameProps {
56
+ app: WorkspaceAppFrameApp;
57
+ /** Chat-first app tabs use their own route while standalone hosts use app metadata. */
58
+ embedPath?: string;
59
+ copy?: ChatFirstCopy;
60
+ }
61
+
62
+ export function WorkspaceAppFrame({
63
+ app,
64
+ embedPath,
65
+ copy = defaultChatFirstCopy,
66
+ }: WorkspaceAppFrameProps) {
67
+ const [embedUrl, setEmbedUrl] = useState<string | null>(null);
68
+ const [embedError, setEmbedError] = useState<Error | null>(null);
69
+ const [embedAttempt, setEmbedAttempt] = useState(0);
70
+ const embedFrameRef = useRef<HTMLIFrameElement>(null);
71
+ const workspaceSsoEnabled = useFeatureFlag(DISPATCH_WORKSPACE_SSO_FLAG.key);
72
+ const createEmbedSession = useActionMutation<
73
+ EmbedSessionResult,
74
+ EmbedSessionInput
75
+ >("create_embed_session", {
76
+ skipActionQueryInvalidation: true,
77
+ });
78
+ const createWorkspaceSsoEmbedSession = useActionMutation<
79
+ EmbedSessionResult,
80
+ EmbedSessionInput
81
+ >("create-workspace-app-embed-session", {
82
+ skipActionQueryInvalidation: true,
83
+ });
84
+ const appHref = workspaceAppHref({
85
+ id: app.id,
86
+ name: app.name,
87
+ path: app.path ?? "",
88
+ url: app.url,
89
+ });
90
+ const embedInput = useMemo<EmbedSessionInput | null>(() => {
91
+ if (embedPath !== undefined) {
92
+ return buildChatFirstEmbedSessionInput(app.id, embedPath);
93
+ }
94
+ if (!appHref) return null;
95
+ return {
96
+ app: app.id,
97
+ ...workspaceAppEmbedTarget({ path: app.path ?? "", url: app.url }),
98
+ chrome: "minimal",
99
+ };
100
+ }, [app.id, app.path, app.url, appHref, embedPath]);
101
+
102
+ useEffect(() => {
103
+ if (!embedInput) return;
104
+ let cancelled = false;
105
+ setEmbedUrl(null);
106
+ setEmbedError(null);
107
+ const createSession = workspaceSsoEnabled
108
+ ? createWorkspaceSsoEmbedSession
109
+ : createEmbedSession;
110
+ void createSession
111
+ .mutateAsync(embedInput)
112
+ .then((result) => {
113
+ if (!cancelled) setEmbedUrl(result.startUrl);
114
+ })
115
+ .catch((cause: unknown) => {
116
+ if (cancelled) return;
117
+ setEmbedUrl(
118
+ workspaceAppDirectHref(
119
+ { path: app.path ?? "", url: app.url },
120
+ embedPath ?? "/",
121
+ ),
122
+ );
123
+ setEmbedError(
124
+ cause instanceof Error ? cause : new Error(String(cause)),
125
+ );
126
+ });
127
+ return () => {
128
+ cancelled = true;
129
+ };
130
+ }, [
131
+ app.id,
132
+ app.path,
133
+ app.url,
134
+ createEmbedSession.mutateAsync,
135
+ createWorkspaceSsoEmbedSession.mutateAsync,
136
+ embedInput,
137
+ embedPath,
138
+ embedAttempt,
139
+ workspaceSsoEnabled,
140
+ ]);
141
+
142
+ useEffect(() => {
143
+ const handleEmbedSessionExpired = (event: MessageEvent) => {
144
+ if (
145
+ !isEmbedSessionExpiredMessage(event, embedFrameRef.current, embedUrl)
146
+ ) {
147
+ return;
148
+ }
149
+ setEmbedAttempt((attempt) => attempt + 1);
150
+ };
151
+
152
+ window.addEventListener("message", handleEmbedSessionExpired);
153
+ return () =>
154
+ window.removeEventListener("message", handleEmbedSessionExpired);
155
+ }, [embedUrl]);
156
+
157
+ return (
158
+ <ChatFirstAppPane
159
+ app={app}
160
+ status={
161
+ embedUrl
162
+ ? "ready"
163
+ : embedError
164
+ ? "error"
165
+ : embedInput
166
+ ? "loading"
167
+ : "unresolved"
168
+ }
169
+ embedUrl={embedUrl}
170
+ errorMessage={embedError?.message}
171
+ onRetry={
172
+ embedInput ? () => setEmbedAttempt((attempt) => attempt + 1) : undefined
173
+ }
174
+ renderEmbed={({ url, title }) => (
175
+ <iframe
176
+ key={url + ":" + embedAttempt}
177
+ data-dispatch-workspace-app-frame
178
+ src={url}
179
+ title={title ?? app.name}
180
+ ref={embedFrameRef}
181
+ referrerPolicy="no-referrer"
182
+ allow="clipboard-read; clipboard-write"
183
+ className="h-full w-full border-0 bg-background"
184
+ />
185
+ )}
186
+ copy={copy}
187
+ />
188
+ );
189
+ }
190
+
191
+ export function WorkspaceAppHost({ appId }: { appId?: string }) {
192
+ const t = useT();
193
+ const appsQuery = useActionQuery("list-workspace-apps", {
194
+ includeAgentCards: false,
195
+ });
196
+ const { data: apps = [], isLoading } = appsQuery;
197
+ const app = useMemo(
198
+ () =>
199
+ (apps as WorkspaceAppSummary[]).find((item) => item.id === appId) ?? null,
200
+ [appId, apps],
201
+ );
202
+
203
+ if (appsQuery.isError) {
204
+ return (
205
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
206
+ <div className="w-full max-w-2xl">
207
+ <ActionQueryError
208
+ error={appsQuery.error}
209
+ onRetry={() => void appsQuery.refetch()}
210
+ />
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ if (isLoading && !app) {
217
+ return (
218
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
219
+ <div className="w-full max-w-2xl space-y-3 rounded-xl border bg-card p-6">
220
+ <Skeleton className="h-5 w-48" />
221
+ <Skeleton className="h-4 w-full" />
222
+ <Skeleton className="h-4 w-2/3" />
223
+ </div>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ if (!app) {
229
+ return (
230
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
231
+ <div className="w-full max-w-2xl rounded-xl border bg-card p-6">
232
+ <Button asChild size="sm" variant="ghost" className="-ml-2 mb-4">
233
+ <Link to="/apps">
234
+ <IconArrowLeft size={15} className="mr-1.5" />
235
+ {t("dispatch.nav.apps")}
236
+ </Link>
237
+ </Button>
238
+ <div className="space-y-3">
239
+ <h2 className="text-base font-semibold text-foreground">
240
+ {t("dispatch.pages.appNotFound")}
241
+ </h2>
242
+ <p className="text-sm text-muted-foreground">
243
+ {t("dispatch.pages.pageNotFoundDescription")}
244
+ </p>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ if (app.status === "pending") {
252
+ return (
253
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
254
+ <div className="w-full max-w-2xl rounded-xl border bg-card p-6">
255
+ <Button asChild size="sm" variant="ghost" className="-ml-2 mb-4">
256
+ <Link to="/apps">
257
+ <IconArrowLeft size={15} className="mr-1.5" />
258
+ {t("dispatch.nav.apps")}
259
+ </Link>
260
+ </Button>
261
+ <div className="space-y-4">
262
+ <div className="flex flex-wrap items-center gap-2">
263
+ <h2 className="text-base font-semibold text-foreground">
264
+ {app.name}
265
+ </h2>
266
+ <Badge
267
+ variant="outline"
268
+ className="gap-1 border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
269
+ >
270
+ <IconClockHour4 size={12} />
271
+ {t("dispatch.pages.building")}
272
+ </Badge>
273
+ </div>
274
+ <p className="text-sm text-muted-foreground">
275
+ {t("dispatch.pages.appBuildingPrefix")}{" "}
276
+ <span className="font-mono text-foreground">{app.path}</span>{" "}
277
+ {t("dispatch.pages.appBuildingSuffix")}
278
+ </p>
279
+ {app.builderUrl ? (
280
+ <Button asChild>
281
+ <a
282
+ href={withBuilderUtmTrackingParams(app.builderUrl, {
283
+ campaign: "product",
284
+ content: "dispatch_branch",
285
+ })}
286
+ target="_blank"
287
+ rel="noreferrer"
288
+ >
289
+ {t("dispatch.pages.openBuilderBranch", {
290
+ defaultValue: "Open in Builder",
291
+ })}
292
+ </a>
293
+ </Button>
294
+ ) : null}
295
+ </div>
296
+ </div>
297
+ </div>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <div
303
+ data-dispatch-workspace-app-host
304
+ className="flex h-full min-h-0 flex-col bg-background"
305
+ >
306
+ <div className="min-h-0 flex-1 bg-muted/20">
307
+ <WorkspaceAppFrame app={app} />
308
+ </div>
309
+ </div>
310
+ );
311
+ }
312
+
313
+ const MAX_KEEP_ALIVE_APPS = 3;
314
+
315
+ export function WorkspaceAppKeepAlive({
316
+ activeAppId,
317
+ }: {
318
+ activeAppId: string | null;
319
+ }) {
320
+ const [visitedAppIds, setVisitedAppIds] = useState<string[]>(() =>
321
+ activeAppId ? [activeAppId] : [],
322
+ );
323
+
324
+ useEffect(() => {
325
+ if (!activeAppId) return;
326
+ setVisitedAppIds((current) =>
327
+ [activeAppId, ...current.filter((appId) => appId !== activeAppId)].slice(
328
+ 0,
329
+ MAX_KEEP_ALIVE_APPS,
330
+ ),
331
+ );
332
+ }, [activeAppId]);
333
+
334
+ const renderedAppIds =
335
+ activeAppId && !visitedAppIds.includes(activeAppId)
336
+ ? [activeAppId, ...visitedAppIds]
337
+ : visitedAppIds;
338
+
339
+ return (
340
+ <div
341
+ data-dispatch-workspace-app-cache
342
+ className={activeAppId ? "absolute inset-0 overflow-hidden" : "hidden"}
343
+ >
344
+ {renderedAppIds.map((appId) => {
345
+ const active = appId === activeAppId;
346
+ return (
347
+ <div
348
+ key={appId}
349
+ data-dispatch-workspace-app-cache-entry={appId}
350
+ aria-hidden={!active}
351
+ className={active ? "h-full min-h-0" : "hidden"}
352
+ >
353
+ <WorkspaceAppHost appId={appId} />
354
+ </div>
355
+ );
356
+ })}
357
+ </div>
358
+ );
359
+ }
@@ -75,6 +75,10 @@ describe("dispatch migrations", () => {
75
75
  const { rows } = await exec.execute(
76
76
  "SELECT MAX(version) as version FROM dispatch_migrations",
77
77
  );
78
- expect(rows[0]?.version).toBe(4);
78
+ expect(rows[0]?.version).toBe(5);
79
+ const { rows: identityRows } = await exec.execute(
80
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'identity_sso_authorization_code'",
81
+ );
82
+ expect(identityRows).toHaveLength(1);
79
83
  });
80
84
  });
@@ -1,4 +1,6 @@
1
- export const dispatchMigrations: Array<{ version: number; sql: string }> = [
1
+ import type { MigrationEntry } from "@agent-native/core/db";
2
+
3
+ export const dispatchMigrations: MigrationEntry[] = [
2
4
  {
3
5
  version: 1,
4
6
  sql: `
@@ -221,4 +223,29 @@ export const dispatchMigrations: Array<{ version: number; sql: string }> = [
221
223
  ALTER TABLE dispatch_dreams ADD COLUMN IF NOT EXISTS source_health TEXT;
222
224
  `,
223
225
  },
226
+ {
227
+ version: 5,
228
+ name: "identity-sso-authorization-code-table",
229
+ sql: `
230
+ CREATE TABLE IF NOT EXISTS identity_sso_authorization_code (
231
+ code_hash TEXT PRIMARY KEY,
232
+ state TEXT NOT NULL,
233
+ app_id TEXT NOT NULL,
234
+ client_id TEXT NOT NULL,
235
+ redirect_uri TEXT NOT NULL,
236
+ authority TEXT NOT NULL,
237
+ code_challenge TEXT NOT NULL,
238
+ email TEXT NOT NULL,
239
+ name TEXT,
240
+ org_domain TEXT,
241
+ jti TEXT NOT NULL,
242
+ created_at INTEGER NOT NULL,
243
+ expires_at INTEGER NOT NULL,
244
+ consumed_at INTEGER
245
+ );
246
+
247
+ CREATE INDEX IF NOT EXISTS identity_sso_authorization_code_expires_idx
248
+ ON identity_sso_authorization_code (expires_at);
249
+ `,
250
+ },
224
251
  ];
package/src/db/schema.ts CHANGED
@@ -180,7 +180,7 @@ export const workspaceResources = table("workspace_resources", {
180
180
  id: text("id").primaryKey(),
181
181
  ownerEmail: text("owner_email").notNull(),
182
182
  orgId: text("org_id"),
183
- kind: text("kind").notNull(), // "skill" | "instruction" | "agent" | "knowledge" | "mcp-server"
183
+ kind: text("kind").notNull(), // "skill" | "instruction" | "agent" | "agent-file" | "knowledge" | "mcp-server"
184
184
  name: text("name").notNull(),
185
185
  description: text("description"),
186
186
  path: text("path").notNull(), // resource path, e.g. "skills/designer.md"
@@ -10,6 +10,20 @@ describe("buildDispatchNavigationState", () => {
10
10
  });
11
11
  });
12
12
 
13
+ it("keeps the active simple agent on the chat navigation state", () => {
14
+ expect(
15
+ buildDispatchNavigationState(
16
+ "/chat/thread-1",
17
+ "?agent=agents/research-partner.md",
18
+ ),
19
+ ).toEqual({
20
+ view: "chat",
21
+ path: "/chat/thread-1",
22
+ threadId: "thread-1",
23
+ agentPath: "agents/research-partner.md",
24
+ });
25
+ });
26
+
13
27
  it("recognizes the embedded browser chat route", () => {
14
28
  expect(buildDispatchNavigationState("/browser-chat")).toEqual({
15
29
  view: "browser-chat",
@@ -61,6 +75,17 @@ describe("buildDispatchNavigationState", () => {
61
75
  });
62
76
  });
63
77
 
78
+ it("keeps simple agents and connected-agent admin routes distinct", () => {
79
+ expect(buildDispatchNavigationState("/agents")).toEqual({
80
+ view: "agents",
81
+ path: "/agents",
82
+ });
83
+ expect(buildDispatchNavigationState("/admin/agents")).toEqual({
84
+ view: "connected-agents",
85
+ path: "/admin/agents",
86
+ });
87
+ });
88
+
64
89
  it("preserves thread debug filters and selection", () => {
65
90
  expect(
66
91
  buildDispatchNavigationState(
@@ -32,6 +32,7 @@ export interface NavigationState {
32
32
  query?: string;
33
33
  runId?: string;
34
34
  threadId?: string;
35
+ agentPath?: string;
35
36
  usageScope?: "me" | "workspace";
36
37
  usageUserEmail?: string;
37
38
  }
@@ -138,6 +139,11 @@ export function buildDispatchNavigationState(
138
139
  const threadId = threadIdFromPath(pathname);
139
140
  if (threadId) state.threadId = threadId;
140
141
 
142
+ if (state.view === "chat") {
143
+ const agentPath = new URLSearchParams(search).get("agent")?.trim();
144
+ if (agentPath) state.agentPath = agentPath;
145
+ }
146
+
141
147
  const extensionId = extensionIdFromPathname(pathname);
142
148
  if (extensionId) {
143
149
  state.view = "extensions";
@@ -250,6 +256,9 @@ function resolveView(
250
256
  return "extensions";
251
257
  }
252
258
  if (pathname === "/admin") return "admin";
259
+ if (pathname === "/admin/agents" || pathname.startsWith("/admin/agents/")) {
260
+ return "connected-agents";
261
+ }
253
262
  if (pathname.startsWith("/admin/")) {
254
263
  const adminView = resolveView(pathname.slice("/admin".length), extensions);
255
264
  return adminView === "overview" ? "admin" : adminView;
@@ -318,6 +327,8 @@ function resolvePath(
318
327
  case "resources":
319
328
  return "/admin/workspace";
320
329
  case "agents":
330
+ return "/agents";
331
+ case "connected-agents":
321
332
  return "/admin/agents";
322
333
  case "messaging":
323
334
  return "/admin/messaging";