@agent-native/dispatch 0.23.3 → 0.24.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 (139) hide show
  1. package/dist/actions/list-connected-agents.d.ts +1 -0
  2. package/dist/actions/list-connected-agents.js +5 -1
  3. package/dist/actions/list-connected-agents.js.map +1 -1
  4. package/dist/actions/list-dispatch-usage-metrics.d.ts +2 -0
  5. package/dist/actions/list-dispatch-usage-metrics.js +11 -1
  6. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
  7. package/dist/actions/provider-api-register.d.ts +11 -11
  8. package/dist/actions/remix-workspace-template.js +4 -3
  9. package/dist/actions/remix-workspace-template.js.map +1 -1
  10. package/dist/actions/scaffold-workspace-app.js +3 -2
  11. package/dist/actions/scaffold-workspace-app.js.map +1 -1
  12. package/dist/actions/start-workspace-app-creation.js +3 -2
  13. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  14. package/dist/actions/view-screen.js +11 -1
  15. package/dist/actions/view-screen.js.map +1 -1
  16. package/dist/components/app-icon.js +1 -1
  17. package/dist/components/app-icon.js.map +1 -1
  18. package/dist/components/app-list-row.d.ts +1 -1
  19. package/dist/components/app-list-row.d.ts.map +1 -1
  20. package/dist/components/app-list-row.js +2 -2
  21. package/dist/components/app-list-row.js.map +1 -1
  22. package/dist/components/connected-app-card.d.ts.map +1 -1
  23. package/dist/components/connected-app-card.js +2 -1
  24. package/dist/components/connected-app-card.js.map +1 -1
  25. package/dist/components/create-app-popover.d.ts.map +1 -1
  26. package/dist/components/create-app-popover.js +11 -0
  27. package/dist/components/create-app-popover.js.map +1 -1
  28. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  29. package/dist/components/dispatch-control-plane.js +3 -2
  30. package/dist/components/dispatch-control-plane.js.map +1 -1
  31. package/dist/components/layout/Layout.d.ts +1 -0
  32. package/dist/components/layout/Layout.d.ts.map +1 -1
  33. package/dist/components/layout/Layout.js +47 -10
  34. package/dist/components/layout/Layout.js.map +1 -1
  35. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  36. package/dist/components/layout/workspace-apps-rail.js +2 -2
  37. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  38. package/dist/components/other-apps-section.d.ts.map +1 -1
  39. package/dist/components/other-apps-section.js +5 -1
  40. package/dist/components/other-apps-section.js.map +1 -1
  41. package/dist/components/workspace-template-card.d.ts.map +1 -1
  42. package/dist/components/workspace-template-card.js +8 -14
  43. package/dist/components/workspace-template-card.js.map +1 -1
  44. package/dist/config.d.ts +2 -0
  45. package/dist/config.d.ts.map +1 -1
  46. package/dist/config.js.map +1 -1
  47. package/dist/hooks/use-navigation-state.d.ts +2 -0
  48. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  49. package/dist/hooks/use-navigation-state.js +10 -0
  50. package/dist/hooks/use-navigation-state.js.map +1 -1
  51. package/dist/lib/embed-session-recovery.d.ts +3 -0
  52. package/dist/lib/embed-session-recovery.d.ts.map +1 -0
  53. package/dist/lib/embed-session-recovery.js +18 -0
  54. package/dist/lib/embed-session-recovery.js.map +1 -0
  55. package/dist/lib/other-apps.d.ts +2 -0
  56. package/dist/lib/other-apps.d.ts.map +1 -1
  57. package/dist/lib/other-apps.js +2 -0
  58. package/dist/lib/other-apps.js.map +1 -1
  59. package/dist/lib/workspace-apps.d.ts +7 -0
  60. package/dist/lib/workspace-apps.d.ts.map +1 -1
  61. package/dist/lib/workspace-apps.js +17 -0
  62. package/dist/lib/workspace-apps.js.map +1 -1
  63. package/dist/lib/workspace-apps.test.d.ts +2 -0
  64. package/dist/lib/workspace-apps.test.d.ts.map +1 -0
  65. package/dist/lib/workspace-apps.test.js +16 -0
  66. package/dist/lib/workspace-apps.test.js.map +1 -0
  67. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  68. package/dist/routes/pages/apps.$appId.js +10 -11
  69. package/dist/routes/pages/apps.$appId.js.map +1 -1
  70. package/dist/routes/pages/apps.d.ts.map +1 -1
  71. package/dist/routes/pages/apps.js +2 -1
  72. package/dist/routes/pages/apps.js.map +1 -1
  73. package/dist/routes/pages/chat.d.ts.map +1 -1
  74. package/dist/routes/pages/chat.js +7 -2
  75. package/dist/routes/pages/chat.js.map +1 -1
  76. package/dist/routes/pages/metrics.d.ts.map +1 -1
  77. package/dist/routes/pages/metrics.js +143 -17
  78. package/dist/routes/pages/metrics.js.map +1 -1
  79. package/dist/routes/pages/settings.d.ts.map +1 -1
  80. package/dist/routes/pages/settings.js +13 -1
  81. package/dist/routes/pages/settings.js.map +1 -1
  82. package/dist/server/lib/usage-metrics-store.d.ts +17 -0
  83. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  84. package/dist/server/lib/usage-metrics-store.js +187 -30
  85. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  86. package/dist/server/plugins/agent-chat.js +2 -0
  87. package/dist/server/plugins/agent-chat.js.map +1 -1
  88. package/dist/server/plugins/auth.d.ts.map +1 -1
  89. package/dist/server/plugins/auth.js +1 -0
  90. package/dist/server/plugins/auth.js.map +1 -1
  91. package/dist/server/plugins/integrations.d.ts.map +1 -1
  92. package/dist/server/plugins/integrations.js +1 -0
  93. package/dist/server/plugins/integrations.js.map +1 -1
  94. package/package.json +3 -3
  95. package/src/actions/list-connected-agents.spec.ts +73 -0
  96. package/src/actions/list-connected-agents.ts +6 -2
  97. package/src/actions/list-dispatch-usage-metrics.ts +11 -1
  98. package/src/actions/remix-workspace-template.spec.ts +29 -0
  99. package/src/actions/remix-workspace-template.ts +13 -6
  100. package/src/actions/scaffold-workspace-app.ts +12 -5
  101. package/src/actions/start-workspace-app-creation.ts +8 -2
  102. package/src/actions/view-screen.ts +13 -1
  103. package/src/components/app-icon.spec.tsx +34 -0
  104. package/src/components/app-icon.tsx +1 -1
  105. package/src/components/app-list-row.tsx +3 -2
  106. package/src/components/connected-app-card.tsx +3 -1
  107. package/src/components/create-app-popover.spec.tsx +26 -2
  108. package/src/components/create-app-popover.tsx +10 -0
  109. package/src/components/dispatch-control-plane.spec.tsx +9 -1
  110. package/src/components/dispatch-control-plane.tsx +8 -2
  111. package/src/components/layout/Layout.spec.tsx +12 -0
  112. package/src/components/layout/Layout.tsx +84 -13
  113. package/src/components/layout/workspace-apps-rail.tsx +2 -1
  114. package/src/components/other-apps-section.tsx +8 -1
  115. package/src/components/workspace-template-card.spec.tsx +1 -1
  116. package/src/components/workspace-template-card.tsx +9 -16
  117. package/src/config.ts +2 -0
  118. package/src/hooks/use-navigation-state.ts +12 -0
  119. package/src/lib/embed-session-recovery.spec.ts +63 -0
  120. package/src/lib/embed-session-recovery.ts +26 -0
  121. package/src/lib/other-apps.spec.ts +26 -0
  122. package/src/lib/other-apps.ts +5 -0
  123. package/src/lib/workspace-apps.spec.ts +24 -1
  124. package/src/lib/workspace-apps.test.ts +20 -0
  125. package/src/lib/workspace-apps.ts +25 -0
  126. package/src/routes/pages/apps.$appId.tsx +10 -11
  127. package/src/routes/pages/apps.tsx +5 -2
  128. package/src/routes/pages/chat.spec.tsx +10 -0
  129. package/src/routes/pages/chat.tsx +8 -0
  130. package/src/routes/pages/metrics.tsx +452 -102
  131. package/src/routes/pages/settings.tsx +13 -1
  132. package/src/server/lib/usage-metrics-store.spec.ts +117 -0
  133. package/src/server/lib/usage-metrics-store.ts +249 -30
  134. package/src/server/plugins/agent-chat.spec.ts +4 -0
  135. package/src/server/plugins/agent-chat.ts +2 -0
  136. package/src/server/plugins/auth.spec.ts +45 -0
  137. package/src/server/plugins/auth.ts +1 -0
  138. package/src/server/plugins/integrations.ts +1 -0
  139. package/src/styles/dispatch.css +9 -0
