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,140 @@
1
+ 'use client';
2
+
3
+ import { Pencil, Trash2, Check, RefreshCw } from 'lucide-react';
4
+
5
+ interface Profile {
6
+ id: string;
7
+ name: string;
8
+ emoji: string;
9
+ description?: string;
10
+ isBuiltIn?: boolean;
11
+ }
12
+
13
+ interface ProfileCardProps {
14
+ profile: Profile;
15
+ isActive: boolean;
16
+ isApplied: boolean;
17
+ onApply: () => void;
18
+ onEdit: () => void;
19
+ onDelete: () => void;
20
+ }
21
+
22
+ export function ProfileCard({
23
+ profile,
24
+ isActive,
25
+ isApplied,
26
+ onApply,
27
+ onEdit,
28
+ onDelete,
29
+ }: ProfileCardProps) {
30
+ return (
31
+ <div
32
+ className={`
33
+ group relative flex flex-col rounded-lg border
34
+ transition-all duration-200
35
+ bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-700
36
+ hover:border-zinc-300 dark:hover:border-zinc-600
37
+ `}
38
+ >
39
+ <div className="flex items-center justify-between p-4">
40
+ <div className="flex items-center gap-3 flex-1 min-w-0">
41
+ <span className="text-2xl" role="img" aria-label={profile.name}>
42
+ {profile.emoji}
43
+ </span>
44
+
45
+ <div className="flex-1 min-w-0">
46
+ <div className="flex items-center gap-2 flex-wrap">
47
+ <h4 className="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
48
+ {profile.name}
49
+ </h4>
50
+
51
+ {profile.isBuiltIn && (
52
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
53
+ Built-in
54
+ </span>
55
+ )}
56
+
57
+ {isActive && (
58
+ <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">
59
+ Active
60
+ </span>
61
+ )}
62
+
63
+ {isApplied && (
64
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
65
+ <Check className="h-3 w-3" />
66
+ Last Applied
67
+ </span>
68
+ )}
69
+ </div>
70
+
71
+ {profile.description && (
72
+ <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400 truncate">
73
+ {profile.description}
74
+ </p>
75
+ )}
76
+ </div>
77
+ </div>
78
+
79
+ <div className="flex items-center gap-2 ml-4">
80
+ <button
81
+ type="button"
82
+ onClick={onApply}
83
+ className={`
84
+ inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium
85
+ transition-colors
86
+ ${
87
+ isApplied
88
+ ? 'bg-emerald-600 text-white hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700'
89
+ : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700'
90
+ }
91
+ `}
92
+ aria-label={isApplied ? `Re-apply ${profile.name} to reset configs` : `Apply ${profile.name}`}
93
+ title={isApplied ? 'Re-apply to reset all configs to this profile' : `Apply ${profile.name}`}
94
+ >
95
+ {isApplied ? (
96
+ <>
97
+ <RefreshCw className="h-3.5 w-3.5" />
98
+ Re-apply
99
+ </>
100
+ ) : (
101
+ 'Apply'
102
+ )}
103
+ </button>
104
+
105
+ <button
106
+ type="button"
107
+ onClick={onEdit}
108
+ className="p-1.5 rounded-md text-zinc-500 hover:text-zinc-900 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800 transition-colors"
109
+ aria-label={`Edit ${profile.name}`}
110
+ title={`Edit ${profile.name}`}
111
+ >
112
+ <Pencil className="h-4 w-4" />
113
+ </button>
114
+
115
+ {!profile.isBuiltIn && (
116
+ <button
117
+ type="button"
118
+ onClick={onDelete}
119
+ className="p-1.5 rounded-md text-zinc-500 hover:text-red-600 hover:bg-red-50 dark:text-zinc-400 dark:hover:text-red-400 dark:hover:bg-red-900/20 transition-colors"
120
+ aria-label={`Delete ${profile.name}`}
121
+ title={`Delete ${profile.name}`}
122
+ >
123
+ <Trash2 className="h-4 w-4" />
124
+ </button>
125
+ )}
126
+ </div>
127
+ </div>
128
+
129
+ {isApplied && (
130
+ <div className="border-t border-zinc-100 dark:border-zinc-800 px-4 py-2">
131
+ <p className="text-[11px] text-zinc-400 dark:text-zinc-500">
132
+ 💡 Modified configs after applying? Click <strong>Re-apply</strong> to reset back to this profile.
133
+ </p>
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ export default ProfileCard;
@@ -0,0 +1,446 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { useForm, Controller } from 'react-hook-form';
5
+ import { Check, AlertCircle, Loader2, Upload, ChevronDown, RotateCcw } from 'lucide-react';
6
+ import { Profile, ProfileConfig } from '../../../types/opencodeConfig';
7
+
8
+ interface ProfileFormData {
9
+ id: string;
10
+ name: string;
11
+ emoji: string;
12
+ description: string;
13
+ }
14
+
15
+ interface ProfileEditorProps {
16
+ profile?: Profile;
17
+ initialConfig?: ProfileConfig;
18
+ onSave: (data: { profile: Partial<Profile>; config: ProfileConfig }) => void;
19
+ onCancel: () => void;
20
+ }
21
+
22
+ const COMMON_EMOJIS = [
23
+ '⚡', '🔥', '💎', '🚀', '🎯', '💡', '🔧', '🎨', '📊', '🤖',
24
+ '👾', '💻', '⚙️', '🔍', '✨', '🌟', '🎭', '🎪', '🧩', '🎲',
25
+ '📚', '🔐', '🛠️', '⚡️', '🌊', '🔮', '📡', '🎸', '🏆', '🌈',
26
+ ];
27
+
28
+ export function ProfileEditor({
29
+ profile,
30
+ initialConfig,
31
+ onSave,
32
+ onCancel,
33
+ }: ProfileEditorProps) {
34
+ const isEditing = !!profile;
35
+ const [toast, setToast] = React.useState<{
36
+ type: 'success' | 'error';
37
+ message: string;
38
+ } | null>(null);
39
+ const [config, setConfig] = React.useState<ProfileConfig>(
40
+ initialConfig || { agents: {} }
41
+ );
42
+ // Store original config for reset functionality
43
+ const [originalConfig, setOriginalConfig] = React.useState<ProfileConfig>(
44
+ initialConfig || { agents: {} }
45
+ );
46
+
47
+ React.useEffect(() => {
48
+ if (initialConfig) {
49
+ setConfig(initialConfig);
50
+ setOriginalConfig(initialConfig);
51
+ }
52
+ }, [initialConfig]);
53
+ const [isConfigExpanded, setIsConfigExpanded] = React.useState(false);
54
+
55
+ const {
56
+ control,
57
+ handleSubmit,
58
+ watch,
59
+ formState: { errors, isSubmitting },
60
+ } = useForm<ProfileFormData>({
61
+ defaultValues: {
62
+ id: profile?.id || '',
63
+ name: profile?.name || '',
64
+ emoji: profile?.emoji || '⚡',
65
+ description: profile?.description || '',
66
+ },
67
+ });
68
+
69
+ const watchedName = watch('name');
70
+ const watchedEmoji = watch('emoji');
71
+
72
+ React.useEffect(() => {
73
+ if (toast) {
74
+ const timer = setTimeout(() => setToast(null), 3000);
75
+ return () => clearTimeout(timer);
76
+ }
77
+ }, [toast]);
78
+
79
+ const handleImportFromCurrent = async () => {
80
+ try {
81
+ const res = await fetch('/api/opencode-config');
82
+ if (!res.ok) {
83
+ throw new Error('Failed to fetch configuration');
84
+ }
85
+ const parsed = await res.json();
86
+ const importedConfig: ProfileConfig = {
87
+ agents: parsed.agents || {},
88
+ categories: parsed.categories,
89
+ };
90
+ setConfig(importedConfig);
91
+ setToast({ type: 'success', message: 'Configuration imported successfully' });
92
+ } catch {
93
+ setToast({ type: 'error', message: 'Failed to import configuration' });
94
+ }
95
+ };
96
+
97
+ const handleResetToOriginal = () => {
98
+ setConfig(originalConfig);
99
+ setToast({ type: 'success', message: 'Configuration reset to original values' });
100
+ };
101
+
102
+ const hasConfigChanged = React.useMemo(() => {
103
+ return JSON.stringify(config) !== JSON.stringify(originalConfig);
104
+ }, [config, originalConfig]);
105
+
106
+ const onSubmit = (data: ProfileFormData) => {
107
+ const now = new Date().toISOString();
108
+
109
+ const newProfile: Profile = {
110
+ id: isEditing ? profile.id : data.id,
111
+ name: data.name,
112
+ emoji: data.emoji,
113
+ description: data.description || undefined,
114
+ createdAt: isEditing ? profile.createdAt : now,
115
+ updatedAt: now,
116
+ isDefault: isEditing ? profile.isDefault : false,
117
+ isBuiltIn: isEditing ? profile.isBuiltIn : false,
118
+ };
119
+
120
+ onSave({ profile: newProfile, config });
121
+ setToast({ type: 'success', message: `Profile ${isEditing ? 'updated' : 'created'} successfully` });
122
+ };
123
+
124
+ const agentCount = Object.keys(config.agents || {}).length;
125
+ const categoryCount = Object.keys(config.categories || {}).length;
126
+
127
+ return (
128
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-6" aria-label="Profile editor form">
129
+ {toast && (
130
+ <div
131
+ role="alert"
132
+ className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
133
+ toast.type === 'success'
134
+ ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300'
135
+ : 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300'
136
+ }`}
137
+ >
138
+ {toast.type === 'success' ? (
139
+ <Check className="h-4 w-4 shrink-0" aria-hidden="true" />
140
+ ) : (
141
+ <AlertCircle className="h-4 w-4 shrink-0" aria-hidden="true" />
142
+ )}
143
+ <span>{toast.message}</span>
144
+ </div>
145
+ )}
146
+
147
+ {!isEditing && (
148
+ <div className="space-y-2">
149
+ <label htmlFor="profile-id" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
150
+ Profile ID
151
+ <span className="ml-1 text-red-500">*</span>
152
+ </label>
153
+ <Controller
154
+ name="id"
155
+ control={control}
156
+ rules={{
157
+ required: 'Profile ID is required',
158
+ pattern: {
159
+ value: /^[a-zA-Z0-9_-]+$/,
160
+ message: 'Only alphanumeric characters, hyphens, and underscores allowed',
161
+ },
162
+ }}
163
+ render={({ field }) => (
164
+ <input
165
+ id="profile-id"
166
+ type="text"
167
+ value={field.value}
168
+ onChange={(e) => field.onChange(e.target.value)}
169
+ placeholder="e.g., my-custom-profile"
170
+ className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
171
+ />
172
+ )}
173
+ />
174
+ {errors.id && (
175
+ <p className="text-xs text-red-600 dark:text-red-400" role="alert">
176
+ {errors.id.message}
177
+ </p>
178
+ )}
179
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
180
+ Unique identifier for this profile. Use only letters, numbers, hyphens, and underscores.
181
+ </p>
182
+ </div>
183
+ )}
184
+
185
+ {isEditing && (
186
+ <div className="space-y-2">
187
+ <label htmlFor="profile-id-readonly" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
188
+ Profile ID
189
+ </label>
190
+ <input
191
+ id="profile-id-readonly"
192
+ type="text"
193
+ value={profile.id}
194
+ disabled
195
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-100 px-3 py-2 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400"
196
+ />
197
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
198
+ Profile ID cannot be changed after creation.
199
+ </p>
200
+ </div>
201
+ )}
202
+
203
+ <div className="space-y-2">
204
+ <label htmlFor="profile-name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
205
+ Name
206
+ <span className="ml-1 text-red-500">*</span>
207
+ </label>
208
+ <Controller
209
+ name="name"
210
+ control={control}
211
+ rules={{
212
+ required: 'Name is required',
213
+ minLength: {
214
+ value: 1,
215
+ message: 'Name cannot be empty',
216
+ },
217
+ }}
218
+ render={({ field }) => (
219
+ <input
220
+ id="profile-name"
221
+ type="text"
222
+ value={field.value}
223
+ onChange={(e) => field.onChange(e.target.value)}
224
+ placeholder="e.g., My Custom Profile"
225
+ className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
226
+ />
227
+ )}
228
+ />
229
+ {errors.name && (
230
+ <p className="text-xs text-red-600 dark:text-red-400" role="alert">
231
+ {errors.name.message}
232
+ </p>
233
+ )}
234
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
235
+ Display name for this profile.
236
+ </p>
237
+ </div>
238
+
239
+ <div className="space-y-2">
240
+ <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
241
+ Emoji Icon
242
+ </p>
243
+ <Controller
244
+ name="emoji"
245
+ control={control}
246
+ render={({ field }) => (
247
+ <div className="flex flex-wrap gap-2">
248
+ {COMMON_EMOJIS.map((emoji) => (
249
+ <button
250
+ key={emoji}
251
+ type="button"
252
+ onClick={() => field.onChange(emoji)}
253
+ className={`h-10 w-10 rounded-lg text-xl transition-all ${
254
+ field.value === emoji
255
+ ? 'bg-blue-100 ring-2 ring-blue-500 dark:bg-blue-900/30 dark:ring-blue-400'
256
+ : 'bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700'
257
+ }`}
258
+ aria-label={`Select ${emoji} emoji`}
259
+ aria-pressed={field.value === emoji}
260
+ >
261
+ {emoji}
262
+ </button>
263
+ ))}
264
+ </div>
265
+ )}
266
+ />
267
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
268
+ Choose an emoji to represent this profile.
269
+ </p>
270
+ </div>
271
+
272
+ <div className="space-y-2">
273
+ <label htmlFor="profile-description" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
274
+ Description
275
+ </label>
276
+ <Controller
277
+ name="description"
278
+ control={control}
279
+ render={({ field }) => (
280
+ <textarea
281
+ id="profile-description"
282
+ value={field.value}
283
+ onChange={(e) => field.onChange(e.target.value)}
284
+ rows={3}
285
+ className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm resize-none dark:border-zinc-800 dark:bg-zinc-950"
286
+ placeholder="Optional description of this profile..."
287
+ />
288
+ )}
289
+ />
290
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
291
+ Optional description to help identify this profile.
292
+ </p>
293
+ </div>
294
+
295
+ <div className="space-y-2">
296
+ <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
297
+ Configuration
298
+ </p>
299
+ <div className="flex flex-wrap gap-2">
300
+ <button
301
+ type="button"
302
+ onClick={handleImportFromCurrent}
303
+ className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
304
+ >
305
+ <Upload className="h-4 w-4" aria-hidden="true" />
306
+ Import from Current Config
307
+ </button>
308
+
309
+ {isEditing && hasConfigChanged && (
310
+ <button
311
+ type="button"
312
+ onClick={handleResetToOriginal}
313
+ className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 hover:text-amber-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-amber-400"
314
+ >
315
+ <RotateCcw className="h-4 w-4" aria-hidden="true" />
316
+ Reset to Original
317
+ </button>
318
+ )}
319
+ </div>
320
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
321
+ Import agent and category configurations from your current settings, or reset to the profile&apos;s original values.
322
+ </p>
323
+ </div>
324
+
325
+ <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
326
+ <h4 className="text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
327
+ Profile Preview
328
+ </h4>
329
+ <div className="flex items-center gap-3">
330
+ <span className="text-3xl" aria-hidden="true">
331
+ {watchedEmoji || '⚡'}
332
+ </span>
333
+ <div>
334
+ <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
335
+ {watchedName || 'Untitled Profile'}
336
+ </p>
337
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
338
+ {agentCount} agent{agentCount !== 1 ? 's' : ''}, {categoryCount} categor{categoryCount !== 1 ? 'ies' : 'y'} configured
339
+ </p>
340
+ </div>
341
+ </div>
342
+ </div>
343
+
344
+ <div className="rounded-lg border border-zinc-200 dark:border-zinc-700 overflow-hidden">
345
+ <button
346
+ type="button"
347
+ onClick={() => setIsConfigExpanded(!isConfigExpanded)}
348
+ className="w-full flex items-center justify-between p-4 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors"
349
+ aria-expanded={isConfigExpanded}
350
+ >
351
+ <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
352
+ Configuration Details
353
+ </span>
354
+ <ChevronDown
355
+ className={`h-4 w-4 text-zinc-500 transition-transform duration-200 ${isConfigExpanded ? 'rotate-180' : ''}`}
356
+ aria-hidden="true"
357
+ />
358
+ </button>
359
+ {isConfigExpanded && (
360
+ <div className="border-t border-zinc-200 dark:border-zinc-700 p-4 space-y-4">
361
+ <div>
362
+ <h5 className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide mb-2">
363
+ Agents ({agentCount})
364
+ </h5>
365
+ {agentCount === 0 ? (
366
+ <p className="text-sm text-zinc-400 dark:text-zinc-500 italic">
367
+ No agents configured
368
+ </p>
369
+ ) : (
370
+ <ul className="space-y-1.5">
371
+ {Object.entries(config.agents || {}).map(([name, agentConfig]) => (
372
+ <li
373
+ key={name}
374
+ className="text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-2"
375
+ >
376
+ <span className="font-medium text-zinc-900 dark:text-zinc-100 min-w-[80px]">
377
+ {name}
378
+ </span>
379
+ <span className="text-zinc-400">→</span>
380
+ <span className="text-zinc-600 dark:text-zinc-400">
381
+ {agentConfig.model}
382
+ {agentConfig.temperature !== undefined && ` temp: ${agentConfig.temperature}`}
383
+ </span>
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ )}
388
+ </div>
389
+
390
+ <div>
391
+ <h5 className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide mb-2">
392
+ Categories ({categoryCount})
393
+ </h5>
394
+ {categoryCount === 0 ? (
395
+ <p className="text-sm text-zinc-400 dark:text-zinc-500 italic">
396
+ No categories configured
397
+ </p>
398
+ ) : (
399
+ <ul className="space-y-1.5">
400
+ {Object.entries(config.categories || {}).map(([name, categoryConfig]) => (
401
+ <li
402
+ key={name}
403
+ className="text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-2"
404
+ >
405
+ <span className="font-medium text-zinc-900 dark:text-zinc-100 min-w-[80px]">
406
+ {name}
407
+ </span>
408
+ <span className="text-zinc-400">→</span>
409
+ <span className="text-zinc-600 dark:text-zinc-400">
410
+ {categoryConfig.model}
411
+ {categoryConfig.variant && ` (${categoryConfig.variant})`}
412
+ {categoryConfig.temperature !== undefined && ` temp: ${categoryConfig.temperature}`}
413
+ </span>
414
+ </li>
415
+ ))}
416
+ </ul>
417
+ )}
418
+ </div>
419
+ </div>
420
+ )}
421
+ </div>
422
+
423
+ <div className="flex items-center justify-end gap-3 pt-2">
424
+ <button
425
+ type="button"
426
+ onClick={onCancel}
427
+ className="inline-flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
428
+ >
429
+ Cancel
430
+ </button>
431
+ <button
432
+ type="submit"
433
+ disabled={isSubmitting || !watchedName.trim()}
434
+ className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-500"
435
+ >
436
+ {isSubmitting && (
437
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
438
+ )}
439
+ {isEditing ? 'Save Changes' : 'Create Profile'}
440
+ </button>
441
+ </div>
442
+ </form>
443
+ );
444
+ }
445
+
446
+ export default ProfileEditor;