@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
@@ -0,0 +1,189 @@
1
+ import {
2
+ useActionMutation,
3
+ useActionQuery,
4
+ } from "@agent-native/core/client/hooks";
5
+ import { useT } from "@agent-native/core/client/i18n";
6
+ import { SettingsGroup, SettingsRow } from "@agent-native/toolkit/app/settings";
7
+ import { IconArrowUpRight } from "@tabler/icons-react";
8
+ import { useState } from "react";
9
+ import { toast } from "sonner";
10
+
11
+ import { useWorkspaceAppLists } from "../lib/workspace-app-lists";
12
+ import { HOSTED_APPS_SETTINGS_INVALID_ERROR_CODE } from "../shared/hosted-apps-settings";
13
+ import { ActionQueryError } from "./action-query-error";
14
+ import { Button } from "./ui/button";
15
+ import { Skeleton } from "./ui/skeleton";
16
+ import { Switch } from "./ui/switch";
17
+
18
+ export interface HostedAppsSettings {
19
+ showHostedApps: boolean;
20
+ }
21
+
22
+ function isInvalidStoredSetting(error: unknown): boolean {
23
+ return (
24
+ !!error &&
25
+ typeof error === "object" &&
26
+ (error as { errorCode?: unknown }).errorCode ===
27
+ HOSTED_APPS_SETTINGS_INVALID_ERROR_CODE
28
+ );
29
+ }
30
+
31
+ function hostOf(url: string): string {
32
+ try {
33
+ return new URL(url).host;
34
+ } catch {
35
+ // coercion-ok: a URL the browser cannot parse is shown as stored.
36
+ return url;
37
+ }
38
+ }
39
+
40
+ /** Settings › Organization › Apps: show or hide hosted apps for everyone. */
41
+ export function HostedAppsSettingsGroup() {
42
+ const t = useT();
43
+ const query = useActionQuery<HostedAppsSettings>(
44
+ "get-hosted-apps-settings",
45
+ {},
46
+ );
47
+ // The switched value holds until the setting reads back: a save invalidates
48
+ // action queries, and the next read replaces `query.data`.
49
+ const [optimistic, setOptimistic] = useState<{
50
+ showHostedApps: boolean;
51
+ readFrom: HostedAppsSettings | undefined;
52
+ } | null>(null);
53
+ const save = useActionMutation<
54
+ HostedAppsSettings,
55
+ { showHostedApps: boolean }
56
+ >("set-hosted-apps-settings", {
57
+ onError: (error) => {
58
+ setOptimistic(null);
59
+ toast.error(t("dispatch.pages.hostedAppsSaveFailed"), {
60
+ description: error.message,
61
+ });
62
+ },
63
+ });
64
+ const title = t("dispatch.pages.hostedApps");
65
+ // An invalid stored value has no state to show; saving any value repairs it.
66
+ const storedInvalid = query.isError && isInvalidStoredSetting(query.error);
67
+ const showHostedApps =
68
+ optimistic && optimistic.readFrom === query.data
69
+ ? optimistic.showHostedApps
70
+ : storedInvalid
71
+ ? false
72
+ : query.data?.showHostedApps;
73
+ // The launchers' Hosted apps tier, including the apps this setting hides.
74
+ const lists = useWorkspaceAppLists({ includeHiddenFromLaunchers: true });
75
+ const listsFailed =
76
+ lists.status === "error" ||
77
+ (lists.connectedAppsQuery.isError &&
78
+ lists.connectedAppsQuery.data === undefined);
79
+ const listsLoading =
80
+ !listsFailed &&
81
+ (lists.status === "loading" || lists.connectedAppsQuery.data === undefined);
82
+
83
+ return (
84
+ <SettingsGroup id="hosted-apps" title={title}>
85
+ {query.isError && !storedInvalid && query.data === undefined ? (
86
+ <div className="px-5 py-4 sm:px-6">
87
+ <ActionQueryError
88
+ error={query.error}
89
+ onRetry={() => void query.refetch()}
90
+ queryName="get-hosted-apps-settings"
91
+ />
92
+ </div>
93
+ ) : showHostedApps === undefined ? (
94
+ <div
95
+ className="flex items-center justify-between gap-4 px-5 py-4 sm:px-6"
96
+ aria-busy="true"
97
+ >
98
+ <Skeleton className="h-3.5 w-32" />
99
+ <Skeleton className="h-5 w-9 rounded-full" />
100
+ </div>
101
+ ) : (
102
+ <>
103
+ <SettingsRow
104
+ id="show-hosted-apps"
105
+ label={t("dispatch.pages.showHostedApps")}
106
+ control={
107
+ <Switch
108
+ aria-label={t("dispatch.pages.showHostedApps")}
109
+ checked={showHostedApps}
110
+ disabled={save.isPending}
111
+ onCheckedChange={(checked) => {
112
+ setOptimistic({
113
+ showHostedApps: checked,
114
+ readFrom: query.data,
115
+ });
116
+ save.mutate({ showHostedApps: checked });
117
+ }}
118
+ />
119
+ }
120
+ >
121
+ {storedInvalid ? (
122
+ <p className="text-xs text-destructive" role="alert">
123
+ {t("dispatch.pages.hostedAppsSettingInvalid")}
124
+ </p>
125
+ ) : null}
126
+ </SettingsRow>
127
+ {listsFailed ? (
128
+ <div className="px-5 py-4 sm:px-6">
129
+ <ActionQueryError
130
+ error={
131
+ lists.status === "error"
132
+ ? lists.appsQuery.error
133
+ : lists.connectedAppsQuery.error
134
+ }
135
+ onRetry={() => {
136
+ void lists.appsQuery.refetch();
137
+ void lists.connectedAppsQuery.refetch();
138
+ }}
139
+ queryName={
140
+ lists.status === "error"
141
+ ? "list-workspace-apps"
142
+ : "list-connected-agents"
143
+ }
144
+ />
145
+ </div>
146
+ ) : listsLoading ? (
147
+ <div
148
+ className="flex items-center justify-between gap-4 px-5 py-4 sm:px-6"
149
+ aria-busy="true"
150
+ data-hosted-apps-loading
151
+ >
152
+ <Skeleton className="h-3.5 w-24" />
153
+ <Skeleton className="h-8 w-16 rounded-md" />
154
+ </div>
155
+ ) : lists.hostedApps.length > 0 ? (
156
+ <div
157
+ data-state={showHostedApps ? "shown" : "hidden"}
158
+ className="divide-y divide-border/60 transition-opacity data-[state=hidden]:opacity-50"
159
+ >
160
+ {lists.hostedApps.map((app) => (
161
+ <SettingsRow
162
+ key={app.id}
163
+ id={`hosted-app-${app.id}`}
164
+ label={app.name}
165
+ description={hostOf(app.homeUrl?.trim() || app.url)}
166
+ control={
167
+ <Button variant="outline" size="sm" asChild>
168
+ <a
169
+ href={app.homeUrl?.trim() || app.url}
170
+ target="_blank"
171
+ rel="noopener noreferrer"
172
+ aria-label={t("dispatch.pages.chatFirstOpenApp", {
173
+ name: app.name,
174
+ })}
175
+ >
176
+ {t("dispatch.pages.openApp")}
177
+ <IconArrowUpRight size={13} aria-hidden="true" />
178
+ </a>
179
+ </Button>
180
+ }
181
+ />
182
+ ))}
183
+ </div>
184
+ ) : null}
185
+ </>
186
+ )}
187
+ </SettingsGroup>
188
+ );
189
+ }
@@ -7,6 +7,7 @@ import { MemoryRouter, useLocation, useNavigate } from "react-router";
7
7
  import { toast } from "sonner";
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
9
 
