@agent-relay/dashboard 2.0.80 → 2.0.82

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 (244) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
  3. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
  4. package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
  5. package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
  6. package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
  7. package/out/about.html +2 -2
  8. package/out/about.txt +1 -1
  9. package/out/app/onboarding.html +1 -1
  10. package/out/app/onboarding.txt +1 -1
  11. package/out/app.html +1 -1
  12. package/out/app.txt +2 -2
  13. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  14. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  15. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  16. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  17. package/out/blog.html +2 -2
  18. package/out/blog.txt +1 -1
  19. package/out/careers.html +2 -2
  20. package/out/careers.txt +1 -1
  21. package/out/changelog.html +2 -2
  22. package/out/changelog.txt +1 -1
  23. package/out/cloud/link.html +1 -1
  24. package/out/cloud/link.txt +2 -2
  25. package/out/complete-profile.html +2 -2
  26. package/out/complete-profile.txt +1 -1
  27. package/out/connect-repos.html +1 -1
  28. package/out/connect-repos.txt +1 -1
  29. package/out/contact.html +2 -2
  30. package/out/contact.txt +1 -1
  31. package/out/docs.html +2 -2
  32. package/out/docs.txt +1 -1
  33. package/out/history.html +1 -1
  34. package/out/history.txt +2 -2
  35. package/out/index.html +1 -1
  36. package/out/index.txt +2 -2
  37. package/out/login.html +2 -2
  38. package/out/login.txt +1 -1
  39. package/out/metrics.html +1 -1
  40. package/out/metrics.txt +2 -2
  41. package/out/pricing.html +2 -2
  42. package/out/pricing.txt +1 -1
  43. package/out/privacy.html +2 -2
  44. package/out/privacy.txt +1 -1
  45. package/out/providers/setup/claude.html +1 -1
  46. package/out/providers/setup/claude.txt +1 -1
  47. package/out/providers/setup/codex.html +1 -1
  48. package/out/providers/setup/codex.txt +1 -1
  49. package/out/providers/setup/cursor.html +1 -1
  50. package/out/providers/setup/cursor.txt +1 -1
  51. package/out/providers.html +1 -1
  52. package/out/providers.txt +1 -1
  53. package/out/security.html +2 -2
  54. package/out/security.txt +1 -1
  55. package/out/signup.html +2 -2
  56. package/out/signup.txt +1 -1
  57. package/out/terms.html +2 -2
  58. package/out/terms.txt +1 -1
  59. package/package.json +7 -1
  60. package/src/app/about/page.tsx +7 -0
  61. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
  62. package/src/app/app/[[...slug]]/page.tsx +23 -0
  63. package/src/app/app/onboarding/page.tsx +394 -0
  64. package/src/app/apple-icon.png +0 -0
  65. package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
  66. package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
  67. package/src/app/blog/page.tsx +15 -0
  68. package/src/app/careers/page.tsx +7 -0
  69. package/src/app/changelog/page.tsx +7 -0
  70. package/src/app/cloud/link/page.tsx +464 -0
  71. package/src/app/complete-profile/page.tsx +204 -0
  72. package/src/app/connect-repos/page.tsx +410 -0
  73. package/src/app/contact/page.tsx +7 -0
  74. package/src/app/docs/page.tsx +7 -0
  75. package/src/app/favicon.png +0 -0
  76. package/src/app/globals.css +200 -0
  77. package/src/app/history/page.tsx +658 -0
  78. package/src/app/layout.tsx +25 -0
  79. package/src/app/login/page.tsx +424 -0
  80. package/src/app/metrics/page.tsx +781 -0
  81. package/src/app/page.tsx +59 -0
  82. package/src/app/pricing/page.tsx +7 -0
  83. package/src/app/privacy/page.tsx +7 -0
  84. package/src/app/providers/page.tsx +193 -0
  85. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
  86. package/src/app/providers/setup/[provider]/constants.ts +35 -0
  87. package/src/app/providers/setup/[provider]/page.tsx +42 -0
  88. package/src/app/security/page.tsx +7 -0
  89. package/src/app/signup/page.tsx +533 -0
  90. package/src/app/terms/page.tsx +7 -0
  91. package/src/components/ActivityFeed.tsx +216 -0
  92. package/src/components/AddWorkspaceModal.tsx +170 -0
  93. package/src/components/AgentCard.test.tsx +134 -0
  94. package/src/components/AgentCard.tsx +585 -0
  95. package/src/components/AgentList.test.tsx +147 -0
  96. package/src/components/AgentList.tsx +419 -0
  97. package/src/components/AgentLogPreview.tsx +173 -0
  98. package/src/components/AgentProfilePanel.tsx +569 -0
  99. package/src/components/App.tsx +3424 -0
  100. package/src/components/BillingPanel.tsx +922 -0
  101. package/src/components/BillingResult.tsx +447 -0
  102. package/src/components/BroadcastComposer.tsx +690 -0
  103. package/src/components/ChannelAdminPanel.tsx +773 -0
  104. package/src/components/ChannelBrowser.tsx +385 -0
  105. package/src/components/ChannelChat.tsx +261 -0
  106. package/src/components/ChannelSidebar.tsx +399 -0
  107. package/src/components/CloudSessionProvider.tsx +130 -0
  108. package/src/components/CommandPalette.tsx +815 -0
  109. package/src/components/ConfirmationDialog.tsx +133 -0
  110. package/src/components/ConversationHistory.tsx +518 -0
  111. package/src/components/CoordinatorPanel.tsx +956 -0
  112. package/src/components/DecisionQueue.tsx +717 -0
  113. package/src/components/DirectMessageView.tsx +164 -0
  114. package/src/components/FileAutocomplete.tsx +368 -0
  115. package/src/components/FleetOverview.tsx +278 -0
  116. package/src/components/LogViewer.tsx +310 -0
  117. package/src/components/LogViewerPanel.tsx +482 -0
  118. package/src/components/Logo.tsx +284 -0
  119. package/src/components/MentionAutocomplete.tsx +384 -0
  120. package/src/components/MessageComposer.tsx +473 -0
  121. package/src/components/MessageList.tsx +725 -0
  122. package/src/components/MessageSenderName.tsx +91 -0
  123. package/src/components/MessageStatusIndicator.tsx +142 -0
  124. package/src/components/NewConversationModal.tsx +400 -0
  125. package/src/components/NotificationToast.tsx +488 -0
  126. package/src/components/OnlineUsersIndicator.tsx +164 -0
  127. package/src/components/Pagination.tsx +124 -0
  128. package/src/components/PricingPlans.tsx +386 -0
  129. package/src/components/ProjectList.tsx +711 -0
  130. package/src/components/ProviderAuthFlow.tsx +343 -0
  131. package/src/components/ProviderConnectionList.tsx +375 -0
  132. package/src/components/ProvisioningProgress.tsx +730 -0
  133. package/src/components/ReactionChips.tsx +70 -0
  134. package/src/components/ReactionPicker.tsx +121 -0
  135. package/src/components/RepoAccessPanel.tsx +787 -0
  136. package/src/components/RepositoriesPanel.tsx +901 -0
  137. package/src/components/ServerCard.tsx +202 -0
  138. package/src/components/SessionExpiredModal.tsx +128 -0
  139. package/src/components/SpawnModal.test.tsx +190 -0
  140. package/src/components/SpawnModal.tsx +1001 -0
  141. package/src/components/TaskAssignmentUI.tsx +375 -0
  142. package/src/components/TerminalProviderSetup.tsx +517 -0
  143. package/src/components/ThemeProvider.tsx +159 -0
  144. package/src/components/ThinkingIndicator.tsx +231 -0
  145. package/src/components/ThreadList.tsx +198 -0
  146. package/src/components/ThreadPanel.tsx +405 -0
  147. package/src/components/TrajectoryViewer.tsx +698 -0
  148. package/src/components/TypingIndicator.tsx +69 -0
  149. package/src/components/UsageBanner.tsx +231 -0
  150. package/src/components/UserProfilePanel.tsx +233 -0
  151. package/src/components/WorkspaceContext.tsx +95 -0
  152. package/src/components/WorkspaceSelector.tsx +234 -0
  153. package/src/components/WorkspaceStatusIndicator.tsx +396 -0
  154. package/src/components/XTermInteractive.tsx +516 -0
  155. package/src/components/XTermLogViewer.tsx +719 -0
  156. package/src/components/channels/ChannelDialogs.tsx +1411 -0
  157. package/src/components/channels/ChannelHeader.tsx +317 -0
  158. package/src/components/channels/ChannelMessageList.tsx +463 -0
  159. package/src/components/channels/ChannelViewV1.tsx +146 -0
  160. package/src/components/channels/MessageInput.tsx +302 -0
  161. package/src/components/channels/SearchInput.tsx +172 -0
  162. package/src/components/channels/SearchResults.tsx +336 -0
  163. package/src/components/channels/api.test.ts +1527 -0
  164. package/src/components/channels/api.ts +703 -0
  165. package/src/components/channels/index.ts +76 -0
  166. package/src/components/channels/mockApi.ts +344 -0
  167. package/src/components/channels/types.ts +566 -0
  168. package/src/components/hooks/index.ts +58 -0
  169. package/src/components/hooks/useAgentLogs.ts +504 -0
  170. package/src/components/hooks/useAgents.ts +127 -0
  171. package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
  172. package/src/components/hooks/useBroadcastDedup.ts +86 -0
  173. package/src/components/hooks/useChannelAdmin.ts +329 -0
  174. package/src/components/hooks/useChannelBrowser.ts +239 -0
  175. package/src/components/hooks/useChannelCommands.ts +138 -0
  176. package/src/components/hooks/useChannels.ts +367 -0
  177. package/src/components/hooks/useDebounce.ts +29 -0
  178. package/src/components/hooks/useDirectMessage.test.ts +952 -0
  179. package/src/components/hooks/useDirectMessage.ts +141 -0
  180. package/src/components/hooks/useMessages.ts +310 -0
  181. package/src/components/hooks/useOrchestrator.test.ts +165 -0
  182. package/src/components/hooks/useOrchestrator.ts +424 -0
  183. package/src/components/hooks/usePinnedAgents.test.ts +356 -0
  184. package/src/components/hooks/usePinnedAgents.ts +140 -0
  185. package/src/components/hooks/usePresence.test.ts +245 -0
  186. package/src/components/hooks/usePresence.ts +377 -0
  187. package/src/components/hooks/useRecentRepos.ts +130 -0
  188. package/src/components/hooks/useSession.ts +209 -0
  189. package/src/components/hooks/useThread.ts +138 -0
  190. package/src/components/hooks/useTrajectory.ts +265 -0
  191. package/src/components/hooks/useWebSocket.ts +290 -0
  192. package/src/components/hooks/useWorkspaceMembers.ts +132 -0
  193. package/src/components/hooks/useWorkspaceRepos.ts +73 -0
  194. package/src/components/hooks/useWorkspaceStatus.ts +237 -0
  195. package/src/components/index.ts +81 -0
  196. package/src/components/layout/Header.tsx +311 -0
  197. package/src/components/layout/RepoContextHeader.tsx +361 -0
  198. package/src/components/layout/Sidebar.archive.test.tsx +126 -0
  199. package/src/components/layout/Sidebar.test.tsx +691 -0
  200. package/src/components/layout/Sidebar.tsx +900 -0
  201. package/src/components/layout/index.ts +7 -0
  202. package/src/components/settings/BillingSettingsPanel.tsx +564 -0
  203. package/src/components/settings/SettingsPage.tsx +683 -0
  204. package/src/components/settings/TeamSettingsPanel.tsx +560 -0
  205. package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
  206. package/src/components/settings/index.ts +11 -0
  207. package/src/components/settings/types.ts +79 -0
  208. package/src/components/utils/messageFormatting.test.tsx +331 -0
  209. package/src/components/utils/messageFormatting.tsx +597 -0
  210. package/src/index.ts +63 -0
  211. package/src/landing/AboutPage.tsx +77 -0
  212. package/src/landing/BlogContent.tsx +187 -0
  213. package/src/landing/BlogPage.tsx +47 -0
  214. package/src/landing/CareersPage.tsx +53 -0
  215. package/src/landing/ChangelogPage.tsx +33 -0
  216. package/src/landing/ContactPage.tsx +41 -0
  217. package/src/landing/DocsPage.tsx +43 -0
  218. package/src/landing/LandingPage.tsx +702 -0
  219. package/src/landing/PricingPage.tsx +549 -0
  220. package/src/landing/PrivacyPage.tsx +117 -0
  221. package/src/landing/SecurityPage.tsx +42 -0
  222. package/src/landing/StaticPage.tsx +165 -0
  223. package/src/landing/TermsPage.tsx +125 -0
  224. package/src/landing/blogData.ts +312 -0
  225. package/src/landing/index.ts +18 -0
  226. package/src/landing/styles.css +3673 -0
  227. package/src/lib/agent-merge.test.ts +43 -0
  228. package/src/lib/agent-merge.ts +35 -0
  229. package/src/lib/api.ts +1294 -0
  230. package/src/lib/cloudApi.ts +893 -0
  231. package/src/lib/colors.test.ts +175 -0
  232. package/src/lib/colors.ts +218 -0
  233. package/src/lib/config.ts +109 -0
  234. package/src/lib/hierarchy.ts +242 -0
  235. package/src/lib/stuckDetection.ts +142 -0
  236. package/src/lib/useUrlRouting.ts +190 -0
  237. package/src/types/index.ts +317 -0
  238. package/src/types/threading.ts +7 -0
  239. package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
  240. package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
  241. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
  242. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
  243. /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
  244. /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Tests for usePinnedAgents hook utilities
