@agent-native/dispatch 0.40.13 → 0.40.14-nightly-20261006033736

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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/actions/provider-api-register.d.ts +10 -10
  3. package/dist/components/connected-app-card.d.ts.map +1 -1
  4. package/dist/components/connected-app-card.js +23 -4
  5. package/dist/components/connected-app-card.js.map +1 -1
  6. package/dist/components/layout/Layout.d.ts.map +1 -1
  7. package/dist/components/layout/Layout.js +116 -60
  8. package/dist/components/layout/Layout.js.map +1 -1
  9. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  10. package/dist/components/layout/workspace-apps-rail.js +35 -9
  11. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  12. package/dist/lib/other-apps.d.ts +1 -0
  13. package/dist/lib/other-apps.d.ts.map +1 -1
  14. package/dist/lib/other-apps.js +29 -1
  15. package/dist/lib/other-apps.js.map +1 -1
  16. package/dist/lib/workspace-apps.d.ts +2 -1
  17. package/dist/lib/workspace-apps.d.ts.map +1 -1
  18. package/dist/lib/workspace-apps.js +15 -5
  19. package/dist/lib/workspace-apps.js.map +1 -1
  20. package/dist/routes/pages/apps.d.ts.map +1 -1
  21. package/dist/routes/pages/apps.js +17 -4
  22. package/dist/routes/pages/apps.js.map +1 -1
  23. package/dist/server/plugins/auth.js +2 -2
  24. package/dist/server/plugins/auth.js.map +1 -1
  25. package/package.json +4 -4
  26. package/src/components/connected-app-card.spec.tsx +100 -0
  27. package/src/components/connected-app-card.tsx +38 -9
  28. package/src/components/layout/Layout.spec.tsx +397 -6
  29. package/src/components/layout/Layout.tsx +173 -85
  30. package/src/components/layout/workspace-apps-rail.spec.tsx +69 -7
  31. package/src/components/layout/workspace-apps-rail.tsx +49 -7
  32. package/src/lib/other-apps.spec.ts +84 -2
  33. package/src/lib/other-apps.ts +39 -1
  34. package/src/lib/workspace-apps.spec.ts +53 -0
  35. package/src/lib/workspace-apps.ts +15 -7
  36. package/src/routes/pages/apps.spec.tsx +132 -0
  37. package/src/routes/pages/apps.tsx +46 -10
  38. package/src/server/plugins/auth.ts +2 -2
@@ -101,6 +101,10 @@ import { Link, useLocation, useNavigate } from "react-router";
101
101
  import { toast } from "sonner";
102
102
 
103
103
  import { useIsMobile } from "../../hooks/use-mobile";
104
+ import {
105
+ filterBuiltInApps,
106
+ type ConnectedAppSummary,
107
+ } from "../../lib/other-apps";
104
108
  import { cn } from "../../lib/utils";
105
109
  import { normalizeWorkspaceAppLayout } from "../../lib/workspace-app-layout";
