@agent-native/dispatch 0.27.0 → 0.27.2

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 (32) hide show
  1. package/dist/actions/provider-api-register.d.ts +14 -14
  2. package/dist/components/app-keys-popover.d.ts.map +1 -1
  3. package/dist/components/app-keys-popover.js +16 -9
  4. package/dist/components/app-keys-popover.js.map +1 -1
  5. package/dist/components/create-app-popover.d.ts.map +1 -1
  6. package/dist/components/create-app-popover.js +7 -1
  7. package/dist/components/create-app-popover.js.map +1 -1
  8. package/dist/components/layout/Layout.d.ts.map +1 -1
  9. package/dist/components/layout/Layout.js +14 -10
  10. package/dist/components/layout/Layout.js.map +1 -1
  11. package/dist/components/workspace-app-host.d.ts.map +1 -1
  12. package/dist/components/workspace-app-host.js +40 -9
  13. package/dist/components/workspace-app-host.js.map +1 -1
  14. package/dist/lib/workspace-apps.d.ts.map +1 -1
  15. package/dist/lib/workspace-apps.js +6 -2
  16. package/dist/lib/workspace-apps.js.map +1 -1
  17. package/dist/server/lib/vault-store.d.ts.map +1 -1
  18. package/dist/server/lib/vault-store.js +1 -1
  19. package/dist/server/lib/vault-store.js.map +1 -1
  20. package/package.json +3 -3
  21. package/src/components/app-keys-popover.spec.tsx +60 -2
  22. package/src/components/app-keys-popover.tsx +23 -10
  23. package/src/components/create-app-popover.spec.tsx +20 -0
  24. package/src/components/create-app-popover.tsx +7 -1
  25. package/src/components/layout/Layout.spec.tsx +27 -0
  26. package/src/components/layout/Layout.tsx +35 -30
  27. package/src/components/workspace-app-host.spec.tsx +140 -28
  28. package/src/components/workspace-app-host.tsx +59 -9
  29. package/src/lib/workspace-apps.spec.ts +51 -0
  30. package/src/lib/workspace-apps.ts +7 -2
  31. package/src/server/lib/vault-store.spec.ts +5 -3
  32. package/src/server/lib/vault-store.ts +5 -2
@@ -216,6 +216,26 @@ describe("CreateAppFlow", () => {
216
216
  ).toBe(false);
217
217
  });
218
218
 
