@agent-native/dispatch 0.23.0 → 0.23.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.
@@ -392,6 +392,7 @@ describe("Dispatch NavContent", () => {
392
392
  <TooltipProvider>
393
393
  <NavContent
394
394
  chatFirstMode
395
+ collapsible
395
396
  chatFirstApps={[{ id: "mail", name: "Mail" }]}
396
397
  />
397
398
  </TooltipProvider>
@@ -403,7 +404,13 @@ describe("Dispatch NavContent", () => {
403
404
  expect(container.querySelector("[data-chat-first-app]")).not.toBeNull();
404
405
  expect(
405
406
  container.querySelector("[data-chat-first-app] span[style]"),
406
- ).toBeNull();
407
+ ).not.toBeNull();
408
+ expect(
409
+ container.querySelector("[data-sidebar-footer-feedback]"),
410
+ ).not.toBeNull();
411
+ expect(
412
+ container.querySelector("[data-sidebar-footer-collapse]"),
413
+ ).not.toBeNull();
407
414
  });
408
415
 
409
416
  it("opens a workspace app in the main app route from the left rail", async () => {
@@ -23,6 +23,7 @@ import {
23
23
  type ChatFirstAppRegistration,
24
24
  type ChatFirstAppLayoutPreference,
25
25
  type ChatFirstAppResolution,
26
+ type ChatFirstAppSurfacePlacement,
26
27
  type ChatFirstOpenBrowserDetail,
27
28
  type ChatFirstOpenAppDetail,
28
29
  type ChatFirstSessionReference,
@@ -199,6 +200,7 @@ const CHAT_FIRST_PANE_STATE_KEY = "chat-first-pane";
199
200
 
200
201
  interface DispatchChatFirstPane {
201
202
  appId: string;
203
+ placement?: ChatFirstAppSurfacePlacement;
202
204
  path?: string;
203
205
  view?: string;
204
206
  }
@@ -461,6 +463,9 @@ function persistedChatFirstPane(value: unknown): DispatchChatFirstPane | null {
461
463
  if (typeof record.appId !== "string" || !record.appId.trim()) return null;
462
464
  return {
463
465
  appId: record.appId,
466
+ ...(record.placement === "main" || record.placement === "side"
467
+ ? { placement: record.placement }
468
+ : {}),
464
469
  ...(typeof record.path === "string" ? { path: record.path } : {}),
465
470
  ...(typeof record.view === "string" ? { view: record.view } : {}),
466
471
  };
@@ -678,7 +683,7 @@ function DispatchChatsSection({
678
683
  )}
679
684
  >
680
685
  {showNewChat && chatFirstNavigation ? (
681
- <nav className="space-y-0.5 px-2 py-2">
686
+ <nav className="space-y-0.5 px-0 py-2">
682
687
  <ChatFirstPrimaryNavigation
683
688
  copy={chatFirstCopy}
684
689
  onNewChat={() => void handleNewChat()}
@@ -1025,8 +1030,7 @@ export function NavContent({
1025
1030
  id={app.id}
1026
1031
  name={app.name}
1027
1032
  size="sm"
1028
- monochrome
1029
- className="size-5 rounded-md"
1033
+ className="size-7 rounded-lg"
1030
1034
  />
1031
1035
  )}
1032
1036
  copy={chatFirstCopy}
@@ -1257,7 +1261,7 @@ export function NavContent({
1257
1261
  <div className="mt-auto shrink-0">
1258
1262
  {bottomNavigation}
1259
1263
  {organizationPicker}
1260
- {collapsed ? sidebarFooterActions : null}
1264
+ {sidebarFooterActions}
1261
1265
  </div>
1262
1266
  ) : null}
1263
1267
  </>
@@ -1302,6 +1306,7 @@ export function Layout({
1302
1306
  new URLSearchParams(window.location.search).get("chatFirst") === "1";
1303
1307
  const chatFirstMode =
1304
1308
  extensions?.chatFirst === true || chatFirstPreference || chatFirstEmbedded;
1309
+ const chatFirstHasActiveChat = chatFirstSurfaceScope !== "new";
1305
1310
  const [chatFirstAppLayout, setChatFirstAppLayout] =
1306
1311
  useState<ChatFirstAppLayoutPreference>(() => readChatFirstAppLayout());
1307
1312
  const chatFirstAppLayoutHydratedRef = useRef(false);
@@ -1344,11 +1349,13 @@ export function Layout({
1344
1349
  }, [chatFirstGrantedAppsQuery.data?.apps, chatFirstWorkspaceApps]);
1345
1350
  const chatFirstAppItems = useMemo<ChatFirstAppItem[]>(
1346
1351
  () =>
1347
- chatFirstWorkspaceApps.map((app) => ({
1348
- id: app.id,
1349
- name: app.name,
1350
- })),
1351
- [chatFirstWorkspaceApps],
1352
+ chatFirstAppRegistrations
1353
+ .filter((app) => app.enabled)
1354
+ .map((app) => ({
1355
+ id: app.id,
1356
+ name: app.name ?? app.id,
1357
+ })),
1358
+ [chatFirstAppRegistrations],
1352
1359
  );
1353
1360
  const chatFirstCopy = useMemo(() => createDispatchChatFirstCopy(t), [t]);
1354
1361
  const createChatFirstEmbedSession = useActionMutation<
@@ -1399,7 +1406,10 @@ export function Layout({
1399
1406
  [chatFirstSurfaceTabs],
1400
1407
  );
1401
1408
  const chatFirstAppTakesMain =
1402
- chatFirstMode && isChatRoute && activeChatFirstSurfaceTab?.kind === "app";
1409
+ chatFirstMode &&
1410
+ isChatRoute &&
1411
+ activeChatFirstSurfaceTab?.kind === "app" &&
1412
+ activeChatFirstSurfaceTab.placement === "main";
1403
1413
  const activeChatFirstAppRegistration = useMemo(
1404
1414
  () =>
1405
1415
  activeChatFirstSurfaceTab?.kind === "app" &&
@@ -1498,7 +1508,11 @@ export function Layout({
1498
1508
  [],
1499
1509
  );
1500
1510
  const openChatFirstPane = useCallback(
1501
- (pane: DispatchChatFirstPane) => {
1511
+ (
1512
+ pane: DispatchChatFirstPane,
1513
+ placement: ChatFirstAppSurfacePlacement = "side",
1514
+ ) => {
1515
+ if (placement === "side" && !chatFirstHasActiveChat) return;
1502
1516
  setChatFirstNotice(null);
1503
1517
  closeChatFirstSessionWatch();
1504
1518
  if (!isChatRoute) {
@@ -1518,15 +1532,17 @@ export function Layout({
1518
1532
  kind: "app",
1519
1533
  title: app?.name ?? pane.appId,
1520
1534
  appId: pane.appId,
1535
+ placement,
1521
1536
  ...(pane.path ? { path: pane.path } : {}),
1522
1537
  ...(pane.view ? { view: pane.view } : {}),
1523
1538
  });
1524
1539
  setChatFirstSurfacePanelOpen(true);
1525
- persistChatFirstPane(pane);
1540
+ persistChatFirstPane({ ...pane, placement });
1526
1541
  },
1527
1542
  [
1528
1543
  chatFirstAppRegistrations,
1529
1544
  chatFirstSurfaceTabsStore,
1545
+ chatFirstHasActiveChat,
1530
1546
  persistChatFirstPane,
1531
1547
  isChatRoute,
1532
1548
  navigate,
@@ -1535,6 +1551,17 @@ export function Layout({
1535
1551
  );
1536
1552
  const openChatFirstSurface = useCallback(
1537
1553
  (kind: ChatFirstSurfaceKind) => {
1554
+ if (kind === "browser") {
1555
+ persistChatFirstPane(null);
1556
+ closeChatFirstSessionWatch();
1557
+ chatFirstSurfaceTabsStore.open({
1558
+ id: chatFirstSurfaceTabId("browser", "homepage"),
1559
+ kind: "browser",
1560
+ title: "Browser",
1561
+ url: "https://www.google.com/",
1562
+ });
1563
+ return;
1564
+ }
1538
1565
  if (kind !== "agents") return;
1539
1566
  persistChatFirstPane(null);
1540
1567
  closeChatFirstSessionWatch();
@@ -1555,6 +1582,24 @@ export function Layout({
1555
1582
  );
1556
1583
  return;
1557
1584
  }
1585
+ if (resolution.target.openExternally && typeof window !== "undefined") {
1586
+ try {
1587
+ // Builder's Visual Editor rejects iframe ancestors with CSP/X-Frame-
1588
+ // Options. Prefer the real browser tab; the browser pane below is a
1589
+ // visible fallback when popup policy blocks this non-click event.
1590
+ if (
1591
+ window.open(resolution.target.url, "_blank", "noopener,noreferrer")
1592
+ ) {
1593
+ return;
1594
+ }
1595
+ } catch (error) {
1596
+ console.warn(
1597
+ "[chat-first] external browser open failed; keeping link fallback",
1598
+ error,
1599
+ );
1600
+ // Fall through to the non-iframe browser pane below.
1601
+ }
1602
+ }
1558
1603
  persistChatFirstPane(null);
1559
1604
  closeChatFirstSessionWatch();
1560
1605
  setChatFirstNotice(null);
@@ -1731,7 +1776,9 @@ export function Layout({
1731
1776
  : window.location.origin,
1732
1777
  },
1733
1778
  );
1734
- if (resolution.status === "ready") openChatFirstPane(resolution.target);
1779
+ if (resolution.status === "ready") {
1780
+ openChatFirstPane(resolution.target, persisted.placement);
1781
+ }
1735
1782
  })
1736
1783
  .catch(() => {
1737
1784
  if (active) {
@@ -1777,7 +1824,7 @@ export function Layout({
1777
1824
  useEffect(() => {
1778
1825
  const tabCount = chatFirstSurfaceTabs.tabs.length;
1779
1826
  const previousTabCount = previousChatFirstSurfaceTabCountRef.current;
1780
- if (!chatFirstMode) {
1827
+ if (!chatFirstMode || !chatFirstHasActiveChat) {
1781
1828
  setChatFirstSurfacePanelOpen(false);
1782
1829
  } else if (
1783
1830
  tabCount > 0 &&
@@ -1794,6 +1841,7 @@ export function Layout({
1794
1841
  previousChatFirstSurfaceTabCountRef.current = tabCount;
1795
1842
  }, [
1796
1843
  chatFirstMode,
1844
+ chatFirstHasActiveChat,
1797
1845
  setChatFirstSurfacePanelOpen,
1798
1846
  chatFirstSurfaceTabs.tabs.length,
1799
1847
  isChatRoute,
@@ -1864,6 +1912,7 @@ export function Layout({
1864
1912
  closeChatFirstSessionWatch();
1865
1913
  persistChatFirstPane({
1866
1914
  appId: tab.appId,
1915
+ placement: tab.placement,
1867
1916
  ...(tab.path ? { path: tab.path } : {}),
1868
1917
  ...(tab.view ? { view: tab.view } : {}),
1869
1918
  });
@@ -2096,7 +2145,7 @@ export function Layout({
2096
2145
  <div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden">
2097
2146
  {showHeader ? <Header onOpenMobile={() => setMobileOpen(true)} /> : null}
2098
2147
  <InvitationBanner />
2099
- {isChatRoute && chatFirstMode ? (
2148
+ {isChatRoute && chatFirstMode && chatFirstHasActiveChat ? (
2100
2149
  <ChatFirstSurfacePanelToggle
2101
2150
  open={chatFirstSurfacePanel.open}
2102
2151
  onToggle={chatFirstSurfacePanel.toggle}
@@ -2160,6 +2209,16 @@ export function Layout({
2160
2209
  }}
2161
2210
  onCloseAll={closeAllChatFirstSurfaceTabs}
2162
2211
  onOpenSurface={openChatFirstSurface}
2212
+ apps={chatFirstAppItems}
2213
+ onOpenApp={(app) => openChatFirstPane({ appId: app.id }, "side")}
2214
+ renderAppIcon={(app) => (
2215
+ <AppIcon
2216
+ id={app.id}
2217
+ name={app.name}
2218
+ size="sm"
2219
+ className="size-7 rounded-lg"
2220
+ />
2221
+ )}
2163
2222
  copy={chatFirstCopy}
2164
2223
  />
2165
2224
  );
@@ -2188,7 +2247,9 @@ export function Layout({
2188
2247
  ) : (
2189
2248
  <>
2190
2249
  {appContent}
2191
- {chatFirstMode && chatFirstSurfacePanel.open ? (
2250
+ {chatFirstMode &&
2251
+ chatFirstHasActiveChat &&
2252
+ chatFirstSurfacePanel.open ? (
2192
2253
  <ChatFirstSurfacePanel
2193
2254
  width={chatFirstSurfaceResize.width}
2194
2255
  onResizePointerDown={chatFirstSurfaceResize.onPointerDown}
@@ -2249,7 +2310,9 @@ export function Layout({
2249
2310
  ? activeChatFirstSurfaceTab.appId
2250
2311
  : undefined
2251
2312
  }
2252
- onChatFirstAppOpen={(app) => openChatFirstPane({ appId: app.id })}
2313
+ onChatFirstAppOpen={(app) =>
2314
+ openChatFirstPane({ appId: app.id }, "main")
2315
+ }
2253
2316
  onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
2254
2317
  collapsible
2255
2318
  onCollapsedChange={setSidebarCollapsed}
@@ -2259,7 +2322,7 @@ export function Layout({
2259
2322
  <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
2260
2323
  <SheetContent
2261
2324
  side="left"
2262
- className="w-72 p-0 bg-sidebar text-sidebar-foreground [&>button]:hidden"
2325
+ className="w-72 bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
2263
2326
  >
2264
2327
  <SheetTitle className="sr-only">
2265
2328
  {t("dispatch.nav.navigation")}
@@ -2288,7 +2351,7 @@ export function Layout({
2288
2351
  : undefined
2289
2352
  }
2290
2353
  onChatFirstAppOpen={(app) =>
2291
- openChatFirstPane({ appId: app.id })
2354
+ openChatFirstPane({ appId: app.id }, "main")
2292
2355
  }
2293
2356
  onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
2294
2357
  onNavigate={() => setMobileOpen(false)}
@@ -174,7 +174,7 @@ export default function ChatRoute() {
174
174
  <DispatchAgentChatSurface
175
175
  mode="page"
176
176
  chatViewTransition
177
- className="dispatch-chat-panel"
177
+ className="dispatch-chat-panel px-4 sm:px-6"
178
178
  defaultMode="chat"
179
179
  storageKey="dispatch"
180
180
  threadUrlSync={threadUrlSync}
@@ -880,6 +880,54 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
880
880
  ).rejects.toThrow(/safe app-relative route/);
881
881
  });
882
882
 
883
+ it("resolves target-relative embed start URLs against the granted app", async () => {
884
+ mocks.managerCallTool.mockResolvedValueOnce({
885
+ structuredContent: {
886
+ startUrl: "/_agent-native/embed/start?ticket=relative",
887
+ },
888
+ });
889
+
890
+ const result = await runWithRequestContext(
891
+ {
892
+ userEmail: "owner@example.test",
893
+ requestOrigin: "http://localhost:8092",
894
+ },
895
+ () =>
896
+ createGrantedDispatchMcpEmbedSession({
897
+ app: "analytics",
898
+ path: "/overview",
899
+ chrome: "minimal",
900
+ }),
901
+ );
902
+
903
+ expect(result.startUrl).toBe(
904
+ "http://localhost:8086/_agent-native/embed/start?ticket=relative",
905
+ );
906
+ });
907
+
908
+ it("rejects cross-origin embed start URLs from a granted app", async () => {
909
+ mocks.managerCallTool.mockResolvedValueOnce({
910
+ structuredContent: {
911
+ startUrl: "https://attacker.example/steal?ticket=remote",
912
+ },
913
+ });
914
+
915
+ await expect(
916
+ runWithRequestContext(
917
+ {
918
+ userEmail: "owner@example.test",
919
+ requestOrigin: "http://localhost:8092",
920
+ },
921
+ () =>
922
+ createGrantedDispatchMcpEmbedSession({
923
+ app: "analytics",
924
+ path: "/overview",
925
+ chrome: "minimal",
926
+ }),
927
+ ),
928
+ ).rejects.toThrow(/invalid embed start URL/);
929
+ });
930
+
883
931
  it("rejects a URL that does not belong to the explicitly named app", async () => {
884
932
  await expect(
885
933
  runWithRequestContext(
@@ -905,6 +953,12 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
905
953
  url: "http://localhost:8092/analytics",
906
954
  },
907
955
  ]);
956
+ mocks.managerCallTool.mockResolvedValueOnce({
957
+ structuredContent: {
958
+ startUrl:
959
+ "http://localhost:8092/analytics/_agent-native/embed/start?ticket=remote",
960
+ },
961
+ });
908
962
 
909
963
  const result = await runWithRequestContext(
910
964
  {
@@ -934,7 +988,8 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
934
988
  );
935
989
  expect(result).toEqual({
936
990
  app: "analytics",
937
- startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
991
+ startUrl:
992
+ "http://localhost:8092/analytics/_agent-native/embed/start?ticket=remote",
938
993
  });
939
994
  });
940
995
 
@@ -955,6 +1010,12 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
955
1010
  color: "#2563EB",
956
1011
  },
957
1012
  ]);
1013
+ mocks.managerCallTool.mockResolvedValueOnce({
1014
+ structuredContent: {
1015
+ startUrl:
1016
+ "https://mail.agent-native.com/_agent-native/embed/start?ticket=remote",
1017
+ },
1018
+ });
958
1019
 
959
1020
  const result = await runWithRequestContext(
960
1021
  {
@@ -976,7 +1037,8 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
976
1037
  });
977
1038
  expect(result).toEqual({
978
1039
  app: "mail",
979
- startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
1040
+ startUrl:
1041
+ "https://mail.agent-native.com/_agent-native/embed/start?ticket=remote",
980
1042
  });
981
1043
  });
982
1044
 
@@ -451,6 +451,24 @@ function appBaseUrl(app: DispatchMcpAccessibleApp): string {
451
451
  return app.url.replace(/\/+$/, "");
452
452
  }
453
453
 
454
+ function resolveGrantedAppEmbedStartUrl(
455
+ app: DispatchMcpAccessibleApp,
456
+ startUrl: string,
457
+ ): string {
458
+ try {
459
+ const baseUrl = appBaseUrl(app);
460
+ const resolved = new URL(startUrl, `${baseUrl}/`);
461
+ if (!appMatchesUrlPath(app, resolved)) {
462
+ throw new Error(
463
+ "Target app returned an embed start URL outside the granted app.",
464
+ );
465
+ }
466
+ return resolved.toString();
467
+ } catch {
468
+ throw new Error("Target app returned an invalid embed start URL.");
469
+ }
470
+ }
471
+
454
472
  function appBasePath(app: DispatchMcpAccessibleApp): string {
455
473
  const pathname = new URL(appBaseUrl(app)).pathname.replace(/\/+$/, "");
456
474
  return pathname === "/" ? "" : pathname;
@@ -994,7 +1012,7 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
994
1012
  expiresAt?: number;
995
1013
  app: string;
996
1014
  } = {
997
- startUrl: parsed.startUrl,
1015
+ startUrl: resolveGrantedAppEmbedStartUrl(target.app, parsed.startUrl),
998
1016
  app: target.app.id,
999
1017
  };
1000
1018
  if (parsed.targetPath) output.targetPath = parsed.targetPath;
@@ -39,6 +39,17 @@ describe("dispatch Tailwind styles", () => {
39
39
  '@import "@agent-native/dispatch/styles/dispatch.css";',
40
40
  );
41
41
  });
42
+
43
+ it("keeps the full-page composer on the shared wide width contract", () => {
44
+ const stylesheet = fs.readFileSync(
45
+ path.join(packageRoot, "src/styles/dispatch.css"),
46
+ "utf-8",
47
+ );
48
+
49
+ expect(stylesheet).toMatch(
50
+ /\.dispatch-chat-panel \[data-agent-empty-state="centered"\] \.agent-composer-area \{[\s\S]*?max-width: min\(750px, 100%\);/,
51
+ );
52
+ });
42
53
  });
43
54
 
44
55
  describe("dispatch route shells", () => {
@@ -79,7 +79,7 @@
79
79
 
80
80
  .dispatch-chat-panel [data-agent-empty-state="centered"] .agent-composer-area {
81
81
  width: 100%;
82
- max-width: min(750px, 90%);
82
+ max-width: min(750px, 100%);
83
83
  padding: 0;
84
84
  }
85
85