@aion0/forge 0.4.3 → 0.4.5
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/RELEASE_NOTES.md +4 -4
- package/app/api/issue-scanner/route.ts +2 -2
- package/app/api/pipelines/route.ts +14 -0
- package/app/api/project-pipelines/route.ts +68 -0
- package/components/CodeViewer.tsx +11 -1
- package/components/Dashboard.tsx +10 -12
- package/components/DocsViewer.tsx +11 -1
- package/components/PipelineEditor.tsx +3 -1
- package/components/PipelineView.tsx +262 -129
- package/components/ProjectDetail.tsx +186 -233
- package/components/SessionView.tsx +9 -1
- package/components/SkillsPanel.tsx +9 -1
- package/hooks/useSidebarResize.ts +52 -0
- package/lib/help-docs/05-pipelines.md +22 -7
- package/lib/help-docs/09-issue-autofix.md +1 -1
- package/lib/init.ts +7 -1
- package/lib/issue-scanner.ts +2 -2
- package/lib/pipeline-scheduler.ts +239 -0
- package/lib/pipeline.ts +43 -87
- package/package.json +1 -1
- package/src/core/db/database.ts +24 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
|
4
|
+
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
4
5
|
|
|
5
6
|
const PipelineEditor = lazy(() => import('./PipelineEditor'));
|
|
6
7
|
|
|
@@ -8,6 +9,8 @@ interface WorkflowNode {
|
|
|
8
9
|
id: string;
|
|
9
10
|
project: string;
|
|
10
11
|
prompt: string;
|
|
12
|
+
mode?: 'claude' | 'shell';
|
|
13
|
+
branch?: string;
|
|
11
14
|
dependsOn: string[];
|
|
12
15
|
outputs: { name: string; extract: string }[];
|
|
13
16
|
routes: { condition: string; next: string }[];
|
|
@@ -62,25 +65,33 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
62
65
|
};
|
|
63
66
|
|
|
64
67
|
export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: string) => void }) {
|
|
68
|
+
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 256, minWidth: 140, maxWidth: 480 });
|
|
65
69
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
|
66
70
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
67
71
|
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
|
|
72
|
+
const [activeWorkflow, setActiveWorkflow] = useState<string | null>(null); // selected workflow in left panel
|
|
68
73
|
const [showCreate, setShowCreate] = useState(false);
|
|
69
74
|
const [selectedWorkflow, setSelectedWorkflow] = useState<string>('');
|
|
75
|
+
const [projects, setProjects] = useState<{ name: string; path: string }[]>([]);
|
|
70
76
|
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
71
77
|
const [creating, setCreating] = useState(false);
|
|
72
78
|
const [showEditor, setShowEditor] = useState(false);
|
|
73
79
|
const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
|
|
80
|
+
const [showImport, setShowImport] = useState(false);
|
|
81
|
+
const [importYaml, setImportYaml] = useState('');
|
|
74
82
|
|
|
75
83
|
const fetchData = useCallback(async () => {
|
|
76
|
-
const [pRes, wRes] = await Promise.all([
|
|
84
|
+
const [pRes, wRes, projRes] = await Promise.all([
|
|
77
85
|
fetch('/api/pipelines'),
|
|
78
86
|
fetch('/api/pipelines?type=workflows'),
|
|
87
|
+
fetch('/api/projects'),
|
|
79
88
|
]);
|
|
80
89
|
const pData = await pRes.json();
|
|
81
90
|
const wData = await wRes.json();
|
|
91
|
+
const projData = await projRes.json();
|
|
82
92
|
if (Array.isArray(pData)) setPipelines(pData);
|
|
83
93
|
if (Array.isArray(wData)) setWorkflows(wData);
|
|
94
|
+
if (Array.isArray(projData)) setProjects(projData.map((p: any) => ({ name: p.name, path: p.path })));
|
|
84
95
|
}, []);
|
|
85
96
|
|
|
86
97
|
useEffect(() => {
|
|
@@ -148,37 +159,62 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
148
159
|
|
|
149
160
|
return (
|
|
150
161
|
<div className="flex-1 flex min-h-0">
|
|
151
|
-
{/* Left —
|
|
152
|
-
<aside
|
|
153
|
-
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center
|
|
154
|
-
<span className="text-[11px] font-semibold text-[var(--text-primary)]">
|
|
155
|
-
<select
|
|
156
|
-
onChange={async (e) => {
|
|
157
|
-
const name = e.target.value;
|
|
158
|
-
if (!name) { setEditorYaml(undefined); setShowEditor(true); return; }
|
|
159
|
-
try {
|
|
160
|
-
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(name)}`);
|
|
161
|
-
const data = await res.json();
|
|
162
|
-
setEditorYaml(data.yaml || undefined);
|
|
163
|
-
} catch { setEditorYaml(undefined); }
|
|
164
|
-
setShowEditor(true);
|
|
165
|
-
e.target.value = '';
|
|
166
|
-
}}
|
|
167
|
-
className="text-[10px] px-1 py-0.5 rounded text-green-400 bg-transparent hover:bg-green-400/10 cursor-pointer"
|
|
168
|
-
defaultValue=""
|
|
169
|
-
>
|
|
170
|
-
<option value="">Editor ▾</option>
|
|
171
|
-
<option value="">+ New workflow</option>
|
|
172
|
-
{workflows.map(w => <option key={w.name} value={w.name}>{w.builtin ? '⚙ ' : ''}{w.name}</option>)}
|
|
173
|
-
</select>
|
|
162
|
+
{/* Left — Workflow list */}
|
|
163
|
+
<aside style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
|
|
164
|
+
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center gap-1.5">
|
|
165
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)] flex-1">Workflows</span>
|
|
174
166
|
<button
|
|
175
|
-
onClick={() =>
|
|
176
|
-
className=
|
|
177
|
-
>
|
|
178
|
-
|
|
179
|
-
|
|
167
|
+
onClick={() => setShowImport(v => !v)}
|
|
168
|
+
className="text-[9px] text-green-400 hover:underline"
|
|
169
|
+
>Import</button>
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => { setEditorYaml(undefined); setShowEditor(true); }}
|
|
172
|
+
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
173
|
+
>+ New</button>
|
|
180
174
|
</div>
|
|
181
175
|
|
|
176
|
+
{/* Import form */}
|
|
177
|
+
{showImport && (
|
|
178
|
+
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
179
|
+
<textarea
|
|
180
|
+
value={importYaml}
|
|
181
|
+
onChange={e => setImportYaml(e.target.value)}
|
|
182
|
+
placeholder="Paste YAML workflow here..."
|
|
183
|
+
className="w-full h-40 text-xs font-mono bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-2 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)]"
|
|
184
|
+
spellCheck={false}
|
|
185
|
+
/>
|
|
186
|
+
<div className="flex gap-2">
|
|
187
|
+
<button
|
|
188
|
+
onClick={async () => {
|
|
189
|
+
if (!importYaml.trim()) return;
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch('/api/pipelines', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({ action: 'save-workflow', yaml: importYaml }),
|
|
195
|
+
});
|
|
196
|
+
const data = await res.json();
|
|
197
|
+
if (data.ok) {
|
|
198
|
+
setShowImport(false);
|
|
199
|
+
setImportYaml('');
|
|
200
|
+
fetchData();
|
|
201
|
+
alert(`Workflow "${data.name}" imported successfully`);
|
|
202
|
+
} else {
|
|
203
|
+
alert(`Import failed: ${data.error}`);
|
|
204
|
+
}
|
|
205
|
+
} catch { alert('Import failed'); }
|
|
206
|
+
}}
|
|
207
|
+
disabled={!importYaml.trim()}
|
|
208
|
+
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
209
|
+
>Save Workflow</button>
|
|
210
|
+
<button
|
|
211
|
+
onClick={() => { setShowImport(false); setImportYaml(''); }}
|
|
212
|
+
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
213
|
+
>Cancel</button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
182
218
|
{/* Create form */}
|
|
183
219
|
{showCreate && (
|
|
184
220
|
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
@@ -193,17 +229,28 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
193
229
|
))}
|
|
194
230
|
</select>
|
|
195
231
|
|
|
196
|
-
{/* Input fields */}
|
|
232
|
+
{/* Input fields — project fields get a dropdown */}
|
|
197
233
|
{currentWorkflow && Object.keys(currentWorkflow.input).length > 0 && (
|
|
198
234
|
<div className="space-y-1.5">
|
|
199
235
|
{Object.entries(currentWorkflow.input).map(([key, desc]) => (
|
|
200
236
|
<div key={key}>
|
|
201
237
|
<label className="text-[9px] text-[var(--text-secondary)]">{key}: {desc}</label>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
238
|
+
{key.toLowerCase() === 'project' ? (
|
|
239
|
+
<select
|
|
240
|
+
value={inputValues[key] || ''}
|
|
241
|
+
onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
242
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)]"
|
|
243
|
+
>
|
|
244
|
+
<option value="">Select project...</option>
|
|
245
|
+
{projects.map(p => <option key={p.path} value={p.name}>{p.name}</option>)}
|
|
246
|
+
</select>
|
|
247
|
+
) : (
|
|
248
|
+
<input
|
|
249
|
+
value={inputValues[key] || ''}
|
|
250
|
+
onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
251
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
252
|
+
/>
|
|
253
|
+
)}
|
|
207
254
|
</div>
|
|
208
255
|
))}
|
|
209
256
|
</div>
|
|
@@ -238,44 +285,117 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
238
285
|
</div>
|
|
239
286
|
)}
|
|
240
287
|
|
|
241
|
-
{/*
|
|
288
|
+
{/* Workflow list + execution history */}
|
|
242
289
|
<div className="flex-1 overflow-y-auto">
|
|
243
|
-
{
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<
|
|
259
|
-
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
290
|
+
{workflows.map(w => {
|
|
291
|
+
const isActive = activeWorkflow === w.name;
|
|
292
|
+
const runs = pipelines.filter(p => p.workflowName === w.name);
|
|
293
|
+
return (
|
|
294
|
+
<div key={w.name}>
|
|
295
|
+
<div
|
|
296
|
+
onClick={() => { setActiveWorkflow(isActive ? null : w.name); setSelectedPipeline(null); }}
|
|
297
|
+
className={`w-full text-left px-3 py-2 border-b border-[var(--border)]/30 flex items-center gap-2 cursor-pointer ${
|
|
298
|
+
isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
|
|
299
|
+
}`}
|
|
300
|
+
>
|
|
301
|
+
<span className="text-[8px] text-[var(--text-secondary)]">{isActive ? '▾' : '▸'}</span>
|
|
302
|
+
{w.builtin && <span className="text-[7px] text-[var(--text-secondary)]">⚙</span>}
|
|
303
|
+
<span className="text-[11px] text-[var(--text-primary)] truncate flex-1">{w.name}</span>
|
|
304
|
+
{runs.length > 0 && <span className="text-[8px] text-[var(--text-secondary)]">{runs.length}</span>}
|
|
305
|
+
<button
|
|
306
|
+
onClick={async (e) => {
|
|
307
|
+
e.stopPropagation();
|
|
308
|
+
setSelectedWorkflow(w.name);
|
|
309
|
+
setInputValues({});
|
|
310
|
+
setShowCreate(true);
|
|
311
|
+
setActiveWorkflow(w.name);
|
|
312
|
+
}}
|
|
313
|
+
className="text-[8px] text-[var(--accent)] hover:underline shrink-0"
|
|
314
|
+
title="Run this workflow"
|
|
315
|
+
>Run</button>
|
|
316
|
+
<button
|
|
317
|
+
onClick={async (e) => {
|
|
318
|
+
e.stopPropagation();
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
|
|
321
|
+
const data = await res.json();
|
|
322
|
+
setEditorYaml(data.yaml || undefined);
|
|
323
|
+
} catch { setEditorYaml(undefined); }
|
|
324
|
+
setShowEditor(true);
|
|
325
|
+
}}
|
|
326
|
+
className="text-[8px] text-green-400 hover:underline shrink-0"
|
|
327
|
+
title={w.builtin ? 'View YAML' : 'Edit'}
|
|
328
|
+
>{w.builtin ? 'View' : 'Edit'}</button>
|
|
329
|
+
</div>
|
|
330
|
+
{/* Execution history for this workflow */}
|
|
331
|
+
{isActive && (
|
|
332
|
+
<div className="bg-[var(--bg-tertiary)]/50">
|
|
333
|
+
{runs.length === 0 ? (
|
|
334
|
+
<div className="px-4 py-2 text-[9px] text-[var(--text-secondary)]">No runs yet</div>
|
|
335
|
+
) : (
|
|
336
|
+
runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20).map(p => (
|
|
337
|
+
<button
|
|
338
|
+
key={p.id}
|
|
339
|
+
onClick={() => setSelectedPipeline(p)}
|
|
340
|
+
className={`w-full text-left px-4 py-1.5 border-b border-[var(--border)]/20 hover:bg-[var(--bg-tertiary)] ${
|
|
341
|
+
selectedPipeline?.id === p.id ? 'bg-[var(--accent)]/5' : ''
|
|
342
|
+
}`}
|
|
343
|
+
>
|
|
344
|
+
<div className="flex items-center gap-1.5">
|
|
345
|
+
<span className={`text-[9px] ${STATUS_COLOR[p.status]}`}>●</span>
|
|
346
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono">{p.id.slice(0, 8)}</span>
|
|
347
|
+
<div className="flex gap-0.5 ml-1">
|
|
348
|
+
{p.nodeOrder.map(nodeId => (
|
|
349
|
+
<span key={nodeId} className={`text-[8px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
|
|
350
|
+
{STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
|
|
351
|
+
</span>
|
|
352
|
+
))}
|
|
353
|
+
</div>
|
|
354
|
+
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">
|
|
355
|
+
{new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
356
|
+
</span>
|
|
357
|
+
</div>
|
|
358
|
+
</button>
|
|
359
|
+
))
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
265
363
|
</div>
|
|
266
|
-
|
|
267
|
-
)
|
|
268
|
-
{
|
|
364
|
+
);
|
|
365
|
+
})}
|
|
366
|
+
{workflows.length === 0 && (
|
|
269
367
|
<div className="p-4 text-center text-xs text-[var(--text-secondary)]">
|
|
270
|
-
No
|
|
368
|
+
No workflows. Click Import or + New to create one.
|
|
271
369
|
</div>
|
|
272
370
|
)}
|
|
273
371
|
</div>
|
|
274
372
|
</aside>
|
|
275
373
|
|
|
276
|
-
{/*
|
|
277
|
-
<
|
|
278
|
-
{
|
|
374
|
+
{/* Sidebar resize handle */}
|
|
375
|
+
<div
|
|
376
|
+
onMouseDown={onSidebarDragStart}
|
|
377
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
|
|
378
|
+
/>
|
|
379
|
+
|
|
380
|
+
{/* Right — Pipeline detail / Editor */}
|
|
381
|
+
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
382
|
+
{showEditor ? (
|
|
383
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading editor...</div>}>
|
|
384
|
+
<PipelineEditor
|
|
385
|
+
initialYaml={editorYaml}
|
|
386
|
+
onSave={async (yaml) => {
|
|
387
|
+
await fetch('/api/pipelines', {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({ action: 'save-workflow', yaml }),
|
|
391
|
+
});
|
|
392
|
+
setShowEditor(false);
|
|
393
|
+
fetchData();
|
|
394
|
+
}}
|
|
395
|
+
onClose={() => setShowEditor(false)}
|
|
396
|
+
/>
|
|
397
|
+
</Suspense>
|
|
398
|
+
) : selectedPipeline ? (
|
|
279
399
|
<>
|
|
280
400
|
{/* Header */}
|
|
281
401
|
<div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
|
|
@@ -383,74 +503,87 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
383
503
|
})}
|
|
384
504
|
</div>
|
|
385
505
|
</>
|
|
386
|
-
) : (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
506
|
+
) : activeWorkflow ? (() => {
|
|
507
|
+
const w = workflows.find(wf => wf.name === activeWorkflow);
|
|
508
|
+
if (!w) return null;
|
|
509
|
+
const nodeEntries = Object.entries(w.nodes);
|
|
510
|
+
return (
|
|
511
|
+
<div className="flex-1 flex flex-col overflow-y-auto">
|
|
512
|
+
{/* Workflow header */}
|
|
513
|
+
<div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
|
|
514
|
+
<div className="flex items-center gap-2">
|
|
515
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{w.name}</span>
|
|
516
|
+
{w.builtin && <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">built-in</span>}
|
|
517
|
+
<div className="ml-auto flex gap-2">
|
|
518
|
+
<button
|
|
519
|
+
onClick={() => { setSelectedWorkflow(w.name); setInputValues({}); setShowCreate(true); }}
|
|
520
|
+
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
521
|
+
>Run</button>
|
|
522
|
+
<button
|
|
523
|
+
onClick={async () => {
|
|
524
|
+
try {
|
|
525
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
|
|
526
|
+
const data = await res.json();
|
|
527
|
+
setEditorYaml(data.yaml || undefined);
|
|
528
|
+
} catch { setEditorYaml(undefined); }
|
|
529
|
+
setShowEditor(true);
|
|
530
|
+
}}
|
|
531
|
+
className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
|
|
532
|
+
>{w.builtin ? 'View YAML' : 'Edit'}</button>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
{w.description && <p className="text-[10px] text-[var(--text-secondary)] mt-1">{w.description}</p>}
|
|
536
|
+
{Object.keys(w.input).length > 0 && (
|
|
537
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
538
|
+
{Object.entries(w.input).map(([k, v]) => (
|
|
539
|
+
<span key={k} className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)]">{k}</span>
|
|
540
|
+
))}
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
{/* Node flow visualization */}
|
|
546
|
+
<div className="p-4 space-y-2">
|
|
547
|
+
{nodeEntries.map(([nodeId, node], i) => (
|
|
548
|
+
<div key={nodeId}>
|
|
549
|
+
{/* Connection line */}
|
|
550
|
+
{i > 0 && (
|
|
551
|
+
<div className="flex items-center justify-center py-1">
|
|
552
|
+
<div className="w-px h-4 bg-[var(--border)]" />
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
{/* Node card */}
|
|
556
|
+
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]">
|
|
557
|
+
<div className="flex items-center gap-2">
|
|
558
|
+
<span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${
|
|
559
|
+
node.mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
|
|
560
|
+
}`}>{node.mode === 'shell' ? 'shell' : 'claude'}</span>
|
|
561
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">{nodeId}</span>
|
|
562
|
+
{node.project && <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.project}</span>}
|
|
563
|
+
</div>
|
|
564
|
+
{node.dependsOn.length > 0 && (
|
|
565
|
+
<div className="text-[8px] text-[var(--text-secondary)] mt-1">depends: {node.dependsOn.join(', ')}</div>
|
|
566
|
+
)}
|
|
567
|
+
<p className="text-[9px] text-[var(--text-secondary)] mt-1 line-clamp-2">{node.prompt.slice(0, 120)}{node.prompt.length > 120 ? '...' : ''}</p>
|
|
568
|
+
{node.outputs.length > 0 && (
|
|
569
|
+
<div className="flex gap-1 mt-1">
|
|
570
|
+
{node.outputs.map(o => (
|
|
571
|
+
<span key={o.name} className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">{o.name}</span>
|
|
572
|
+
))}
|
|
573
|
+
</div>
|
|
574
|
+
)}
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
))}
|
|
578
|
+
</div>
|
|
430
579
|
</div>
|
|
580
|
+
);
|
|
581
|
+
})() : (
|
|
582
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
583
|
+
<p className="text-xs">Select a workflow to view details or run</p>
|
|
431
584
|
</div>
|
|
432
585
|
)}
|
|
433
586
|
</main>
|
|
434
|
-
|
|
435
|
-
{/* Visual Editor */}
|
|
436
|
-
{showEditor && (
|
|
437
|
-
<Suspense fallback={null}>
|
|
438
|
-
<PipelineEditor
|
|
439
|
-
initialYaml={editorYaml}
|
|
440
|
-
onSave={async (yaml) => {
|
|
441
|
-
// Save YAML to ~/.forge/flows/
|
|
442
|
-
await fetch('/api/pipelines', {
|
|
443
|
-
method: 'POST',
|
|
444
|
-
headers: { 'Content-Type': 'application/json' },
|
|
445
|
-
body: JSON.stringify({ action: 'save-workflow', yaml }),
|
|
446
|
-
});
|
|
447
|
-
setShowEditor(false);
|
|
448
|
-
fetchData();
|
|
449
|
-
}}
|
|
450
|
-
onClose={() => setShowEditor(false)}
|
|
451
|
-
/>
|
|
452
|
-
</Suspense>
|
|
453
|
-
)}
|
|
454
587
|
</div>
|
|
455
588
|
);
|
|
456
589
|
}
|