219
+ it("reuses an empty local chat for direct dev-mode app creation", async () => {
220
+ devState.isDevMode = true;
221
+
222
+ await renderAndSubmit("Build a quality dashboard");
223
+
224
+ expect(sendToAgentChatMock).toHaveBeenCalledWith(
225
+ expect.objectContaining({
226
+ submit: true,
227
+ type: "code",
228
+ newTab: true,
229
+ reuseEmptyTab: true,
230
+ }),
231
+ );
232
+ expect(
233
+ fetchSpy.mock.calls.some(([input]) =>
234
+ String(input).includes("start-workspace-app-creation"),
235
+ ),
236
+ ).toBe(false);
237
+ });
238
+
219
239
  it("opens a fresh local chat when the server hands off app creation", async () => {
220
240
  startWorkspaceAppCreationResponse.result = {
221
241
  mode: "local-agent",
@@ -253,7 +253,13 @@ export function CreateAppFlow({
253
253
  setStatusMessage("Sent to Builder chat.");
254
254
  onClose?.();
255
255
  } else if (isDevMode) {
256
- sendToAgentChat({ message, submit: true, type: "code", newTab: true });
256
+ sendToAgentChat({
257
+ message,
258
+ submit: true,
259
+ type: "code",
260
+ newTab: true,
261
+ reuseEmptyTab: true,
262
+ });
257
263
  setStatusMessage("Sent to the local agent.");
258
264
  onClose?.();
259
265
  } else {
@@ -220,6 +220,33 @@ describe("Dispatch NavContent", () => {
220
220
  expect(lists[0].querySelector("a")?.className).toContain("size-9");
221
221
  });
222
222
 
223
+ it("keeps chat-first primary actions in the collapsed sidebar", async () => {
224
+ await act(async () => {
225
+ root.render(
226
+ <MemoryRouter initialEntries={["/chat"]}>
227
+ <TooltipProvider>
228
+ <NavContent
229
+ chatFirstMode
230
+ collapsed
231
+ chatFirstApps={[{ id: "mail", name: "Mail" }]}
232
+ />
233
+ </TooltipProvider>
234
+ </MemoryRouter>,
235
+ );
236
+ });
237
+
238
+ for (const label of ["New chat", "Integrations", "Search"]) {
239
+ expect(
240
+ [...container.querySelectorAll("button")].find(
241
+ (button) => button.textContent?.trim() === label,
242
+ ),
243
+ ).toBeDefined();
244
+ }
245
+ expect(
246
+ container.querySelector("[data-chat-first-apps-rail]"),
247
+ ).not.toBeNull();
248
+ });
249
+
223
250
  it("keeps management routes out of the primary navigation", async () => {
224
251
  await act(async () => {
225
252
  root.render(
@@ -569,6 +569,7 @@ function DispatchChatsSection({
569
569
  prelude,
570
570
  chatFirstMode = false,
571
571
  chatFirstEmbedded = false,
572
+ collapsed = false,
572
573
  chatFirstNavigation,
573
574
  }: {
574
575
  onNavigate?: () => void;
@@ -576,6 +577,7 @@ function DispatchChatsSection({
576
577
  prelude?: ReactNode;
577
578
  chatFirstMode?: boolean;
578
579
  chatFirstEmbedded?: boolean;
580
+ collapsed?: boolean;
579
581
  chatFirstNavigation?: {
580
582
  activeTab?: ChatFirstPrimaryTab;
581
583
  onNewChat?: () => void;
@@ -722,6 +724,8 @@ function DispatchChatsSection({
722
724
  if (threadId) openThread(threadId, { isNew: true });
723
725
  }
724
726
 
727
+ const collapsedChatFirst = collapsed && chatFirstMode;
728
+
725
729
  return (
726
730
  <div
727
731
  className={cn(
@@ -730,7 +734,7 @@ function DispatchChatsSection({
730
734
  )}
731
735
  >
732
736
  {showNewChat && chatFirstNavigation ? (
733
- <nav className="space-y-0.5 px-2 py-2">
737
+ <nav className={cn("space-y-0.5 py-2", collapsed ? "px-1.5" : "px-2")}>
734
738
  <ChatFirstPrimaryNavigation
735
739
  copy={chatFirstCopy}
736
740
  onNewChat={() => {
@@ -741,11 +745,12 @@ function DispatchChatsSection({
741
745
  onOpenScheduled={chatFirstNavigation.onOpenScheduled}
742
746
  onSearch={openCommandMenu}
743
747
  activeTab={chatFirstNavigation.activeTab}
748
+ collapsed={collapsed}
744
749
  />
745
750
  </nav>
746
751
  ) : null}
747
752
  {prelude}
748
- {!chatFirstMode ? (
753
+ {!collapsedChatFirst && !chatFirstMode ? (
749
754
  <div className="flex justify-end px-2 pt-0.5">
750
755
  <Tooltip>
751
756
  <TooltipTrigger asChild>
@@ -784,7 +789,8 @@ function DispatchChatsSection({
784
789
  </Tooltip>
785
790
  </div>
786
791
  ) : null}
787
- {!chatFirstMode &&
792
+ {!collapsedChatFirst &&
793
+ !chatFirstMode &&
788
794
  chatsLoading &&
789
795
  visibleThreads.length === 0 &&
790
796
  Array.from({ length: 3 }).map((_, index) => (
@@ -796,7 +802,9 @@ function DispatchChatsSection({
796
802
  <Skeleton className="h-3 w-3/4 rounded" />
797
803
  </div>
798
804
  ))}
799
- {chatFirstMode && (chatsLoading || visibleThreads.length > 0) ? (
805
+ {!collapsedChatFirst &&
806
+ chatFirstMode &&
807
+ (chatsLoading || visibleThreads.length > 0) ? (
800
808
  <ChatFirstChatHistory
801
809
  items={chatItems}
802
810
  activeId={displayedActiveThreadId}
@@ -870,7 +878,7 @@ function DispatchChatsSection({
870
878
  )}
871
879
  className="min-w-0 px-2"
872
880
  />
873
- ) : (
881
+ ) : !collapsedChatFirst ? (
874
882
  <ChatHistoryRail
875
883
  items={chatItems}
876
884
  activeId={displayedActiveThreadId}
@@ -938,7 +946,7 @@ function DispatchChatsSection({
938
946
  )}
939
947
  className="min-w-0 px-2"
940
948
  />
941
- )}
949
+ ) : null}
942
950
  </div>
943
951
  );
944
952
  }
@@ -1273,29 +1281,26 @@ export function NavContent({
1273
1281
 
1274
1282
  {chatFirstMode ? (
1275
1283
  <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
1276
- {!collapsed ? (
1277
- <DispatchChatsSection
1278
- onNavigate={onNavigate}
1279
- showNewChat
1280
- chatFirstMode
1281
- chatFirstEmbedded={chatFirstEmbedded}
1282
- chatFirstNavigation={{
1283
- activeTab: chatFirstActivePrimaryTab,
1284
- onNewChat: onChatFirstNewChat,
1285
- onOpenIntegrations: () => {
1286
- navigate(dispatchNavLinkTarget("/admin/integrations"));
1287
- onNavigate?.();
1288
- },
1289
- onOpenScheduled: () => {
1290
- navigate(dispatchNavLinkTarget("/admin/automations"));
1291
- onNavigate?.();
1292
- },
1293
- }}
1294
- prelude={chatFirstAppsRail}
1295
- />
1296
- ) : (
1297
- chatFirstAppsRail
1298
- )}
1284
+ <DispatchChatsSection
1285
+ onNavigate={onNavigate}
1286
+ showNewChat
1287
+ chatFirstMode
1288
+ chatFirstEmbedded={chatFirstEmbedded}
1289
+ collapsed={collapsed}
1290
+ chatFirstNavigation={{
1291
+ activeTab: chatFirstActivePrimaryTab,
1292
+ onNewChat: onChatFirstNewChat,
1293
+ onOpenIntegrations: () => {
1294
+ navigate(dispatchNavLinkTarget("/admin/integrations"));
1295
+ onNavigate?.();
1296
+ },
1297
+ onOpenScheduled: () => {
1298
+ navigate(dispatchNavLinkTarget("/admin/automations"));
1299
+ onNavigate?.();
1300
+ },
1301
+ }}
1302
+ prelude={chatFirstAppsRail}
1303
+ />
1299
1304
  </div>
1300
1305
  ) : null}
1301
1306
  <div
@@ -2374,7 +2379,7 @@ export function Layout({
2374
2379
  const content = isChatRoute ? (
2375
2380
  <div
2376
2381
  className={cn(
2377
- "agent-layout-main-surface flex min-w-0 flex-1 overflow-hidden",
2382
+ "agent-layout-main-surface flex h-full min-w-0 flex-1 overflow-hidden",
2378
2383
  chatFirstMode && "dispatch-chat-first-surface",
2379
2384
  )}
2380
2385
  >
@@ -11,10 +11,22 @@ const clientState = vi.hoisted(() => {
11
11
  const workspaceSsoMutateAsync = vi.fn().mockImplementation(async () => ({
12
12
  startUrl: "about:blank",
13
13
  }));
14
+ const legacyErrorMutateAsync = vi.fn();
14
15
  const actionNames: string[] = [];
15
16
  return {
16
17
  actionNames,
18
+ grantedApps: [
19
+ {
20
+ id: "analytics.agent-native.com",
21
+ name: "Analytics",
22
+ url: "https://analytics.agent-native.com",
23
+ },
24
+ ],
17
25
  legacyMutateAsync,
26
+ legacyMutateError: null as Error | null,
27
+ legacyErrorMutateAsync,
28
+ frameLoadHandler: null as (() => void) | null,
29
+ suppressFrameLoad: false,
18
30
  theme: "dark" as "dark" | "light",
19
31
  workspaceSsoEnabled: false,
20
32
  workspaceSsoMutateAsync,
@@ -22,20 +34,43 @@ const clientState = vi.hoisted(() => {
22
34
  });
23
35
 
24
36
  vi.mock("@agent-native/core/client/chat-first", () => ({
37
+ CHAT_FIRST_DEFAULT_APP_IDS: [
38
+ "content",
39
+ "design",
40
+ "mail",
41
+ "calendar",
42
+ "clips",
43
+ ],
25
44
  ChatFirstAppPane: ({
26
45
  app,
27
46
  embedUrl,
47
+ errorMessage,
28
48
  renderEmbed,
29
49
  status,
30
50
  }: {
31
51
  app: { name: string } | null;
32
52
  embedUrl?: string | null;
53
+ errorMessage?: string;
33
54
  renderEmbed: (target: { url: string; title?: string }) => React.ReactNode;
34
55
  status: string;
35
56
  }) => (
36
- <div data-chat-first-app-status={status}>
57
+ <div
58
+ data-chat-first-app-error={errorMessage ?? ""}
59
+ data-chat-first-app-status={status}
60
+ >
37
61
  {status === "ready" && embedUrl
38
- ? renderEmbed({ url: embedUrl, title: app?.name })
62
+ ? (() => {
63
+ const frame = renderEmbed({ url: embedUrl, title: app?.name });
64
+ if (clientState.suppressFrameLoad && React.isValidElement(frame)) {
65
+ clientState.frameLoadHandler =
66
+ (frame.props as { onLoad?: () => void }).onLoad ?? null;
67
+ return React.cloneElement(frame, {
68
+ onLoad: undefined,
69
+ src: undefined,
70
+ });
71
+ }
72
+ return frame;
73
+ })()
39
74
  : null}
40
75
  </div>
41
76
  ),
@@ -53,34 +88,45 @@ vi.mock("@agent-native/core/client/hooks", () => ({
53
88
  mutateAsync:
54
89
  name === "create-workspace-app-embed-session"
55
90
  ? clientState.workspaceSsoMutateAsync
56
- : clientState.legacyMutateAsync,
91
+ : clientState.legacyMutateError
92
+ ? clientState.legacyErrorMutateAsync
93
+ : clientState.legacyMutateAsync,
57
94
  };
58
95
  },
59
- useActionQuery: () => ({
60
- data: [
61
- { id: "mail", name: "Mail", path: "/mail", url: null, status: "ready" },
62
- {
63
- id: "calendar",
64
- name: "Calendar",
65
- path: "/calendar",
66
- url: null,
67
- status: "ready",
68
- },
69
- {
70
- id: "documents",
71
- name: "Documents",
72
- path: "/documents",
73
- url: null,
74
- status: "ready",
75
- },
76
- {
77
- id: "settings",
78
- name: "Settings",
79
- path: "/settings",
80
- url: null,
81
- status: "ready",
82
- },
83
- ],
96
+ useActionQuery: (name: string) => ({
97
+ data:
98
+ name === "list_apps"
99
+ ? { apps: clientState.grantedApps }
100
+ : [
101
+ {
102
+ id: "mail",
103
+ name: "Mail",
104
+ path: "/mail",
105
+ url: null,
106
+ status: "ready",
107
+ },
108
+ {
109
+ id: "calendar",
110
+ name: "Calendar",
111
+ path: "/calendar",
112
+ url: null,
113
+ status: "ready",
114
+ },
115
+ {
116
+ id: "documents",
117
+ name: "Documents",
118
+ path: "/documents",
119
+ url: null,
120
+ status: "ready",
121
+ },
122
+ {
123
+ id: "settings",
124
+ name: "Settings",
125
+ path: "/settings",
126
+ url: null,
127
+ status: "ready",
128
+ },
129
+ ],
84
130
  isError: false,
85
131
  isLoading: false,
86
132
  refetch: vi.fn(),
@@ -108,6 +154,10 @@ describe("WorkspaceAppKeepAlive", () => {
108
154
  root = createRoot(container);
109
155
  clientState.actionNames.length = 0;
110
156
  clientState.legacyMutateAsync.mockClear();
157
+ clientState.legacyMutateError = null;
158
+ clientState.legacyErrorMutateAsync.mockReset();
159
+ clientState.frameLoadHandler = null;
160
+ clientState.suppressFrameLoad = false;
111
161
  clientState.workspaceSsoMutateAsync.mockClear();
112
162
  clientState.theme = "dark";
113
163
  clientState.workspaceSsoEnabled = false;
@@ -150,6 +200,30 @@ describe("WorkspaceAppKeepAlive", () => {
150
200
  expect(container.querySelectorAll("iframe")).toHaveLength(2);
151
201
  });
152
202
 
203
+ it("resolves a granted external app instead of showing app not found", async () => {
204
+ await act(async () => {
205
+ root.render(
206
+ <WorkspaceAppKeepAlive activeAppId="analytics.agent-native.com" />,
207
+ );
208
+ await Promise.resolve();
209
+ await Promise.resolve();
210
+ });
211
+
212
+ expect(
213
+ container.querySelector(
214
+ '[data-dispatch-workspace-app-cache-entry="analytics.agent-native.com"]',
215
+ ),
216
+ ).not.toBeNull();
217
+ expect(
218
+ container.querySelector('[data-chat-first-app-status="ready"]'),
219
+ ).not.toBeNull();
220
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
221
+ app: "analytics.agent-native.com",
222
+ url: "https://analytics.agent-native.com",
223
+ chrome: "minimal",
224
+ });
225
+ });
226
+
153
227
  it("uses the app-scoped workspace session action when the rollout is enabled", async () => {
154
228
  clientState.workspaceSsoEnabled = true;
155
229
 
@@ -220,6 +294,44 @@ describe("WorkspaceAppKeepAlive", () => {
220
294
  );
221
295
  });
222
296
 
297
+ it("clears the embed error after a direct fallback iframe loads", async () => {
298
+ clientState.legacyMutateError = new Error("Restore request failed");
299
+ clientState.legacyErrorMutateAsync.mockRejectedValue(
300
+ clientState.legacyMutateError,
301
+ );
302
+ clientState.suppressFrameLoad = true;
303
+
304
+ await act(async () => {
305
+ root.render(
306
+ <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
307
+ );
308
+ await Promise.resolve();
309
+ await Promise.resolve();
310
+ });
311
+
312
+ const iframe = container.querySelector<HTMLIFrameElement>("iframe");
313
+ expect(iframe).not.toBeNull();
314
+ expect(
315
+ container
316
+ .querySelector("[data-chat-first-app-error]")
317
+ ?.getAttribute("data-chat-first-app-error"),
318
+ ).toBe("Restore request failed");
319
+
320
+ if (!iframe) throw new Error("Workspace app iframe was not rendered");
321
+ expect(clientState.frameLoadHandler).not.toBeNull();
322
+
323
+ await act(async () => {
324
+ clientState.frameLoadHandler?.();
325
+ await Promise.resolve();
326
+ });
327
+
328
+ expect(
329
+ container
330
+ .querySelector("[data-chat-first-app-error]")
331
+ ?.getAttribute("data-chat-first-app-error"),
332
+ ).toBe("");
333
+ });
334
+
223
335
  it("evicts the oldest inactive app after reaching the keep-alive limit", async () => {
224
336
  await act(async () => {
225
337
  root.render(<WorkspaceAppKeepAlive activeAppId="mail" />);
@@ -23,6 +23,7 @@ import { Link } from "react-router";
23
23
 
24
24
  import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
25
25
  import {
26
+ mergeChatFirstWorkspaceApps,
26
27
  workspaceAppDirectHref,
27
28
  workspaceAppEmbedTarget,
28
29
  workspaceAppHref,
@@ -45,6 +46,16 @@ interface EmbedSessionInput {
45
46
  chrome: "minimal";
46
47
  }
47
48
 
49
+ interface GrantedWorkspaceAppSummary {
50
+ id: string;
51
+ name: string;
52
+ url?: string | null;
53
+ }
54
+
55
+ interface GrantedWorkspaceAppsResult {
56
+ apps: GrantedWorkspaceAppSummary[];
57
+ }
58
+
48
59
  type WorkspaceAppTheme = "light" | "dark";
49
60
  type WorkspaceAppAuthState = "unknown" | "authenticated" | "unauthenticated";
50
61
 
@@ -118,6 +129,7 @@ export function WorkspaceAppFrame({
118
129
  : "light";
119
130
  const [embedUrl, setEmbedUrl] = useState<string | null>(null);
120
131
  const [embedError, setEmbedError] = useState<Error | null>(null);
132
+ const [isDirectFallback, setIsDirectFallback] = useState(false);
121
133
  const [embedAttempt, setEmbedAttempt] = useState(0);
122
134
  const [authState, setAuthState] = useState<WorkspaceAppAuthState>("unknown");
123
135
  const embedFrameRef = useRef<HTMLIFrameElement>(null);
@@ -129,6 +141,7 @@ export function WorkspaceAppFrame({
129
141
  }, [theme]);
130
142
  const handleFrameLoad = useCallback(() => {
131
143
  postThemeToFrame();
144
+ if (isDirectFallback) setEmbedError(null);
132
145
  const frame = embedFrameRef.current;
133
146
  let frameUrl = embedUrl;
134
147
  try {
@@ -139,7 +152,7 @@ export function WorkspaceAppFrame({
139
152
  }
140
153
  const nextAuthState = resolveWorkspaceAppAuthState(frameUrl);
141
154
  if (nextAuthState !== "unknown") setAuthState(nextAuthState);
142
- }, [embedUrl, postThemeToFrame]);
155
+ }, [embedUrl, isDirectFallback, postThemeToFrame]);
143
156
  const workspaceSsoEnabled = useFeatureFlag(DISPATCH_WORKSPACE_SSO_FLAG.key);
144
157
  const createEmbedSession = useActionMutation<
145
158
  EmbedSessionResult,
@@ -176,6 +189,7 @@ export function WorkspaceAppFrame({
176
189
  let cancelled = false;
177
190
  setEmbedUrl(null);
178
191
  setEmbedError(null);
192
+ setIsDirectFallback(false);
179
193
  setAuthState("unknown");
180
194
  const createSession = workspaceSsoEnabled
181
195
  ? createWorkspaceSsoEmbedSession
@@ -187,6 +201,7 @@ export function WorkspaceAppFrame({
187
201
  })
188
202
  .catch((cause: unknown) => {
189
203
  if (cancelled) return;
204
+ setIsDirectFallback(true);
190
205
  setEmbedUrl(
191
206
  workspaceAppDirectHref(
192
207
  { path: app.path ?? "", url: app.url },
@@ -326,23 +341,58 @@ export function WorkspaceAppFrame({
326
341
 
327
342
  export function WorkspaceAppHost({ appId }: { appId?: string }) {
328
343
  const t = useT();
329
- const appsQuery = useActionQuery("list-workspace-apps", {
330
- includeAgentCards: false,
331
- });
332
- const { data: apps = [], isLoading } = appsQuery;
344
+ const workspaceAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
345
+ "list-workspace-apps",
346
+ { includeAgentCards: false },
347
+ );
348
+ const grantedAppsQuery = useActionQuery<GrantedWorkspaceAppsResult>(
349
+ "list_apps",
350
+ {},
351
+ );
352
+ const apps = useMemo(() => {
353
+ const merged = new Map<string, WorkspaceAppSummary>();
354
+
355
+ for (const app of mergeChatFirstWorkspaceApps(workspaceAppsQuery.data)) {
356
+ merged.set(app.id.trim().toLowerCase(), app);
357
+ }
358
+ for (const app of grantedAppsQuery.data?.apps ?? []) {
359
+ const id = app.id.trim();
360
+ if (!id || merged.has(id.toLowerCase())) continue;
361
+ merged.set(id.toLowerCase(), {
362
+ id,
363
+ name: app.name.trim() || id,
364
+ path: "",
365
+ url: app.url?.trim() || null,
366
+ status: "ready",
367
+ });
368
+ }
369
+
370
+ return [...merged.values()];
371
+ }, [grantedAppsQuery.data?.apps, workspaceAppsQuery.data]);
333
372
  const app = useMemo(
334
373
  () =>
335
- (apps as WorkspaceAppSummary[]).find((item) => item.id === appId) ?? null,
374
+ apps.find(
375
+ (item) => item.id.trim().toLowerCase() === appId?.trim().toLowerCase(),
376
+ ) ?? null,
336
377
  [appId, apps],
337
378
  );
379
+ const isLoading = workspaceAppsQuery.isLoading || grantedAppsQuery.isLoading;
380
+ const queryError = workspaceAppsQuery.isError
381
+ ? workspaceAppsQuery.error
382
+ : grantedAppsQuery.isError
383
+ ? grantedAppsQuery.error
384
+ : null;
338
385
 
339
- if (appsQuery.isError) {
386
+ if (queryError && !app) {
340
387
  return (
341
388
  <div className="flex h-full min-h-0 items-center justify-center p-6">
342
389
  <div className="w-full max-w-2xl">
343
390
  <ActionQueryError
344
- error={appsQuery.error}
345
- onRetry={() => void appsQuery.refetch()}
391
+ error={queryError}
392
+ onRetry={() => {
393
+ void workspaceAppsQuery.refetch();
394
+ void grantedAppsQuery.refetch();
395
+ }}
346
396
  />
347
397
  </div>
348
398
  </div>
@@ -4,6 +4,7 @@ import {
4
4
  isDefaultWorkspaceAppHiddenId,
5
5
  isDispatchWorkspaceAppId,
6
6
  isWorkspaceAppVisibleInDefaultLaunchers,
7
+ mergeChatFirstWorkspaceApps,
7
8
  workspaceAppIdFromRoute,
8
9
  workspaceAppRoute,
9
10
  } from "./workspace-apps";
@@ -41,4 +42,54 @@ describe("workspace app routes", () => {
41
42
  ).toBe(false);
42
43
  expect(isWorkspaceAppVisibleInDefaultLaunchers({ id: "mail" })).toBe(true);
43
44
  });
45
+
46
+ it("maps default first-party apps to their canonical hosted origins", () => {
47
+ const apps = mergeChatFirstWorkspaceApps(undefined);
48
+ expect(apps).toEqual(
49
+ expect.arrayContaining([
50
+ expect.objectContaining({
51
+ id: "content",
52
+ path: "/",
53
+ url: "https://content.agent-native.com",
54
+ }),
55
+ expect.objectContaining({
56
+ id: "design",
57
+ path: "/",
58
+ url: "https://design.agent-native.com",
59
+ }),
60
+ expect.objectContaining({
61
+ id: "mail",
62
+ path: "/",
63
+ url: "https://mail.agent-native.com",
64
+ }),
65
+ expect.objectContaining({
66
+ id: "calendar",
67
+ path: "/",
68
+ url: "https://calendar.agent-native.com",
69
+ }),
70
+ expect.objectContaining({
71
+ id: "clips",
72
+ path: "/",
73
+ url: "https://clips.agent-native.com",
74
+ }),
75
+ ]),
76
+ );
77
+ });
78
+
79
+ it("lets a mounted workspace app override a default row", () => {
80
+ const apps = mergeChatFirstWorkspaceApps([
81
+ {
82
+ id: "mail",
83
+ name: "Internal Mail",
84
+ path: "/internal-mail",
85
+ url: null,
86
+ status: "ready",
87
+ },
88
+ ]);
89
+ expect(apps.find((app) => app.id === "mail")).toMatchObject({
90
+ name: "Internal Mail",
91
+ path: "/internal-mail",
92
+ url: null,
93
+ });
94
+ });
44
95
  });
@@ -1,6 +1,8 @@
1
1
  import { CHAT_FIRST_DEFAULT_APP_IDS } from "@agent-native/core/client/chat-first";
2
2
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
3
3
 
4
+ import { CANONICAL_WORKSPACE_SSO_APP_ORIGINS } from "../shared/workspace-sso";
5
+
4
6
  export interface WorkspaceAppSummary {
5
7
  id: string;
6
8
  name: string;
@@ -169,8 +171,11 @@ export function mergeChatFirstWorkspaceApps(
169
171
  merged.set(id, {
170
172
  id,
171
173
  name: id.charAt(0).toUpperCase() + id.slice(1),
172
- path: `/${id}`,
173
- url: null,
174
+ // The five default rows are hosted sibling apps, not routes owned by
175
+ // Dispatch. Keep a mounted path for legacy callers, but give embed
176
+ // session resolution the exact canonical origin.
177
+ path: "/",
178
+ url: CANONICAL_WORKSPACE_SSO_APP_ORIGINS[id],
174
179
  status: "ready",
175
180
  });
176
181
  }
@@ -102,9 +102,11 @@ describe("vault authorization", () => {
102
102
  execute: vi.fn().mockResolvedValue({ rows: [{ role: "member" }] }),
103
103
  });
104
104
 
105
- await expect(assertCanManageVault()).rejects.toThrow(
106
- "Only organization owners and admins can manage the workspace vault.",
107
- );
105
+ await expect(assertCanManageVault()).rejects.toMatchObject({
106
+ message:
107
+ "Only organization owners and admins can manage the workspace vault.",
108
+ statusCode: 403,
109
+ });
108
110
  });
109
111
 
110
112
  it("rejects organization members before listing or reading grants", async () => {