@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,261 @@
1
+ // @vitest-environment happy-dom
2
+ import { CHAT_FIRST_APP_LAYOUT_STORAGE_KEY } from "@agent-native/core/client/agent-chat";
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { renderToString } from "react-dom/server";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+ const appState = vi.hoisted(() => ({
9
+ read: vi.fn(),
10
+ write: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("@agent-native/core/client/application-state", () => ({
14
+ readClientAppState: appState.read,
15
+ writeClientAppState: appState.write,
16
+ }));
17
+
18
+ import {
19
+ resetWorkspaceAppLayoutStoreForTests,
20
+ useWorkspaceAppLayout,
21
+ WORKSPACE_APP_LAYOUT_STATE_KEY,
22
+ } from "./workspace-app-layout";
23
+
24
+ function Surface({ name, appId }: { name: string; appId: string }) {
25
+ const { layout, persistenceError, togglePinned } = useWorkspaceAppLayout();
26
+ return (
27
+ <button
28
+ type="button"
29
+ data-surface={name}
30
+ data-pinned={layout.pinnedIds.join(",")}
31
+ data-error={persistenceError?.scope ?? ""}
32
+ onClick={() => togglePinned(appId)}
33
+ />
34
+ );
35
+ }
36
+
37
+ describe("useWorkspaceAppLayout", () => {
38
+ let container: HTMLDivElement;
39
+ let root: Root;
40
+
41
+ beforeEach(() => {
42
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
43
+ resetWorkspaceAppLayoutStoreForTests();
44
+ localStorage.clear();
45
+ appState.read.mockReset().mockResolvedValue(null);
46
+ appState.write.mockReset().mockResolvedValue(null);
47
+ container = document.createElement("div");
48
+ document.body.appendChild(container);
49
+ root = createRoot(container);
50
+ });
51
+
52
+ afterEach(() => {
53
+ act(() => root.unmount());
54
+ container.remove();
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ const surface = (name: string) =>
59
+ container.querySelector<HTMLButtonElement>(`[data-surface="${name}"]`)!;
60
+
61
+ it("shares one layout so a pin on one surface never drops another's", async () => {
62
+ await act(async () => {
63
+ root.render(
64
+ <>
65
+ <Surface name="home" appId="mail" />
66
+ <Surface name="rail" appId="plan" />
67
+ </>,
68
+ );
69
+ });
70
+
71
+ await act(async () => surface("home").click());
72
+ await act(async () => surface("rail").click());
73
+
74
+ expect(surface("home").dataset.pinned).toBe("plan,mail");
75
+ expect(surface("rail").dataset.pinned).toBe("plan,mail");
76
+ expect(appState.write).toHaveBeenLastCalledWith(
77
+ WORKSPACE_APP_LAYOUT_STATE_KEY,
78
+ { pinnedIds: ["plan", "mail"], orderedIds: [] },
79
+ );
80
+ expect(
81
+ JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
82
+ ).toEqual({ pinnedIds: ["plan", "mail"], orderedIds: [] });
83
+ });
84
+
85
+ it("replays a pin made before workspace state loads onto the saved pins", async () => {
86
+ let resolveRead: (value: unknown) => void = () => {};
87
+ appState.read.mockReturnValue(
88
+ new Promise((resolve) => {
89
+ resolveRead = resolve;
90
+ }),
91
+ );
92
+ await act(async () => {
93
+ root.render(
94
+ <>
95
+ <Surface name="home" appId="mail" />
96
+ <Surface name="rail" appId="plan" />
97
+ </>,
98
+ );
99
+ });
100
+ expect(appState.read).toHaveBeenCalledOnce();
101
+
102
+ await act(async () => surface("home").click());
103
+ expect(appState.write).not.toHaveBeenCalled();
104
+ await act(async () => {
105
+ resolveRead({ pinnedIds: ["calendar", "notes"], orderedIds: [] });
106
+ });
107
+
108
+ const replayed = {
109
+ pinnedIds: ["mail", "calendar", "notes"],
110
+ orderedIds: [],
111
+ };
112
+ expect(surface("rail").dataset.pinned).toBe("mail,calendar,notes");
113
+ expect(appState.write).toHaveBeenCalledOnce();
114
+ expect(appState.write).toHaveBeenCalledWith(
115
+ WORKSPACE_APP_LAYOUT_STATE_KEY,
116
+ replayed,
117
+ );
118
+ expect(
119
+ JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
120
+ ).toEqual(replayed);
121
+ });
122
+
123
+ it("keeps a pin's intent when the workspace already had it pinned", async () => {
124
+ // A new device shows no pins; the workspace pinned mail elsewhere.
125
+ let resolveRead: (value: unknown) => void = () => {};
126
+ appState.read.mockReturnValue(
127
+ new Promise((resolve) => {
128
+ resolveRead = resolve;
129
+ }),
130
+ );
131
+ await act(async () => {
132
+ root.render(<Surface name="home" appId="mail" />);
133
+ });
134
+
135
+ await act(async () => surface("home").click());
136
+ await act(async () => {
137
+ resolveRead({ pinnedIds: ["mail", "notes"], orderedIds: [] });
138
+ });
139
+
140
+ expect(surface("home").dataset.pinned).toBe("mail,notes");
141
+ expect(appState.write).toHaveBeenCalledWith(
142
+ WORKSPACE_APP_LAYOUT_STATE_KEY,
143
+ { pinnedIds: ["mail", "notes"], orderedIds: [] },
144
+ );
145
+ });
146
+
147
+ it("renders an empty layout on the server even with device pins", () => {
148
+ localStorage.setItem(
149
+ CHAT_FIRST_APP_LAYOUT_STORAGE_KEY,
150
+ JSON.stringify({ pinnedIds: ["mail"], orderedIds: [] }),
151
+ );
152
+
153
+ expect(renderToString(<Surface name="home" appId="mail" />)).toContain(
154
+ 'data-pinned=""',
155
+ );
156
+ });
157
+
158
+ it("adopts workspace pins when nothing changed locally", async () => {
159
+ appState.read.mockResolvedValue({
160
+ pinnedIds: ["Calendar"],
161
+ orderedIds: [],
162
+ });
163
+ await act(async () => {
164
+ root.render(<Surface name="home" appId="mail" />);
165
+ });
166
+
167
+ expect(surface("home").dataset.pinned).toBe("calendar");
168
+ expect(appState.write).not.toHaveBeenCalled();
169
+ expect(
170
+ JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
171
+ ).toEqual({ pinnedIds: ["calendar"], orderedIds: [] });
172
+ await act(async () => surface("home").click());
173
+ expect(surface("home").dataset.pinned).toBe("mail,calendar");
174
+ });
175
+
176
+ it("never writes workspace state it could not read, and replays once it can", async () => {
177
+ appState.read
178
+ .mockRejectedValueOnce(new Error("offline"))
179
+ .mockRejectedValueOnce(new Error("offline"))
180
+ .mockResolvedValueOnce({ pinnedIds: ["calendar"], orderedIds: [] });
181
+ await act(async () => {
182
+ root.render(
183
+ <>
184
+ <Surface name="home" appId="mail" />
185
+ <Surface name="rail" appId="plan" />
186
+ </>,
187
+ );
188
+ });
189
+ // A failed read with nothing unsaved is not reported.
190
+ expect(surface("home").dataset.error).toBe("");
191
+
192
+ await act(async () => surface("home").click());
193
+ expect(surface("home").dataset.pinned).toBe("mail");
194
+ expect(surface("home").dataset.error).toBe("workspace");
195
+ expect(appState.write).not.toHaveBeenCalled();
196
+ expect(
197
+ JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
198
+ ).toEqual({ pinnedIds: ["mail"], orderedIds: [] });
199
+
200
+ // The next change retries the read and replays both onto the saved pins.
201
+ await act(async () => surface("rail").click());
202
+ const replayed = {
203
+ pinnedIds: ["plan", "mail", "calendar"],
204
+ orderedIds: [],
205
+ };
206
+ expect(appState.read).toHaveBeenCalledTimes(3);
207
+ expect(surface("rail").dataset.pinned).toBe("plan,mail,calendar");
208
+ expect(appState.write).toHaveBeenCalledOnce();
209
+ expect(appState.write).toHaveBeenCalledWith(
210
+ WORKSPACE_APP_LAYOUT_STATE_KEY,
211
+ replayed,
212
+ );
213
+ expect(surface("rail").dataset.error).toBe("");
214
+ });
215
+
216
+ it("retries an unreadable workspace layout when a surface mounts again", async () => {
217
+ appState.read
218
+ .mockRejectedValueOnce(new Error("offline"))
219
+ .mockResolvedValueOnce({ pinnedIds: ["calendar"], orderedIds: [] });
220
+ await act(async () => {
221
+ root.render(<Surface name="home" appId="mail" />);
222
+ });
223
+ expect(surface("home").dataset.error).toBe("");
224
+
225
+ act(() => root.unmount());
226
+ root = createRoot(container);
227
+ await act(async () => {
228
+ root.render(<Surface name="home" appId="mail" />);
229
+ });
230
+
231
+ expect(surface("home").dataset.pinned).toBe("calendar");
232
+ expect(surface("home").dataset.error).toBe("");
233
+ expect(appState.write).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("keeps device pins over a malformed workspace layout, then replaces it", async () => {
237
+ vi.spyOn(console, "warn").mockImplementation(() => {});
238
+ const device = { pinnedIds: ["mail"], orderedIds: [] };
239
+ localStorage.setItem(
240
+ CHAT_FIRST_APP_LAYOUT_STORAGE_KEY,
241
+ JSON.stringify(device),
242
+ );
243
+ appState.read.mockResolvedValue({ pinnedIds: "mail" });
244
+
245
+ await act(async () => {
246
+ root.render(<Surface name="home" appId="plan" />);
247
+ });
248
+
249
+ expect(surface("home").dataset.pinned).toBe("mail");
250
+ expect(
251
+ JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
252
+ ).toEqual(device);
253
+ expect(appState.write).not.toHaveBeenCalled();
254
+
255
+ await act(async () => surface("home").click());
256
+ expect(appState.write).toHaveBeenLastCalledWith(
257
+ WORKSPACE_APP_LAYOUT_STATE_KEY,
258
+ { pinnedIds: ["plan", "mail"], orderedIds: [] },
259
+ );
260
+ });
261
+ });
@@ -8,7 +8,7 @@ import {
8
8
  readClientAppState,
9
9
  writeClientAppState,
10
10
  } from "@agent-native/core/client/application-state";
11
- import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { useEffect, useSyncExternalStore } from "react";
12
12
 
13
13
  export const WORKSPACE_APP_LAYOUT_STATE_KEY = "chat-first-app-layout";
14
14
 
@@ -59,7 +59,7 @@ export function orderWorkspaceApps<T extends { id: string }>(
59
59
  }
60
60
 
61
61
  export function workspaceAppMatchesQuery(
62
- app: { name: string; description?: string },
62
+ app: { name: string; description?: string | undefined },
63
63
  query: string,
64
64
  ): boolean {
65
65
  const normalizedQuery = query.trim().toLowerCase();
@@ -69,63 +69,280 @@ export function workspaceAppMatchesQuery(
69
69
  .includes(normalizedQuery);
70
70
  }
71
71
 
72
- export function toggleWorkspaceAppPinned(
72
+ /** Pins (newest first) or unpins `appId`; a no-op when it already matches. */
73
+ export function setWorkspaceAppPinned(
73
74
  layout: ChatFirstAppLayoutPreference,
74
75
  appId: string,
76
+ pinned: boolean,
75
77
  ): ChatFirstAppLayoutPreference {
76
78
  const normalizedLayout = normalizeWorkspaceAppLayout(layout);
77
79
  const normalizedAppId = appId.trim().toLowerCase();
78
- const pinnedIds = normalizedLayout.pinnedIds.includes(normalizedAppId)
79
- ? normalizedLayout.pinnedIds.filter((id) => id !== normalizedAppId)
80
- : [normalizedAppId, ...normalizedLayout.pinnedIds];
80
+ const isPinned = normalizedLayout.pinnedIds.includes(normalizedAppId);
81
+ if (isPinned === pinned) return normalizedLayout;
82
+ const pinnedIds = pinned
83
+ ? [normalizedAppId, ...normalizedLayout.pinnedIds]
84
+ : normalizedLayout.pinnedIds.filter((id) => id !== normalizedAppId);
81
85
  return { ...normalizedLayout, pinnedIds };
82
86
  }
83
87
 
84
- export function useWorkspaceAppLayout() {
85
- const [layout, setLayout] = useState<ChatFirstAppLayoutPreference>(() =>
86
- normalizeWorkspaceAppLayout(readChatFirstAppLayout()),
88
+ export type WorkspaceAppsStatus = "loading" | "error" | "ready";
89
+
90
+ /**
91
+ * The workspace app list is known once it has arrived, and a failed refresh
92
+ * keeps the last one. Until then nothing can be tiered: an empty list is not
93
+ * "no apps", and a hosted app cannot be told from a mounted one.
94
+ */
95
+ export function workspaceAppsStatus(query: {
96
+ data: unknown;
97
+ isError: boolean;
98
+ }): WorkspaceAppsStatus {
99
+ if (query.data !== undefined) return "ready";
100
+ return query.isError ? "error" : "loading";
101
+ }
102
+
103
+ /**
104
+ * Both app lists: ready once workspace apps arrived and the hosted list has
105
+ * settled (arrived or failed), so the tiers, and the header actions placed by
106
+ * them, do not reshuffle when hosted apps land a moment later.
107
+ */
108
+ export function appListsStatus(
109
+ workspaceApps: { data: unknown; isError: boolean },
110
+ connectedApps: { data: unknown; isError: boolean },
111
+ ): WorkspaceAppsStatus {
112
+ const status = workspaceAppsStatus(workspaceApps);
113
+ if (status !== "ready") return status;
114
+ return connectedApps.data === undefined && !connectedApps.isError
115
+ ? "loading"
116
+ : "ready";
117
+ }
118
+
119
+ /** Whether any of `apps` is pinned, before a search narrows the list. */
120
+ export function hasPinnedWorkspaceApp(
121
+ apps: readonly { id: string }[],
122
+ layout: ChatFirstAppLayoutPreference,
123
+ ): boolean {
124
+ const pinned = new Set(normalizeWorkspaceAppLayout(layout).pinnedIds);
125
+ return apps.some((app) => pinned.has(app.id.trim().toLowerCase()));
126
+ }
127
+
128
+ export interface WorkspaceAppTiers<T> {
129
+ pinned: T[];
130
+ apps: T[];
131
+ hosted: T[];
132
+ }
133
+
134
+ export function compareWorkspaceAppNames(
135
+ a: { name: string },
136
+ b: { name: string },
137
+ ): number {
138
+ return a.name.localeCompare(b.name);
139
+ }
140
+
141
+ /**
142
+ * Pinned apps leave their home tier and keep pin order (newest first). The
143
+ * Apps and Hosted apps (built-in catalog) tiers each sort A to Z.
144
+ */
145
+ export function tierWorkspaceApps<
146
+ T extends { id: string; name: string; source?: string | undefined },
147
+ >(
148
+ apps: readonly T[],
149
+ layout: ChatFirstAppLayoutPreference,
150
+ ): WorkspaceAppTiers<T> {
151
+ const appsById = new Map(
152
+ apps.map((app) => [app.id.trim().toLowerCase(), app]),
87
153
  );
88
- const [persistenceError, setPersistenceError] =
89
- useState<WorkspaceAppLayoutPersistenceError | null>(null);
90
- const hydratedRef = useRef(false);
91
- const localChangeRef = useRef(false);
154
+ const pinned = normalizeWorkspaceAppLayout(layout)
155
+ .pinnedIds.map((id) => appsById.get(id))
156
+ .filter((app): app is T => Boolean(app));
157
+ const pinnedSet = new Set(pinned);
158
+ const unpinned = apps.filter((app) => !pinnedSet.has(app));
159
+ return {
160
+ pinned,
161
+ apps: unpinned
162
+ .filter((app) => app.source !== "builtin")
163
+ .sort(compareWorkspaceAppNames),
164
+ hosted: unpinned
165
+ .filter((app) => app.source === "builtin")
166
+ .sort(compareWorkspaceAppNames),
167
+ };
168
+ }
92
169
 
93
- useEffect(() => {
94
- if (hydratedRef.current) return;
95
- hydratedRef.current = true;
96
- void readClientAppState<unknown>(WORKSPACE_APP_LAYOUT_STATE_KEY)
97
- .then((value) => {
98
- if (value !== null && !localChangeRef.current) {
99
- setLayout(normalizeWorkspaceAppLayout(value));
100
- }
101
- })
102
- .catch(() => {
103
- // Device-local preferences remain usable when workspace state is unavailable.
104
- });
105
- }, []);
106
-
107
- const persistLayout = useCallback((next: ChatFirstAppLayoutPreference) => {
108
- localChangeRef.current = true;
109
- const normalizedNext = normalizeWorkspaceAppLayout(next);
110
- setLayout(normalizedNext);
111
- const deviceResult = writeChatFirstAppLayout(normalizedNext);
112
- const deviceFailed = !deviceResult.ok;
113
-
114
- void writeClientAppState(WORKSPACE_APP_LAYOUT_STATE_KEY, normalizedNext)
115
- .then(() => {
116
- setPersistenceError(deviceFailed ? "device" : null);
117
- })
118
- .catch(() => {
119
- setPersistenceError(deviceFailed ? "both" : "workspace");
120
- });
121
- }, []);
122
-
123
- const togglePinned = useCallback(
124
- (appId: string) => {
125
- persistLayout(toggleWorkspaceAppPinned(layout, appId));
170
+ interface WorkspaceAppLayoutSnapshot {
171
+ layout: ChatFirstAppLayoutPreference;
172
+ /** `writeId` is new for each save, so a dismissed notice shows again. */
173
+ persistenceError: {
174
+ scope: WorkspaceAppLayoutPersistenceError;
175
+ writeId: number;
176
+ } | null;
177
+ }
178
+
179
+ type WorkspaceAppLayoutUpdate = (
180
+ layout: ChatFirstAppLayoutPreference,
181
+ ) => ChatFirstAppLayoutPreference;
182
+
183
+ const SERVER_SNAPSHOT: WorkspaceAppLayoutSnapshot = {
184
+ layout: { pinnedIds: [], orderedIds: [] },
185
+ persistenceError: null,
186
+ };
187
+
188
+ // One store per page: every surface reads and writes the same pins, so a
189
+ // write always starts from the latest layout instead of a surface's copy.
190
+ let snapshot: WorkspaceAppLayoutSnapshot | null = null;
191
+ // Workspace state is written only after it has been read: until then a write
192
+ // would replace saved pins this page never saw. "failed" retries the read.
193
+ let hydration: "idle" | "pending" | "failed" | "done" = "idle";
194
+ // Changes made before workspace state was read. They replay onto the read
195
+ // layout, then persist.
196
+ let pendingUpdates: WorkspaceAppLayoutUpdate[] = [];
197
+ let deviceSaved = true;
198
+ let writeSequence = 0;
199
+ const listeners = new Set<() => void>();
200
+
201
+ function currentSnapshot(): WorkspaceAppLayoutSnapshot {
202
+ snapshot ??= {
203
+ layout: normalizeWorkspaceAppLayout(readChatFirstAppLayout()),
204
+ persistenceError: null,
205
+ };
206
+ return snapshot;
207
+ }
208
+
209
+ function setSnapshot(next: Partial<WorkspaceAppLayoutSnapshot>) {
210
+ snapshot = { ...currentSnapshot(), ...next };
211
+ for (const listener of listeners) listener();
212
+ }
213
+
214
+ function writeDeviceLayout(next: ChatFirstAppLayoutPreference) {
215
+ deviceSaved = writeChatFirstAppLayout(next).ok;
216
+ }
217
+
218
+ function settlePersistence(writeId: number, workspaceSaved: boolean) {
219
+ if (writeId !== writeSequence) return;
220
+ const scope: WorkspaceAppLayoutPersistenceError | null = workspaceSaved
221
+ ? deviceSaved
222
+ ? null
223
+ : "device"
224
+ : deviceSaved
225
+ ? "workspace"
226
+ : "both";
227
+ setSnapshot({ persistenceError: scope ? { scope, writeId } : null });
228
+ }
229
+
230
+ function persistWorkspaceAppLayout(next: ChatFirstAppLayoutPreference) {
231
+ writeDeviceLayout(next);
232
+ const writeId = ++writeSequence;
233
+ void writeClientAppState(WORKSPACE_APP_LAYOUT_STATE_KEY, next).then(
234
+ () => settlePersistence(writeId, true),
235
+ () => settlePersistence(writeId, false),
236
+ );
237
+ }
238
+
239
+ function finishHydration(saved: ChatFirstAppLayoutPreference | null) {
240
+ hydration = "done";
241
+ const updates = pendingUpdates;
242
+ pendingUpdates = [];
243
+ // Absent workspace state keeps the device layout, which already holds the
244
+ // queued updates.
245
+ const next = saved
246
+ ? updates.reduce(
247
+ (layout, update) => normalizeWorkspaceAppLayout(update(layout)),
248
+ saved,
249
+ )
250
+ : currentSnapshot().layout;
251
+ setSnapshot({ layout: next });
252
+ if (updates.length > 0) {
253
+ persistWorkspaceAppLayout(next);
254
+ return;
255
+ }
256
+ if (saved) writeDeviceLayout(next);
257
+ // Nothing was saved before this read, so the only error it can clear is
258
+ // the earlier read failure.
259
+ setSnapshot({ persistenceError: null });
260
+ }
261
+
262
+ function isSavedWorkspaceAppLayout(value: unknown): boolean {
263
+ if (!value || typeof value !== "object") return false;
264
+ const candidate = value as Record<string, unknown>;
265
+ return (
266
+ Array.isArray(candidate.pinnedIds) && Array.isArray(candidate.orderedIds)
267
+ );
268
+ }
269
+
270
+ function hydrateWorkspaceAppLayout() {
271
+ if (hydration === "pending" || hydration === "done") return;
272
+ hydration = "pending";
273
+ void readClientAppState<unknown>(WORKSPACE_APP_LAYOUT_STATE_KEY).then(
274
+ (value) => {
275
+ if (value === null) return finishHydration(null);
276
+ // A malformed value is not "no pins": treat it as absent so the device
277
+ // pins survive, and the next save replaces it.
278
+ if (!isSavedWorkspaceAppLayout(value)) {
279
+ console.warn(
280
+ `[dispatch] ignoring malformed ${WORKSPACE_APP_LAYOUT_STATE_KEY}`,
281
+ value,
282
+ );
283
+ return finishHydration(null);
284
+ }
285
+ finishHydration(normalizeWorkspaceAppLayout(value));
286
+ },
287
+ () => {
288
+ hydration = "failed";
289
+ // Only unsaved pins make a failed read worth reporting.
290
+ if (pendingUpdates.length > 0) settlePersistence(++writeSequence, false);
126
291
  },
127
- [layout, persistLayout],
128
292
  );
293
+ }
294
+
295
+ function subscribe(listener: () => void) {
296
+ listeners.add(listener);
297
+ return () => {
298
+ listeners.delete(listener);
299
+ };
300
+ }
129
301
 
130
- return { layout, persistenceError, persistLayout, togglePinned };
302
+ export function updateWorkspaceAppLayout(update: WorkspaceAppLayoutUpdate) {
303
+ const next = normalizeWorkspaceAppLayout(update(currentSnapshot().layout));
304
+ setSnapshot({ layout: next });
305
+ if (hydration === "done") {
306
+ persistWorkspaceAppLayout(next);
307
+ return;
308
+ }
309
+ pendingUpdates.push(update);
310
+ writeDeviceLayout(next);
311
+ if (hydration === "failed") settlePersistence(++writeSequence, false);
312
+ hydrateWorkspaceAppLayout();
313
+ }
314
+
315
+ // The click decides pin or unpin from what is on screen, so a change replayed
316
+ // onto workspace pins read later keeps that intent.
317
+ function togglePinned(appId: string) {
318
+ const pinned = !currentSnapshot().layout.pinnedIds.includes(
319
+ appId.trim().toLowerCase(),
320
+ );
321
+ updateWorkspaceAppLayout((layout) =>
322
+ setWorkspaceAppPinned(layout, appId, pinned),
323
+ );
324
+ }
325
+
326
+ /** Test-only: forget the page's layout so the next read starts from storage. */
327
+ export function resetWorkspaceAppLayoutStoreForTests() {
328
+ snapshot = null;
329
+ hydration = "idle";
330
+ pendingUpdates = [];
331
+ deviceSaved = true;
332
+ writeSequence = 0;
333
+ listeners.clear();
334
+ }
335
+
336
+ export function useWorkspaceAppLayout({
337
+ hydrate = true,
338
+ }: { hydrate?: boolean } = {}) {
339
+ const { layout, persistenceError } = useSyncExternalStore(
340
+ subscribe,
341
+ currentSnapshot,
342
+ () => SERVER_SNAPSHOT,
343
+ );
344
+ useEffect(() => {
345
+ if (hydrate) hydrateWorkspaceAppLayout();
346
+ }, [hydrate]);
347
+ return { layout, persistenceError, togglePinned };
131
348
  }
@@ -0,0 +1,32 @@
1
+ import { useActionQuery } from "@agent-native/core/client/hooks";
2
+
3
+ import { filterBuiltInApps, type ConnectedAppSummary } from "./other-apps";
4
+ import { appListsStatus } from "./workspace-app-layout";
5
+ import type { WorkspaceAppSummary } from "./workspace-apps";
6
+
7
+ /**
8
+ * The app lists behind Home and All apps. Hosted apps stay empty until both
9
+ * lists settle: a workspace that mounts its own copy of a hosted app is only
10
+ * known from the workspace list.
11
+ */
12
+ export function useWorkspaceAppLists({
13
+ includeHiddenFromLaunchers = false,
14
+ }: { includeHiddenFromLaunchers?: boolean } = {}) {
15
+ const appsQuery = useActionQuery<WorkspaceAppSummary[]>(
16
+ "list-workspace-apps",
17
+ { includeAgentCards: false, includeArchived: true },
18
+ );
19
+ const connectedAppsQuery = useActionQuery<ConnectedAppSummary[]>(
20
+ "list-connected-agents",
21
+ {},
22
+ );
23
+ const status = appListsStatus(appsQuery, connectedAppsQuery);
24
+ const workspaceApps = appsQuery.data ?? [];
25
+ const hostedApps =
26
+ status === "ready"
27
+ ? filterBuiltInApps(connectedAppsQuery.data ?? [], workspaceApps, {
28
+ includeHiddenFromLaunchers,
29
+ })
30
+ : [];
31
+ return { status, workspaceApps, hostedApps, appsQuery, connectedAppsQuery };
32
+ }
@@ -47,6 +47,7 @@ export interface WorkspaceAppSummary {
47
47
  archived?: boolean;
48
48
  workspaceSso?: boolean;
49
49
  source?: WorkspaceAppSource;
50
+ hiddenFromLaunchers?: boolean;
50
51
  }
51
52
 
52
53
  export type WorkspaceAppSource = "workspace" | "builtin" | "connected";
@@ -168,9 +169,13 @@ export function isDefaultWorkspaceAppHiddenId(appId: string): boolean {
168
169
  }
169
170
 
170
171
  export function isWorkspaceAppVisibleInDefaultLaunchers(
171
- app: Pick<WorkspaceAppSummary, "id" | "isDispatch">,
172
+ app: Pick<WorkspaceAppSummary, "id" | "isDispatch" | "hiddenFromLaunchers">,
172
173
  ): boolean {
173
- return !app.isDispatch && !isDefaultWorkspaceAppHiddenId(app.id);
174
+ return (
175
+ !app.isDispatch &&
176
+ !app.hiddenFromLaunchers &&
177
+ !isDefaultWorkspaceAppHiddenId(app.id)
178
+ );
174
179
  }
175
180
 
176
181
  const DEFAULT_WORKSPACE_APP_DESCRIPTIONS: Record<
@@ -506,6 +511,7 @@ export function mergeChatFirstWorkspaceApps(
506
511
  description?: string | null;
507
512
  url?: string | null;
508
513
  source?: WorkspaceAppSource;
514
+ hiddenFromLaunchers?: boolean;
509
515
  }[] = [],
510
516
  ): WorkspaceAppSummary[] {
511
517
  const enabledBuiltins = new Set(
@@ -554,6 +560,7 @@ export function mergeChatFirstWorkspaceApps(
554
560
  name: app.name.trim() || existing.name,
555
561
  description: app.description ?? existing.description,
556
562
  url: app.url?.trim() || existing.url,
563
+ ...(app.hiddenFromLaunchers ? { hiddenFromLaunchers: true } : {}),
557
564
  });
558
565
  }
559
566
  continue;
@@ -566,6 +573,7 @@ export function mergeChatFirstWorkspaceApps(
566
573
  url: app.url?.trim() || null,
567
574
  status: "ready",
568
575
  source: app.source ?? "connected",
576
+ ...(app.hiddenFromLaunchers ? { hiddenFromLaunchers: true } : {}),
569
577
  });
570
578
  }
571
579