@agent-native/dispatch 0.41.1-nightly-20261104110058 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +26 -55
  2. package/dist/actions/get-hosted-apps-settings.d.ts +5 -0
  3. package/dist/actions/get-hosted-apps-settings.d.ts.map +1 -0
  4. package/dist/actions/get-hosted-apps-settings.js +25 -0
  5. package/dist/actions/get-hosted-apps-settings.js.map +1 -0
  6. package/dist/actions/index.d.ts.map +1 -1
  7. package/dist/actions/index.js +4 -0
  8. package/dist/actions/index.js.map +1 -1
  9. package/dist/actions/list-connected-agents.d.ts +1 -0
  10. package/dist/actions/list-connected-agents.js +11 -10
  11. package/dist/actions/list-connected-agents.js.map +1 -1
  12. package/dist/actions/list_apps.js +3 -2
  13. package/dist/actions/list_apps.js.map +1 -1
  14. package/dist/actions/provider-api-register.d.ts +10 -10
  15. package/dist/actions/set-hosted-apps-settings.d.ts +7 -0
  16. package/dist/actions/set-hosted-apps-settings.d.ts.map +1 -0
  17. package/dist/actions/set-hosted-apps-settings.js +35 -0
  18. package/dist/actions/set-hosted-apps-settings.js.map +1 -0
  19. package/dist/components/app-card-tiers.d.ts +13 -0
  20. package/dist/components/app-card-tiers.d.ts.map +1 -0
  21. package/dist/components/app-card-tiers.js +22 -0
  22. package/dist/components/app-card-tiers.js.map +1 -0
  23. package/dist/components/app-list-row.d.ts +12 -4
  24. package/dist/components/app-list-row.d.ts.map +1 -1
  25. package/dist/components/app-list-row.js +19 -4
  26. package/dist/components/app-list-row.js.map +1 -1
  27. package/dist/components/app-open-actions.d.ts +4 -0
  28. package/dist/components/app-open-actions.d.ts.map +1 -1
  29. package/dist/components/app-open-actions.js +9 -0
  30. package/dist/components/app-open-actions.js.map +1 -1
  31. package/dist/components/app-pin-button.d.ts +9 -0
  32. package/dist/components/app-pin-button.d.ts.map +1 -0
  33. package/dist/components/app-pin-button.js +29 -0
  34. package/dist/components/app-pin-button.js.map +1 -0
  35. package/dist/components/app-tier-sections.d.ts +27 -0
  36. package/dist/components/app-tier-sections.d.ts.map +1 -0
  37. package/dist/components/app-tier-sections.js +44 -0
  38. package/dist/components/app-tier-sections.js.map +1 -0
  39. package/dist/components/chat-home-apps.d.ts.map +1 -1
  40. package/dist/components/chat-home-apps.js +19 -26
  41. package/dist/components/chat-home-apps.js.map +1 -1
  42. package/dist/components/connected-app-card.d.ts +3 -1
  43. package/dist/components/connected-app-card.d.ts.map +1 -1
  44. package/dist/components/connected-app-card.js +6 -3
  45. package/dist/components/connected-app-card.js.map +1 -1
  46. package/dist/components/create-app-popover.d.ts +4 -0
  47. package/dist/components/create-app-popover.d.ts.map +1 -1
  48. package/dist/components/create-app-popover.js +6 -0
  49. package/dist/components/create-app-popover.js.map +1 -1
  50. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  51. package/dist/components/dispatch-control-plane.js +25 -35
  52. package/dist/components/dispatch-control-plane.js.map +1 -1
  53. package/dist/components/hosted-apps-settings-group.d.ts +6 -0
  54. package/dist/components/hosted-apps-settings-group.d.ts.map +1 -0
  55. package/dist/components/hosted-apps-settings-group.js +76 -0
  56. package/dist/components/hosted-apps-settings-group.js.map +1 -0
  57. package/dist/components/layout/Layout.d.ts +6 -4
  58. package/dist/components/layout/Layout.d.ts.map +1 -1
  59. package/dist/components/layout/Layout.js +97 -80
  60. package/dist/components/layout/Layout.js.map +1 -1
  61. package/dist/components/workspace-app-card.d.ts.map +1 -1
  62. package/dist/components/workspace-app-card.js +9 -20
  63. package/dist/components/workspace-app-card.js.map +1 -1
  64. package/dist/components/workspace-app-layout-notice.d.ts +9 -0
  65. package/dist/components/workspace-app-layout-notice.d.ts.map +1 -0
  66. package/dist/components/workspace-app-layout-notice.js +27 -0
  67. package/dist/components/workspace-app-layout-notice.js.map +1 -0
  68. package/dist/components/workspace-app-search.d.ts.map +1 -1
  69. package/dist/components/workspace-app-search.js +11 -5
  70. package/dist/components/workspace-app-search.js.map +1 -1
  71. package/dist/components/workspace-template-card.d.ts.map +1 -1
  72. package/dist/components/workspace-template-card.js +7 -2
  73. package/dist/components/workspace-template-card.js.map +1 -1
  74. package/dist/db/index.d.ts.map +1 -1
  75. package/dist/db/index.js +1 -0
  76. package/dist/db/index.js.map +1 -1
  77. package/dist/lib/other-apps.d.ts +10 -1
  78. package/dist/lib/other-apps.d.ts.map +1 -1
  79. package/dist/lib/other-apps.js +7 -1
  80. package/dist/lib/other-apps.js.map +1 -1
  81. package/dist/lib/workspace-app-layout.d.ts +62 -6
  82. package/dist/lib/workspace-app-layout.d.ts.map +1 -1
  83. package/dist/lib/workspace-app-layout.js +196 -41
  84. package/dist/lib/workspace-app-layout.js.map +1 -1
  85. package/dist/lib/workspace-app-lists.d.ts +17 -0
  86. package/dist/lib/workspace-app-lists.d.ts.map +1 -0
  87. package/dist/lib/workspace-app-lists.js +21 -0
  88. package/dist/lib/workspace-app-lists.js.map +1 -0
  89. package/dist/lib/workspace-apps.d.ts +3 -1
  90. package/dist/lib/workspace-apps.d.ts.map +1 -1
  91. package/dist/lib/workspace-apps.js +5 -1
  92. package/dist/lib/workspace-apps.js.map +1 -1
  93. package/dist/routes/pages/apps.d.ts.map +1 -1
  94. package/dist/routes/pages/apps.js +23 -35
  95. package/dist/routes/pages/apps.js.map +1 -1
  96. package/dist/routes/pages/settings-page.d.ts.map +1 -1
  97. package/dist/routes/pages/settings-page.js +24 -3
  98. package/dist/routes/pages/settings-page.js.map +1 -1
  99. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  100. package/dist/server/lib/app-creation-store.js +13 -18
  101. package/dist/server/lib/app-creation-store.js.map +1 -1
  102. package/dist/server/lib/app-roles.d.ts +2 -0
  103. package/dist/server/lib/app-roles.d.ts.map +1 -1
  104. package/dist/server/lib/app-roles.js +26 -7
  105. package/dist/server/lib/app-roles.js.map +1 -1
  106. package/dist/server/lib/hosted-apps-settings-schema.d.ts +6 -0
  107. package/dist/server/lib/hosted-apps-settings-schema.d.ts.map +1 -0
  108. package/dist/server/lib/hosted-apps-settings-schema.js +6 -0
  109. package/dist/server/lib/hosted-apps-settings-schema.js.map +1 -0
  110. package/dist/server/lib/hosted-apps-settings.d.ts +27 -0
  111. package/dist/server/lib/hosted-apps-settings.d.ts.map +1 -0
  112. package/dist/server/lib/hosted-apps-settings.js +72 -0
  113. package/dist/server/lib/hosted-apps-settings.js.map +1 -0
  114. package/dist/server/plugins/agent-chat.js +6 -3
  115. package/dist/server/plugins/agent-chat.js.map +1 -1
  116. package/dist/server/plugins/integrations.js +3 -3
  117. package/dist/server/plugins/integrations.js.map +1 -1
  118. package/dist/shared/hosted-apps-settings.d.ts +3 -0
  119. package/dist/shared/hosted-apps-settings.d.ts.map +1 -0
  120. package/dist/shared/hosted-apps-settings.js +3 -0
  121. package/dist/shared/hosted-apps-settings.js.map +1 -0
  122. package/package.json +4 -4
  123. package/src/actions/get-hosted-apps-settings.ts +29 -0
  124. package/src/actions/hosted-apps-settings.spec.ts +100 -0
  125. package/src/actions/index.ts +4 -0
  126. package/src/actions/list-connected-agents.spec.ts +67 -0
  127. package/src/actions/list-connected-agents.ts +14 -15
  128. package/src/actions/list_apps.spec.ts +16 -0
  129. package/src/actions/list_apps.ts +5 -2
  130. package/src/actions/set-hosted-apps-settings.ts +42 -0
  131. package/src/components/app-card-tiers.tsx +85 -0
  132. package/src/components/app-list-row.spec.tsx +54 -0
  133. package/src/components/app-list-row.tsx +50 -8
  134. package/src/components/app-open-actions.tsx +16 -0
  135. package/src/components/app-pin-button.spec.tsx +68 -0
  136. package/src/components/app-pin-button.tsx +66 -0
  137. package/src/components/app-tier-sections.spec.tsx +155 -0
  138. package/src/components/app-tier-sections.tsx +158 -0
  139. package/src/components/chat-home-apps.spec.tsx +135 -29
  140. package/src/components/chat-home-apps.tsx +115 -111
  141. package/src/components/connected-app-card.spec.tsx +18 -2
  142. package/src/components/connected-app-card.tsx +18 -1
  143. package/src/components/create-app-popover.tsx +22 -0
  144. package/src/components/dispatch-control-plane.spec.tsx +89 -20
  145. package/src/components/dispatch-control-plane.tsx +91 -125
  146. package/src/components/hosted-apps-settings-group.spec.tsx +218 -0
  147. package/src/components/hosted-apps-settings-group.tsx +189 -0
  148. package/src/components/layout/Layout.spec.tsx +356 -49
  149. package/src/components/layout/Layout.tsx +189 -142
  150. package/src/components/workspace-app-card.spec.tsx +33 -29
  151. package/src/components/workspace-app-card.tsx +13 -31
  152. package/src/components/workspace-app-layout-notice.spec.tsx +106 -0
  153. package/src/components/workspace-app-layout-notice.tsx +65 -0
  154. package/src/components/workspace-app-search.spec.tsx +53 -0
  155. package/src/components/workspace-app-search.tsx +39 -32
  156. package/src/components/workspace-template-card.spec.tsx +10 -0
  157. package/src/components/workspace-template-card.tsx +7 -2
  158. package/src/db/index.spec.ts +9 -0
  159. package/src/db/index.ts +1 -0
  160. package/src/lib/other-apps.spec.ts +25 -0
  161. package/src/lib/other-apps.ts +9 -0
  162. package/src/lib/workspace-app-layout.spec.ts +61 -3
  163. package/src/lib/workspace-app-layout.store.spec.tsx +261 -0
  164. package/src/lib/workspace-app-layout.ts +265 -48
  165. package/src/lib/workspace-app-lists.ts +32 -0
  166. package/src/lib/workspace-apps.ts +10 -2
  167. package/src/routes/pages/apps.spec.tsx +133 -5
  168. package/src/routes/pages/apps.tsx +79 -140
  169. package/src/routes/pages/chat.spec.tsx +66 -49
  170. package/src/routes/pages/settings-page.tsx +31 -3
  171. package/src/routes/pages/settings.spec.tsx +37 -0
  172. package/src/server/lib/app-creation-store.spec.ts +14 -0
  173. package/src/server/lib/app-creation-store.ts +16 -29
  174. package/src/server/lib/app-creation-store.write-observation.spec.ts +216 -0
  175. package/src/server/lib/app-roles.spec.ts +48 -1
  176. package/src/server/lib/app-roles.ts +44 -12
  177. package/src/server/lib/hosted-apps-settings-schema.ts +6 -0
  178. package/src/server/lib/hosted-apps-settings.spec.ts +154 -0
  179. package/src/server/lib/hosted-apps-settings.ts +108 -0
  180. package/src/server/plugins/agent-chat.spec.ts +16 -0
  181. package/src/server/plugins/agent-chat.ts +7 -2
  182. package/src/server/plugins/integrations.ts +3 -3
  183. package/src/shared/hosted-apps-settings.ts +3 -0
  184. package/src/styles/dispatch.css +40 -11
  185. package/dist/components/available-apps-section.d.ts +0 -9
  186. package/dist/components/available-apps-section.d.ts.map +0 -1
  187. package/dist/components/available-apps-section.js +0 -97
  188. package/dist/components/available-apps-section.js.map +0 -1
  189. package/dist/components/other-apps-section.d.ts +0 -35
  190. package/dist/components/other-apps-section.d.ts.map +0 -1
  191. package/dist/components/other-apps-section.js +0 -79
  192. package/dist/components/other-apps-section.js.map +0 -1
  193. package/src/components/available-apps-section.tsx +0 -239
  194. package/src/components/other-apps-section.spec.ts +0 -50
  195. package/src/components/other-apps-section.tsx +0 -254
