rufloui 0.3.1
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/'1' +0 -0
- package/.env.example +46 -0
- package/CHANGELOG.md +87 -0
- package/CLAUDE.md +287 -0
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/Webhooks) +0 -0
- package/docs/plans/2026-03-11-github-webhooks.md +957 -0
- package/docs/screenshot-swarm-monitor.png +0 -0
- package/frontend +0 -0
- package/index.html +13 -0
- package/package.json +56 -0
- package/public/vite.svg +4 -0
- package/src/backend/__tests__/webhook-github.test.ts +934 -0
- package/src/backend/jsonl-monitor.ts +430 -0
- package/src/backend/server.ts +2972 -0
- package/src/backend/telegram-bot.ts +511 -0
- package/src/backend/webhook-github.ts +350 -0
- package/src/frontend/App.tsx +461 -0
- package/src/frontend/api.ts +281 -0
- package/src/frontend/components/ErrorBoundary.tsx +98 -0
- package/src/frontend/components/Layout.tsx +431 -0
- package/src/frontend/components/ui/Button.tsx +111 -0
- package/src/frontend/components/ui/Card.tsx +51 -0
- package/src/frontend/components/ui/StatusBadge.tsx +60 -0
- package/src/frontend/main.tsx +63 -0
- package/src/frontend/pages/AgentVizPanel.tsx +428 -0
- package/src/frontend/pages/AgentsPanel.tsx +445 -0
- package/src/frontend/pages/ConfigPanel.tsx +661 -0
- package/src/frontend/pages/Dashboard.tsx +482 -0
- package/src/frontend/pages/HiveMindPanel.tsx +355 -0
- package/src/frontend/pages/HooksPanel.tsx +240 -0
- package/src/frontend/pages/LogsPanel.tsx +261 -0
- package/src/frontend/pages/MemoryPanel.tsx +444 -0
- package/src/frontend/pages/NeuralPanel.tsx +301 -0
- package/src/frontend/pages/PerformancePanel.tsx +198 -0
- package/src/frontend/pages/SessionsPanel.tsx +428 -0
- package/src/frontend/pages/SetupWizard.tsx +181 -0
- package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
- package/src/frontend/pages/SwarmPanel.tsx +322 -0
- package/src/frontend/pages/TasksPanel.tsx +535 -0
- package/src/frontend/pages/WebhooksPanel.tsx +335 -0
- package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
- package/src/frontend/store.ts +185 -0
- package/src/frontend/styles/global.css +113 -0
- package/src/frontend/test-setup.ts +1 -0
- package/src/frontend/tour/TourContext.tsx +161 -0
- package/src/frontend/tour/tourSteps.ts +181 -0
- package/src/frontend/tour/tourStyles.css +116 -0
- package/src/frontend/types.ts +239 -0
- package/src/frontend/utils/formatTime.test.ts +83 -0
- package/src/frontend/utils/formatTime.ts +23 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +17 -0
- package/{,+ +0 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback, useRef, type CSSProperties } from 'react'
|
|
2
|
+
import { useStore } from '@/store'
|
|
3
|
+
import { api } from '@/api'
|
|
4
|
+
import { Card } from '@/components/ui/Card'
|
|
5
|
+
import { Button } from '@/components/ui/Button'
|
|
6
|
+
import { StatusBadge } from '@/components/ui/StatusBadge'
|
|
7
|
+
import type { Task } from '@/types'
|
|
8
|
+
|
|
9
|
+
const PRIORITY_COLORS: Record<string, string> = {
|
|
10
|
+
critical: 'var(--accent-red)',
|
|
11
|
+
high: 'var(--accent-orange)',
|
|
12
|
+
normal: 'var(--accent-blue)',
|
|
13
|
+
low: 'var(--text-muted)',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getPriorityBadge(priority: string): CSSProperties {
|
|
17
|
+
return {
|
|
18
|
+
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10,
|
|
19
|
+
background: `${PRIORITY_COLORS[priority] || 'var(--text-muted)'}20`, color: PRIORITY_COLORS[priority] || 'var(--text-muted)',
|
|
20
|
+
textTransform: 'uppercase', letterSpacing: '0.04em',
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const COLUMNS: Array<{ key: string; label: string; statuses: string[] }> = [
|
|
25
|
+
{ key: 'pending', label: 'Pending', statuses: ['pending'] },
|
|
26
|
+
{ key: 'in_progress', label: 'In Progress', statuses: ['in_progress'] },
|
|
27
|
+
{ key: 'completed', label: 'Completed', statuses: ['completed'] },
|
|
28
|
+
{ key: 'failed', label: 'Failed / Cancelled', statuses: ['failed', 'cancelled'] },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
interface TaskSummary {
|
|
32
|
+
total: number
|
|
33
|
+
completed: number
|
|
34
|
+
pending: number
|
|
35
|
+
inProgress: number
|
|
36
|
+
failed: number
|
|
37
|
+
completionRate: number
|
|
38
|
+
averageTime: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const s: Record<string, CSSProperties> = {
|
|
42
|
+
page: { display: 'flex', flexDirection: 'column', gap: 20 },
|
|
43
|
+
formToggle: { cursor: 'pointer', color: 'var(--accent-blue)', fontSize: 13, userSelect: 'none' },
|
|
44
|
+
formGrid: {
|
|
45
|
+
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12,
|
|
46
|
+
},
|
|
47
|
+
formFull: { gridColumn: '1 / -1' },
|
|
48
|
+
label: { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' },
|
|
49
|
+
input: {
|
|
50
|
+
width: '100%', padding: '8px 12px', background: 'var(--bg-tertiary)',
|
|
51
|
+
border: '1px solid var(--border)', borderRadius: 'var(--radius)', color: 'var(--text-primary)',
|
|
52
|
+
fontSize: 13, outline: 'none',
|
|
53
|
+
},
|
|
54
|
+
textarea: {
|
|
55
|
+
width: '100%', padding: '8px 12px', background: 'var(--bg-tertiary)',
|
|
56
|
+
border: '1px solid var(--border)', borderRadius: 'var(--radius)', color: 'var(--text-primary)',
|
|
57
|
+
fontSize: 13, outline: 'none', resize: 'vertical' as const, minHeight: 60,
|
|
58
|
+
},
|
|
59
|
+
select: {
|
|
60
|
+
width: '100%', padding: '8px 12px', background: 'var(--bg-tertiary)',
|
|
61
|
+
border: '1px solid var(--border)', borderRadius: 'var(--radius)', color: 'var(--text-primary)',
|
|
62
|
+
fontSize: 13, outline: 'none',
|
|
63
|
+
},
|
|
64
|
+
board: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, minHeight: 300 },
|
|
65
|
+
column: {
|
|
66
|
+
background: 'var(--bg-secondary)', borderRadius: 'var(--radius-lg)',
|
|
67
|
+
border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
|
68
|
+
},
|
|
69
|
+
colHeader: {
|
|
70
|
+
padding: '12px 16px', fontSize: 13, fontWeight: 600,
|
|
71
|
+
color: 'var(--text-primary)', borderBottom: '1px solid var(--border)',
|
|
72
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
73
|
+
},
|
|
74
|
+
colCount: {
|
|
75
|
+
fontSize: 11, background: 'var(--bg-tertiary)', padding: '2px 8px',
|
|
76
|
+
borderRadius: 10, color: 'var(--text-muted)',
|
|
77
|
+
},
|
|
78
|
+
colBody: { padding: 8, flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 },
|
|
79
|
+
taskCard: {
|
|
80
|
+
padding: 12, background: 'var(--bg-card)', borderRadius: 'var(--radius)',
|
|
81
|
+
border: '1px solid var(--border)', cursor: 'pointer', transition: 'border-color var(--transition)',
|
|
82
|
+
},
|
|
83
|
+
taskTitle: { fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', marginBottom: 6 },
|
|
84
|
+
taskMeta: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
|
|
85
|
+
priorityBadge: {} as CSSProperties, // use getPriorityBadge() instead
|
|
86
|
+
agentTag: {
|
|
87
|
+
fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4,
|
|
88
|
+
},
|
|
89
|
+
timeTag: { fontSize: 11, color: 'var(--text-muted)' },
|
|
90
|
+
detail: {
|
|
91
|
+
marginTop: 10, padding: '10px 0 0', borderTop: '1px solid var(--border)',
|
|
92
|
+
display: 'flex', flexDirection: 'column', gap: 8,
|
|
93
|
+
},
|
|
94
|
+
detailRow: { fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5 },
|
|
95
|
+
detailLabel: { fontWeight: 600, color: 'var(--text-muted)', marginRight: 6 },
|
|
96
|
+
detailActions: { display: 'flex', gap: 6, marginTop: 4 },
|
|
97
|
+
summaryGrid: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 },
|
|
98
|
+
summaryItem: {
|
|
99
|
+
textAlign: 'center', padding: 16, background: 'var(--bg-secondary)',
|
|
100
|
+
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
|
101
|
+
},
|
|
102
|
+
summaryValue: { fontSize: 24, fontWeight: 700, color: 'var(--text-primary)' },
|
|
103
|
+
summaryLabel: { fontSize: 12, color: 'var(--text-muted)', marginTop: 4 },
|
|
104
|
+
assignSelect: {
|
|
105
|
+
padding: '4px 8px', background: 'var(--bg-tertiary)', border: '1px solid var(--border)',
|
|
106
|
+
borderRadius: 'var(--radius)', color: 'var(--text-primary)', fontSize: 12,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatTime(iso: string): string {
|
|
111
|
+
const d = new Date(iso)
|
|
112
|
+
const now = new Date()
|
|
113
|
+
const diff = now.getTime() - d.getTime()
|
|
114
|
+
if (diff < 60000) return 'just now'
|
|
115
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
|
116
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
|
117
|
+
return d.toLocaleDateString()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function TaskCard({
|
|
121
|
+
task, agents, expanded, onToggle, swarmActive, taskOutput, onLoadHistory,
|
|
122
|
+
}: { task: Task; agents: Array<{ id: string; name: string }>; expanded: boolean; onToggle: () => void; swarmActive: boolean; taskOutput: string[]; onLoadHistory: (id: string) => void }) {
|
|
123
|
+
const [assigning, setAssigning] = useState(false)
|
|
124
|
+
const [loading, setLoading] = useState('')
|
|
125
|
+
const [continueOpen, setContinueOpen] = useState(false)
|
|
126
|
+
const [continueText, setContinueText] = useState('')
|
|
127
|
+
const logRef = useRef<HTMLDivElement>(null)
|
|
128
|
+
const agentName = agents.find((a) => a.id === task.assignedTo)?.name ?? task.assignedTo
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
132
|
+
}, [taskOutput])
|
|
133
|
+
|
|
134
|
+
// Load persisted output history when card is expanded
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (expanded && taskOutput.length === 0) onLoadHistory(task.id)
|
|
137
|
+
}, [expanded, task.id, taskOutput.length, onLoadHistory])
|
|
138
|
+
|
|
139
|
+
const handleAssign = async (agentId: string) => {
|
|
140
|
+
setLoading('assign')
|
|
141
|
+
try { await api.tasks.assign(task.id, agentId) } catch { /* handled by polling */ }
|
|
142
|
+
setAssigning(false)
|
|
143
|
+
setLoading('')
|
|
144
|
+
}
|
|
145
|
+
const handleAssignToSwarm = async () => {
|
|
146
|
+
setLoading('swarm')
|
|
147
|
+
try { await api.tasks.assign(task.id, 'swarm') } catch { /* handled by polling */ }
|
|
148
|
+
setLoading('')
|
|
149
|
+
}
|
|
150
|
+
const handleComplete = async () => {
|
|
151
|
+
setLoading('complete')
|
|
152
|
+
try { await api.tasks.complete(task.id) } catch { /* handled by polling */ }
|
|
153
|
+
setLoading('')
|
|
154
|
+
}
|
|
155
|
+
const handleCancel = async () => {
|
|
156
|
+
setLoading('cancel')
|
|
157
|
+
try { await api.tasks.cancel(task.id) } catch { /* handled by polling */ }
|
|
158
|
+
setLoading('')
|
|
159
|
+
}
|
|
160
|
+
const handleContinue = async () => {
|
|
161
|
+
if (!continueText.trim()) return
|
|
162
|
+
setLoading('continue')
|
|
163
|
+
try {
|
|
164
|
+
await api.tasks.continue(task.id, continueText.trim())
|
|
165
|
+
setContinueText('')
|
|
166
|
+
setContinueOpen(false)
|
|
167
|
+
} catch { /* silent */ }
|
|
168
|
+
setLoading('')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const isRunning = task.status === 'in_progress'
|
|
172
|
+
const isDone = task.status === 'completed' || task.status === 'failed'
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
style={{ ...s.taskCard, ...(expanded ? { borderColor: 'var(--accent-blue)' } : {}), ...(isRunning ? { borderColor: 'var(--accent-cyan)', borderWidth: 2 } : {}) }}
|
|
177
|
+
onClick={onToggle}
|
|
178
|
+
onMouseEnter={(e) => { e.currentTarget.style.borderColor = isRunning ? 'var(--accent-cyan)' : 'var(--accent-blue)' }}
|
|
179
|
+
onMouseLeave={(e) => { if (!expanded && !isRunning) e.currentTarget.style.borderColor = 'var(--border)' }}
|
|
180
|
+
>
|
|
181
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
182
|
+
{isRunning && <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--accent-cyan)', animation: 'pulse-glow 2s ease infinite', flexShrink: 0 }} />}
|
|
183
|
+
<div style={s.taskTitle}>{task.title}</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div style={s.taskMeta}>
|
|
186
|
+
<span style={getPriorityBadge(task.priority)}>{task.priority}</span>
|
|
187
|
+
{task.assignedTo && <span style={s.agentTag}>{agentName}</span>}
|
|
188
|
+
<span style={s.timeTag}>{formatTime(task.createdAt)}</span>
|
|
189
|
+
</div>
|
|
190
|
+
{/* Live output for running tasks + persisted output for completed/failed */}
|
|
191
|
+
{taskOutput.length > 0 && (isRunning || task.status === 'failed' || (expanded && isDone)) && (
|
|
192
|
+
<div ref={logRef} onClick={(e) => e.stopPropagation()} style={{
|
|
193
|
+
marginTop: 8, padding: 8, background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
|
|
194
|
+
border: `1px solid ${task.status === 'failed' ? 'var(--accent-red)' : 'var(--border)'}`, maxHeight: 200, overflowY: 'auto',
|
|
195
|
+
fontFamily: 'monospace', fontSize: 11, lineHeight: 1.5, color: 'var(--text-secondary)',
|
|
196
|
+
}}>
|
|
197
|
+
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginBottom: 4, fontWeight: 600 }}>
|
|
198
|
+
OUTPUT LOG ({taskOutput.length} lines)
|
|
199
|
+
</div>
|
|
200
|
+
{taskOutput.map((line, i) => (
|
|
201
|
+
<div key={i} style={{ color: line.startsWith('[tool]') ? 'var(--accent-yellow)' : line.startsWith('[err]') ? 'var(--accent-red)' : line.startsWith('Progress:') ? 'var(--accent-cyan)' : 'var(--text-secondary)' }}>
|
|
202
|
+
{line}
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
{/* Always show error result for failed tasks */}
|
|
208
|
+
{task.status === 'failed' && task.result && (
|
|
209
|
+
<div onClick={(e) => e.stopPropagation()} style={{
|
|
210
|
+
marginTop: 6, padding: 8, background: 'rgba(239,68,68,0.08)', borderRadius: 'var(--radius)',
|
|
211
|
+
border: '1px solid var(--accent-red)', maxHeight: 200, overflowY: 'auto',
|
|
212
|
+
}}>
|
|
213
|
+
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-red)', marginBottom: 4 }}>Error:</div>
|
|
214
|
+
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 11, margin: 0, color: 'var(--text-secondary)' }}>{task.result}</pre>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
{expanded && (
|
|
218
|
+
<div style={s.detail} onClick={(e) => e.stopPropagation()}>
|
|
219
|
+
{task.description && (
|
|
220
|
+
<div style={s.detailRow}><span style={s.detailLabel}>Description:</span>{task.description}</div>
|
|
221
|
+
)}
|
|
222
|
+
{(task as any).cwd && (
|
|
223
|
+
<div style={s.detailRow}><span style={s.detailLabel}>CWD:</span><code style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>{(task as any).cwd}</code></div>
|
|
224
|
+
)}
|
|
225
|
+
{task.result && task.status !== 'failed' && (
|
|
226
|
+
<div style={{ ...s.detailRow, maxHeight: 200, overflowY: 'auto' }}>
|
|
227
|
+
<span style={s.detailLabel}>Result:</span>
|
|
228
|
+
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12, margin: 0 }}>{task.result}</pre>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
{task.completedAt && (
|
|
232
|
+
<div style={s.detailRow}><span style={s.detailLabel}>Completed:</span>{formatTime(task.completedAt)}</div>
|
|
233
|
+
)}
|
|
234
|
+
<div style={s.detailActions}>
|
|
235
|
+
{assigning ? (
|
|
236
|
+
<select
|
|
237
|
+
style={s.assignSelect}
|
|
238
|
+
onChange={(e) => { if (e.target.value) handleAssign(e.target.value) }}
|
|
239
|
+
defaultValue=""
|
|
240
|
+
>
|
|
241
|
+
<option value="" disabled>Select agent...</option>
|
|
242
|
+
{agents.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
|
243
|
+
</select>
|
|
244
|
+
) : (
|
|
245
|
+
<>
|
|
246
|
+
{task.status !== 'completed' && task.status !== 'cancelled' && task.status !== 'failed' && (
|
|
247
|
+
<>
|
|
248
|
+
<Button size="sm" variant="secondary" onClick={() => setAssigning(true)}
|
|
249
|
+
loading={loading === 'assign'}>Assign</Button>
|
|
250
|
+
{swarmActive && !isRunning && (
|
|
251
|
+
<Button size="sm" variant="secondary" onClick={handleAssignToSwarm}
|
|
252
|
+
loading={loading === 'swarm'}
|
|
253
|
+
style={{ background: 'rgba(6,182,212,0.15)', color: 'var(--accent-cyan)', borderColor: 'var(--accent-cyan)' }}
|
|
254
|
+
>Assign to Swarm</Button>
|
|
255
|
+
)}
|
|
256
|
+
{!isRunning && <Button size="sm" onClick={handleComplete}
|
|
257
|
+
loading={loading === 'complete'}>Complete</Button>}
|
|
258
|
+
<Button size="sm" variant="danger" onClick={handleCancel}
|
|
259
|
+
loading={loading === 'cancel'}>{isRunning ? 'Stop' : 'Cancel'}</Button>
|
|
260
|
+
</>
|
|
261
|
+
)}
|
|
262
|
+
{isDone && (
|
|
263
|
+
<Button size="sm" variant="secondary" onClick={() => setContinueOpen(!continueOpen)}
|
|
264
|
+
style={{ background: 'rgba(139,92,246,0.15)', color: 'var(--accent-purple)', borderColor: 'var(--accent-purple)' }}
|
|
265
|
+
>Continue Task</Button>
|
|
266
|
+
)}
|
|
267
|
+
</>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
{/* Continue task form */}
|
|
271
|
+
{continueOpen && (
|
|
272
|
+
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
273
|
+
<textarea
|
|
274
|
+
style={{
|
|
275
|
+
width: '100%', padding: '8px 10px', background: 'var(--bg-tertiary)',
|
|
276
|
+
border: '1px solid var(--accent-purple)', borderRadius: 'var(--radius)',
|
|
277
|
+
color: 'var(--text-primary)', fontSize: 12, outline: 'none',
|
|
278
|
+
resize: 'vertical', minHeight: 50, fontFamily: 'inherit',
|
|
279
|
+
}}
|
|
280
|
+
placeholder="What should be done next? (e.g. 'Add tests for the new feature', 'Fix the styling issue')"
|
|
281
|
+
value={continueText}
|
|
282
|
+
onChange={(e) => setContinueText(e.target.value)}
|
|
283
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && e.ctrlKey) handleContinue() }}
|
|
284
|
+
/>
|
|
285
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
286
|
+
<Button size="sm" onClick={handleContinue} loading={loading === 'continue'}
|
|
287
|
+
disabled={!continueText.trim()}
|
|
288
|
+
style={{ background: 'var(--accent-purple)', borderColor: 'var(--accent-purple)' }}
|
|
289
|
+
>Launch Follow-up</Button>
|
|
290
|
+
<Button size="sm" variant="secondary" onClick={() => { setContinueOpen(false); setContinueText('') }}>Cancel</Button>
|
|
291
|
+
</div>
|
|
292
|
+
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
|
293
|
+
Ctrl+Enter to submit. Previous task context will be injected automatically.
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default function TasksPanel() {
|
|
304
|
+
const tasks = useStore((st) => st.tasks)
|
|
305
|
+
const setTasks = useStore((st) => st.setTasks)
|
|
306
|
+
const agents = useStore((st) => st.agents)
|
|
307
|
+
const swarm = useStore((st) => st.swarm)
|
|
308
|
+
const swarmActive = swarm != null && swarm.status !== 'shutdown' && swarm.status !== 'inactive'
|
|
309
|
+
const [formOpen, setFormOpen] = useState(false)
|
|
310
|
+
const [title, setTitle] = useState('')
|
|
311
|
+
const [description, setDescription] = useState('')
|
|
312
|
+
const [priority, setPriority] = useState<string>('normal')
|
|
313
|
+
const [assignTo, setAssignTo] = useState('')
|
|
314
|
+
const [cwd, setCwd] = useState('')
|
|
315
|
+
const [creating, setCreating] = useState(false)
|
|
316
|
+
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
317
|
+
const [summary, setSummary] = useState<TaskSummary | null>(null)
|
|
318
|
+
const [taskOutputs, setTaskOutputs] = useState<Record<string, string[]>>({})
|
|
319
|
+
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
|
|
320
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
321
|
+
|
|
322
|
+
// Load persisted output history for a task
|
|
323
|
+
const loadOutputHistory = useCallback(async (taskId: string) => {
|
|
324
|
+
// Only load if we don't have live output already
|
|
325
|
+
if ((taskOutputs[taskId] || []).length > 0) return
|
|
326
|
+
try {
|
|
327
|
+
const data = await api.tasks.output(taskId) as { taskId: string; lines: Array<{ content: string }> }
|
|
328
|
+
if (data.lines?.length > 0) {
|
|
329
|
+
setTaskOutputs(prev => ({
|
|
330
|
+
...prev,
|
|
331
|
+
[taskId]: data.lines.map((l: { content: string }) => l.content),
|
|
332
|
+
}))
|
|
333
|
+
}
|
|
334
|
+
} catch { /* silent */ }
|
|
335
|
+
}, [taskOutputs])
|
|
336
|
+
|
|
337
|
+
// WebSocket for live task output
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
340
|
+
const ws = new WebSocket(`${protocol}//${window.location.hostname}:3001/ws`)
|
|
341
|
+
wsRef.current = ws
|
|
342
|
+
ws.onmessage = (evt) => {
|
|
343
|
+
try {
|
|
344
|
+
const msg = JSON.parse(evt.data)
|
|
345
|
+
if (msg.type === 'task:output' && msg.payload?.id) {
|
|
346
|
+
const { id, type, content, tool, input } = msg.payload
|
|
347
|
+
let line = ''
|
|
348
|
+
if (type === 'tool') line = `[tool] ${tool}: ${input || ''}`
|
|
349
|
+
else if (type === 'stderr') line = `[err] ${content}`
|
|
350
|
+
else if (type === 'text') line = content?.slice(0, 200) || ''
|
|
351
|
+
else if (type === 'raw') line = content?.slice(0, 200) || ''
|
|
352
|
+
else if (type === 'progress') line = content || ''
|
|
353
|
+
else if (type === 'done') line = `--- Done (exit ${msg.payload.code}) ---`
|
|
354
|
+
if (line) {
|
|
355
|
+
setTaskOutputs(prev => ({
|
|
356
|
+
...prev,
|
|
357
|
+
[id]: [...(prev[id] || []).slice(-100), line],
|
|
358
|
+
}))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch { /* ignore */ }
|
|
362
|
+
}
|
|
363
|
+
return () => { ws.close() }
|
|
364
|
+
}, [])
|
|
365
|
+
|
|
366
|
+
const setAgents = useStore((st) => st.setAgents)
|
|
367
|
+
|
|
368
|
+
const fetchData = useCallback(async () => {
|
|
369
|
+
try {
|
|
370
|
+
const [taskRes, summaryRes, agentRes] = await Promise.allSettled([
|
|
371
|
+
api.tasks.list(),
|
|
372
|
+
api.tasks.summary(),
|
|
373
|
+
api.agents.list(),
|
|
374
|
+
])
|
|
375
|
+
if (taskRes.status === 'fulfilled') {
|
|
376
|
+
const data = taskRes.value as { tasks?: Task[] } | Task[]
|
|
377
|
+
setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
|
|
378
|
+
}
|
|
379
|
+
if (summaryRes.status === 'fulfilled') {
|
|
380
|
+
setSummary(summaryRes.value as TaskSummary)
|
|
381
|
+
}
|
|
382
|
+
if (agentRes.status === 'fulfilled') {
|
|
383
|
+
const data = agentRes.value as { agents?: Array<{ id: string; name: string; type: string; status: string }> } | Array<{ id: string; name: string; type: string; status: string }>
|
|
384
|
+
setAgents(Array.isArray(data) ? data : (data.agents ?? []) as any)
|
|
385
|
+
}
|
|
386
|
+
} catch { /* silent */ }
|
|
387
|
+
}, [setTasks, setAgents])
|
|
388
|
+
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
fetchData()
|
|
391
|
+
intervalRef.current = setInterval(fetchData, 5000)
|
|
392
|
+
return () => clearInterval(intervalRef.current)
|
|
393
|
+
}, [fetchData])
|
|
394
|
+
|
|
395
|
+
const handleCreate = async () => {
|
|
396
|
+
if (!title.trim()) return
|
|
397
|
+
setCreating(true)
|
|
398
|
+
try {
|
|
399
|
+
await api.tasks.create({
|
|
400
|
+
title: title.trim(),
|
|
401
|
+
description: description.trim(),
|
|
402
|
+
priority,
|
|
403
|
+
...(assignTo ? { assignTo } : {}),
|
|
404
|
+
...(cwd.trim() ? { cwd: cwd.trim() } : {}),
|
|
405
|
+
})
|
|
406
|
+
setTitle('')
|
|
407
|
+
setDescription('')
|
|
408
|
+
setPriority('normal')
|
|
409
|
+
setAssignTo('')
|
|
410
|
+
setCwd('')
|
|
411
|
+
fetchData()
|
|
412
|
+
} catch { /* silent */ }
|
|
413
|
+
setCreating(false)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<div style={s.page}>
|
|
418
|
+
<Card
|
|
419
|
+
title="Create Task"
|
|
420
|
+
actions={
|
|
421
|
+
<span style={s.formToggle} onClick={() => setFormOpen(!formOpen)}>
|
|
422
|
+
{formOpen ? 'Collapse' : 'Expand'}
|
|
423
|
+
</span>
|
|
424
|
+
}
|
|
425
|
+
>
|
|
426
|
+
{formOpen && (
|
|
427
|
+
<>
|
|
428
|
+
<div style={s.formGrid}>
|
|
429
|
+
<div>
|
|
430
|
+
<label style={s.label}>Title</label>
|
|
431
|
+
<input style={s.input} placeholder="Task title..." value={title}
|
|
432
|
+
onChange={(e) => setTitle(e.target.value)} />
|
|
433
|
+
</div>
|
|
434
|
+
<div>
|
|
435
|
+
<label style={s.label}>Priority</label>
|
|
436
|
+
<select style={s.select} value={priority} onChange={(e) => setPriority(e.target.value)}>
|
|
437
|
+
<option value="low">Low</option>
|
|
438
|
+
<option value="normal">Normal</option>
|
|
439
|
+
<option value="high">High</option>
|
|
440
|
+
<option value="critical">Critical</option>
|
|
441
|
+
</select>
|
|
442
|
+
</div>
|
|
443
|
+
<div style={s.formFull}>
|
|
444
|
+
<label style={s.label}>Description</label>
|
|
445
|
+
<textarea style={s.textarea} placeholder="Task description..." value={description}
|
|
446
|
+
onChange={(e) => setDescription(e.target.value)} />
|
|
447
|
+
</div>
|
|
448
|
+
<div>
|
|
449
|
+
<label style={s.label}>Assign To</label>
|
|
450
|
+
<select style={s.select} value={assignTo} onChange={(e) => setAssignTo(e.target.value)}>
|
|
451
|
+
<option value="">Unassigned</option>
|
|
452
|
+
{swarmActive && <option value="swarm">Swarm (Coordinator)</option>}
|
|
453
|
+
{agents.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.type})</option>)}
|
|
454
|
+
</select>
|
|
455
|
+
</div>
|
|
456
|
+
<div style={s.formFull}>
|
|
457
|
+
<label style={s.label}>Working Directory</label>
|
|
458
|
+
<input style={{ ...s.input, fontFamily: 'monospace', fontSize: '0.85rem' }}
|
|
459
|
+
placeholder="C:\Projects\my-app (optional — defaults to server CWD)"
|
|
460
|
+
value={cwd} onChange={(e) => setCwd(e.target.value)} />
|
|
461
|
+
</div>
|
|
462
|
+
<div style={{ display: 'flex', alignItems: 'flex-end' }} data-tour="task-create">
|
|
463
|
+
<Button onClick={handleCreate} loading={creating} disabled={!title.trim()}>
|
|
464
|
+
Create Task
|
|
465
|
+
</Button>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
</>
|
|
469
|
+
)}
|
|
470
|
+
</Card>
|
|
471
|
+
|
|
472
|
+
<Card title="Task Board">
|
|
473
|
+
<div style={s.board} data-tour="task-board">
|
|
474
|
+
{COLUMNS.map((col) => {
|
|
475
|
+
const colTasks = tasks.filter((t) => col.statuses.includes(t.status))
|
|
476
|
+
return (
|
|
477
|
+
<div key={col.key} style={s.column}>
|
|
478
|
+
<div style={s.colHeader}>
|
|
479
|
+
{col.label}
|
|
480
|
+
<span style={s.colCount}>{colTasks.length}</span>
|
|
481
|
+
</div>
|
|
482
|
+
<div style={s.colBody}>
|
|
483
|
+
{colTasks.length === 0 && (
|
|
484
|
+
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>
|
|
485
|
+
No tasks
|
|
486
|
+
</div>
|
|
487
|
+
)}
|
|
488
|
+
{colTasks.map((task) => (
|
|
489
|
+
<TaskCard
|
|
490
|
+
key={task.id}
|
|
491
|
+
task={task}
|
|
492
|
+
agents={agents}
|
|
493
|
+
expanded={expandedId === task.id}
|
|
494
|
+
onToggle={() => setExpandedId(expandedId === task.id ? null : task.id)}
|
|
495
|
+
swarmActive={swarmActive}
|
|
496
|
+
taskOutput={taskOutputs[task.id] || []}
|
|
497
|
+
onLoadHistory={loadOutputHistory}
|
|
498
|
+
/>
|
|
499
|
+
))}
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
)
|
|
503
|
+
})}
|
|
504
|
+
</div>
|
|
505
|
+
</Card>
|
|
506
|
+
|
|
507
|
+
{summary && (
|
|
508
|
+
<Card title="Task Summary">
|
|
509
|
+
<div style={s.summaryGrid}>
|
|
510
|
+
<div style={s.summaryItem}>
|
|
511
|
+
<div style={s.summaryValue}>{summary.total}</div>
|
|
512
|
+
<div style={s.summaryLabel}>Total Tasks</div>
|
|
513
|
+
</div>
|
|
514
|
+
<div style={s.summaryItem}>
|
|
515
|
+
<div style={{ ...s.summaryValue, color: 'var(--accent-green)' }}>
|
|
516
|
+
{summary.completionRate != null ? `${Math.round(summary.completionRate * 100)}%` : '--'}
|
|
517
|
+
</div>
|
|
518
|
+
<div style={s.summaryLabel}>Completion Rate</div>
|
|
519
|
+
</div>
|
|
520
|
+
<div style={s.summaryItem}>
|
|
521
|
+
<div style={{ ...s.summaryValue, color: 'var(--accent-cyan)' }}>{summary.completed}</div>
|
|
522
|
+
<div style={s.summaryLabel}>Completed</div>
|
|
523
|
+
</div>
|
|
524
|
+
<div style={s.summaryItem}>
|
|
525
|
+
<div style={{ ...s.summaryValue, color: 'var(--accent-yellow)' }}>
|
|
526
|
+
{summary.averageTime ?? '--'}
|
|
527
|
+
</div>
|
|
528
|
+
<div style={s.summaryLabel}>Avg Time</div>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
</Card>
|
|
532
|
+
)}
|
|
533
|
+
</div>
|
|
534
|
+
)
|
|
535
|
+
}
|