@geminilight/mindos 0.6.28 → 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 (113) hide show
  1. package/README.md +10 -4
  2. package/app/app/api/a2a/agents/route.ts +9 -0
  3. package/app/app/api/a2a/delegations/route.ts +9 -0
  4. package/app/app/api/a2a/discover/route.ts +2 -0
  5. package/app/app/api/a2a/route.ts +6 -6
  6. package/app/app/api/acp/config/route.ts +82 -0
  7. package/app/app/api/acp/detect/route.ts +114 -0
  8. package/app/app/api/acp/install/route.ts +51 -0
  9. package/app/app/api/acp/registry/route.ts +31 -0
  10. package/app/app/api/acp/session/route.ts +185 -0
  11. package/app/app/api/ask/route.ts +116 -13
  12. package/app/app/api/workflows/route.ts +156 -0
  13. package/app/app/layout.tsx +2 -0
  14. package/app/app/page.tsx +7 -2
  15. package/app/components/ActivityBar.tsx +12 -4
  16. package/app/components/AskModal.tsx +4 -1
  17. package/app/components/DirView.tsx +64 -2
  18. package/app/components/FileTree.tsx +40 -10
  19. package/app/components/GuideCard.tsx +7 -17
  20. package/app/components/HomeContent.tsx +1 -0
  21. package/app/components/MarkdownView.tsx +2 -0
  22. package/app/components/Panel.tsx +1 -0
  23. package/app/components/RightAskPanel.tsx +5 -1
  24. package/app/components/SearchModal.tsx +234 -80
  25. package/app/components/SidebarLayout.tsx +6 -0
  26. package/app/components/agents/AgentDetailContent.tsx +266 -52
  27. package/app/components/agents/AgentsContentPage.tsx +32 -6
  28. package/app/components/agents/AgentsPanelA2aTab.tsx +684 -0
  29. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  30. package/app/components/agents/SkillDetailPopover.tsx +4 -9
  31. package/app/components/agents/agents-content-model.ts +2 -2
  32. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  33. package/app/components/ask/AskContent.tsx +197 -239
  34. package/app/components/ask/FileChip.tsx +82 -17
  35. package/app/components/ask/MentionPopover.tsx +21 -3
  36. package/app/components/ask/MessageList.tsx +30 -9
  37. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  38. package/app/components/help/HelpContent.tsx +9 -9
  39. package/app/components/panels/AgentsPanel.tsx +2 -0
  40. package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -8
  41. package/app/components/panels/AgentsPanelHubNav.tsx +16 -2
  42. package/app/components/panels/EchoPanel.tsx +5 -1
  43. package/app/components/panels/EchoSidebarStats.tsx +136 -0
  44. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  45. package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
  46. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
  47. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
  48. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  49. package/app/components/renderers/workflow-yaml/execution.ts +177 -0
  50. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  51. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  52. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  53. package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
  54. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  55. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  56. package/app/components/settings/KnowledgeTab.tsx +3 -6
  57. package/app/components/settings/McpSkillsSection.tsx +4 -5
  58. package/app/components/settings/McpTab.tsx +6 -8
  59. package/app/components/setup/StepSecurity.tsx +4 -5
  60. package/app/components/setup/index.tsx +5 -11
  61. package/app/components/ui/Toaster.tsx +39 -0
  62. package/app/hooks/useA2aRegistry.ts +6 -1
  63. package/app/hooks/useAcpConfig.ts +96 -0
  64. package/app/hooks/useAcpDetection.ts +120 -0
  65. package/app/hooks/useAcpRegistry.ts +86 -0
  66. package/app/hooks/useAskModal.ts +12 -5
  67. package/app/hooks/useAskPanel.ts +8 -5
  68. package/app/hooks/useAskSession.ts +19 -2
  69. package/app/hooks/useDelegationHistory.ts +49 -0
  70. package/app/hooks/useImageUpload.ts +152 -0
  71. package/app/lib/a2a/client.ts +49 -5
  72. package/app/lib/a2a/orchestrator.ts +0 -1
  73. package/app/lib/a2a/task-handler.ts +4 -4
  74. package/app/lib/a2a/types.ts +15 -0
  75. package/app/lib/acp/acp-tools.ts +95 -0
  76. package/app/lib/acp/agent-descriptors.ts +274 -0
  77. package/app/lib/acp/bridge.ts +144 -0
  78. package/app/lib/acp/index.ts +40 -0
  79. package/app/lib/acp/registry.ts +202 -0
  80. package/app/lib/acp/session.ts +717 -0
  81. package/app/lib/acp/subprocess.ts +495 -0
  82. package/app/lib/acp/types.ts +274 -0
  83. package/app/lib/agent/model.ts +18 -3
  84. package/app/lib/agent/to-agent-messages.ts +25 -2
  85. package/app/lib/agent/tools.ts +2 -1
  86. package/app/lib/i18n/_core.ts +22 -0
  87. package/app/lib/i18n/index.ts +35 -0
  88. package/app/lib/i18n/modules/ai-chat.ts +215 -0
  89. package/app/lib/i18n/modules/common.ts +71 -0
  90. package/app/lib/i18n/modules/features.ts +153 -0
  91. package/app/lib/i18n/modules/knowledge.ts +429 -0
  92. package/app/lib/i18n/modules/navigation.ts +153 -0
  93. package/app/lib/i18n/modules/onboarding.ts +523 -0
  94. package/app/lib/i18n/modules/panels.ts +1196 -0
  95. package/app/lib/i18n/modules/settings.ts +585 -0
  96. package/app/lib/i18n-en.ts +2 -1518
  97. package/app/lib/i18n-zh.ts +2 -1542
  98. package/app/lib/i18n.ts +3 -6
  99. package/app/lib/pi-integration/skills.ts +21 -6
  100. package/app/lib/renderers/index.ts +2 -2
  101. package/app/lib/settings.ts +10 -0
  102. package/app/lib/toast.ts +79 -0
  103. package/app/lib/types.ts +12 -1
  104. package/app/next-env.d.ts +1 -1
  105. package/app/package.json +3 -1
  106. package/bin/cli.js +25 -25
  107. package/bin/commands/file.js +29 -2
  108. package/bin/commands/space.js +249 -91
  109. package/package.json +1 -1
  110. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  111. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  112. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  113. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import Link from 'next/link';