@@ -1,23 +1,28 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
- import { AppOpenActions } from "@agent-native/toolkit/app/chat/chat-first/app-open-actions";
3
- import { IconArrowUpRight, IconPlus } from "@tabler/icons-react";
4
2
  import { useMemo, useState } from "react";
5
- import { Link } from "react-router";
6
3
 
7
4
  import {
8
- orderWorkspaceApps,
5
+ hasPinnedWorkspaceApp,
6
+ tierWorkspaceApps,
9
7
  useWorkspaceAppLayout,
10
8
  workspaceAppMatchesQuery,
11
9
  } from "../lib/workspace-app-layout";
12
10
  import { workspaceAppHref } from "../lib/workspace-apps";
13
11
  import { ActionQueryError } from "./action-query-error";
14
12
  import { AppIcon } from "./app-icon";
15
- import { CreateAppPopover } from "./create-app-popover";
13
+ import { AppGridItem, AppList, AppListRow } from "./app-list-row";
14
+ import { AppOpenActions, appOpenActionLabels } from "./app-open-actions";
15
+ import { AppPinButton } from "./app-pin-button";
16
+ import {
17
+ AppListActions,
18
+ AppTierSections,
19
+ NoWorkspaceAppsText,
20
+ } from "./app-tier-sections";
21
+ import { NewAppButton } from "./create-app-popover";
16
22
  import {
17
23
  dispatchNavLinkTarget,
18
24
  useDispatchWorkspaceAppLauncher,
19
25
  } from "./layout/Layout";