@@ -12,7 +12,10 @@ import { Link, useNavigate } from "react-router";
12
12
 
13
13
  import type { ConnectedAppSummary } from "../lib/other-apps";
14
14
  import { cn } from "../lib/utils";
15
- import type { WorkspaceAppSummary } from "../lib/workspace-apps";
15
+ import {
16
+ isWorkspaceAppVisibleInDefaultLaunchers,
17
+ type WorkspaceAppSummary,
18
+ } from "../lib/workspace-apps";
16
19
  import { ActionQueryError } from "./action-query-error";
17
20
  import {
18
21
  APP_LIST_GRID_CLASS,
@@ -115,6 +118,7 @@ function CommandPanel() {
115
118
  selectedEffort={selectedEffort}
116
119
  selectedEngine={selectedEngine}
117
120
  selectedModel={selectedModel}
121
+ rootClassName="bg-card"
118
122
  onEffortChange={onEffortChange}
119
123
  onModelChange={onModelChange}
120
124
  onSubmit={(text) => send(text)}
@@ -161,7 +165,9 @@ function AppsPanel({
161
165
  }) {
162
166
  const t = useT();
163
167
  const [showPending, setShowPending] = useState(false);
164
- const visibleApps = apps.filter((app) => !app.isDispatch && !app.archived);
168
+ const visibleApps = apps.filter(
169
+ (app) => isWorkspaceAppVisibleInDefaultLaunchers(app) && !app.archived,
170
+ );
165
171
  const activeApps = visibleApps.filter((app) => app.status !== "pending");
166
172
  const pendingApps = visibleApps.filter((app) => app.status === "pending");
167
173
  const otherAppEntries = mergeOtherAppEntries({
@@ -9,6 +9,7 @@ import { TooltipProvider } from "../ui/tooltip";
9
9
  import {
10
10
  buildChatFirstEmbedSessionInput,
11
11
  formatThreadAge,
12
+ isElectronEmbeddedSearch,
12
13
  NavContent,
13
14
  } from "./Layout";
14
15
 
@@ -119,6 +120,14 @@ describe("chat-first embed sessions", () => {
119
120
  });
120
121
  });
121
122
 
123
+ describe("Electron control-plane mode", () => {
124
+ it("recognizes only the explicit Electron query flag", () => {
125
+ expect(isElectronEmbeddedSearch("?electron=1")).toBe(true);
126
+ expect(isElectronEmbeddedSearch("?electron=0")).toBe(false);
127
+ expect(isElectronEmbeddedSearch("?chatFirst=1")).toBe(false);
128
+ });
129
+ });
130
+
122
131
  describe("Dispatch NavContent", () => {
123
132
  let container: HTMLDivElement;
124
133
  let root: Root;
@@ -293,6 +302,9 @@ describe("Dispatch NavContent", () => {
293
302
  );
294
303
  expect(sidebarLabel?.textContent?.trim()).toBe("Dispatch");
295
304
  expect(container.textContent).not.toContain("Agent-Native Dispatch");
305
+ expect(
306
+ sidebarLabel?.closest('a[data-dispatch-logo][href="/overview"]'),
307
+ ).not.toBeNull();
296
308
 
297
309
  const settingsLink = container.querySelector('a[href="/settings"]');
298
310
  const adminLink = container.querySelector('a[href="/admin"]');
@@ -102,8 +102,11 @@ import {
102
102
  import { Link, useLocation, useNavigate } from "react-router";
103
103
  import { toast } from "sonner";
104
104
 
105
+ import { isEmbedSessionExpiredMessage } from "../../lib/embed-session-recovery";
105
106
  import { cn } from "../../lib/utils";
106
107
  import {
108
+ isDispatchWorkspaceAppId,
109
+ isWorkspaceAppVisibleInDefaultLaunchers,
107
110
  mergeChatFirstWorkspaceApps,
108
111
  workspaceAppIdFromRoute,
109
112
  workspaceAppRoute,
@@ -299,6 +302,10 @@ function localDispatchPath(pathname: string): string {
299
302
  return pathname;
300
303
  }
301
304
 
305
+ export function isElectronEmbeddedSearch(search: string): boolean {
306
+ return new URLSearchParams(search).get("electron") === "1";
307
+ }
308
+
302
309
  function chatFirstPrimaryTabForPath(
303
310
  pathname: string,
304
311
  ): ChatFirstPrimaryTab | undefined {
@@ -1187,7 +1194,7 @@ export function NavContent({
1187
1194
  <div
1188
1195
  className={cn("py-2", collapsed ? "flex justify-center px-1" : "px-3")}
1189
1196
  >
1190
- <OrgSwitcher compact={collapsed} reserveSpace />
1197
+ <OrgSwitcher compact={collapsed} reserveSpace currentAppId="dispatch" />
1191
1198
  </div>
1192
1199
  );
1193
1200
  const sidebarFooterActions = (
@@ -1207,9 +1214,12 @@ export function NavContent({
1207
1214
  collapsed ? "justify-center px-0" : "px-4",
1208
1215
  )}
1209
1216
  >
1210
- <div
1217
+ <Link
1218
+ to={dispatchNavLinkTarget("/overview")}
1219
+ aria-label={`${DISPATCH_SIDEBAR_LABEL} overview`}
1220
+ data-dispatch-logo
1211
1221
  className={cn(
1212
- "flex items-center",
1222
+ "flex items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
1213
1223
  collapsed ? "justify-center" : "gap-2",
1214
1224
  )}
1215
1225
  >
@@ -1245,7 +1255,7 @@ export function NavContent({
1245
1255
  </div>
1246
1256
  </div>
1247
1257
  )}
1248
- </div>
1258
+ </Link>
1249
1259
  </div>
1250
1260
 
1251
1261
  {chatFirstMode ? (
@@ -1330,6 +1340,11 @@ export function Layout({
1330
1340
  }
1331
1341
  });
1332
1342
  const localPathname = localDispatchPath(location.pathname);
1343
+ const [electronEmbedded] = useState(() =>
1344
+ typeof window !== "undefined"
1345
+ ? isElectronEmbeddedSearch(window.location.search)
1346
+ : false,
1347
+ );
1333
1348
  const isChatRoute =
1334
1349
  localPathname === "/chat" || localPathname.startsWith("/chat/");
1335
1350
  const chatFirstSurfaceScope = threadIdFromPath(localPathname) ?? "new";
@@ -1347,6 +1362,17 @@ export function Layout({
1347
1362
  const chatFirstMode =
1348
1363
  extensions?.chatFirst === true || chatFirstPreference || chatFirstEmbedded;
1349
1364
  const chatFirstHasActiveChat = chatFirstSurfaceScope !== "new";
1365
+ useEffect(() => {
1366
+ if (!electronEmbedded || typeof window === "undefined") return;
1367
+ const url = new URL(window.location.href);
1368
+ if (isElectronEmbeddedSearch(url.search)) return;
1369
+ url.searchParams.set("electron", "1");
1370
+ window.history.replaceState(
1371
+ window.history.state,
1372
+ "",
1373
+ `${url.pathname}${url.search}${url.hash}`,
1374
+ );
1375
+ }, [electronEmbedded, location.pathname, location.search]);
1350
1376
  useEffect(() => {
1351
1377
  if (!chatFirstMode || chatFirstEmbedded || localPathname !== "/overview") {
1352
1378
  return;
@@ -1396,7 +1422,9 @@ export function Layout({
1396
1422
  const chatFirstAppItems = useMemo<ChatFirstAppItem[]>(
1397
1423
  () =>
1398
1424
  chatFirstAppRegistrations
1399
- .filter((app) => app.enabled)
1425
+ .filter(
1426
+ (app) => app.enabled && isWorkspaceAppVisibleInDefaultLaunchers(app),
1427
+ )
1400
1428
  .map((app) => ({
1401
1429
  id: app.id,
1402
1430
  name: app.name ?? app.id,
@@ -1562,11 +1590,9 @@ export function Layout({
1562
1590
 
1563
1591
  const handleEmbedSessionExpired = (event: MessageEvent) => {
1564
1592
  if (
1565
- event.data?.type !== "agentNative.embedSessionExpired" ||
1566
- event.source !== chatFirstAppFrameRef.current?.contentWindow
1567
- ) {
1593
+ !isEmbedSessionExpiredMessage(event, chatFirstAppFrameRef.current, null)
1594
+ )
1568
1595
  return;
1569
- }
1570
1596
  setChatFirstEmbedAttempt((attempt) => attempt + 1);
1571
1597
  };
1572
1598
 
@@ -1729,6 +1755,19 @@ export function Layout({
1729
1755
  setChatFirstNotice(chatFirstResolutionMessage(resolution.reason));
1730
1756
  return;
1731
1757
  }
1758
+ if (isDispatchWorkspaceAppId(resolution.target.appId)) {
1759
+ closeChatFirstSessionWatch();
1760
+ chatFirstSurfaceTabsStore.closeAll();
1761
+ setChatFirstSurfacePanelOpen(false);
1762
+ persistChatFirstPane(null);
1763
+ if (resolution.target.path) {
1764
+ navigateWithAgentChatViewTransition(
1765
+ navigate,
1766
+ dispatchNavLinkTarget(resolution.target.path),
1767
+ );
1768
+ }
1769
+ return;
1770
+ }
1732
1771
  openChatFirstPane(resolution.target);
1733
1772
  },
1734
1773
  [
@@ -1736,7 +1775,12 @@ export function Layout({
1736
1775
  chatFirstAppsQuery.isError,
1737
1776
  chatFirstAppsQuery.isLoading,
1738
1777
  chatFirstGrantedAppsQuery.isLoading,
1778
+ chatFirstSurfaceTabsStore,
1779
+ closeChatFirstSessionWatch,
1739
1780
  openChatFirstPane,
1781
+ navigate,
1782
+ persistChatFirstPane,
1783
+ setChatFirstSurfacePanelOpen,
1740
1784
  ],
1741
1785
  );
1742
1786
  const chatHomeHandoffActive = useAgentChatHomeHandoff({
@@ -2067,13 +2111,14 @@ export function Layout({
2067
2111
  chatOnly
2068
2112
  dynamicSuggestions={false}
2069
2113
  suggestions={[]}
2114
+ suppressInlineOpenApp={chatFirstMode}
2070
2115
  emptyStateDisplay="hidden"
2071
2116
  composerPlaceholder={t(
2072
2117
  "dispatch.pages.chatFirstSessionMessagePlaceholder",
2073
2118
  )}
2074
2119
  />
2075
2120
  ),
2076
- [t],
2121
+ [chatFirstMode, t],
2077
2122
  );
2078
2123
 
2079
2124
  const renderChatFirstSurfaceTab = useCallback(
@@ -2110,6 +2155,7 @@ export function Layout({
2110
2155
  onRetry={() => setChatFirstEmbedAttempt((value) => value + 1)}
2111
2156
  renderEmbed={({ url, title }: ChatFirstEmbedTarget) => (
2112
2157
  <iframe
2158
+ key={`${url}:${chatFirstEmbedAttempt}`}
2113
2159
  data-dispatch-chat-first-app-frame
2114
2160
  src={url}
2115
2161
  title={title ?? chatFirstCopy("appUnavailable")}
@@ -2204,6 +2250,29 @@ export function Layout({
2204
2250
  return <>{children}</>;
2205
2251
  }
2206
2252
 
2253
+ if (electronEmbedded) {
2254
+ return (
2255
+ <DispatchExtensionsContext.Provider value={extensions}>
2256
+ <HeaderActionsProvider>
2257
+ <div
2258
+ data-dispatch-electron-control-plane
2259
+ className="flex h-screen w-full overflow-hidden bg-background"
2260
+ >
2261
+ <div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
2262
+ <Header showAgentToggle={false} />
2263
+ <InvitationBanner />
2264
+ <main className="flex-1 overflow-y-auto">
2265
+ <div className="mx-auto max-w-7xl space-y-10 px-4 py-6 sm:px-6">
2266
+ {children}
2267
+ </div>
2268
+ </main>
2269
+ </div>
2270
+ </div>
2271
+ </HeaderActionsProvider>
2272
+ </DispatchExtensionsContext.Provider>
2273
+ );
2274
+ }
2275
+
2207
2276
  if (isWorkspaceAppHostRoute) {
2208
2277
  return (
2209
2278
  <DispatchExtensionsContext.Provider value={extensions}>
@@ -2403,9 +2472,11 @@ export function Layout({
2403
2472
  chatFirstSurfaceTabsStore.closeAll();
2404
2473
  setChatFirstSurfacePanelOpen(false);
2405
2474
  }}
2406
- onChatFirstAppOpen={(app) =>
2407
- navigate(dispatchNavLinkTarget(workspaceAppRoute(app.id)))
2408
- }
2475
+ onChatFirstAppOpen={(app) => {
2476
+ if (isDispatchWorkspaceAppId(app.id)) return;
2477
+ setSidebarCollapsed(true);
2478
+ navigate(dispatchNavLinkTarget(workspaceAppRoute(app.id)));
2479
+ }}
2409
2480
  onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
2410
2481
  collapsible
2411
2482
  onCollapsedChange={setSidebarCollapsed}
@@ -5,6 +5,7 @@ import { Link, useLocation } from "react-router";
5
5
 
6
6
  import { cn } from "../../lib/utils";
7
7
  import {
8
+ isWorkspaceAppVisibleInDefaultLaunchers,
8
9
  workspaceAppIdFromRoute,
9
10
  workspaceAppRoute,
10
11
  workspaceAppHref,
@@ -41,7 +42,7 @@ export function WorkspaceAppsRail({
41
42
  const apps = appsQuery.data
42
43
  .filter(
43
44
  (app) =>
44
- !app.isDispatch &&
45
+ isWorkspaceAppVisibleInDefaultLaunchers(app) &&
45
46
  !app.archived &&
46
47
  app.status !== "pending" &&
47
48
  !!workspaceAppHref(app),
@@ -3,6 +3,7 @@ import { useT } from "@agent-native/core/client/i18n";
3
3
  import { filterOtherApps, type ConnectedAppSummary } from "../lib/other-apps";
4
4
  import type { WorkspaceAppId } from "../lib/other-apps";
5
5
  import { cn } from "../lib/utils";
6
+ import { isDefaultWorkspaceAppHiddenId } from "../lib/workspace-apps";
6
7
  import { ActionQueryError } from "./action-query-error";
7
8
  import {
8
9
  APP_LIST_GRID_CLASS,
@@ -52,7 +53,13 @@ export function mergeOtherAppEntries({
52
53
 
53
54
  for (const template of getTemplateItems(templates)) {
54
55
  const id = templateKey(template);
55
- if (!id || template.installed || workspaceAppIds.has(id)) continue;
56
+ if (
57
+ !id ||
58
+ isDefaultWorkspaceAppHiddenId(id) ||
59
+ template.installed ||
60
+ workspaceAppIds.has(id)
61
+ )
62
+ continue;
56
63
  seen.add(id);
57
64
  entries.push({ kind: "template", template });
58
65
  }
@@ -130,7 +130,7 @@ describe("WorkspaceTemplateCard", () => {
130
130
  Object.getOwnPropertyDescriptor(
131
131
  HTMLInputElement.prototype,
132
132
  "value",
133
- )?.set?.call(input, "sales-ops");
133
+ )?.set?.call(input, "Sales Ops");
134
134
  input.dispatchEvent(new Event("input", { bubbles: true }));
135
135
  });
136
136
 
@@ -1,4 +1,5 @@
1
1
  import { useActionMutation } from "@agent-native/core/client/hooks";
2
+ import { normalizeWorkspaceAppId } from "@agent-native/core/shared";
2
3
  import {
3
4
  IconArrowUpRight,
4
5
  IconCircleCheck,
@@ -94,17 +95,6 @@ export interface WorkspaceTemplateCardProps {
94
95
  ) => void;
95
96
  }
96
97
 
97
- function slugifyAppId(value: string): string {
98
- return (
99
- value
100
- .trim()
101
- .toLowerCase()
102
- .replace(/[^a-z0-9]+/g, "-")
103
- .replace(/^-+|-+$/g, "")
104
- .slice(0, 48) || "new-app"
105
- );
106
- }
107
-
108
98
  function templateIdFor(template: CuratedWorkspaceTemplate): string {
109
99
  return template.templateId || template.id || template.appId || template.name;
110
100
  }
@@ -113,8 +103,9 @@ function defaultAppIdFor(
113
103
  template: CuratedWorkspaceTemplate,
114
104
  defaultAppId?: string,
115
105
  ): string {
116
- if (defaultAppId) return slugifyAppId(defaultAppId);
117
- const sourceId = slugifyAppId(template.appId || template.name);
106
+ if (defaultAppId) return normalizeWorkspaceAppId(defaultAppId) || "new-app";
107
+ const sourceId =
108
+ normalizeWorkspaceAppId(template.appId || template.name) || "new-app";
118
109
  return `${sourceId}-app`;
119
110
  }
120
111
 
@@ -173,15 +164,16 @@ export function WorkspaceTemplateCard({
173
164
  }, [defaultAppId, open, template]);
174
165
 
175
166
  function submitRemix() {
176
- const trimmedAppId = appId.trim();
177
- if (!trimmedAppId) {
167
+ const normalizedAppId = normalizeWorkspaceAppId(appId);
168
+ if (!normalizedAppId) {
178
169
  toast.error(labels.appIdRequired);
179
170
  return;
180
171
  }
172
+ setAppId(normalizedAppId);
181
173
 
182
174
  remix.mutate({
183
175
  templateId: templateIdFor(template),
184
- appId: trimmedAppId,
176
+ appId: normalizedAppId,
185
177
  });
186
178
  }
187
179
 
@@ -280,6 +272,7 @@ export function WorkspaceTemplateCard({
280
272
  value={appId}
281
273
  autoComplete="off"
282
274
  onChange={(event) => setAppId(event.target.value)}
275
+ onBlur={() => setAppId(normalizeWorkspaceAppId(appId))}
283
276
  disabled={remix.isPending}
284
277
  />
285
278
  </div>
package/src/config.ts CHANGED
@@ -13,6 +13,8 @@ export interface DispatchAuthConfig {
13
13
  googleOnly?: boolean;
14
14
  /** Marketing/branding copy passed straight through to `createAuthPlugin`. */
15
15
  marketing?: Record<string, unknown>;
16
+ /** Exact framework routes that perform their own authentication checks. */
17
+ publicPaths?: string[];
16
18
  }
17
19
 
18
20
  export interface DispatchIntegrationsConfig {
@@ -32,6 +32,8 @@ export interface NavigationState {
32
32
  query?: string;
33
33
  runId?: string;
34
34
  threadId?: string;
35
+ usageScope?: "me" | "workspace";
36
+ usageUserEmail?: string;
35
37
  }
36
38
 
37
39
  export function useNavigationState(extensions?: DispatchExtensionConfig) {
@@ -177,6 +179,16 @@ export function buildDispatchNavigationState(
177
179
  if (selectedThreadId) state.threadId = selectedThreadId;
178
180
  }
179
181
 
182
+ if (state.view === "metrics") {
183
+ const params = new URLSearchParams(search);
184
+ const usageScope = params.get("scope");
185
+ const usageUserEmail = params.get("user");
186
+ if (usageScope === "me" || usageScope === "workspace") {
187
+ state.usageScope = usageScope;
188
+ }
189
+ if (usageUserEmail) state.usageUserEmail = usageUserEmail;
190
+ }
191
+
180
192
  return state;
181
193
  }
182
194
 
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ EMBED_SESSION_EXPIRED_MESSAGE,
5
+ isEmbedSessionExpiredMessage,
6
+ } from "./embed-session-recovery";
7
+
8
+ const frameWindow = {} as Window;
9
+ const frame = {
10
+ contentWindow: frameWindow,
11
+ src: "https://mail.example.test/_agent-native/embed/start?ticket=one",
12
+ } as HTMLIFrameElement;
13
+
14
+ describe("isEmbedSessionExpiredMessage", () => {
15
+ it("accepts a message from the active app frame", () => {
16
+ expect(
17
+ isEmbedSessionExpiredMessage(
18
+ { data: { type: EMBED_SESSION_EXPIRED_MESSAGE }, source: frameWindow },
19
+ frame,
20
+ ),
21
+ ).toBe(true);
22
+ });
23
+
24
+ it("rejects messages from another frame", () => {
25
+ expect(
26
+ isEmbedSessionExpiredMessage(
27
+ { data: { type: EMBED_SESSION_EXPIRED_MESSAGE }, source: {} as Window },
28
+ frame,
29
+ ),
30
+ ).toBe(false);
31
+ });
32
+
33
+ it("accepts a matching start URL when the browser omits event.source", () => {
34
+ expect(
35
+ isEmbedSessionExpiredMessage(
36
+ {
37
+ data: {
38
+ type: EMBED_SESSION_EXPIRED_MESSAGE,
39
+ embedStartUrl: frame.src,
40
+ },
41
+ source: null,
42
+ },
43
+ frame,
44
+ ),
45
+ ).toBe(true);
46
+ });
47
+
48
+ it("can match the expected URL before the iframe ref is available", () => {
49
+ expect(
50
+ isEmbedSessionExpiredMessage(
51
+ {
52
+ data: {
53
+ type: EMBED_SESSION_EXPIRED_MESSAGE,
54
+ embedStartUrl: frame.src,
55
+ },
56
+ source: null,
57
+ },
58
+ null,
59
+ frame.src,
60
+ ),
61
+ ).toBe(true);
62
+ });
63
+ });
@@ -0,0 +1,26 @@
1
+ export const EMBED_SESSION_EXPIRED_MESSAGE = "agentNative.embedSessionExpired";
2
+
3
+ interface EmbedSessionExpiredMessage {
4
+ type?: unknown;
5
+ embedStartUrl?: unknown;
6
+ }
7
+
8
+ export function isEmbedSessionExpiredMessage(
9
+ event: Pick<MessageEvent, "data" | "source">,
10
+ frame: Pick<HTMLIFrameElement, "contentWindow" | "src"> | null,
11
+ expectedStartUrl?: string | null,
12
+ ): boolean {
13
+ const data =
14
+ event.data && typeof event.data === "object"
15
+ ? (event.data as EmbedSessionExpiredMessage)
16
+ : null;
17
+ if (data?.type !== EMBED_SESSION_EXPIRED_MESSAGE) return false;
18
+
19
+ const frameWindow = frame?.contentWindow;
20
+ if (frameWindow && event.source === frameWindow) return true;
21
+ if (event.source !== null) return false;
22
+ if (typeof data.embedStartUrl !== "string") return false;
23
+
24
+ const frameUrl = frame?.src || expectedStartUrl;
25
+ return Boolean(frameUrl && data.embedStartUrl === frameUrl);
26
+ }
@@ -104,4 +104,30 @@ describe("filterOtherApps", () => {
104
104
  },
105
105
  ]);
106
106
  });
107
+
108
+ it("hides the generic chat starter from connected app launchers", () => {
109
+ expect(
110
+ filterOtherApps(
111
+ [
112
+ {
113
+ id: "chat",
114
+ name: "Chat",
115
+ url: "https://chat.agent-native.com",
116
+ },
117
+ {
118
+ id: "mail",
119
+ name: "Mail",
120
+ url: "https://mail.agent-native.com",
121
+ },
122
+ ],
123
+ [],
124
+ ),
125
+ ).toEqual([
126
+ {
127
+ id: "mail",
128
+ name: "Mail",
129
+ url: "https://mail.agent-native.com",
130
+ },
131
+ ]);
132
+ });
107
133
  });
@@ -1,8 +1,12 @@
1
+ import { isDefaultWorkspaceAppHiddenId } from "./workspace-apps";
2
+
1
3
  export interface ConnectedAppSummary {
2
4
  id: string;
3
5
  name: string;
4
6
  description?: string;
5
7
  url: string;
8
+ /** Canonical app-home URL used for launchers; `url` remains the A2A endpoint. */
9
+ homeUrl?: string;
6
10
  color?: string;
7
11
  source?: "builtin" | "custom" | "workspace";
8
12
  }
@@ -38,6 +42,7 @@ export function filterOtherApps(
38
42
  const id = app.id.trim().toLowerCase();
39
43
  if (
40
44
  !id ||
45
+ isDefaultWorkspaceAppHiddenId(id) ||
41
46
  HIDDEN_OTHER_APP_IDS.has(id) ||
42
47
  workspaceAppIds.has(id) ||
43
48
  seen.has(id)
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { workspaceAppIdFromRoute, workspaceAppRoute } from "./workspace-apps";
3
+ import {
4
+ isDefaultWorkspaceAppHiddenId,
5
+ isDispatchWorkspaceAppId,
6
+ isWorkspaceAppVisibleInDefaultLaunchers,
7
+ workspaceAppIdFromRoute,
8
+ workspaceAppRoute,
9
+ } from "./workspace-apps";
4
10
 
5
11
  describe("workspace app routes", () => {
6
12
  it("round-trips encoded app ids", () => {
@@ -18,4 +24,21 @@ describe("workspace app routes", () => {
18
24
  it("accepts nested app routes while preserving the app id", () => {
19
25
  expect(workspaceAppIdFromRoute("/apps/mail/inbox")).toBe("mail");
20
26
  });
27
+
28
+ it("identifies Dispatch regardless of casing or surrounding whitespace", () => {
29
+ expect(isDispatchWorkspaceAppId(" Dispatch ")).toBe(true);
30
+ expect(isDispatchWorkspaceAppId("dispatch-tools")).toBe(false);
31
+ });
32
+
33
+ it("hides the generic chat starter and Dispatch from default launchers", () => {
34
+ expect(isDefaultWorkspaceAppHiddenId(" Chat ")).toBe(true);
35
+ expect(isWorkspaceAppVisibleInDefaultLaunchers({ id: "chat" })).toBe(false);
36
+ expect(
37
+ isWorkspaceAppVisibleInDefaultLaunchers({
38
+ id: "dispatch",
39
+ isDispatch: true,
40
+ }),
41
+ ).toBe(false);
42
+ expect(isWorkspaceAppVisibleInDefaultLaunchers({ id: "mail" })).toBe(true);
43
+ });
21
44
  });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { workspaceAppEmbedTarget } from "./workspace-apps";
4
+
5
+ describe("workspaceAppEmbedTarget", () => {
6
+ it("uses the app URL as the embed root when a mount path is also present", () => {
7
+ expect(
8
+ workspaceAppEmbedTarget({
9
+ path: "/content",
10
+ url: "https://workspace.example.test/content",
11
+ }),
12
+ ).toEqual({ url: "https://workspace.example.test/content" });
13
+ });
14
+
15
+ it("falls back to the mounted path when no app URL is available", () => {
16
+ expect(workspaceAppEmbedTarget({ path: "/content", url: null })).toEqual({
17
+ path: "/content",
18
+ });
19
+ });
20
+ });
@@ -27,6 +27,21 @@ export interface WorkspaceAppSummary {
27
27
  archived?: boolean;
28
28
  }
29
29
 
30
+ export function isDispatchWorkspaceAppId(appId: string): boolean {
31
+ return appId.trim().toLowerCase() === "dispatch";
32
+ }
33
+
34
+ export function isDefaultWorkspaceAppHiddenId(appId: string): boolean {
35
+ const normalized = appId.trim().toLowerCase();
36
+ return normalized === "chat" || isDispatchWorkspaceAppId(normalized);
37
+ }
38
+
39
+ export function isWorkspaceAppVisibleInDefaultLaunchers(
40
+ app: Pick<WorkspaceAppSummary, "id" | "isDispatch">,
41
+ ): boolean {
42
+ return !app.isDispatch && !isDefaultWorkspaceAppHiddenId(app.id);
43
+ }
44
+
30
45
  export function workspaceAppRoute(appId: string): string {
31
46
  return `/apps/${encodeURIComponent(appId)}`;
32
47
  }
@@ -55,6 +70,16 @@ export function workspaceAppHref(app: WorkspaceAppSummary): string | null {
55
70
  return app.path || app.url || null;
56
71
  }
57
72
 
73
+ export function workspaceAppEmbedTarget(
74
+ app: Pick<WorkspaceAppSummary, "path" | "url">,
75
+ ): { path?: string; url?: string } {
76
+ const url = app.url?.trim();
77
+ if (url) return { url };
78
+
79
+ const path = app.path.trim();
80
+ return path.startsWith("/") ? { path } : path ? { url: path } : {};
81
+ }
82
+
58
83
  export function isPendingBuilderHref(app: WorkspaceAppSummary): boolean {
59
84
  return app.status === "pending" && !!app.builderUrl;
60
85
  }