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.
- package/README.md +0 -29
- package/docs/session-status-detection.md +258 -0
- package/next.config.ts +11 -0
- package/package.json +14 -1
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/readme-cover.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/opencode-config/route.ts +304 -0
- package/src/app/api/opencode-config/status/route.ts +31 -0
- package/src/app/api/opencode-events/route.ts +86 -0
- package/src/app/api/opencode-models/route.test.ts +135 -0
- package/src/app/api/opencode-models/route.ts +58 -0
- package/src/app/api/profiles/[id]/apply/route.ts +49 -0
- package/src/app/api/profiles/[id]/route.ts +160 -0
- package/src/app/api/profiles/route.ts +107 -0
- package/src/app/api/sessions/[id]/archive/route.ts +35 -0
- package/src/app/api/sessions/[id]/delete/route.ts +26 -0
- package/src/app/api/sessions/[id]/route.ts +45 -0
- package/src/app/api/sessions/route.ts +596 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +66 -0
- package/src/app/layout.tsx +37 -0
- package/src/app/page.tsx +239 -0
- package/src/components/ErrorBoundary.tsx +72 -0
- package/src/components/KanbanBoard.tsx +442 -0
- package/src/components/LoadingState.tsx +37 -0
- package/src/components/ProjectCard.tsx +382 -0
- package/src/components/QueryProvider.tsx +25 -0
- package/src/components/SessionCard.tsx +291 -0
- package/src/components/SessionList.tsx +60 -0
- package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
- package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
- package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
- package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
- package/src/components/opencode-config/ConfigButton.tsx +43 -0
- package/src/components/opencode-config/ConfigPanel.tsx +91 -0
- package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
- package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
- package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
- package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
- package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
- package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
- package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
- package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
- package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
- package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
- package/src/components/ui/Tabs.tsx +59 -0
- package/src/hooks/useOpencodeSync.ts +378 -0
- package/src/index.ts +2 -0
- package/src/lib/notificationSound.ts +266 -0
- package/src/lib/opencodeConfig.test.ts +81 -0
- package/src/lib/opencodeConfig.ts +48 -0
- package/src/lib/opencodeDiscovery.ts +154 -0
- package/src/lib/profiles/storage.ts +264 -0
- package/src/lib/transform.ts +84 -0
- package/src/test/setup.ts +8 -0
- package/src/types/index.ts +89 -0
- package/src/types/opencodeConfig.ts +133 -0
- package/src/types/testing-library-vitest.d.ts +17 -0
- package/tsconfig.json +34 -0
- 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
|
+
});
|