@aion0/forge 0.4.16 → 0.5.0

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 (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. 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}" && claude -c${sf}\n` }));
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 — Claude console */}
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>