20
- import { Button } from "./ui/button";
21
26
  import { Skeleton } from "./ui/skeleton";
22
27
  import {
23
28
  WorkspaceAppSearch,
@@ -27,25 +32,34 @@ import {
27
32
  export function DispatchChatHomeApps() {
28
33
  const t = useT();
29
34
  const launcher = useDispatchWorkspaceAppLauncher();
30
- const { layout } = useWorkspaceAppLayout();
35
+ const { layout, togglePinned } = useWorkspaceAppLayout();
31
36
  const [query, setQuery] = useState("");
32
- const apps = useMemo(
37
+ const tiers = useMemo(
33
38
  () =>
34
- orderWorkspaceApps(launcher?.workspaceApps ?? [], layout)
35
- .map((app) => ({
36
- app,
37
- description: app.defaultDescriptionKey
38
- ? t(app.defaultDescriptionKey)
39
- : app.description,
40
- }))
41
- .filter(({ app, description }) =>
42
- workspaceAppMatchesQuery({ ...app, description }, query),
43
- ),
39
+ tierWorkspaceApps(
40
+ (launcher?.workspaceApps ?? [])
41
+ .map((app) => ({
42
+ id: app.id,
43
+ name: app.name,
44
+ source: app.source,
45
+ app,
46
+ description: app.defaultDescriptionKey
47
+ ? t(app.defaultDescriptionKey)
48
+ : app.description,
49
+ }))
50
+ .filter((row) => workspaceAppMatchesQuery(row, query)),
51
+ layout,
52
+ ),
44
53
  [launcher?.workspaceApps, layout, query, t],
45
54
  );
55
+ const hasMatches =
56
+ tiers.pinned.length + tiers.apps.length + tiers.hosted.length > 0;
46
57
  if (!launcher) return null;
58
+ const hasOwnApps = launcher.workspaceApps.some(
59
+ (app) => app.source !== "builtin",
60
+ );
47
61
 
48
- if (launcher.error && launcher.workspaceApps.length === 0) {
62
+ if (launcher.workspaceAppsStatus === "error") {
49
63
  return (
50
64
  <div className="mx-auto w-full max-w-[1000px]">
51
65
  <ActionQueryError
@@ -56,8 +70,6 @@ export function DispatchChatHomeApps() {
56
70
  </div>
57
71
  );
58
72
  }
59
- if (!launcher.isLoading && launcher.workspaceApps.length === 0) return null;
60
-
61
73
  return (
62
74
  <div className="mx-auto w-full max-w-[1000px]">
63
75
  {launcher.error ? (
@@ -68,99 +80,91 @@ export function DispatchChatHomeApps() {
68
80
  queryName={launcher.errorQueryName}
69
81
  />
70
82
  ) : null}
71
- <section aria-label={t("dispatch.nav.apps")}>
72
- <header className="mb-3 flex flex-wrap items-center justify-between gap-3">
73
- <h2 className="text-sm font-semibold text-foreground">
74
- {t("dispatch.nav.apps")}
75
- </h2>
76
- <div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:flex-none">
77
- <WorkspaceAppSearch
78
- query={query}
79
- onQueryChange={setQuery}
80
- className="w-full max-w-[280px]"
81
- />
82
- <CreateAppPopover
83
- align="end"
84
- onCreated={launcher.retry}
85
- trigger={
86
- <Button type="button" size="sm" variant="outline">
87
- <IconPlus size={16} aria-hidden="true" />
88
- {t("dispatch.pages.chatFirstNewApp")}
89
- </Button>
83
+ <section>
84
+ <AppTierSections
85
+ tiers={tiers}
86
+ appsTitle={t("dispatch.pages.workspaceApps")}
87
+ hasPinnedApps={hasPinnedWorkspaceApp(launcher.workspaceApps, layout)}
88
+ actions={
89
+ <AppListActions
90
+ viewAllHref={dispatchNavLinkTarget("/apps")}
91
+ search={
92
+ <WorkspaceAppSearch
93
+ query={query}
94
+ onQueryChange={setQuery}
95
+ className="w-full sm:w-[280px]"
96
+ />
90
97
  }
98
+ newApp={<NewAppButton onCreated={launcher.retry} />}
91
99
  />
92
- </div>
93
- </header>
94
- {launcher.isLoading && launcher.workspaceApps.length === 0 ? (
95
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
96
- {Array.from({ length: 6 }, (_, index) => (
97
- <div
98
- key={index}
99
- className="flex min-w-0 items-center gap-3 rounded-xl bg-muted/30 px-3 py-2.5"
100
- >
101
- <Skeleton className="size-7 rounded-lg" />
102
- <div className="min-w-0 flex-1 space-y-1.5">
103
- <Skeleton className="h-4 w-20" />
104
- <Skeleton className="h-3 w-40 max-w-full" />
105
- </div>
106
- <Skeleton className="h-7 w-20" />
107
- </div>
108
- ))}
109
- </div>
110
- ) : apps.length > 0 ? (
111
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
112
- {apps.map(({ app, description }) => {
113
- const href = workspaceAppHref(app);
114
- return (
115
- <article
116
- key={app.id}
117
- className="flex min-w-0 items-center gap-3 rounded-xl bg-muted/30 px-3 py-2.5 transition-colors hover:bg-muted/50"
118
- >
119
- <div className="flex min-w-0 flex-1 items-center gap-3">
120
- <AppIcon id={app.id} name={app.name} size="sm" />
121
- <span className="min-w-0 flex-1">
122
- <span className="block truncate text-sm font-semibold text-foreground">
123
- {app.name}
124
- </span>
125
- {description ? (
126
- <span className="mt-0.5 block truncate text-xs text-muted-foreground">
127
- {description}
100
+ }
101
+ appsFallback={
102
+ launcher.workspaceAppsStatus === "loading" ? (
103
+ <AppList layout="grid">
104
+ {Array.from({ length: 6 }, (_, index) => (
105
+ <AppListRow key={index}>
106
+ <Skeleton className="size-7 rounded-lg" />
107
+ <div className="min-w-0 flex-1 space-y-1.5">
108
+ <Skeleton className="h-4 w-20" />
109
+ <Skeleton className="h-3 w-40 max-w-full" />
110
+ </div>
111
+ <Skeleton className="h-7 w-20" />
112
+ </AppListRow>
113
+ ))}
114
+ </AppList>
115
+ ) : query.trim() ? (
116
+ hasMatches ? null : (
117
+ <WorkspaceAppSearchEmpty
118
+ query={query}
119
+ onClear={() => setQuery("")}
120
+ />
121
+ )
122
+ ) : hasOwnApps ? null : (
123
+ <NoWorkspaceAppsText />
124
+ )
125
+ }
126
+ renderItems={(rows) => (
127
+ <AppList layout="grid">
128
+ {rows.map(({ app, description }) => {
129
+ const href = workspaceAppHref(app);
130
+ const pinned = layout.pinnedIds.includes(app.id.toLowerCase());
131
+ return (
132
+ <AppGridItem key={app.id} asChild>
133
+ <article>
134
+ <div className="flex min-w-0 flex-1 items-center gap-3">
135
+ <AppIcon id={app.id} name={app.name} size="sm" />
136
+ <span className="min-w-0 flex-1">
137
+ <span className="block truncate text-sm font-semibold text-foreground">
138
+ {app.name}
139
+ </span>
140
+ {description ? (
141
+ <span className="mt-0.5 block truncate text-xs text-muted-foreground">
142
+ {description}
143
+ </span>
144
+ ) : null}
128
145
  </span>
129
- ) : null}
130
- </span>
131
- </div>
132
- <AppOpenActions
133
- name={app.name}
134
- href={href}
135
- onOpen={() => launcher.openApp(app)}
136
- showNewTabOption={Boolean(href)}
137
- labels={{
138
- openApp: t("dispatch.pages.openApp", {
139
- defaultValue: "Open",
140
- }),
141
- openAppAccessible: t("dispatch.pages.chatFirstOpenApp", {
142
- name: app.name,
143
- }),
144
- openInNewTab: t("dispatch.pages.chatFirstOpenInNewTab"),
145
- moreOptions: t("extensions.optionsFor", {
146
- name: app.name,
147
- }),
148
- }}
149
- />
150
- </article>
151
- );
152
- })}
153
- </div>
154
- ) : (
155
- <WorkspaceAppSearchEmpty query={query} onClear={() => setQuery("")} />
156
- )}
157
- <Link
158
- to={dispatchNavLinkTarget("/apps")}
159
- className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
160
- >
161
- {t("dispatch.pages.allApps")}
162
- <IconArrowUpRight size={14} aria-hidden="true" />
163
- </Link>
146
+ </div>
147
+ <AppPinButton
148
+ appId={app.id}
149
+ name={app.name}
150
+ pinned={pinned}
151
+ onToggle={() => togglePinned(app.id)}
152
+ />
153
+ <AppOpenActions
154
+ name={app.name}
155
+ href={href}
156
+ onOpen={() => launcher.openApp(app)}
157
+ showNewTabOption={Boolean(href)}
158
+ external={app.source === "builtin"}
159
+ labels={appOpenActionLabels(t, app.name)}
160
+ />
161
+ </article>
162
+ </AppGridItem>
163
+ );
164
+ })}
165
+ </AppList>
166
+ )}
167
+ />
164
168
  </section>
165
169
  </div>
166
170
  );
@@ -8,10 +8,25 @@ import { ConnectedAppCard } from "./connected-app-card";
8
8
 
9
9
  vi.mock("./app-icon", () => ({ AppIcon: () => null }));
10
10
  vi.mock("./app-open-actions", () => ({
11
- AppOpenActions: ({ name, href }: { name: string; href: string }) =>
11
+ appOpenActionLabels: (_t: unknown, name: string) => ({
12
+ openInNewTab: `localized new tab for ${name}`,
13
+ }),
14
+ AppOpenActions: ({
15
+ name,
16
+ href,
17
+ labels,
18
+ }: {
19
+ name: string;
20
+ href: string;
21
+ labels?: { openInNewTab?: string };
22
+ }) =>
12
23
  React.createElement(
13
24
  "a",
14
- { className: "app-open-actions__primary", href },
25
+ {
26
+ className: "app-open-actions__primary",
27
+ href,
28
+ "data-new-tab-label": labels?.openInNewTab,
29
+ },
15
30
  name,
16
31
  ),
17
32
  }));
@@ -66,6 +81,7 @@ describe("ConnectedAppCard", () => {
66
81
  expect(anchor?.getAttribute("href")).toContain(
67
82
  "https://clips.agent-native.com/home",
68
83
  );
84
+ expect(anchor?.dataset.newTabLabel).toBe("localized new tab for Clips");
69
85
  });
70
86
 
71
87
  it("opens built-in app actions through the host window on primary clicks", async () => {
@@ -1,3 +1,4 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
1
2
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
2
3
  import type { MouseEvent } from "react";
3
4
 
@@ -8,15 +9,21 @@ import {
8
9
  } from "../lib/workspace-apps";
9
10
  import { AppIcon } from "./app-icon";
10
11
  import { AppListRow } from "./app-list-row";
11
- import { AppOpenActions } from "./app-open-actions";
12
+ import { AppOpenActions, appOpenActionLabels } from "./app-open-actions";
13
+ import { AppPinButton } from "./app-pin-button";
12
14
 
13
15
  export function ConnectedAppCard({
14
16
  app,
15
17
  className,
18
+ isPinned = false,
19
+ onTogglePinned,
16
20
  }: {
17
21
  app: ConnectedAppSummary;
18
22
  className?: string;
23
+ isPinned?: boolean;
24
+ onTogglePinned?: () => void;
19
25
  }) {
26
+ const t = useT();
20
27
  const launchUrl = app.homeUrl ?? app.url;
21
28
  const trackedLaunchUrl = withBuilderUtmTrackingParams(launchUrl, {
22
29
  campaign: "product",
@@ -55,12 +62,22 @@ export function ConnectedAppCard({
55
62
  {app.description || app.url}
56
63
  </div>
57
64
  </div>
65
+ {onTogglePinned ? (
66
+ <AppPinButton
67
+ appId={app.id}
68
+ name={app.name}
69
+ pinned={isPinned}
70
+ onToggle={onTogglePinned}
71
+ />
72
+ ) : null}
58
73
  <div className="flex shrink-0" onClickCapture={handleActionClick}>
59
74
  <AppOpenActions
60
75
  name={app.name}
61
76
  href={trackedLaunchUrl}
62
77
  rel="noopener noreferrer"
63
78
  showNewTabOption
79
+ external={app.source === "builtin"}
80
+ labels={appOpenActionLabels(t, app.name)}
64
81
  />
65
82
  </div>
66
83
  </AppListRow>
@@ -5,6 +5,7 @@ import {
5
5
  appBasePath,
6
6
  } from "@agent-native/core/client/api-path";
7
7
  import { isInBuilderFrame } from "@agent-native/core/client/host";
8
+ import { useT } from "@agent-native/core/client/i18n";
8
9
  import {
9
10
  buildChatFirstAppCreationPrompt,
10
11
  docsUrl,
@@ -698,3 +699,24 @@ function CreateAppPopoverBody({
698
699
  <CreateAppFlow onClose={onClose} {...(onCreated ? { onCreated } : {})} />
699
700
  );
700
701
  }
702
+
703
+ /** The "New app" button every app list puts beside its search. */
704
+ export function NewAppButton({
705
+ onCreated,
706
+ }: {
707
+ onCreated?: (() => void) | undefined;
708
+ }) {
709
+ const t = useT();
710
+ return (
711
+ <CreateAppPopover
712
+ align="end"
713
+ {...(onCreated ? { onCreated } : {})}
714
+ trigger={
715
+ <Button type="button" variant="default" size="sm" className="gap-1.5">
716
+ <IconPlus size={14} aria-hidden="true" />
717
+ {t("dispatch.pages.newApp")}
718
+ </Button>
719
+ }
720
+ />
721
+ );
722
+ }
@@ -12,7 +12,8 @@ const clientState = vi.hoisted(() => ({
12
12
  inBuilderFrame: false,
13
13
  navigateWithTransition: vi.fn(),
14
14
  promptComposerProps: null as Record<string, unknown> | null,
15
- workspaceApps: [] as Array<Record<string, unknown>>,
15
+ workspaceApps: [] as Array<Record<string, unknown>> | undefined,
16
+ workspaceAppsError: null as Error | null,
16
17
  connectedApps: [] as Array<Record<string, unknown>>,
17
18
  curatedTemplates: [] as Array<Record<string, unknown>>,
18
19
  useChatModels: vi.fn(() => ({
@@ -77,8 +78,10 @@ vi.mock("@agent-native/core/client/hooks", () => ({
77
78
  ? clientState.curatedTemplates
78
79
  : clientState.workspaceApps,
79
80
  isLoading: false,
80
- isError: false,
81
- error: null,
81
+ isError:
82
+ name === "list-workspace-apps" && clientState.workspaceAppsError != null,
83
+ error:
84
+ name === "list-workspace-apps" ? clientState.workspaceAppsError : null,
82
85
  refetch: vi.fn(),
83
86
  }),
84
87
  useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
@@ -110,6 +113,12 @@ vi.mock("./create-app-popover", () => ({
110
113
  <span>Create app</span>
111
114
  </div>
112
115
  ),
116
+ NewAppButton: () => (
117
+ <div>
118
+ <button type="button">New app</button>
119
+ <span>Create app</span>
120
+ </div>
121
+ ),
113
122
  }));
114
123
 
115
124
  describe("DispatchControlPlane", () => {
@@ -138,6 +147,7 @@ describe("DispatchControlPlane", () => {
138
147
  clientState.navigateWithTransition.mockReset();
139
148
  clientState.promptComposerProps = null;
140
149
  clientState.workspaceApps = [];
150
+ clientState.workspaceAppsError = null;
141
151
  clientState.connectedApps = [];
142
152
  clientState.curatedTemplates = [];
143
153
  clientState.activeOrgId = "org-a";
@@ -293,7 +303,7 @@ describe("DispatchControlPlane", () => {
293
303
  });
294
304
  });
295
305
 
296
- it("shows apps built in this workspace without cross-workspace catalogs", async () => {
306
+ it("shows this workspace's apps, then hosted apps, without other catalogs", async () => {
297
307
  clientState.workspaceApps = [
298
308
  {
299
309
  id: "onboarding",
@@ -377,10 +387,21 @@ describe("DispatchControlPlane", () => {
377
387
  );
378
388
  });
379
389
 
380
- expect(container.textContent).toContain("Onboarding");
381
- expect(container.textContent).not.toContain("Mail");
390
+ const tier = (label: string) =>
391
+ container.querySelector(`section[aria-label="${label}"]`);
392
+ expect(tier("dispatch.pages.workspaceApps")?.textContent).toContain(
393
+ "Onboarding",
394
+ );
395
+ const hosted = tier("dispatch.pages.hostedApps");
396
+ expect(hosted?.textContent).toContain("Clips");
397
+ expect(hosted?.textContent).toContain("Mail");
398
+ expect(hosted?.textContent).not.toContain("Onboarding");
399
+ expect(
400
+ hosted?.querySelector(
401
+ 'a[href^="https://clips.agent-native.com"]:not([href*="/share/"])',
402
+ ),
403
+ ).not.toBeNull();
382
404
  expect(container.textContent).not.toContain("Custom app");
383
- expect(container.textContent).not.toContain("Clips");
384
405
  expect(container.textContent).not.toContain("Analytics");
385
406
  expect(container.textContent).toContain("Apps");
386
407
  expect(container.textContent).toContain("New");
@@ -388,7 +409,7 @@ describe("DispatchControlPlane", () => {
388
409
  container.querySelector('input[placeholder="Search apps"]'),
389
410
  ).not.toBeNull();
390
411
  const viewAllLink = Array.from(container.querySelectorAll("a")).find(
391
- (link) => link.textContent?.trim() === "View all",
412
+ (link) => link.textContent?.trim() === "dispatch.pages.viewAllApps",
392
413
  );
393
414
  expect(viewAllLink?.className).toContain("text-muted-foreground");
394
415
  expect(container.textContent).not.toContain("Other apps");
@@ -396,19 +417,22 @@ describe("DispatchControlPlane", () => {
396
417
  expect(container.textContent).not.toContain("Archived app");
397
418
  expect(container.textContent).not.toContain("Duplicate onboarding");
398
419
  expect(container.textContent).not.toContain("CRM");
399
- expect(
400
- container.querySelectorAll(
401
- 'button[aria-label="Open options for Onboarding"]',
402
- ),
403
- ).toHaveLength(1);
420
+ let onboardingRow: Element | null | undefined = container.querySelector(
421
+ 'button[aria-label="Settings for Onboarding"]',
422
+ );
423
+ while (onboardingRow && !onboardingRow.querySelector(".app-open-actions")) {
424
+ onboardingRow = onboardingRow.parentElement;
425
+ }
426
+ const onboardingMenus = [
427
+ ...(onboardingRow?.querySelectorAll<HTMLButtonElement>(
428
+ ".app-open-actions__menu",
429
+ ) ?? []),
430
+ ];
431
+ expect(onboardingMenus).toHaveLength(1);
404
432
  await act(async () => {
405
- container
406
- .querySelector<HTMLButtonElement>(
407
- 'button[aria-label="Open options for Onboarding"]',
408
- )
409
- ?.dispatchEvent(
410
- new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
411
- );
433
+ onboardingMenus[0]?.dispatchEvent(
434
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
435
+ );
412
436
  });
413
437
  const onboardingNewTabLink = document.querySelector<HTMLAnchorElement>(
414
438
  'a[href="/onboarding/home"][target="_blank"]',
@@ -416,6 +440,51 @@ describe("DispatchControlPlane", () => {
416
440
  expect(onboardingNewTabLink).not.toBeNull();
417
441
  });
418
442
 
443
+ const renderHome = () =>
444
+ act(async () => {
445
+ root.render(
446
+ <MemoryRouter initialEntries={["/overview"]}>
447
+ <TooltipProvider>
448
+ <QueryClientProvider client={queryClient}>
449
+ <DispatchControlPlane />
450
+ </QueryClientProvider>
451
+ </TooltipProvider>
452
+ </MemoryRouter>,
453
+ );
454
+ });
455
+ const hostedMail = {
456
+ id: "mail",
457
+ name: "Mail",
458
+ url: "https://mail.agent-native.com",
459
+ source: "builtin",
460
+ };
461
+
462
+ it("lists no apps while the workspace list is still loading", async () => {
463
+ clientState.workspaceApps = undefined;
464
+ clientState.connectedApps = [hostedMail];
465
+
466
+ await renderHome();
467
+
468
+ expect(container.textContent).not.toContain(
469
+ "dispatch.pages.chatFirstNoWorkspaceApps",
470
+ );
471
+ expect(container.textContent).not.toContain("Mail");
472
+ });
473
+
474
+ it("keeps the last workspace list and reports a failed refresh", async () => {
475
+ clientState.workspaceApps = [
476
+ { id: "notes", name: "Notes", path: "/notes", status: "ready" },
477
+ ];
478
+ clientState.workspaceAppsError = new Error("refresh failed");
479
+ clientState.connectedApps = [hostedMail];
480
+
481
+ await renderHome();
482
+
483
+ expect(container.textContent).toContain("Notes");
484
+ expect(container.textContent).toContain("Mail");
485
+ expect(container.querySelector('[role="alert"]')).not.toBeNull();
486
+ });
487
+
419
488
  it("searches only apps in this workspace", async () => {
420
489
  clientState.workspaceApps = [
421
490
  {