@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.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
- package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +1 -1
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +7 -1
- package/src/app/about/page.tsx +7 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
- package/src/app/app/[[...slug]]/page.tsx +23 -0
- package/src/app/app/onboarding/page.tsx +394 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
- package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
- package/src/app/blog/page.tsx +15 -0
- package/src/app/careers/page.tsx +7 -0
- package/src/app/changelog/page.tsx +7 -0
- package/src/app/cloud/link/page.tsx +464 -0
- package/src/app/complete-profile/page.tsx +204 -0
- package/src/app/connect-repos/page.tsx +410 -0
- package/src/app/contact/page.tsx +7 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/favicon.png +0 -0
- package/src/app/globals.css +200 -0
- package/src/app/history/page.tsx +658 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/login/page.tsx +424 -0
- package/src/app/metrics/page.tsx +781 -0
- package/src/app/page.tsx +59 -0
- package/src/app/pricing/page.tsx +7 -0
- package/src/app/privacy/page.tsx +7 -0
- package/src/app/providers/page.tsx +193 -0
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
- package/src/app/providers/setup/[provider]/constants.ts +35 -0
- package/src/app/providers/setup/[provider]/page.tsx +42 -0
- package/src/app/security/page.tsx +7 -0
- package/src/app/signup/page.tsx +533 -0
- package/src/app/terms/page.tsx +7 -0
- package/src/components/ActivityFeed.tsx +216 -0
- package/src/components/AddWorkspaceModal.tsx +170 -0
- package/src/components/AgentCard.test.tsx +134 -0
- package/src/components/AgentCard.tsx +585 -0
- package/src/components/AgentList.test.tsx +147 -0
- package/src/components/AgentList.tsx +419 -0
- package/src/components/AgentLogPreview.tsx +173 -0
- package/src/components/AgentProfilePanel.tsx +569 -0
- package/src/components/App.tsx +3424 -0
- package/src/components/BillingPanel.tsx +922 -0
- package/src/components/BillingResult.tsx +447 -0
- package/src/components/BroadcastComposer.tsx +690 -0
- package/src/components/ChannelAdminPanel.tsx +773 -0
- package/src/components/ChannelBrowser.tsx +385 -0
- package/src/components/ChannelChat.tsx +261 -0
- package/src/components/ChannelSidebar.tsx +399 -0
- package/src/components/CloudSessionProvider.tsx +130 -0
- package/src/components/CommandPalette.tsx +815 -0
- package/src/components/ConfirmationDialog.tsx +133 -0
- package/src/components/ConversationHistory.tsx +518 -0
- package/src/components/CoordinatorPanel.tsx +956 -0
- package/src/components/DecisionQueue.tsx +717 -0
- package/src/components/DirectMessageView.tsx +164 -0
- package/src/components/FileAutocomplete.tsx +368 -0
- package/src/components/FleetOverview.tsx +278 -0
- package/src/components/LogViewer.tsx +310 -0
- package/src/components/LogViewerPanel.tsx +482 -0
- package/src/components/Logo.tsx +284 -0
- package/src/components/MentionAutocomplete.tsx +384 -0
- package/src/components/MessageComposer.tsx +473 -0
- package/src/components/MessageList.tsx +725 -0
- package/src/components/MessageSenderName.tsx +91 -0
- package/src/components/MessageStatusIndicator.tsx +142 -0
- package/src/components/NewConversationModal.tsx +400 -0
- package/src/components/NotificationToast.tsx +488 -0
- package/src/components/OnlineUsersIndicator.tsx +164 -0
- package/src/components/Pagination.tsx +124 -0
- package/src/components/PricingPlans.tsx +386 -0
- package/src/components/ProjectList.tsx +711 -0
- package/src/components/ProviderAuthFlow.tsx +343 -0
- package/src/components/ProviderConnectionList.tsx +375 -0
- package/src/components/ProvisioningProgress.tsx +730 -0
- package/src/components/ReactionChips.tsx +70 -0
- package/src/components/ReactionPicker.tsx +121 -0
- package/src/components/RepoAccessPanel.tsx +787 -0
- package/src/components/RepositoriesPanel.tsx +901 -0
- package/src/components/ServerCard.tsx +202 -0
- package/src/components/SessionExpiredModal.tsx +128 -0
- package/src/components/SpawnModal.test.tsx +190 -0
- package/src/components/SpawnModal.tsx +1001 -0
- package/src/components/TaskAssignmentUI.tsx +375 -0
- package/src/components/TerminalProviderSetup.tsx +517 -0
- package/src/components/ThemeProvider.tsx +159 -0
- package/src/components/ThinkingIndicator.tsx +231 -0
- package/src/components/ThreadList.tsx +198 -0
- package/src/components/ThreadPanel.tsx +405 -0
- package/src/components/TrajectoryViewer.tsx +698 -0
- package/src/components/TypingIndicator.tsx +69 -0
- package/src/components/UsageBanner.tsx +231 -0
- package/src/components/UserProfilePanel.tsx +233 -0
- package/src/components/WorkspaceContext.tsx +95 -0
- package/src/components/WorkspaceSelector.tsx +234 -0
- package/src/components/WorkspaceStatusIndicator.tsx +396 -0
- package/src/components/XTermInteractive.tsx +516 -0
- package/src/components/XTermLogViewer.tsx +719 -0
- package/src/components/channels/ChannelDialogs.tsx +1411 -0
- package/src/components/channels/ChannelHeader.tsx +317 -0
- package/src/components/channels/ChannelMessageList.tsx +463 -0
- package/src/components/channels/ChannelViewV1.tsx +146 -0
- package/src/components/channels/MessageInput.tsx +302 -0
- package/src/components/channels/SearchInput.tsx +172 -0
- package/src/components/channels/SearchResults.tsx +336 -0
- package/src/components/channels/api.test.ts +1527 -0
- package/src/components/channels/api.ts +703 -0
- package/src/components/channels/index.ts +76 -0
- package/src/components/channels/mockApi.ts +344 -0
- package/src/components/channels/types.ts +566 -0
- package/src/components/hooks/index.ts +58 -0
- package/src/components/hooks/useAgentLogs.ts +504 -0
- package/src/components/hooks/useAgents.ts +127 -0
- package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
- package/src/components/hooks/useBroadcastDedup.ts +86 -0
- package/src/components/hooks/useChannelAdmin.ts +329 -0
- package/src/components/hooks/useChannelBrowser.ts +239 -0
- package/src/components/hooks/useChannelCommands.ts +138 -0
- package/src/components/hooks/useChannels.ts +367 -0
- package/src/components/hooks/useDebounce.ts +29 -0
- package/src/components/hooks/useDirectMessage.test.ts +952 -0
- package/src/components/hooks/useDirectMessage.ts +141 -0
- package/src/components/hooks/useMessages.ts +310 -0
- package/src/components/hooks/useOrchestrator.test.ts +165 -0
- package/src/components/hooks/useOrchestrator.ts +424 -0
- package/src/components/hooks/usePinnedAgents.test.ts +356 -0
- package/src/components/hooks/usePinnedAgents.ts +140 -0
- package/src/components/hooks/usePresence.test.ts +245 -0
- package/src/components/hooks/usePresence.ts +377 -0
- package/src/components/hooks/useRecentRepos.ts +130 -0
- package/src/components/hooks/useSession.ts +209 -0
- package/src/components/hooks/useThread.ts +138 -0
- package/src/components/hooks/useTrajectory.ts +265 -0
- package/src/components/hooks/useWebSocket.ts +290 -0
- package/src/components/hooks/useWorkspaceMembers.ts +132 -0
- package/src/components/hooks/useWorkspaceRepos.ts +73 -0
- package/src/components/hooks/useWorkspaceStatus.ts +237 -0
- package/src/components/index.ts +81 -0
- package/src/components/layout/Header.tsx +311 -0
- package/src/components/layout/RepoContextHeader.tsx +361 -0
- package/src/components/layout/Sidebar.archive.test.tsx +126 -0
- package/src/components/layout/Sidebar.test.tsx +691 -0
- package/src/components/layout/Sidebar.tsx +900 -0
- package/src/components/layout/index.ts +7 -0
- package/src/components/settings/BillingSettingsPanel.tsx +564 -0
- package/src/components/settings/SettingsPage.tsx +683 -0
- package/src/components/settings/TeamSettingsPanel.tsx +560 -0
- package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
- package/src/components/settings/index.ts +11 -0
- package/src/components/settings/types.ts +79 -0
- package/src/components/utils/messageFormatting.test.tsx +331 -0
- package/src/components/utils/messageFormatting.tsx +597 -0
- package/src/index.ts +63 -0
- package/src/landing/AboutPage.tsx +77 -0
- package/src/landing/BlogContent.tsx +187 -0
- package/src/landing/BlogPage.tsx +47 -0
- package/src/landing/CareersPage.tsx +53 -0
- package/src/landing/ChangelogPage.tsx +33 -0
- package/src/landing/ContactPage.tsx +41 -0
- package/src/landing/DocsPage.tsx +43 -0
- package/src/landing/LandingPage.tsx +702 -0
- package/src/landing/PricingPage.tsx +549 -0
- package/src/landing/PrivacyPage.tsx +117 -0
- package/src/landing/SecurityPage.tsx +42 -0
- package/src/landing/StaticPage.tsx +165 -0
- package/src/landing/TermsPage.tsx +125 -0
- package/src/landing/blogData.ts +312 -0
- package/src/landing/index.ts +18 -0
- package/src/landing/styles.css +3673 -0
- package/src/lib/agent-merge.test.ts +43 -0
- package/src/lib/agent-merge.ts +35 -0
- package/src/lib/api.ts +1294 -0
- package/src/lib/cloudApi.ts +893 -0
- package/src/lib/colors.test.ts +175 -0
- package/src/lib/colors.ts +218 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/hierarchy.ts +242 -0
- package/src/lib/stuckDetection.ts +142 -0
- package/src/lib/useUrlRouting.ts +190 -0
- package/src/types/index.ts +317 -0
- package/src/types/threading.ts +7 -0
- package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
- package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
- /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
- /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
|
+
}
|