vibepulse 0.1.0 → 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 (68) hide show
  1. package/README.md +7 -13
  2. package/bin/vibepulse.js +1 -0
  3. package/dist/index.js +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/docs/session-status-detection.md +258 -0
  6. package/next.config.ts +11 -0
  7. package/package.json +17 -11
  8. package/postcss.config.mjs +7 -0
  9. package/public/file.svg +1 -0
  10. package/public/globe.svg +1 -0
  11. package/public/next.svg +1 -0
  12. package/public/readme-cover.png +0 -0
  13. package/public/vercel.svg +1 -0
  14. package/public/window.svg +1 -0
  15. package/src/app/api/opencode-config/route.ts +304 -0
  16. package/src/app/api/opencode-config/status/route.ts +31 -0
  17. package/src/app/api/opencode-events/route.ts +86 -0
  18. package/src/app/api/opencode-models/route.test.ts +135 -0
  19. package/src/app/api/opencode-models/route.ts +58 -0
  20. package/src/app/api/profiles/[id]/apply/route.ts +49 -0
  21. package/src/app/api/profiles/[id]/route.ts +160 -0
  22. package/src/app/api/profiles/route.ts +107 -0
  23. package/src/app/api/sessions/[id]/archive/route.ts +35 -0
  24. package/src/app/api/sessions/[id]/delete/route.ts +26 -0
  25. package/src/app/api/sessions/[id]/route.ts +45 -0
  26. package/src/app/api/sessions/route.ts +596 -0
  27. package/src/app/favicon.ico +0 -0
  28. package/src/app/globals.css +66 -0
  29. package/src/app/layout.tsx +37 -0
  30. package/src/app/page.tsx +239 -0
  31. package/src/components/ErrorBoundary.tsx +72 -0
  32. package/src/components/KanbanBoard.tsx +442 -0
  33. package/src/components/LoadingState.tsx +37 -0
  34. package/src/components/ProjectCard.tsx +382 -0
  35. package/src/components/QueryProvider.tsx +25 -0
  36. package/src/components/SessionCard.tsx +291 -0
  37. package/src/components/SessionList.tsx +60 -0
  38. package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
  39. package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
  40. package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
  41. package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
  42. package/src/components/opencode-config/ConfigButton.tsx +43 -0
  43. package/src/components/opencode-config/ConfigPanel.tsx +91 -0
  44. package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
  45. package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
  46. package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
  47. package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
  48. package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
  49. package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
  50. package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
  51. package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
  52. package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
  53. package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
  54. package/src/components/ui/Tabs.tsx +59 -0
  55. package/src/hooks/useOpencodeSync.ts +378 -0
  56. package/src/index.ts +2 -0
  57. package/src/lib/notificationSound.ts +266 -0
  58. package/src/lib/opencodeConfig.test.ts +81 -0
  59. package/src/lib/opencodeConfig.ts +48 -0
  60. package/src/lib/opencodeDiscovery.ts +154 -0
  61. package/src/lib/profiles/storage.ts +264 -0
  62. package/src/lib/transform.ts +84 -0
  63. package/src/test/setup.ts +8 -0
  64. package/src/types/index.ts +89 -0
  65. package/src/types/opencodeConfig.ts +133 -0
  66. package/src/types/testing-library-vitest.d.ts +17 -0
  67. package/tsconfig.json +34 -0
  68. package/tsconfig.lib.json +17 -0
