vibepulse 0.1.1 → 0.1.2

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 (65) hide show
  1. package/README.md +0 -29
  2. package/docs/session-status-detection.md +258 -0
  3. package/next.config.ts +11 -0
  4. package/package.json +14 -1
  5. package/postcss.config.mjs +7 -0
  6. package/public/file.svg +1 -0
  7. package/public/globe.svg +1 -0
  8. package/public/next.svg +1 -0
  9. package/public/readme-cover.png +0 -0
  10. package/public/vercel.svg +1 -0
  11. package/public/window.svg +1 -0
  12. package/src/app/api/opencode-config/route.ts +304 -0
  13. package/src/app/api/opencode-config/status/route.ts +31 -0
  14. package/src/app/api/opencode-events/route.ts +86 -0
  15. package/src/app/api/opencode-models/route.test.ts +135 -0
  16. package/src/app/api/opencode-models/route.ts +58 -0
  17. package/src/app/api/profiles/[id]/apply/route.ts +49 -0
  18. package/src/app/api/profiles/[id]/route.ts +160 -0
  19. package/src/app/api/profiles/route.ts +107 -0
  20. package/src/app/api/sessions/[id]/archive/route.ts +35 -0
  21. package/src/app/api/sessions/[id]/delete/route.ts +26 -0
  22. package/src/app/api/sessions/[id]/route.ts +45 -0
  23. package/src/app/api/sessions/route.ts +596 -0
  24. package/src/app/favicon.ico +0 -0
  25. package/src/app/globals.css +66 -0
  26. package/src/app/layout.tsx +37 -0
  27. package/src/app/page.tsx +239 -0
  28. package/src/components/ErrorBoundary.tsx +72 -0
  29. package/src/components/KanbanBoard.tsx +442 -0
  30. package/src/components/LoadingState.tsx +37 -0
  31. package/src/components/ProjectCard.tsx +382 -0
  32. package/src/components/QueryProvider.tsx +25 -0
  33. package/src/components/SessionCard.tsx +291 -0
  34. package/src/components/SessionList.tsx +60 -0
  35. package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
  36. package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
  37. package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
  38. package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
  39. package/src/components/opencode-config/ConfigButton.tsx +43 -0
  40. package/src/components/opencode-config/ConfigPanel.tsx +91 -0
  41. package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
  42. package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
  43. package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
  44. package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
  45. package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
  46. package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
  47. package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
  48. package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
  49. package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
  50. package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
  51. package/src/components/ui/Tabs.tsx +59 -0
  52. package/src/hooks/useOpencodeSync.ts +378 -0
  53. package/src/index.ts +2 -0
  54. package/src/lib/notificationSound.ts +266 -0
  55. package/src/lib/opencodeConfig.test.ts +81 -0
  56. package/src/lib/opencodeConfig.ts +48 -0
  57. package/src/lib/opencodeDiscovery.ts +154 -0
  58. package/src/lib/profiles/storage.ts +264 -0
  59. package/src/lib/transform.ts +84 -0
  60. package/src/test/setup.ts +8 -0
  61. package/src/types/index.ts +89 -0
  62. package/src/types/opencodeConfig.ts +133 -0
  63. package/src/types/testing-library-vitest.d.ts +17 -0
  64. package/tsconfig.json +34 -0
  65. package/tsconfig.lib.json +17 -0
