@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.
Files changed (71) hide show
  1. package/README.md +10 -4
  2. package/app/app/api/acp/config/route.ts +82 -0
  3. package/app/app/api/acp/detect/route.ts +71 -48
  4. package/app/app/api/acp/install/route.ts +51 -0
  5. package/app/app/api/acp/session/route.ts +141 -11
  6. package/app/app/api/ask/route.ts +116 -13
  7. package/app/app/api/workflows/route.ts +156 -0
  8. package/app/app/page.tsx +7 -2
  9. package/app/components/ActivityBar.tsx +12 -4
  10. package/app/components/AskModal.tsx +4 -1
  11. package/app/components/FileTree.tsx +21 -10
  12. package/app/components/HomeContent.tsx +1 -0
  13. package/app/components/Panel.tsx +1 -0
  14. package/app/components/RightAskPanel.tsx +5 -1
  15. package/app/components/SidebarLayout.tsx +6 -0
  16. package/app/components/agents/AgentDetailContent.tsx +263 -47
  17. package/app/components/agents/AgentsContentPage.tsx +11 -0
  18. package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
  19. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  20. package/app/components/agents/agents-content-model.ts +2 -2
  21. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  22. package/app/components/ask/AskContent.tsx +197 -239
  23. package/app/components/ask/FileChip.tsx +82 -17
  24. package/app/components/ask/MentionPopover.tsx +21 -3
  25. package/app/components/ask/MessageList.tsx +30 -9
  26. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  27. package/app/components/panels/AgentsPanel.tsx +1 -0
  28. package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
  29. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  30. package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
  31. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
  32. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
  33. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  34. package/app/components/renderers/workflow-yaml/execution.ts +177 -0
  35. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  36. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  37. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  38. package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
  39. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  40. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  41. package/app/hooks/useAcpConfig.ts +96 -0
  42. package/app/hooks/useAcpDetection.ts +69 -14
  43. package/app/hooks/useAcpRegistry.ts +46 -11
  44. package/app/hooks/useAskModal.ts +12 -5
  45. package/app/hooks/useAskPanel.ts +8 -5
  46. package/app/hooks/useAskSession.ts +19 -2
  47. package/app/hooks/useImageUpload.ts +152 -0
  48. package/app/lib/acp/acp-tools.ts +3 -1
  49. package/app/lib/acp/agent-descriptors.ts +274 -0
  50. package/app/lib/acp/bridge.ts +6 -0
  51. package/app/lib/acp/index.ts +20 -4
  52. package/app/lib/acp/registry.ts +74 -7
  53. package/app/lib/acp/session.ts +481 -28
  54. package/app/lib/acp/subprocess.ts +307 -21
  55. package/app/lib/acp/types.ts +158 -20
  56. package/app/lib/agent/model.ts +18 -3
  57. package/app/lib/agent/to-agent-messages.ts +25 -2
  58. package/app/lib/i18n/modules/knowledge.ts +4 -0
  59. package/app/lib/i18n/modules/navigation.ts +2 -0
  60. package/app/lib/i18n/modules/panels.ts +146 -2
  61. package/app/lib/pi-integration/skills.ts +21 -6
  62. package/app/lib/renderers/index.ts +2 -2
  63. package/app/lib/settings.ts +10 -0
  64. package/app/lib/types.ts +12 -1
  65. package/app/next-env.d.ts +1 -1
  66. package/app/package.json +3 -1
  67. package/package.json +1 -1
  68. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  69. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  70. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  71. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { ChevronDown, ChevronUp, Trash2 } from 'lucide-react';
