@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.
Files changed (93) hide show
  1. package/README.md +27 -2
  2. package/RELEASE_NOTES.md +21 -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 +2245 -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 +254 -0
  65. package/lib/help-docs/CLAUDE.md +7 -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 +1914 -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 +814 -0
  88. package/middleware.ts +1 -0
  89. package/next-env.d.ts +1 -1
  90. package/package.json +4 -1
  91. package/src/config/index.ts +12 -1
  92. package/src/core/db/database.ts +1 -0
  93. package/start.sh +7 -0
@@ -0,0 +1,347 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useRef } from 'react';
4
+ import {
5
+ ReactFlow,
6
+ Background,
7
+ Controls,
8
+ Handle,
9
+ Position,
10
+ useNodesState,
11
+ useEdgesState,
12
+ type Node,
13
+ type Edge,
14
+ type NodeProps,
15
+ MarkerType,
16
+ } from '@xyflow/react';
17
+ import '@xyflow/react/dist/style.css';
18
+ import type { TaskLogEntry } from '@/src/types';
19
+
20
+ // ─── Colors ──────────────────────────────────────────────
21
+
22
+ const PALETTE = [
23
+ { bg: '#1e2a4a', border: '#3b82f6', accent: '#60a5fa', running: '#fbbf24' },
24
+ { bg: '#2a1e4a', border: '#8b5cf6', accent: '#a78bfa', running: '#fbbf24' },
25
+ { bg: '#1e3a2a', border: '#22c55e', accent: '#4ade80', running: '#fbbf24' },
26
+ { bg: '#3a2a1e', border: '#f97316', accent: '#fb923c', running: '#fbbf24' },
27
+ ];
28
+
29
+ // ─── Task stream hook ────────────────────────────────────
30
+
31
+ function useTaskStream(taskId: string | undefined, isRunning: boolean) {
32
+ const [log, setLog] = useState<TaskLogEntry[]>([]);
33
+ useEffect(() => {
34
+ if (!taskId || !isRunning) { setLog([]); return; }
35
+ const es = new EventSource(`/api/tasks/${taskId}/stream`);
36
+ es.onmessage = (event) => {
37
+ try {
38
+ const data = JSON.parse(event.data);
39
+ if (data.type === 'log') setLog(prev => [...prev, data.entry]);
40
+ else if (data.type === 'complete' && data.task) setLog(data.task.log);
41
+ } catch {}
42
+ };
43
+ es.onerror = () => es.close();
44
+ return () => es.close();
45
+ }, [taskId, isRunning]);
46
+ return log;
47
+ }
48
+
49
+ // ─── Custom Nodes ────────────────────────────────────────
50
+
51
+ interface AgentNodeData {
52
+ agentId: string;
53
+ agent: string;
54
+ role: string;
55
+ colorIdx: number;
56
+ status: 'idle' | 'running' | 'done' | 'failed';
57
+ lastContent: string;
58
+ taskId?: string;
59
+ round: number;
60
+ [key: string]: unknown;
61
+ }
62
+
63
+ function AgentExecutionNode({ data }: NodeProps<Node<AgentNodeData>>) {
64
+ const p = PALETTE[data.colorIdx % PALETTE.length];
65
+ const isRunning = data.status === 'running';
66
+ const log = useTaskStream(data.taskId, isRunning);
67
+ const logEndRef = useRef<HTMLDivElement>(null);
68
+ useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [log]);
69
+
70
+ const borderColor = isRunning ? p.running : data.status === 'done' ? p.accent : data.status === 'failed' ? '#ef4444' : p.border;
71
+
72
+ return (
73
+ <div
74
+ className={`rounded-xl shadow-lg ${isRunning ? 'ring-2 ring-yellow-400/40' : ''}`}
75
+ style={{ background: p.bg, border: `2px solid ${borderColor}`, width: 280, minHeight: 120 }}
76
+ >
77
+ <Handle type="target" position={Position.Top} className="!w-3 !h-3" style={{ background: p.accent }} />
78
+
79
+ {/* Header */}
80
+ <div className="px-3 py-2 flex items-center gap-2" style={{ borderBottom: `1px solid ${p.border}` }}>
81
+ <span className="text-[8px] px-1.5 py-0.5 rounded font-bold" style={{ background: `${p.accent}30`, color: p.accent }}>{data.agent}</span>
82
+ <span className="text-xs font-bold text-white">{data.agentId}</span>
83
+ {isRunning && <span className="text-[8px] text-yellow-400 animate-pulse ml-auto">● R{data.round}</span>}
84
+ {data.status === 'done' && <span className="text-[8px] text-green-400 ml-auto">✓ R{data.round}</span>}
85
+ {data.status === 'failed' && <span className="text-[8px] text-red-400 ml-auto">✗</span>}
86
+ {data.status === 'idle' && <span className="text-[8px] text-gray-500 ml-auto">idle</span>}
87
+ </div>
88
+
89
+ {/* Role */}
90
+ <div className="px-3 pt-1">
91
+ <div className="text-[8px] text-gray-500 line-clamp-1">{data.role}</div>
92
+ </div>
93
+
94
+ {/* Content area — live log or last output */}
95
+ <div className="px-3 py-2">
96
+ {isRunning ? (
97
+ <div className="max-h-[100px] overflow-y-auto text-[8px] font-mono space-y-0.5">
98
+ {log.length === 0 ? (
99
+ <div className="text-gray-500 italic">Starting...</div>
100
+ ) : (
101
+ log.slice(-15).map((entry, i) => (
102
+ <div key={i} className={
103
+ entry.type === 'result' ? 'text-green-400' :
104
+ entry.subtype === 'error' ? 'text-red-400' :
105
+ entry.type === 'system' ? 'text-yellow-500/60' :
106
+ 'text-gray-400'
107
+ }>
108
+ {entry.type === 'assistant' && entry.subtype === 'tool_use'
109
+ ? `⚙ ${entry.tool || 'tool'}`
110
+ : entry.content.slice(0, 80)}{entry.content.length > 80 ? '...' : ''}
111
+ </div>
112
+ ))
113
+ )}
114
+ <div ref={logEndRef} />
115
+ </div>
116
+ ) : data.lastContent ? (
117
+ <div className="text-[8px] text-gray-400 max-h-[80px] overflow-y-auto whitespace-pre-wrap line-clamp-5">
118
+ {data.lastContent.slice(0, 300)}{data.lastContent.length > 300 ? '...' : ''}
119
+ </div>
120
+ ) : (
121
+ <div className="text-[8px] text-gray-600 italic">Waiting for input...</div>
122
+ )}
123
+ </div>
124
+
125
+ <Handle type="source" position={Position.Bottom} className="!w-3 !h-3" style={{ background: p.accent }} />
126
+ </div>
127
+ );
128
+ }
129
+
130
+ interface PromptNodeData { prompt: string; [key: string]: unknown }
131
+
132
+ function PromptExecutionNode({ data }: NodeProps<Node<PromptNodeData>>) {
133
+ return (
134
+ <div className="bg-[#1a2a1a] border-2 border-green-500/50 rounded-xl shadow-lg" style={{ width: 260 }}>
135
+ <div className="px-3 py-2 border-b border-green-500/30 flex items-center gap-2">
136
+ <span className="text-green-400 text-sm">▶</span>
137
+ <span className="text-[10px] font-bold text-green-300">Initial Prompt</span>
138
+ </div>
139
+ <div className="px-3 py-2 text-[9px] text-gray-400 whitespace-pre-wrap line-clamp-3">{data.prompt}</div>
140
+ <Handle type="source" position={Position.Bottom} className="!bg-green-400 !w-3 !h-3" />
141
+ </div>
142
+ );
143
+ }
144
+
145
+ interface StatusNodeData { label: string; status: string; round: number; maxRounds: number; [key: string]: unknown }
146
+
147
+ function StatusNode({ data }: NodeProps<Node<StatusNodeData>>) {
148
+ const color = data.status === 'done' ? '#22c55e' : data.status === 'failed' ? '#ef4444' : data.status === 'running' ? '#fbbf24' : '#6b7280';
149
+ return (
150
+ <div className="rounded-xl shadow-lg" style={{ background: '#1a1a2a', border: `2px solid ${color}`, width: 200 }}>
151
+ <Handle type="target" position={Position.Top} className="!w-3 !h-3" style={{ background: color }} />
152
+ <div className="px-3 py-3 text-center">
153
+ <div className="text-[10px] font-bold" style={{ color }}>{data.label}</div>
154
+ <div className="text-[8px] text-gray-500 mt-0.5">R{data.round}/{data.maxRounds} · {data.status}</div>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ const executionNodeTypes = {
161
+ agentExec: AgentExecutionNode,
162
+ promptExec: PromptExecutionNode,
163
+ statusExec: StatusNode,
164
+ };
165
+
166
+ // ─── Build graph from pipeline state ─────────────────────
167
+
168
+ function buildExecutionGraph(pipeline: any): { nodes: Node[]; edges: Edge[] } {
169
+ const conv = pipeline.conversation;
170
+ if (!conv) return { nodes: [], edges: [] };
171
+
172
+ const config = conv.config;
173
+ const messages = conv.messages || [];
174
+ const agents = config.agents || [];
175
+ const nodes: Node[] = [];
176
+ const edges: Edge[] = [];
177
+
178
+ const centerX = 300;
179
+ const agentSpacing = 320;
180
+ const totalWidth = (agents.length - 1) * agentSpacing;
181
+ const startX = centerX - totalWidth / 2;
182
+
183
+ // 1. Prompt node
184
+ nodes.push({
185
+ id: 'prompt',
186
+ type: 'promptExec',
187
+ position: { x: centerX - 130, y: 20 },
188
+ data: { prompt: config.initialPrompt },
189
+ draggable: true,
190
+ });
191
+
192
+ // 2. Agent nodes — with execution state
193
+ agents.forEach((a: any, i: number) => {
194
+ const agentMsgs = messages.filter((m: any) => m.agentId === a.id);
195
+ const lastMsg = [...agentMsgs].reverse()[0];
196
+ const status = lastMsg?.status === 'running' ? 'running' :
197
+ agentMsgs.some((m: any) => m.status === 'done') ? 'done' :
198
+ lastMsg?.status === 'failed' ? 'failed' : 'idle';
199
+ const lastDone = [...agentMsgs].reverse().find((m: any) => m.status === 'done');
200
+
201
+ nodes.push({
202
+ id: `agent-${a.id}`,
203
+ type: 'agentExec',
204
+ position: { x: startX + i * agentSpacing - 140, y: 180 },
205
+ data: {
206
+ agentId: a.id,
207
+ agent: a.agent,
208
+ role: a.role || '',
209
+ colorIdx: i,
210
+ status,
211
+ lastContent: lastDone?.content || '',
212
+ taskId: lastMsg?.status === 'running' ? lastMsg.taskId : undefined,
213
+ round: lastMsg?.round || 0,
214
+ },
215
+ draggable: true,
216
+ });
217
+
218
+ // Prompt → Agent
219
+ edges.push({
220
+ id: `prompt-agent-${a.id}`,
221
+ source: 'prompt',
222
+ target: `agent-${a.id}`,
223
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#22c55e' },
224
+ style: { stroke: '#22c55e', strokeWidth: 2 },
225
+ animated: status === 'running' || (i === 0 && pipeline.status === 'running' && !messages.length),
226
+ });
227
+ });
228
+
229
+ // 3. Data flow edges between agents
230
+ for (let i = 0; i < agents.length - 1; i++) {
231
+ const fromId = agents[i].id;
232
+ const toId = agents[i + 1].id;
233
+ const fromMsgs = messages.filter((m: any) => m.agentId === fromId && m.status === 'done');
234
+ const toMsgs = messages.filter((m: any) => m.agentId === toId);
235
+ const isFlowing = fromMsgs.length > 0 && toMsgs.length > 0;
236
+ const isActive = messages.some((m: any) => m.agentId === toId && m.status === 'running');
237
+ const p = PALETTE[i % PALETTE.length];
238
+
239
+ edges.push({
240
+ id: `flow-${fromId}-${toId}`,
241
+ source: `agent-${fromId}`,
242
+ target: `agent-${toId}`,
243
+ markerEnd: { type: MarkerType.ArrowClosed, color: p.accent },
244
+ style: { stroke: p.accent, strokeWidth: 2, strokeDasharray: '6 3' },
245
+ animated: isActive,
246
+ label: isFlowing ? `context (R${Math.max(...fromMsgs.map((m: any) => m.round))})` : 'context →',
247
+ labelStyle: { fill: p.accent, fontSize: 9, fontWeight: 600 },
248
+ type: 'smoothstep',
249
+ });
250
+ }
251
+
252
+ // 4. Loop back edge (last → first for next round)
253
+ if (agents.length >= 2 && config.maxRounds > 1) {
254
+ const lastAgent = agents[agents.length - 1];
255
+ const firstAgent = agents[0];
256
+ const lastDone = messages.filter((m: any) => m.agentId === lastAgent.id && m.status === 'done');
257
+ const isLooping = lastDone.length > 0 && conv.currentRound > 1;
258
+
259
+ edges.push({
260
+ id: `loop-back`,
261
+ source: `agent-${lastAgent.id}`,
262
+ target: `agent-${firstAgent.id}`,
263
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#f97316' },
264
+ style: { stroke: '#f97316', strokeWidth: 2, strokeDasharray: '8 4', opacity: isLooping ? 1 : 0.3 },
265
+ animated: isLooping && pipeline.status === 'running',
266
+ label: isLooping ? `round ${conv.currentRound}` : 'next round',
267
+ labelStyle: { fill: '#f97316', fontSize: 9, fontWeight: 600 },
268
+ type: 'smoothstep',
269
+ });
270
+ }
271
+
272
+ // 5. Status node
273
+ nodes.push({
274
+ id: 'status',
275
+ type: 'statusExec',
276
+ position: { x: centerX - 100, y: 480 },
277
+ data: {
278
+ label: pipeline.status === 'done' ? 'Complete' : pipeline.status === 'failed' ? 'Failed' : pipeline.status === 'cancelled' ? 'Cancelled' : 'Running...',
279
+ status: pipeline.status,
280
+ round: conv.currentRound,
281
+ maxRounds: config.maxRounds,
282
+ },
283
+ draggable: true,
284
+ });
285
+
286
+ // Agents → Status
287
+ agents.forEach((a: any) => {
288
+ edges.push({
289
+ id: `agent-${a.id}-status`,
290
+ source: `agent-${a.id}`,
291
+ target: 'status',
292
+ style: { stroke: '#6b7280', strokeWidth: 1, opacity: 0.3 },
293
+ type: 'smoothstep',
294
+ });
295
+ });
296
+
297
+ return { nodes, edges };
298
+ }
299
+
300
+ // ─── Main Component ──────────────────────────────────────
301
+
302
+ export default function ConversationGraphView({ pipeline }: { pipeline: any }) {
303
+ const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
304
+ const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
305
+
306
+ // Rebuild graph when pipeline updates
307
+ useEffect(() => {
308
+ const graph = buildExecutionGraph(pipeline);
309
+ setNodes(graph.nodes);
310
+ setEdges(graph.edges);
311
+ }, [pipeline, setNodes, setEdges]);
312
+
313
+ return (
314
+ <div style={{ width: '100%', height: '100%', minHeight: 400 }} className="relative">
315
+ <ReactFlow
316
+ nodes={nodes}
317
+ edges={edges}
318
+ onNodesChange={onNodesChange}
319
+ onEdgesChange={onEdgesChange}
320
+ nodeTypes={executionNodeTypes}
321
+ fitView
322
+ fitViewOptions={{ padding: 0.2 }}
323
+ nodesConnectable={false}
324
+ style={{ background: '#0a0a1a' }}
325
+ minZoom={0.3}
326
+ maxZoom={2}
327
+ >
328
+ <Background color="#1a1a3a" gap={20} />
329
+ <Controls />
330
+ </ReactFlow>
331
+
332
+ {/* Legend */}
333
+ <div className="absolute bottom-4 left-4 bg-[#0a0a1a]/90 border border-[#3a3a5a] rounded-lg p-2.5 space-y-1 backdrop-blur-sm">
334
+ <div className="text-[7px] font-bold text-gray-400 uppercase">Legend</div>
335
+ <div className="flex items-center gap-2 text-[7px] text-gray-400">
336
+ <span className="w-2 h-2 rounded-full bg-yellow-400 inline-block" /> Running
337
+ </div>
338
+ <div className="flex items-center gap-2 text-[7px] text-gray-400">
339
+ <span className="w-2 h-2 rounded-full bg-green-400 inline-block" /> Done
340
+ </div>
341
+ <div className="flex items-center gap-2 text-[7px] text-gray-400">
342
+ <span className="w-3 h-0.5 inline-block" style={{ borderBottom: '2px dashed #f97316' }} /> Round loop
343
+ </div>
344
+ </div>
345
+ </div>
346
+ );
347
+ }
@@ -0,0 +1,303 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import type { TaskLogEntry } from '@/src/types';
5
+
6
+ // ─── Colors per agent ─────────────────────────────────────
7
+
8
+ const TERM_COLORS = [
9
+ { headerBg: '#1e2a4a', border: '#3b82f6', accent: '#60a5fa' },
10
+ { headerBg: '#2a1e4a', border: '#8b5cf6', accent: '#a78bfa' },
11
+ { headerBg: '#1e3a2a', border: '#22c55e', accent: '#4ade80' },
12
+ { headerBg: '#3a2a1e', border: '#f97316', accent: '#fb923c' },
13
+ ];
14
+
15
+ // ─── Task SSE stream hook ─────────────────────────────────
16
+
17
+ function useAgentStream(taskIds: string[]) {
18
+ const [logs, setLogs] = useState<Map<string, TaskLogEntry[]>>(new Map());
19
+ const activeRef = useRef(new Set<string>());
20
+
21
+ useEffect(() => {
22
+ const sources: EventSource[] = [];
23
+ for (const taskId of taskIds) {
24
+ if (!taskId || activeRef.current.has(taskId)) continue;
25
+ activeRef.current.add(taskId);
26
+ const es = new EventSource(`/api/tasks/${taskId}/stream`);
27
+ sources.push(es);
28
+ es.onmessage = (event) => {
29
+ try {
30
+ const data = JSON.parse(event.data);
31
+ if (data.type === 'log') {
32
+ setLogs(prev => { const next = new Map(prev); next.set(taskId, [...(next.get(taskId) || []), data.entry]); return next; });
33
+ } else if (data.type === 'complete' && data.task) {
34
+ setLogs(prev => { const next = new Map(prev); next.set(taskId, data.task.log || []); return next; });
35
+ }
36
+ } catch {}
37
+ };
38
+ es.onerror = () => { es.close(); activeRef.current.delete(taskId); };
39
+ }
40
+ return () => { sources.forEach(es => es.close()); };
41
+ }, [taskIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
42
+
43
+ return logs;
44
+ }
45
+
46
+ // ─── Log line renderer ────────────────────────────────────
47
+
48
+ function LogLine({ entry, accent }: { entry: TaskLogEntry; accent: string }) {
49
+ if (entry.type === 'system' && entry.subtype === 'init') return <div className="text-gray-600 text-[9px]">{entry.content}</div>;
50
+ if (entry.type === 'assistant' && entry.subtype === 'tool_use') {
51
+ return <div className="text-[10px]"><span style={{ color: accent }}>⚙</span> <span className="text-blue-400">{entry.tool || 'tool'}</span><span className="text-gray-600"> {entry.content.slice(0, 100)}{entry.content.length > 100 ? '...' : ''}</span></div>;
52
+ }
53
+ if (entry.type === 'assistant' && entry.subtype === 'tool_result') {
54
+ return <div className={`text-[10px] ${entry.content.toLowerCase().includes('error') ? 'text-red-400' : 'text-gray-500'}`}>{' → '}{entry.content.slice(0, 150)}{entry.content.length > 150 ? '...' : ''}</div>;
55
+ }
56
+ if (entry.type === 'result') return <div className="text-green-400 text-[10px]">{entry.content}</div>;
57
+ if (entry.subtype === 'error') return <div className="text-red-400 text-[10px]">{entry.content}</div>;
58
+ return <div className="text-[10px]" style={{ color: '#c9d1d9' }}>{entry.content.slice(0, 200)}{entry.content.length > 200 ? '...' : ''}</div>;
59
+ }
60
+
61
+ // ─── Agent terminal panel with inline input ───────────────
62
+
63
+ function AgentTerminalPanel({ agentDef, messages, colorIdx, allLogs, pipelineId, pipelineRunning, onViewTask }: {
64
+ agentDef: { id: string; agent: string; role: string };
65
+ messages: any[];
66
+ colorIdx: number;
67
+ allLogs: Map<string, TaskLogEntry[]>;
68
+ pipelineId: string;
69
+ pipelineRunning: boolean;
70
+ onViewTask?: (taskId: string) => void;
71
+ }) {
72
+ const colors = TERM_COLORS[colorIdx % TERM_COLORS.length];
73
+ const scrollRef = useRef<HTMLDivElement>(null);
74
+ const [input, setInput] = useState('');
75
+ const [sending, setSending] = useState(false);
76
+ const lastMsg = messages[messages.length - 1];
77
+ const isRunning = lastMsg?.status === 'running';
78
+
79
+ useEffect(() => {
80
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
81
+ }, [messages, allLogs]);
82
+
83
+ const handleSend = async () => {
84
+ if (!input.trim() || sending) return;
85
+ setSending(true);
86
+ try {
87
+ await fetch(`/api/pipelines/${pipelineId}`, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({ action: 'inject', agentId: agentDef.id, message: input }),
91
+ });
92
+ setInput('');
93
+ } catch {}
94
+ setSending(false);
95
+ };
96
+
97
+ return (
98
+ <div className="flex flex-col min-w-0 min-h-0 border rounded-lg overflow-hidden" style={{ borderColor: colors.border, flex: 1 }}>
99
+ {/* Header */}
100
+ <div className="flex items-center gap-2 px-3 py-1.5 shrink-0" style={{ background: colors.headerBg, borderBottom: `1px solid ${colors.border}` }}>
101
+ <div className="flex gap-1">
102
+ <span className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
103
+ <span className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
104
+ <span className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
105
+ </div>
106
+ <span className="text-[10px] font-bold text-white ml-1">{agentDef.id}</span>
107
+ <span className="text-[8px] px-1.5 py-0.5 rounded font-medium" style={{ background: `${colors.accent}30`, color: colors.accent }}>{agentDef.agent}</span>
108
+ {isRunning && <span className="text-[8px] text-yellow-400 animate-pulse">● running</span>}
109
+ {lastMsg && !isRunning && lastMsg.status === 'done' && <span className="text-[8px] text-green-400">✓</span>}
110
+ <span className="text-[7px] text-gray-500 ml-auto truncate max-w-[120px]">{agentDef.role}</span>
111
+ </div>
112
+
113
+ {/* Terminal body */}
114
+ <div ref={scrollRef} className="flex-1 overflow-y-auto p-2 font-mono text-[11px] leading-[1.6]" style={{ background: '#0d1117', color: '#c9d1d9' }}>
115
+ {messages.length === 0 && (
116
+ <div className="text-gray-600"><span style={{ color: colors.accent }}>$</span> waiting for input...</div>
117
+ )}
118
+ {messages.map((msg: any, idx: number) => {
119
+ const taskLogs = allLogs.get(msg.taskId) || [];
120
+ return (
121
+ <div key={idx} className="mb-2">
122
+ <div className="text-gray-600 text-[8px]">{'─'.repeat(15)} R{msg.round} {'─'.repeat(15)}</div>
123
+ {/* User inject messages */}
124
+ {msg.agentId === 'user' ? (
125
+ <div className="text-yellow-300 text-[10px]"><span className="text-yellow-500">▸</span> {msg.content}</div>
126
+ ) : msg.status === 'running' ? (
127
+ <>
128
+ <div className="text-gray-500 text-[9px]"><span style={{ color: colors.accent }}>$</span> task:{msg.taskId}</div>
129
+ {taskLogs.length === 0 ? (
130
+ <div className="text-gray-600 animate-pulse">Starting...</div>
131
+ ) : taskLogs.map((e, i) => <LogLine key={i} entry={e} accent={colors.accent} />)}
132
+ <span className="inline-block w-2 h-4 bg-gray-400 animate-pulse ml-0.5" />
133
+ </>
134
+ ) : msg.status === 'done' ? (
135
+ <>
136
+ <div className="text-gray-500 text-[9px]">
137
+ <span style={{ color: colors.accent }}>$</span> task:{msg.taskId}
138
+ {onViewTask && <button onClick={() => onViewTask(msg.taskId)} className="ml-2 hover:underline" style={{ color: colors.accent }}>view</button>}
139
+ </div>
140
+ <div className="whitespace-pre-wrap text-[10px]" style={{ color: '#c9d1d9' }}>{msg.content.slice(0, 5000)}{msg.content.length > 5000 ? '\n[...]' : ''}</div>
141
+ <div className="text-green-500 text-[8px] mt-0.5">✓ {new Date(msg.timestamp).toLocaleTimeString()}</div>
142
+ </>
143
+ ) : msg.status === 'failed' ? (
144
+ <div className="text-red-400 text-[10px]">{msg.content || 'Failed'}</div>
145
+ ) : null}
146
+ </div>
147
+ );
148
+ })}
149
+ </div>
150
+
151
+ {/* Inline input */}
152
+ {pipelineRunning && (
153
+ <div className="flex items-center gap-1 px-2 py-1 shrink-0" style={{ background: '#161b22', borderTop: `1px solid ${colors.border}40` }}>
154
+ <span className="text-[10px] font-mono" style={{ color: colors.accent }}>$</span>
155
+ <input
156
+ value={input}
157
+ onChange={e => setInput(e.target.value)}
158
+ onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
159
+ placeholder={`@${agentDef.id}...`}
160
+ className="flex-1 bg-transparent text-[10px] font-mono text-gray-300 focus:outline-none placeholder:text-gray-600"
161
+ />
162
+ {input.trim() && (
163
+ <button onClick={handleSend} disabled={sending} className="text-[8px] px-1.5 py-0.5 rounded" style={{ background: `${colors.accent}30`, color: colors.accent }}>
164
+ {sending ? '...' : 'Send'}
165
+ </button>
166
+ )}
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // ─── Horizontal data flow connector ───────────────────────
174
+
175
+ function HorizontalFlow({ fromColor, toColor, label, isActive }: {
176
+ fromColor: string; toColor: string; label: string; isActive: boolean;
177
+ }) {
178
+ return (
179
+ <div className="flex flex-col items-center justify-center w-10 shrink-0 gap-0.5">
180
+ <svg width="32" height="24" viewBox="0 0 32 24">
181
+ <defs>
182
+ <linearGradient id={`hflow-${label}`} x1="0" y1="0" x2="1" y2="0">
183
+ <stop offset="0%" stopColor={fromColor} />
184
+ <stop offset="100%" stopColor={toColor} />
185
+ </linearGradient>
186
+ </defs>
187
+ <line x1="0" y1="12" x2="24" y2="12" stroke={`url(#hflow-${label})`} strokeWidth="2"
188
+ strokeDasharray={isActive ? '4 3' : '2 2'} opacity={isActive ? 1 : 0.3}>
189
+ {isActive && <animate attributeName="stroke-dashoffset" from="14" to="0" dur="0.6s" repeatCount="indefinite" />}
190
+ </line>
191
+ <polygon points="22,6 32,12 22,18" fill={toColor} opacity={isActive ? 1 : 0.3} />
192
+ </svg>
193
+ <div className="text-[7px] text-gray-500">{label}</div>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ // ─── Main component ───────────────────────────────────────
199
+
200
+ export default function ConversationTerminalView({ pipeline, onViewTask }: {
201
+ pipeline: any;
202
+ onViewTask?: (taskId: string) => void;
203
+ }) {
204
+ const conv = pipeline.conversation;
205
+ if (!conv) return null;
206
+
207
+ const { config, messages } = conv;
208
+ const agents = config.agents || [];
209
+ const isRunning = pipeline.status === 'running';
210
+
211
+ const runningTaskIds = messages.filter((m: any) => m.status === 'running' && m.taskId).map((m: any) => m.taskId);
212
+ const allLogs = useAgentStream(runningTaskIds);
213
+
214
+ const agentMessages: Record<string, any[]> = {};
215
+ for (const a of agents) {
216
+ agentMessages[a.id] = messages.filter((m: any) => m.agentId === a.id || (m.agentId === 'user' && m.content.includes(`@${a.id}`)));
217
+ }
218
+
219
+ // Data flow state per pair
220
+ const getFlowState = (fromIdx: number, toIdx: number) => {
221
+ const toAgent = agents[toIdx];
222
+ const toMsgs = messages.filter((m: any) => m.agentId === toAgent.id);
223
+ const latestTo = toMsgs[toMsgs.length - 1];
224
+ const isActive = latestTo?.status === 'running';
225
+ const latestRound = latestTo?.round || 0;
226
+ return { isActive, round: latestRound };
227
+ };
228
+
229
+ return (
230
+ <div className="flex flex-col h-full" style={{ background: '#0d1117' }}>
231
+ {/* Top: Initial Prompt bar */}
232
+ <div className="shrink-0 mx-3 mt-2 mb-1 border border-green-500/40 rounded-lg overflow-hidden" style={{ background: '#0d1e0d' }}>
233
+ <div className="flex items-center gap-2 px-3 py-1" style={{ borderBottom: '1px solid rgba(34,197,94,0.2)' }}>
234
+ <span className="text-green-400 text-[10px]">▶</span>
235
+ <span className="text-[10px] font-bold text-green-300">Initial Prompt</span>
236
+ <span className="text-[8px] text-gray-500 ml-auto">R{conv.currentRound}/{config.maxRounds}</span>
237
+ </div>
238
+ <div className="px-3 py-1.5 text-[10px] font-mono text-green-200/80 whitespace-pre-wrap line-clamp-2">{config.initialPrompt}</div>
239
+ </div>
240
+
241
+ {/* Down arrow from prompt */}
242
+ <div className="flex justify-center shrink-0">
243
+ <svg width="20" height="16" viewBox="0 0 20 16">
244
+ <line x1="10" y1="0" x2="10" y2="10" stroke="#22c55e" strokeWidth="2" strokeDasharray="3 2">
245
+ {isRunning && <animate attributeName="stroke-dashoffset" from="10" to="0" dur="0.6s" repeatCount="indefinite" />}
246
+ </line>
247
+ <polygon points="5,10 15,10 10,16" fill="#22c55e" opacity={isRunning ? 1 : 0.4} />
248
+ </svg>
249
+ </div>
250
+
251
+ {/* Middle: Agent terminals with flow connectors */}
252
+ <div className="flex-1 flex items-stretch gap-0 px-3 min-h-0">
253
+ {agents.map((agent: any, i: number) => {
254
+ const flow = i > 0 ? getFlowState(i - 1, i) : null;
255
+ return (
256
+ <div key={agent.id} className="flex items-stretch min-w-0" style={{ flex: 1 }}>
257
+ {i > 0 && flow && (
258
+ <HorizontalFlow
259
+ fromColor={TERM_COLORS[(i - 1) % TERM_COLORS.length].accent}
260
+ toColor={TERM_COLORS[i % TERM_COLORS.length].accent}
261
+ label={flow.round > 0 ? `R${flow.round}` : ''}
262
+ isActive={flow.isActive}
263
+ />
264
+ )}
265
+ <AgentTerminalPanel
266
+ agentDef={agent}
267
+ messages={agentMessages[agent.id] || []}
268
+ colorIdx={i}
269
+ allLogs={allLogs}
270
+ pipelineId={pipeline.id}
271
+ pipelineRunning={isRunning}
272
+ onViewTask={onViewTask}
273
+ />
274
+ </div>
275
+ );
276
+ })}
277
+ </div>
278
+
279
+ {/* Up arrow to status */}
280
+ <div className="flex justify-center shrink-0">
281
+ <svg width="20" height="16" viewBox="0 0 20 16">
282
+ <line x1="10" y1="0" x2="10" y2="10" stroke={pipeline.status === 'done' ? '#22c55e' : pipeline.status === 'failed' ? '#ef4444' : '#6b7280'} strokeWidth="2" strokeDasharray="3 2" />
283
+ <polygon points="5,10 15,10 10,16" fill={pipeline.status === 'done' ? '#22c55e' : pipeline.status === 'failed' ? '#ef4444' : '#6b7280'} opacity="0.5" />
284
+ </svg>
285
+ </div>
286
+
287
+ {/* Bottom: Status bar */}
288
+ <div className={`shrink-0 mx-3 mb-2 border rounded-lg px-3 py-1.5 text-center text-[10px] font-mono ${
289
+ pipeline.status === 'done' ? 'border-green-500/40 text-green-400 bg-green-500/5' :
290
+ pipeline.status === 'failed' ? 'border-red-500/40 text-red-400 bg-red-500/5' :
291
+ pipeline.status === 'cancelled' ? 'border-gray-500/40 text-gray-400 bg-gray-500/5' :
292
+ 'border-yellow-500/40 text-yellow-400 bg-yellow-500/5'
293
+ }`}>
294
+ {pipeline.status === 'running' ? `Running — Round ${conv.currentRound}/${config.maxRounds}` :
295
+ pipeline.status === 'done' ? `Complete — ${messages.filter((m: any) => m.agentId !== 'user').length} messages` :
296
+ pipeline.status === 'failed' ? 'Failed' : 'Cancelled'}
297
+ {config.stopCondition && pipeline.status === 'running' && (
298
+ <span className="text-gray-500 ml-2">stop: {config.stopCondition}</span>
299
+ )}
300
+ </div>
301
+ </div>
302
+ );
303
+ }