10
+ import { resetWorkspaceAppLayoutStoreForTests } from "../../lib/workspace-app-layout";
10
11
  import { CHAT_FIRST_PANE_STATE_KEY } from "../../shared/chat-first-pane";
11
12
  import { AdminShell } from "../admin-navigation";
12
13
  import { Tooltip, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
@@ -43,6 +44,7 @@ const clientState = vi.hoisted(() => ({
43
44
  connectedApps: [] as Array<Record<string, unknown>>,
44
45
  connectedAppsError: null as Error | null,
45
46
  connectedAppsLoading: false,
47
+ railRenders: 0,
46
48
  appState: {} as Record<string, unknown>,
47
49
  appStatePromises: {} as Record<string, Promise<unknown>>,
48
50
  basePath: "",
@@ -143,6 +145,15 @@ vi.mock("@agent-native/toolkit/app/chat/AgentSidebar", () => ({
143
145
  vi.mock("../deferred-chat-components.js", async (importOriginal) => ({
144
146
  ...(await importOriginal<typeof import("../deferred-chat-components.js")>()),
145
147
  AgentChatSurface: () => null,
148
+ ChatFirstAppsRail: await (async () => {
149
+ const { createElement, memo } = await import("react");
150
+ const { ChatFirstAppsRail: Rail } =
151
+ await import("../../../../toolkit/src/app/chat/chat-first/apps-rail.tsx");
152
+ return memo(function CountedRail(props: Parameters<typeof Rail>[0]) {
153
+ clientState.railRenders += 1;
154
+ return createElement(Rail, props);
155
+ });
156
+ })(),
146
157
  ChatFirstChatHistory: (
147
158
  await import("../../../../toolkit/src/app/chat/chat-first/chat-history.tsx")
148
159
  ).ChatFirstChatHistory,
@@ -222,47 +233,51 @@ vi.mock("@agent-native/core/client/i18n", () => ({
222
233
  DEFAULT_LOCALE: "en-US",
223
234
  useOptionalLocale: () => ({ locale: "en-US" }),
224
235
  useFormatters: () => ({ formatNumber: (value: number) => String(value) }),
225
- useT: () => (key: string, values?: Record<string, unknown>) => {
226
- const messages: Record<string, string> = {
227
- "dispatch.nav.chat": "Chat",
228
- "dispatch.nav.overview": "Overview",
229
- "dispatch.nav.apps": "Apps",
230
- "dispatch.nav.agents": "Agents",
231
- "dispatch.pages.workspaceApps": "Workspace apps",
232
- "dispatch.nav.operate": "Operate",
233
- "dispatch.nav.advanced": "Advanced",
234
- "agentChat.history.mine": "My chats",
235
- "agentChat.history.sharedWithYou": "Shared with you",
236
- "agentChat.history.ownedBy": `Owner: ${values?.name ?? ""}`,
237
- "agentChat.history.accessDirect": "Shared with you",
238
- "agentChat.history.accessOrganization": "Shared with your organization",
239
- "agentChat.history.copyLink": "Copy link",
240
- "agentChat.history.onlyYou": "Only you",
241
- "agentChat.history.hide": "Hide from my list",
242
- "agentChat.history.showAll": "Show all",
243
- "agentChat.history.showLess": "Show less",
244
- "agentChat.history.loadMore": "Load more",
245
- "agentChat.history.loadingMore": "Loading...",
246
- "agentChat.history.emptyMine": "No chats yet",
247
- "dispatch.sidebar.chats": "Chats",
248
- "dispatch.sidebar.showLocalChats": "Show local chats",
249
- "dispatch.sidebar.showAllChats": "Show all chats",
250
- "agentChat.contextXray.pin": "Pin",
251
- "agentChat.contextXray.unpin": "Unpin",
252
- "extensions.delete": "Archive",
253
- "dispatch.integrations.failedToSave": "Failed to save",
254
- "dispatch.sidebar.newChat": "New chat",
255
- "dispatch.sidebar.newDispatchChat": "New Dispatch chat",
256
- "dispatch.sidebar.renameChat": "Rename chat",
257
- "dispatch.sidebar.chatOptions": `Options for ${values?.title ?? ""}`,
258
- "dispatch.sidebar.renameThread": `Rename ${values?.title ?? ""}`,
259
- "sidebar.collapseSidebar": "Collapse sidebar",
260
- "sidebar.expandSidebar": "Expand sidebar",
261
- };
262
- return messages[key] ?? String(values?.defaultValue ?? key);
263
- },
236
+ // Stable like the real useT, so memoized copy stays memoized.
237
+ useT: () => translate,
264
238
  }));
265
239
 
240
+ function translate(key: string, values?: Record<string, unknown>) {
241
+ const messages: Record<string, string> = {
242
+ "dispatch.nav.chat": "Chat",
243
+ "dispatch.nav.overview": "Overview",
244
+ "dispatch.nav.apps": "Apps",
245
+ "dispatch.nav.agents": "Agents",
246
+ "dispatch.pages.workspaceApps": "Workspace apps",
247
+ "dispatch.nav.operate": "Operate",
248
+ "dispatch.nav.advanced": "Advanced",
249
+ "agentChat.history.mine": "My chats",
250
+ "agentChat.history.sharedWithYou": "Shared with you",
251
+ "agentChat.history.ownedBy": `Owner: ${values?.name ?? ""}`,
252
+ "agentChat.history.accessDirect": "Shared with you",
253
+ "agentChat.history.accessOrganization": "Shared with your organization",
254
+ "agentChat.history.copyLink": "Copy link",
255
+ "agentChat.history.onlyYou": "Only you",
256
+ "agentChat.history.hide": "Hide from my list",
257
+ "agentChat.history.showAll": "Show all",
258
+ "agentChat.history.showLess": "Show less",
259
+ "agentChat.history.loadMore": "Load more",
260
+ "agentChat.history.loadingMore": "Loading...",
261
+ "agentChat.history.emptyMine": "No chats yet",
262
+ "dispatch.sidebar.chats": "Chats",
263
+ "dispatch.sidebar.showLocalChats": "Show local chats",
264
+ "dispatch.sidebar.showAllChats": "Show all chats",
265
+ "agentChat.contextXray.pin": "Pin",
266
+ "agentChat.contextXray.unpin": "Unpin",
267
+ "extensions.delete": "Archive",
268
+ "dispatch.integrations.failedToSave": "Failed to save",
269
+ "dispatch.sidebar.newChat": "New chat",
270
+ "dispatch.sidebar.newDispatchChat": "New Dispatch chat",
271
+ "dispatch.sidebar.renameChat": "Rename chat",
272
+ "dispatch.sidebar.chatOptions": `Options for ${values?.title ?? ""}`,
273
+ "dispatch.sidebar.renameThread": `Rename ${values?.title ?? ""}`,
274
+ "agentChat.chatFirst.showMoreCount": `${values?.count ?? ""} more apps`,
275
+ "sidebar.collapseSidebar": "Collapse sidebar",
276
+ "sidebar.expandSidebar": "Expand sidebar",
277
+ };
278
+ return messages[key] ?? String(values?.defaultValue ?? key);
279
+ }
280
+
266
281
  vi.mock("@agent-native/toolkit/app/shared", async (importOriginal) => ({
267
282
  ...(await importOriginal<
268
283
  typeof import("@agent-native/toolkit/app/shared")
@@ -447,6 +462,7 @@ describe("Dispatch NavContent", () => {
447
462
  clientState.connectedAppsLoading = false;
448
463
  clientState.appState = {};
449
464
  clientState.appStatePromises = {};
465
+ resetWorkspaceAppLayoutStoreForTests();
450
466
  workspaceAppNavigation.navigateToWorkspaceApp.mockClear();
451
467
  container = document.createElement("div");
452
468
  document.body.appendChild(container);
@@ -595,6 +611,46 @@ describe("Dispatch NavContent", () => {
595
611
  ).not.toBeNull();
596
612
  });
597
613
 
614
+ it("keeps hosted apps the organization hides out of the rail but openable", async () => {
615
+ clientState.workspaceApps = [
616
+ { id: "notes", name: "Notes", path: "/notes", status: "ready" },
617
+ ];
618
+ clientState.connectedApps = [
619
+ {
620
+ id: "clips",
621
+ name: "Clips",
622
+ url: "https://clips.agent-native.com",
623
+ source: "builtin",
624
+ hiddenFromLaunchers: true,
625
+ },
626
+ ];
627
+
628
+ await act(async () => {
629
+ root.render(
630
+ <MemoryRouter initialEntries={["/chat/hosted-hidden"]}>
631
+ <Layout extensions={{ chatFirst: true }}>
632
+ <div />
633
+ </Layout>
634
+ </MemoryRouter>,
635
+ );
636
+ });
637
+
638
+ expect(
639
+ container.querySelector('[data-chat-first-app][data-app-id="notes"]'),
640
+ ).not.toBeNull();
641
+ expect(
642
+ container.querySelector('[data-chat-first-app][data-app-id="clips"]'),
643
+ ).toBeNull();
644
+
645
+ await act(async () => {
646
+ emitChatFirstOpenApp({ app: "clips" });
647
+ });
648
+
649
+ expect(
650
+ container.querySelector("[data-chat-first-app-pane]"),
651
+ ).not.toBeNull();
652
+ });
653
+
598
654
  it("retries an app open after connected built-in discovery recovers", async () => {
599
655
  clientState.workspaceApps = [
600
656
  {
@@ -646,6 +702,77 @@ describe("Dispatch NavContent", () => {
646
702
  ).not.toBeNull();
647
703
  });
648
704
 
705
+ it("keeps saved pins when the rail pins an app before workspace state loads", async () => {
706
+ clientState.workspaceApps = ["alpha", "beta"].map((id) => ({
707
+ id,
708
+ name: id,
709
+ path: `/${id}`,
710
+ url: `https://${id}.example.com`,
711
+ status: "ready",
712
+ }));
713
+ let resolveLayout: (value: unknown) => void = () => {};
714
+ clientState.appStatePromises["chat-first-app-layout"] = new Promise(
715
+ (resolve) => {
716
+ resolveLayout = resolve;
717
+ },
718
+ );
719
+
720
+ await act(async () => {
721
+ root.render(
722
+ <MemoryRouter initialEntries={["/chat"]}>
723
+ <Layout extensions={{ chatFirst: true }}>
724
+ <div />
725
+ </Layout>
726
+ </MemoryRouter>,
727
+ );
728
+ });
729
+ const pinAlpha = container.querySelector<HTMLButtonElement>(
730
+ '[data-chat-first-app][data-app-id="alpha"] button[aria-pressed]',
731
+ );
732
+ expect(pinAlpha).not.toBeNull();
733
+ await act(async () => pinAlpha!.click());
734
+ await act(async () => {
735
+ resolveLayout({ pinnedIds: ["beta"], orderedIds: [] });
736
+ });
737
+
738
+ expect(clientState.appState["chat-first-app-layout"]).toEqual({
739
+ pinnedIds: ["alpha", "beta"],
740
+ orderedIds: [],
741
+ });
742
+ });
743
+
744
+ it("loads saved pins for the rail on non-chat routes", async () => {
745
+ clientState.workspaceApps = ["alpha", "beta"].map((id) => ({
746
+ id,
747
+ name: id,
748
+ path: `/${id}`,
749
+ url: `https://${id}.example.com`,
750
+ status: "ready",
751
+ }));
752
+ clientState.appState["chat-first-app-layout"] = {
753
+ pinnedIds: ["beta"],
754
+ orderedIds: [],
755
+ };
756
+
757
+ await act(async () => {
758
+ root.render(
759
+ <MemoryRouter initialEntries={["/admin/automations"]}>
760
+ <Layout extensions={{ chatFirst: true }}>
761
+ <div />
762
+ </Layout>
763
+ </MemoryRouter>,
764
+ );
765
+ });
766
+
767
+ expect(
768
+ container
769
+ .querySelector(
770
+ '[data-chat-first-app][data-app-id="beta"] button[aria-pressed]',
771
+ )
772
+ ?.getAttribute("aria-pressed"),
773
+ ).toBe("true");
774
+ });
775
+
649
776
  it("keeps hidden Dispatch registered for agent app targets", async () => {
650
777
  clientState.workspaceApps = [
651
778
  {
@@ -671,7 +798,7 @@ describe("Dispatch NavContent", () => {
671
798
  emitChatFirstOpenApp({ app: "dispatch" });
672
799
  });
673
800
 
674
- expect(container.querySelector(".dispatch-chat-first-notice")).toBeNull();
801
+ expect(container.querySelector("[data-app-notice]")).toBeNull();
675
802
  });
676
803
 
677
804
  it("does not restore a deferred pane after switching chats", async () => {
@@ -1168,15 +1295,19 @@ describe("Dispatch NavContent", () => {
1168
1295
  );
1169
1296
  });
1170
1297
 
1171
- expect(container.querySelectorAll("[data-chat-first-app]")).toHaveLength(5);
1172
- const showMore = [...container.querySelectorAll("button")].find(
1173
- (button) => button.textContent?.trim() === "Show more",
1174
- );
1175
- expect(showMore).toBeDefined();
1176
- await act(async () => {
1177
- showMore?.click();
1178
- });
1179
- expect(container.querySelectorAll("[data-chat-first-app]")).toHaveLength(6);
1298
+ expect(container.querySelectorAll("[data-chat-first-app]")).toHaveLength(3);
1299
+ expect(
1300
+ container.querySelector('[data-chat-first-app-section="apps"]')
1301
+ ?.textContent,
1302
+ ).toContain("Apps");
1303
+ expect(
1304
+ container.querySelector('[data-chat-first-app][draggable="true"]'),
1305
+ ).toBeNull();
1306
+ expect(
1307
+ container.querySelector(
1308
+ "[data-chat-first-apps-rail] button[aria-expanded]",
1309
+ ),
1310
+ ).toBeNull();
1180
1311
 
1181
1312
  const allApps = container.querySelector<HTMLButtonElement>(
1182
1313
  "[data-chat-first-all-apps]",
@@ -1188,6 +1319,147 @@ describe("Dispatch NavContent", () => {
1188
1319
  expect(paths.at(-1)).toBe("/apps");
1189
1320
  });
1190
1321
 
1322
+ describe.each([false, true])(
1323
+ "the chat-first rail's apps (collapsed %s)",
1324
+ (collapsed) => {
1325
+ const workspace = ["zeta", "eta", "beta", "delta", "alpha"].map((id) => ({
1326
+ id,
1327
+ name: id[0]!.toUpperCase() + id.slice(1),
1328
+ }));
1329
+ const hosted = ["mail", "clips", "slides", "design"].map((id) => ({
1330
+ id,
1331
+ name: id,
1332
+ source: "builtin" as const,
1333
+ }));
1334
+ async function renderRail(
1335
+ apps: Array<{ id: string; name: string; source?: "builtin" }>,
1336
+ pinnedIds: string[],
1337
+ activeAppId?: string,
1338
+ ) {
1339
+ await act(async () => {
1340
+ root.render(
1341
+ <MemoryRouter initialEntries={["/chat"]}>
1342
+ <TooltipProvider>
1343
+ <NavContent
1344
+ chatFirstMode
1345
+ collapsed={collapsed}
1346
+ chatFirstApps={apps}
1347
+ chatFirstAppLayout={{ pinnedIds, orderedIds: [] }}
1348
+ {...(activeAppId
1349
+ ? { chatFirstActiveAppId: activeAppId }
1350
+ : {})}
1351
+ />
1352
+ </TooltipProvider>
1353
+ </MemoryRouter>,
1354
+ );
1355
+ });
1356
+ const sections = [
1357
+ ...container.querySelectorAll<HTMLElement>(
1358
+ "[data-chat-first-app-section]",
1359
+ ),
1360
+ ].map((section) => section.dataset.chatFirstAppSection);
1361
+ return {
1362
+ sections,
1363
+ ids: [
1364
+ ...container.querySelectorAll<HTMLElement>("[data-chat-first-app]"),
1365
+ ].map((row) => row.dataset.appId),
1366
+ };
1367
+ }
1368
+
1369
+ it("shows only pins, in pin order, once something is pinned", async () => {
1370
+ const rail = await renderRail(
1371
+ [...workspace, ...hosted],
1372
+ ["clips", "beta"],
1373
+ );
1374
+ expect(rail.ids).toEqual(["clips", "beta"]);
1375
+ if (!collapsed) {
1376
+ expect(rail.sections).toEqual(["pinned"]);
1377
+ expect(
1378
+ container
1379
+ .querySelector("[data-chat-first-app-section]")
1380
+ ?.getAttribute("aria-label"),
1381
+ ).toBe("dispatch.pages.pinnedApps");
1382
+ }
1383
+ });
1384
+
1385
+ it("suggests the first three workspace apps A to Z with no pins", async () => {
1386
+ const rail = await renderRail([...workspace, ...hosted], []);
1387
+ expect(rail.ids).toEqual(["alpha", "beta", "delta"]);
1388
+ if (!collapsed) expect(rail.sections).toEqual(["apps"]);
1389
+ });
1390
+
1391
+ it("suggests hosted apps when the workspace has none", async () => {
1392
+ const rail = await renderRail(hosted, []);
1393
+ expect(rail.ids).toEqual(["clips", "design", "mail"]);
1394
+ });
1395
+
1396
+ it("adds the open app when it is not shown", async () => {
1397
+ const rail = await renderRail(
1398
+ [...workspace, ...hosted],
1399
+ ["clips"],
1400
+ "zeta",
1401
+ );
1402
+ expect(rail.ids).toEqual(["clips", "zeta"]);
1403
+ });
1404
+ },
1405
+ );
1406
+
1407
+ it.each([
1408
+ ["loading", false],
1409
+ ["error", false],
1410
+ ["ready", true],
1411
+ ] as const)(
1412
+ "says there are no apps only once the list arrived (%s)",
1413
+ async (status, saysEmpty) => {
1414
+ await act(async () => {
1415
+ root.render(
1416
+ <MemoryRouter initialEntries={["/chat"]}>
1417
+ <TooltipProvider>
1418
+ <NavContent
1419
+ chatFirstMode
1420
+ chatFirstApps={[]}
1421
+ chatFirstAppsStatus={status}
1422
+ />
1423
+ </TooltipProvider>
1424
+ </MemoryRouter>,
1425
+ );
1426
+ });
1427
+
1428
+ const appsText =
1429
+ container.querySelector('[data-chat-first-app-section="apps"]')
1430
+ ?.textContent ?? "";
1431
+ if (saysEmpty) expect(appsText).toContain("No apps yet");
1432
+ else expect(appsText).not.toContain("No apps yet");
1433
+ },
1434
+ );
1435
+
1436
+ it("does not re-render the apps rail when the sidebar re-renders with the same apps", async () => {
1437
+ const apps = [
1438
+ { id: "alpha", name: "Alpha" },
1439
+ { id: "mail", name: "Mail", source: "builtin" as const },
1440
+ ];
1441
+ const layout = { pinnedIds: [], orderedIds: [] };
1442
+ const renderNav = () =>
1443
+ root.render(
1444
+ <MemoryRouter initialEntries={["/chat"]}>
1445
+ <TooltipProvider>
1446
+ <NavContent
1447
+ chatFirstMode
1448
+ chatFirstApps={apps}
1449
+ chatFirstAppLayout={layout}
1450
+ />
1451
+ </TooltipProvider>
1452
+ </MemoryRouter>,
1453
+ );
1454
+
1455
+ await act(async () => renderNav());
1456
+ const rendersAfterMount = clientState.railRenders;
1457
+ expect(rendersAfterMount).toBeGreaterThan(0);
1458
+ await act(async () => renderNav());
1459
+
1460
+ expect(clientState.railRenders).toBe(rendersAfterMount);
1461
+ });
1462
+
1191
1463
  it("omits the Chats section when chat-first has no chats", async () => {
1192
1464
  clientState.threads = [];
1193
1465
  await act(async () => {
@@ -1409,6 +1681,41 @@ describe("Dispatch NavContent", () => {
1409
1681
  },
1410
1682
  );
1411
1683
 
1684
+ it("lists chats above the apps rail", async () => {
1685
+ await act(async () => {
1686
+ root.render(
1687
+ <MemoryRouter initialEntries={["/chat/active-thread"]}>
1688
+ <TooltipProvider>
1689
+ <NavContent
1690
+ chatFirstMode
1691
+ chatFirstApps={[{ id: "alpha", name: "Alpha" }]}
1692
+ />
1693
+ </TooltipProvider>
1694
+ </MemoryRouter>,
1695
+ );
1696
+ });
1697
+
1698
+ const chat = [...container.querySelectorAll("button, a")].find((node) =>
1699
+ node.textContent?.includes("Current Dispatch work"),
1700
+ );
1701
+ const rail = container.querySelector("[data-chat-first-apps-rail]");
1702
+ expect(chat).toBeDefined();
1703
+ expect(rail).not.toBeNull();
1704
+ expect(
1705
+ chat!.compareDocumentPosition(rail!) & Node.DOCUMENT_POSITION_FOLLOWING,
1706
+ ).toBeTruthy();
1707
+ });
1708
+
1709
+ it("puts the chat source toggle in the first chat section header", async () => {
1710
+ await renderHistory(true);
1711
+
1712
+ const toggles = container.querySelectorAll(
1713
+ "[data-dispatch-chat-source-toggle]",
1714
+ );
1715
+ expect(toggles).toHaveLength(1);
1716
+ expect(toggles[0]!.parentElement!.textContent).toBe("My chats");
1717
+ });
1718
+
1412
1719
  it.each([false, true])(
1413
1720
  "passes the persisted external source toggle to both ownership queries (chat-first %s)",
1414
1721
  async (chatFirstMode) => {