5
+ import { AgentSelector, ModelSelector, SkillsSelector, ContextSelector } from './selectors';
6
+ import type { WorkflowStep } from './types';
7
+
8
+ interface StepEditorProps {
9
+ step: WorkflowStep;
10
+ index: number;
11
+ onChange: (step: WorkflowStep) => void;
12
+ onDelete: () => void;
13
+ onMoveUp?: () => void;
14
+ onMoveDown?: () => void;
15
+ }
16
+
17
+ export default function StepEditor({ step, index, onChange, onDelete, onMoveUp, onMoveDown }: StepEditorProps) {
18
+ // Auto-expand if step has no prompt (newly created)
19
+ const [expanded, setExpanded] = useState(!step.prompt);
20
+
21
+ const update = (patch: Partial<WorkflowStep>) => onChange({ ...step, ...patch });
22
+
23
+ // Merge legacy single skill into skills array for display
24
+ const allSkills = step.skills?.length ? step.skills : (step.skill ? [step.skill] : []);
25
+
26
+ // Collapsed view: summary line
27
+ if (!expanded) {
28
+ return (
29
+ <div className="group flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border bg-card hover:border-[var(--amber)]/30 transition-colors cursor-pointer"
30
+ onClick={() => setExpanded(true)}>
31
+ <span className="text-2xs text-muted-foreground/60 font-mono w-5 text-center shrink-0">{index + 1}</span>
32
+ <span className={`text-sm font-medium truncate flex-1 ${step.name ? 'text-foreground' : 'text-muted-foreground italic'}`}>
33
+ {step.name || 'Untitled step'}
34
+ </span>
35
+ <div className="flex items-center gap-1 shrink-0 flex-wrap justify-end">
36
+ {allSkills.slice(0, 2).map(s => (
37
+ <span key={s} className="text-2xs px-1.5 py-0.5 rounded bg-[var(--amber)]/10 text-[var(--amber)] border border-[var(--amber)]/20">{s}</span>
38
+ ))}
39
+ {allSkills.length > 2 && (
40
+ <span className="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground">+{allSkills.length - 2}</span>
41
+ )}
42
+ {step.agent && <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">🤖 {step.agent}</span>}
43
+ {step.agent && step.model && <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">🧠 {step.model}</span>}
44
+ {step.context?.length ? <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">📎 {step.context.length}</span> : null}
45
+ </div>
46
+ <ChevronDown size={12} className="text-muted-foreground/50 shrink-0" />
47
+ </div>
48
+ );
49
+ }
50
+
51
+ // Expanded edit form
52
+ return (
53
+ <div className="rounded-xl border border-[var(--amber)]/30 bg-card overflow-hidden">
54
+ {/* Header */}
55
+ <div className="flex items-center gap-2 px-3.5 py-2.5 border-b border-border bg-muted/30">
56
+ <span className="text-2xs text-muted-foreground/60 font-mono w-5 text-center shrink-0">{index + 1}</span>
57
+ <span className={`text-xs font-medium flex-1 truncate ${step.name ? 'text-foreground' : 'text-muted-foreground italic'}`}>
58
+ {step.name || 'Untitled step'}
59
+ </span>
60
+ <div className="flex items-center gap-0.5">
61
+ {onMoveUp && (
62
+ <button onClick={onMoveUp} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Move up">
63
+ <ChevronUp size={13} />
64
+ </button>
65
+ )}
66
+ {onMoveDown && (
67
+ <button onClick={onMoveDown} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Move down">
68
+ <ChevronDown size={13} />
69
+ </button>
70
+ )}
71
+ <button onClick={onDelete} className="p-1 rounded hover:bg-[var(--error)]/10 text-muted-foreground hover:text-[var(--error)] transition-colors" title="Delete step">
72
+ <Trash2 size={12} />
73
+ </button>
74
+ <button onClick={() => setExpanded(false)} className="p-1 rounded hover:bg-muted text-muted-foreground transition-colors" title="Collapse">
75
+ <ChevronUp size={13} />
76
+ </button>
77
+ </div>
78
+ </div>
79
+
80
+ {/* Form body */}
81
+ <div className="px-3.5 py-3 space-y-3">
82
+ {/* Step name */}
83
+ <div>
84
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Step name</label>
85
+ <input type="text" value={step.name} onChange={e => update({ name: e.target.value })}
86
+ placeholder="e.g. Run Tests"
87
+ autoFocus={!step.name}
88
+ className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
89
+ />
90
+ </div>
91
+
92
+ {/* Prompt */}
93
+ <div>
94
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Prompt</label>
95
+ <textarea value={step.prompt} onChange={e => update({ prompt: e.target.value })}
96
+ placeholder="Describe what the AI should do in this step..."
97
+ rows={4}
98
+ className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y leading-relaxed"
99
+ />
100
+ </div>
101
+
102
+ {/* Agent + Model */}
103
+ <div className={`grid ${step.agent ? 'grid-cols-2' : 'grid-cols-1'} gap-3`}>
104
+ <div>
105
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Agent</label>
106
+ <AgentSelector value={step.agent} onChange={agent => update({ agent, model: agent ? step.model : undefined })} />
107
+ </div>
108
+ {step.agent && (
109
+ <div>
110
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Model</label>
111
+ <ModelSelector value={step.model} onChange={model => update({ model })} />
112
+ </div>
113
+ )}
114
+ </div>
115
+
116
+ {/* Skills (multi-select) */}
117
+ <div>
118
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Skills</label>
119
+ <SkillsSelector
120
+ value={allSkills}
121
+ onChange={skills => update({ skills, skill: undefined })}
122
+ />
123
+ </div>
124
+
125
+ {/* Context files */}
126
+ <div>
127
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Context files</label>
128
+ <ContextSelector
129
+ value={step.context ?? []}
130
+ onChange={context => update({ context: context.length ? context : undefined })}
131
+ />
132
+ </div>
133
+
134
+ {/* Description + Timeout in one row */}
135
+ <div className="grid grid-cols-[1fr,auto] gap-3">
136
+ <div>
137
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Description <span className="text-muted-foreground/50">(optional)</span></label>
138
+ <input type="text" value={step.description || ''} onChange={e => update({ description: e.target.value || undefined })}
139
+ placeholder="Brief description of this step"
140
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
141
+ />
142
+ </div>
143
+ <div>
144
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Timeout</label>
145
+ <div className="flex items-center gap-1.5">
146
+ <input type="number" min={0} value={step.timeout || ''} onChange={e => update({ timeout: e.target.value ? Number(e.target.value) : undefined })}
147
+ placeholder="120"
148
+ className="w-20 px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
149
+ />
150
+ <span className="text-2xs text-muted-foreground/50">sec</span>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useEffect } from 'react';
4
+ import { Plus, Save, Loader2, FolderOpen, Zap, CheckCircle2 } from 'lucide-react';
5
+ import StepEditor from './StepEditor';
6
+ import { serializeWorkflowYaml, generateStepId } from './serializer';
7
+ import type { WorkflowYaml, WorkflowStep } from './types';
8
+ import { DirPicker } from './selectors';
9
+
10
+ interface WorkflowEditorProps {
11
+ workflow: WorkflowYaml;
12
+ filePath: string;
13
+ onChange: (workflow: WorkflowYaml) => void;
14
+ onSaved?: () => void;
15
+ }
16
+
17
+ export default function WorkflowEditor({ workflow, filePath, onChange, onSaved }: WorkflowEditorProps) {
18
+ const [saving, setSaving] = useState(false);
19
+ const [saveError, setSaveError] = useState('');
20
+ const [saveSuccess, setSaveSuccess] = useState(false);
21
+
22
+ // Clear success indicator after 3s
23
+ useEffect(() => {
24
+ if (!saveSuccess) return;
25
+ const t = setTimeout(() => setSaveSuccess(false), 3000);
26
+ return () => clearTimeout(t);
27
+ }, [saveSuccess]);
28
+
29
+ const updateMeta = (patch: Partial<WorkflowYaml>) => {
30
+ onChange({ ...workflow, ...patch });
31
+ };
32
+
33
+ const updateStep = useCallback((index: number, step: WorkflowStep) => {
34
+ const steps = [...workflow.steps];
35
+ steps[index] = step;
36
+ onChange({ ...workflow, steps });
37
+ }, [workflow, onChange]);
38
+
39
+ const deleteStep = useCallback((index: number) => {
40
+ const step = workflow.steps[index];
41
+ const hasContent = step.name || step.prompt;
42
+ if (hasContent && !window.confirm(`Delete step "${step.name || 'Untitled'}"?`)) return;
43
+ onChange({ ...workflow, steps: workflow.steps.filter((_, i) => i !== index) });
44
+ }, [workflow, onChange]);
45
+
46
+ const addStep = useCallback(() => {
47
+ const existingIds = workflow.steps.map(s => s.id);
48
+ const num = workflow.steps.length + 1;
49
+ const id = generateStepId(`step-${num}`, existingIds);
50
+ const step: WorkflowStep = { id, name: `Step ${num}`, prompt: '' };
51
+ onChange({ ...workflow, steps: [...workflow.steps, step] });
52
+ }, [workflow, onChange]);
53
+
54
+ const moveStep = useCallback((from: number, to: number) => {
55
+ if (to < 0 || to >= workflow.steps.length) return;
56
+ const steps = [...workflow.steps];
57
+ const [moved] = steps.splice(from, 1);
58
+ steps.splice(to, 0, moved);
59
+ onChange({ ...workflow, steps });
60
+ }, [workflow, onChange]);
61
+
62
+ const handleSave = async () => {
63
+ setSaving(true);
64
+ setSaveError('');
65
+ setSaveSuccess(false);
66
+ try {
67
+ const yaml = serializeWorkflowYaml(workflow);
68
+ const res = await fetch('/api/file', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ path: filePath, op: 'save_file', content: yaml }),
72
+ });
73
+ if (!res.ok) {
74
+ const data = await res.json().catch(() => ({}));
75
+ throw new Error(data.error || `Save failed (HTTP ${res.status})`);
76
+ }
77
+ setSaveSuccess(true);
78
+ onSaved?.();
79
+ } catch (err) {
80
+ setSaveError(err instanceof Error ? err.message : 'Save failed');
81
+ } finally {
82
+ setSaving(false);
83
+ }
84
+ };
85
+
86
+ // Keyboard shortcut: Cmd/Ctrl+S to save
87
+ useEffect(() => {
88
+ const handler = (e: KeyboardEvent) => {
89
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
90
+ e.preventDefault();
91
+ if (!saving && workflow.title.trim() && workflow.steps.length > 0) {
92
+ handleSave();
93
+ }
94
+ }
95
+ };
96
+ window.addEventListener('keydown', handler);
97
+ return () => window.removeEventListener('keydown', handler);
98
+ });
99
+
100
+ const canSave = !saving && !!workflow.title.trim() && workflow.steps.length > 0;
101
+
102
+ return (
103
+ <div>
104
+ {/* Metadata */}
105
+ <div className="space-y-3 mb-6">
106
+ <div>
107
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Title</label>
108
+ <input type="text" value={workflow.title} onChange={e => updateMeta({ title: e.target.value })}
109
+ placeholder="Workflow title"
110
+ className="w-full px-3 py-2 text-sm font-medium rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
111
+ />
112
+ </div>
113
+ <div className="grid grid-cols-[1fr,auto] gap-3">
114
+ <div>
115
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">Description <span className="text-muted-foreground/50">(optional)</span></label>
116
+ <input type="text" value={workflow.description || ''} onChange={e => updateMeta({ description: e.target.value || undefined })}
117
+ placeholder="What does this workflow do?"
118
+ className="w-full px-3 py-1.5 text-xs rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
119
+ />
120
+ </div>
121
+ <div>
122
+ <label className="block text-2xs font-medium text-muted-foreground mb-1">
123
+ <FolderOpen size={10} className="inline mr-0.5 -mt-0.5" />
124
+ Working dir
125
+ </label>
126
+ <DirPicker value={workflow.workDir || ''} onChange={v => updateMeta({ workDir: v || undefined })} />
127
+ </div>
128
+ </div>
129
+ </div>
130
+
131
+ {/* Steps section */}
132
+ {workflow.steps.length > 0 ? (
133
+ <>
134
+ {/* Steps header */}
135
+ <div className="flex items-center justify-between mb-3">
136
+ <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
137
+ Steps ({workflow.steps.length})
138
+ </h3>
139
+ </div>
140
+
141
+ {/* Step list */}
142
+ <div className="flex flex-col gap-2 mb-4">
143
+ {workflow.steps.map((step, i) => (
144
+ <StepEditor
145
+ key={step.id}
146
+ step={step}
147
+ index={i}
148
+ onChange={s => updateStep(i, s)}
149
+ onDelete={() => deleteStep(i)}
150
+ onMoveUp={i > 0 ? () => moveStep(i, i - 1) : undefined}
151
+ onMoveDown={i < workflow.steps.length - 1 ? () => moveStep(i, i + 1) : undefined}
152
+ />
153
+ ))}
154
+ </div>
155
+
156
+ {/* Add step */}
157
+ <button onClick={addStep}
158
+ className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-border text-xs text-muted-foreground hover:text-foreground hover:border-[var(--amber)]/30 hover:bg-muted/30 transition-colors">
159
+ <Plus size={13} />
160
+ Add step
161
+ </button>
162
+ </>
163
+ ) : (
164
+ /* Empty state: prominent CTA */
165
+ <div className="flex flex-col items-center justify-center py-10 px-4 text-center rounded-xl border border-dashed border-border bg-muted/10">
166
+ <Zap size={28} className="text-muted-foreground/30 mb-3" />
167
+ <p className="text-sm font-medium text-muted-foreground mb-1">No steps yet</p>
168
+ <p className="text-xs text-muted-foreground/60 mb-4 max-w-[260px]">
169
+ Add your first step to define what the AI should do.
170
+ </p>
171
+ <button onClick={addStep}
172
+ className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-medium bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors hover:opacity-90">
173
+ <Plus size={13} />
174
+ Add first step
175
+ </button>
176
+ </div>
177
+ )}
178
+
179
+ {/* Save bar */}
180
+ <div className="flex items-center gap-3 mt-6 pt-4 border-t border-border">
181
+ <button onClick={handleSave} disabled={!canSave}
182
+ title={!workflow.title.trim() ? 'Title is required' : workflow.steps.length === 0 ? 'Add at least one step' : undefined}
183
+ className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-[var(--amber)] text-[var(--amber-foreground)]">
184
+ {saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />}
185
+ {saving ? 'Saving...' : 'Save'}
186
+ </button>
187
+
188
+ {saveError && <span className="text-xs text-[var(--error)]">{saveError}</span>}
189
+
190
+ {saveSuccess && !saveError && (
191
+ <span className="flex items-center gap-1 text-2xs text-[var(--success)] animate-in fade-in">
192
+ <CheckCircle2 size={11} />
193
+ Saved
194
+ </span>
195
+ )}
196
+
197
+ <span className="text-2xs text-muted-foreground/40 ml-auto">Ctrl+S</span>
198
+ </div>
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,226 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useCallback, useEffect } from 'react';
4
+ import { Play, SkipForward, RotateCcw, CheckCircle2, Circle, Loader2, AlertCircle, ChevronDown, Sparkles, XCircle, Clock } from 'lucide-react';
5
+ import { runStepWithAI, clearSkillCache } from './execution';
6
+ import type { WorkflowYaml, WorkflowStepRuntime, StepStatus } from './types';
7
+
8
+ function initSteps(workflow: WorkflowYaml): WorkflowStepRuntime[] {
9
+ return workflow.steps.map((s, idx) => ({
10
+ ...s, index: idx, status: 'pending' as const, output: '', error: undefined,
11
+ }));
12
+ }
13
+
14
+ function StatusIcon({ status }: { status: StepStatus }) {
15
+ if (status === 'pending') return <Circle size={15} className="text-border" />;
16
+ if (status === 'running') return <Loader2 size={15} className="text-[var(--amber)] animate-spin" />;
17
+ if (status === 'done') return <CheckCircle2 size={15} className="text-[var(--success)]" />;
18
+ if (status === 'skipped') return <SkipForward size={15} className="text-muted-foreground opacity-50" />;
19
+ return <AlertCircle size={15} className="text-[var(--error)]" />;
20
+ }
21
+
22
+ function Badge({ label, variant }: { label: string; variant?: 'amber' | 'default' }) {
23
+ return (
24
+ <span className={`inline-flex items-center gap-1 text-2xs px-2 py-0.5 rounded whitespace-nowrap ${
25
+ variant === 'amber'
26
+ ? 'bg-[var(--amber)]/10 text-[var(--amber)] border border-[var(--amber)]/20'
27
+ : 'bg-muted text-muted-foreground'
28
+ }`}>
29
+ {label}
30
+ </span>
31
+ );
32
+ }
33
+
34
+ function formatDuration(ms: number): string {
35
+ if (ms < 1000) return `${ms}ms`;
36
+ const secs = Math.round(ms / 1000);
37
+ if (secs < 60) return `${secs}s`;
38
+ return `${Math.floor(secs / 60)}m ${secs % 60}s`;
39
+ }
40
+
41
+ function RunStepCard({ step, canRun, onRun, onSkip, onCancel }: {
42
+ step: WorkflowStepRuntime; canRun: boolean;
43
+ onRun: () => void; onSkip: () => void; onCancel: () => void;
44
+ }) {
45
+ const [expanded, setExpanded] = useState(false);
46
+ const hasContent = !!(step.description || step.output || step.error);
47
+ const borderColor = {
48
+ pending: 'border-border', running: 'border-[var(--amber)]/50',
49
+ done: 'border-[var(--success)]/40', skipped: 'border-border', error: 'border-[var(--error)]/40',
50
+ }[step.status];
51
+
52
+ // Merge skills for display
53
+ const allSkills = step.skills?.length ? step.skills : (step.skill ? [step.skill] : []);
54
+
55
+ return (
56
+ <div className={`rounded-xl border overflow-hidden bg-card transition-all ${borderColor} ${step.status === 'skipped' ? 'opacity-60' : ''}`}>
57
+ <div className="flex items-center gap-2.5 px-3.5 py-2.5 justify-between flex-wrap">
58
+ <div className="flex items-center gap-2 flex-1 min-w-0">
59
+ <StatusIcon status={step.status} />
60
+ <div className="min-w-0">
61
+ <div className="font-semibold text-sm text-foreground cursor-pointer" onClick={() => hasContent && setExpanded(v => !v)}>
62
+ {step.name}
63
+ </div>
64
+ {(step.agent || allSkills.length > 0 || step.model) && (
65
+ <div className="flex gap-1.5 mt-1 flex-wrap">
66
+ {allSkills.map(s => <Badge key={s} label={`🎓 ${s}`} variant="amber" />)}
67
+ {step.agent && <Badge label={`🤖 ${step.agent}`} />}
68
+ {step.model && <Badge label={`🧠 ${step.model}`} />}
69
+ </div>
70
+ )}
71
+ </div>
72
+ </div>
73
+ <div className="flex items-center gap-1.5 shrink-0">
74
+ {/* Duration display */}
75
+ {step.durationMs != null && (step.status === 'done' || step.status === 'error') && (
76
+ <span className="flex items-center gap-1 text-2xs text-muted-foreground/60">
77
+ <Clock size={10} />
78
+ {formatDuration(step.durationMs)}
79
+ </span>
80
+ )}
81
+
82
+ {step.status === 'pending' && (
83
+ <>
84
+ <button onClick={onRun} disabled={!canRun}
85
+ title={!canRun ? 'Another step is running' : undefined}
86
+ className="flex items-center gap-1 px-2.5 py-1 rounded-md text-2xs font-medium border-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-[var(--amber)] text-[var(--amber-foreground)] disabled:bg-muted disabled:text-muted-foreground"
87
+ ><Play size={10} /> Run</button>
88
+ <button onClick={onSkip} className="px-2 py-1 rounded-md text-2xs border border-border bg-transparent text-muted-foreground hover:bg-muted transition-colors">Skip</button>
89
+ </>
90
+ )}
91
+ {step.status === 'running' && (
92
+ <button onClick={onCancel} className="flex items-center gap-1 px-2.5 py-1 rounded-md text-2xs border border-[var(--error)] bg-transparent text-[var(--error)] hover:bg-[var(--error)]/10 transition-colors">
93
+ <XCircle size={10} /> Cancel
94
+ </button>
95
+ )}
96
+ {(step.status === 'done' || step.status === 'error') && (
97
+ <button onClick={() => setExpanded(v => !v)} className="px-2 py-1 rounded-md text-2xs border border-border bg-transparent text-muted-foreground hover:bg-muted transition-colors">
98
+ <ChevronDown size={11} className={`inline transition-transform ${expanded ? 'rotate-180' : ''}`} />
99
+ </button>
100
+ )}
101
+ </div>
102
+ </div>
103
+ {(expanded || step.status === 'running') && hasContent && (
104
+ <div className="border-t border-border">
105
+ {step.description && (
106
+ <div className={`px-3.5 py-2.5 text-xs leading-relaxed text-muted-foreground ${(step.output || step.error) ? 'border-b border-border' : ''}`}>
107
+ {step.description}
108
+ </div>
109
+ )}
110
+ {step.error && (
111
+ <div className="px-3.5 py-2.5 bg-[var(--error)]/10">
112
+ <div className="flex items-center gap-1.5 mb-1.5">
113
+ <AlertCircle size={11} className="text-[var(--error)]" />
114
+ <span className="text-2xs text-[var(--error)] uppercase font-medium">Error</span>
115
+ </div>
116
+ <div className="text-xs text-[var(--error)] whitespace-pre-wrap break-words">{step.error}</div>
117
+ </div>
118
+ )}
119
+ {step.output && (
120
+ <div className="px-3.5 py-2.5 bg-background">
121
+ <div className="flex items-center gap-1.5 mb-1.5">
122
+ <Sparkles size={11} className="text-[var(--amber)]" />
123
+ <span className="text-2xs text-muted-foreground uppercase tracking-wide">AI Output</span>
124
+ {step.status === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)] animate-pulse ml-1" />}
125
+ </div>
126
+ <div className="text-xs leading-relaxed text-foreground whitespace-pre-wrap break-words">{step.output}</div>
127
+ </div>
128
+ )}
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ // ─── Main Runner ──────────────────────────────────────────────────────────
136
+
137
+ export default function WorkflowRunner({ workflow, filePath }: { workflow: WorkflowYaml; filePath: string }) {
138
+ const [steps, setSteps] = useState<WorkflowStepRuntime[]>(() => initSteps(workflow));
139
+ const [running, setRunning] = useState(false);
140
+ const abortRef = useRef<AbortController | null>(null);
141
+
142
+ useEffect(() => {
143
+ setSteps(initSteps(workflow));
144
+ setRunning(false);
145
+ }, [workflow]);
146
+
147
+ const cancelExecution = useCallback(() => {
148
+ abortRef.current?.abort();
149
+ abortRef.current = null;
150
+ setRunning(false);
151
+ setSteps(prev => prev.map(s => s.status === 'running' ? { ...s, status: 'error', error: 'Cancelled by user' } : s));
152
+ }, []);
153
+
154
+ const runStep = useCallback(async (idx: number) => {
155
+ if (running) return;
156
+ abortRef.current?.abort();
157
+ const ctrl = new AbortController();
158
+ abortRef.current = ctrl;
159
+ setRunning(true);
160
+
161
+ const startTime = Date.now();
162
+ setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'running', output: '', error: undefined, startedAt: new Date() } : s));
163
+
164
+ try {
165
+ const step = workflow.steps[idx];
166
+ const runtimeStep: WorkflowStepRuntime = { ...step, index: idx, status: 'running', output: '' };
167
+ await runStepWithAI(runtimeStep, workflow, filePath,
168
+ (acc) => setSteps(prev => prev.map((s, i) => i === idx ? { ...s, output: acc } : s)),
169
+ ctrl.signal);
170
+ const duration = Date.now() - startTime;
171
+ setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'done', completedAt: new Date(), durationMs: duration } : s));
172
+ } catch (err: unknown) {
173
+ if (err instanceof Error && err.name === 'AbortError') return;
174
+ const duration = Date.now() - startTime;
175
+ setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'error', error: err instanceof Error ? err.message : String(err), durationMs: duration } : s));
176
+ } finally { setRunning(false); }
177
+ }, [running, workflow, filePath]);
178
+
179
+ const skipStep = useCallback((idx: number) => {
180
+ setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'skipped' } : s));
181
+ }, []);
182
+
183
+ const reset = useCallback(() => {
184
+ abortRef.current?.abort();
185
+ abortRef.current = null;
186
+ setRunning(false);
187
+ clearSkillCache();
188
+ setSteps(initSteps(workflow));
189
+ }, [workflow]);
190
+
191
+ const doneCount = steps.filter(s => s.status === 'done').length;
192
+ const progress = steps.length > 0 ? Math.round((doneCount / steps.length) * 100) : 0;
193
+ const allDone = doneCount === steps.length && steps.length > 0;
194
+ const nextPendingIdx = steps.findIndex(s => s.status === 'pending');
195
+
196
+ return (
197
+ <div>
198
+ {workflow.description && (
199
+ <p className="text-xs text-muted-foreground leading-relaxed mb-3">{workflow.description}</p>
200
+ )}
201
+ <div className="flex items-center gap-2.5 flex-wrap mb-4">
202
+ <div className="flex-1 min-w-[120px] h-1 rounded-full bg-border overflow-hidden">
203
+ <div className={`h-full rounded-full transition-all duration-300 ${allDone ? 'bg-[var(--success)]' : 'bg-[var(--amber)]'}`} style={{ width: `${progress}%` }} />
204
+ </div>
205
+ <span className={`text-2xs shrink-0 ${allDone ? 'text-[var(--success)] font-medium' : 'text-muted-foreground'}`}>
206
+ {doneCount}/{steps.length}{allDone ? ' Complete' : ''}
207
+ </span>
208
+ {nextPendingIdx >= 0 && (
209
+ <button onClick={() => runStep(nextPendingIdx)} disabled={running}
210
+ title={running ? 'A step is currently running' : undefined}
211
+ className="flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-medium border-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed bg-[var(--amber)] text-[var(--amber-foreground)] disabled:bg-muted disabled:text-muted-foreground"
212
+ >{running ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />} Run next</button>
213
+ )}
214
+ <button onClick={reset} className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs border border-border bg-transparent text-muted-foreground hover:bg-muted transition-colors">
215
+ <RotateCcw size={11} /> Reset
216
+ </button>
217
+ </div>
218
+ <div className="flex flex-col gap-2">
219
+ {steps.map((step, i) => (
220
+ <RunStepCard key={step.id} step={step} canRun={!running}
221
+ onRun={() => runStep(i)} onSkip={() => skipStep(i)} onCancel={cancelExecution} />
222
+ ))}
223
+ </div>
224
+ </div>
225
+ );
226
+ }