@aion0/forge 0.4.16 → 0.5.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/README.md +27 -2
- package/RELEASE_NOTES.md +21 -14
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +13 -1
- package/check-forge-status.sh +9 -0
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +10 -2
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +11 -6
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +31 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +257 -43
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2245 -0
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +7 -1
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +89 -0
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +254 -0
- package/lib/help-docs/CLAUDE.md +7 -2
- package/lib/init.ts +60 -10
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1914 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +814 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +4 -1
- package/src/config/index.ts +12 -1
- package/src/core/db/database.ts +1 -0
- package/start.sh +7 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import type { TaskLogEntry } from '@/src/types';
|
|
5
|
+
|
|
6
|
+
// ─── Types ────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface Artifact {
|
|
9
|
+
id: string;
|
|
10
|
+
type: string;
|
|
11
|
+
name: string;
|
|
12
|
+
content: string;
|
|
13
|
+
producedBy: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DeliveryPhase {
|
|
18
|
+
name: string;
|
|
19
|
+
status: string;
|
|
20
|
+
agentRole: string;
|
|
21
|
+
agentId: string;
|
|
22
|
+
taskIds: string[];
|
|
23
|
+
outputArtifactIds: string[];
|
|
24
|
+
interactions: { from: string; message: string; timestamp: string }[];
|
|
25
|
+
startedAt?: string;
|
|
26
|
+
completedAt?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
_label?: string;
|
|
29
|
+
_icon?: string;
|
|
30
|
+
_waitForHuman?: boolean;
|
|
31
|
+
_outputArtifactName?: string;
|
|
32
|
+
_requires?: string[];
|
|
33
|
+
_produces?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Delivery {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
status: string;
|
|
40
|
+
input: { prUrl?: string; description?: string; project: string; projectPath: string };
|
|
41
|
+
phases: DeliveryPhase[];
|
|
42
|
+
currentPhaseIndex: number;
|
|
43
|
+
artifacts?: Artifact[];
|
|
44
|
+
createdAt: string;
|
|
45
|
+
completedAt?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Phase config ─────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const PHASE_COLORS = ['#22c55e', '#3b82f6', '#a855f7', '#f97316', '#ec4899', '#06b6d4', '#eab308'];
|
|
51
|
+
|
|
52
|
+
const PHASE_META_DEFAULTS: Record<string, { icon: string; label: string }> = {
|
|
53
|
+
analyze: { icon: '📋', label: 'PM - Analyze' },
|
|
54
|
+
implement: { icon: '🔨', label: 'Engineer - Implement' },
|
|
55
|
+
test: { icon: '🧪', label: 'QA - Test' },
|
|
56
|
+
review: { icon: '🔍', label: 'Reviewer - Review' },
|
|
57
|
+
pm: { icon: '📋', label: 'PM - Analyze' },
|
|
58
|
+
engineer: { icon: '🔨', label: 'Engineer - Implement' },
|
|
59
|
+
qa: { icon: '🧪', label: 'QA - Test' },
|
|
60
|
+
reviewer: { icon: '🔍', label: 'Reviewer - Review' },
|
|
61
|
+
devops: { icon: '🚀', label: 'DevOps - Deploy' },
|
|
62
|
+
security: { icon: '🔒', label: 'Security Audit' },
|
|
63
|
+
docs: { icon: '📝', label: 'Tech Writer - Docs' },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function getPhaseMeta(phase: DeliveryPhase, index: number) {
|
|
67
|
+
const defaults = PHASE_META_DEFAULTS[phase.name] || { icon: '⚙', label: phase.name };
|
|
68
|
+
return {
|
|
69
|
+
icon: phase._icon || defaults.icon,
|
|
70
|
+
label: phase._label || defaults.label,
|
|
71
|
+
color: PHASE_COLORS[index % PHASE_COLORS.length],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Task SSE stream ──────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function useTaskStream(taskId: string | undefined, isRunning: boolean) {
|
|
78
|
+
const [log, setLog] = useState<TaskLogEntry[]>([]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!taskId || !isRunning) { setLog([]); return; }
|
|
81
|
+
const es = new EventSource(`/api/tasks/${taskId}/stream`);
|
|
82
|
+
es.onmessage = (event) => {
|
|
83
|
+
try {
|
|
84
|
+
const data = JSON.parse(event.data);
|
|
85
|
+
if (data.type === 'log') setLog(prev => [...prev, data.entry]);
|
|
86
|
+
else if (data.type === 'complete' && data.task) setLog(data.task.log);
|
|
87
|
+
} catch {}
|
|
88
|
+
};
|
|
89
|
+
es.onerror = () => es.close();
|
|
90
|
+
return () => es.close();
|
|
91
|
+
}, [taskId, isRunning]);
|
|
92
|
+
return log;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Phase Terminal Panel ─────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function PhaseTerminal({ phase, phaseIndex, deliveryId, artifacts }: {
|
|
98
|
+
phase: DeliveryPhase;
|
|
99
|
+
phaseIndex: number;
|
|
100
|
+
deliveryId: string;
|
|
101
|
+
artifacts: Artifact[];
|
|
102
|
+
}) {
|
|
103
|
+
const meta = getPhaseMeta(phase, phaseIndex);
|
|
104
|
+
const isRunning = phase.status === 'running';
|
|
105
|
+
const lastTaskId = phase.taskIds[phase.taskIds.length - 1];
|
|
106
|
+
const log = useTaskStream(lastTaskId, isRunning);
|
|
107
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
108
|
+
const [input, setInput] = useState('');
|
|
109
|
+
const [sending, setSending] = useState(false);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
113
|
+
}, [log, phase.interactions]);
|
|
114
|
+
|
|
115
|
+
const handleSend = async () => {
|
|
116
|
+
if (!input.trim() || sending) return;
|
|
117
|
+
setSending(true);
|
|
118
|
+
await fetch(`/api/delivery/${deliveryId}`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ action: 'send', phase: phase.name, message: input }),
|
|
122
|
+
});
|
|
123
|
+
setInput('');
|
|
124
|
+
setSending(false);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const phaseArtifacts = artifacts.filter(a => phase.outputArtifactIds.includes(a.id));
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="flex flex-col min-h-0 border rounded-lg overflow-hidden" style={{ borderColor: meta.color + '60' }}>
|
|
131
|
+
{/* Header */}
|
|
132
|
+
<div className="flex items-center gap-2 px-3 py-1.5 shrink-0" style={{ background: meta.color + '15', borderBottom: `1px solid ${meta.color}30` }}>
|
|
133
|
+
<span className="text-sm">{meta.icon}</span>
|
|
134
|
+
<span className="text-[10px] font-bold text-white">{meta.label}</span>
|
|
135
|
+
<span className="text-[8px] px-1.5 py-0.5 rounded" style={{ background: meta.color + '30', color: meta.color }}>{phase.agentId}</span>
|
|
136
|
+
{isRunning && <span className="text-[8px] text-yellow-400 animate-pulse ml-auto">● running</span>}
|
|
137
|
+
{phase.status === 'done' && <span className="text-[8px] text-green-400 ml-auto">✓ done</span>}
|
|
138
|
+
{phase.status === 'waiting_human' && <span className="text-[8px] text-yellow-300 ml-auto animate-pulse">⏸ waiting approval</span>}
|
|
139
|
+
{phase.status === 'failed' && <span className="text-[8px] text-red-400 ml-auto">✗ failed</span>}
|
|
140
|
+
{phase.status === 'pending' && <span className="text-[8px] text-gray-500 ml-auto">○ pending</span>}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Terminal body */}
|
|
144
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto p-2 font-mono text-[11px] leading-[1.6]" style={{ background: '#0d1117', color: '#c9d1d9', minHeight: 80 }}>
|
|
145
|
+
{phase.status === 'pending' && (
|
|
146
|
+
<div className="text-gray-600">Waiting for previous phase...</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{/* Interactions (user messages) */}
|
|
150
|
+
{phase.interactions.map((inter, i) => (
|
|
151
|
+
<div key={i} className="text-yellow-300 text-[10px] mb-1">
|
|
152
|
+
<span className="text-yellow-500">▸ [{inter.from}]</span> {inter.message}
|
|
153
|
+
</div>
|
|
154
|
+
))}
|
|
155
|
+
|
|
156
|
+
{/* Live log */}
|
|
157
|
+
{isRunning && (
|
|
158
|
+
<>
|
|
159
|
+
{lastTaskId && <div className="text-gray-500 text-[9px] mb-1">$ task:{lastTaskId}</div>}
|
|
160
|
+
{log.length === 0 ? (
|
|
161
|
+
<div className="text-gray-600 animate-pulse">Starting...</div>
|
|
162
|
+
) : (
|
|
163
|
+
log.slice(-40).map((entry, i) => <LogLine key={i} entry={entry} color={meta.color} />)
|
|
164
|
+
)}
|
|
165
|
+
<span className="inline-block w-2 h-4 bg-gray-400 animate-pulse" />
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{/* Done — show output artifacts */}
|
|
170
|
+
{(phase.status === 'done' || phase.status === 'waiting_human') && phaseArtifacts.length > 0 && (
|
|
171
|
+
<div className="mt-1">
|
|
172
|
+
{phaseArtifacts.map(a => (
|
|
173
|
+
<details key={a.id} className="mb-1">
|
|
174
|
+
<summary className="text-[9px] cursor-pointer" style={{ color: meta.color }}>
|
|
175
|
+
📄 {a.name} ({a.content.length} chars)
|
|
176
|
+
</summary>
|
|
177
|
+
<pre className="text-[9px] text-gray-400 mt-1 whitespace-pre-wrap max-h-[150px] overflow-y-auto bg-black/30 rounded p-2">
|
|
178
|
+
{a.content.slice(0, 3000)}{a.content.length > 3000 ? '\n[...]' : ''}
|
|
179
|
+
</pre>
|
|
180
|
+
</details>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{phase.error && <div className="text-red-400 text-[10px] mt-1">{phase.error}</div>}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Input bar */}
|
|
189
|
+
{(phase.status === 'running' || phase.status === 'done' || phase.status === 'waiting_human') && (
|
|
190
|
+
<div className="flex items-center gap-1 px-2 py-1 shrink-0" style={{ background: '#161b22', borderTop: `1px solid ${meta.color}20` }}>
|
|
191
|
+
<span className="text-[10px] font-mono" style={{ color: meta.color }}>$</span>
|
|
192
|
+
<input
|
|
193
|
+
value={input}
|
|
194
|
+
onChange={e => setInput(e.target.value)}
|
|
195
|
+
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
|
196
|
+
placeholder={`Send to ${phase.name}...`}
|
|
197
|
+
className="flex-1 bg-transparent text-[10px] font-mono text-gray-300 focus:outline-none placeholder:text-gray-600"
|
|
198
|
+
/>
|
|
199
|
+
{input.trim() && (
|
|
200
|
+
<button onClick={handleSend} disabled={sending} className="text-[8px] px-1.5 py-0.5 rounded"
|
|
201
|
+
style={{ background: meta.color + '30', color: meta.color }}>Send</button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function LogLine({ entry, color }: { entry: TaskLogEntry; color: string }) {
|
|
210
|
+
if (entry.type === 'system' && entry.subtype === 'init') return <div className="text-gray-600 text-[9px]">{entry.content}</div>;
|
|
211
|
+
if (entry.type === 'assistant' && entry.subtype === 'tool_use') {
|
|
212
|
+
return <div className="text-[10px]"><span style={{ color }}>⚙</span> <span className="text-blue-400">{entry.tool || 'tool'}</span> <span className="text-gray-600">{entry.content.slice(0, 80)}{entry.content.length > 80 ? '...' : ''}</span></div>;
|
|
213
|
+
}
|
|
214
|
+
if (entry.type === 'result') return <div className="text-green-400 text-[10px]">{entry.content.slice(0, 200)}</div>;
|
|
215
|
+
if (entry.subtype === 'error') return <div className="text-red-400 text-[10px]">{entry.content}</div>;
|
|
216
|
+
return <div className="text-[10px]" style={{ color: '#c9d1d9' }}>{entry.content.slice(0, 200)}{entry.content.length > 200 ? '...' : ''}</div>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Phase Timeline ───────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function PhaseTimeline({ phases, currentIndex }: { phases: DeliveryPhase[]; currentIndex: number }) {
|
|
222
|
+
return (
|
|
223
|
+
<div className="space-y-1">
|
|
224
|
+
{phases.map((phase, i) => {
|
|
225
|
+
const meta = getPhaseMeta(phase, i);
|
|
226
|
+
const isCurrent = i === currentIndex && phase.status !== 'done';
|
|
227
|
+
return (
|
|
228
|
+
<div key={phase.name} className="flex items-center gap-2">
|
|
229
|
+
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs shrink-0 ${
|
|
230
|
+
phase.status === 'done' ? 'bg-green-500/20' :
|
|
231
|
+
phase.status === 'running' ? 'bg-yellow-500/20 ring-2 ring-yellow-400/40' :
|
|
232
|
+
phase.status === 'waiting_human' ? 'bg-yellow-500/20 ring-2 ring-yellow-300/40' :
|
|
233
|
+
phase.status === 'failed' ? 'bg-red-500/20' :
|
|
234
|
+
'bg-gray-500/10'
|
|
235
|
+
}`}>
|
|
236
|
+
{phase.status === 'done' ? '✓' : phase.status === 'failed' ? '✗' : meta.icon}
|
|
237
|
+
</div>
|
|
238
|
+
<div className="flex-1 min-w-0">
|
|
239
|
+
<div className={`text-[10px] font-medium ${isCurrent ? 'text-white' : 'text-gray-400'}`}>{meta.label}</div>
|
|
240
|
+
<div className="text-[8px] text-gray-600">{phase.status}{phase.taskIds.length > 0 ? ` · ${phase.taskIds.length} task${phase.taskIds.length > 1 ? 's' : ''}` : ''}</div>
|
|
241
|
+
</div>
|
|
242
|
+
{/* Connector line */}
|
|
243
|
+
{i < phases.length - 1 && (
|
|
244
|
+
<div className="absolute left-3 mt-8 w-px h-2" style={{ background: phase.status === 'done' ? '#22c55e40' : '#333' }} />
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
})}
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Artifact Sidebar ─────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function ArtifactList({ artifacts }: { artifacts: Artifact[] }) {
|
|
256
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
257
|
+
|
|
258
|
+
if (artifacts.length === 0) return <div className="text-[9px] text-gray-500 p-2">No artifacts yet</div>;
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className="space-y-1">
|
|
262
|
+
{artifacts.map(a => (
|
|
263
|
+
<div key={a.id} className="border border-[var(--border)] rounded overflow-hidden">
|
|
264
|
+
<button
|
|
265
|
+
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
|
266
|
+
className="w-full text-left px-2 py-1.5 hover:bg-[var(--bg-tertiary)] flex items-center gap-1.5"
|
|
267
|
+
>
|
|
268
|
+
<span className="text-[9px]">📄</span>
|
|
269
|
+
<span className="text-[9px] text-[var(--text-primary)] font-medium truncate flex-1">{a.name}</span>
|
|
270
|
+
<span className="text-[7px] text-gray-500">{a.producedBy}</span>
|
|
271
|
+
</button>
|
|
272
|
+
{expanded === a.id && (
|
|
273
|
+
<pre className="px-2 py-1.5 text-[8px] text-gray-400 whitespace-pre-wrap max-h-[200px] overflow-y-auto bg-black/20 border-t border-[var(--border)]">
|
|
274
|
+
{a.content.slice(0, 5000)}{a.content.length > 5000 ? '\n[...]' : ''}
|
|
275
|
+
</pre>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Data Flow SVG ────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function DataFlowOverlay({ phases }: { phases: DeliveryPhase[] }) {
|
|
286
|
+
// Simple horizontal flow indicator showing artifact passing
|
|
287
|
+
const donePhases = phases.filter(p => p.status === 'done').length;
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="flex items-center gap-1 px-2">
|
|
291
|
+
{phases.map((phase, i) => {
|
|
292
|
+
const meta = getPhaseMeta(phase, i);
|
|
293
|
+
const isDone = phase.status === 'done';
|
|
294
|
+
const isRunning = phase.status === 'running' || phase.status === 'waiting_human';
|
|
295
|
+
return (
|
|
296
|
+
<div key={phase.name} className="flex items-center gap-1">
|
|
297
|
+
<div className="text-[9px] px-1.5 py-0.5 rounded" style={{
|
|
298
|
+
background: isDone ? meta.color + '20' : isRunning ? meta.color + '15' : 'transparent',
|
|
299
|
+
color: isDone ? meta.color : isRunning ? meta.color : '#555',
|
|
300
|
+
border: `1px solid ${isDone ? meta.color + '40' : isRunning ? meta.color + '30' : '#333'}`,
|
|
301
|
+
}}>
|
|
302
|
+
{meta.icon} {phase.name}
|
|
303
|
+
</div>
|
|
304
|
+
{i < phases.length - 1 && (
|
|
305
|
+
<svg width="24" height="12" viewBox="0 0 24 12" className="shrink-0">
|
|
306
|
+
<line x1="0" y1="6" x2="18" y2="6" stroke={isDone ? meta.color : '#333'} strokeWidth="1.5"
|
|
307
|
+
strokeDasharray={isRunning ? '3 2' : 'none'}>
|
|
308
|
+
{isRunning && <animate attributeName="stroke-dashoffset" from="10" to="0" dur="0.6s" repeatCount="indefinite" />}
|
|
309
|
+
</line>
|
|
310
|
+
<polygon points="16,2 24,6 16,10" fill={isDone ? meta.color : '#333'} opacity={isDone ? 0.8 : 0.3} />
|
|
311
|
+
</svg>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
})}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Human Approval Panel ─────────────────────────────────
|
|
321
|
+
|
|
322
|
+
function ApprovalPanel({ deliveryId, artifacts, onRefresh }: { deliveryId: string; artifacts: Artifact[]; onRefresh: () => void }) {
|
|
323
|
+
const [feedback, setFeedback] = useState('');
|
|
324
|
+
const [acting, setActing] = useState(false);
|
|
325
|
+
const reqArtifact = artifacts.find(a => a.type === 'requirements' || a.name.includes('requirement') || a.producedBy === 'analyze');
|
|
326
|
+
|
|
327
|
+
const act = async (action: 'approve' | 'reject') => {
|
|
328
|
+
setActing(true);
|
|
329
|
+
await fetch(`/api/delivery/${deliveryId}`, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: { 'Content-Type': 'application/json' },
|
|
332
|
+
body: JSON.stringify({ action, feedback: feedback || undefined }),
|
|
333
|
+
});
|
|
334
|
+
setFeedback('');
|
|
335
|
+
setActing(false);
|
|
336
|
+
onRefresh();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="border-2 border-yellow-500/40 rounded-lg p-3 bg-yellow-500/5 space-y-2">
|
|
341
|
+
<div className="flex items-center gap-2">
|
|
342
|
+
<span className="text-yellow-400 text-sm">⏸</span>
|
|
343
|
+
<span className="text-[11px] font-bold text-yellow-300">Requirements Review Required</span>
|
|
344
|
+
</div>
|
|
345
|
+
{reqArtifact && (
|
|
346
|
+
<pre className="text-[9px] text-gray-300 whitespace-pre-wrap max-h-[200px] overflow-y-auto bg-black/30 rounded p-2">
|
|
347
|
+
{reqArtifact.content.slice(0, 5000)}
|
|
348
|
+
</pre>
|
|
349
|
+
)}
|
|
350
|
+
<textarea
|
|
351
|
+
value={feedback}
|
|
352
|
+
onChange={e => setFeedback(e.target.value)}
|
|
353
|
+
placeholder="Optional feedback or changes..."
|
|
354
|
+
className="w-full text-[10px] bg-black/20 border border-yellow-500/20 rounded p-2 text-gray-300 resize-none focus:outline-none focus:border-yellow-500/40"
|
|
355
|
+
rows={2}
|
|
356
|
+
/>
|
|
357
|
+
<div className="flex gap-2">
|
|
358
|
+
<button onClick={() => act('approve')} disabled={acting}
|
|
359
|
+
className="text-[10px] px-3 py-1 bg-green-600 text-white rounded hover:opacity-90 disabled:opacity-50">
|
|
360
|
+
✓ Approve & Continue
|
|
361
|
+
</button>
|
|
362
|
+
<button onClick={() => act('reject')} disabled={acting || !feedback.trim()}
|
|
363
|
+
className="text-[10px] px-3 py-1 bg-red-600/80 text-white rounded hover:opacity-90 disabled:opacity-50">
|
|
364
|
+
✗ Reject & Redo
|
|
365
|
+
</button>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ─── Grid Flow Overlay (SVG arrows between panels) ────────
|
|
372
|
+
|
|
373
|
+
function GridFlowOverlay({ phases }: { phases: DeliveryPhase[] }) {
|
|
374
|
+
// Draw arrows based on actual _requires/_produces relationships, not sequential order
|
|
375
|
+
const cols = phases.length <= 2 ? 2 : phases.length <= 4 ? 2 : 3;
|
|
376
|
+
const rows = Math.ceil(phases.length / cols);
|
|
377
|
+
const cellW = 1000 / cols;
|
|
378
|
+
const cellH = 500 / rows;
|
|
379
|
+
|
|
380
|
+
function cellCenter(i: number): [number, number] {
|
|
381
|
+
const col = i % cols;
|
|
382
|
+
const row = Math.floor(i / cols);
|
|
383
|
+
return [col * cellW + cellW / 2, row * cellH + cellH / 2];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Build producer map: artifact name → phase index
|
|
387
|
+
const producerMap = new Map<string, number>();
|
|
388
|
+
phases.forEach((p, i) => {
|
|
389
|
+
const produces = p._produces || [p._outputArtifactName || `${p.name}-output.md`];
|
|
390
|
+
for (const name of produces) {
|
|
391
|
+
producerMap.set(name, i);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Build arrows from requires → producer
|
|
396
|
+
interface Arrow {
|
|
397
|
+
id: string; x1: number; y1: number; x2: number; y2: number;
|
|
398
|
+
active: boolean; done: boolean; color: string; label: string;
|
|
399
|
+
}
|
|
400
|
+
const arrows: Arrow[] = [];
|
|
401
|
+
|
|
402
|
+
phases.forEach((phase, targetIdx) => {
|
|
403
|
+
const requires = phase._requires || [];
|
|
404
|
+
for (const reqName of requires) {
|
|
405
|
+
const sourceIdx = producerMap.get(reqName);
|
|
406
|
+
if (sourceIdx === undefined || sourceIdx === targetIdx) continue;
|
|
407
|
+
|
|
408
|
+
const [sx, sy] = cellCenter(sourceIdx);
|
|
409
|
+
const [tx, ty] = cellCenter(targetIdx);
|
|
410
|
+
const dx = tx - sx, dy = ty - sy;
|
|
411
|
+
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
412
|
+
const offset = 80;
|
|
413
|
+
|
|
414
|
+
const sourceDone = phases[sourceIdx].status === 'done';
|
|
415
|
+
const targetActive = phase.status === 'running' || phase.status === 'waiting_human';
|
|
416
|
+
|
|
417
|
+
arrows.push({
|
|
418
|
+
id: `flow-${sourceIdx}-${targetIdx}-${reqName}`,
|
|
419
|
+
x1: sx + (dx / len) * offset,
|
|
420
|
+
y1: sy + (dy / len) * offset,
|
|
421
|
+
x2: tx - (dx / len) * offset,
|
|
422
|
+
y2: ty - (dy / len) * offset,
|
|
423
|
+
active: sourceDone && targetActive,
|
|
424
|
+
done: sourceDone,
|
|
425
|
+
color: PHASE_COLORS[sourceIdx % PHASE_COLORS.length],
|
|
426
|
+
label: reqName,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<svg className="absolute inset-0 w-full h-full pointer-events-none z-10" viewBox="0 0 1000 500" preserveAspectRatio="none">
|
|
433
|
+
<defs>
|
|
434
|
+
{PHASE_COLORS.map((c, i) => (
|
|
435
|
+
<marker key={i} id={`darrow-${i}`} markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
|
436
|
+
<polygon points="0 0, 8 3, 0 6" fill={c} />
|
|
437
|
+
</marker>
|
|
438
|
+
))}
|
|
439
|
+
</defs>
|
|
440
|
+
{arrows.map(a => {
|
|
441
|
+
const opacity = a.active ? 1 : a.done ? 0.5 : 0.12;
|
|
442
|
+
const markerIdx = PHASE_COLORS.indexOf(a.color) >= 0 ? PHASE_COLORS.indexOf(a.color) : 0;
|
|
443
|
+
return (
|
|
444
|
+
<g key={a.id}>
|
|
445
|
+
<line
|
|
446
|
+
x1={a.x1} y1={a.y1} x2={a.x2} y2={a.y2}
|
|
447
|
+
stroke={a.color} strokeWidth={a.active ? 3 : 2}
|
|
448
|
+
strokeDasharray={a.active ? '8 4' : a.done ? '0' : '4 4'}
|
|
449
|
+
opacity={opacity}
|
|
450
|
+
markerEnd={`url(#darrow-${markerIdx % PHASE_COLORS.length})`}
|
|
451
|
+
>
|
|
452
|
+
{a.active && <animate attributeName="stroke-dashoffset" from="24" to="0" dur="0.8s" repeatCount="indefinite" />}
|
|
453
|
+
</line>
|
|
454
|
+
{/* Artifact name label on the line */}
|
|
455
|
+
{(a.active || a.done) && (
|
|
456
|
+
<text
|
|
457
|
+
x={(a.x1 + a.x2) / 2}
|
|
458
|
+
y={(a.y1 + a.y2) / 2 - 5}
|
|
459
|
+
fill={a.color}
|
|
460
|
+
fontSize="9"
|
|
461
|
+
textAnchor="middle"
|
|
462
|
+
opacity={Math.min(opacity + 0.3, 1)}
|
|
463
|
+
>📄 {a.label}</text>
|
|
464
|
+
)}
|
|
465
|
+
</g>
|
|
466
|
+
);
|
|
467
|
+
})}
|
|
468
|
+
</svg>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── Main Component ───────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
export default function DeliveryWorkspace({ deliveryId, onClose }: {
|
|
475
|
+
deliveryId: string;
|
|
476
|
+
onClose: () => void;
|
|
477
|
+
}) {
|
|
478
|
+
const [delivery, setDelivery] = useState<Delivery | null>(null);
|
|
479
|
+
|
|
480
|
+
const fetchDelivery = useCallback(async () => {
|
|
481
|
+
const res = await fetch(`/api/delivery/${deliveryId}`);
|
|
482
|
+
if (res.ok) setDelivery(await res.json());
|
|
483
|
+
}, [deliveryId]);
|
|
484
|
+
|
|
485
|
+
// Initial load + polling
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
fetchDelivery();
|
|
488
|
+
const timer = setInterval(fetchDelivery, 3000);
|
|
489
|
+
return () => clearInterval(timer);
|
|
490
|
+
}, [fetchDelivery]);
|
|
491
|
+
|
|
492
|
+
if (!delivery) {
|
|
493
|
+
return <div className="flex-1 flex items-center justify-center text-xs text-gray-500">Loading delivery...</div>;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const waitingPhase = delivery.phases.find(p => p.status === 'waiting_human');
|
|
497
|
+
const needsApproval = !!waitingPhase;
|
|
498
|
+
const artifacts = delivery.artifacts || [];
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<div className="flex-1 flex flex-col min-h-0" style={{ background: '#0a0a1a' }}>
|
|
502
|
+
{/* Header */}
|
|
503
|
+
<div className="flex items-center gap-3 px-4 py-2 border-b border-[#2a2a3a] shrink-0">
|
|
504
|
+
<button onClick={onClose} className="text-gray-400 hover:text-white text-sm">←</button>
|
|
505
|
+
<span className="text-sm font-bold text-white">{delivery.title}</span>
|
|
506
|
+
<span className={`text-[8px] px-1.5 py-0.5 rounded ${
|
|
507
|
+
delivery.status === 'running' ? 'bg-yellow-500/20 text-yellow-400' :
|
|
508
|
+
delivery.status === 'done' ? 'bg-green-500/20 text-green-400' :
|
|
509
|
+
delivery.status === 'failed' ? 'bg-red-500/20 text-red-400' :
|
|
510
|
+
'bg-gray-500/20 text-gray-400'
|
|
511
|
+
}`}>{delivery.status}</span>
|
|
512
|
+
<span className="text-[9px] text-gray-500">{delivery.input.project}</span>
|
|
513
|
+
<div className="flex-1" />
|
|
514
|
+
<DataFlowOverlay phases={delivery.phases} />
|
|
515
|
+
<div className="flex-1" />
|
|
516
|
+
{delivery.status === 'running' && (
|
|
517
|
+
<button onClick={async () => {
|
|
518
|
+
await fetch(`/api/delivery/${deliveryId}`, {
|
|
519
|
+
method: 'POST',
|
|
520
|
+
headers: { 'Content-Type': 'application/json' },
|
|
521
|
+
body: JSON.stringify({ action: 'cancel' }),
|
|
522
|
+
});
|
|
523
|
+
fetchDelivery();
|
|
524
|
+
}} className="text-[9px] px-2 py-0.5 text-red-400 border border-red-400/30 rounded hover:bg-red-400 hover:text-white">
|
|
525
|
+
Cancel
|
|
526
|
+
</button>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
{/* Body: sidebar + workspace */}
|
|
531
|
+
<div className="flex-1 flex min-h-0">
|
|
532
|
+
{/* Left sidebar: phases + artifacts */}
|
|
533
|
+
<aside className="w-[200px] shrink-0 border-r border-[#2a2a3a] flex flex-col overflow-hidden">
|
|
534
|
+
<div className="px-3 py-2 border-b border-[#2a2a3a]">
|
|
535
|
+
<div className="text-[9px] text-gray-500 uppercase font-bold mb-2">Phases</div>
|
|
536
|
+
<PhaseTimeline phases={delivery.phases} currentIndex={delivery.currentPhaseIndex} />
|
|
537
|
+
</div>
|
|
538
|
+
<div className="px-3 py-2 flex-1 overflow-y-auto">
|
|
539
|
+
<div className="text-[9px] text-gray-500 uppercase font-bold mb-2">Artifacts ({artifacts.length})</div>
|
|
540
|
+
<ArtifactList artifacts={artifacts} />
|
|
541
|
+
</div>
|
|
542
|
+
</aside>
|
|
543
|
+
|
|
544
|
+
{/* Main: all 4 agent terminals always visible */}
|
|
545
|
+
<main className="flex-1 flex flex-col gap-2 p-2 min-h-0 overflow-hidden">
|
|
546
|
+
{/* Approval panel overlay */}
|
|
547
|
+
{needsApproval && (
|
|
548
|
+
<ApprovalPanel deliveryId={deliveryId} artifacts={artifacts} onRefresh={fetchDelivery} />
|
|
549
|
+
)}
|
|
550
|
+
|
|
551
|
+
{/* 2x2 grid with flow overlay — all phases always shown */}
|
|
552
|
+
<div className="flex-1 relative min-h-0">
|
|
553
|
+
{/* Flow arrows overlay */}
|
|
554
|
+
<GridFlowOverlay phases={delivery.phases} />
|
|
555
|
+
{/* Agent panels */}
|
|
556
|
+
<div className={`absolute inset-0 grid gap-2 ${
|
|
557
|
+
delivery.phases.length <= 2 ? 'grid-cols-2 grid-rows-1' :
|
|
558
|
+
delivery.phases.length <= 4 ? 'grid-cols-2 grid-rows-2' :
|
|
559
|
+
delivery.phases.length <= 6 ? 'grid-cols-3 grid-rows-2' :
|
|
560
|
+
'grid-cols-3 grid-rows-3'
|
|
561
|
+
}`}>
|
|
562
|
+
{delivery.phases.map((phase, i) => (
|
|
563
|
+
<PhaseTerminal
|
|
564
|
+
key={phase.name}
|
|
565
|
+
phase={phase}
|
|
566
|
+
phaseIndex={i}
|
|
567
|
+
deliveryId={deliveryId}
|
|
568
|
+
artifacts={artifacts}
|
|
569
|
+
/>
|
|
570
|
+
))}
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
{/* Status bar */}
|
|
575
|
+
{delivery.status !== 'running' && (
|
|
576
|
+
<div className={`text-center py-1.5 rounded text-[10px] font-mono shrink-0 ${
|
|
577
|
+
delivery.status === 'done' ? 'bg-green-500/5 text-green-400 border border-green-500/30' :
|
|
578
|
+
delivery.status === 'failed' ? 'bg-red-500/5 text-red-400 border border-red-500/30' :
|
|
579
|
+
'bg-gray-500/5 text-gray-400 border border-gray-500/30'
|
|
580
|
+
}`}>
|
|
581
|
+
Delivery {delivery.status}
|
|
582
|
+
{delivery.completedAt && ` — ${new Date(delivery.completedAt).toLocaleString()}`}
|
|
583
|
+
</div>
|
|
584
|
+
)}
|
|
585
|
+
</main>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
@@ -18,17 +18,25 @@ function getWsUrl() {
|
|
|
18
18
|
return `${wsProtocol}//${wsHost}:${webPort + 1}`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export default function DocTerminal({ docRoot }: { docRoot: string }) {
|
|
21
|
+
export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent?: string }) {
|
|
22
22
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
23
|
const [connected, setConnected] = useState(false);
|
|
24
24
|
const wsRef = useRef<WebSocket | null>(null);
|
|
25
25
|
const docRootRef = useRef(docRoot);
|
|
26
26
|
const skipPermRef = useRef(false);
|
|
27
|
+
const agentCmdRef = useRef('claude');
|
|
27
28
|
|
|
28
29
|
useEffect(() => {
|
|
29
30
|
fetch('/api/settings').then(r => r.json())
|
|
30
31
|
.then((s: any) => { if (s.skipPermissions) skipPermRef.current = true; })
|
|
31
32
|
.catch(() => {});
|
|
33
|
+
fetch('/api/agents').then(r => r.json())
|
|
34
|
+
.then(data => {
|
|
35
|
+
const targetId = agent || data.defaultAgent || 'claude';
|
|
36
|
+
const found = (data.agents || []).find((a: any) => a.id === targetId);
|
|
37
|
+
if (found?.path) agentCmdRef.current = found.path;
|
|
38
|
+
})
|
|
39
|
+
.catch(() => {});
|
|
32
40
|
}, []);
|
|
33
41
|
docRootRef.current = docRoot;
|
|
34
42
|
|
|
@@ -89,7 +97,7 @@ export default function DocTerminal({ docRoot }: { docRoot: string }) {
|
|
|
89
97
|
setTimeout(() => {
|
|
90
98
|
if (socket.readyState === WebSocket.OPEN) {
|
|
91
99
|
const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
92
|
-
socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" &&
|
|
100
|
+
socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && ${agentCmdRef.current} -c${sf}\n` }));
|
|
93
101
|
}
|
|
94
102
|
}, 300);
|
|
95
103
|
}
|
|
@@ -108,6 +108,7 @@ export default function DocsViewer() {
|
|
|
108
108
|
const [loading, setLoading] = useState(false);
|
|
109
109
|
const [search, setSearch] = useState('');
|
|
110
110
|
const [terminalHeight, setTerminalHeight] = useState(250);
|
|
111
|
+
const [docsAgent, setDocsAgent] = useState('');
|
|
111
112
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
112
113
|
const [editing, setEditing] = useState(false);
|
|
113
114
|
const [editContent, setEditContent] = useState('');
|
|
@@ -267,6 +268,13 @@ export default function DocsViewer() {
|
|
|
267
268
|
|
|
268
269
|
useEffect(() => { fetchTree(activeRoot); }, [activeRoot, fetchTree]);
|
|
269
270
|
|
|
271
|
+
// Fetch agent config for doc roots
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
fetch('/api/settings').then(r => r.json())
|
|
274
|
+
.then((s: any) => setDocsAgent(s.docsAgent || ''))
|
|
275
|
+
.catch(() => {});
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
270
278
|
// Re-fetch when tab becomes visible (settings may have changed)
|
|
271
279
|
useEffect(() => {
|
|
272
280
|
const handleVisibility = () => {
|
|
@@ -555,10 +563,10 @@ export default function DocsViewer() {
|
|
|
555
563
|
className="h-1 bg-[var(--border)] cursor-row-resize hover:bg-[var(--accent)]/50 shrink-0"
|
|
556
564
|
/>
|
|
557
565
|
|
|
558
|
-
{/* Bottom —
|
|
566
|
+
{/* Bottom — Agent console */}
|
|
559
567
|
<div className="shrink-0" style={{ height: terminalHeight }}>
|
|
560
568
|
<Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
|
|
561
|
-
<DocTerminal docRoot={rootPaths[activeRoot] || ''} />
|
|
569
|
+
<DocTerminal docRoot={rootPaths[activeRoot] || ''} agent={docsAgent || undefined} />
|
|
562
570
|
</Suspense>
|
|
563
571
|
</div>
|
|
564
572
|
</div>
|