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.
- package/README.md +7 -13
- package/bin/vibepulse.js +1 -0
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/docs/session-status-detection.md +258 -0
- package/next.config.ts +11 -0
- package/package.json +17 -11
- 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,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;
|