@@ -0,0 +1,398 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Search, Plus, Users, AlertTriangle, X, Check } from 'lucide-react';
5
+ import { Profile } from '../../../types/opencodeConfig';
6
+
7
+ interface ProfileListProps {
8
+ /** Array of profiles to display */
9
+ profiles: Profile[];
10
+ /** ID of the currently active profile */
11
+ activeProfileId: string | null;
12
+ /** ID of the profile currently being applied */
13
+ appliedProfileId: string | null;
14
+ /** Callback when Apply button is clicked */
15
+ onApply: (profileId: string) => void;
16
+ /** Callback when Edit button is clicked */
17
+ onEdit: (profile: Profile) => void;
18
+ /** Callback when Delete button is clicked */
19
+ onDelete: (profileId: string) => void;
20
+ /** Callback when Create New button is clicked */
21
+ onCreateNew: () => void;
22
+ }
23
+
24
+ /**
25
+ * ProfileList component - displays all profiles with search/filter
26
+ *
27
+ * Design: Refined industrial data-table aesthetic
28
+ * - Dark charcoal palette with teal accents
29
+ * - Monospace numeric indicators for technical precision
30
+ * - Subtle borders that evoke control panel interfaces
31
+ */
32
+ export function ProfileList({
33
+ profiles,
34
+ activeProfileId,
35
+ appliedProfileId,
36
+ onApply,
37
+ onEdit,
38
+ onDelete,
39
+ onCreateNew,
40
+ }: ProfileListProps) {
41
+ const [searchQuery, setSearchQuery] = React.useState('');
42
+ const [confirmingProfile, setConfirmingProfile] = React.useState<Profile | null>(null);
43
+
44
+ const handleApplyWithConfirm = (profile: Profile, isApplied: boolean) => {
45
+ if (isApplied) {
46
+ setConfirmingProfile(profile);
47
+ } else {
48
+ onApply(profile.id);
49
+ }
50
+ };
51
+
52
+ const handleConfirmReset = () => {
53
+ if (confirmingProfile) {
54
+ onApply(confirmingProfile.id);
55
+ setConfirmingProfile(null);
56
+ }
57
+ };
58
+
59
+ const handleCancelConfirm = () => {
60
+ setConfirmingProfile(null);
61
+ };
62
+
63
+ // Filter profiles based on search query (name or description)
64
+ const filteredProfiles = React.useMemo(() => {
65
+ if (!searchQuery.trim()) return profiles;
66
+
67
+ const query = searchQuery.toLowerCase();
68
+ return profiles.filter(
69
+ (profile) =>
70
+ profile.name.toLowerCase().includes(query) ||
71
+ (profile.description?.toLowerCase() || '').includes(query)
72
+ );
73
+ }, [profiles, searchQuery]);
74
+
75
+ // Separate active, built-in, and custom profiles for grouping
76
+ const { active, builtIn, custom } = React.useMemo(() => {
77
+ const active: Profile[] = [];
78
+ const builtIn: Profile[] = [];
79
+ const custom: Profile[] = [];
80
+
81
+ filteredProfiles.forEach((profile) => {
82
+ if (profile.id === activeProfileId) {
83
+ active.push(profile);
84
+ } else if (profile.isBuiltIn) {
85
+ builtIn.push(profile);
86
+ } else {
87
+ custom.push(profile);
88
+ }
89
+ });
90
+
91
+ return { active, builtIn, custom };
92
+ }, [filteredProfiles, activeProfileId]);
93
+
94
+ const totalCount = profiles.length;
95
+ const filteredCount = filteredProfiles.length;
96
+ const isFiltering = searchQuery.trim().length > 0;
97
+
98
+ return (
99
+ <div className="space-y-4">
100
+ {/* Header with search and count */}
101
+ <div className="flex items-center gap-3">
102
+ <div className="relative flex-1">
103
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
104
+ <input
105
+ type="text"
106
+ placeholder="Search profiles..."
107
+ value={searchQuery}
108
+ onChange={(e) => setSearchQuery(e.target.value)}
109
+ className="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg
110
+ placeholder:text-zinc-400
111
+ focus:outline-none focus:ring-2 focus:ring-teal-500/20 focus:border-teal-500
112
+ transition-all duration-200"
113
+ />
114
+ </div>
115
+ <div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400 tabular-nums">
116
+ <Users className="h-3.5 w-3.5" />
117
+ <span>
118
+ {isFiltering ? `${filteredCount} of ${totalCount}` : `${totalCount}`} profiles
119
+ </span>
120
+ </div>
121
+ </div>
122
+
123
+ {/* Empty state */}
124
+ {filteredProfiles.length === 0 && (
125
+ <div className="text-center py-12 px-4">
126
+ <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-zinc-100 dark:bg-zinc-800 mb-3">
127
+ <Users className="h-5 w-5 text-zinc-400 dark:text-zinc-500" />
128
+ </div>
129
+ <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
130
+ {isFiltering ? 'No profiles match your search' : 'No profiles yet'}
131
+ </p>
132
+ <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
133
+ {isFiltering
134
+ ? 'Try a different search term'
135
+ : 'Create your first profile to get started'}
136
+ </p>
137
+ </div>
138
+ )}
139
+
140
+ {/* Profile groups */}
141
+ {filteredProfiles.length > 0 && (
142
+ <div className="space-y-4">
143
+ {/* Active Profile */}
144
+ {active.length > 0 && (
145
+ <section>
146
+ <div className="flex items-center gap-2 mb-2">
147
+ <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300 uppercase tracking-wider">
148
+ <span className="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse" />
149
+ Active
150
+ </span>
151
+ <span className="text-[10px] text-zinc-400 dark:text-zinc-500 tabular-nums">
152
+ {active.length}
153
+ </span>
154
+ </div>
155
+ <div className="space-y-2">
156
+ {active.map((profile) => (
157
+ <ProfileCard
158
+ key={profile.id}
159
+ profile={profile}
160
+ isActive={true}
161
+ isApplied={profile.id === appliedProfileId}
162
+ onApply={() => handleApplyWithConfirm(profile, profile.id === appliedProfileId)}
163
+ onEdit={() => onEdit(profile)}
164
+ onDelete={() => onDelete(profile.id)}
165
+ />
166
+ ))}
167
+ </div>
168
+ </section>
169
+ )}
170
+
171
+ {/* Built-in Profiles */}
172
+ {builtIn.length > 0 && (
173
+ <section>
174
+ <div className="flex items-center gap-2 mb-2">
175
+ <span className="text-[10px] font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
176
+ Built-in
177
+ </span>
178
+ <span className="text-[10px] text-zinc-400 dark:text-zinc-500 tabular-nums">
179
+ {builtIn.length}
180
+ </span>
181
+ </div>
182
+ <div className="space-y-2">
183
+ {builtIn.map((profile) => (
184
+ <ProfileCard
185
+ key={profile.id}
186
+ profile={profile}
187
+ isActive={false}
188
+ isApplied={profile.id === appliedProfileId}
189
+ onApply={() => handleApplyWithConfirm(profile, profile.id === appliedProfileId)}
190
+ onEdit={() => onEdit(profile)}
191
+ onDelete={() => onDelete(profile.id)}
192
+ />
193
+ ))}
194
+ </div>
195
+ </section>
196
+ )}
197
+
198
+ {/* Custom Profiles */}
199
+ {custom.length > 0 && (
200
+ <section>
201
+ <div className="flex items-center gap-2 mb-2">
202
+ <span className="text-[10px] font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
203
+ Custom
204
+ </span>
205
+ <span className="text-[10px] text-zinc-400 dark:text-zinc-500 tabular-nums">
206
+ {custom.length}
207
+ </span>
208
+ </div>
209
+ <div className="space-y-2">
210
+ {custom.map((profile) => (
211
+ <ProfileCard
212
+ key={profile.id}
213
+ profile={profile}
214
+ isActive={false}
215
+ isApplied={profile.id === appliedProfileId}
216
+ onApply={() => handleApplyWithConfirm(profile, profile.id === appliedProfileId)}
217
+ onEdit={() => onEdit(profile)}
218
+ onDelete={() => onDelete(profile.id)}
219
+ />
220
+ ))}
221
+ </div>
222
+ </section>
223
+ )}
224
+ </div>
225
+ )}
226
+
227
+ {/* Reset Confirmation Dialog */}
228
+ {confirmingProfile && (
229
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
230
+ <div className="w-full max-w-md mx-4 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-700 shadow-2xl">
231
+ <div className="p-6">
232
+ <div className="flex items-start gap-4">
233
+ <div className="flex-shrink-0 w-12 h-12 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
234
+ <AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
235
+ </div>
236
+ <div className="flex-1">
237
+ <h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
238
+ Reset Profile Configuration
239
+ </h3>
240
+ <p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
241
+ This will reset all agent and category configurations back to the <strong>{confirmingProfile.name}</strong> profile values. Any changes made after applying this profile will be lost.
242
+ </p>
243
+ </div>
244
+ </div>
245
+
246
+ <div className="mt-6 flex items-center justify-end gap-3">
247
+ <button
248
+ type="button"
249
+ onClick={handleCancelConfirm}
250
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
251
+ >
252
+ <X className="h-4 w-4" />
253
+ Cancel
254
+ </button>
255
+ <button
256
+ type="button"
257
+ onClick={handleConfirmReset}
258
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-600 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
259
+ >
260
+ <Check className="h-4 w-4" />
261
+ Reset to {confirmingProfile.name}
262
+ </button>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ )}
268
+
269
+ {/* Create New button */}
270
+ <button
271
+ type="button"
272
+ onClick={onCreateNew}
273
+ className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-700
274
+ text-sm font-medium text-zinc-600 dark:text-zinc-400
275
+ hover:border-teal-400 hover:text-teal-600 dark:hover:border-teal-500 dark:hover:text-teal-400
276
+ hover:bg-teal-50/50 dark:hover:bg-teal-900/10
277
+ transition-all duration-200"
278
+ >
279
+ <Plus className="h-4 w-4" />
280
+ Create New Profile
281
+ </button>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ // ProfileCard interface - assume external component exists
287
+ interface ProfileCardProps {
288
+ profile: Profile;
289
+ isActive: boolean;
290
+ isApplied: boolean;
291
+ onApply: () => void;
292
+ onEdit: () => void;
293
+ onDelete: () => void;
294
+ }
295
+
296
+ // Inline ProfileCard implementation for completeness
297
+ function ProfileCard({
298
+ profile,
299
+ isActive,
300
+ isApplied,
301
+ onApply,
302
+ onEdit,
303
+ onDelete,
304
+ }: ProfileCardProps) {
305
+ return (
306
+ <div
307
+ className={`
308
+ group relative flex items-center justify-between p-3 rounded-lg border
309
+ transition-all duration-200
310
+ ${
311
+ isActive
312
+ ? 'bg-teal-50/50 border-teal-200 dark:bg-teal-900/20 dark:border-teal-700/50'
313
+ : 'bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
314
+ }
315
+ `}
316
+ >
317
+ <div className="flex items-center gap-3 flex-1 min-w-0">
318
+ <span className="text-xl" role="img" aria-label={`${profile.name} icon`}>
319
+ {profile.emoji}
320
+ </span>
321
+ <div className="flex-1 min-w-0">
322
+ <div className="flex items-center gap-2">
323
+ <h4 className="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
324
+ {profile.name}
325
+ </h4>
326
+ {profile.isDefault && (
327
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
328
+ Default
329
+ </span>
330
+ )}
331
+ {profile.isBuiltIn && (
332
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
333
+ Built-in
334
+ </span>
335
+ )}
336
+ </div>
337
+ {profile.description && (
338
+ <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400 truncate">
339
+ {profile.description}
340
+ </p>
341
+ )}
342
+ </div>
343
+ </div>
344
+
345
+ <div className="flex items-center gap-1 ml-3">
346
+ <button
347
+ type="button"
348
+ onClick={onApply}
349
+ disabled={isActive && !isApplied}
350
+ title={isApplied ? 'Reset config to this profile' : isActive ? 'Currently active' : 'Apply this profile'}
351
+ className={`
352
+ px-2.5 py-1 rounded-md text-xs font-medium
353
+ transition-all duration-200
354
+ ${
355
+ isApplied
356
+ ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-900/60'
357
+ : isActive
358
+ ? 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300 cursor-default'
359
+ : 'bg-zinc-100 text-zinc-700 hover:bg-teal-100 hover:text-teal-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-teal-900/30 dark:hover:text-teal-300'
360
+ }
361
+ `}
362
+ >
363
+ {isApplied ? 'Reset' : isActive ? 'Active' : 'Apply'}
364
+ </button>
365
+ <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
366
+ <button
367
+ type="button"
368
+ onClick={onEdit}
369
+ className="p-1.5 rounded-md text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 dark:text-zinc-500 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors"
370
+ aria-label={`Edit ${profile.name}`}
371
+ title={`Edit ${profile.name}`}
372
+ >
373
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} role="img">
374
+ <title>Edit</title>
375
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
376
+ </svg>
377
+ </button>
378
+ {!profile.isBuiltIn && !profile.isDefault && (
379
+ <button
380
+ type="button"
381
+ onClick={onDelete}
382
+ className="p-1.5 rounded-md text-zinc-400 hover:text-red-600 hover:bg-red-50 dark:text-zinc-500 dark:hover:text-red-400 dark:hover:bg-red-900/20 transition-colors"
383
+ aria-label={`Delete ${profile.name}`}
384
+ title={`Delete ${profile.name}`}
385
+ >
386
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} role="img">
387
+ <title>Delete</title>
388
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
389
+ </svg>
390
+ </button>
391
+ )}
392
+ </div>
393
+ </div>
394
+ </div>
395
+ );
396
+ }
397
+
398
+ export default ProfileList;
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { ProfileManager } from './ProfileManager';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+
7
+ const mockFetch = vi.fn();
8
+ global.fetch = mockFetch;
9
+
10
+ describe('ProfileManager', () => {
11
+ let queryClient: QueryClient;
12
+
13
+ beforeEach(() => {
14
+ queryClient = new QueryClient({
15
+ defaultOptions: {
16
+ queries: { staleTime: 0 },
17
+ },
18
+ });
19
+ vi.clearAllMocks();
20
+ });
21
+
22
+ it('should load and display profile list correctly', async () => {
23
+ mockFetch.mockResolvedValueOnce({
24
+ json: async () => ({
25
+ profiles: [
26
+ { id: 'coding', name: 'Coding Mode', emoji: '🚀', isBuiltIn: true },
27
+ { id: 'custom1', name: 'Custom Profile', emoji: '⚙️' },
28
+ ],
29
+ activeProfileId: null,
30
+ }),
31
+ ok: true,
32
+ });
33
+
34
+ render(
35
+ <QueryClientProvider client={queryClient}>
36
+ <ProfileManager />
37
+ </QueryClientProvider>
38
+ );
39
+
40
+ await waitFor(() => {
41
+ expect(screen.getByText('Coding Mode')).toBeInTheDocument();
42
+ });
43
+ });
44
+
45
+ it('should call API correctly when applying profile', async () => {
46
+ const user = userEvent.setup();
47
+
48
+ mockFetch
49
+ .mockResolvedValueOnce({
50
+ json: async () => ({
51
+ profiles: [{ id: 'coding', name: 'Coding', emoji: '🚀', isBuiltIn: true }],
52
+ activeProfileId: null,
53
+ }),
54
+ ok: true,
55
+ })
56
+ .mockResolvedValueOnce({
57
+ json: async () => ({ message: 'Profile applied successfully' }),
58
+ ok: true,
59
+ });
60
+
61
+ render(
62
+ <QueryClientProvider client={queryClient}>
63
+ <ProfileManager />
64
+ </QueryClientProvider>
65
+ );
66
+
67
+ await waitFor(() => {
68
+ expect(screen.getByText('Coding')).toBeInTheDocument();
69
+ });
70
+
71
+ const applyButtons = screen.getAllByRole('button', { name: /apply/i });
72
+ await user.click(applyButtons[0]);
73
+
74
+ await waitFor(() => {
75
+ expect(mockFetch).toHaveBeenCalledWith(
76
+ '/api/profiles/coding/apply',
77
+ expect.objectContaining({ method: 'POST' })
78
+ );
79
+ });
80
+ });
81
+
82
+ it('should invalidate profiles and opencode-config query cache when applying profile', async () => {
83
+ const user = userEvent.setup();
84
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
85
+
86
+ await queryClient.prefetchQuery({
87
+ queryKey: ['opencode-config'],
88
+ queryFn: async () => ({ config: 'test' }),
89
+ });
90
+
91
+ mockFetch
92
+ .mockResolvedValueOnce({
93
+ json: async () => ({
94
+ profiles: [{ id: 'coding', name: 'Coding', emoji: '🚀', isBuiltIn: true }],
95
+ activeProfileId: null,
96
+ }),
97
+ ok: true,
98
+ })
99
+ .mockResolvedValueOnce({
100
+ json: async () => ({ message: 'Profile applied successfully' }),
101
+ ok: true,
102
+ });
103
+
104
+ render(
105
+ <QueryClientProvider client={queryClient}>
106
+ <ProfileManager />
107
+ </QueryClientProvider>
108
+ );
109
+
110
+ await waitFor(() => {
111
+ expect(screen.getByText('Coding')).toBeInTheDocument();
112
+ });
113
+
114
+ const applyButtons = screen.getAllByRole('button', { name: /apply/i });
115
+ await user.click(applyButtons[0]);
116
+
117
+ await waitFor(() => {
118
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['profiles'] });
119
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['opencode-config'] });
120
+ });
121
+ });
122
+ });