forked from coleam00/Archon
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWorkflowList.tsx
More file actions
292 lines (275 loc) · 10.7 KB
/
Copy pathWorkflowList.tsx
File metadata and controls
292 lines (275 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import { useEffect, useRef, useState, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { Search, X } from 'lucide-react';
import { listWorkflows, createConversation, runWorkflow, deleteConversation } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { useProject } from '@/contexts/ProjectContext';
import { WorkflowCard } from '@/components/workflows/WorkflowCard';
import {
getWorkflowCategory,
getWorkflowDisplayName,
CATEGORIES,
type WorkflowCategory,
} from '@/lib/workflow-metadata';
export function WorkflowList(): React.ReactElement {
const navigate = useNavigate();
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
const [runMessage, setRunMessage] = useState('');
const [running, setRunning] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<WorkflowCategory>('All');
const { codebases, selectedProjectId } = useProject();
const [localProjectId, setLocalProjectId] = useState<string | null>(selectedProjectId);
const messageInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalProjectId(selectedProjectId);
}, [selectedProjectId]);
// Focus message input when a workflow is selected
useEffect(() => {
if (selectedWorkflow) {
requestAnimationFrame(() => {
messageInputRef.current?.focus();
});
}
}, [selectedWorkflow]);
// Reset selection when filters change so stale run panel state doesn't persist
useEffect(() => {
setSelectedWorkflow(null);
setRunMessage('');
setRunError(null);
}, [searchQuery, activeCategory]);
const handleRun = async (workflowName: string): Promise<void> => {
if (!runMessage.trim() || running) return;
setRunning(true);
setRunError(null);
let conversationId: string | undefined;
let workflowStarted = false;
try {
({ conversationId } = await createConversation(localProjectId ?? undefined));
await runWorkflow(workflowName, conversationId, runMessage.trim());
workflowStarted = true;
setRunMessage('');
setSelectedWorkflow(null);
navigate(`/chat/${conversationId}`);
} catch (error) {
console.error('[Workflows] Failed to run workflow', { error });
setRunError(
error instanceof Error
? `Failed to start workflow: ${error.message}`
: 'Failed to start workflow. Check server connectivity.'
);
if (conversationId !== undefined && !workflowStarted) {
void deleteConversation(conversationId).catch((cleanupErr: unknown) => {
console.warn('[Workflows] Failed to clean up orphan conversation', {
conversationId,
error: cleanupErr,
});
});
}
} finally {
setRunning(false);
}
};
const selectedCwd = localProjectId
? codebases?.find(cb => cb.id === localProjectId)?.default_cwd
: undefined;
const {
data: workflows,
isLoading: loadingWorkflows,
isError: workflowsError,
} = useQuery({
queryKey: ['workflows', selectedCwd ?? null],
queryFn: () => listWorkflows(selectedCwd),
});
// Filter workflows by search query and category
const filteredWorkflows = useMemo(() => {
if (!workflows) return [];
return workflows
.map(entry => entry.workflow)
.filter(wf => {
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesName = wf.name.toLowerCase().includes(query);
const matchesDesc = wf.description?.toLowerCase().includes(query) ?? false;
if (!matchesName && !matchesDesc) return false;
}
// Category filter
if (activeCategory !== 'All') {
const cat = getWorkflowCategory(wf.name, wf.description ?? '');
if (cat !== activeCategory) return false;
}
return true;
});
}, [workflows, searchQuery, activeCategory]);
if (loadingWorkflows) {
return (
<div className="flex items-center justify-center h-32 text-text-secondary text-sm">
Loading workflows...
</div>
);
}
if (workflowsError) {
return (
<div className="text-sm text-error">Failed to load workflows. Check server connectivity.</div>
);
}
const hasWorkflows = workflows != null && workflows.length > 0;
const displayName = selectedWorkflow ? getWorkflowDisplayName(selectedWorkflow) : '';
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-auto space-y-4 p-0">
{/* Search + Category Filters — only show when workflows exist */}
{hasWorkflows && (
<div className="space-y-3">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-text-tertiary" />
<input
type="text"
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
}}
placeholder="Search workflows..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-surface text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
{/* Category filter tabs */}
<div className="flex items-center gap-2 flex-wrap">
{CATEGORIES.map(cat => (
<button
key={cat}
onClick={(): void => {
setActiveCategory(cat);
}}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
activeCategory === cat
? 'bg-primary text-white'
: 'bg-surface-elevated text-text-secondary hover:text-text-primary'
}`}
>
{cat}
</button>
))}
</div>
</div>
)}
{/* Workflow grid */}
{!hasWorkflows ? (
<div className="text-sm text-text-secondary">
{localProjectId ? (
<>
No workflows found in this project. Add workflow definitions to{' '}
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">
.archon/workflows/
</code>{' '}
in the project root.
</>
) : (
<>
No workflows are available. Bundled defaults should appear here automatically; if
they do not, check that{' '}
<code className="text-xs bg-surface-inset px-1 py-0.5 rounded">
defaults.loadDefaultWorkflows
</code>{' '}
is enabled in your config.
</>
)}
</div>
) : filteredWorkflows.length === 0 ? (
<div className="text-sm text-text-secondary py-8 text-center">
No workflows match your search.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredWorkflows.map(wf => (
<WorkflowCard
key={wf.name}
workflow={wf}
isSelected={selectedWorkflow === wf.name}
onToggle={(name): void => {
setSelectedWorkflow(selectedWorkflow === name ? null : name);
setRunMessage('');
setRunError(null);
}}
onRun={(name): void => {
setSelectedWorkflow(name);
setRunMessage('');
setRunError(null);
}}
/>
))}
</div>
)}
</div>
{/* Sticky run bar — anchored at bottom, slides up with glow when workflow selected */}
{selectedWorkflow && (
<div className="shrink-0 border-t border-accent/40 bg-surface-elevated px-4 py-3 animate-slide-up shadow-[0_-4px_20px_rgba(59,130,246,0.15)]">
<div className="flex items-center gap-3">
{/* Workflow name + dismiss */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-medium text-text-primary">{displayName}</span>
<button
onClick={(): void => {
setSelectedWorkflow(null);
setRunMessage('');
setRunError(null);
}}
className="p-0.5 rounded text-text-tertiary hover:text-text-primary transition-colors"
title="Dismiss"
>
<X className="size-3.5" />
</button>
</div>
{/* Project picker */}
<select
value={localProjectId ?? ''}
onChange={(e): void => {
setLocalProjectId(e.target.value || null);
}}
className="w-48 shrink-0 rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
>
<option value="">No project</option>
{codebases?.map(cb => (
<option key={cb.id} value={cb.id}>
{cb.name}
</option>
))}
</select>
{/* Message input + Run button */}
<input
ref={messageInputRef}
type="text"
value={runMessage}
onChange={(e): void => {
setRunMessage(e.target.value);
}}
placeholder="Enter a message for this workflow..."
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border border-border bg-surface text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
onKeyDown={(e): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleRun(selectedWorkflow);
}
}}
disabled={running}
/>
<Button
size="sm"
onClick={(): void => {
void handleRun(selectedWorkflow);
}}
disabled={running || !runMessage.trim()}
>
{running ? 'Starting...' : 'Run'}
</Button>
</div>
{runError && <p className="text-xs text-error mt-1">{runError}</p>}
</div>
)}
</div>
);
}