@geminilight/mindos 0.6.29 → 0.6.30
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 +10 -4
- package/app/app/api/acp/config/route.ts +82 -0
- package/app/app/api/acp/detect/route.ts +71 -48
- package/app/app/api/acp/install/route.ts +51 -0
- package/app/app/api/acp/session/route.ts +141 -11
- package/app/app/api/ask/route.ts +116 -13
- package/app/app/api/workflows/route.ts +156 -0
- package/app/app/page.tsx +7 -2
- package/app/components/ActivityBar.tsx +12 -4
- package/app/components/AskModal.tsx +4 -1
- package/app/components/FileTree.tsx +21 -10
- package/app/components/HomeContent.tsx +1 -0
- package/app/components/Panel.tsx +1 -0
- package/app/components/RightAskPanel.tsx +5 -1
- package/app/components/SidebarLayout.tsx +6 -0
- package/app/components/agents/AgentDetailContent.tsx +263 -47
- package/app/components/agents/AgentsContentPage.tsx +11 -0
- package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
- package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
- package/app/components/agents/agents-content-model.ts +2 -2
- package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
- package/app/components/ask/AskContent.tsx +197 -239
- package/app/components/ask/FileChip.tsx +82 -17
- package/app/components/ask/MentionPopover.tsx +21 -3
- package/app/components/ask/MessageList.tsx +30 -9
- package/app/components/ask/SlashCommandPopover.tsx +21 -3
- package/app/components/panels/AgentsPanel.tsx +1 -0
- package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
- package/app/components/panels/WorkflowsPanel.tsx +206 -0
- package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
- package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
- package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
- package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
- package/app/components/renderers/workflow-yaml/execution.ts +177 -0
- package/app/components/renderers/workflow-yaml/index.ts +6 -0
- package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
- package/app/components/renderers/workflow-yaml/parser.ts +172 -0
- package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
- package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
- package/app/components/renderers/workflow-yaml/types.ts +46 -0
- package/app/hooks/useAcpConfig.ts +96 -0
- package/app/hooks/useAcpDetection.ts +69 -14
- package/app/hooks/useAcpRegistry.ts +46 -11
- package/app/hooks/useAskModal.ts +12 -5
- package/app/hooks/useAskPanel.ts +8 -5
- package/app/hooks/useAskSession.ts +19 -2
- package/app/hooks/useImageUpload.ts +152 -0
- package/app/lib/acp/acp-tools.ts +3 -1
- package/app/lib/acp/agent-descriptors.ts +274 -0
- package/app/lib/acp/bridge.ts +6 -0
- package/app/lib/acp/index.ts +20 -4
- package/app/lib/acp/registry.ts +74 -7
- package/app/lib/acp/session.ts +481 -28
- package/app/lib/acp/subprocess.ts +307 -21
- package/app/lib/acp/types.ts +158 -20
- package/app/lib/agent/model.ts +18 -3
- package/app/lib/agent/to-agent-messages.ts +25 -2
- package/app/lib/i18n/modules/knowledge.ts +4 -0
- package/app/lib/i18n/modules/navigation.ts +2 -0
- package/app/lib/i18n/modules/panels.ts +146 -2
- package/app/lib/pi-integration/skills.ts +21 -6
- package/app/lib/renderers/index.ts +2 -2
- package/app/lib/settings.ts +10 -0
- package/app/lib/types.ts +12 -1
- package/app/next-env.d.ts +1 -1
- package/app/package.json +3 -1
- package/package.json +1 -1
- package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
- package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
- package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
- package/app/components/renderers/workflow/manifest.ts +0 -14
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { ChevronDown, X, Search, FileText, FolderOpen, Folder, ChevronRight } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
// ─── Dropdown Shell ───────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function Dropdown({ trigger, children, open, onClose }: {
|
|
9
|
+
trigger: React.ReactNode; children: React.ReactNode; open: boolean; onClose: () => void;
|
|
10
|
+
}) {
|
|
11
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!open) return;
|
|
14
|
+
const handler = (e: MouseEvent) => {
|
|
15
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
16
|
+
};
|
|
17
|
+
document.addEventListener('mousedown', handler);
|
|
18
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
19
|
+
}, [open, onClose]);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div ref={ref} className="relative">
|
|
23
|
+
{trigger}
|
|
24
|
+
{open && (
|
|
25
|
+
<div className="absolute top-full left-0 mt-1 w-full min-w-[220px] max-h-[260px] overflow-y-auto bg-card border border-border rounded-lg shadow-lg z-50">
|
|
26
|
+
{children}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Agent Selector ───────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const KNOWN_AGENTS = ['cursor', 'claude-code', 'mindos', 'gemini'];
|
|
36
|
+
|
|
37
|
+
export function AgentSelector({ value, onChange }: { value?: string; onChange: (v: string | undefined) => void }) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const [custom, setCustom] = useState('');
|
|
40
|
+
|
|
41
|
+
const select = (v: string | undefined) => { onChange(v); setOpen(false); };
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Dropdown
|
|
45
|
+
open={open}
|
|
46
|
+
onClose={() => setOpen(false)}
|
|
47
|
+
trigger={
|
|
48
|
+
<button type="button" onClick={() => setOpen(v => !v)}
|
|
49
|
+
className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
|
|
50
|
+
<span className={value ? 'text-foreground' : 'text-muted-foreground'}>
|
|
51
|
+
{value || 'Select agent'}
|
|
52
|
+
</span>
|
|
53
|
+
<ChevronDown size={12} className="text-muted-foreground" />
|
|
54
|
+
</button>
|
|
55
|
+
}
|
|
56
|
+
>
|
|
57
|
+
<button onClick={() => select(undefined)}
|
|
58
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground'}`}>
|
|
59
|
+
(none)
|
|
60
|
+
</button>
|
|
61
|
+
{KNOWN_AGENTS.map(a => (
|
|
62
|
+
<button key={a} onClick={() => select(a)}
|
|
63
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${value === a ? 'text-[var(--amber)] font-medium' : 'text-foreground'}`}>
|
|
64
|
+
{a}
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
<div className="border-t border-border px-2.5 py-1.5">
|
|
68
|
+
<input type="text" value={custom} onChange={e => setCustom(e.target.value)}
|
|
69
|
+
onKeyDown={e => { if (e.key === 'Enter' && custom.trim()) { select(custom.trim()); setCustom(''); } }}
|
|
70
|
+
placeholder="Custom agent..."
|
|
71
|
+
className="w-full px-2 py-1 text-xs rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</Dropdown>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Model Selector ───────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const KNOWN_MODELS = [
|
|
81
|
+
{ group: 'Anthropic', models: ['claude-sonnet-4-6', 'claude-opus-4', 'claude-3.5-sonnet', 'claude-3.5-haiku'] },
|
|
82
|
+
{ group: 'OpenAI', models: ['gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'] },
|
|
83
|
+
{ group: 'Google', models: ['gemini-2.5-pro', 'gemini-2.5-flash'] },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
export function ModelSelector({ value, onChange }: { value?: string; onChange: (v: string | undefined) => void }) {
|
|
87
|
+
const [open, setOpen] = useState(false);
|
|
88
|
+
const [custom, setCustom] = useState('');
|
|
89
|
+
|
|
90
|
+
const select = (v: string | undefined) => { onChange(v); setOpen(false); };
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Dropdown
|
|
94
|
+
open={open}
|
|
95
|
+
onClose={() => setOpen(false)}
|
|
96
|
+
trigger={
|
|
97
|
+
<button type="button" onClick={() => setOpen(v => !v)}
|
|
98
|
+
className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
|
|
99
|
+
<span className={value ? 'text-foreground truncate' : 'text-muted-foreground'}>
|
|
100
|
+
{value || 'Default model'}
|
|
101
|
+
</span>
|
|
102
|
+
{value ? (
|
|
103
|
+
<X size={12} className="text-muted-foreground shrink-0 hover:text-foreground cursor-pointer" onClick={e => { e.stopPropagation(); select(undefined); }} />
|
|
104
|
+
) : (
|
|
105
|
+
<ChevronDown size={12} className="text-muted-foreground shrink-0" />
|
|
106
|
+
)}
|
|
107
|
+
</button>
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
<button onClick={() => select(undefined)}
|
|
111
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground'}`}>
|
|
112
|
+
(default)
|
|
113
|
+
</button>
|
|
114
|
+
{KNOWN_MODELS.map(g => (
|
|
115
|
+
<div key={g.group}>
|
|
116
|
+
<div className="px-3 py-1 text-2xs font-semibold text-muted-foreground/60 uppercase tracking-wide bg-muted/30">{g.group}</div>
|
|
117
|
+
{g.models.map(m => (
|
|
118
|
+
<button key={m} onClick={() => select(m)}
|
|
119
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${value === m ? 'text-[var(--amber)] font-medium' : 'text-foreground'}`}>
|
|
120
|
+
{m}
|
|
121
|
+
</button>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
<div className="border-t border-border px-2.5 py-1.5">
|
|
126
|
+
<input type="text" value={custom} onChange={e => setCustom(e.target.value)}
|
|
127
|
+
onKeyDown={e => { if (e.key === 'Enter' && custom.trim()) { select(custom.trim()); setCustom(''); } }}
|
|
128
|
+
placeholder="Custom model ID..."
|
|
129
|
+
className="w-full px-2 py-1 text-xs rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
</Dropdown>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Skills Multi-Select ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
interface SkillInfo { name: string; description?: string }
|
|
139
|
+
|
|
140
|
+
/** Cache skill list globally so all SkillsSelector instances share it */
|
|
141
|
+
let _skillsCache: SkillInfo[] | null = null;
|
|
142
|
+
let _skillsFetching = false;
|
|
143
|
+
const _skillsListeners: Array<(skills: SkillInfo[]) => void> = [];
|
|
144
|
+
|
|
145
|
+
function fetchSkillsOnce(cb: (skills: SkillInfo[]) => void) {
|
|
146
|
+
if (_skillsCache) { cb(_skillsCache); return; }
|
|
147
|
+
_skillsListeners.push(cb);
|
|
148
|
+
if (_skillsFetching) return;
|
|
149
|
+
_skillsFetching = true;
|
|
150
|
+
fetch('/api/skills').then(r => r.json()).then(data => {
|
|
151
|
+
_skillsCache = (data.skills ?? []).map((s: { name: string; description?: string }) => ({
|
|
152
|
+
name: s.name, description: s.description,
|
|
153
|
+
}));
|
|
154
|
+
_skillsListeners.forEach(fn => fn(_skillsCache!));
|
|
155
|
+
_skillsListeners.length = 0;
|
|
156
|
+
}).catch(() => {
|
|
157
|
+
_skillsCache = [];
|
|
158
|
+
_skillsListeners.forEach(fn => fn([]));
|
|
159
|
+
_skillsListeners.length = 0;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function SkillsSelector({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
|
164
|
+
const [open, setOpen] = useState(false);
|
|
165
|
+
const [skills, setSkills] = useState<SkillInfo[]>(_skillsCache ?? []);
|
|
166
|
+
const [query, setQuery] = useState('');
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
fetchSkillsOnce(setSkills);
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
const filtered = query
|
|
173
|
+
? skills.filter(s => s.name.toLowerCase().includes(query.toLowerCase()))
|
|
174
|
+
: skills;
|
|
175
|
+
|
|
176
|
+
const toggle = (name: string) => {
|
|
177
|
+
if (value.includes(name)) {
|
|
178
|
+
onChange(value.filter(v => v !== name));
|
|
179
|
+
} else {
|
|
180
|
+
onChange([...value, name]);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const remove = (name: string) => onChange(value.filter(v => v !== name));
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div>
|
|
188
|
+
{/* Selected chips */}
|
|
189
|
+
{value.length > 0 && (
|
|
190
|
+
<div className="flex flex-wrap gap-1 mb-1.5">
|
|
191
|
+
{value.map(s => (
|
|
192
|
+
<span key={s} className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-md text-2xs bg-[var(--amber)]/10 text-[var(--amber)] border border-[var(--amber)]/20">
|
|
193
|
+
{s}
|
|
194
|
+
<button onClick={() => remove(s)} className="p-0.5 rounded hover:bg-[var(--amber)]/20 transition-colors">
|
|
195
|
+
<X size={10} />
|
|
196
|
+
</button>
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* Dropdown trigger */}
|
|
203
|
+
<Dropdown
|
|
204
|
+
open={open}
|
|
205
|
+
onClose={() => { setOpen(false); setQuery(''); }}
|
|
206
|
+
trigger={
|
|
207
|
+
<button type="button" onClick={() => setOpen(v => !v)}
|
|
208
|
+
className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
|
|
209
|
+
<span className="text-muted-foreground">
|
|
210
|
+
{value.length === 0 ? 'Add skills...' : `${value.length} selected`}
|
|
211
|
+
</span>
|
|
212
|
+
<ChevronDown size={12} className="text-muted-foreground" />
|
|
213
|
+
</button>
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{/* Search */}
|
|
217
|
+
<div className="sticky top-0 bg-card border-b border-border px-2.5 py-1.5">
|
|
218
|
+
<div className="flex items-center gap-1.5 px-2 py-1 rounded border border-border bg-background">
|
|
219
|
+
<Search size={11} className="text-muted-foreground shrink-0" />
|
|
220
|
+
<input type="text" value={query} onChange={e => setQuery(e.target.value)} autoFocus
|
|
221
|
+
placeholder="Search skills..."
|
|
222
|
+
className="flex-1 text-xs bg-transparent text-foreground placeholder:text-muted-foreground focus:outline-none"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Skill list with checkboxes */}
|
|
228
|
+
{filtered.slice(0, 60).map(s => {
|
|
229
|
+
const checked = value.includes(s.name);
|
|
230
|
+
return (
|
|
231
|
+
<button key={s.name} onClick={() => toggle(s.name)}
|
|
232
|
+
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2 ${checked ? 'bg-[var(--amber)]/5' : ''}`}>
|
|
233
|
+
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
|
234
|
+
checked ? 'bg-[var(--amber)] border-[var(--amber)] text-white' : 'border-border'
|
|
235
|
+
}`}>
|
|
236
|
+
{checked && <span className="text-[9px]">✓</span>}
|
|
237
|
+
</span>
|
|
238
|
+
<div className="min-w-0 flex-1">
|
|
239
|
+
<span className="block truncate">{s.name}</span>
|
|
240
|
+
{s.description && <span className="block text-2xs text-muted-foreground truncate mt-0.5">{s.description}</span>}
|
|
241
|
+
</div>
|
|
242
|
+
</button>
|
|
243
|
+
);
|
|
244
|
+
})}
|
|
245
|
+
{skills.length === 0 && <div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>}
|
|
246
|
+
{skills.length > 0 && filtered.length === 0 && (
|
|
247
|
+
<div className="px-3 py-2 text-xs text-muted-foreground">No skills found</div>
|
|
248
|
+
)}
|
|
249
|
+
</Dropdown>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Context Selector (file drop zone) ───────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export function ContextSelector({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
|
257
|
+
const [dragOver, setDragOver] = useState(false);
|
|
258
|
+
const [manualInput, setManualInput] = useState('');
|
|
259
|
+
|
|
260
|
+
const addPath = useCallback((path: string) => {
|
|
261
|
+
const trimmed = path.trim();
|
|
262
|
+
if (trimmed && !value.includes(trimmed)) {
|
|
263
|
+
onChange([...value, trimmed]);
|
|
264
|
+
}
|
|
265
|
+
}, [value, onChange]);
|
|
266
|
+
|
|
267
|
+
const remove = (path: string) => onChange(value.filter(v => v !== path));
|
|
268
|
+
|
|
269
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
setDragOver(false);
|
|
272
|
+
// FileTree drag format: text/mindos-path
|
|
273
|
+
const mindosPath = e.dataTransfer.getData('text/mindos-path');
|
|
274
|
+
if (mindosPath) {
|
|
275
|
+
addPath(mindosPath);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Fallback: plain text
|
|
279
|
+
const text = e.dataTransfer.getData('text/plain');
|
|
280
|
+
if (text) addPath(text);
|
|
281
|
+
}, [addPath]);
|
|
282
|
+
|
|
283
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
286
|
+
setDragOver(true);
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
const handleDragLeave = useCallback(() => setDragOver(false), []);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div>
|
|
293
|
+
{/* Selected files */}
|
|
294
|
+
{value.length > 0 && (
|
|
295
|
+
<div className="flex flex-col gap-1 mb-1.5">
|
|
296
|
+
{value.map(p => (
|
|
297
|
+
<div key={p} className="flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-md text-2xs bg-muted/60 border border-border group">
|
|
298
|
+
<FileText size={11} className="text-muted-foreground shrink-0" />
|
|
299
|
+
<span className="text-foreground truncate flex-1" title={p}>{p}</span>
|
|
300
|
+
<button onClick={() => remove(p)} className="p-0.5 rounded hover:bg-[var(--error)]/10 text-muted-foreground hover:text-[var(--error)] opacity-0 group-hover:opacity-100 transition-all">
|
|
301
|
+
<X size={10} />
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{/* Drop zone */}
|
|
309
|
+
<div
|
|
310
|
+
onDrop={handleDrop}
|
|
311
|
+
onDragOver={handleDragOver}
|
|
312
|
+
onDragLeave={handleDragLeave}
|
|
313
|
+
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border-2 border-dashed transition-colors ${
|
|
314
|
+
dragOver
|
|
315
|
+
? 'border-[var(--amber)] bg-[var(--amber)]/5'
|
|
316
|
+
: 'border-border hover:border-muted-foreground/30'
|
|
317
|
+
}`}
|
|
318
|
+
>
|
|
319
|
+
<FolderOpen size={14} className={`shrink-0 ${dragOver ? 'text-[var(--amber)]' : 'text-muted-foreground/40'}`} />
|
|
320
|
+
<span className="text-2xs text-muted-foreground">
|
|
321
|
+
Drag files here from the sidebar
|
|
322
|
+
</span>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
{/* Manual input */}
|
|
326
|
+
<div className="mt-1.5">
|
|
327
|
+
<input type="text" value={manualInput} onChange={e => setManualInput(e.target.value)}
|
|
328
|
+
onKeyDown={e => { if (e.key === 'Enter' && manualInput.trim()) { addPath(manualInput.trim()); setManualInput(''); } }}
|
|
329
|
+
placeholder="Or type a file path..."
|
|
330
|
+
className="w-full px-2.5 py-1 text-2xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── Directory Picker ────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
async function fetchDirs(dirPath: string): Promise<string[]> {
|
|
340
|
+
try {
|
|
341
|
+
const res = await fetch('/api/setup/ls', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'Content-Type': 'application/json' },
|
|
344
|
+
body: JSON.stringify({ path: dirPath || '~' }),
|
|
345
|
+
});
|
|
346
|
+
if (!res.ok) return [];
|
|
347
|
+
const data = await res.json();
|
|
348
|
+
return data.dirs ?? [];
|
|
349
|
+
} catch { return []; }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Get parent directory from a path (supports / and \) */
|
|
353
|
+
function getParentDir(p: string): string {
|
|
354
|
+
const trimmed = p.trim();
|
|
355
|
+
if (!trimmed) return '';
|
|
356
|
+
if (trimmed.endsWith('/') || trimmed.endsWith('\\')) return trimmed;
|
|
357
|
+
const lastSlash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
358
|
+
return lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : '';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function DirPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
362
|
+
// ── Autocomplete state ──
|
|
363
|
+
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
364
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
365
|
+
const [activeIdx, setActiveIdx] = useState(-1);
|
|
366
|
+
const justSelectedRef = useRef(false);
|
|
367
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
368
|
+
|
|
369
|
+
// ── Browse dialog state ──
|
|
370
|
+
const [browseOpen, setBrowseOpen] = useState(false);
|
|
371
|
+
const [browsePath, setBrowsePath] = useState(value || '~');
|
|
372
|
+
const [browseDirs, setBrowseDirs] = useState<string[]>([]);
|
|
373
|
+
const [browseLoading, setBrowseLoading] = useState(false);
|
|
374
|
+
|
|
375
|
+
// Debounced autocomplete on typing
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
if (justSelectedRef.current) { justSelectedRef.current = false; return; }
|
|
378
|
+
if (!value.trim()) { setSuggestions([]); setShowSuggestions(false); return; }
|
|
379
|
+
const timer = setTimeout(async () => {
|
|
380
|
+
const parent = getParentDir(value) || '~';
|
|
381
|
+
const dirs = await fetchDirs(parent);
|
|
382
|
+
if (!dirs.length) { setSuggestions([]); setShowSuggestions(false); return; }
|
|
383
|
+
const sep = parent.includes('\\') ? '\\' : '/';
|
|
384
|
+
const parentNorm = (parent.endsWith('/') || parent.endsWith('\\')) ? parent : parent + sep;
|
|
385
|
+
const full = dirs.map(d => parentNorm + d);
|
|
386
|
+
const endsWithSep = value.endsWith('/') || value.endsWith('\\');
|
|
387
|
+
const filtered = endsWithSep ? full : full.filter(f => f.startsWith(value.trim()));
|
|
388
|
+
setSuggestions(filtered.slice(0, 15));
|
|
389
|
+
setShowSuggestions(filtered.length > 0);
|
|
390
|
+
setActiveIdx(-1);
|
|
391
|
+
}, 300);
|
|
392
|
+
return () => clearTimeout(timer);
|
|
393
|
+
}, [value]);
|
|
394
|
+
|
|
395
|
+
const hideSuggestions = () => { setSuggestions([]); setShowSuggestions(false); setActiveIdx(-1); };
|
|
396
|
+
|
|
397
|
+
const selectSuggestion = (val: string) => {
|
|
398
|
+
justSelectedRef.current = true;
|
|
399
|
+
onChange(val);
|
|
400
|
+
hideSuggestions();
|
|
401
|
+
inputRef.current?.focus();
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
405
|
+
if (!showSuggestions || suggestions.length === 0) return;
|
|
406
|
+
if (e.key === 'ArrowDown') {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
setActiveIdx(i => Math.min(i + 1, suggestions.length - 1));
|
|
409
|
+
} else if (e.key === 'ArrowUp') {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
setActiveIdx(i => Math.max(i - 1, -1));
|
|
412
|
+
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
selectSuggestion(suggestions[activeIdx]);
|
|
415
|
+
} else if (e.key === 'Escape') {
|
|
416
|
+
hideSuggestions();
|
|
417
|
+
} else if (e.key === 'Tab' && activeIdx >= 0) {
|
|
418
|
+
e.preventDefault();
|
|
419
|
+
selectSuggestion(suggestions[activeIdx]);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Browse dialog
|
|
424
|
+
const loadBrowse = useCallback(async (dirPath: string) => {
|
|
425
|
+
setBrowseLoading(true);
|
|
426
|
+
const result = await fetchDirs(dirPath);
|
|
427
|
+
setBrowseDirs(result);
|
|
428
|
+
setBrowseLoading(false);
|
|
429
|
+
}, []);
|
|
430
|
+
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (browseOpen) loadBrowse(browsePath);
|
|
433
|
+
}, [browseOpen, browsePath, loadBrowse]);
|
|
434
|
+
|
|
435
|
+
const navigateInto = (subDir: string) => {
|
|
436
|
+
const next = browsePath === '~' ? `~/${subDir}` : `${browsePath.replace(/\/+$/, '')}/${subDir}`;
|
|
437
|
+
setBrowsePath(next);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const selectBrowsed = () => { onChange(browsePath); setBrowseOpen(false); };
|
|
441
|
+
|
|
442
|
+
const goUp = () => {
|
|
443
|
+
const parts = browsePath.split('/');
|
|
444
|
+
if (parts.length <= 1) return;
|
|
445
|
+
parts.pop();
|
|
446
|
+
setBrowsePath(parts.join('/') || '~');
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<div className="relative flex gap-1.5">
|
|
451
|
+
{/* Input with autocomplete */}
|
|
452
|
+
<div className="relative flex-1">
|
|
453
|
+
<input
|
|
454
|
+
ref={inputRef}
|
|
455
|
+
type="text"
|
|
456
|
+
value={value}
|
|
457
|
+
onChange={e => { onChange(e.target.value); setShowSuggestions(true); }}
|
|
458
|
+
onKeyDown={handleKeyDown}
|
|
459
|
+
onBlur={() => setTimeout(hideSuggestions, 150)}
|
|
460
|
+
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
|
461
|
+
placeholder="~/projects/my-app"
|
|
462
|
+
className="w-full px-2.5 py-1.5 text-xs rounded-lg border border-border bg-background text-foreground font-mono placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
463
|
+
/>
|
|
464
|
+
{showSuggestions && suggestions.length > 0 && (
|
|
465
|
+
<div role="listbox"
|
|
466
|
+
className="absolute z-50 left-0 right-0 top-full mt-1 rounded-lg border border-border bg-card shadow-lg overflow-auto max-h-[200px]">
|
|
467
|
+
{suggestions.map((s, i) => (
|
|
468
|
+
<button key={s} type="button" role="option" aria-selected={i === activeIdx}
|
|
469
|
+
onMouseDown={() => selectSuggestion(s)}
|
|
470
|
+
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors flex items-center gap-1.5 ${
|
|
471
|
+
i === activeIdx ? 'bg-muted text-foreground' : 'text-foreground hover:bg-muted/50'
|
|
472
|
+
}`}>
|
|
473
|
+
<Folder size={11} className="text-[var(--amber)] shrink-0" />
|
|
474
|
+
<span className="truncate">{s}</span>
|
|
475
|
+
</button>
|
|
476
|
+
))}
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
{/* Browse button */}
|
|
482
|
+
<Dropdown
|
|
483
|
+
open={browseOpen}
|
|
484
|
+
onClose={() => setBrowseOpen(false)}
|
|
485
|
+
trigger={
|
|
486
|
+
<button type="button" onClick={() => { setBrowsePath(value || '~'); setBrowseOpen(v => !v); }}
|
|
487
|
+
className="px-2 py-1.5 rounded-lg border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
488
|
+
title="Browse directories"
|
|
489
|
+
>
|
|
490
|
+
<FolderOpen size={13} />
|
|
491
|
+
</button>
|
|
492
|
+
}
|
|
493
|
+
>
|
|
494
|
+
<div className="sticky top-0 bg-card border-b border-border px-2.5 py-2 flex items-center gap-1.5">
|
|
495
|
+
<span className="text-2xs font-mono text-muted-foreground truncate flex-1" title={browsePath}>{browsePath}</span>
|
|
496
|
+
<button onClick={selectBrowsed}
|
|
497
|
+
className="px-2 py-0.5 text-2xs rounded font-medium bg-[var(--amber)] text-[var(--amber-foreground)] shrink-0">
|
|
498
|
+
Select
|
|
499
|
+
</button>
|
|
500
|
+
</div>
|
|
501
|
+
{browsePath !== '~' && (
|
|
502
|
+
<button onClick={goUp}
|
|
503
|
+
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5">
|
|
504
|
+
<Folder size={12} className="shrink-0" /> ..
|
|
505
|
+
</button>
|
|
506
|
+
)}
|
|
507
|
+
{browseLoading && <div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>}
|
|
508
|
+
{!browseLoading && browseDirs.length === 0 && (
|
|
509
|
+
<div className="px-3 py-2 text-xs text-muted-foreground">No subdirectories</div>
|
|
510
|
+
)}
|
|
511
|
+
{browseDirs.map(d => (
|
|
512
|
+
<button key={d} onClick={() => navigateInto(d)}
|
|
513
|
+
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors text-foreground flex items-center gap-1.5">
|
|
514
|
+
<Folder size={12} className="text-[var(--amber)] shrink-0" />
|
|
515
|
+
<span className="truncate flex-1">{d}</span>
|
|
516
|
+
<ChevronRight size={10} className="text-muted-foreground/40 shrink-0" />
|
|
517
|
+
</button>
|
|
518
|
+
))}
|
|
519
|
+
</Dropdown>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import YAML from 'js-yaml';
|
|
2
|
+
import type { WorkflowYaml } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Serialize a WorkflowYaml object to a YAML string.
|
|
6
|
+
* Strips undefined/empty optional fields to keep output clean.
|
|
7
|
+
*/
|
|
8
|
+
export function serializeWorkflowYaml(workflow: WorkflowYaml): string {
|
|
9
|
+
const clean: Record<string, unknown> = {
|
|
10
|
+
title: workflow.title,
|
|
11
|
+
};
|
|
12
|
+
if (workflow.description) clean.description = workflow.description;
|
|
13
|
+
if (workflow.workDir) clean.workDir = workflow.workDir;
|
|
14
|
+
if (workflow.skills?.length) clean.skills = workflow.skills;
|
|
15
|
+
if (workflow.tools?.length) clean.tools = workflow.tools;
|
|
16
|
+
|
|
17
|
+
clean.steps = workflow.steps.map((step) => {
|
|
18
|
+
const s: Record<string, unknown> = {
|
|
19
|
+
id: step.id,
|
|
20
|
+
name: step.name,
|
|
21
|
+
};
|
|
22
|
+
if (step.description) s.description = step.description;
|
|
23
|
+
if (step.agent) s.agent = step.agent;
|
|
24
|
+
if (step.model) s.model = step.model;
|
|
25
|
+
if (step.skill) s.skill = step.skill;
|
|
26
|
+
if (step.skills?.length) s.skills = step.skills;
|
|
27
|
+
if (step.tools?.length) s.tools = step.tools;
|
|
28
|
+
if (step.context?.length) s.context = step.context;
|
|
29
|
+
s.prompt = step.prompt;
|
|
30
|
+
if (step.timeout) s.timeout = step.timeout;
|
|
31
|
+
return s;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return YAML.dump(clean, {
|
|
35
|
+
lineWidth: -1,
|
|
36
|
+
noRefs: true,
|
|
37
|
+
quotingType: '"',
|
|
38
|
+
forceQuotes: false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Generate a URL-safe step ID from a name */
|
|
43
|
+
export function generateStepId(name: string, existingIds: string[]): string {
|
|
44
|
+
const base = name
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
47
|
+
.replace(/^-|-$/g, '')
|
|
48
|
+
|| 'step';
|
|
49
|
+
let id = base;
|
|
50
|
+
let i = 2;
|
|
51
|
+
while (existingIds.includes(id)) {
|
|
52
|
+
id = `${base}-${i}`;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Types for YAML Workflow
|
|
2
|
+
|
|
3
|
+
export type StepStatus = 'pending' | 'running' | 'done' | 'error' | 'skipped';
|
|
4
|
+
|
|
5
|
+
export interface WorkflowStep {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
agent?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
skill?: string;
|
|
12
|
+
skills?: string[];
|
|
13
|
+
tools?: string[];
|
|
14
|
+
context?: string[];
|
|
15
|
+
prompt: string;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WorkflowYaml {
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
workDir?: string;
|
|
23
|
+
skills?: string[];
|
|
24
|
+
tools?: string[];
|
|
25
|
+
steps: WorkflowStep[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkflowStepRuntime extends WorkflowStep {
|
|
29
|
+
index: number;
|
|
30
|
+
status: StepStatus;
|
|
31
|
+
output: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
startedAt?: Date;
|
|
34
|
+
completedAt?: Date;
|
|
35
|
+
durationMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParseResult {
|
|
39
|
+
workflow: WorkflowYaml | null;
|
|
40
|
+
errors: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ValidationResult {
|
|
44
|
+
valid: boolean;
|
|
45
|
+
errors: string[];
|
|
46
|
+
}
|