5
+ import { Plus, Zap, AlertTriangle, Loader2 } from 'lucide-react';
6
+ import PanelHeader from './PanelHeader';
7
+ import { useLocale } from '@/lib/LocaleContext';
8
+ import { encodePath, relativeTime } from '@/lib/utils';
9
+
10
+ interface WorkflowItem {
11
+ path: string;
12
+ fileName: string;
13
+ title: string;
14
+ description?: string;
15
+ stepCount: number;
16
+ mtime: number;
17
+ error?: string;
18
+ }
19
+
20
+ interface WorkflowsPanelProps {
21
+ active: boolean;
22
+ maximized?: boolean;
23
+ onMaximize?: () => void;
24
+ }
25
+
26
+ export default function WorkflowsPanel({ active, maximized, onMaximize }: WorkflowsPanelProps) {
27
+ const { t } = useLocale();
28
+ const wt = t.panels.workflows as {
29
+ title: string;
30
+ empty: string;
31
+ emptyDesc: string;
32
+ newWorkflow: string;
33
+ nSteps: (n: number) => string;
34
+ parseError: string;
35
+ name: string;
36
+ namePlaceholder: string;
37
+ template: string;
38
+ templateBlank: string;
39
+ create: string;
40
+ cancel: string;
41
+ creating: string;
42
+ exists: string;
43
+ };
44
+
45
+ const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
46
+ const [loading, setLoading] = useState(true);
47
+ const [showCreate, setShowCreate] = useState(false);
48
+ const [newName, setNewName] = useState('');
49
+ const [creating, setCreating] = useState(false);
50
+ const [createError, setCreateError] = useState('');
51
+
52
+ const fetchWorkflows = useCallback(async () => {
53
+ try {
54
+ const res = await fetch('/api/workflows');
55
+ if (!res.ok) return;
56
+ const data = await res.json();
57
+ setWorkflows(data.workflows ?? []);
58
+ } catch {
59
+ // silent
60
+ } finally {
61
+ setLoading(false);
62
+ }
63
+ }, []);
64
+
65
+ useEffect(() => {
66
+ if (active) fetchWorkflows();
67
+ }, [active, fetchWorkflows]);
68
+
69
+ const handleCreate = async () => {
70
+ const name = newName.trim();
71
+ if (!name || creating) return;
72
+ setCreating(true);
73
+ setCreateError('');
74
+
75
+ try {
76
+ const res = await fetch('/api/workflows', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ name }),
80
+ });
81
+ const data = await res.json();
82
+ if (!res.ok) {
83
+ setCreateError(res.status === 409 ? wt.exists : (data.error || 'Error'));
84
+ return;
85
+ }
86
+ // Refresh list and navigate to new file
87
+ setShowCreate(false);
88
+ setNewName('');
89
+ await fetchWorkflows();
90
+ window.location.href = `/view/${encodePath(data.path)}`;
91
+ } catch {
92
+ setCreateError('Network error');
93
+ } finally {
94
+ setCreating(false);
95
+ }
96
+ };
97
+
98
+ return (
99
+ <div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
100
+ <PanelHeader title={wt.title} maximized={maximized} onMaximize={onMaximize}>
101
+ <button
102
+ onClick={() => setShowCreate(v => !v)}
103
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors focus-visible:ring-1 focus-visible:ring-ring"
104
+ aria-label={wt.newWorkflow}
105
+ title={wt.newWorkflow}
106
+ >
107
+ <Plus size={13} />
108
+ </button>
109
+ </PanelHeader>
110
+
111
+ <div className="flex-1 overflow-y-auto min-h-0">
112
+ {/* Create form */}
113
+ {showCreate && (
114
+ <div className="px-3 py-3 border-b border-border">
115
+ <label className="block text-xs font-medium text-muted-foreground mb-1.5">{wt.name}</label>
116
+ <input
117
+ type="text"
118
+ value={newName}
119
+ onChange={e => { setNewName(e.target.value); setCreateError(''); }}
120
+ onKeyDown={e => e.key === 'Enter' && handleCreate()}
121
+ placeholder={wt.namePlaceholder}
122
+ autoFocus
123
+ 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:ring-1 focus:ring-ring"
124
+ />
125
+ {createError && (
126
+ <p className="text-xs text-[var(--error)] mt-1">{createError}</p>
127
+ )}
128
+ <div className="flex gap-2 mt-2.5">
129
+ <button
130
+ onClick={() => { setShowCreate(false); setNewName(''); setCreateError(''); }}
131
+ className="flex-1 px-3 py-1.5 text-xs rounded-md border border-border text-muted-foreground hover:bg-muted transition-colors"
132
+ >
133
+ {wt.cancel}
134
+ </button>
135
+ <button
136
+ onClick={handleCreate}
137
+ disabled={!newName.trim() || creating}
138
+ className="flex-1 px-3 py-1.5 text-xs rounded-md font-medium transition-colors disabled:opacity-50 bg-[var(--amber)] text-[var(--amber-foreground)]"
139
+ >
140
+ {creating ? wt.creating : wt.create}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ )}
145
+
146
+ {/* Loading */}
147
+ {loading && (
148
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
149
+ <Loader2 size={16} className="animate-spin" />
150
+ </div>
151
+ )}
152
+
153
+ {/* Empty state */}
154
+ {!loading && workflows.length === 0 && !showCreate && (
155
+ <div className="flex flex-col items-center justify-center py-10 px-4 text-center">
156
+ <Zap size={24} className="text-muted-foreground/40 mb-3" />
157
+ <p className="text-sm font-medium text-muted-foreground mb-1">{wt.empty}</p>
158
+ <p className="text-xs text-muted-foreground/70 mb-4 max-w-[200px]">{wt.emptyDesc}</p>
159
+ <button
160
+ onClick={() => setShowCreate(true)}
161
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors"
162
+ >
163
+ <Plus size={12} />
164
+ {wt.newWorkflow}
165
+ </button>
166
+ </div>
167
+ )}
168
+
169
+ {/* Workflow list */}
170
+ {!loading && workflows.length > 0 && (
171
+ <div className="flex flex-col gap-0.5 py-1.5">
172
+ {workflows.map(w => (
173
+ <Link
174
+ key={w.path}
175
+ href={`/view/${encodePath(w.path)}`}
176
+ className={`flex items-start gap-2.5 px-3 py-2 mx-1 rounded-lg transition-colors hover:bg-muted ${
177
+ w.error ? 'opacity-70' : ''
178
+ }`}
179
+ >
180
+ <Zap size={14} className="shrink-0 mt-0.5 text-[var(--amber)]" />
181
+ <div className="min-w-0 flex-1">
182
+ <span className="text-sm font-medium text-foreground block truncate">{w.title}</span>
183
+ <div className="flex items-center gap-2 mt-0.5">
184
+ {w.error ? (
185
+ <span className="inline-flex items-center gap-1 text-2xs text-[var(--error)]">
186
+ <AlertTriangle size={10} />
187
+ {wt.parseError}
188
+ </span>
189
+ ) : (
190
+ <span className="text-2xs text-muted-foreground">
191
+ {wt.nSteps(w.stepCount)}
192
+ </span>
193
+ )}
194
+ <span className="text-2xs text-muted-foreground/60" suppressHydrationWarning>
195
+ {relativeTime(w.mtime, t.home?.relativeTime)}
196
+ </span>
197
+ </div>
198
+ </div>
199
+ </Link>
200
+ ))}
201
+ </div>
202
+ )}
203
+ </div>
204
+ </div>
205
+ );
206
+ }
@@ -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
+ }