3
+ *
4
+ * Tests the pure functions that power the hook:
5
+ * - loadPinnedAgents, savePinnedAgents (localStorage operations)
6
+ * - pinAgent, unpinAgent (state transformations)
7
+ *
8
+ * @vitest-environment jsdom
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
12
+ import {
13
+ STORAGE_KEY,
14
+ MAX_PINNED,
15
+ loadPinnedAgents,
16
+ savePinnedAgents,
17
+ pinAgent,
18
+ unpinAgent,
19
+ } from './usePinnedAgents';
20
+
21
+ // Mock localStorage
22
+ const createLocalStorageMock = () => {
23
+ let store: Record<string, string> = {};
24
+ return {
25
+ getItem: vi.fn((key: string) => store[key] || null),
26
+ setItem: vi.fn((key: string, value: string) => {
27
+ store[key] = value;
28
+ }),
29
+ removeItem: vi.fn((key: string) => {
30
+ delete store[key];
31
+ }),
32
+ clear: vi.fn(() => {
33
+ store = {};
34
+ }),
35
+ get length() {
36
+ return Object.keys(store).length;
37
+ },
38
+ key: vi.fn((index: number) => Object.keys(store)[index] || null),
39
+ _getStore: () => store,
40
+ _setStore: (newStore: Record<string, string>) => {
41
+ store = newStore;
42
+ },
43
+ };
44
+ };
45
+
46
+ describe('usePinnedAgents utilities', () => {
47
+ let localStorageMock: ReturnType<typeof createLocalStorageMock>;
48
+
49
+ beforeEach(() => {
50
+ localStorageMock = createLocalStorageMock();
51
+ vi.stubGlobal('localStorage', localStorageMock);
52
+ });
53
+
54
+ describe('Constants', () => {
55
+ it('should have correct storage key', () => {
56
+ expect(STORAGE_KEY).toBe('agent-relay-pinned-agents');
57
+ });
58
+
59
+ it('should have max pinned limit of 5', () => {
60
+ expect(MAX_PINNED).toBe(5);
61
+ });
62
+ });
63
+
64
+ describe('loadPinnedAgents', () => {
65
+ it('should return empty array when no data in localStorage', () => {
66
+ const result = loadPinnedAgents();
67
+ expect(result).toEqual([]);
68
+ });
69
+
70
+ it('should load pinned agents from localStorage', () => {
71
+ const savedAgents = ['Agent1', 'Agent2', 'Agent3'];
72
+ localStorageMock.setItem(STORAGE_KEY, JSON.stringify(savedAgents));
73
+
74
+ const result = loadPinnedAgents();
75
+ expect(result).toEqual(savedAgents);
76
+ });
77
+
78
+ it('should limit loaded agents to max 5', () => {
79
+ const savedAgents = ['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7'];
80
+ localStorageMock.setItem(STORAGE_KEY, JSON.stringify(savedAgents));
81
+
82
+ const result = loadPinnedAgents();
83
+ expect(result).toHaveLength(5);
84
+ expect(result).toEqual(['A1', 'A2', 'A3', 'A4', 'A5']);
85
+ });
86
+
87
+ it('should handle corrupted JSON gracefully', () => {
88
+ localStorageMock.setItem(STORAGE_KEY, 'not valid json {{{');
89
+
90
+ const result = loadPinnedAgents();
91
+ expect(result).toEqual([]);
92
+ });
93
+
94
+ it('should handle non-array JSON gracefully', () => {
95
+ localStorageMock.setItem(STORAGE_KEY, JSON.stringify({ foo: 'bar' }));
96
+
97
+ const result = loadPinnedAgents();
98
+ expect(result).toEqual([]);
99
+ });
100
+
101
+ it('should handle localStorage.getItem throwing', () => {
102
+ const errorMock = {
103
+ getItem: () => {
104
+ throw new Error('localStorage disabled');
105
+ },
106
+ setItem: vi.fn(),
107
+ removeItem: vi.fn(),
108
+ clear: vi.fn(),
109
+ length: 0,
110
+ key: vi.fn(),
111
+ };
112
+ vi.stubGlobal('localStorage', errorMock);
113
+
114
+ const result = loadPinnedAgents();
115
+ expect(result).toEqual([]);
116
+ });
117
+
118
+ it('should handle localStorage being undefined (SSR)', () => {
119
+ vi.stubGlobal('localStorage', undefined);
120
+
121
+ const result = loadPinnedAgents();
122
+ expect(result).toEqual([]);
123
+ });
124
+ });
125
+
126
+ describe('savePinnedAgents', () => {
127
+ it('should save pinned agents to localStorage', () => {
128
+ const agents = ['Agent1', 'Agent2'];
129
+ savePinnedAgents(agents);
130
+
131
+ const stored = JSON.parse(localStorageMock.getItem(STORAGE_KEY) || '[]');
132
+ expect(stored).toEqual(agents);
133
+ });
134
+
135
+ it('should overwrite existing data', () => {
136
+ localStorageMock.setItem(STORAGE_KEY, JSON.stringify(['Old']));
137
+
138
+ savePinnedAgents(['New1', 'New2']);
139
+
140
+ const stored = JSON.parse(localStorageMock.getItem(STORAGE_KEY) || '[]');
141
+ expect(stored).toEqual(['New1', 'New2']);
142
+ });
143
+
144
+ it('should handle localStorage.setItem throwing', () => {
145
+ const errorMock = {
146
+ getItem: vi.fn(() => null),
147
+ setItem: () => {
148
+ throw new Error('QuotaExceededError');
149
+ },
150
+ removeItem: vi.fn(),
151
+ clear: vi.fn(),
152
+ length: 0,
153
+ key: vi.fn(),
154
+ };
155
+ vi.stubGlobal('localStorage', errorMock);
156
+
157
+ // Should not throw
158
+ expect(() => savePinnedAgents(['Agent1'])).not.toThrow();
159
+ });
160
+
161
+ it('should handle localStorage being undefined (SSR)', () => {
162
+ vi.stubGlobal('localStorage', undefined);
163
+
164
+ // Should not throw
165
+ expect(() => savePinnedAgents(['Agent1'])).not.toThrow();
166
+ });
167
+ });
168
+
169
+ describe('pinAgent', () => {
170
+ it('should add agent to empty list', () => {
171
+ const { newPinned, success } = pinAgent([], 'Agent1');
172
+
173
+ expect(success).toBe(true);
174
+ expect(newPinned).toEqual(['Agent1']);
175
+ });
176
+
177
+ it('should add agent to existing list', () => {
178
+ const { newPinned, success } = pinAgent(['Agent1', 'Agent2'], 'Agent3');
179
+
180
+ expect(success).toBe(true);
181
+ expect(newPinned).toEqual(['Agent1', 'Agent2', 'Agent3']);
182
+ });
183
+
184
+ it('should return success true but same list when agent already pinned', () => {
185
+ const current = ['Agent1', 'Agent2'];
186
+ const { newPinned, success } = pinAgent(current, 'Agent1');
187
+
188
+ expect(success).toBe(true);
189
+ expect(newPinned).toBe(current); // Same reference, not modified
190
+ });
191
+
192
+ it('should not create duplicates', () => {
193
+ const { newPinned } = pinAgent(['Agent1'], 'Agent1');
194
+
195
+ expect(newPinned.filter((a) => a === 'Agent1')).toHaveLength(1);
196
+ });
197
+
198
+ it('should enforce max 5 limit', () => {
199
+ const fullList = ['A1', 'A2', 'A3', 'A4', 'A5'];
200
+ const { newPinned, success } = pinAgent(fullList, 'A6');
201
+
202
+ expect(success).toBe(false);
203
+ expect(newPinned).toBe(fullList); // Same reference
204
+ expect(newPinned).not.toContain('A6');
205
+ });
206
+
207
+ it('should maintain pin order', () => {
208
+ let current: string[] = [];
209
+ current = pinAgent(current, 'First').newPinned;
210
+ current = pinAgent(current, 'Second').newPinned;
211
+ current = pinAgent(current, 'Third').newPinned;
212
+
213
+ expect(current).toEqual(['First', 'Second', 'Third']);
214
+ });
215
+
216
+ it('should handle agent names with special characters', () => {
217
+ const specialNames = [
218
+ 'Agent-With-Dashes',
219
+ 'Agent_With_Underscores',
220
+ 'Agent.With.Dots',
221
+ 'Agent With Spaces',
222
+ ];
223
+
224
+ let current: string[] = [];
225
+ for (const name of specialNames) {
226
+ const { newPinned, success } = pinAgent(current, name);
227
+ expect(success).toBe(true);
228
+ current = newPinned;
229
+ }
230
+
231
+ expect(current).toEqual(specialNames);
232
+ });
233
+
234
+ it('should handle empty string agent name', () => {
235
+ const { newPinned, success } = pinAgent([], '');
236
+
237
+ expect(success).toBe(true);
238
+ expect(newPinned).toContain('');
239
+ });
240
+ });
241
+
242
+ describe('unpinAgent', () => {
243
+ it('should remove agent from list', () => {
244
+ const result = unpinAgent(['Agent1', 'Agent2', 'Agent3'], 'Agent2');
245
+
246
+ expect(result).toEqual(['Agent1', 'Agent3']);
247
+ });
248
+
249
+ it('should return same list without the agent when unpinning from start', () => {
250
+ const result = unpinAgent(['Agent1', 'Agent2', 'Agent3'], 'Agent1');
251
+
252
+ expect(result).toEqual(['Agent2', 'Agent3']);
253
+ });
254
+
255
+ it('should return same list without the agent when unpinning from end', () => {
256
+ const result = unpinAgent(['Agent1', 'Agent2', 'Agent3'], 'Agent3');
257
+
258
+ expect(result).toEqual(['Agent1', 'Agent2']);
259
+ });
260
+
261
+ it('should return same content when unpinning non-existent agent', () => {
262
+ const current = ['Agent1', 'Agent2'];
263
+ const result = unpinAgent(current, 'NonExistent');
264
+
265
+ expect(result).toEqual(['Agent1', 'Agent2']);
266
+ });
267
+
268
+ it('should not throw when unpinning from empty list', () => {
269
+ const result = unpinAgent([], 'Agent1');
270
+
271
+ expect(result).toEqual([]);
272
+ });
273
+
274
+ it('should return empty array when unpinning last agent', () => {
275
+ const result = unpinAgent(['Agent1'], 'Agent1');
276
+
277
+ expect(result).toEqual([]);
278
+ });
279
+ });
280
+
281
+ describe('Integration: pin then unpin', () => {
282
+ it('should correctly pin and unpin agents', () => {
283
+ let current: string[] = [];
284
+
285
+ // Pin some agents
286
+ current = pinAgent(current, 'Agent1').newPinned;
287
+ current = pinAgent(current, 'Agent2').newPinned;
288
+ current = pinAgent(current, 'Agent3').newPinned;
289
+ expect(current).toEqual(['Agent1', 'Agent2', 'Agent3']);
290
+
291
+ // Unpin middle agent
292
+ current = unpinAgent(current, 'Agent2');
293
+ expect(current).toEqual(['Agent1', 'Agent3']);
294
+
295
+ // Pin new agent - should go to end
296
+ current = pinAgent(current, 'Agent4').newPinned;
297
+ expect(current).toEqual(['Agent1', 'Agent3', 'Agent4']);
298
+
299
+ // Unpin all
300
+ current = unpinAgent(current, 'Agent1');
301
+ current = unpinAgent(current, 'Agent3');
302
+ current = unpinAgent(current, 'Agent4');
303
+ expect(current).toEqual([]);
304
+ });
305
+
306
+ it('should allow pinning after reaching limit then unpinning', () => {
307
+ let current: string[] = ['A1', 'A2', 'A3', 'A4', 'A5'];
308
+
309
+ // Try to pin 6th - should fail
310
+ let result = pinAgent(current, 'A6');
311
+ expect(result.success).toBe(false);
312
+
313
+ // Unpin one
314
+ current = unpinAgent(current, 'A3');
315
+ expect(current).toHaveLength(4);
316
+
317
+ // Now pinning should work
318
+ result = pinAgent(current, 'A6');
319
+ expect(result.success).toBe(true);
320
+ expect(result.newPinned).toContain('A6');
321
+ expect(result.newPinned).toHaveLength(5);
322
+ });
323
+ });
324
+
325
+ describe('Integration: localStorage round-trip', () => {
326
+ it('should correctly save and load agents', () => {
327
+ const agents = ['Agent1', 'Agent2', 'Agent3'];
328
+
329
+ savePinnedAgents(agents);
330
+ const loaded = loadPinnedAgents();
331
+
332
+ expect(loaded).toEqual(agents);
333
+ });
334
+
335
+ it('should handle save after pin operations', () => {
336
+ let current: string[] = [];
337
+ current = pinAgent(current, 'Agent1').newPinned;
338
+ current = pinAgent(current, 'Agent2').newPinned;
339
+
340
+ savePinnedAgents(current);
341
+ const loaded = loadPinnedAgents();
342
+
343
+ expect(loaded).toEqual(['Agent1', 'Agent2']);
344
+ });
345
+
346
+ it('should handle save after unpin operations', () => {
347
+ let current = ['Agent1', 'Agent2', 'Agent3'];
348
+ current = unpinAgent(current, 'Agent2');
349
+
350
+ savePinnedAgents(current);
351
+ const loaded = loadPinnedAgents();
352
+
353
+ expect(loaded).toEqual(['Agent1', 'Agent3']);
354
+ });
355
+ });
356
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * usePinnedAgents Hook
3
+ *
4
+ * Manages pinned agents with localStorage persistence.
5
+ * Pinned agents appear at the top of the agents panel.
6
+ */
7
+
8
+ import { useState, useCallback, useEffect, useMemo } from 'react';
9
+
10
+ export const STORAGE_KEY = 'agent-relay-pinned-agents';
11
+ export const MAX_PINNED = 5;
12
+
13
+ /**
14
+ * Load pinned agents from localStorage
15
+ * Exported for testing
16
+ */
17
+ export function loadPinnedAgents(): string[] {
18
+ try {
19
+ if (typeof localStorage === 'undefined') return [];
20
+ const stored = localStorage.getItem(STORAGE_KEY);
21
+ if (stored) {
22
+ const parsed = JSON.parse(stored);
23
+ if (Array.isArray(parsed)) {
24
+ return parsed.slice(0, MAX_PINNED);
25
+ }
26
+ }
27
+ } catch {
28
+ // localStorage not available or invalid data
29
+ }
30
+ return [];
31
+ }
32
+
33
+ /**
34
+ * Save pinned agents to localStorage
35
+ * Exported for testing
36
+ */
37
+ export function savePinnedAgents(agents: string[]): void {
38
+ try {
39
+ if (typeof localStorage === 'undefined') return;
40
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(agents));
41
+ } catch {
42
+ // localStorage not available
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Pin an agent to the list
48
+ * Returns the new list and whether the pin was successful
49
+ */
50
+ export function pinAgent(
51
+ currentPinned: string[],
52
+ agentName: string
53
+ ): { newPinned: string[]; success: boolean } {
54
+ if (currentPinned.includes(agentName)) {
55
+ return { newPinned: currentPinned, success: true }; // Already pinned
56
+ }
57
+ if (currentPinned.length >= MAX_PINNED) {
58
+ return { newPinned: currentPinned, success: false }; // At max capacity
59
+ }
60
+ return { newPinned: [...currentPinned, agentName], success: true };
61
+ }
62
+
63
+ /**
64
+ * Unpin an agent from the list
65
+ */
66
+ export function unpinAgent(currentPinned: string[], agentName: string): string[] {
67
+ return currentPinned.filter((name) => name !== agentName);
68
+ }
69
+
70
+ export interface UsePinnedAgentsReturn {
71
+ /** Array of pinned agent names */
72
+ pinnedAgents: string[];
73
+ /** Check if an agent is pinned */
74
+ isPinned: (agentName: string) => boolean;
75
+ /** Toggle pin status for an agent */
76
+ togglePin: (agentName: string) => void;
77
+ /** Pin an agent (no-op if already pinned or at max) */
78
+ pin: (agentName: string) => boolean;
79
+ /** Unpin an agent */
80
+ unpin: (agentName: string) => void;
81
+ /** Whether max pins reached */
82
+ isMaxPinned: boolean;
83
+ /** Maximum number of pinned agents allowed */
84
+ maxPinned: number;
85
+ }
86
+
87
+ export function usePinnedAgents(): UsePinnedAgentsReturn {
88
+ const [pinnedAgents, setPinnedAgents] = useState<string[]>(() => loadPinnedAgents());
89
+
90
+ // Persist to localStorage when pinnedAgents changes
91
+ useEffect(() => {
92
+ savePinnedAgents(pinnedAgents);
93
+ }, [pinnedAgents]);
94
+
95
+ const isPinned = useCallback(
96
+ (agentName: string) => pinnedAgents.includes(agentName),
97
+ [pinnedAgents]
98
+ );
99
+
100
+ const pin = useCallback(
101
+ (agentName: string): boolean => {
102
+ const { newPinned, success } = pinAgent(pinnedAgents, agentName);
103
+ if (newPinned !== pinnedAgents) {
104
+ setPinnedAgents(newPinned);
105
+ }
106
+ return success;
107
+ },
108
+ [pinnedAgents]
109
+ );
110
+
111
+ const unpin = useCallback((agentName: string) => {
112
+ setPinnedAgents((prev) => unpinAgent(prev, agentName));
113
+ }, []);
114
+
115
+ const togglePin = useCallback(
116
+ (agentName: string) => {
117
+ if (isPinned(agentName)) {
118
+ unpin(agentName);
119
+ } else {
120
+ pin(agentName);
121
+ }
122
+ },
123
+ [isPinned, pin, unpin]
124
+ );
125
+
126
+ const isMaxPinned = useMemo(
127
+ () => pinnedAgents.length >= MAX_PINNED,
128
+ [pinnedAgents]
129
+ );
130
+
131
+ return {
132
+ pinnedAgents,
133
+ isPinned,
134
+ togglePin,
135
+ pin,
136
+ unpin,
137
+ isMaxPinned,
138
+ maxPinned: MAX_PINNED,
139
+ };
140
+ }