@geminilight/mindos 0.6.29 → 0.6.31
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/README_zh.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 +126 -18
- package/app/app/api/export/route.ts +105 -0
- package/app/app/api/workflows/route.ts +156 -0
- package/app/app/globals.css +2 -2
- package/app/app/page.tsx +7 -2
- package/app/app/trash/page.tsx +7 -0
- package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
- package/app/components/ActivityBar.tsx +12 -4
- package/app/components/AskModal.tsx +4 -1
- package/app/components/ExportModal.tsx +220 -0
- package/app/components/FileTree.tsx +42 -11
- package/app/components/HomeContent.tsx +92 -20
- package/app/components/MarkdownView.tsx +45 -10
- package/app/components/Panel.tsx +1 -0
- package/app/components/RightAskPanel.tsx +5 -1
- package/app/components/Sidebar.tsx +10 -1
- package/app/components/SidebarLayout.tsx +6 -0
- package/app/components/TrashPageClient.tsx +263 -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/ask/ToolCallBlock.tsx +102 -18
- package/app/components/changes/ChangesContentPage.tsx +58 -14
- package/app/components/explore/ExploreContent.tsx +4 -7
- package/app/components/explore/UseCaseCard.tsx +18 -1
- package/app/components/explore/use-cases.generated.ts +76 -0
- package/app/components/explore/use-cases.yaml +185 -0
- package/app/components/panels/AgentsPanel.tsx +1 -0
- package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
- package/app/components/panels/DiscoverPanel.tsx +1 -1
- package/app/components/panels/WorkflowsPanel.tsx +206 -0
- package/app/components/renderers/workflow-yaml/StepEditor.tsx +164 -0
- package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +211 -0
- package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +269 -0
- package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
- package/app/components/renderers/workflow-yaml/execution.ts +229 -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 +574 -0
- package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
- package/app/components/renderers/workflow-yaml/types.ts +46 -0
- package/app/components/settings/AiTab.tsx +191 -174
- package/app/components/settings/AppearanceTab.tsx +168 -77
- package/app/components/settings/KnowledgeTab.tsx +131 -136
- package/app/components/settings/McpTab.tsx +11 -11
- package/app/components/settings/Primitives.tsx +60 -0
- package/app/components/settings/SettingsContent.tsx +15 -8
- package/app/components/settings/SyncTab.tsx +12 -12
- package/app/components/settings/UninstallTab.tsx +8 -18
- package/app/components/settings/UpdateTab.tsx +82 -82
- package/app/components/settings/types.ts +17 -8
- 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 +490 -28
- package/app/lib/acp/subprocess.ts +307 -21
- package/app/lib/acp/types.ts +158 -20
- package/app/lib/actions.ts +57 -3
- package/app/lib/agent/model.ts +18 -3
- package/app/lib/agent/stream-consumer.ts +18 -0
- package/app/lib/agent/to-agent-messages.ts +25 -2
- package/app/lib/agent/tools.ts +56 -9
- package/app/lib/core/export.ts +116 -0
- package/app/lib/core/trash.ts +241 -0
- package/app/lib/fs.ts +47 -0
- package/app/lib/hooks/usePinnedFiles.ts +90 -0
- package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
- package/app/lib/i18n/index.ts +3 -0
- package/app/lib/i18n/modules/knowledge.ts +124 -6
- package/app/lib/i18n/modules/navigation.ts +2 -0
- package/app/lib/i18n/modules/onboarding.ts +2 -134
- package/app/lib/i18n/modules/panels.ts +146 -2
- package/app/lib/i18n/modules/settings.ts +12 -0
- 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 +11 -3
- package/app/scripts/generate-explore.ts +145 -0
- 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/explore/use-cases.ts +0 -58
- package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
- package/app/components/renderers/workflow/manifest.ts +0 -14
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { ChevronDown, ChevronUp, Trash2, Settings2 } 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
|
+
const [expanded, setExpanded] = useState(!step.prompt);
|
|
19
|
+
const [showConfig, setShowConfig] = useState(false);
|
|
20
|
+
|
|
21
|
+
const update = (patch: Partial<WorkflowStep>) => onChange({ ...step, ...patch });
|
|
22
|
+
const allSkills = step.skills?.length ? step.skills : (step.skill ? [step.skill] : []);
|
|
23
|
+
const hasConfig = !!(step.agent || step.model || allSkills.length || step.context?.length || step.description || step.timeout);
|
|
24
|
+
|
|
25
|
+
// ── Collapsed ──
|
|
26
|
+
if (!expanded) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="group flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-muted/40 transition-colors cursor-pointer -ml-1"
|
|
29
|
+
onClick={() => setExpanded(true)}>
|
|
30
|
+
<div className="min-w-0 flex-1">
|
|
31
|
+
<span className={`text-sm leading-tight block ${step.name ? 'text-foreground font-medium' : 'text-muted-foreground/50 italic'}`}>
|
|
32
|
+
{step.name || 'Untitled step'}
|
|
33
|
+
</span>
|
|
34
|
+
{/* Config summary pills */}
|
|
35
|
+
{hasConfig && (
|
|
36
|
+
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
|
37
|
+
{step.agent && <span className="text-2xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">{step.agent}</span>}
|
|
38
|
+
{allSkills.slice(0, 2).map(s => (
|
|
39
|
+
<span key={s} className="text-2xs px-1.5 py-0.5 rounded-full bg-[var(--amber)]/8 text-[var(--amber)]">{s}</span>
|
|
40
|
+
))}
|
|
41
|
+
{allSkills.length > 2 && <span className="text-2xs text-muted-foreground/50">+{allSkills.length - 2}</span>}
|
|
42
|
+
{step.context?.length ? <span className="text-2xs text-muted-foreground/50">{step.context.length} files</span> : null}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
{/* Prompt preview */}
|
|
46
|
+
{step.prompt && (
|
|
47
|
+
<p className="text-2xs text-muted-foreground/50 mt-1 line-clamp-1 leading-relaxed">{step.prompt}</p>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
<ChevronDown size={12} className="text-muted-foreground/30 mt-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Expanded ──
|
|
56
|
+
return (
|
|
57
|
+
<div className="rounded-lg border border-[var(--amber)]/20 bg-card overflow-hidden shadow-sm">
|
|
58
|
+
{/* Header */}
|
|
59
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-muted/20">
|
|
60
|
+
<span className={`text-xs font-medium flex-1 truncate ${step.name ? 'text-foreground' : 'text-muted-foreground/50 italic'}`}>
|
|
61
|
+
{step.name || 'Untitled step'}
|
|
62
|
+
</span>
|
|
63
|
+
<div className="flex items-center gap-0.5">
|
|
64
|
+
{onMoveUp && (
|
|
65
|
+
<button onClick={onMoveUp} className="p-1 rounded hover:bg-muted text-muted-foreground/50 hover:text-foreground transition-colors" title="Move up">
|
|
66
|
+
<ChevronUp size={12} />
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
|
69
|
+
{onMoveDown && (
|
|
70
|
+
<button onClick={onMoveDown} className="p-1 rounded hover:bg-muted text-muted-foreground/50 hover:text-foreground transition-colors" title="Move down">
|
|
71
|
+
<ChevronDown size={12} />
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
<button onClick={onDelete} className="p-1 rounded hover:bg-[var(--error)]/10 text-muted-foreground/50 hover:text-[var(--error)] transition-colors" title="Delete">
|
|
75
|
+
<Trash2 size={11} />
|
|
76
|
+
</button>
|
|
77
|
+
<button onClick={() => setExpanded(false)} className="p-1 rounded hover:bg-muted text-muted-foreground/50 transition-colors" title="Collapse">
|
|
78
|
+
<ChevronUp size={12} />
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Body */}
|
|
84
|
+
<div className="px-3 py-3 space-y-3">
|
|
85
|
+
{/* Name — inline style, not a labeled field */}
|
|
86
|
+
<input type="text" value={step.name} onChange={e => update({ name: e.target.value })}
|
|
87
|
+
placeholder="Step name..."
|
|
88
|
+
autoFocus={!step.name}
|
|
89
|
+
className="w-full text-sm font-medium bg-transparent text-foreground placeholder:text-muted-foreground/30 focus:outline-none border-none p-0"
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{/* Prompt — the main content */}
|
|
93
|
+
<textarea value={step.prompt} onChange={e => update({ prompt: e.target.value })}
|
|
94
|
+
placeholder="What should the AI do?"
|
|
95
|
+
rows={3}
|
|
96
|
+
className="w-full px-3 py-2.5 text-sm rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y leading-relaxed"
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
{/* Agent selector — always visible, it's important */}
|
|
100
|
+
<div className={`grid ${step.agent ? 'grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
|
101
|
+
<div>
|
|
102
|
+
<label className="block text-2xs text-muted-foreground/60 mb-1">Agent</label>
|
|
103
|
+
<AgentSelector value={step.agent} onChange={agent => update({ agent, model: agent ? step.model : undefined })} />
|
|
104
|
+
</div>
|
|
105
|
+
{step.agent && (
|
|
106
|
+
<div>
|
|
107
|
+
<label className="block text-2xs text-muted-foreground/60 mb-1">Model</label>
|
|
108
|
+
<ModelSelector value={step.model} onChange={model => update({ model })} />
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Config toggle — progressive disclosure for skills, context, timeout */}
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => setShowConfig(v => !v)}
|
|
116
|
+
className={`flex items-center gap-1.5 text-2xs transition-colors ${
|
|
117
|
+
showConfig || hasConfig ? 'text-muted-foreground' : 'text-muted-foreground/40 hover:text-muted-foreground'
|
|
118
|
+
}`}
|
|
119
|
+
>
|
|
120
|
+
<Settings2 size={11} />
|
|
121
|
+
{showConfig ? 'Less options' : 'More options'}
|
|
122
|
+
{!showConfig && hasConfig && <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]" />}
|
|
123
|
+
</button>
|
|
124
|
+
|
|
125
|
+
{showConfig && (
|
|
126
|
+
<div className="space-y-3 pt-1">
|
|
127
|
+
{/* Skills */}
|
|
128
|
+
<div>
|
|
129
|
+
<label className="block text-2xs text-muted-foreground/60 mb-1">Skills</label>
|
|
130
|
+
<SkillsSelector value={allSkills} onChange={skills => update({ skills, skill: undefined })} />
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Context files */}
|
|
134
|
+
<div>
|
|
135
|
+
<label className="block text-2xs text-muted-foreground/60 mb-1">Context files</label>
|
|
136
|
+
<ContextSelector value={step.context ?? []} onChange={context => update({ context: context.length ? context : undefined })} />
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Description + Timeout */}
|
|
140
|
+
<div className="grid grid-cols-[1fr,auto] gap-3">
|
|
141
|
+
<div>
|
|
142
|
+
<label className="block text-2xs text-muted-foreground/60 mb-1">Description</label>
|
|
143
|
+
<input type="text" value={step.description || ''} onChange={e => update({ description: e.target.value || undefined })}
|
|
144
|
+
placeholder="Optional note..."
|
|
145
|
+
className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
<div>
|
|
149
|
+
<label className="block text-2xs text-muted-foreground/60 mb-1">Timeout</label>
|
|
150
|
+
<div className="flex items-center gap-1">
|
|
151
|
+
<input type="number" min={0} value={step.timeout || ''} onChange={e => update({ timeout: e.target.value ? Number(e.target.value) : undefined })}
|
|
152
|
+
placeholder="120"
|
|
153
|
+
className="w-16 px-2 py-1.5 text-xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
154
|
+
/>
|
|
155
|
+
<span className="text-2xs text-muted-foreground/40">s</span>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { Plus, Save, Loader2, FolderOpen, Zap, CheckCircle2, GripVertical } 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
|
+
const [showAdvanced, setShowAdvanced] = useState(!!(workflow.workDir));
|
|
22
|
+
|
|
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>) => onChange({ ...workflow, ...patch });
|
|
30
|
+
|
|
31
|
+
const updateStep = useCallback((index: number, step: WorkflowStep) => {
|
|
32
|
+
const steps = [...workflow.steps];
|
|
33
|
+
steps[index] = step;
|
|
34
|
+
onChange({ ...workflow, steps });
|
|
35
|
+
}, [workflow, onChange]);
|
|
36
|
+
|
|
37
|
+
const deleteStep = useCallback((index: number) => {
|
|
38
|
+
const step = workflow.steps[index];
|
|
39
|
+
const hasContent = step.name || step.prompt;
|
|
40
|
+
if (hasContent && !window.confirm(`Delete step "${step.name || 'Untitled'}"?`)) return;
|
|
41
|
+
onChange({ ...workflow, steps: workflow.steps.filter((_, i) => i !== index) });
|
|
42
|
+
}, [workflow, onChange]);
|
|
43
|
+
|
|
44
|
+
const addStep = useCallback(() => {
|
|
45
|
+
const existingIds = workflow.steps.map(s => s.id);
|
|
46
|
+
const num = workflow.steps.length + 1;
|
|
47
|
+
const id = generateStepId(`step-${num}`, existingIds);
|
|
48
|
+
const step: WorkflowStep = { id, name: `Step ${num}`, prompt: '' };
|
|
49
|
+
onChange({ ...workflow, steps: [...workflow.steps, step] });
|
|
50
|
+
}, [workflow, onChange]);
|
|
51
|
+
|
|
52
|
+
const moveStep = useCallback((from: number, to: number) => {
|
|
53
|
+
if (to < 0 || to >= workflow.steps.length) return;
|
|
54
|
+
const steps = [...workflow.steps];
|
|
55
|
+
const [moved] = steps.splice(from, 1);
|
|
56
|
+
steps.splice(to, 0, moved);
|
|
57
|
+
onChange({ ...workflow, steps });
|
|
58
|
+
}, [workflow, onChange]);
|
|
59
|
+
|
|
60
|
+
const handleSave = async () => {
|
|
61
|
+
setSaving(true);
|
|
62
|
+
setSaveError('');
|
|
63
|
+
setSaveSuccess(false);
|
|
64
|
+
try {
|
|
65
|
+
const yaml = serializeWorkflowYaml(workflow);
|
|
66
|
+
const res = await fetch('/api/file', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ path: filePath, op: 'save_file', content: yaml }),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const data = await res.json().catch(() => ({}));
|
|
73
|
+
throw new Error(data.error || `Save failed (HTTP ${res.status})`);
|
|
74
|
+
}
|
|
75
|
+
setSaveSuccess(true);
|
|
76
|
+
onSaved?.();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setSaveError(err instanceof Error ? err.message : 'Save failed');
|
|
79
|
+
} finally {
|
|
80
|
+
setSaving(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const handler = (e: KeyboardEvent) => {
|
|
86
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
if (!saving && workflow.title.trim() && workflow.steps.length > 0) handleSave();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
window.addEventListener('keydown', handler);
|
|
92
|
+
return () => window.removeEventListener('keydown', handler);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const canSave = !saving && !!workflow.title.trim() && workflow.steps.length > 0;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div>
|
|
99
|
+
{/* ── Metadata Section ── */}
|
|
100
|
+
<div className="space-y-3 mb-8">
|
|
101
|
+
{/* Title — large, inline feel */}
|
|
102
|
+
<input type="text" value={workflow.title} onChange={e => updateMeta({ title: e.target.value })}
|
|
103
|
+
placeholder="Flow title..."
|
|
104
|
+
className="w-full text-lg font-semibold bg-transparent text-foreground placeholder:text-muted-foreground/40 focus:outline-none border-none p-0 leading-tight"
|
|
105
|
+
/>
|
|
106
|
+
{/* Description — subtle underline */}
|
|
107
|
+
<input type="text" value={workflow.description || ''} onChange={e => updateMeta({ description: e.target.value || undefined })}
|
|
108
|
+
placeholder="Add a description..."
|
|
109
|
+
className="w-full text-sm bg-transparent text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none border-none p-0"
|
|
110
|
+
/>
|
|
111
|
+
|
|
112
|
+
{/* Advanced toggle */}
|
|
113
|
+
<div className="flex items-center gap-3">
|
|
114
|
+
{!showAdvanced ? (
|
|
115
|
+
<button onClick={() => setShowAdvanced(true)}
|
|
116
|
+
className="text-2xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
|
117
|
+
+ Working directory
|
|
118
|
+
</button>
|
|
119
|
+
) : (
|
|
120
|
+
<div className="flex items-center gap-2 flex-1">
|
|
121
|
+
<FolderOpen size={12} className="text-muted-foreground/40 shrink-0" />
|
|
122
|
+
<DirPicker value={workflow.workDir || ''} onChange={v => updateMeta({ workDir: v || undefined })} />
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* ── Steps Section — Timeline style ── */}
|
|
129
|
+
{workflow.steps.length > 0 ? (
|
|
130
|
+
<div className="relative">
|
|
131
|
+
{/* Vertical timeline line */}
|
|
132
|
+
{workflow.steps.length > 1 && (
|
|
133
|
+
<div className="absolute left-[15px] top-6 bottom-16 w-px bg-border" />
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* Step list */}
|
|
137
|
+
<div className="flex flex-col gap-3 mb-5 relative">
|
|
138
|
+
{workflow.steps.map((step, i) => (
|
|
139
|
+
<div key={step.id} className="relative pl-9">
|
|
140
|
+
{/* Timeline node */}
|
|
141
|
+
<div className="absolute left-[9px] top-3 w-[13px] h-[13px] rounded-full border-2 border-border bg-background z-10 flex items-center justify-center">
|
|
142
|
+
<span className="text-[7px] font-bold text-muted-foreground/60">{i + 1}</span>
|
|
143
|
+
</div>
|
|
144
|
+
<StepEditor
|
|
145
|
+
step={step}
|
|
146
|
+
index={i}
|
|
147
|
+
onChange={s => updateStep(i, s)}
|
|
148
|
+
onDelete={() => deleteStep(i)}
|
|
149
|
+
onMoveUp={i > 0 ? () => moveStep(i, i - 1) : undefined}
|
|
150
|
+
onMoveDown={i < workflow.steps.length - 1 ? () => moveStep(i, i + 1) : undefined}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* Add step — at the end of timeline */}
|
|
157
|
+
<div className="relative pl-9">
|
|
158
|
+
<div className="absolute left-[9px] top-2.5 w-[13px] h-[13px] rounded-full border-2 border-dashed border-border bg-background z-10 flex items-center justify-center">
|
|
159
|
+
<Plus size={7} className="text-muted-foreground/40" />
|
|
160
|
+
</div>
|
|
161
|
+
<button onClick={addStep}
|
|
162
|
+
className="w-full text-left px-3 py-2 rounded-lg text-xs text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/40 transition-colors">
|
|
163
|
+
Add step...
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
) : (
|
|
168
|
+
/* Empty state */
|
|
169
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
170
|
+
<div className="w-12 h-12 rounded-2xl bg-[var(--amber)]/8 flex items-center justify-center mb-4">
|
|
171
|
+
<Zap size={22} className="text-[var(--amber)]/60" />
|
|
172
|
+
</div>
|
|
173
|
+
<p className="text-sm font-medium text-foreground mb-1">Build your flow</p>
|
|
174
|
+
<p className="text-xs text-muted-foreground/60 mb-5 max-w-[240px]">
|
|
175
|
+
Each step is a task for an AI agent. Chain them together to automate complex workflows.
|
|
176
|
+
</p>
|
|
177
|
+
<button onClick={addStep}
|
|
178
|
+
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)] hover:opacity-90 transition-opacity">
|
|
179
|
+
<Plus size={12} />
|
|
180
|
+
Add first step
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{/* ── Save bar — sticky bottom feel ── */}
|
|
186
|
+
<div className="flex items-center gap-3 mt-8 pt-4 border-t border-border/50">
|
|
187
|
+
<button onClick={handleSave} disabled={!canSave}
|
|
188
|
+
title={!workflow.title.trim() ? 'Title is required' : workflow.steps.length === 0 ? 'Add at least one step' : undefined}
|
|
189
|
+
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-medium transition-all disabled:opacity-40 disabled:cursor-not-allowed ${
|
|
190
|
+
canSave
|
|
191
|
+
? 'bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90'
|
|
192
|
+
: 'bg-muted text-muted-foreground'
|
|
193
|
+
}`}>
|
|
194
|
+
{saving ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
|
195
|
+
{saving ? 'Saving...' : 'Save'}
|
|
196
|
+
</button>
|
|
197
|
+
|
|
198
|
+
{saveError && <span className="text-xs text-[var(--error)]">{saveError}</span>}
|
|
199
|
+
|
|
200
|
+
{saveSuccess && !saveError && (
|
|
201
|
+
<span className="flex items-center gap-1 text-2xs text-[var(--success)]">
|
|
202
|
+
<CheckCircle2 size={11} />
|
|
203
|
+
Saved
|
|
204
|
+
</span>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
<kbd className="text-2xs text-muted-foreground/30 ml-auto font-mono">Ctrl+S</kbd>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
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, ArrowRight } 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 formatDuration(ms: number): string {
|
|
15
|
+
if (ms < 1000) return `${ms}ms`;
|
|
16
|
+
const secs = Math.round(ms / 1000);
|
|
17
|
+
if (secs < 60) return `${secs}s`;
|
|
18
|
+
return `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Timeline node — the colored circle on the left */
|
|
22
|
+
function TimelineNode({ status, index }: { status: StepStatus; index: number }) {
|
|
23
|
+
const base = 'w-[26px] h-[26px] rounded-full flex items-center justify-center shrink-0 transition-all';
|
|
24
|
+
if (status === 'running') return (
|
|
25
|
+
<div className={`${base} bg-[var(--amber)]/15 ring-2 ring-[var(--amber)]/30`}>
|
|
26
|
+
<Loader2 size={12} className="text-[var(--amber)] animate-spin" />
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
if (status === 'done') return (
|
|
30
|
+
<div className={`${base} bg-[var(--success)]/15`}>
|
|
31
|
+
<CheckCircle2 size={13} className="text-[var(--success)]" />
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
if (status === 'error') return (
|
|
35
|
+
<div className={`${base} bg-[var(--error)]/15`}>
|
|
36
|
+
<AlertCircle size={13} className="text-[var(--error)]" />
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
if (status === 'skipped') return (
|
|
40
|
+
<div className={`${base} bg-muted/50`}>
|
|
41
|
+
<SkipForward size={11} className="text-muted-foreground/40" />
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
// pending
|
|
45
|
+
return (
|
|
46
|
+
<div className={`${base} border-2 border-border bg-background`}>
|
|
47
|
+
<span className="text-[9px] font-bold text-muted-foreground/40">{index + 1}</span>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function RunStepCard({ step, canRun, onRun, onSkip, onCancel }: {
|
|
53
|
+
step: WorkflowStepRuntime; canRun: boolean;
|
|
54
|
+
onRun: () => void; onSkip: () => void; onCancel: () => void;
|
|
55
|
+
}) {
|
|
56
|
+
const [expanded, setExpanded] = useState(false);
|
|
57
|
+
const hasOutput = !!(step.output || step.error);
|
|
58
|
+
const allSkills = step.skills?.length ? step.skills : (step.skill ? [step.skill] : []);
|
|
59
|
+
const isActive = step.status === 'running' || step.status === 'pending';
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={`transition-opacity ${step.status === 'skipped' ? 'opacity-50' : ''}`}>
|
|
63
|
+
{/* Main row */}
|
|
64
|
+
<div className="flex items-start gap-3">
|
|
65
|
+
<TimelineNode status={step.status} index={step.index} />
|
|
66
|
+
<div className="flex-1 min-w-0 pt-0.5">
|
|
67
|
+
{/* Name + meta */}
|
|
68
|
+
<div className="flex items-center gap-2 justify-between">
|
|
69
|
+
<span className={`text-sm font-medium truncate ${
|
|
70
|
+
step.status === 'done' ? 'text-foreground' :
|
|
71
|
+
step.status === 'running' ? 'text-[var(--amber)]' :
|
|
72
|
+
step.status === 'error' ? 'text-[var(--error)]' :
|
|
73
|
+
'text-foreground/70'
|
|
74
|
+
}`}>
|
|
75
|
+
{step.name}
|
|
76
|
+
</span>
|
|
77
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
78
|
+
{step.durationMs != null && (step.status === 'done' || step.status === 'error') && (
|
|
79
|
+
<span className="text-2xs text-muted-foreground/40 font-mono">{formatDuration(step.durationMs)}</span>
|
|
80
|
+
)}
|
|
81
|
+
{step.status === 'pending' && (
|
|
82
|
+
<>
|
|
83
|
+
<button onClick={onRun} disabled={!canRun}
|
|
84
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded-md text-2xs font-medium transition-all disabled:opacity-30 bg-[var(--amber)] text-[var(--amber-foreground)]"
|
|
85
|
+
><Play size={9} /> Run</button>
|
|
86
|
+
<button onClick={onSkip}
|
|
87
|
+
className="px-1.5 py-0.5 rounded-md text-2xs text-muted-foreground/50 hover:text-muted-foreground hover:bg-muted transition-colors">
|
|
88
|
+
Skip
|
|
89
|
+
</button>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
{step.status === 'running' && (
|
|
93
|
+
<button onClick={onCancel}
|
|
94
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded-md text-2xs text-[var(--error)]/70 hover:text-[var(--error)] hover:bg-[var(--error)]/10 transition-colors">
|
|
95
|
+
<XCircle size={9} /> Stop
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
{hasOutput && step.status !== 'running' && (
|
|
99
|
+
<button onClick={() => setExpanded(v => !v)}
|
|
100
|
+
className="p-0.5 rounded text-muted-foreground/30 hover:text-muted-foreground transition-colors">
|
|
101
|
+
<ChevronDown size={12} className={`transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Agent/skill badges */}
|
|
108
|
+
{(step.agent || allSkills.length > 0) && (
|
|
109
|
+
<div className="flex gap-1 mt-0.5 flex-wrap">
|
|
110
|
+
{step.agent && <span className="text-2xs text-muted-foreground/50">{step.agent}</span>}
|
|
111
|
+
{step.agent && allSkills.length > 0 && <span className="text-2xs text-muted-foreground/20">/</span>}
|
|
112
|
+
{allSkills.map(s => <span key={s} className="text-2xs text-[var(--amber)]/60">{s}</span>)}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Output area — auto-show when running */}
|
|
117
|
+
{(expanded || step.status === 'running') && hasOutput && (
|
|
118
|
+
<div className="mt-2 rounded-lg overflow-hidden border border-border/50">
|
|
119
|
+
{step.error && (
|
|
120
|
+
<div className="px-3 py-2.5 bg-[var(--error)]/5">
|
|
121
|
+
<div className="flex items-center gap-1.5 mb-1">
|
|
122
|
+
<AlertCircle size={10} className="text-[var(--error)]" />
|
|
123
|
+
<span className="text-2xs font-medium text-[var(--error)]">Error</span>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="text-xs text-[var(--error)]/80 whitespace-pre-wrap break-words leading-relaxed">{step.error}</div>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
{step.output && (
|
|
129
|
+
<div className="px-3 py-2.5 bg-muted/20">
|
|
130
|
+
<div className="flex items-center gap-1.5 mb-1.5">
|
|
131
|
+
<Sparkles size={10} className="text-[var(--amber)]" />
|
|
132
|
+
<span className="text-2xs text-muted-foreground/50 uppercase tracking-wider font-medium">Output</span>
|
|
133
|
+
{step.status === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)] animate-pulse" />}
|
|
134
|
+
</div>
|
|
135
|
+
<div className="text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words">{step.output}</div>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Main Runner ──────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export default function WorkflowRunner({ workflow, filePath }: { workflow: WorkflowYaml; filePath: string }) {
|
|
149
|
+
const [steps, setSteps] = useState<WorkflowStepRuntime[]>(() => initSteps(workflow));
|
|
150
|
+
const [running, setRunning] = useState(false);
|
|
151
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
setSteps(initSteps(workflow));
|
|
155
|
+
setRunning(false);
|
|
156
|
+
}, [workflow]);
|
|
157
|
+
|
|
158
|
+
const cancelExecution = useCallback(() => {
|
|
159
|
+
abortRef.current?.abort();
|
|
160
|
+
abortRef.current = null;
|
|
161
|
+
setRunning(false);
|
|
162
|
+
setSteps(prev => prev.map(s => s.status === 'running' ? { ...s, status: 'error', error: 'Cancelled' } : s));
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const runStep = useCallback(async (idx: number) => {
|
|
166
|
+
if (running) return;
|
|
167
|
+
abortRef.current?.abort();
|
|
168
|
+
const ctrl = new AbortController();
|
|
169
|
+
abortRef.current = ctrl;
|
|
170
|
+
setRunning(true);
|
|
171
|
+
const startTime = Date.now();
|
|
172
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'running', output: '', error: undefined, startedAt: new Date() } : s));
|
|
173
|
+
try {
|
|
174
|
+
const step = workflow.steps[idx];
|
|
175
|
+
const runtimeStep: WorkflowStepRuntime = { ...step, index: idx, status: 'running', output: '' };
|
|
176
|
+
await runStepWithAI(runtimeStep, workflow, filePath,
|
|
177
|
+
(acc) => setSteps(prev => prev.map((s, i) => i === idx ? { ...s, output: acc } : s)),
|
|
178
|
+
ctrl.signal);
|
|
179
|
+
const duration = Date.now() - startTime;
|
|
180
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'done', completedAt: new Date(), durationMs: duration } : s));
|
|
181
|
+
} catch (err: unknown) {
|
|
182
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
183
|
+
const duration = Date.now() - startTime;
|
|
184
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'error', error: err instanceof Error ? err.message : String(err), durationMs: duration } : s));
|
|
185
|
+
} finally { setRunning(false); }
|
|
186
|
+
}, [running, workflow, filePath]);
|
|
187
|
+
|
|
188
|
+
const skipStep = useCallback((idx: number) => {
|
|
189
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'skipped' } : s));
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const reset = useCallback(() => {
|
|
193
|
+
abortRef.current?.abort();
|
|
194
|
+
abortRef.current = null;
|
|
195
|
+
setRunning(false);
|
|
196
|
+
clearSkillCache();
|
|
197
|
+
setSteps(initSteps(workflow));
|
|
198
|
+
}, [workflow]);
|
|
199
|
+
|
|
200
|
+
const doneCount = steps.filter(s => s.status === 'done').length;
|
|
201
|
+
const progress = steps.length > 0 ? Math.round((doneCount / steps.length) * 100) : 0;
|
|
202
|
+
const allDone = doneCount === steps.length && steps.length > 0;
|
|
203
|
+
const nextPendingIdx = steps.findIndex(s => s.status === 'pending');
|
|
204
|
+
const hasErrors = steps.some(s => s.status === 'error');
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div>
|
|
208
|
+
{/* Progress bar — full width, thin, elegant */}
|
|
209
|
+
<div className="mb-6">
|
|
210
|
+
<div className="flex items-center justify-between mb-2">
|
|
211
|
+
<div className="flex items-center gap-2">
|
|
212
|
+
{allDone ? (
|
|
213
|
+
<span className="flex items-center gap-1.5 text-xs font-medium text-[var(--success)]">
|
|
214
|
+
<CheckCircle2 size={13} />
|
|
215
|
+
Complete
|
|
216
|
+
</span>
|
|
217
|
+
) : hasErrors ? (
|
|
218
|
+
<span className="flex items-center gap-1.5 text-xs font-medium text-[var(--error)]">
|
|
219
|
+
<AlertCircle size={13} />
|
|
220
|
+
{doneCount}/{steps.length} done
|
|
221
|
+
</span>
|
|
222
|
+
) : (
|
|
223
|
+
<span className="text-xs text-muted-foreground">
|
|
224
|
+
{doneCount}/{steps.length}
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
<div className="flex items-center gap-1.5">
|
|
229
|
+
{nextPendingIdx >= 0 && (
|
|
230
|
+
<button onClick={() => runStep(nextPendingIdx)} disabled={running}
|
|
231
|
+
className="flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-all disabled:opacity-40 bg-[var(--amber)] text-[var(--amber-foreground)]">
|
|
232
|
+
{running ? <Loader2 size={11} className="animate-spin" /> : <ArrowRight size={11} />}
|
|
233
|
+
{running ? 'Running...' : 'Run next'}
|
|
234
|
+
</button>
|
|
235
|
+
)}
|
|
236
|
+
<button onClick={reset}
|
|
237
|
+
className="flex items-center gap-1 px-2 py-1 rounded-md text-2xs text-muted-foreground/50 hover:text-muted-foreground hover:bg-muted transition-colors">
|
|
238
|
+
<RotateCcw size={10} />
|
|
239
|
+
Reset
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="h-1 rounded-full bg-border/50 overflow-hidden">
|
|
244
|
+
<div
|
|
245
|
+
className={`h-full rounded-full transition-all duration-500 ease-out ${
|
|
246
|
+
allDone ? 'bg-[var(--success)]' : hasErrors ? 'bg-[var(--error)]' : 'bg-[var(--amber)]'
|
|
247
|
+
}`}
|
|
248
|
+
style={{ width: `${progress}%` }}
|
|
249
|
+
/>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* Timeline step list */}
|
|
254
|
+
<div className="relative">
|
|
255
|
+
{/* Vertical line */}
|
|
256
|
+
{steps.length > 1 && (
|
|
257
|
+
<div className="absolute left-[12px] top-5 bottom-5 w-px bg-border/50" />
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
<div className="flex flex-col gap-4">
|
|
261
|
+
{steps.map((step, i) => (
|
|
262
|
+
<RunStepCard key={step.id} step={step} canRun={!running}
|
|
263
|
+
onRun={() => runStep(i)} onSkip={() => skipStep(i)} onCancel={cancelExecution} />
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|