@@ -0,0 +1,360 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { X, Search, Bot, Settings, ChevronRight, AlertTriangle } from 'lucide-react';
5
+ import { useQuery } from '@tanstack/react-query';
6
+ import { CategoriesManager } from './categories/CategoriesManager';
7
+ import { ProfileManager } from './profiles/ProfileManager';
8
+ import { AgentConfigForm } from './AgentConfigForm';
9
+
10
+ interface AgentConfig {
11
+ model?: string;
12
+ }
13
+
14
+ interface ConfigResponse {
15
+ agents: Record<string, AgentConfig>;
16
+ }
17
+
18
+ interface ModelsResponse {
19
+ models: string[];
20
+ source: string;
21
+ }
22
+
23
+ type AgentStatus = 'ok' | 'invalid' | 'unconfigured';
24
+
25
+ interface FullscreenConfigPanelProps {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ }
29
+
30
+ interface AgentItem {
31
+ key: string;
32
+ name: string;
33
+ description: string;
34
+ icon?: React.ReactNode;
35
+ }
36
+
37
+ const AGENTS: AgentItem[] = [
38
+ { key: 'default', name: 'Default', description: 'Fallback configuration' },
39
+ { key: 'sisyphus', name: 'Sisyphus', description: 'Task execution agent' },
40
+ { key: 'hephaestus', name: 'Hephaestus', description: 'Build & automation' },
41
+ { key: 'prometheus', name: 'Prometheus', description: 'Planning agent' },
42
+ { key: 'oracle', name: 'Oracle', description: 'Knowledge & research' },
43
+ { key: 'metis', name: 'Metis', description: 'Strategy & consultation' },
44
+ { key: 'momus', name: 'Momus', description: 'Review & critique' },
45
+ { key: 'atlas', name: 'Atlas', description: 'Execution-focused' },
46
+ { key: 'librarian', name: 'Librarian', description: 'Documentation & exploration' },
47
+ { key: 'explore', name: 'Explore', description: 'Code navigation' },
48
+ ];
49
+
50
+ export function FullscreenConfigPanel({ open, onClose }: FullscreenConfigPanelProps) {
51
+ const [activeTab, setActiveTab] = React.useState<'agents' | 'categories' | 'profiles'>('agents');
52
+ const [selectedAgent, setSelectedAgent] = React.useState('default');
53
+ const [searchQuery, setSearchQuery] = React.useState('');
54
+ const inputRef = React.useRef<HTMLInputElement>(null);
55
+
56
+ // Fetch config and models for status indicators
57
+ const { data: configData } = useQuery<ConfigResponse>({
58
+ queryKey: ['opencode-config'],
59
+ queryFn: async () => {
60
+ const res = await fetch('/api/opencode-config');
61
+ if (!res.ok) throw new Error('Failed to fetch config');
62
+ return res.json();
63
+ },
64
+ enabled: open,
65
+ });
66
+
67
+ const { data: modelsData } = useQuery<ModelsResponse>({
68
+ queryKey: ['opencode-models'],
69
+ queryFn: async () => {
70
+ const res = await fetch('/api/opencode-models');
71
+ if (!res.ok) throw new Error('Failed to fetch models');
72
+ return res.json();
73
+ },
74
+ enabled: open,
75
+ });
76
+
77
+ const availableModels = React.useMemo(
78
+ () => new Set(modelsData?.models ?? []),
79
+ [modelsData]
80
+ );
81
+
82
+ const getAgentStatus = React.useCallback(
83
+ (agentKey: string): AgentStatus => {
84
+ const agentConfig = configData?.agents?.[agentKey];
85
+ if (!agentConfig?.model) return 'unconfigured';
86
+ if (availableModels.size > 0 && !availableModels.has(agentConfig.model)) return 'invalid';
87
+ return 'ok';
88
+ },
89
+ [configData, availableModels]
90
+ );
91
+
92
+ const attentionCount = React.useMemo(() => {
93
+ return AGENTS.filter((a) => {
94
+ const s = getAgentStatus(a.key);
95
+ return s === 'invalid';
96
+ }).length;
97
+ }, [getAgentStatus]);
98
+
99
+ React.useEffect(() => {
100
+ if (!open) return;
101
+
102
+ const handleKeyDown = (e: KeyboardEvent) => {
103
+ if (e.key === 'Escape') {
104
+ onClose();
105
+ }
106
+ };
107
+
108
+ document.addEventListener('keydown', handleKeyDown);
109
+ return () => document.removeEventListener('keydown', handleKeyDown);
110
+ }, [open, onClose]);
111
+
112
+ React.useEffect(() => {
113
+ if (open) {
114
+ const originalStyle = window.getComputedStyle(document.body).overflow;
115
+ document.body.style.overflow = 'hidden';
116
+ return () => {
117
+ document.body.style.overflow = originalStyle;
118
+ };
119
+ }
120
+ }, [open]);
121
+
122
+ React.useEffect(() => {
123
+ if (open) {
124
+ setTimeout(() => inputRef.current?.focus(), 100);
125
+ }
126
+ }, [open]);
127
+
128
+ const filteredAgents = React.useMemo(() => {
129
+ if (!searchQuery.trim()) return AGENTS;
130
+ const query = searchQuery.toLowerCase();
131
+ return AGENTS.filter(
132
+ (agent) =>
133
+ agent.name.toLowerCase().includes(query) ||
134
+ agent.description.toLowerCase().includes(query)
135
+ );
136
+ }, [searchQuery]);
137
+
138
+ const selectedAgentData = AGENTS.find((a) => a.key === selectedAgent);
139
+
140
+ if (!open) return null;
141
+
142
+ return (
143
+ <div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-zinc-950">
144
+ <header className="flex h-16 items-center justify-between border-b border-zinc-200 px-6 dark:border-zinc-800">
145
+ <div className="flex items-center gap-3">
146
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-600 text-white">
147
+ <Settings className="h-5 w-5" />
148
+ </div>
149
+ <div>
150
+ <h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
151
+ Agent Configuration
152
+ </h1>
153
+ <p className="text-xs text-zinc-500 dark:text-zinc-400">
154
+ Manage AI agent settings and preferences
155
+ </p>
156
+ </div>
157
+ </div>
158
+
159
+ <div className="flex items-center gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800">
160
+ <button
161
+ type="button"
162
+ onClick={() => setActiveTab('agents')}
163
+ className={`rounded-md px-4 py-1.5 text-sm font-medium transition-colors ${
164
+ activeTab === 'agents'
165
+ ? 'bg-blue-600 text-white shadow-sm'
166
+ : 'text-zinc-600 hover:bg-zinc-200 dark:text-zinc-400 dark:hover:bg-zinc-700'
167
+ }`}
168
+ >
169
+ Agents
170
+ </button>
171
+ <button
172
+ type="button"
173
+ onClick={() => setActiveTab('categories')}
174
+ className={`rounded-md px-4 py-1.5 text-sm font-medium transition-colors ${
175
+ activeTab === 'categories'
176
+ ? 'bg-blue-600 text-white shadow-sm'
177
+ : 'text-zinc-600 hover:bg-zinc-200 dark:text-zinc-400 dark:hover:bg-zinc-700'
178
+ }`}
179
+ >
180
+ Categories
181
+ </button>
182
+ <button
183
+ type="button"
184
+ onClick={() => setActiveTab('profiles')}
185
+ className={`rounded-md px-4 py-1.5 text-sm font-medium transition-colors ${
186
+ activeTab === 'profiles'
187
+ ? 'bg-blue-600 text-white shadow-sm'
188
+ : 'text-zinc-600 hover:bg-zinc-200 dark:text-zinc-400 dark:hover:bg-zinc-700'
189
+ }`}
190
+ >
191
+ Profiles
192
+ </button>
193
+ </div>
194
+
195
+ <button
196
+ type="button"
197
+ onClick={onClose}
198
+ className="flex h-9 w-9 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
199
+ aria-label="Close panel"
200
+ >
201
+ <X className="h-5 w-5" />
202
+ </button>
203
+ </header>
204
+
205
+ {activeTab === 'agents' ? (
206
+ <div className="flex flex-1 overflow-hidden">
207
+ <aside className="flex w-[280px] flex-col border-r border-zinc-200 bg-zinc-50/50 dark:border-zinc-800 dark:bg-zinc-900/20">
208
+ <div className="border-b border-zinc-200 p-4 dark:border-zinc-800">
209
+ <div className="relative">
210
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
211
+ <input
212
+ ref={inputRef}
213
+ type="text"
214
+ value={searchQuery}
215
+ onChange={(e) => setSearchQuery(e.target.value)}
216
+ placeholder="Search agents..."
217
+ className="h-10 w-full rounded-lg border border-zinc-200 bg-white pl-9 pr-4 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-600"
218
+ />
219
+ </div>
220
+ </div>
221
+
222
+ <nav className="flex-1 overflow-y-auto p-2">
223
+ <div className="space-y-1">
224
+ {filteredAgents.map((agent) => (
225
+ <button
226
+ key={agent.key}
227
+ type="button"
228
+ onClick={() => setSelectedAgent(agent.key)}
229
+ className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ${
230
+ selectedAgent === agent.key
231
+ ? 'bg-blue-600 text-white'
232
+ : 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800'
233
+ }`}
234
+ >
235
+ <div
236
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ${
237
+ selectedAgent === agent.key
238
+ ? 'bg-white/20'
239
+ : 'bg-zinc-200 dark:bg-zinc-800'
240
+ }`}
241
+ >
242
+ <Bot
243
+ className={`h-4 w-4 ${
244
+ selectedAgent === agent.key
245
+ ? 'text-white'
246
+ : 'text-zinc-600 dark:text-zinc-400'
247
+ }`}
248
+ />
249
+ </div>
250
+ <div className="min-w-0 flex-1">
251
+ <div className="flex items-center gap-2 truncate text-sm font-medium">
252
+ {agent.name}
253
+ {(() => {
254
+ const status = getAgentStatus(agent.key);
255
+ if (status === 'unconfigured') return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${selectedAgent === agent.key ? 'bg-zinc-300' : 'bg-zinc-400 dark:bg-zinc-500'}`} title="Inherits category configuration" />;
256
+ if (status === 'invalid') return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${selectedAgent === agent.key ? 'bg-amber-300' : 'bg-amber-500'}`} title="Model not available" />;
257
+ return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${selectedAgent === agent.key ? 'bg-emerald-300' : 'bg-emerald-500'}`} title="Configured" />;
258
+ })()}
259
+ </div>
260
+ <div
261
+ className={`truncate text-xs ${
262
+ selectedAgent === agent.key
263
+ ? 'text-blue-100'
264
+ : 'text-zinc-500 dark:text-zinc-500'
265
+ }`}
266
+ >
267
+ {agent.description}
268
+ </div>
269
+ </div>
270
+ {selectedAgent === agent.key && (
271
+ <ChevronRight className="h-4 w-4 shrink-0 text-blue-200" />
272
+ )}
273
+ </button>
274
+ ))}
275
+ </div>
276
+
277
+ {filteredAgents.length === 0 && (
278
+ <div className="py-8 text-center">
279
+ <p className="text-sm text-zinc-500 dark:text-zinc-400">
280
+ No agents found
281
+ </p>
282
+ </div>
283
+ )}
284
+ </nav>
285
+
286
+ <div className="border-t border-zinc-200 p-4 dark:border-zinc-800">
287
+ <div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
288
+ <span className="flex items-center gap-1.5">
289
+ {AGENTS.length} agents
290
+ {attentionCount > 0 && (
291
+ <span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
292
+ <AlertTriangle className="h-2.5 w-2.5" />
293
+ {attentionCount} need attention
294
+ </span>
295
+ )}
296
+ </span>
297
+ <kbd className="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-800">
298
+ ESC
299
+ </kbd>
300
+ </div>
301
+ </div>
302
+ </aside>
303
+
304
+ <main className="flex-1 overflow-y-auto bg-white dark:bg-zinc-950">
305
+ <div className="mx-auto max-w-3xl p-8">
306
+ <div className="mb-8 flex items-center gap-4">
307
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg">
308
+ <Bot className="h-8 w-8" />
309
+ </div>
310
+ <div>
311
+ <h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
312
+ {selectedAgentData?.name}
313
+ </h2>
314
+ <p className="text-zinc-500 dark:text-zinc-400">
315
+ {selectedAgentData?.description}
316
+ </p>
317
+ </div>
318
+ </div>
319
+
320
+ <AgentConfigForm
321
+ agentName={selectedAgent}
322
+ onSaveSuccess={onClose}
323
+ />
324
+ </div>
325
+ </main>
326
+ </div>
327
+ ) : activeTab === 'categories' ? (
328
+ <main className="flex-1 overflow-y-auto bg-white dark:bg-zinc-950">
329
+ <div className="mx-auto max-w-4xl p-8">
330
+ <div className="mb-8">
331
+ <h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
332
+ Categories
333
+ </h2>
334
+ <p className="text-zinc-500 dark:text-zinc-400">
335
+ Manage agent categories and their configurations
336
+ </p>
337
+ </div>
338
+ <CategoriesManager />
339
+ </div>
340
+ </main>
341
+ ) : (
342
+ <main className="flex-1 overflow-y-auto bg-white dark:bg-zinc-950">
343
+ <div className="mx-auto max-w-4xl p-8">
344
+ <div className="mb-8">
345
+ <h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
346
+ Profiles
347
+ </h2>
348
+ <p className="text-zinc-500 dark:text-zinc-400">
349
+ Manage configuration profiles for different agent setups
350
+ </p>
351
+ </div>
352
+ <ProfileManager />
353
+ </div>
354
+ </main>
355
+ )}
356
+ </div>
357
+ );
358
+ }
359
+
360
+ export default FullscreenConfigPanel;
@@ -0,0 +1,328 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Pencil, Trash2, Layers, AlertTriangle, AlertCircle } from 'lucide-react';
5
+ import { useQuery } from '@tanstack/react-query';
6
+ import { CategoryConfig } from '../../../types/opencodeConfig';
7
+
8
+
9
+ interface ModelsResponse {
10
+ models: string[];
11
+ source: string;
12
+ }
13
+
14
+ interface CategoriesListProps {
15
+ /** Record of category key to category configuration */
16
+ categories: Record<string, CategoryConfig>;
17
+ /** Callback when edit button is clicked */
18
+ onEdit: (categoryKey: string, config: CategoryConfig) => void;
19
+ /** Callback when delete button is clicked (only for custom categories) */
20
+ onDelete: (categoryKey: string) => void;
21
+ }
22
+
23
+ interface CategoryDefinition {
24
+ key: string;
25
+ name: string;
26
+ description: string;
27
+ }
28
+
29
+ /** Built-in categories as defined by oh-my-opencode */
30
+ const BUILT_IN_CATEGORIES: CategoryDefinition[] = [
31
+ {
32
+ key: 'visual-engineering',
33
+ name: 'Visual Engineering',
34
+ description: 'Visual and UI component engineering tasks',
35
+ },
36
+ {
37
+ key: 'ultrabrain',
38
+ name: 'Ultrabrain',
39
+ description: 'Complex reasoning and deep analysis tasks',
40
+ },
41
+ {
42
+ key: 'deep',
43
+ name: 'Deep',
44
+ description: 'Deep research and comprehensive tasks',
45
+ },
46
+ {
47
+ key: 'artistry',
48
+ name: 'Artistry',
49
+ description: 'Creative and design-focused tasks',
50
+ },
51
+ {
52
+ key: 'quick',
53
+ name: 'Quick',
54
+ description: 'Fast, simple tasks requiring minimal processing',
55
+ },
56
+ {
57
+ key: 'unspecified-low',
58
+ name: 'Unspecified Low',
59
+ description: 'Default low-complexity tasks',
60
+ },
61
+ {
62
+ key: 'unspecified-high',
63
+ name: 'Unspecified High',
64
+ description: 'Default high-complexity tasks',
65
+ },
66
+ {
67
+ key: 'writing',
68
+ name: 'Writing',
69
+ description: 'Content creation and writing tasks',
70
+ },
71
+ ];
72
+
73
+ /** Check if a category key is a built-in category */
74
+ function isBuiltInCategory(key: string): boolean {
75
+ return BUILT_IN_CATEGORIES.some((cat) => cat.key === key);
76
+ }
77
+
78
+ /** Get display info for a category */
79
+ function getCategoryInfo(key: string): CategoryDefinition {
80
+ const builtIn = BUILT_IN_CATEGORIES.find((cat) => cat.key === key);
81
+ if (builtIn) {
82
+ return builtIn;
83
+ }
84
+ // For custom categories, use the key as the name
85
+ return {
86
+ key,
87
+ name: key,
88
+ description: 'Custom category',
89
+ };
90
+ }
91
+
92
+ /** Format variant display */
93
+ function formatVariant(variant?: string): string {
94
+ if (!variant) return '—';
95
+ return variant;
96
+ }
97
+
98
+ /** Format model display */
99
+ function formatModel(model?: string): string {
100
+ if (!model) return 'Default model';
101
+ // Extract just the model name from a full path like "google/gemini-3.1-pro"
102
+ const parts = model.split('/');
103
+ return parts[parts.length - 1];
104
+ }
105
+
106
+ interface CategoryCardProps {
107
+ categoryKey: string;
108
+ config: CategoryConfig;
109
+ isBuiltIn: boolean;
110
+ availableModels: Set<string> | null;
111
+ onEdit: (key: string, config: CategoryConfig) => void;
112
+ onDelete: (key: string) => void;
113
+ }
114
+
115
+ function CategoryCard({
116
+ categoryKey,
117
+ config,
118
+ isBuiltIn,
119
+ availableModels,
120
+ onEdit,
121
+ onDelete,
122
+ }: CategoryCardProps) {
123
+ const info = getCategoryInfo(categoryKey);
124
+ const hasConfig = !!(config.model || config.variant);
125
+ const isModelInvalid = config.model && availableModels && availableModels.size > 0 && !availableModels.has(config.model);
126
+
127
+ // Dynamic border/bg classes based on status
128
+ let cardStateClasses = hasConfig
129
+ ? 'bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
130
+ : 'bg-zinc-50/50 border-zinc-200/50 dark:bg-zinc-900/50 dark:border-zinc-800/50 hover:border-zinc-300 dark:hover:border-zinc-600';
131
+
132
+ if (isModelInvalid) {
133
+ cardStateClasses = 'bg-amber-50/50 border-amber-200 dark:bg-amber-900/10 dark:border-amber-800/50 hover:border-amber-300 dark:hover:border-amber-700';
134
+ }
135
+
136
+ return (
137
+ <div
138
+ className={`
139
+ group relative flex items-center justify-between p-4 rounded-lg border flex-col sm:flex-row items-start sm:items-center gap-4
140
+ transition-all duration-200
141
+ ${cardStateClasses}
142
+ `}
143
+ >
144
+ <div className="flex-1 min-w-0">
145
+ <div className="flex items-center gap-2">
146
+ <h4 className="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
147
+ {info.name}
148
+ </h4>
149
+ {!isBuiltIn && (
150
+ <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">
151
+ Custom
152
+ </span>
153
+ )}
154
+ {isModelInvalid && (
155
+ <span className="inline-flex items-center gap-1 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-400">
156
+ <AlertTriangle className="h-3 w-3" />
157
+ Model Unavailable
158
+ </span>
159
+ )}
160
+ {!isModelInvalid && hasConfig && (
161
+ <span className="inline-flex items-center 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">
162
+ Configured
163
+ </span>
164
+ )}
165
+ {!hasConfig && (
166
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
167
+ <AlertCircle className="h-3 w-3" />
168
+ Default fallback
169
+ </span>
170
+ )}
171
+ </div>
172
+ <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400 truncate">
173
+ {info.description}
174
+ </p>
175
+ <div className="mt-2 flex items-center gap-3 text-xs">
176
+ <span className="text-zinc-600 dark:text-zinc-400">
177
+ <span className="text-zinc-400 dark:text-zinc-500">Model:</span>{' '}
178
+ {formatModel(config.model)}
179
+ </span>
180
+ {config.variant && (
181
+ <span className="text-zinc-600 dark:text-zinc-400">
182
+ <span className="text-zinc-400 dark:text-zinc-500">Variant:</span>{' '}
183
+ {formatVariant(config.variant)}
184
+ </span>
185
+ )}
186
+ </div>
187
+ </div>
188
+
189
+ <div className="flex items-center gap-1 ml-4 opacity-0 group-hover:opacity-100 transition-opacity">
190
+ <button
191
+ type="button"
192
+ onClick={() => onEdit(categoryKey, config)}
193
+ 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"
194
+ aria-label={`Edit ${info.name} category`}
195
+ title={`Edit ${info.name}`}
196
+ >
197
+ <Pencil className="h-4 w-4" />
198
+ </button>
199
+ {!isBuiltIn && (
200
+ <button
201
+ type="button"
202
+ onClick={() => onDelete(categoryKey)}
203
+ 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"
204
+ aria-label={`Delete ${info.name} category`}
205
+ title={`Delete ${info.name}`}
206
+ >
207
+ <Trash2 className="h-4 w-4" />
208
+ </button>
209
+ )}
210
+ </div>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ export function CategoriesList({
216
+ categories,
217
+ onEdit,
218
+ onDelete,
219
+ }: CategoriesListProps) {
220
+ const { data: modelsData } = useQuery<ModelsResponse>({
221
+ queryKey: ['opencode-models'],
222
+ queryFn: async () => {
223
+ const res = await fetch('/api/opencode-models');
224
+ if (!res.ok) throw new Error('Failed to fetch models');
225
+ return res.json();
226
+ },
227
+ });
228
+
229
+ const availableModels = React.useMemo(
230
+ () => (modelsData ? new Set(modelsData.models) : null),
231
+ [modelsData]
232
+ );
233
+
234
+ // Separate built-in and custom categories
235
+ const { builtIn, custom } = React.useMemo(() => {
236
+ const builtIn: { key: string; config: CategoryConfig }[] = [];
237
+ const custom: { key: string; config: CategoryConfig }[] = [];
238
+
239
+ // First, add all built-in categories (even if not in config)
240
+ BUILT_IN_CATEGORIES.forEach((cat) => {
241
+ builtIn.push({
242
+ key: cat.key,
243
+ config: categories[cat.key] || {},
244
+ });
245
+ });
246
+
247
+ // Then add custom categories
248
+ Object.entries(categories).forEach(([key, config]) => {
249
+ if (!isBuiltInCategory(key)) {
250
+ custom.push({ key, config });
251
+ }
252
+ });
253
+
254
+ return { builtIn, custom };
255
+ }, [categories]);
256
+
257
+ const hasCustomCategories = custom.length > 0;
258
+
259
+ return (
260
+ <div className="space-y-6">
261
+ {/* Built-in Categories */}
262
+ <section>
263
+ <div className="flex items-center gap-2 mb-3">
264
+ <Layers className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
265
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
266
+ Built-in Categories
267
+ </h3>
268
+ <span className="text-xs text-zinc-500 dark:text-zinc-400">
269
+ ({builtIn.length})
270
+ </span>
271
+ </div>
272
+ <div className="space-y-2">
273
+ {builtIn.map(({ key, config }) => (
274
+ <CategoryCard
275
+ key={key}
276
+ categoryKey={key}
277
+ config={config}
278
+ isBuiltIn={true}
279
+ availableModels={availableModels}
280
+ onEdit={onEdit}
281
+ onDelete={onDelete}
282
+ />
283
+ ))}
284
+ </div>
285
+ </section>
286
+
287
+ {/* Custom Categories */}
288
+ {hasCustomCategories && (
289
+ <section>
290
+ <div className="flex items-center gap-2 mb-3">
291
+ <Layers className="h-4 w-4 text-blue-500" />
292
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
293
+ Custom Categories
294
+ </h3>
295
+ <span className="text-xs text-zinc-500 dark:text-zinc-400">
296
+ ({custom.length})
297
+ </span>
298
+ </div>
299
+ <div className="space-y-2">
300
+ {custom.map(({ key, config }) => (
301
+ <CategoryCard
302
+ key={key}
303
+ categoryKey={key}
304
+ config={config}
305
+ isBuiltIn={false}
306
+ availableModels={availableModels}
307
+ onEdit={onEdit}
308
+ onDelete={onDelete}
309
+ />
310
+ ))}
311
+ </div>
312
+ </section>
313
+ )}
314
+
315
+ {/* Empty state when no categories at all */}
316
+ {builtIn.length === 0 && !hasCustomCategories && (
317
+ <div className="text-center py-8">
318
+ <Layers className="h-8 w-8 mx-auto text-zinc-300 dark:text-zinc-600 mb-2" />
319
+ <p className="text-sm text-zinc-500 dark:text-zinc-400">
320
+ No categories configured
321
+ </p>
322
+ </div>
323
+ )}
324
+ </div>
325
+ );
326
+ }
327
+
328
+ export default CategoriesList;