106
110
  import {
@@ -108,6 +112,7 @@ import {
108
112
  isPathMountedWorkspaceApp,
109
113
  isWorkspaceAppVisibleInDefaultLaunchers,
110
114
  isWorkspaceSsoApp,
115
+ mergeChatFirstWorkspaceApps,
111
116
  navigateToWorkspaceApp,
112
117
  shouldOpenWorkspaceAppInTopWindow,
113
118
  workspaceAppIdFromRoute,
@@ -1458,29 +1463,65 @@ function DispatchLayout({
1458
1463
  workspaceAppRouteActive,
1459
1464
  },
1460
1465
  );
1461
- const chatFirstWorkspaceApps = useMemo(
1466
+ const chatFirstConnectedAppsQuery = useActionQuery<ConnectedAppSummary[]>(
1467
+ "list-connected-agents",
1468
+ {},
1469
+ { enabled: shouldQueryChatFirstApps(isChatRoute, chatFirstMode) },
1470
+ );
1471
+ const chatFirstBuiltinApps = useMemo(
1462
1472
  () =>
1463
- (chatFirstAppsQuery.data ?? []).filter(
1464
- (app) =>
1465
- app.status !== "pending" &&
1466
- app.archived !== true &&
1467
- isWorkspaceAppVisibleInDefaultLaunchers(app),
1473
+ filterBuiltInApps(
1474
+ chatFirstConnectedAppsQuery.data ?? [],
1475
+ chatFirstAppsQuery.data ?? [],
1468
1476
  ),
1469
- [chatFirstAppsQuery.data],
1477
+ [chatFirstAppsQuery.data, chatFirstConnectedAppsQuery.data],
1478
+ );
1479
+ const chatFirstEnabledBuiltinAppIds = useMemo(
1480
+ () => chatFirstBuiltinApps.map((app) => app.id),
1481
+ [chatFirstBuiltinApps],
1482
+ );
1483
+ const chatFirstWorkspaceAppsLoading = chatFirstAppsQuery.isLoading;
1484
+ const chatFirstConnectedAppsLoading = chatFirstConnectedAppsQuery.isLoading;
1485
+ const chatFirstAppsLoading =
1486
+ chatFirstWorkspaceAppsLoading || chatFirstConnectedAppsLoading;
1487
+ const chatFirstAppsFailed = chatFirstAppsQuery.isError;
1488
+ const chatFirstAppsError =
1489
+ chatFirstAppsQuery.error ?? chatFirstConnectedAppsQuery.error;
1490
+ const chatFirstResolvableApps = useMemo(
1491
+ () =>
1492
+ mergeChatFirstWorkspaceApps(
1493
+ chatFirstAppsQuery.data,
1494
+ chatFirstEnabledBuiltinAppIds,
1495
+ chatFirstBuiltinApps.map((app) => ({
1496
+ id: app.id,
1497
+ name: app.name,
1498
+ description: app.description,
1499
+ url: app.homeUrl?.trim() || app.url,
1500
+ source: "builtin" as const,
1501
+ })),
1502
+ ).filter((app) => app.status !== "pending" && app.archived !== true),
1503
+ [
1504
+ chatFirstAppsQuery.data,
1505
+ chatFirstBuiltinApps,
1506
+ chatFirstEnabledBuiltinAppIds,
1507
+ ],
1508
+ );
1509
+ const chatFirstWorkspaceApps = useMemo(
1510
+ () =>
1511
+ chatFirstResolvableApps.filter(isWorkspaceAppVisibleInDefaultLaunchers),
1512
+ [chatFirstResolvableApps],
1470
1513
  );
1471
1514
  const chatFirstAppRegistrations = useMemo<ChatFirstAppRegistration[]>(() => {
1472
- return (chatFirstAppsQuery.data ?? [])
1473
- .filter((app) => app.status !== "pending" && app.archived !== true)
1474
- .map((app) => ({
1475
- id: app.id,
1476
- name: app.name,
1477
- source: app.source ?? "workspace",
1478
- path: app.path,
1479
- url: app.url,
1480
- homePath: app.homePath,
1481
- enabled: true,
1482
- }));
1483
- }, [chatFirstAppsQuery.data]);
1515
+ return chatFirstResolvableApps.map((app) => ({
1516
+ id: app.id,
1517
+ name: app.name,
1518
+ source: app.source ?? "workspace",
1519
+ path: app.path,
1520
+ url: app.url,
1521
+ homePath: app.homePath,
1522
+ enabled: true,
1523
+ }));
1524
+ }, [chatFirstResolvableApps]);
1484
1525
  const chatFirstAppItems = useMemo<ChatFirstAppItem[]>(
1485
1526
  () =>
1486
1527
  chatFirstWorkspaceApps.map((app) => ({
@@ -1496,6 +1537,14 @@ function DispatchLayout({
1496
1537
  const registration = chatFirstAppRegistrations.find(
1497
1538
  (candidate) => candidate.id.toLowerCase() === app.id.toLowerCase(),
1498
1539
  );
1540
+ if (registration?.source === "builtin") {
1541
+ const directHref = workspaceAppDirectHref(
1542
+ registration,
1543
+ workspaceAppTargetPath(registration),
1544
+ );
1545
+ if (directHref) navigateToWorkspaceApp(directHref);
1546
+ return;
1547
+ }
1499
1548
  const hasWorkspaceRoute = Boolean(registration?.path?.trim());
1500
1549
  if (!hasWorkspaceRoute) {
1501
1550
  if (registration) {
@@ -1523,16 +1572,19 @@ function DispatchLayout({
1523
1572
  const chatHomeAppLauncher = useMemo<DispatchWorkspaceAppLauncher>(
1524
1573
  () => ({
1525
1574
  workspaceApps: chatFirstWorkspaceApps,
1526
- isLoading: chatFirstAppsQuery.isLoading,
1527
- error: chatFirstAppsQuery.isError ? chatFirstAppsQuery.error : undefined,
1575
+ isLoading: chatFirstAppsLoading,
1576
+ error: chatFirstAppsError,
1528
1577
  openApp: openChatFirstApp,
1529
- retry: () => void chatFirstAppsQuery.refetch(),
1578
+ retry: () => {
1579
+ void chatFirstAppsQuery.refetch();
1580
+ void chatFirstConnectedAppsQuery.refetch();
1581
+ },
1530
1582
  }),
1531
1583
  [
1532
- chatFirstAppsQuery.error,
1533
- chatFirstAppsQuery.isError,
1534
- chatFirstAppsQuery.isLoading,
1584
+ chatFirstAppsError,
1585
+ chatFirstAppsLoading,
1535
1586
  chatFirstAppsQuery.refetch,
1587
+ chatFirstConnectedAppsQuery.refetch,
1536
1588
  chatFirstWorkspaceApps,
1537
1589
  openChatFirstApp,
1538
1590
  ],
@@ -1540,6 +1592,8 @@ function DispatchLayout({
1540
1592
  const chatFirstCopy = useMemo(() => createDispatchChatFirstCopy(t), [t]);
1541
1593
  const [chatFirstPane, setChatFirstPane] =
1542
1594
  useState<DispatchChatFirstPane | null>(null);
1595
+ const [pendingChatFirstPaneRestore, setPendingChatFirstPaneRestore] =
1596
+ useState<DispatchChatFirstPane | null>(null);
1543
1597
  const [chatFirstNotice, setChatFirstNotice] = useState<string | null>(null);
1544
1598
  const chatFirstSessionWatch = useChatFirstSessionWatch();
1545
1599
  const chatFirstSurfaceTabs = useChatFirstSurfaceTabs(chatFirstSurfaceScope);
@@ -1731,18 +1785,6 @@ function DispatchLayout({
1731
1785
  );
1732
1786
  const resolveChatFirstOpenApp = useCallback(
1733
1787
  (detail: ChatFirstOpenAppDetail) => {
1734
- if (chatFirstAppsQuery.isLoading) {
1735
- pendingChatFirstOpenAppRef.current = detail;
1736
- return;
1737
- }
1738
- if (chatFirstAppsQuery.isError) {
1739
- pendingChatFirstOpenAppRef.current = null;
1740
- setChatFirstNotice(
1741
- "Workspace apps could not be loaded, so the requested app was not opened.",
1742
- );
1743
- return;
1744
- }
1745
-
1746
1788
  const resolution = resolveChatFirstAppTarget(
1747
1789
  detail,
1748
1790
  chatFirstAppRegistrations,
@@ -1751,11 +1793,29 @@ function DispatchLayout({
1751
1793
  typeof window === "undefined" ? undefined : window.location.origin,
1752
1794
  },
1753
1795
  );
1754
- pendingChatFirstOpenAppRef.current = null;
1755
1796
  if (resolution.status === "unresolved") {
1797
+ if (
1798
+ chatFirstWorkspaceAppsLoading ||
1799
+ (resolution.reason === "unknown-app" && chatFirstConnectedAppsLoading)
1800
+ ) {
1801
+ pendingChatFirstOpenAppRef.current = detail;
1802
+ return;
1803
+ }
1804
+ if (chatFirstConnectedAppsQuery.isError) {
1805
+ pendingChatFirstOpenAppRef.current = detail;
1806
+ return;
1807
+ }
1808
+ pendingChatFirstOpenAppRef.current = null;
1809
+ if (chatFirstAppsFailed) {
1810
+ setChatFirstNotice(
1811
+ "Workspace apps could not be loaded, so the requested app was not opened.",
1812
+ );
1813
+ return;
1814
+ }
1756
1815
  setChatFirstNotice(chatFirstResolutionMessage(resolution.reason));
1757
1816
  return;
1758
1817
  }
1818
+ pendingChatFirstOpenAppRef.current = null;
1759
1819
  if (isDispatchWorkspaceAppId(resolution.target.appId)) {
1760
1820
  closeChatFirstSessionWatch();
1761
1821
  chatFirstSurfaceTabsStore.closeAll();
@@ -1773,8 +1833,10 @@ function DispatchLayout({
1773
1833
  },
1774
1834
  [
1775
1835
  chatFirstAppRegistrations,
1776
- chatFirstAppsQuery.isError,
1777
- chatFirstAppsQuery.isLoading,
1836
+ chatFirstAppsFailed,
1837
+ chatFirstWorkspaceAppsLoading,
1838
+ chatFirstConnectedAppsLoading,
1839
+ chatFirstConnectedAppsQuery.isError,
1778
1840
  chatFirstSurfaceTabsStore,
1779
1841
  openChatFirstPane,
1780
1842
  navigate,
@@ -1850,6 +1912,7 @@ function DispatchLayout({
1850
1912
  chatFirstSurfaceTabsStore.closeAll();
1851
1913
  setChatFirstNotice(null);
1852
1914
  pendingChatFirstOpenAppRef.current = null;
1915
+ setPendingChatFirstPaneRestore(null);
1853
1916
  chatFirstPaneHydratedRef.current = false;
1854
1917
  return;
1855
1918
  }
@@ -1870,13 +1933,7 @@ function DispatchLayout({
1870
1933
  ]);
1871
1934
 
1872
1935
  useEffect(() => {
1873
- if (
1874
- !chatFirstMode ||
1875
- !isChatRoute ||
1876
- chatFirstPaneHydratedRef.current ||
1877
- chatFirstAppsQuery.isLoading ||
1878
- chatFirstAppsQuery.isError
1879
- ) {
1936
+ if (!chatFirstMode || !isChatRoute || chatFirstPaneHydratedRef.current) {
1880
1937
  return;
1881
1938
  }
1882
1939
  chatFirstPaneHydratedRef.current = true;
@@ -1885,25 +1942,7 @@ function DispatchLayout({
1885
1942
  void readClientAppState<unknown>(CHAT_FIRST_PANE_STATE_KEY)
1886
1943
  .then((value) => {
1887
1944
  if (!active) return;
1888
- const persisted = persistedChatFirstPane(value);
1889
- if (!persisted) return;
1890
- const resolution = resolveChatFirstAppTarget(
1891
- {
1892
- app: persisted.appId,
1893
- path: persisted.path,
1894
- view: persisted.view,
1895
- },
1896
- chatFirstAppRegistrations,
1897
- {
1898
- currentOrigin:
1899
- typeof window === "undefined"
1900
- ? undefined
1901
- : window.location.origin,
1902
- },
1903
- );
1904
- if (resolution.status === "ready") {
1905
- openChatFirstPane(resolution.target, persisted.placement);
1906
- }
1945
+ setPendingChatFirstPaneRestore(persistedChatFirstPane(value));
1907
1946
  })
1908
1947
  .catch(() => {
1909
1948
  if (active) {
@@ -1915,14 +1954,56 @@ function DispatchLayout({
1915
1954
  return () => {
1916
1955
  active = false;
1917
1956
  };
1957
+ }, [chatFirstMode, chatFirstPane, chatFirstSurfaceScope, isChatRoute]);
1958
+
1959
+ useEffect(() => {
1960
+ if (!chatFirstMode || !isChatRoute || !pendingChatFirstPaneRestore) return;
1961
+ if (chatFirstPane) {
1962
+ setPendingChatFirstPaneRestore(null);
1963
+ return;
1964
+ }
1965
+ const resolution = resolveChatFirstAppTarget(
1966
+ {
1967
+ app: pendingChatFirstPaneRestore.appId,
1968
+ path: pendingChatFirstPaneRestore.path,
1969
+ view: pendingChatFirstPaneRestore.view,
1970
+ },
1971
+ chatFirstAppRegistrations,
1972
+ {
1973
+ currentOrigin:
1974
+ typeof window === "undefined" ? undefined : window.location.origin,
1975
+ },
1976
+ );
1977
+ if (resolution.status === "ready") {
1978
+ setPendingChatFirstPaneRestore(null);
1979
+ openChatFirstPane(
1980
+ resolution.target,
1981
+ pendingChatFirstPaneRestore.placement,
1982
+ );
1983
+ return;
1984
+ }
1985
+ if (
1986
+ chatFirstWorkspaceAppsLoading ||
1987
+ (resolution.status === "unresolved" &&
1988
+ resolution.reason === "unknown-app" &&
1989
+ chatFirstConnectedAppsLoading) ||
1990
+ chatFirstAppsFailed ||
1991
+ chatFirstConnectedAppsQuery.isError
1992
+ ) {
1993
+ return;
1994
+ }
1995
+ setPendingChatFirstPaneRestore(null);
1918
1996
  }, [
1919
- chatFirstAppsQuery.isError,
1920
- chatFirstAppsQuery.isLoading,
1997
+ chatFirstAppsFailed,
1921
1998
  chatFirstAppRegistrations,
1999
+ chatFirstWorkspaceAppsLoading,
2000
+ chatFirstConnectedAppsLoading,
2001
+ chatFirstConnectedAppsQuery.isError,
1922
2002
  chatFirstMode,
1923
2003
  chatFirstPane,
1924
2004
  isChatRoute,
1925
2005
  openChatFirstPane,
2006
+ pendingChatFirstPaneRestore,
1926
2007
  ]);
1927
2008
 
1928
2009
  useEffect(() => {
@@ -1933,6 +2014,7 @@ function DispatchLayout({
1933
2014
  persistChatFirstPane(null);
1934
2015
  setChatFirstNotice(null);
1935
2016
  pendingChatFirstOpenAppRef.current = null;
2017
+ setPendingChatFirstPaneRestore(null);
1936
2018
  chatFirstPaneHydratedRef.current = true;
1937
2019
  chatFirstSurfaceTabsStore.closeAll();
1938
2020
  setChatFirstSurfacePanelOpen(false);
@@ -1983,12 +2065,15 @@ function DispatchLayout({
1983
2065
 
1984
2066
  useEffect(() => {
1985
2067
  const pending = pendingChatFirstOpenAppRef.current;
1986
- if (!pending || chatFirstAppsQuery.isLoading) return;
2068
+ if (!pending) return;
1987
2069
  resolveChatFirstOpenApp(pending);
1988
2070
  }, [
1989
2071
  chatFirstAppsQuery.data,
1990
- chatFirstAppsQuery.isError,
1991
- chatFirstAppsQuery.isLoading,
2072
+ chatFirstAppsFailed,
2073
+ chatFirstWorkspaceAppsLoading,
2074
+ chatFirstConnectedAppsLoading,
2075
+ chatFirstConnectedAppsQuery.data,
2076
+ chatFirstConnectedAppsQuery.isError,
1992
2077
  resolveChatFirstOpenApp,
1993
2078
  ]);
1994
2079
 
@@ -2165,7 +2250,9 @@ function DispatchLayout({
2165
2250
  return renderChatFirstAppSurfaceTab({
2166
2251
  registration,
2167
2252
  embedPath: tab.path ?? registration?.path ?? "/",
2168
- loading: chatFirstAppsQuery.isLoading,
2253
+ loading:
2254
+ !registration &&
2255
+ (chatFirstWorkspaceAppsLoading || chatFirstConnectedAppsLoading),
2169
2256
  isMobileSurface,
2170
2257
  copy: chatFirstCopy,
2171
2258
  });
@@ -2235,7 +2322,8 @@ function DispatchLayout({
2235
2322
  chatFirstAgentsQuery.error,
2236
2323
  chatFirstAgentsQuery.isError,
2237
2324
  chatFirstAgentsQuery.isLoading,
2238
- chatFirstAppsQuery.isLoading,
2325
+ chatFirstWorkspaceAppsLoading,
2326
+ chatFirstConnectedAppsLoading,
2239
2327
  chatFirstCopy,
2240
2328
  chatFirstSessionWatch.target,
2241
2329
  chatFirstSurfaceTabs.activeTabId,
@@ -2553,11 +2641,9 @@ function DispatchLayout({
2553
2641
  chatFirstAppLayout={chatFirstAppLayout}
2554
2642
  onChatFirstAppLayoutChange={persistChatFirstAppLayout}
2555
2643
  chatFirstApps={chatFirstAppItems}
2556
- chatFirstAppsLoading={chatFirstAppsQuery.isLoading}
2644
+ chatFirstAppsLoading={chatFirstAppsLoading}
2557
2645
  chatFirstAppsError={
2558
- chatFirstAppsQuery.isError
2559
- ? chatFirstCopy("appsLoadError")
2560
- : null
2646
+ chatFirstAppsError ? chatFirstCopy("appsLoadError") : null
2561
2647
  }
2562
2648
  chatFirstActiveAppId={chatFirstActiveAppId}
2563
2649
  chatFirstActivePrimaryTab={chatFirstActivePrimaryTab}
@@ -2571,7 +2657,10 @@ function DispatchLayout({
2571
2657
  setSidebarCollapsed(true);
2572
2658
  openChatFirstApp(app);
2573
2659
  }}
2574
- onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
2660
+ onChatFirstAppsRetry={() => {
2661
+ void chatFirstAppsQuery.refetch();
2662
+ void chatFirstConnectedAppsQuery.refetch();
2663
+ }}
2575
2664
  collapsible
2576
2665
  onCollapsedChange={setSidebarCollapsed}
2577
2666
  />
@@ -2600,11 +2689,9 @@ function DispatchLayout({
2600
2689
  chatFirstAppLayout={chatFirstAppLayout}
2601
2690
  onChatFirstAppLayoutChange={persistChatFirstAppLayout}
2602
2691
  chatFirstApps={chatFirstAppItems}
2603
- chatFirstAppsLoading={chatFirstAppsQuery.isLoading}
2692
+ chatFirstAppsLoading={chatFirstAppsLoading}
2604
2693
  chatFirstAppsError={
2605
- chatFirstAppsQuery.isError
2606
- ? chatFirstCopy("appsLoadError")
2607
- : null
2694
+ chatFirstAppsError ? chatFirstCopy("appsLoadError") : null
2608
2695
  }
2609
2696
  chatFirstActiveAppId={chatFirstActiveAppId}
2610
2697
  chatFirstActivePrimaryTab={chatFirstActivePrimaryTab}
@@ -2614,9 +2701,10 @@ function DispatchLayout({
2614
2701
  setChatFirstSurfacePanelOpen(false);
2615
2702
  }}
2616
2703
  onChatFirstAppOpen={openChatFirstApp}
2617
- onChatFirstAppsRetry={() =>
2618
- void chatFirstAppsQuery.refetch()
2619
- }
2704
+ onChatFirstAppsRetry={() => {
2705
+ void chatFirstAppsQuery.refetch();
2706
+ void chatFirstConnectedAppsQuery.refetch();
2707
+ }}
2620
2708
  onNavigate={() => setMobileOpen(false)}
2621
2709
  />
2622
2710
  </div>
@@ -7,6 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  const clientState = vi.hoisted(() => ({
8
8
  actions: [] as string[],
9
9
  }));
10
+ const navigation = vi.hoisted(() => ({
11
+ navigate: vi.fn(() => true),
12
+ shouldOpenInTopWindow: vi.fn(() => false),
13
+ }));
10
14
 
11
15
  vi.mock("@agent-native/core/client/hooks", () => ({
12
16
  useActionQuery: (action: string) => {
@@ -25,12 +29,18 @@ vi.mock("@agent-native/core/client/hooks", () => ({
25
29
  ]
26
30
  : [
27
31
  {
28
- id: "internal-app",
29
- name: "Internal app",
30
- url: "https://internal.example.test",
31
- homeUrl: "https://internal.example.test",
32
+ id: "clips",
33
+ name: "Clips",
34
+ url: "https://clips.agent-native.com",
35
+ homeUrl: "https://clips.agent-native.com",
32
36
  source: "builtin",
33
37
  },
38
+ {
39
+ id: "remote-tool",
40
+ name: "Remote tool",
41
+ url: "https://remote.example.test",
42
+ source: "custom",
43
+ },
34
44
  ],
35
45
  isError: false,
36
46
  };
@@ -42,6 +52,12 @@ vi.mock("@agent-native/core/client/i18n", () => ({
42
52
  values?.defaultValue ?? "Workspace apps",
43
53
  }));
44
54
 
55
+ vi.mock("../../lib/workspace-apps", async (importOriginal) => ({
56
+ ...(await importOriginal<typeof import("../../lib/workspace-apps")>()),
57
+ navigateToWorkspaceApp: navigation.navigate,
58
+ shouldOpenWorkspaceAppInTopWindow: navigation.shouldOpenInTopWindow,
59
+ }));
60
+
45
61
  import { WorkspaceAppsRail } from "./workspace-apps-rail";
46
62
 
47
63
  describe("WorkspaceAppsRail", () => {
@@ -51,6 +67,8 @@ describe("WorkspaceAppsRail", () => {
51
67
  beforeEach(() => {
52
68
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
53
69
  clientState.actions = [];
70
+ navigation.navigate.mockClear();
71
+ navigation.shouldOpenInTopWindow.mockReturnValue(false);
54
72
  container = document.createElement("div");
55
73
  document.body.appendChild(container);
56
74
  root = createRoot(container);
@@ -62,7 +80,7 @@ describe("WorkspaceAppsRail", () => {
62
80
  vi.unstubAllGlobals();
63
81
  });
64
82
 
65
- it("lists mounted workspace apps without connected agents", async () => {
83
+ it("lists mounted apps and configured first-party apps, not custom agents", async () => {
66
84
  await act(async () => {
67
85
  root.render(
68
86
  <MemoryRouter initialEntries={["/overview"]}>
@@ -72,8 +90,52 @@ describe("WorkspaceAppsRail", () => {
72
90
  });
73
91
 
74
92
  expect(container.textContent).toContain("Reports");
75
- expect(container.textContent).not.toContain("Internal app");
93
+ expect(container.textContent).toContain("Clips");
94
+ expect(container.textContent).not.toContain("Remote tool");
76
95
  expect(container.querySelector('a[href="/apps/reports"]')).not.toBeNull();
77
- expect(clientState.actions).toEqual(["list-workspace-apps"]);
96
+ expect(
97
+ [...container.querySelectorAll("a")].map((link) =>
98
+ link.getAttribute("href"),
99
+ ),
100
+ ).toEqual(["https://clips.agent-native.com/home", "/apps/reports"]);
101
+ expect(clientState.actions).toEqual([
102
+ "list-workspace-apps",
103
+ "list-connected-agents",
104
+ ]);
105
+ });
106
+
107
+ it("routes built-in apps through the host window on unmodified clicks", async () => {
108
+ navigation.shouldOpenInTopWindow.mockReturnValue(true);
109
+ await act(async () => {
110
+ root.render(
111
+ <MemoryRouter initialEntries={["/overview"]}>
112
+ <WorkspaceAppsRail />
113
+ </MemoryRouter>,
114
+ );
115
+ });
116
+
117
+ const link = container.querySelector<HTMLAnchorElement>(
118
+ 'a[href^="https://clips.agent-native.com"]',
119
+ );
120
+ expect(link?.getAttribute("href")).toBe(
121
+ "https://clips.agent-native.com/home",
122
+ );
123
+ const click = new MouseEvent("click", { bubbles: true, cancelable: true });
124
+ link?.dispatchEvent(click);
125
+
126
+ expect(navigation.navigate).toHaveBeenCalledWith(
127
+ "https://clips.agent-native.com/home",
128
+ );
129
+ expect(click.defaultPrevented).toBe(true);
130
+
131
+ const modifiedClick = new MouseEvent("click", {
132
+ bubbles: true,
133
+ cancelable: true,
134
+ ctrlKey: true,
135
+ });
136
+ link?.dispatchEvent(modifiedClick);
137
+
138
+ expect(navigation.navigate).toHaveBeenCalledTimes(1);
139
+ expect(modifiedClick.defaultPrevented).toBe(false);
78
140
  });
79
141
  });
@@ -1,11 +1,18 @@
1
1
  import { useActionQuery } from "@agent-native/core/client/hooks";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
3
  import { IconApps } from "@tabler/icons-react";
4
+ import type { MouseEvent } from "react";
4
5
  import { Link, useLocation } from "react-router";
5
6
 
7
+ import {
8
+ filterBuiltInApps,
9
+ type ConnectedAppSummary,
10
+ } from "../../lib/other-apps";
6
11
  import { cn } from "../../lib/utils";
7
12
  import {
8
13
  isWorkspaceAppVisibleInDefaultLaunchers,
14
+ navigateToWorkspaceApp,
15
+ shouldOpenWorkspaceAppInTopWindow,
9
16
  workspaceAppIdFromRoute,
10
17
  workspaceAppRoute,
11
18
  workspaceAppHref,
@@ -22,7 +29,7 @@ function appLabel(app: WorkspaceAppSummary): string {
22
29
  return app.name.trim() || app.id;
23
30
  }
24
31
 
25
- type RailApp = WorkspaceAppSummary;
32
+ type RailApp = WorkspaceAppSummary & { external?: boolean };
26
33
 
27
34
  export function WorkspaceAppsRail({
28
35
  collapsed = false,
@@ -39,17 +46,34 @@ export function WorkspaceAppsRail({
39
46
  includeAgentCards: false,
40
47
  },
41
48
  );
49
+ const connectedAppsQuery = useActionQuery<ConnectedAppSummary[]>(
50
+ "list-connected-agents",
51
+ {},
52
+ );
42
53
  if (appsQuery.isError || !appsQuery.data) return null;
43
54
 
44
- const apps: RailApp[] = appsQuery.data
45
- .filter(
55
+ const workspaceApps = appsQuery.data;
56
+ const apps: RailApp[] = [
57
+ ...workspaceApps.filter(
46
58
  (app) =>
47
59
  isWorkspaceAppVisibleInDefaultLaunchers(app) &&
48
60
  !app.archived &&
49
61
  app.status !== "pending" &&
50
62
  !!workspaceAppHref(app),
51
- )
52
- .sort((a, b) => appLabel(a).localeCompare(appLabel(b)));
63
+ ),
64
+ ...filterBuiltInApps(connectedAppsQuery.data ?? [], workspaceApps).map(
65
+ (app) => ({
66
+ id: app.id,
67
+ name: app.name,
68
+ description: app.description,
69
+ path: "",
70
+ url: app.homeUrl?.trim() || app.url,
71
+ status: "ready" as const,
72
+ source: "builtin" as const,
73
+ external: true,
74
+ }),
75
+ ),
76
+ ].sort((a, b) => appLabel(a).localeCompare(appLabel(b)));
53
77
 
54
78
  if (apps.length === 0) return null;
55
79
 
@@ -62,7 +86,21 @@ export function WorkspaceAppsRail({
62
86
  const linkProps = {
63
87
  "aria-current": active ? ("page" as const) : undefined,
64
88
  "aria-label": collapsed ? label : undefined,
65
- onClick: onNavigate,
89
+ onClick: (event: MouseEvent<HTMLAnchorElement>) => {
90
+ onNavigate?.();
91
+ if (
92
+ !app.external ||
93
+ event.button !== 0 ||
94
+ event.metaKey ||
95
+ event.ctrlKey ||
96
+ event.shiftKey ||
97
+ event.altKey ||
98
+ !shouldOpenWorkspaceAppInTopWindow()
99
+ ) {
100
+ return;
101
+ }
102
+ if (navigateToWorkspaceApp(href)) event.preventDefault();
103
+ },
66
104
  className: cn(
67
105
  "flex h-9 items-center rounded-md text-sm transition-colors",
68
106
  collapsed ? "w-9 justify-center" : "w-full gap-2 px-2 text-start",
@@ -82,7 +120,11 @@ export function WorkspaceAppsRail({
82
120
  {!collapsed ? <span className="truncate">{label}</span> : null}
83
121
  </>
84
122
  );
85
- const link = (
123
+ const link = app.external ? (
124
+ <a href={href} {...linkProps}>
125
+ {linkContent}
126
+ </a>
127
+ ) : (
86
128
  <Link to={workspaceAppRoute(app.id)} {...linkProps}>
87
129
  {linkContent}
88
130
  </Link>