agentcraft 0.0.1
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/.claude-plugin/plugin.json +7 -0
- package/README.md +95 -0
- package/bin/agentcraft.js +12 -0
- package/commands/agentcraft.md +46 -0
- package/hooks/hooks.json +13 -0
- package/hooks/play-sound.sh +69 -0
- package/opencode.js +87 -0
- package/package.json +19 -0
- package/screenshot.jpg +0 -0
- package/social-share-og.jpg +0 -0
- package/social-share-tw.jpg +0 -0
- package/social-share.png +0 -0
- package/web/README.md +36 -0
- package/web/app/api/agents/[name]/route.ts +44 -0
- package/web/app/api/agents/route.ts +62 -0
- package/web/app/api/assignments/route.ts +53 -0
- package/web/app/api/audio/[...path]/route.ts +35 -0
- package/web/app/api/health/route.ts +5 -0
- package/web/app/api/preview/route.ts +22 -0
- package/web/app/api/skills/route.ts +83 -0
- package/web/app/api/sounds/route.ts +106 -0
- package/web/app/api/ui-sounds/route.ts +44 -0
- package/web/app/favicon.ico +0 -0
- package/web/app/globals.css +99 -0
- package/web/app/icon.svg +13 -0
- package/web/app/layout.tsx +19 -0
- package/web/app/opengraph-image.tsx +96 -0
- package/web/app/page.tsx +268 -0
- package/web/bun.lock +292 -0
- package/web/components/agent-form.tsx +190 -0
- package/web/components/agent-roster-panel.tsx +502 -0
- package/web/components/assignment-log-panel.tsx +109 -0
- package/web/components/hook-slot.tsx +149 -0
- package/web/components/hud-header.tsx +135 -0
- package/web/components/sound-browser-panel.tsx +206 -0
- package/web/components/sound-unit.tsx +203 -0
- package/web/components/ui-sounds-modal.tsx +308 -0
- package/web/lib/types.ts +87 -0
- package/web/lib/ui-audio.ts +126 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.ts +8 -0
- package/web/package.json +37 -0
- package/web/postcss.config.mjs +1 -0
- package/web/public/file.svg +1 -0
- package/web/public/fonts/starcraft-normal.ttf +0 -0
- package/web/public/globe.svg +1 -0
- package/web/public/next.svg +1 -0
- package/web/public/vercel.svg +1 -0
- package/web/public/window.svg +1 -0
- package/web/tsconfig.json +34 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { AgentFormData, AgentInfo } from '@/lib/types';
|
|
5
|
+
|
|
6
|
+
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
7
|
+
const COLORS = ['blue', 'green', 'red', 'purple', 'orange', 'yellow', 'pink'];
|
|
8
|
+
const COMMON_TOOLS = ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite'];
|
|
9
|
+
|
|
10
|
+
interface AgentFormProps {
|
|
11
|
+
initial?: AgentInfo;
|
|
12
|
+
onSave: (data: AgentFormData, originalName?: string) => Promise<void>;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AgentForm({ initial, onSave, onCancel }: AgentFormProps) {
|
|
17
|
+
const [form, setForm] = useState<AgentFormData>({
|
|
18
|
+
name: initial?.name ?? '',
|
|
19
|
+
description: initial?.description ?? '',
|
|
20
|
+
model: initial?.model ?? 'sonnet',
|
|
21
|
+
tools: initial?.tools ?? 'Read, Write, Edit, Bash, Grep',
|
|
22
|
+
color: initial?.color ?? 'blue',
|
|
23
|
+
prompt: initial?.prompt ?? '',
|
|
24
|
+
});
|
|
25
|
+
const [saving, setSaving] = useState(false);
|
|
26
|
+
|
|
27
|
+
const set = (key: keyof AgentFormData, val: string) => setForm((f) => ({ ...f, [key]: val }));
|
|
28
|
+
|
|
29
|
+
const toggleTool = (tool: string) => {
|
|
30
|
+
const current = form.tools.split(',').map((t) => t.trim()).filter(Boolean);
|
|
31
|
+
const next = current.includes(tool)
|
|
32
|
+
? current.filter((t) => t !== tool)
|
|
33
|
+
: [...current, tool];
|
|
34
|
+
set('tools', next.join(', '));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const hasTools = (tool: string) => form.tools.split(',').map((t) => t.trim()).includes(tool);
|
|
38
|
+
|
|
39
|
+
const handleSave = async () => {
|
|
40
|
+
if (!form.name.trim() || !form.description.trim()) return;
|
|
41
|
+
setSaving(true);
|
|
42
|
+
try {
|
|
43
|
+
await onSave(form, initial?.name);
|
|
44
|
+
} finally {
|
|
45
|
+
setSaving(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const inputStyle = {
|
|
50
|
+
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
51
|
+
border: '1px solid var(--sf-border)',
|
|
52
|
+
color: 'rgba(255,255,255,0.85)',
|
|
53
|
+
outline: 'none',
|
|
54
|
+
fontFamily: 'inherit',
|
|
55
|
+
fontSize: '11px',
|
|
56
|
+
padding: '4px 8px',
|
|
57
|
+
width: '100%',
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
const labelStyle = {
|
|
61
|
+
fontSize: '9px',
|
|
62
|
+
color: 'var(--sf-cyan)',
|
|
63
|
+
opacity: 0.6,
|
|
64
|
+
textTransform: 'uppercase' as const,
|
|
65
|
+
letterSpacing: '0.1em',
|
|
66
|
+
marginBottom: '3px',
|
|
67
|
+
display: 'block',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className="mt-1 mb-2 p-3 space-y-3"
|
|
73
|
+
style={{ border: '1px solid rgba(0,229,255,0.3)', backgroundColor: 'rgba(0,229,255,0.03)' }}
|
|
74
|
+
>
|
|
75
|
+
<div className="sf-heading text-[10px] tracking-widest uppercase opacity-60" style={{ color: 'var(--sf-cyan)' }}>
|
|
76
|
+
{initial ? 'EDIT UNIT' : 'DEPLOY NEW UNIT'}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Name */}
|
|
80
|
+
<div>
|
|
81
|
+
<label style={labelStyle}>CALLSIGN (name)</label>
|
|
82
|
+
<input
|
|
83
|
+
style={inputStyle}
|
|
84
|
+
value={form.name}
|
|
85
|
+
onChange={(e) => set('name', e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
|
86
|
+
placeholder="my-specialist"
|
|
87
|
+
disabled={!!initial}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Description */}
|
|
92
|
+
<div>
|
|
93
|
+
<label style={labelStyle}>BRIEFING (description)</label>
|
|
94
|
+
<textarea
|
|
95
|
+
style={{ ...inputStyle, resize: 'vertical', minHeight: '48px' }}
|
|
96
|
+
value={form.description}
|
|
97
|
+
onChange={(e) => set('description', e.target.value)}
|
|
98
|
+
placeholder="Expert in..."
|
|
99
|
+
rows={2}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Model + Color row */}
|
|
104
|
+
<div className="flex gap-3">
|
|
105
|
+
<div className="flex-1">
|
|
106
|
+
<label style={labelStyle}>MODEL</label>
|
|
107
|
+
<select
|
|
108
|
+
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
109
|
+
value={form.model}
|
|
110
|
+
onChange={(e) => set('model', e.target.value)}
|
|
111
|
+
>
|
|
112
|
+
{MODELS.map((m) => <option key={m} value={m}>{m}</option>)}
|
|
113
|
+
</select>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="flex-1">
|
|
116
|
+
<label style={labelStyle}>COLOR</label>
|
|
117
|
+
<select
|
|
118
|
+
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
119
|
+
value={form.color}
|
|
120
|
+
onChange={(e) => set('color', e.target.value)}
|
|
121
|
+
>
|
|
122
|
+
{COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
|
|
123
|
+
</select>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Tools */}
|
|
128
|
+
<div>
|
|
129
|
+
<label style={labelStyle}>ARMAMENTS (tools)</label>
|
|
130
|
+
<div className="flex flex-wrap gap-1 mb-1">
|
|
131
|
+
{COMMON_TOOLS.map((tool) => (
|
|
132
|
+
<button
|
|
133
|
+
key={tool}
|
|
134
|
+
onClick={() => toggleTool(tool)}
|
|
135
|
+
className="text-[9px] px-1.5 py-0.5 transition-all"
|
|
136
|
+
style={{
|
|
137
|
+
border: `1px solid ${hasTools(tool) ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
|
|
138
|
+
color: hasTools(tool) ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.3)',
|
|
139
|
+
backgroundColor: hasTools(tool) ? 'rgba(0,229,255,0.08)' : 'transparent',
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{tool}
|
|
143
|
+
</button>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
<input
|
|
147
|
+
style={inputStyle}
|
|
148
|
+
value={form.tools}
|
|
149
|
+
onChange={(e) => set('tools', e.target.value)}
|
|
150
|
+
placeholder="Read, Write, Edit, Bash"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* System prompt */}
|
|
155
|
+
<div>
|
|
156
|
+
<label style={labelStyle}>ORDERS (system prompt)</label>
|
|
157
|
+
<textarea
|
|
158
|
+
style={{ ...inputStyle, resize: 'vertical', minHeight: '80px' }}
|
|
159
|
+
value={form.prompt}
|
|
160
|
+
onChange={(e) => set('prompt', e.target.value)}
|
|
161
|
+
placeholder="You are a specialist in..."
|
|
162
|
+
rows={4}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Actions */}
|
|
167
|
+
<div className="flex gap-2 pt-1">
|
|
168
|
+
<button
|
|
169
|
+
onClick={handleSave}
|
|
170
|
+
disabled={saving || !form.name.trim() || !form.description.trim()}
|
|
171
|
+
className="flex-1 py-1.5 text-[10px] sf-heading font-bold uppercase tracking-wider transition-all"
|
|
172
|
+
style={{
|
|
173
|
+
border: '1px solid var(--sf-cyan)',
|
|
174
|
+
color: saving ? 'rgba(0,229,255,0.4)' : 'var(--sf-cyan)',
|
|
175
|
+
backgroundColor: 'rgba(0,229,255,0.08)',
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{saving ? 'DEPLOYING...' : (initial ? 'UPDATE UNIT' : 'DEPLOY UNIT')}
|
|
179
|
+
</button>
|
|
180
|
+
<button
|
|
181
|
+
onClick={onCancel}
|
|
182
|
+
className="px-4 py-1.5 text-[10px] sf-heading uppercase tracking-wider transition-all"
|
|
183
|
+
style={{ border: '1px solid var(--sf-border)', color: 'rgba(255,255,255,0.4)' }}
|
|
184
|
+
>
|
|
185
|
+
ABORT
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
import { HookSlot } from './hook-slot';
|
|
5
|
+
import { AgentForm } from './agent-form';
|
|
6
|
+
import { playUISound } from '@/lib/ui-audio';
|
|
7
|
+
import { getEventLabel } from '@/lib/utils';
|
|
8
|
+
import type { HookEvent, SkillHookEvent, SoundAssignments, AgentInfo, SkillInfo, AgentFormData, SelectMode } from '@/lib/types';
|
|
9
|
+
|
|
10
|
+
const HOOK_GROUPS: { label: string; events: HookEvent[] }[] = [
|
|
11
|
+
{ label: 'LIFECYCLE', events: ['SessionStart', 'SessionEnd', 'Stop'] },
|
|
12
|
+
{ label: 'TOOLING', events: ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'] },
|
|
13
|
+
{ label: 'SUBAGENTS', events: ['SubagentStop'] },
|
|
14
|
+
{ label: 'SYSTEM', events: ['Notification', 'PreCompact'] },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const ALL_EVENTS: HookEvent[] = HOOK_GROUPS.flatMap((g) => g.events);
|
|
18
|
+
const SKILL_EVENTS: SkillHookEvent[] = ['PreToolUse', 'PostToolUse'];
|
|
19
|
+
const SKILL_EVENT_LABELS: Record<SkillHookEvent, string> = {
|
|
20
|
+
PreToolUse: 'ON INVOKE',
|
|
21
|
+
PostToolUse: 'ON COMPLETE',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ── Agent row ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface AgentRowProps {
|
|
27
|
+
scope: string;
|
|
28
|
+
label: string;
|
|
29
|
+
isGlobal?: boolean;
|
|
30
|
+
hooks: Partial<Record<HookEvent, string>>;
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
agentInfo?: AgentInfo;
|
|
33
|
+
onToggle?: () => void;
|
|
34
|
+
onClear: (event: HookEvent) => void;
|
|
35
|
+
onPreview: (path: string) => void;
|
|
36
|
+
onEdit?: () => void;
|
|
37
|
+
onDelete?: () => void;
|
|
38
|
+
selectMode: SelectMode | null;
|
|
39
|
+
onSlotSelect: (mode: SelectMode) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function AgentRow({ scope, label, isGlobal, hooks, enabled, onToggle, onClear, onPreview, onEdit, onDelete, selectMode, onSlotSelect }: AgentRowProps) {
|
|
43
|
+
const [expanded, setExpanded] = useState(!!isGlobal);
|
|
44
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
45
|
+
const filledCount = Object.values(hooks).filter(Boolean).length;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="mb-1">
|
|
49
|
+
<div
|
|
50
|
+
data-sf-hover
|
|
51
|
+
data-no-ui-sound
|
|
52
|
+
className="flex items-center justify-between px-3 py-2 cursor-pointer transition-all group"
|
|
53
|
+
style={{
|
|
54
|
+
border: `1px solid ${isGlobal ? 'var(--sf-border-gold)' : isHovered ? 'rgba(0,229,255,0.35)' : 'var(--sf-border)'}`,
|
|
55
|
+
backgroundColor: isGlobal
|
|
56
|
+
? isHovered ? 'rgba(255,192,0,0.08)' : 'rgba(255,192,0,0.04)'
|
|
57
|
+
: isHovered ? 'rgba(0,229,255,0.04)' : 'transparent',
|
|
58
|
+
}}
|
|
59
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
60
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
61
|
+
onClick={() => { playUISound('toggle', 0.3); setExpanded(!expanded); }}
|
|
62
|
+
>
|
|
63
|
+
<div className="flex items-center gap-2 overflow-hidden">
|
|
64
|
+
<span className="text-[10px] opacity-60 shrink-0">{expanded ? '▾' : '▸'}</span>
|
|
65
|
+
<span
|
|
66
|
+
className="text-xs sf-heading font-semibold uppercase tracking-wider truncate"
|
|
67
|
+
style={{ color: isGlobal ? 'var(--sf-gold)' : 'rgba(255,255,255,0.8)' }}
|
|
68
|
+
>
|
|
69
|
+
{label}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
73
|
+
<span className="text-[10px] opacity-40">{filledCount}/{ALL_EVENTS.length}</span>
|
|
74
|
+
{!isGlobal && onToggle && (
|
|
75
|
+
<button
|
|
76
|
+
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
|
77
|
+
className="text-[10px] px-2 py-0.5 transition-all"
|
|
78
|
+
style={{
|
|
79
|
+
border: `1px solid ${enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.2)'}`,
|
|
80
|
+
color: enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.3)',
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{enabled ? 'ON' : 'OFF'}
|
|
84
|
+
</button>
|
|
85
|
+
)}
|
|
86
|
+
{onEdit && (
|
|
87
|
+
<button
|
|
88
|
+
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
|
89
|
+
className="text-[10px] px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-all"
|
|
90
|
+
style={{ border: '1px solid var(--sf-border)', color: 'rgba(255,255,255,0.4)' }}
|
|
91
|
+
title="Edit agent"
|
|
92
|
+
>
|
|
93
|
+
✎
|
|
94
|
+
</button>
|
|
95
|
+
)}
|
|
96
|
+
{onDelete && (
|
|
97
|
+
<button
|
|
98
|
+
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
|
99
|
+
className="text-[10px] px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-all"
|
|
100
|
+
style={{ border: '1px solid rgba(255,51,102,0.3)', color: 'var(--sf-alert)' }}
|
|
101
|
+
title="Delete agent"
|
|
102
|
+
>
|
|
103
|
+
✕
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{expanded && (
|
|
110
|
+
<div className="ml-3 mt-1 mb-2 space-y-1">
|
|
111
|
+
{HOOK_GROUPS.map((group) => (
|
|
112
|
+
<div key={group.label}>
|
|
113
|
+
<div className="text-[9px] uppercase tracking-widest opacity-30 mb-1 ml-1">{group.label}</div>
|
|
114
|
+
{group.events.map((event) => (
|
|
115
|
+
<HookSlot
|
|
116
|
+
key={event}
|
|
117
|
+
event={event}
|
|
118
|
+
scope={scope}
|
|
119
|
+
assignedSound={hooks[event]}
|
|
120
|
+
onClear={() => onClear(event)}
|
|
121
|
+
onPreview={onPreview}
|
|
122
|
+
selectMode={selectMode}
|
|
123
|
+
onSelect={() => onSlotSelect({ scope, event, label: getEventLabel(event) })}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Skill row ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
interface SkillRowProps {
|
|
137
|
+
skill: SkillInfo;
|
|
138
|
+
hooks: Partial<Record<SkillHookEvent, string>>;
|
|
139
|
+
enabled: boolean;
|
|
140
|
+
onToggle: () => void;
|
|
141
|
+
onClear: (event: SkillHookEvent) => void;
|
|
142
|
+
onPreview: (path: string) => void;
|
|
143
|
+
selectMode: SelectMode | null;
|
|
144
|
+
onSlotSelect: (mode: SelectMode) => void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function SkillRow({ skill, hooks, enabled, onToggle, onClear, onPreview, selectMode, onSlotSelect }: SkillRowProps) {
|
|
148
|
+
const [expanded, setExpanded] = useState(false);
|
|
149
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
150
|
+
const filledCount = Object.values(hooks).filter(Boolean).length;
|
|
151
|
+
const scope = `skill/${skill.qualifiedName}`;
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="mb-1">
|
|
155
|
+
<div
|
|
156
|
+
data-sf-hover
|
|
157
|
+
data-no-ui-sound
|
|
158
|
+
className="flex items-center justify-between px-3 py-2 cursor-pointer transition-all"
|
|
159
|
+
style={{
|
|
160
|
+
border: `1px solid ${isHovered ? 'rgba(0,168,255,0.4)' : 'rgba(0,168,255,0.2)'}`,
|
|
161
|
+
backgroundColor: isHovered ? 'rgba(0,168,255,0.07)' : 'rgba(0,168,255,0.03)',
|
|
162
|
+
}}
|
|
163
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
164
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
165
|
+
onClick={() => { playUISound('toggle', 0.3); setExpanded(!expanded); }}
|
|
166
|
+
>
|
|
167
|
+
<div className="flex items-center gap-2 overflow-hidden">
|
|
168
|
+
<span className="text-[10px] opacity-60 shrink-0">{expanded ? '▾' : '▸'}</span>
|
|
169
|
+
<span className="text-xs sf-heading font-semibold uppercase tracking-wider truncate" style={{ color: 'var(--sf-blue)' }}>
|
|
170
|
+
{skill.name}
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
174
|
+
<span className="text-[10px] opacity-40">{filledCount}/2</span>
|
|
175
|
+
<button
|
|
176
|
+
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
|
177
|
+
className="text-[10px] px-2 py-0.5 transition-all"
|
|
178
|
+
style={{
|
|
179
|
+
border: `1px solid ${enabled ? 'var(--sf-blue)' : 'rgba(255,255,255,0.2)'}`,
|
|
180
|
+
color: enabled ? 'var(--sf-blue)' : 'rgba(255,255,255,0.3)',
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{enabled ? 'ON' : 'OFF'}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{expanded && (
|
|
189
|
+
<div className="ml-3 mt-1 mb-2">
|
|
190
|
+
<div className="text-[9px] uppercase tracking-widest opacity-30 mb-1 ml-1">SIGNALS</div>
|
|
191
|
+
{SKILL_EVENTS.map((event) => (
|
|
192
|
+
<HookSlot
|
|
193
|
+
key={event}
|
|
194
|
+
event={event as HookEvent}
|
|
195
|
+
scope={scope}
|
|
196
|
+
assignedSound={hooks[event]}
|
|
197
|
+
onClear={() => onClear(event)}
|
|
198
|
+
onPreview={onPreview}
|
|
199
|
+
selectMode={selectMode}
|
|
200
|
+
onSelect={() => onSlotSelect({ scope, event, label: SKILL_EVENT_LABELS[event] })}
|
|
201
|
+
/>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Panel export ─────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
interface AgentRosterPanelProps {
|
|
212
|
+
assignments: SoundAssignments;
|
|
213
|
+
agents: AgentInfo[];
|
|
214
|
+
skills: SkillInfo[];
|
|
215
|
+
onAssignmentChange: (next: SoundAssignments) => void;
|
|
216
|
+
onPreview: (path: string) => void;
|
|
217
|
+
onAgentsChange: () => void;
|
|
218
|
+
selectMode: SelectMode | null;
|
|
219
|
+
onSlotSelect: (mode: SelectMode) => void;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChange, onPreview, onAgentsChange, selectMode, onSlotSelect }: AgentRosterPanelProps) {
|
|
223
|
+
const [activeView, setActiveView] = useState<'agents' | 'skills'>('agents');
|
|
224
|
+
const [showForm, setShowForm] = useState(false);
|
|
225
|
+
const [editingAgent, setEditingAgent] = useState<AgentInfo | undefined>();
|
|
226
|
+
const [skillSearch, setSkillSearch] = useState('');
|
|
227
|
+
const [collapsedNs, setCollapsedNs] = useState<Set<string>>(new Set());
|
|
228
|
+
|
|
229
|
+
const clearGlobalHook = (event: HookEvent) => {
|
|
230
|
+
onAssignmentChange({ ...assignments, global: { ...assignments.global, [event]: undefined } });
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const clearAgentHook = (agentName: string, event: HookEvent) => {
|
|
234
|
+
onAssignmentChange({
|
|
235
|
+
...assignments,
|
|
236
|
+
agents: {
|
|
237
|
+
...assignments.agents,
|
|
238
|
+
[agentName]: {
|
|
239
|
+
...assignments.agents[agentName],
|
|
240
|
+
hooks: { ...assignments.agents[agentName]?.hooks, [event]: undefined },
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const clearSkillHook = (skillName: string, event: SkillHookEvent) => {
|
|
247
|
+
onAssignmentChange({
|
|
248
|
+
...assignments,
|
|
249
|
+
skills: {
|
|
250
|
+
...assignments.skills,
|
|
251
|
+
[skillName]: {
|
|
252
|
+
...assignments.skills[skillName],
|
|
253
|
+
hooks: { ...assignments.skills[skillName]?.hooks, [event]: undefined },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const toggleAgent = (agentName: string) => {
|
|
260
|
+
const current = assignments.agents[agentName] ?? { enabled: true, hooks: {} };
|
|
261
|
+
onAssignmentChange({
|
|
262
|
+
...assignments,
|
|
263
|
+
agents: { ...assignments.agents, [agentName]: { ...current, enabled: !current.enabled } },
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const toggleSkill = (skillName: string) => {
|
|
268
|
+
const current = assignments.skills[skillName] ?? { enabled: true, hooks: {} };
|
|
269
|
+
onAssignmentChange({
|
|
270
|
+
...assignments,
|
|
271
|
+
skills: { ...assignments.skills, [skillName]: { ...current, enabled: !current.enabled } },
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const handleSaveAgent = async (data: AgentFormData, originalName?: string) => {
|
|
276
|
+
if (originalName) {
|
|
277
|
+
await fetch(`/api/agents/${originalName}`, {
|
|
278
|
+
method: 'PUT',
|
|
279
|
+
headers: { 'Content-Type': 'application/json' },
|
|
280
|
+
body: JSON.stringify(data),
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
await fetch('/api/agents', {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'Content-Type': 'application/json' },
|
|
286
|
+
body: JSON.stringify(data),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
setShowForm(false);
|
|
290
|
+
setEditingAgent(undefined);
|
|
291
|
+
onAgentsChange();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const handleDeleteAgent = async (agentName: string) => {
|
|
295
|
+
await fetch(`/api/agents/${agentName}`, { method: 'DELETE' });
|
|
296
|
+
onAgentsChange();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const allAgentNames = new Set([
|
|
300
|
+
...Object.keys(assignments.agents),
|
|
301
|
+
...agents.map((a) => a.name),
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
// Build unified skill list: merge assigned skills + discovered skills by qualifiedName
|
|
305
|
+
const allSkills = useMemo(() => {
|
|
306
|
+
const map = new Map<string, SkillInfo>();
|
|
307
|
+
// Discovered skills
|
|
308
|
+
for (const s of skills) map.set(s.qualifiedName, s);
|
|
309
|
+
// Skills in assignments that weren't discovered (show them anyway)
|
|
310
|
+
for (const key of Object.keys(assignments.skills)) {
|
|
311
|
+
if (!map.has(key)) {
|
|
312
|
+
map.set(key, { name: key.includes(':') ? key.split(':').pop()! : key, qualifiedName: key, description: '' });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return [...map.values()];
|
|
316
|
+
}, [skills, assignments.skills]);
|
|
317
|
+
|
|
318
|
+
// Filter by search
|
|
319
|
+
const filteredSkills = useMemo(() => {
|
|
320
|
+
const q = skillSearch.toLowerCase();
|
|
321
|
+
return q ? allSkills.filter(s => s.qualifiedName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)) : allSkills;
|
|
322
|
+
}, [allSkills, skillSearch]);
|
|
323
|
+
|
|
324
|
+
// Group by namespace
|
|
325
|
+
const skillGroups = useMemo(() => {
|
|
326
|
+
const groups = new Map<string, SkillInfo[]>();
|
|
327
|
+
for (const s of filteredSkills) {
|
|
328
|
+
const ns = s.namespace ?? '(user)';
|
|
329
|
+
if (!groups.has(ns)) groups.set(ns, []);
|
|
330
|
+
groups.get(ns)!.push(s);
|
|
331
|
+
}
|
|
332
|
+
return [...groups.entries()].sort(([a], [b]) => {
|
|
333
|
+
if (a === '(user)') return -1;
|
|
334
|
+
if (b === '(user)') return 1;
|
|
335
|
+
return a.localeCompare(b);
|
|
336
|
+
});
|
|
337
|
+
}, [filteredSkills]);
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="flex flex-col overflow-hidden">
|
|
341
|
+
{/* Tab bar header */}
|
|
342
|
+
<div className="shrink-0 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
|
|
343
|
+
<div className="flex items-stretch">
|
|
344
|
+
{(['agents', 'skills'] as const).map((view) => (
|
|
345
|
+
<button
|
|
346
|
+
key={view}
|
|
347
|
+
data-sf-hover
|
|
348
|
+
data-no-ui-sound
|
|
349
|
+
onClick={() => {
|
|
350
|
+
if (activeView !== view) {
|
|
351
|
+
playUISound('pageChange', 0.25);
|
|
352
|
+
setActiveView(view);
|
|
353
|
+
}
|
|
354
|
+
}}
|
|
355
|
+
className="flex-1 py-2.5 text-[10px] sf-heading font-semibold uppercase tracking-widest transition-all"
|
|
356
|
+
style={{
|
|
357
|
+
color: activeView === view ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.35)',
|
|
358
|
+
borderBottom: `2px solid ${activeView === view ? 'var(--sf-cyan)' : 'transparent'}`,
|
|
359
|
+
backgroundColor: activeView === view ? 'rgba(0,229,255,0.04)' : 'transparent',
|
|
360
|
+
}}
|
|
361
|
+
>
|
|
362
|
+
{view === 'agents' ? 'AGENTS' : 'SKILLS'}
|
|
363
|
+
</button>
|
|
364
|
+
))}
|
|
365
|
+
{activeView === 'agents' && (
|
|
366
|
+
<button
|
|
367
|
+
onClick={() => { setShowForm(!showForm); setEditingAgent(undefined); }}
|
|
368
|
+
className="text-[10px] px-3 sf-heading uppercase tracking-wider transition-all"
|
|
369
|
+
style={{
|
|
370
|
+
borderLeft: '1px solid var(--sf-border)',
|
|
371
|
+
color: showForm ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
|
|
372
|
+
}}
|
|
373
|
+
>
|
|
374
|
+
{showForm ? '✕' : '+'}
|
|
375
|
+
</button>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<div className="flex-1 overflow-y-auto p-3">
|
|
381
|
+
{activeView === 'agents' && (
|
|
382
|
+
<>
|
|
383
|
+
{/* Add/Edit form */}
|
|
384
|
+
{(showForm || editingAgent) && (
|
|
385
|
+
<AgentForm
|
|
386
|
+
initial={editingAgent}
|
|
387
|
+
onSave={handleSaveAgent}
|
|
388
|
+
onCancel={() => { setShowForm(false); setEditingAgent(undefined); }}
|
|
389
|
+
/>
|
|
390
|
+
)}
|
|
391
|
+
|
|
392
|
+
{/* Global override */}
|
|
393
|
+
<AgentRow
|
|
394
|
+
scope="global"
|
|
395
|
+
label="GLOBAL OVERRIDE"
|
|
396
|
+
isGlobal
|
|
397
|
+
hooks={assignments.global}
|
|
398
|
+
onClear={clearGlobalHook}
|
|
399
|
+
onPreview={onPreview}
|
|
400
|
+
selectMode={selectMode}
|
|
401
|
+
onSlotSelect={onSlotSelect}
|
|
402
|
+
/>
|
|
403
|
+
|
|
404
|
+
{/* Per-agent rows */}
|
|
405
|
+
{[...allAgentNames].map((name) => {
|
|
406
|
+
const config = assignments.agents[name] ?? { enabled: true, hooks: {} };
|
|
407
|
+
const agentInfo = agents.find((a) => a.name === name);
|
|
408
|
+
return (
|
|
409
|
+
<AgentRow
|
|
410
|
+
key={name}
|
|
411
|
+
scope={name}
|
|
412
|
+
label={name}
|
|
413
|
+
hooks={config.hooks}
|
|
414
|
+
enabled={config.enabled}
|
|
415
|
+
agentInfo={agentInfo}
|
|
416
|
+
onToggle={() => toggleAgent(name)}
|
|
417
|
+
onClear={(event) => clearAgentHook(name, event)}
|
|
418
|
+
onPreview={onPreview}
|
|
419
|
+
onEdit={() => { setEditingAgent(agentInfo); setShowForm(false); }}
|
|
420
|
+
onDelete={() => handleDeleteAgent(name)}
|
|
421
|
+
selectMode={selectMode}
|
|
422
|
+
onSlotSelect={onSlotSelect}
|
|
423
|
+
/>
|
|
424
|
+
);
|
|
425
|
+
})}
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{activeView === 'skills' && (
|
|
430
|
+
<>
|
|
431
|
+
{/* Search */}
|
|
432
|
+
<input
|
|
433
|
+
type="text"
|
|
434
|
+
placeholder="FILTER SKILLS..."
|
|
435
|
+
value={skillSearch}
|
|
436
|
+
onChange={e => setSkillSearch(e.target.value)}
|
|
437
|
+
className="w-full text-[10px] px-2 py-1 mb-3 bg-transparent outline-none sf-mono"
|
|
438
|
+
style={{
|
|
439
|
+
border: `1px solid ${skillSearch ? 'var(--sf-blue)' : 'var(--sf-border)'}`,
|
|
440
|
+
color: 'rgba(255,255,255,0.6)',
|
|
441
|
+
caretColor: 'var(--sf-cyan)',
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
|
|
445
|
+
{/* Grouped by namespace */}
|
|
446
|
+
{skillGroups.map(([ns, nsSkills]) => {
|
|
447
|
+
const isCollapsed = collapsedNs.has(ns);
|
|
448
|
+
const assignedCount = nsSkills.filter(s => assignments.skills[s.qualifiedName]?.hooks.PreToolUse || assignments.skills[s.qualifiedName]?.hooks.PostToolUse).length;
|
|
449
|
+
return (
|
|
450
|
+
<div key={ns} className="mb-1">
|
|
451
|
+
{/* Namespace header */}
|
|
452
|
+
<div
|
|
453
|
+
data-sf-hover
|
|
454
|
+
data-no-ui-sound
|
|
455
|
+
className="flex items-center justify-between px-2 py-1 cursor-pointer transition-all"
|
|
456
|
+
style={{ backgroundColor: 'rgba(0,168,255,0.05)', borderBottom: '1px solid rgba(0,168,255,0.1)' }}
|
|
457
|
+
onClick={() => {
|
|
458
|
+
playUISound('toggle', 0.3);
|
|
459
|
+
setCollapsedNs(prev => {
|
|
460
|
+
const next = new Set(prev);
|
|
461
|
+
if (next.has(ns)) next.delete(ns); else next.add(ns);
|
|
462
|
+
return next;
|
|
463
|
+
});
|
|
464
|
+
}}
|
|
465
|
+
>
|
|
466
|
+
<div className="flex items-center gap-1.5">
|
|
467
|
+
<span className="text-[9px] opacity-40">{isCollapsed ? '▸' : '▾'}</span>
|
|
468
|
+
<span className="sf-heading text-[9px] uppercase tracking-wider" style={{ color: 'var(--sf-blue)' }}>
|
|
469
|
+
{ns}
|
|
470
|
+
</span>
|
|
471
|
+
</div>
|
|
472
|
+
<span className="text-[9px] opacity-40">
|
|
473
|
+
{assignedCount > 0 && <span style={{ color: 'var(--sf-blue)', marginRight: 4 }}>{assignedCount}✦</span>}
|
|
474
|
+
{nsSkills.length}
|
|
475
|
+
</span>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
{!isCollapsed && nsSkills.sort((a, b) => a.name.localeCompare(b.name)).map((s) => {
|
|
479
|
+
const config = assignments.skills[s.qualifiedName] ?? { enabled: true, hooks: {} };
|
|
480
|
+
return (
|
|
481
|
+
<SkillRow
|
|
482
|
+
key={s.qualifiedName}
|
|
483
|
+
skill={s}
|
|
484
|
+
hooks={config.hooks}
|
|
485
|
+
enabled={config.enabled}
|
|
486
|
+
onToggle={() => toggleSkill(s.qualifiedName)}
|
|
487
|
+
onClear={(event) => clearSkillHook(s.qualifiedName, event)}
|
|
488
|
+
onPreview={onPreview}
|
|
489
|
+
selectMode={selectMode}
|
|
490
|
+
onSlotSelect={onSlotSelect}
|
|
491
|
+
/>
|
|
492
|
+
);
|
|
493
|
+
})}
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
})}
|
|
497
|
+
</>
|
|
498
|
+
)}
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|