@agent-native/dispatch 0.26.0 → 0.27.1

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 +12 -12
  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 +111 -12
  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 +129 -25
  28. package/src/components/workspace-app-host.tsx +163 -10
  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
  >
@@ -14,13 +14,28 @@ const clientState = vi.hoisted(() => {
14
14
  const actionNames: string[] = [];
15
15
  return {
16
16
  actionNames,
17
+ grantedApps: [
18
+ {
19
+ id: "analytics.agent-native.com",
20
+ name: "Analytics",
21
+ url: "https://analytics.agent-native.com",
22
+ },
23
+ ],
17
24
  legacyMutateAsync,
25
+ theme: "dark" as "dark" | "light",
18
26
  workspaceSsoEnabled: false,
19
27
  workspaceSsoMutateAsync,
20
28
  };
21
29
  });
22
30
 
23
31
  vi.mock("@agent-native/core/client/chat-first", () => ({
32
+ CHAT_FIRST_DEFAULT_APP_IDS: [
33
+ "content",
34
+ "design",
35
+ "mail",
36
+ "calendar",
37
+ "clips",
38
+ ],
24
39
  ChatFirstAppPane: ({
25
40
  app,
26
41
  embedUrl,
@@ -55,31 +70,40 @@ vi.mock("@agent-native/core/client/hooks", () => ({
55
70
  : clientState.legacyMutateAsync,
56
71
  };
57
72
  },
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
- ],
73
+ useActionQuery: (name: string) => ({
74
+ data:
75
+ name === "list_apps"
76
+ ? { apps: clientState.grantedApps }
77
+ : [
78
+ {
79
+ id: "mail",
80
+ name: "Mail",
81
+ path: "/mail",
82
+ url: null,
83
+ status: "ready",
84
+ },
85
+ {
86
+ id: "calendar",
87
+ name: "Calendar",
88
+ path: "/calendar",
89
+ url: null,
90
+ status: "ready",
91
+ },
92
+ {
93
+ id: "documents",
94
+ name: "Documents",
95
+ path: "/documents",
96
+ url: null,
97
+ status: "ready",
98
+ },
99
+ {
100
+ id: "settings",
101
+ name: "Settings",
102
+ path: "/settings",
103
+ url: null,
104
+ status: "ready",
105
+ },
106
+ ],
83
107
  isError: false,
84
108
  isLoading: false,
85
109
  refetch: vi.fn(),
@@ -90,6 +114,10 @@ vi.mock("@agent-native/core/client/i18n", () => ({
90
114
  useT: () => (key: string) => key,
91
115
  }));
92
116
 
117
+ vi.mock("next-themes", () => ({
118
+ useTheme: () => ({ resolvedTheme: clientState.theme }),
119
+ }));
120
+
93
121
  import { WorkspaceAppFrame, WorkspaceAppKeepAlive } from "./workspace-app-host";
94
122
 
95
123
  describe("WorkspaceAppKeepAlive", () => {
@@ -104,6 +132,7 @@ describe("WorkspaceAppKeepAlive", () => {
104
132
  clientState.actionNames.length = 0;
105
133
  clientState.legacyMutateAsync.mockClear();
106
134
  clientState.workspaceSsoMutateAsync.mockClear();
135
+ clientState.theme = "dark";
107
136
  clientState.workspaceSsoEnabled = false;
108
137
  });
109
138
 
@@ -144,6 +173,30 @@ describe("WorkspaceAppKeepAlive", () => {
144
173
  expect(container.querySelectorAll("iframe")).toHaveLength(2);
145
174
  });
146
175
 
176
+ it("resolves a granted external app instead of showing app not found", async () => {
177
+ await act(async () => {
178
+ root.render(
179
+ <WorkspaceAppKeepAlive activeAppId="analytics.agent-native.com" />,
180
+ );
181
+ await Promise.resolve();
182
+ await Promise.resolve();
183
+ });
184
+
185
+ expect(
186
+ container.querySelector(
187
+ '[data-dispatch-workspace-app-cache-entry="analytics.agent-native.com"]',
188
+ ),
189
+ ).not.toBeNull();
190
+ expect(
191
+ container.querySelector('[data-chat-first-app-status="ready"]'),
192
+ ).not.toBeNull();
193
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
194
+ app: "analytics.agent-native.com",
195
+ url: "https://analytics.agent-native.com",
196
+ chrome: "minimal",
197
+ });
198
+ });
199
+
147
200
  it("uses the app-scoped workspace session action when the rollout is enabled", async () => {
148
201
  clientState.workspaceSsoEnabled = true;
149
202
 
@@ -163,6 +216,57 @@ describe("WorkspaceAppKeepAlive", () => {
163
216
  expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
164
217
  });
165
218
 
219
+ it("sends the parent theme on iframe load and when the parent changes", async () => {
220
+ await act(async () => {
221
+ root.render(
222
+ <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
223
+ );
224
+ await Promise.resolve();
225
+ await Promise.resolve();
226
+ });
227
+
228
+ const iframe = container.querySelector<HTMLIFrameElement>("iframe");
229
+ expect(iframe).not.toBeNull();
230
+ if (!iframe) throw new Error("Workspace app iframe was not rendered");
231
+
232
+ const postMessage = vi.fn();
233
+ Object.defineProperty(iframe, "contentWindow", {
234
+ configurable: true,
235
+ value: { postMessage },
236
+ });
237
+
238
+ await act(async () => {
239
+ iframe?.dispatchEvent(new Event("load"));
240
+ await Promise.resolve();
241
+ });
242
+
243
+ expect(postMessage).toHaveBeenCalledWith(
244
+ {
245
+ type: "agent-native-theme-update",
246
+ theme: "dark",
247
+ isDark: true,
248
+ },
249
+ "*",
250
+ );
251
+
252
+ clientState.theme = "light";
253
+ await act(async () => {
254
+ root.render(
255
+ <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
256
+ );
257
+ await Promise.resolve();
258
+ });
259
+
260
+ expect(postMessage).toHaveBeenLastCalledWith(
261
+ {
262
+ type: "agent-native-theme-update",
263
+ theme: "light",
264
+ isDark: false,
265
+ },
266
+ "*",
267
+ );
268
+ });
269
+
166
270
  it("evicts the oldest inactive app after reaching the keep-alive limit", async () => {
167
271
  await act(async () => {
168
272
  root.render(<WorkspaceAppKeepAlive activeAppId="mail" />);
@@ -11,12 +11,19 @@ import {
11
11
  } from "@agent-native/core/client/hooks";
12
12
  import { useT } from "@agent-native/core/client/i18n";
13
13
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
14
- import { IconArrowLeft, IconClockHour4 } from "@tabler/icons-react";
15
- import { useEffect, useMemo, useRef, useState } from "react";
14
+ import {
15
+ IconArrowLeft,
16
+ IconArrowUpRight,
17
+ IconClockHour4,
18
+ IconLock,
19
+ } from "@tabler/icons-react";
20
+ import { useTheme } from "next-themes";
21
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
22
  import { Link } from "react-router";
17
23
 
18
24
  import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
19
25
  import {
26
+ mergeChatFirstWorkspaceApps,
20
27
  workspaceAppDirectHref,
21
28
  workspaceAppEmbedTarget,
22
29
  workspaceAppHref,
@@ -39,6 +46,50 @@ interface EmbedSessionInput {
39
46
  chrome: "minimal";
40
47
  }
41
48
 
49
+ interface GrantedWorkspaceAppSummary {
50
+ id: string;
51
+ name: string;
52
+ url?: string | null;
53
+ }
54
+
55
+ interface GrantedWorkspaceAppsResult {
56
+ apps: GrantedWorkspaceAppSummary[];
57
+ }
58
+
59
+ type WorkspaceAppTheme = "light" | "dark";
60
+ type WorkspaceAppAuthState = "unknown" | "authenticated" | "unauthenticated";
61
+
62
+ function resolveWorkspaceAppAuthState(
63
+ rawUrl: string | null,
64
+ ): WorkspaceAppAuthState {
65
+ if (!rawUrl) return "unknown";
66
+ try {
67
+ const lastSegment = new URL(rawUrl, window.location.origin).pathname
68
+ .split("/")
69
+ .filter(Boolean)
70
+ .at(-1)
71
+ ?.toLowerCase();
72
+ if (
73
+ lastSegment === "sign-in" ||
74
+ lastSegment === "login" ||
75
+ lastSegment === "signup"
76
+ ) {
77
+ return "unauthenticated";
78
+ }
79
+ return "authenticated";
80
+ } catch {
81
+ return "unknown";
82
+ }
83
+ }
84
+
85
+ function buildWorkspaceAppThemeUpdate(theme: WorkspaceAppTheme) {
86
+ return {
87
+ type: "agent-native-theme-update" as const,
88
+ theme,
89
+ isDark: theme === "dark",
90
+ };
91
+ }
92
+
42
93
  export function buildChatFirstEmbedSessionInput(
43
94
  appId: string,
44
95
  path: string,
@@ -68,10 +119,38 @@ export function WorkspaceAppFrame({
68
119
  chatSidebar = false,
69
120
  copy = defaultChatFirstCopy,
70
121
  }: WorkspaceAppFrameProps) {
122
+ const { resolvedTheme } = useTheme();
123
+ const theme: WorkspaceAppTheme =
124
+ resolvedTheme === "dark" || resolvedTheme === "light"
125
+ ? resolvedTheme
126
+ : typeof document !== "undefined" &&
127
+ document.documentElement.classList.contains("dark")
128
+ ? "dark"
129
+ : "light";
71
130
  const [embedUrl, setEmbedUrl] = useState<string | null>(null);
72
131
  const [embedError, setEmbedError] = useState<Error | null>(null);
73
132
  const [embedAttempt, setEmbedAttempt] = useState(0);
133
+ const [authState, setAuthState] = useState<WorkspaceAppAuthState>("unknown");
74
134
  const embedFrameRef = useRef<HTMLIFrameElement>(null);
135
+ const postThemeToFrame = useCallback(() => {
136
+ embedFrameRef.current?.contentWindow?.postMessage(
137
+ buildWorkspaceAppThemeUpdate(theme),
138
+ "*",
139
+ );
140
+ }, [theme]);
141
+ const handleFrameLoad = useCallback(() => {
142
+ postThemeToFrame();
143
+ const frame = embedFrameRef.current;
144
+ let frameUrl = embedUrl;
145
+ try {
146
+ frameUrl = frame?.contentWindow?.location.href ?? frameUrl;
147
+ // coercion-ok: Cross-origin frames cannot expose location; their auth state arrives by message.
148
+ } catch {
149
+ // Cross-origin app frames report their auth state through postMessage.
150
+ }
151
+ const nextAuthState = resolveWorkspaceAppAuthState(frameUrl);
152
+ if (nextAuthState !== "unknown") setAuthState(nextAuthState);
153
+ }, [embedUrl, postThemeToFrame]);
75
154
  const workspaceSsoEnabled = useFeatureFlag(DISPATCH_WORKSPACE_SSO_FLAG.key);
76
155
  const createEmbedSession = useActionMutation<
77
156
  EmbedSessionResult,
@@ -108,6 +187,7 @@ export function WorkspaceAppFrame({
108
187
  let cancelled = false;
109
188
  setEmbedUrl(null);
110
189
  setEmbedError(null);
190
+ setAuthState("unknown");
111
191
  const createSession = workspaceSsoEnabled
112
192
  ? createWorkspaceSsoEmbedSession
113
193
  : createEmbedSession;
@@ -158,6 +238,25 @@ export function WorkspaceAppFrame({
158
238
  window.removeEventListener("message", handleEmbedSessionExpired);
159
239
  }, [embedUrl]);
160
240
 
241
+ useEffect(() => {
242
+ const handleAuthState = (event: MessageEvent) => {
243
+ const frame = embedFrameRef.current;
244
+ if (!frame || event.source !== frame.contentWindow) return;
245
+ if (event.data?.type !== "agentNative.authState") return;
246
+ const status = event.data.data?.status;
247
+ if (status === "authenticated" || status === "unauthenticated") {
248
+ setAuthState(status);
249
+ }
250
+ };
251
+
252
+ window.addEventListener("message", handleAuthState);
253
+ return () => window.removeEventListener("message", handleAuthState);
254
+ }, []);
255
+
256
+ useEffect(() => {
257
+ postThemeToFrame();
258
+ }, [embedUrl, postThemeToFrame]);
259
+
161
260
  const appPane = (
162
261
  <ChatFirstAppPane
163
262
  app={app}
@@ -182,6 +281,7 @@ export function WorkspaceAppFrame({
182
281
  src={url}
183
282
  title={title ?? app.name}
184
283
  ref={embedFrameRef}
284
+ onLoad={handleFrameLoad}
185
285
  referrerPolicy="no-referrer"
186
286
  allow="clipboard-read; clipboard-write"
187
287
  className="h-full w-full border-0 bg-background"
@@ -211,6 +311,24 @@ export function WorkspaceAppFrame({
211
311
  dynamicSuggestions={false}
212
312
  suggestions={[]}
213
313
  emptyStateText={`Ask about ${app.name}`}
314
+ composerSlot={
315
+ authState === "unauthenticated" ? (
316
+ <div className="flex shrink-0 items-center px-3 pb-1">
317
+ <button
318
+ type="button"
319
+ data-dispatch-app-sign-in
320
+ aria-label={`Sign in to ${app.name} on the right`}
321
+ title={`Sign in to ${app.name} on the right`}
322
+ onClick={() => embedFrameRef.current?.focus()}
323
+ className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-background/60 px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
324
+ >
325
+ <IconLock size={12} stroke={1.8} />
326
+ <span>Sign in on the right</span>
327
+ <IconArrowUpRight size={12} stroke={1.8} />
328
+ </button>
329
+ </div>
330
+ ) : null
331
+ }
214
332
  >
215
333
  {appPane}
216
334
  </AgentSidebar>
@@ -219,23 +337,58 @@ export function WorkspaceAppFrame({
219
337
 
220
338
  export function WorkspaceAppHost({ appId }: { appId?: string }) {
221
339
  const t = useT();
222
- const appsQuery = useActionQuery("list-workspace-apps", {
223
- includeAgentCards: false,
224
- });
225
- const { data: apps = [], isLoading } = appsQuery;
340
+ const workspaceAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
341
+ "list-workspace-apps",
342
+ { includeAgentCards: false },
343
+ );
344
+ const grantedAppsQuery = useActionQuery<GrantedWorkspaceAppsResult>(
345
+ "list_apps",
346
+ {},
347
+ );
348
+ const apps = useMemo(() => {
349
+ const merged = new Map<string, WorkspaceAppSummary>();
350
+
351
+ for (const app of mergeChatFirstWorkspaceApps(workspaceAppsQuery.data)) {
352
+ merged.set(app.id.trim().toLowerCase(), app);
353
+ }
354
+ for (const app of grantedAppsQuery.data?.apps ?? []) {
355
+ const id = app.id.trim();
356
+ if (!id || merged.has(id.toLowerCase())) continue;
357
+ merged.set(id.toLowerCase(), {
358
+ id,
359
+ name: app.name.trim() || id,
360
+ path: "",
361
+ url: app.url?.trim() || null,
362
+ status: "ready",
363
+ });
364
+ }
365
+
366
+ return [...merged.values()];
367
+ }, [grantedAppsQuery.data?.apps, workspaceAppsQuery.data]);
226
368
  const app = useMemo(
227
369
  () =>
228
- (apps as WorkspaceAppSummary[]).find((item) => item.id === appId) ?? null,
370
+ apps.find(
371
+ (item) => item.id.trim().toLowerCase() === appId?.trim().toLowerCase(),
372
+ ) ?? null,
229
373
  [appId, apps],
230
374
  );
375
+ const isLoading = workspaceAppsQuery.isLoading || grantedAppsQuery.isLoading;
376
+ const queryError = workspaceAppsQuery.isError
377
+ ? workspaceAppsQuery.error
378
+ : grantedAppsQuery.isError
379
+ ? grantedAppsQuery.error
380
+ : null;
231
381
 
232
- if (appsQuery.isError) {
382
+ if (queryError && !app) {
233
383
  return (
234
384
  <div className="flex h-full min-h-0 items-center justify-center p-6">
235
385
  <div className="w-full max-w-2xl">
236
386
  <ActionQueryError
237
- error={appsQuery.error}
238
- onRetry={() => void appsQuery.refetch()}
387
+ error={queryError}
388
+ onRetry={() => {
389
+ void workspaceAppsQuery.refetch();
390
+ void grantedAppsQuery.refetch();
391
+ }}
239
392
  />
240
393
  </div>
241
394
  </div>