@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,2245 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
4
+ import {
5
+ ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
6
+ type Node, type NodeProps, MarkerType, type NodeChange,
7
+ applyNodeChanges,
8
+ } from '@xyflow/react';
9
+ import '@xyflow/react/dist/style.css';
10
+
11
+ // ─── Types (mirrors lib/workspace/types) ─────────────────
12
+
13
+ interface AgentConfig {
14
+ id: string; label: string; icon: string; role: string;
15
+ type?: 'agent' | 'input';
16
+ content?: string;
17
+ entries?: { content: string; timestamp: number }[];
18
+ backend: 'api' | 'cli';
19
+ agentId?: string; provider?: string; model?: string;
20
+ dependsOn: string[];
21
+ workDir?: string;
22
+ outputs: string[];
23
+ steps: { id: string; label: string; prompt: string }[];
24
+ requiresApproval?: boolean;
25
+ watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve'; prompt?: string };
26
+ }
27
+
28
+ interface AgentState {
29
+ smithStatus: 'down' | 'active';
30
+ mode: 'auto' | 'manual';
31
+ taskStatus: 'idle' | 'running' | 'done' | 'failed';
32
+ currentStep?: number;
33
+ tmuxSession?: string;
34
+ artifacts: { type: string; path?: string; summary?: string }[];
35
+ error?: string; lastCheckpoint?: number;
36
+ daemonIteration?: number;
37
+ }
38
+
39
+ // ─── Constants ───────────────────────────────────────────
40
+
41
+ const COLORS = [
42
+ { border: '#22c55e', bg: '#0a1a0a', accent: '#4ade80' },
43
+ { border: '#3b82f6', bg: '#0a0f1a', accent: '#60a5fa' },
44
+ { border: '#a855f7', bg: '#100a1a', accent: '#c084fc' },
45
+ { border: '#f97316', bg: '#1a100a', accent: '#fb923c' },
46
+ { border: '#ec4899', bg: '#1a0a10', accent: '#f472b6' },
47
+ { border: '#06b6d4', bg: '#0a1a1a', accent: '#22d3ee' },
48
+ ];
49
+
50
+ // Smith status colors
51
+ const SMITH_STATUS: Record<string, { label: string; color: string; glow?: boolean }> = {
52
+ down: { label: 'down', color: '#30363d' },
53
+ active: { label: 'active', color: '#3fb950', glow: true },
54
+ };
55
+
56
+ // Task status colors
57
+ const TASK_STATUS: Record<string, { label: string; color: string; glow?: boolean }> = {
58
+ idle: { label: 'idle', color: '#30363d' },
59
+ running: { label: 'running', color: '#3fb950', glow: true },
60
+ done: { label: 'done', color: '#58a6ff' },
61
+ failed: { label: 'failed', color: '#f85149' },
62
+ };
63
+
64
+ const PRESET_AGENTS: Omit<AgentConfig, 'id'>[] = [
65
+ { label: 'PM', icon: '📋', role: 'Product Manager — analyze requirements, write PRD. Do NOT write code.', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/prd.md'], steps: [
66
+ { id: 'analyze', label: 'Analyze', prompt: 'Read existing docs and project structure. Identify key requirements.' },
67
+ { id: 'write', label: 'Write PRD', prompt: 'Write a detailed PRD to docs/prd.md.' },
68
+ { id: 'review', label: 'Self-Review', prompt: 'Review and improve the PRD.' },
69
+ ]},
70
+ { label: 'Engineer', icon: '🔨', role: 'Senior Engineer — design and implement based on PRD.', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['src/', 'docs/architecture.md'], steps: [
71
+ { id: 'design', label: 'Design', prompt: 'Read PRD, design architecture, write docs/architecture.md.' },
72
+ { id: 'implement', label: 'Implement', prompt: 'Implement features based on the architecture.' },
73
+ { id: 'test', label: 'Self-Test', prompt: 'Review implementation and fix issues.' },
74
+ ]},
75
+ { label: 'QA', icon: '🧪', role: 'QA Engineer — write and run tests. Do NOT fix bugs, only report.', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['tests/', 'docs/test-plan.md'], steps: [
76
+ { id: 'plan', label: 'Test Plan', prompt: 'Write test plan to docs/test-plan.md.' },
77
+ { id: 'write', label: 'Write Tests', prompt: 'Implement test cases in tests/ directory.' },
78
+ { id: 'run', label: 'Run Tests', prompt: 'Run all tests and document results.' },
79
+ ]},
80
+ { label: 'Reviewer', icon: '🔍', role: 'Code Reviewer — review for quality and security. Do NOT modify code.', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/review.md'], steps: [
81
+ { id: 'review', label: 'Review', prompt: 'Review all code changes for quality and security.' },
82
+ { id: 'report', label: 'Report', prompt: 'Write review report to docs/review.md.' },
83
+ ]},
84
+ ];
85
+
86
+ // ─── API helpers ─────────────────────────────────────────
87
+
88
+ async function wsApi(workspaceId: string, action: string, body?: Record<string, any>) {
89
+ const res = await fetch(`/api/workspace/${workspaceId}/agents`, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({ action, ...body }),
93
+ });
94
+ const data = await res.json();
95
+ if (data.warning) {
96
+ alert(`Warning: ${data.warning}`);
97
+ }
98
+ if (!res.ok && data.error) {
99
+ alert(`Error: ${data.error}`);
100
+ }
101
+ return data;
102
+ }
103
+
104
+ async function ensureWorkspace(projectPath: string, projectName: string): Promise<string> {
105
+ // Find or create workspace
106
+ const res = await fetch(`/api/workspace?projectPath=${encodeURIComponent(projectPath)}`);
107
+ const existing = await res.json();
108
+ if (existing?.id) return existing.id;
109
+
110
+ const createRes = await fetch('/api/workspace', {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ projectPath, projectName }),
114
+ });
115
+ const created = await createRes.json();
116
+ return created.id;
117
+ }
118
+
119
+ // ─── SSE Hook ────────────────────────────────────────────
120
+
121
+ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) => void) {
122
+ const [agents, setAgents] = useState<AgentConfig[]>([]);
123
+ const [states, setStates] = useState<Record<string, AgentState>>({});
124
+ const [logPreview, setLogPreview] = useState<Record<string, string[]>>({});
125
+ const [busLog, setBusLog] = useState<any[]>([]);
126
+ const [daemonActive, setDaemonActive] = useState(false);
127
+ const onEventRef = useRef(onEvent);
128
+ onEventRef.current = onEvent;
129
+
130
+ useEffect(() => {
131
+ if (!workspaceId) return;
132
+
133
+ const es = new EventSource(`/api/workspace/${workspaceId}/stream`);
134
+
135
+ es.onmessage = (e) => {
136
+ try {
137
+ const event = JSON.parse(e.data);
138
+
139
+ if (event.type === 'init') {
140
+ setAgents(event.agents || []);
141
+ setStates(event.agentStates || {});
142
+ setBusLog(event.busLog || []);
143
+ if (event.daemonActive !== undefined) setDaemonActive(event.daemonActive);
144
+ return;
145
+ }
146
+
147
+ if (event.type === 'task_status') {
148
+ setStates(prev => ({
149
+ ...prev,
150
+ [event.agentId]: {
151
+ ...prev[event.agentId],
152
+ taskStatus: event.taskStatus,
153
+ error: event.error,
154
+ },
155
+ }));
156
+ }
157
+
158
+ if (event.type === 'smith_status') {
159
+ setStates(prev => ({
160
+ ...prev,
161
+ [event.agentId]: {
162
+ ...prev[event.agentId],
163
+ smithStatus: event.smithStatus,
164
+ mode: event.mode,
165
+ },
166
+ }));
167
+ }
168
+
169
+ if (event.type === 'log') {
170
+ const entry = event.entry;
171
+ if (entry?.content) {
172
+ setLogPreview(prev => {
173
+ // Summary entries replace the preview entirely (cleaner display)
174
+ if (entry.subtype === 'step_summary' || entry.subtype === 'final_summary') {
175
+ const summaryLines = entry.content.split('\n').filter((l: string) => l.trim()).slice(0, 4);
176
+ return { ...prev, [event.agentId]: summaryLines };
177
+ }
178
+ // Regular logs: append, keep last 3
179
+ const lines = [...(prev[event.agentId] || []), entry.content].slice(-3);
180
+ return { ...prev, [event.agentId]: lines };
181
+ });
182
+ }
183
+ }
184
+
185
+ if (event.type === 'step') {
186
+ setStates(prev => ({
187
+ ...prev,
188
+ [event.agentId]: { ...prev[event.agentId], currentStep: event.stepIndex },
189
+ }));
190
+ }
191
+
192
+ if (event.type === 'error') {
193
+ setStates(prev => ({
194
+ ...prev,
195
+ [event.agentId]: { ...prev[event.agentId], taskStatus: 'failed', error: event.error },
196
+ }));
197
+ }
198
+
199
+ if (event.type === 'bus_message') {
200
+ setBusLog(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message]);
201
+ }
202
+
203
+ if (event.type === 'bus_message_status') {
204
+ setBusLog(prev => prev.map(m =>
205
+ m.id === event.messageId ? { ...m, status: event.status } : m
206
+ ));
207
+ }
208
+
209
+ // Server pushed updated agents list + states (after add/remove/update/reset)
210
+ if (event.type === 'agents_changed') {
211
+ const newAgents = event.agents || [];
212
+ setAgents(prev => {
213
+ // Guard: don't accept a smaller agents list unless it was an explicit removal
214
+ // (removal shrinks by exactly 1, not more)
215
+ if (newAgents.length > 0 && newAgents.length < prev.length - 1) {
216
+ console.warn(`[sse] agents_changed: ignoring shrink from ${prev.length} to ${newAgents.length}`);
217
+ return prev;
218
+ }
219
+ return newAgents;
220
+ });
221
+ if (event.agentStates) setStates(event.agentStates);
222
+ }
223
+
224
+ // Watch alerts — update agent state with last alert
225
+ if (event.type === 'watch_alert') {
226
+ setStates(prev => ({
227
+ ...prev,
228
+ [event.agentId]: {
229
+ ...prev[event.agentId],
230
+ lastWatchAlert: event.summary,
231
+ lastWatchTime: event.timestamp,
232
+ },
233
+ }));
234
+ }
235
+
236
+ // Forward special events to the component
237
+ if (event.type === 'user_input_request' || event.type === 'workspace_complete') {
238
+ onEventRef.current?.(event);
239
+ }
240
+ } catch {}
241
+ };
242
+
243
+ return () => es.close();
244
+ }, [workspaceId]);
245
+
246
+ return { agents, states, logPreview, busLog, setAgents, daemonActive, setDaemonActive };
247
+ }
248
+
249
+ // ─── Agent Config Modal ──────────────────────────────────
250
+
251
+ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfirm, onCancel }: {
252
+ initial: Partial<AgentConfig>;
253
+ mode: 'add' | 'edit';
254
+ existingAgents: AgentConfig[];
255
+ projectPath?: string;
256
+ onConfirm: (cfg: Omit<AgentConfig, 'id'>) => void;
257
+ onCancel: () => void;
258
+ }) {
259
+ const [label, setLabel] = useState(initial.label || '');
260
+ const [icon, setIcon] = useState(initial.icon || '🤖');
261
+ const [role, setRole] = useState(initial.role || '');
262
+ const [backend, setBackend] = useState<'api' | 'cli'>(initial.backend === 'api' ? 'api' : 'cli');
263
+ const [agentId, setAgentId] = useState(initial.agentId || 'claude');
264
+ const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; isProfile?: boolean; backendType?: string }[]>([]);
265
+
266
+ useEffect(() => {
267
+ fetch('/api/agents').then(r => r.json()).then(data => {
268
+ const list = (data.agents || data || []).map((a: any) => ({
269
+ id: a.id, name: a.name || a.id,
270
+ isProfile: a.isProfile || a.base,
271
+ backendType: a.backendType || 'cli',
272
+ }));
273
+ setAvailableAgents(list);
274
+ }).catch(() => {});
275
+ }, []);
276
+ const [workDirVal, setWorkDirVal] = useState(initial.workDir || '');
277
+ const [outputs, setOutputs] = useState((initial.outputs || []).join(', '));
278
+ const [selectedDeps, setSelectedDeps] = useState<Set<string>>(new Set(initial.dependsOn || []));
279
+ const [stepsText, setStepsText] = useState(
280
+ (initial.steps || []).map(s => `${s.label}: ${s.prompt}`).join('\n') || ''
281
+ );
282
+ const [requiresApproval, setRequiresApproval] = useState(initial.requiresApproval || false);
283
+ const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
284
+ const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
285
+ const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve'>(initial.watch?.action || 'log');
286
+ const [watchPrompt, setWatchPrompt] = useState(initial.watch?.prompt || '');
287
+ const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string }[]>(
288
+ initial.watch?.targets || []
289
+ );
290
+ const [projectDirs, setProjectDirs] = useState<string[]>([]);
291
+
292
+ useEffect(() => {
293
+ if (!watchEnabled || !projectPath) return;
294
+ fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
295
+ .then(r => r.json())
296
+ .then(data => {
297
+ // Flatten directory tree (type='dir') to list of paths
298
+ const dirs: string[] = [];
299
+ const walk = (nodes: any[], prefix = '') => {
300
+ for (const n of nodes || []) {
301
+ if (n.type === 'dir') {
302
+ const path = prefix ? `${prefix}/${n.name}` : n.name;
303
+ dirs.push(path);
304
+ if (n.children) walk(n.children, path);
305
+ }
306
+ }
307
+ };
308
+ walk(data.tree || []);
309
+ setProjectDirs(dirs);
310
+ })
311
+ .catch(() => {});
312
+ }, [watchEnabled, projectPath]);
313
+
314
+ const applyPreset = (p: Omit<AgentConfig, 'id'>) => {
315
+ setLabel(p.label); setIcon(p.icon); setRole(p.role);
316
+ setBackend(p.backend); setAgentId(p.agentId || 'claude');
317
+ setWorkDirVal(p.workDir || './');
318
+ setOutputs(p.outputs.join(', '));
319
+ setStepsText(p.steps.map(s => `${s.label}: ${s.prompt}`).join('\n'));
320
+ };
321
+
322
+ const toggleDep = (id: string) => {
323
+ setSelectedDeps(prev => {
324
+ const next = new Set(prev);
325
+ if (next.has(id)) next.delete(id); else next.add(id);
326
+ return next;
327
+ });
328
+ };
329
+
330
+ const parseSteps = () => stepsText.split('\n').filter(Boolean).map((line, i) => {
331
+ const [lbl, ...rest] = line.split(':');
332
+ return { id: `step-${i}`, label: lbl.trim(), prompt: rest.join(':').trim() || lbl.trim() };
333
+ });
334
+
335
+ // Filter out self when editing
336
+ const otherAgents = existingAgents.filter(a => a.id !== initial.id);
337
+
338
+ return (
339
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
340
+ onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
341
+ <div className="w-[440px] max-h-[80vh] overflow-auto rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
342
+ <div className="flex items-center justify-between mb-3">
343
+ <span className="text-sm font-bold text-white">{mode === 'add' ? 'Add Agent' : 'Edit Agent'}</span>
344
+ <button onClick={onCancel} className="text-gray-500 hover:text-white text-xs">✕</button>
345
+ </div>
346
+
347
+ <div className="flex flex-col gap-2.5">
348
+ {/* Preset quick-select (add mode only) */}
349
+ {mode === 'add' && (
350
+ <div className="flex flex-col gap-1">
351
+ <label className="text-[9px] text-gray-500 uppercase">Template</label>
352
+ <div className="flex gap-1 flex-wrap">
353
+ {PRESET_AGENTS.map((p, i) => (
354
+ <button key={i} onClick={() => applyPreset(p)}
355
+ className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === p.label ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
356
+ {p.icon} {p.label}
357
+ </button>
358
+ ))}
359
+ <button onClick={() => { setLabel(''); setIcon('🤖'); setRole(''); setStepsText(''); setOutputs(''); }}
360
+ className={`text-[9px] px-2 py-1 rounded border border-dashed ${!label ? 'border-[#58a6ff] text-[#58a6ff]' : 'border-[#30363d] text-gray-500 hover:text-white'}`}>
361
+ Custom
362
+ </button>
363
+ </div>
364
+ </div>
365
+ )}
366
+
367
+ {/* Icon + Label */}
368
+ <div className="flex gap-2">
369
+ <div className="flex flex-col gap-1">
370
+ <label className="text-[9px] text-gray-500 uppercase">Icon</label>
371
+ <input value={icon} onChange={e => setIcon(e.target.value)} className="w-12 text-center text-sm bg-[#161b22] border border-[#30363d] rounded px-1 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
372
+ </div>
373
+ <div className="flex flex-col gap-1 flex-1">
374
+ <label className="text-[9px] text-gray-500 uppercase">Label</label>
375
+ <input value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Engineer" className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
376
+ </div>
377
+ </div>
378
+
379
+ {/* Backend */}
380
+ <div className="flex flex-col gap-1">
381
+ <label className="text-[9px] text-gray-500 uppercase">Backend</label>
382
+ <div className="flex gap-1">
383
+ {(['cli', 'api'] as const).map(b => (
384
+ <button key={b} onClick={() => setBackend(b)}
385
+ className={`text-[9px] px-2 py-1 rounded border ${backend === b ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
386
+ {b === 'cli' ? 'CLI (subscription)' : 'API (api key)'}
387
+ </button>
388
+ ))}
389
+ </div>
390
+ </div>
391
+
392
+ {/* Agent selection — dynamic from /api/agents */}
393
+ {backend === 'cli' && (
394
+ <div className="flex flex-col gap-1">
395
+ <label className="text-[9px] text-gray-500 uppercase">Agent / Profile</label>
396
+ <div className="flex gap-1 flex-wrap">
397
+ {(availableAgents.length > 0
398
+ ? availableAgents.filter(a => a.backendType !== 'api')
399
+ : [{ id: 'claude', name: 'claude' }, { id: 'codex', name: 'codex' }, { id: 'aider', name: 'aider' }]
400
+ ).map(a => (
401
+ <button key={a.id} onClick={() => setAgentId(a.id)}
402
+ className={`text-[9px] px-2 py-1 rounded border ${agentId === a.id ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
403
+ {a.name}{a.isProfile ? ' ●' : ''}
404
+ </button>
405
+ ))}
406
+ </div>
407
+ </div>
408
+ )}
409
+ {backend === 'api' && (
410
+ <div className="flex flex-col gap-1">
411
+ <label className="text-[9px] text-gray-500 uppercase">API Profile</label>
412
+ <div className="flex gap-1 flex-wrap">
413
+ {availableAgents.filter(a => a.backendType === 'api').map(a => (
414
+ <button key={a.id} onClick={() => setAgentId(a.id)}
415
+ className={`text-[9px] px-2 py-1 rounded border ${agentId === a.id ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
416
+ {a.name}
417
+ </button>
418
+ ))}
419
+ {availableAgents.filter(a => a.backendType === 'api').length === 0 && (
420
+ <span className="text-[9px] text-gray-600">No API profiles configured. Add in Settings.</span>
421
+ )}
422
+ </div>
423
+ </div>
424
+ )}
425
+
426
+ {/* Role */}
427
+ <div className="flex flex-col gap-1">
428
+ <label className="text-[9px] text-gray-500 uppercase">Role / System Prompt</label>
429
+ <textarea value={role} onChange={e => setRole(e.target.value)} rows={2}
430
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] resize-none" />
431
+ </div>
432
+
433
+ {/* Depends On — checkbox list of existing agents */}
434
+ {otherAgents.length > 0 && (
435
+ <div className="flex flex-col gap-1">
436
+ <label className="text-[9px] text-gray-500 uppercase">Depends On (upstream agents)</label>
437
+ <div className="flex flex-wrap gap-1.5">
438
+ {otherAgents.map(a => (
439
+ <button key={a.id} onClick={() => toggleDep(a.id)}
440
+ className={`text-[9px] px-2 py-1 rounded border flex items-center gap-1 ${
441
+ selectedDeps.has(a.id)
442
+ ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10'
443
+ : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
444
+ <span>{selectedDeps.has(a.id) ? '☑' : '☐'}</span>
445
+ <span>{a.icon} {a.label}</span>
446
+ </button>
447
+ ))}
448
+ </div>
449
+ </div>
450
+ )}
451
+
452
+ {/* Work Dir + Outputs */}
453
+ <div className="flex gap-2">
454
+ <div className="flex flex-col gap-1 w-28">
455
+ <label className="text-[9px] text-gray-500 uppercase">Work Dir</label>
456
+ <input value={workDirVal} onChange={e => setWorkDirVal(e.target.value)} placeholder={label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : 'engineer/'}
457
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
458
+ <div className="text-[8px] text-gray-600 mt-0.5">
459
+ → {'{project}/'}{(workDirVal || (label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : '')).replace(/^\.?\//, '')}
460
+ </div>
461
+ </div>
462
+ <div className="flex flex-col gap-1 flex-1">
463
+ <label className="text-[9px] text-gray-500 uppercase">Outputs</label>
464
+ <input value={outputs} onChange={e => setOutputs(e.target.value)} placeholder="docs/prd.md, src/"
465
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
466
+ </div>
467
+ </div>
468
+
469
+ {/* Requires Approval */}
470
+ <div className="flex items-center gap-2">
471
+ <input type="checkbox" id="requiresApproval" checked={requiresApproval} onChange={e => setRequiresApproval(e.target.checked)}
472
+ className="accent-[#58a6ff]" />
473
+ <label htmlFor="requiresApproval" className="text-[9px] text-gray-400">Require approval before processing inbox messages</label>
474
+ </div>
475
+
476
+ {/* Steps */}
477
+ <div className="flex flex-col gap-1">
478
+ <label className="text-[9px] text-gray-500 uppercase">Steps (one per line — Label: Prompt)</label>
479
+ <textarea value={stepsText} onChange={e => setStepsText(e.target.value)} rows={4}
480
+ placeholder="Analyze: Read docs and identify requirements&#10;Write: Write PRD to docs/prd.md&#10;Review: Review and improve"
481
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] resize-none font-mono" />
482
+ </div>
483
+
484
+ {/* Watch */}
485
+ <div className="flex flex-col gap-1.5 border-t border-[#21262d] pt-2 mt-1">
486
+ <div className="flex items-center gap-2">
487
+ <label className="text-[9px] text-gray-500 uppercase">Watch</label>
488
+ <input type="checkbox" checked={watchEnabled} onChange={e => setWatchEnabled(e.target.checked)}
489
+ className="accent-[#58a6ff]" />
490
+ <span className="text-[8px] text-gray-600">Autonomous periodic monitoring</span>
491
+ </div>
492
+ {watchEnabled && (<>
493
+ <div className="flex gap-2">
494
+ <div className="flex flex-col gap-0.5 flex-1">
495
+ <label className="text-[8px] text-gray-600">Interval (seconds)</label>
496
+ <input value={watchInterval} onChange={e => setWatchInterval(e.target.value)} type="number" min="10"
497
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-20" />
498
+ </div>
499
+ <div className="flex flex-col gap-0.5 flex-1">
500
+ <label className="text-[8px] text-gray-600">On Change</label>
501
+ <select value={watchAction} onChange={e => setWatchAction(e.target.value as any)}
502
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]">
503
+ <option value="log">Log only</option>
504
+ <option value="analyze">Auto analyze</option>
505
+ <option value="approve">Require approval</option>
506
+ </select>
507
+ </div>
508
+ </div>
509
+ <div className="flex flex-col gap-1">
510
+ <label className="text-[8px] text-gray-600">Targets</label>
511
+ {watchTargets.map((t, i) => (
512
+ <div key={i} className="flex items-center gap-1">
513
+ <select value={t.type} onChange={e => {
514
+ const next = [...watchTargets];
515
+ next[i] = { type: e.target.value };
516
+ setWatchTargets(next);
517
+ }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24">
518
+ <option value="directory">Directory</option>
519
+ <option value="git">Git</option>
520
+ <option value="agent_output">Agent Output</option>
521
+ <option value="command">Command</option>
522
+ </select>
523
+ {t.type === 'directory' && (
524
+ <select value={t.path || ''} onChange={e => {
525
+ const next = [...watchTargets];
526
+ next[i] = { ...t, path: e.target.value };
527
+ setWatchTargets(next);
528
+ }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
529
+ <option value="">Project root</option>
530
+ {projectDirs.map(d => <option key={d} value={d + '/'}>{d}/</option>)}
531
+ </select>
532
+ )}
533
+ {t.type === 'agent_output' && (
534
+ <select value={t.path || ''} onChange={e => {
535
+ const next = [...watchTargets];
536
+ next[i] = { ...t, path: e.target.value };
537
+ setWatchTargets(next);
538
+ }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
539
+ <option value="">Select agent...</option>
540
+ {existingAgents.filter(a => a.id !== initial.id).map(a =>
541
+ <option key={a.id} value={a.id}>{a.icon} {a.label}</option>
542
+ )}
543
+ </select>
544
+ )}
545
+ {t.type === 'command' && (
546
+ <input value={t.cmd || ''} onChange={e => {
547
+ const next = [...watchTargets];
548
+ next[i] = { ...t, cmd: e.target.value };
549
+ setWatchTargets(next);
550
+ }} placeholder="npm test"
551
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1" />
552
+ )}
553
+ <button onClick={() => setWatchTargets(watchTargets.filter((_, j) => j !== i))}
554
+ className="text-[9px] text-gray-500 hover:text-red-400">✕</button>
555
+ </div>
556
+ ))}
557
+ <button onClick={() => setWatchTargets([...watchTargets, { type: 'directory' }])}
558
+ className="text-[8px] text-gray-500 hover:text-[#58a6ff] self-start">+ Add target</button>
559
+ </div>
560
+ {watchAction === 'analyze' && (
561
+ <div className="flex flex-col gap-0.5">
562
+ <label className="text-[8px] text-gray-600">Analysis prompt (optional)</label>
563
+ <input value={watchPrompt} onChange={e => setWatchPrompt(e.target.value)}
564
+ placeholder="Analyze these changes and check for issues..."
565
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
566
+ </div>
567
+ )}
568
+ </>)}
569
+ </div>
570
+ </div>
571
+
572
+ <div className="flex justify-end gap-2 mt-4">
573
+ <button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
574
+ <button disabled={!label.trim()} onClick={() => {
575
+ onConfirm({
576
+ label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
577
+ backend, agentId, dependsOn: Array.from(selectedDeps),
578
+ workDir: workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/',
579
+ outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
580
+ steps: parseSteps(),
581
+ requiresApproval: requiresApproval || undefined,
582
+ watch: watchEnabled && watchTargets.length > 0 ? {
583
+ enabled: true,
584
+ interval: Math.max(10, parseInt(watchInterval) || 60),
585
+ targets: watchTargets,
586
+ action: watchAction,
587
+ prompt: watchPrompt || undefined,
588
+ } : undefined,
589
+ } as any);
590
+ }} className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043] disabled:opacity-40">
591
+ {mode === 'add' ? 'Add' : 'Save'}
592
+ </button>
593
+ </div>
594
+ </div>
595
+ </div>
596
+ );
597
+ }
598
+
599
+ // ─── Message Dialog ──────────────────────────────────────
600
+
601
+ function MessageDialog({ agentLabel, onSend, onCancel }: {
602
+ agentLabel: string;
603
+ onSend: (msg: string) => void;
604
+ onCancel: () => void;
605
+ }) {
606
+ const [msg, setMsg] = useState('');
607
+ return (
608
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
609
+ onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
610
+ <div className="w-96 rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
611
+ <div className="text-sm font-bold text-white mb-2">Message to {agentLabel}</div>
612
+ <textarea value={msg} onChange={e => setMsg(e.target.value)} rows={3} autoFocus
613
+ placeholder="Type your message..."
614
+ className="w-full text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1.5 text-white focus:outline-none focus:border-[#58a6ff] resize-none" />
615
+ <div className="flex justify-end gap-2 mt-3">
616
+ <button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
617
+ <button onClick={() => { if (msg.trim()) onSend(msg.trim()); }}
618
+ className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043]">Send</button>
619
+ </div>
620
+ </div>
621
+ </div>
622
+ );
623
+ }
624
+
625
+ // ─── Run Prompt Dialog ───────────────────────────────────
626
+
627
+ function RunPromptDialog({ agentLabel, onRun, onCancel }: {
628
+ agentLabel: string;
629
+ onRun: (input: string) => void;
630
+ onCancel: () => void;
631
+ }) {
632
+ const [input, setInput] = useState('');
633
+ return (
634
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
635
+ onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
636
+ <div className="w-[460px] rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
637
+ <div className="text-sm font-bold text-white mb-1">Run {agentLabel}</div>
638
+ <div className="text-[9px] text-gray-500 mb-3">Describe the task or requirements. This will be the initial input for the agent.</div>
639
+ <textarea value={input} onChange={e => setInput(e.target.value)} rows={5} autoFocus
640
+ placeholder="e.g. Build a REST API for user management with login, registration, and profile endpoints. Use Express + TypeScript + PostgreSQL."
641
+ className="w-full text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1.5 text-white focus:outline-none focus:border-[#58a6ff] resize-none" />
642
+ <div className="flex items-center justify-between mt-3">
643
+ <span className="text-[8px] text-gray-600">Leave empty to run without specific input</span>
644
+ <div className="flex gap-2">
645
+ <button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
646
+ <button onClick={() => onRun(input.trim())}
647
+ className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043]">▶ Run</button>
648
+ </div>
649
+ </div>
650
+ </div>
651
+ </div>
652
+ );
653
+ }
654
+
655
+ // ─── Log Panel (overlay) ─────────────────────────────────
656
+
657
+ /** Format log content: handle \n, truncate long text, detect JSON */
658
+ function LogContent({ content }: { content: string }) {
659
+ if (!content) return null;
660
+ const MAX_LINES = 30;
661
+ const MAX_CHARS = 3000;
662
+
663
+ let text = content;
664
+
665
+ // Try to parse JSON and extract readable content
666
+ if (text.startsWith('{') || text.startsWith('[')) {
667
+ try {
668
+ const parsed = JSON.parse(text);
669
+ // Tool result with content field
670
+ if (parsed.content) text = String(parsed.content);
671
+ else text = JSON.stringify(parsed, null, 2);
672
+ } catch {
673
+ // Not valid JSON, keep as-is
674
+ }
675
+ }
676
+
677
+ // Truncate
678
+ const lines = text.split('\n');
679
+ const truncatedLines = lines.length > MAX_LINES;
680
+ const truncatedChars = text.length > MAX_CHARS;
681
+ if (truncatedLines) text = lines.slice(0, MAX_LINES).join('\n');
682
+ if (truncatedChars) text = text.slice(0, MAX_CHARS);
683
+ const truncated = truncatedLines || truncatedChars;
684
+
685
+ return (
686
+ <span className="break-all">
687
+ <pre className="whitespace-pre-wrap text-[10px] leading-relaxed inline">{text}</pre>
688
+ {truncated && <span className="text-gray-600 text-[9px]"> ...({lines.length} lines)</span>}
689
+ </span>
690
+ );
691
+ }
692
+
693
+ function LogPanel({ agentId, agentLabel, workspaceId, onClose }: {
694
+ agentId: string; agentLabel: string; workspaceId: string; onClose: () => void;
695
+ }) {
696
+ const [logs, setLogs] = useState<any[]>([]);
697
+ const [filter, setFilter] = useState<'all' | 'messages' | 'summaries'>('all');
698
+ const scrollRef = useRef<HTMLDivElement>(null);
699
+
700
+ useEffect(() => {
701
+ // Read persistent logs from logs.jsonl (not in-memory state history)
702
+ fetch(`/api/workspace/${workspaceId}/smith`, {
703
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
704
+ body: JSON.stringify({ action: 'logs', agentId }),
705
+ }).then(r => r.json()).then(data => {
706
+ if (data.logs?.length) setLogs(data.logs);
707
+ }).catch(() => {});
708
+ }, [workspaceId, agentId]);
709
+
710
+ useEffect(() => {
711
+ scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
712
+ }, [logs, filter]);
713
+
714
+ const filteredLogs = filter === 'all' ? logs :
715
+ filter === 'messages' ? logs.filter((e: any) => e.subtype === 'bus_message' || e.subtype === 'revalidation_request' || e.subtype === 'user_message') :
716
+ logs.filter((e: any) => e.subtype === 'step_summary' || e.subtype === 'final_summary');
717
+
718
+ const msgCount = logs.filter((e: any) => e.subtype === 'bus_message' || e.subtype === 'revalidation_request' || e.subtype === 'user_message').length;
719
+
720
+ return (
721
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
722
+ onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
723
+ <div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '75vw', height: '65vh', border: '1px solid #30363d', background: '#0d1117' }}>
724
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
725
+ <span className="text-sm font-bold text-white">Logs: {agentLabel}</span>
726
+ <span className="text-[9px] text-gray-500">{filteredLogs.length}/{logs.length}</span>
727
+ {/* Filter tabs */}
728
+ <div className="flex gap-1 ml-3">
729
+ {([['all', 'All'], ['messages', `📨 Messages${msgCount > 0 ? ` (${msgCount})` : ''}`], ['summaries', '📊 Summaries']] as const).map(([key, label]) => (
730
+ <button key={key} onClick={() => setFilter(key as any)}
731
+ className={`text-[8px] px-2 py-0.5 rounded ${filter === key ? 'bg-[#21262d] text-white' : 'text-gray-500 hover:text-gray-300'}`}>
732
+ {label}
733
+ </button>
734
+ ))}
735
+ </div>
736
+ <button onClick={async () => {
737
+ await fetch(`/api/workspace/${workspaceId}/smith`, {
738
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
739
+ body: JSON.stringify({ action: 'clear_logs', agentId }),
740
+ });
741
+ setLogs([]);
742
+ }} className="text-[8px] text-gray-500 hover:text-red-400 ml-auto mr-2">Clear</button>
743
+ <button onClick={onClose} className="text-gray-500 hover:text-white text-sm">✕</button>
744
+ </div>
745
+ <div ref={scrollRef} className="flex-1 overflow-auto p-3 font-mono text-[11px] space-y-0.5">
746
+ {filteredLogs.length === 0 && <div className="text-gray-600 text-center mt-8">{filter === 'all' ? 'No logs yet' : 'No matching entries'}</div>}
747
+ {filteredLogs.map((entry, i) => {
748
+ const isSummary = entry.subtype === 'step_summary' || entry.subtype === 'final_summary';
749
+ const isBusMsg = entry.subtype === 'bus_message' || entry.subtype === 'revalidation_request' || entry.subtype === 'user_message';
750
+ return (
751
+ <div key={i} className={`${
752
+ isSummary ? 'my-1 px-2 py-1.5 rounded border border-[#21262d] text-[#58a6ff] bg-[#161b22]' :
753
+ isBusMsg ? 'my-0.5 px-2 py-1 rounded border border-[#f0883e30] text-[#f0883e] bg-[#f0883e08]' :
754
+ 'flex gap-2 ' + (
755
+ entry.type === 'system' ? 'text-gray-600' :
756
+ entry.type === 'result' ? 'text-green-400' : 'text-gray-300'
757
+ )
758
+ }`}>
759
+ {isSummary ? (
760
+ <pre className="whitespace-pre-wrap text-[10px] leading-relaxed">{entry.content}</pre>
761
+ ) : isBusMsg ? (
762
+ <div className="text-[10px] flex items-center gap-2">
763
+ <span>📨</span>
764
+ <span className="text-[8px] text-gray-500">{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
765
+ <span>{entry.content}</span>
766
+ </div>
767
+ ) : (
768
+ <>
769
+ <span className="text-[8px] text-gray-600 shrink-0 w-16">{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
770
+ {entry.tool && <span className="text-yellow-500 shrink-0">[{entry.tool}]</span>}
771
+ <LogContent content={entry.content} />
772
+ </>
773
+ )}
774
+ </div>
775
+ );
776
+ })}
777
+ </div>
778
+ </div>
779
+ </div>
780
+ );
781
+ }
782
+
783
+ // ─── Memory Panel ────────────────────────────────────────
784
+
785
+ const TYPE_COLORS: Record<string, string> = {
786
+ decision: 'text-yellow-400', bugfix: 'text-red-400', feature: 'text-green-400',
787
+ refactor: 'text-cyan-400', discovery: 'text-purple-400', change: 'text-gray-400', session: 'text-blue-400',
788
+ };
789
+
790
+ function MemoryPanel({ agentId, agentLabel, workspaceId, onClose }: {
791
+ agentId: string; agentLabel: string; workspaceId: string; onClose: () => void;
792
+ }) {
793
+ const [data, setData] = useState<any>(null);
794
+
795
+ useEffect(() => {
796
+ fetch(`/api/workspace/${workspaceId}/memory?agentId=${encodeURIComponent(agentId)}`)
797
+ .then(r => r.json()).then(setData).catch(() => {});
798
+ }, [workspaceId, agentId]);
799
+
800
+ const stats = data?.stats;
801
+ const display: any[] = data?.display || [];
802
+
803
+ return (
804
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
805
+ onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
806
+ <div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '70vw', height: '65vh', border: '1px solid #30363d', background: '#0d1117' }}>
807
+ {/* Header */}
808
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
809
+ <span className="text-sm">🧠</span>
810
+ <span className="text-sm font-bold text-white">Memory: {agentLabel}</span>
811
+ {stats && (
812
+ <span className="text-[9px] text-gray-500">
813
+ {stats.totalObservations} observations, {stats.totalSessions} sessions
814
+ {stats.lastUpdated && ` · last updated ${new Date(stats.lastUpdated).toLocaleString()}`}
815
+ </span>
816
+ )}
817
+ <button onClick={onClose} className="text-gray-500 hover:text-white text-sm ml-auto">✕</button>
818
+ </div>
819
+
820
+ {/* Stats bar */}
821
+ {stats?.typeBreakdown && Object.keys(stats.typeBreakdown).length > 0 && (
822
+ <div className="flex items-center gap-3 px-4 py-1.5 border-b border-[#21262d] text-[9px]">
823
+ {Object.entries(stats.typeBreakdown).map(([type, count]) => (
824
+ <span key={type} className={TYPE_COLORS[type] || 'text-gray-400'}>
825
+ {type}: {count as number}
826
+ </span>
827
+ ))}
828
+ </div>
829
+ )}
830
+
831
+ {/* Entries */}
832
+ <div className="flex-1 overflow-auto p-3 space-y-1.5">
833
+ {display.length === 0 && (
834
+ <div className="text-gray-600 text-center mt-8">No memory yet. Run this agent to build memory.</div>
835
+ )}
836
+ {display.map((entry: any) => (
837
+ <div key={entry.id} className={`rounded px-3 py-2 ${entry.isCompact ? 'opacity-60' : ''}`}
838
+ style={{ background: '#161b22', border: '1px solid #21262d' }}>
839
+ <div className="flex items-center gap-2">
840
+ <span className="text-[10px]">{entry.icon}</span>
841
+ <span className={`text-[9px] font-medium ${TYPE_COLORS[entry.type] || 'text-gray-400'}`}>{entry.type}</span>
842
+ <span className="text-[10px] text-white flex-1 truncate">{entry.title}</span>
843
+ <span className="text-[8px] text-gray-600 shrink-0">
844
+ {new Date(entry.timestamp).toLocaleString()}
845
+ </span>
846
+ </div>
847
+ {!entry.isCompact && entry.subtitle && (
848
+ <div className="text-[9px] text-gray-500 mt-1">{entry.subtitle}</div>
849
+ )}
850
+ {!entry.isCompact && entry.facts && entry.facts.length > 0 && (
851
+ <div className="mt-1 space-y-0.5">
852
+ {entry.facts.map((f: string, i: number) => (
853
+ <div key={i} className="text-[8px] text-gray-500">• {f}</div>
854
+ ))}
855
+ </div>
856
+ )}
857
+ {entry.files && entry.files.length > 0 && (
858
+ <div className="text-[8px] text-gray-600 mt-1">
859
+ Files: {entry.files.join(', ')}
860
+ </div>
861
+ )}
862
+ </div>
863
+ ))}
864
+ </div>
865
+ </div>
866
+ </div>
867
+ );
868
+ }
869
+
870
+ // ─── Bus Message Panel ───────────────────────────────────
871
+
872
+ // ─── Agent Inbox/Outbox Panel ────────────────────────────
873
+
874
+ function InboxPanel({ agentId, agentLabel, busLog, agents, workspaceId, onClose }: {
875
+ agentId: string; agentLabel: string; busLog: any[]; agents: AgentConfig[]; workspaceId: string; onClose: () => void;
876
+ }) {
877
+ const labelMap = new Map(agents.map(a => [a.id, `${a.icon} ${a.label}`]));
878
+ const getLabel = (id: string) => labelMap.get(id) || id;
879
+ const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
880
+ const [selected, setSelected] = useState<Set<string>>(new Set());
881
+
882
+ // Filter messages related to this agent, exclude locally deleted
883
+ const inbox = busLog.filter(m => m.to === agentId && m.type !== 'ack' && !deletedIds.has(m.id));
884
+ const outbox = busLog.filter(m => m.from === agentId && m.to !== '_system' && m.type !== 'ack' && !deletedIds.has(m.id));
885
+ const [tab, setTab] = useState<'inbox' | 'outbox'>('inbox');
886
+ const messages = tab === 'inbox' ? inbox : outbox;
887
+
888
+ const handleDelete = async (msgId: string) => {
889
+ await wsApi(workspaceId, 'delete_message', { messageId: msgId });
890
+ setDeletedIds(prev => new Set(prev).add(msgId));
891
+ };
892
+
893
+ const toggleSelect = (msgId: string) => {
894
+ setSelected(prev => { const s = new Set(prev); s.has(msgId) ? s.delete(msgId) : s.add(msgId); return s; });
895
+ };
896
+
897
+ const selectAll = () => {
898
+ const deletable = messages.filter(m => m.status === 'done' || m.status === 'failed');
899
+ setSelected(new Set(deletable.map(m => m.id)));
900
+ };
901
+
902
+ const handleBatchDelete = async () => {
903
+ for (const id of selected) {
904
+ await wsApi(workspaceId, 'delete_message', { messageId: id });
905
+ setDeletedIds(prev => new Set(prev).add(id));
906
+ }
907
+ setSelected(new Set());
908
+ };
909
+
910
+ const handleAbortAllPending = async () => {
911
+ const pendingMsgs = messages.filter(m => m.status === 'pending');
912
+ await Promise.all(pendingMsgs.map(m =>
913
+ wsApi(workspaceId, 'abort_message', { messageId: m.id }).catch(() => {})
914
+ ));
915
+ };
916
+
917
+ return (
918
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
919
+ onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
920
+ <div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '60vw', height: '50vh', border: '1px solid #30363d', background: '#0d1117' }}>
921
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
922
+ <span className="text-sm">📨</span>
923
+ <span className="text-sm font-bold text-white">{agentLabel}</span>
924
+ <div className="flex gap-1 ml-3">
925
+ <button onClick={() => setTab('inbox')}
926
+ className={`text-[9px] px-2 py-0.5 rounded ${tab === 'inbox' ? 'bg-[#21262d] text-white' : 'text-gray-500 hover:text-gray-300'}`}>
927
+ Inbox ({inbox.length})
928
+ </button>
929
+ <button onClick={() => setTab('outbox')}
930
+ className={`text-[9px] px-2 py-0.5 rounded ${tab === 'outbox' ? 'bg-[#21262d] text-white' : 'text-gray-500 hover:text-gray-300'}`}>
931
+ Outbox ({outbox.length})
932
+ </button>
933
+ </div>
934
+ {selected.size > 0 && (
935
+ <div className="flex items-center gap-2 ml-3">
936
+ <span className="text-[9px] text-gray-400">{selected.size} selected</span>
937
+ <button onClick={handleBatchDelete}
938
+ className="text-[8px] px-2 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
939
+ Delete selected
940
+ </button>
941
+ <button onClick={() => setSelected(new Set())}
942
+ className="text-[8px] px-2 py-0.5 rounded bg-gray-600/20 text-gray-400 hover:bg-gray-600/30">
943
+ Clear
944
+ </button>
945
+ </div>
946
+ )}
947
+ {selected.size === 0 && (
948
+ <div className="flex items-center gap-2 ml-3">
949
+ {messages.some(m => m.status === 'done' || m.status === 'failed') && (
950
+ <button onClick={selectAll}
951
+ className="text-[8px] px-2 py-0.5 rounded text-gray-500 hover:text-gray-300">
952
+ Select all completed
953
+ </button>
954
+ )}
955
+ {messages.some(m => m.status === 'pending') && (
956
+ <button onClick={handleAbortAllPending}
957
+ className="text-[8px] px-2 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
958
+ Abort all pending ({messages.filter(m => m.status === 'pending').length})
959
+ </button>
960
+ )}
961
+ </div>
962
+ )}
963
+ <button onClick={onClose} className="text-gray-500 hover:text-white text-sm ml-auto">✕</button>
964
+ </div>
965
+ <div className="flex-1 overflow-auto p-3 space-y-1.5">
966
+ {messages.length === 0 && (
967
+ <div className="text-gray-600 text-center mt-8">No {tab} messages</div>
968
+ )}
969
+ {[...messages].reverse().map((msg, i) => {
970
+ const isTicket = msg.category === 'ticket';
971
+ const canSelect = msg.status === 'done' || msg.status === 'failed';
972
+ return (
973
+ <div key={i} className="flex items-start gap-2 px-3 py-2 rounded text-[10px]" style={{
974
+ background: '#161b22',
975
+ border: `1px solid ${isTicket ? '#6e40c9' : '#21262d'}`,
976
+ borderLeft: isTicket ? '3px solid #a371f7' : undefined,
977
+ }}>
978
+ {canSelect && (
979
+ <input type="checkbox" checked={selected.has(msg.id)} onChange={() => toggleSelect(msg.id)}
980
+ className="mt-1 shrink-0 accent-[#58a6ff]" />
981
+ )}
982
+ <div className="flex-1 min-w-0">
983
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
984
+ <span className="text-[8px] text-gray-600">{new Date(msg.timestamp).toLocaleString()}</span>
985
+ {tab === 'inbox' ? (
986
+ <span className="text-blue-400">← {getLabel(msg.from)}</span>
987
+ ) : (
988
+ <span className="text-green-400">→ {getLabel(msg.to)}</span>
989
+ )}
990
+ {/* Category badge */}
991
+ {isTicket && (
992
+ <span className="px-1 py-0.5 rounded text-[7px] bg-purple-500/20 text-purple-400">TICKET</span>
993
+ )}
994
+ {/* Action badge */}
995
+ <span className={`px-1.5 py-0.5 rounded text-[8px] ${
996
+ msg.payload?.action === 'fix_request' || msg.payload?.action === 'bug_report' ? 'bg-red-500/20 text-red-400' :
997
+ msg.payload?.action === 'update_notify' || msg.payload?.action === 'request_complete' ? 'bg-blue-500/20 text-blue-400' :
998
+ msg.payload?.action === 'question' ? 'bg-yellow-500/20 text-yellow-400' :
999
+ 'bg-gray-500/20 text-gray-400'
1000
+ }`}>{msg.payload?.action}</span>
1001
+ {/* Ticket status */}
1002
+ {isTicket && msg.ticketStatus && (
1003
+ <span className={`text-[7px] px-1 rounded ${
1004
+ msg.ticketStatus === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
1005
+ msg.ticketStatus === 'in_progress' ? 'bg-blue-500/20 text-blue-400' :
1006
+ msg.ticketStatus === 'fixed' ? 'bg-green-500/20 text-green-400' :
1007
+ msg.ticketStatus === 'verified' ? 'bg-green-600/20 text-green-300' :
1008
+ msg.ticketStatus === 'closed' ? 'bg-gray-500/20 text-gray-400' :
1009
+ 'bg-gray-500/20 text-gray-400'
1010
+ }`}>{msg.ticketStatus}</span>
1011
+ )}
1012
+ {/* Message delivery status */}
1013
+ <span className={`text-[7px] ${msg.status === 'done' ? 'text-green-500' : msg.status === 'running' ? 'text-blue-400' : msg.status === 'failed' ? 'text-red-500' : msg.status === 'pending_approval' ? 'text-orange-400' : 'text-yellow-500'}`}>
1014
+ {msg.status || 'pending'}
1015
+ </span>
1016
+ {/* Retry count for tickets */}
1017
+ {isTicket && (msg.ticketRetries || 0) > 0 && (
1018
+ <span className="text-[7px] text-orange-400">retry {msg.ticketRetries}/{msg.maxRetries || 3}</span>
1019
+ )}
1020
+ {/* CausedBy trace */}
1021
+ {msg.causedBy && (
1022
+ <span className="text-[7px] text-gray-600" title={`Triggered by message from ${getLabel(msg.causedBy.from)}`}>
1023
+ ← {getLabel(msg.causedBy.from)}
1024
+ </span>
1025
+ )}
1026
+ {/* Actions */}
1027
+ {msg.status === 'pending_approval' && (
1028
+ <div className="flex gap-1 ml-auto">
1029
+ <button onClick={() => wsApi(workspaceId, 'approve_message', { messageId: msg.id })}
1030
+ className="text-[7px] px-1.5 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30">
1031
+ ✓ Approve
1032
+ </button>
1033
+ <button onClick={() => wsApi(workspaceId, 'reject_message', { messageId: msg.id })}
1034
+ className="text-[7px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
1035
+ ✕ Reject
1036
+ </button>
1037
+ </div>
1038
+ )}
1039
+ {msg.status === 'pending' && msg.type !== 'ack' && (
1040
+ <button onClick={() => wsApi(workspaceId, 'abort_message', { messageId: msg.id })}
1041
+ className="text-[7px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30 ml-auto">
1042
+ ✕ Abort
1043
+ </button>
1044
+ )}
1045
+ {(msg.status === 'done' || msg.status === 'failed') && msg.type !== 'ack' && (
1046
+ <div className="flex gap-1 ml-auto">
1047
+ <button onClick={() => wsApi(workspaceId, 'retry_message', { messageId: msg.id })}
1048
+ className="text-[7px] px-1.5 py-0.5 rounded bg-orange-600/20 text-orange-400 hover:bg-orange-600/30">
1049
+ {msg.status === 'done' ? '↻ Re-run' : '↻ Retry'}
1050
+ </button>
1051
+ <button onClick={() => handleDelete(msg.id)}
1052
+ className="text-[7px] px-1.5 py-0.5 rounded bg-gray-600/20 text-gray-400 hover:bg-red-600/20 hover:text-red-400">
1053
+ 🗑
1054
+ </button>
1055
+ </div>
1056
+ )}
1057
+ </div>
1058
+ <div className="text-gray-300">{msg.payload?.content || ''}</div>
1059
+ {msg.payload?.files?.length > 0 && (
1060
+ <div className="text-[8px] text-gray-600 mt-1">Files: {msg.payload.files.join(', ')}</div>
1061
+ )}
1062
+ </div>
1063
+ </div>
1064
+ );
1065
+ })}
1066
+ </div>
1067
+ </div>
1068
+ </div>
1069
+ );
1070
+ }
1071
+
1072
+ function BusPanel({ busLog, agents, onClose }: {
1073
+ busLog: any[]; agents: AgentConfig[]; onClose: () => void;
1074
+ }) {
1075
+ const labelMap = new Map(agents.map(a => [a.id, `${a.icon} ${a.label}`]));
1076
+ const getLabel = (id: string) => labelMap.get(id) || id;
1077
+
1078
+ return (
1079
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
1080
+ onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
1081
+ <div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '65vw', height: '55vh', border: '1px solid #30363d', background: '#0d1117' }}>
1082
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
1083
+ <span className="text-sm">📡</span>
1084
+ <span className="text-sm font-bold text-white">Agent Communication Logs</span>
1085
+ <span className="text-[9px] text-gray-500">{busLog.length} messages</span>
1086
+ <button onClick={onClose} className="text-gray-500 hover:text-white text-sm ml-auto">✕</button>
1087
+ </div>
1088
+ <div className="flex-1 overflow-auto p-3 space-y-1">
1089
+ {busLog.length === 0 && <div className="text-gray-600 text-center mt-8">No messages yet</div>}
1090
+ {[...busLog].reverse().map((msg, i) => (
1091
+ <div key={i} className="flex items-start gap-2 text-[10px] px-3 py-1.5 rounded"
1092
+ style={{ background: '#161b22', border: '1px solid #21262d' }}>
1093
+ <span className="text-gray-600 shrink-0 w-14">{new Date(msg.timestamp).toLocaleTimeString()}</span>
1094
+ <span className="text-blue-400 shrink-0">{getLabel(msg.from)}</span>
1095
+ <span className="text-gray-600">→</span>
1096
+ <span className="text-green-400 shrink-0">{msg.to === '_system' ? '📡 system' : getLabel(msg.to)}</span>
1097
+ <span className={`px-1 rounded text-[8px] ${
1098
+ msg.payload?.action === 'fix_request' ? 'bg-red-500/20 text-red-400' :
1099
+ msg.payload?.action === 'task_complete' ? 'bg-green-500/20 text-green-400' :
1100
+ msg.payload?.action === 'ack' ? 'bg-gray-500/20 text-gray-500' :
1101
+ 'bg-blue-500/20 text-blue-400'
1102
+ }`}>{msg.payload?.action}</span>
1103
+ <span className="text-gray-400 truncate flex-1">{msg.payload?.content || ''}</span>
1104
+ {msg.status && msg.status !== 'done' && (
1105
+ <span className={`text-[7px] px-1 rounded ${
1106
+ msg.status === 'done' ? 'text-green-500' : msg.status === 'failed' ? 'text-red-500' : 'text-yellow-500'
1107
+ }`}>{msg.status}</span>
1108
+ )}
1109
+ </div>
1110
+ ))}
1111
+ </div>
1112
+ </div>
1113
+ </div>
1114
+ );
1115
+ }
1116
+
1117
+ // ─── Terminal Launch Dialog ───────────────────────────────
1118
+
1119
+ function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspaceId, supportsSession, onLaunch, onCancel }: {
1120
+ agent: AgentConfig; workDir?: string; sessName: string; projectPath: string; workspaceId: string;
1121
+ supportsSession?: boolean;
1122
+ onLaunch: (resumeMode: boolean, sessionId?: string) => void; onCancel: () => void;
1123
+ }) {
1124
+ const [sessions, setSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
1125
+ const [showSessions, setShowSessions] = useState(false);
1126
+ // Use resolved supportsSession from API (defaults to true for backwards compat)
1127
+ const isClaude = supportsSession !== false;
1128
+
1129
+ // Fetch recent sessions (only for claude-based agents)
1130
+ useEffect(() => {
1131
+ if (!isClaude) return;
1132
+ fetch(`/api/workspace/${workspaceId}/smith`, {
1133
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1134
+ body: JSON.stringify({ action: 'sessions' }),
1135
+ }).then(r => r.json()).then(d => {
1136
+ if (d.sessions?.length) setSessions(d.sessions);
1137
+ }).catch(() => {});
1138
+ }, [workspaceId, isClaude]);
1139
+
1140
+ const formatTime = (iso: string) => {
1141
+ const d = new Date(iso);
1142
+ const now = new Date();
1143
+ const diff = now.getTime() - d.getTime();
1144
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
1145
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
1146
+ return d.toLocaleDateString();
1147
+ };
1148
+
1149
+ const formatSize = (bytes: number) => {
1150
+ if (bytes < 1024) return `${bytes}B`;
1151
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
1152
+ return `${(bytes / 1048576).toFixed(1)}MB`;
1153
+ };
1154
+
1155
+ return (
1156
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
1157
+ onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
1158
+ <div className="w-80 rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
1159
+ <div className="text-sm font-bold text-white mb-3">⌨️ {agent.label}</div>
1160
+
1161
+ <div className="space-y-2">
1162
+ <button onClick={() => onLaunch(false)}
1163
+ className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors">
1164
+ <div className="text-xs text-white font-semibold">{isClaude ? 'New Session' : 'Open Terminal'}</div>
1165
+ <div className="text-[9px] text-gray-500">{isClaude ? 'Start fresh claude session' : `Launch ${agent.agentId || 'agent'}`}</div>
1166
+ </button>
1167
+
1168
+ {isClaude && sessions.length > 0 && (
1169
+ <button onClick={() => onLaunch(true)}
1170
+ className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#3fb950] hover:bg-[#161b22] transition-colors">
1171
+ <div className="text-xs text-white font-semibold">Resume Latest</div>
1172
+ <div className="text-[9px] text-gray-500">
1173
+ {sessions[0].id.slice(0, 8)} · {formatTime(sessions[0].modified)} · {formatSize(sessions[0].size)}
1174
+ </div>
1175
+ </button>
1176
+ )}
1177
+
1178
+ {isClaude && sessions.length > 1 && (
1179
+ <button onClick={() => setShowSessions(!showSessions)}
1180
+ className="w-full text-[9px] text-gray-500 hover:text-white py-1">
1181
+ {showSessions ? '▼' : '▶'} More sessions ({sessions.length - 1})
1182
+ </button>
1183
+ )}
1184
+
1185
+ {showSessions && sessions.slice(1).map(s => (
1186
+ <button key={s.id} onClick={() => onLaunch(true, s.id)}
1187
+ className="w-full text-left px-3 py-1.5 rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
1188
+ <div className="flex items-center gap-2">
1189
+ <span className="text-[9px] text-gray-400 font-mono">{s.id.slice(0, 8)}</span>
1190
+ <span className="text-[8px] text-gray-600">{formatTime(s.modified)}</span>
1191
+ <span className="text-[8px] text-gray-600">{formatSize(s.size)}</span>
1192
+ </div>
1193
+ </button>
1194
+ ))}
1195
+ </div>
1196
+
1197
+ <button onClick={onCancel}
1198
+ className="w-full mt-3 text-[9px] text-gray-500 hover:text-white">Cancel</button>
1199
+ </div>
1200
+ </div>
1201
+ );
1202
+ }
1203
+
1204
+ // ─── Floating Terminal ────────────────────────────────────
1205
+
1206
+ function getWsUrl() {
1207
+ if (typeof window === 'undefined') return 'ws://localhost:8404';
1208
+ const p = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1209
+ const h = window.location.hostname;
1210
+ if (h !== 'localhost' && h !== '127.0.0.1') return `${p}//${window.location.host}/terminal-ws`;
1211
+ const port = parseInt(window.location.port) || 8403;
1212
+ return `${p}//${h}:${port + 1}`;
1213
+ }
1214
+
1215
+ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, onSessionReady, onClose }: {
1216
+ agentLabel: string;
1217
+ agentIcon: string;
1218
+ projectPath: string;
1219
+ agentCliId: string;
1220
+ cliCmd?: string; // resolved CLI binary (claude/codex/aider)
1221
+ cliType?: string; // claude-code/codex/aider/generic
1222
+ workDir?: string;
1223
+ preferredSessionName?: string;
1224
+ existingSession?: string;
1225
+ resumeMode?: boolean;
1226
+ resumeSessionId?: string;
1227
+ profileEnv?: Record<string, string>;
1228
+ onSessionReady?: (name: string) => void;
1229
+ onClose: (killSession: boolean) => void;
1230
+ }) {
1231
+ const containerRef = useRef<HTMLDivElement>(null);
1232
+ const wsRef = useRef<WebSocket | null>(null);
1233
+ const sessionNameRef = useRef('');
1234
+ const [pos, setPos] = useState({ x: 80, y: 60 });
1235
+ const [size, setSize] = useState({ w: 750, h: 450 });
1236
+ const [showCloseDialog, setShowCloseDialog] = useState(false);
1237
+ const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
1238
+ const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
1239
+
1240
+ useEffect(() => {
1241
+ const el = containerRef.current;
1242
+ if (!el) return;
1243
+ let disposed = false;
1244
+
1245
+ // Dynamic import xterm to avoid SSR issues
1246
+ Promise.all([
1247
+ import('@xterm/xterm'),
1248
+ import('@xterm/addon-fit'),
1249
+ ]).then(([{ Terminal }, { FitAddon }]) => {
1250
+ if (disposed) return;
1251
+
1252
+ const term = new Terminal({
1253
+ cursorBlink: true, fontSize: 13,
1254
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
1255
+ scrollback: 5000,
1256
+ theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
1257
+ });
1258
+ const fitAddon = new FitAddon();
1259
+ term.loadAddon(fitAddon);
1260
+ term.open(el);
1261
+ setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
1262
+
1263
+ const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
1264
+ ro.observe(el);
1265
+
1266
+ // Connect WebSocket — attach to existing or create new
1267
+ const ws = new WebSocket(getWsUrl());
1268
+ wsRef.current = ws;
1269
+ ws.onopen = () => {
1270
+ if (existingSession) {
1271
+ ws.send(JSON.stringify({ type: 'attach', sessionName: existingSession, cols: term.cols, rows: term.rows }));
1272
+ } else {
1273
+ // Use fixed session name so it survives refresh/suspend
1274
+ ws.send(JSON.stringify({ type: 'create', sessionName: preferredSessionName, cols: term.cols, rows: term.rows }));
1275
+ }
1276
+ };
1277
+
1278
+ ws.onerror = () => {
1279
+ if (!disposed) term.write('\r\n\x1b[91m[Connection error]\x1b[0m\r\n');
1280
+ };
1281
+ ws.onclose = () => {
1282
+ if (!disposed) term.write('\r\n\x1b[90m[Disconnected]\x1b[0m\r\n');
1283
+ };
1284
+
1285
+ let launched = false;
1286
+ ws.onmessage = (event) => {
1287
+ if (disposed) return;
1288
+ try {
1289
+ const msg = JSON.parse(event.data);
1290
+ if (msg.type === 'output') { try { term.write(msg.data); } catch {} }
1291
+ else if (msg.type === 'error') {
1292
+ // Session no longer exists — fall back to creating a new one
1293
+ if (msg.message?.includes('no longer exists') || msg.message?.includes('not found')) {
1294
+ term.write(`\r\n\x1b[93m[Session lost — creating new one]\x1b[0m\r\n`);
1295
+ ws.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows }));
1296
+ // Clear existing session so next connected triggers CLI launch
1297
+ (existingSession as any) = undefined;
1298
+ } else {
1299
+ term.write(`\r\n\x1b[91m[${msg.message || 'error'}]\x1b[0m\r\n`);
1300
+ }
1301
+ }
1302
+ else if (msg.type === 'connected') {
1303
+ if (msg.sessionName) {
1304
+ sessionNameRef.current = msg.sessionName;
1305
+ // Save session name (on create or if session changed after fallback)
1306
+ onSessionReady?.(msg.sessionName);
1307
+ }
1308
+ if (launched) return;
1309
+ launched = true;
1310
+ if (existingSession) {
1311
+ // Force terminal redraw for attached session
1312
+ setTimeout(() => {
1313
+ if (!disposed && ws.readyState === WebSocket.OPEN) {
1314
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols - 1, rows: term.rows }));
1315
+ setTimeout(() => {
1316
+ if (!disposed && ws.readyState === WebSocket.OPEN)
1317
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
1318
+ }, 50);
1319
+ }
1320
+ }, 200);
1321
+ return;
1322
+ }
1323
+ const targetDir = workDir ? `${projectPath}/${workDir}` : projectPath;
1324
+ const cli = cliCmdProp || 'claude';
1325
+
1326
+ const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
1327
+ const isClaude = (cliType || 'claude-code') === 'claude-code';
1328
+ const resumeFlag = isClaude
1329
+ ? (resumeSessionId ? ` --resume ${resumeSessionId}` : resumeMode ? ' -c' : '')
1330
+ : '';
1331
+ const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
1332
+ // Remove CLAUDE_MODEL from env exports (passed via --model flag instead)
1333
+ const envWithoutModel = profileEnv ? Object.fromEntries(
1334
+ Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1335
+ ) : {};
1336
+ const envExportsClean = Object.keys(envWithoutModel).length > 0
1337
+ ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1338
+ : '';
1339
+ const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}\n`;
1340
+ setTimeout(() => {
1341
+ if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1342
+ }, 300);
1343
+ }
1344
+ } catch {}
1345
+ };
1346
+
1347
+ term.onData(data => {
1348
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
1349
+ });
1350
+ term.onResize(({ cols, rows }) => {
1351
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
1352
+ });
1353
+
1354
+ return () => {
1355
+ disposed = true;
1356
+ ro.disconnect();
1357
+ ws.close();
1358
+ term.dispose();
1359
+ };
1360
+ });
1361
+
1362
+ return () => { disposed = true; };
1363
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
1364
+
1365
+ return (
1366
+ <div
1367
+ className="fixed z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-2xl flex flex-col overflow-hidden"
1368
+ style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
1369
+ >
1370
+ {/* Draggable header */}
1371
+ <div
1372
+ className="flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d] cursor-move shrink-0 select-none"
1373
+ onMouseDown={(e) => {
1374
+ e.preventDefault();
1375
+ dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
1376
+ const onMove = (ev: MouseEvent) => {
1377
+ if (!dragRef.current) return;
1378
+ setPos({ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY) });
1379
+ };
1380
+ const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
1381
+ window.addEventListener('mousemove', onMove);
1382
+ window.addEventListener('mouseup', onUp);
1383
+ }}
1384
+ >
1385
+ <span className="text-sm">{agentIcon}</span>
1386
+ <span className="text-[11px] font-semibold text-white">{agentLabel}</span>
1387
+ <span className="text-[8px] text-gray-500">⌨️ manual terminal</span>
1388
+ <button onClick={() => setShowCloseDialog(true)} className="ml-auto text-gray-500 hover:text-white text-sm">✕</button>
1389
+ </div>
1390
+
1391
+ {/* Terminal */}
1392
+ <div ref={containerRef} className="flex-1 min-h-0" style={{ background: '#0d1117' }} />
1393
+
1394
+ {/* Resize handle */}
1395
+ <div
1396
+ className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
1397
+ onMouseDown={(e) => {
1398
+ e.preventDefault();
1399
+ e.stopPropagation();
1400
+ resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
1401
+ const onMove = (ev: MouseEvent) => {
1402
+ if (!resizeRef.current) return;
1403
+ setSize({ w: Math.max(400, resizeRef.current.origW + ev.clientX - resizeRef.current.startX), h: Math.max(250, resizeRef.current.origH + ev.clientY - resizeRef.current.startY) });
1404
+ };
1405
+ const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
1406
+ window.addEventListener('mousemove', onMove);
1407
+ window.addEventListener('mouseup', onUp);
1408
+ }}
1409
+ >
1410
+ <svg viewBox="0 0 16 16" className="w-3 h-3 absolute bottom-0.5 right-0.5 text-gray-600">
1411
+ <path d="M14 14L8 14L14 8Z" fill="currentColor" />
1412
+ </svg>
1413
+ </div>
1414
+
1415
+ {/* Close confirmation dialog */}
1416
+ {showCloseDialog && (
1417
+ <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50" onClick={() => setShowCloseDialog(false)}>
1418
+ <div className="bg-[#161b22] border border-[#30363d] rounded-lg p-4 shadow-xl max-w-sm" onClick={e => e.stopPropagation()}>
1419
+ <h3 className="text-sm font-semibold text-white mb-2">Close Terminal — {agentLabel}</h3>
1420
+ <p className="text-xs text-gray-400 mb-3">
1421
+ This agent has an active terminal session.
1422
+ </p>
1423
+ <div className="flex gap-2">
1424
+ <button onClick={() => { setShowCloseDialog(false); onClose(false); }}
1425
+ className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white">
1426
+ Suspend
1427
+ <span className="block text-[9px] text-gray-500 mt-0.5">Session keeps running</span>
1428
+ </button>
1429
+ <button onClick={() => {
1430
+ setShowCloseDialog(false);
1431
+ if (wsRef.current?.readyState === WebSocket.OPEN && sessionNameRef.current) {
1432
+ wsRef.current.send(JSON.stringify({ type: 'kill', sessionName: sessionNameRef.current }));
1433
+ }
1434
+ onClose(true);
1435
+ }}
1436
+ className="flex-1 px-3 py-1.5 text-[11px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30">
1437
+ Kill Session
1438
+ <span className="block text-[9px] text-red-400/60 mt-0.5">End session, back to auto</span>
1439
+ </button>
1440
+ </div>
1441
+ <button onClick={() => setShowCloseDialog(false)}
1442
+ className="w-full mt-2 px-3 py-1 text-[10px] text-gray-500 hover:text-gray-300">
1443
+ Cancel
1444
+ </button>
1445
+ </div>
1446
+ </div>
1447
+ )}
1448
+ </div>
1449
+ );
1450
+ }
1451
+
1452
+ // ─── ReactFlow Input Node ────────────────────────────────
1453
+
1454
+ interface InputNodeData {
1455
+ config: AgentConfig;
1456
+ state: AgentState;
1457
+ onSubmit: (content: string) => void;
1458
+ onEdit: () => void;
1459
+ onRemove: () => void;
1460
+ [key: string]: unknown;
1461
+ }
1462
+
1463
+ function InputFlowNode({ data }: NodeProps<Node<InputNodeData>>) {
1464
+ const { config, state, onSubmit, onEdit, onRemove } = data;
1465
+ const isDone = state?.taskStatus === 'done';
1466
+ const [text, setText] = useState('');
1467
+ const entries = config.entries || [];
1468
+
1469
+ return (
1470
+ <div className="w-60 flex flex-col rounded-lg select-none"
1471
+ style={{ border: `1px solid ${isDone ? '#58a6ff60' : '#30363d50'}`, background: '#0d1117',
1472
+ boxShadow: isDone ? '0 0 10px #58a6ff15' : 'none' }}>
1473
+ <Handle type="source" position={Position.Right} style={{ background: '#58a6ff', width: 8, height: 8, border: 'none' }} />
1474
+
1475
+ {/* Header */}
1476
+ <div className="flex items-center gap-2 px-3 py-2" style={{ borderBottom: '1px solid #21262d' }}>
1477
+ <span className="text-sm">{config.icon || '📝'}</span>
1478
+ <span className="text-xs font-semibold text-white flex-1">{config.label || 'Input'}</span>
1479
+ {entries.length > 0 && <span className="text-[8px] text-gray-600">{entries.length}</span>}
1480
+ <div className="w-2 h-2 rounded-full" style={{ background: isDone ? '#58a6ff' : '#484f58', boxShadow: isDone ? '0 0 6px #58a6ff' : 'none' }} />
1481
+ </div>
1482
+
1483
+ {/* History entries (scrollable, compact) */}
1484
+ {entries.length > 0 && (
1485
+ <div className="max-h-24 overflow-auto px-3 py-1.5 space-y-1" style={{ borderBottom: '1px solid #21262d' }}
1486
+ onPointerDown={e => e.stopPropagation()}>
1487
+ {entries.map((e, i) => (
1488
+ <div key={i} className={`text-[9px] leading-relaxed ${i === entries.length - 1 ? 'text-gray-300' : 'text-gray-600'}`}>
1489
+ <span className="text-[7px] text-gray-700 mr-1">#{i + 1}</span>
1490
+ {e.content.length > 80 ? e.content.slice(0, 80) + '…' : e.content}
1491
+ </div>
1492
+ ))}
1493
+ </div>
1494
+ )}
1495
+
1496
+ {/* New input */}
1497
+ <div className="px-3 py-2">
1498
+ <textarea value={text} onChange={e => setText(e.target.value)} rows={2}
1499
+ placeholder={entries.length > 0 ? 'Add new requirement or change...' : 'Describe requirements...'}
1500
+ className="w-full text-[10px] bg-[#0d1117] border border-[#21262d] rounded px-2 py-1.5 text-gray-300 placeholder-gray-600 focus:outline-none focus:border-[#58a6ff]/50 resize-none"
1501
+ onPointerDown={e => e.stopPropagation()} />
1502
+ </div>
1503
+
1504
+ {/* Actions */}
1505
+ <div className="flex items-center gap-1 px-2 py-1.5" style={{ borderTop: '1px solid #21262d' }}>
1506
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => {
1507
+ e.stopPropagation();
1508
+ if (!text.trim()) return;
1509
+ onSubmit(text.trim());
1510
+ setText('');
1511
+ }}
1512
+ className="text-[9px] px-2 py-0.5 rounded bg-[#238636]/20 text-[#3fb950] hover:bg-[#238636]/30 disabled:opacity-30"
1513
+ disabled={!text.trim()}>
1514
+ {entries.length > 0 ? '+ Add' : '✓ Submit'}
1515
+ </button>
1516
+ <div className="flex-1" />
1517
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onRemove(); }}
1518
+ className="text-[9px] text-gray-700 hover:text-red-400 px-1">✕</button>
1519
+ </div>
1520
+ </div>
1521
+ );
1522
+ }
1523
+
1524
+ // ─── ReactFlow Agent Node ────────────────────────────────
1525
+
1526
+ interface AgentNodeData {
1527
+ config: AgentConfig;
1528
+ state: AgentState;
1529
+ colorIdx: number;
1530
+ previewLines: string[];
1531
+ onRun: () => void;
1532
+ onPause: () => void;
1533
+ onStop: () => void;
1534
+ onRetry: () => void;
1535
+ onEdit: () => void;
1536
+ onRemove: () => void;
1537
+ onMessage: () => void;
1538
+ onApprove: () => void;
1539
+ onShowLog: () => void;
1540
+ onShowMemory: () => void;
1541
+ onShowInbox: () => void;
1542
+ onOpenTerminal: () => void;
1543
+ inboxPending?: number;
1544
+ inboxFailed?: number;
1545
+ [key: string]: unknown;
1546
+ }
1547
+
1548
+ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1549
+ const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, inboxPending = 0, inboxFailed = 0 } = data;
1550
+ const c = COLORS[colorIdx % COLORS.length];
1551
+ const smithStatus = state?.smithStatus || 'down';
1552
+ const taskStatus = state?.taskStatus || 'idle';
1553
+ const mode = state?.mode || 'auto';
1554
+ const smithInfo = SMITH_STATUS[smithStatus] || SMITH_STATUS.down;
1555
+ const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
1556
+ const currentStep = state?.currentStep;
1557
+ const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
1558
+ const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active'; // approximation, actual check would use approvalQueue
1559
+
1560
+ return (
1561
+ <div className="w-52 flex flex-col rounded-lg select-none"
1562
+ style={{ border: `1px solid ${c.border}${taskStatus === 'running' ? '90' : '40'}`, background: c.bg,
1563
+ boxShadow: taskInfo.glow ? `0 0 12px ${taskInfo.color}25` : smithInfo.glow ? `0 0 8px ${smithInfo.color}15` : 'none' }}>
1564
+ <Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1565
+ <Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1566
+
1567
+ {/* Header */}
1568
+ <div className="flex items-center gap-2 px-3 py-2">
1569
+ <span className="text-sm">{config.icon}</span>
1570
+ <div className="flex-1 min-w-0">
1571
+ <div className="text-xs font-semibold text-white truncate">{config.label}</div>
1572
+ <div className="text-[8px]" style={{ color: c.accent }}>{config.backend === 'api' ? config.provider || 'api' : config.agentId || 'cli'}</div>
1573
+ </div>
1574
+ {/* Status: smith + mode + task */}
1575
+ <div className="flex flex-col items-end gap-0.5">
1576
+ <div className="flex items-center gap-1">
1577
+ <div className="w-1.5 h-1.5 rounded-full" style={{ background: smithInfo.color, boxShadow: smithInfo.glow ? `0 0 4px ${smithInfo.color}` : 'none' }} />
1578
+ <span className="text-[7px]" style={{ color: smithInfo.color }}>{smithInfo.label}</span>
1579
+ </div>
1580
+ <div className="flex items-center gap-1">
1581
+ <div className="w-1.5 h-1.5 rounded-full" style={{ background: mode === 'manual' ? '#d2a8ff' : '#30363d' }} />
1582
+ <span className="text-[7px]" style={{ color: mode === 'manual' ? '#d2a8ff' : '#6e7681' }}>{mode}</span>
1583
+ </div>
1584
+ <div className="flex items-center gap-1">
1585
+ <div className="w-1.5 h-1.5 rounded-full" style={{ background: taskInfo.color, boxShadow: taskInfo.glow ? `0 0 4px ${taskInfo.color}` : 'none' }} />
1586
+ <span className="text-[7px]" style={{ color: taskInfo.color }}>{taskInfo.label}</span>
1587
+ </div>
1588
+ {config.watch?.enabled && (
1589
+ <div className="flex items-center gap-1">
1590
+ <span className="text-[7px]" style={{ color: (state as any)?.lastWatchAlert ? '#f0883e' : '#6e7681' }}>
1591
+ {(state as any)?.lastWatchAlert ? '👁 alert' : '👁 watching'}
1592
+ </span>
1593
+ </div>
1594
+ )}
1595
+ </div>
1596
+ </div>
1597
+
1598
+ {/* Current step */}
1599
+ {step && taskStatus === 'running' && (
1600
+ <div className="px-3 pb-1 text-[8px] text-yellow-400/80" style={{ borderTop: `1px solid ${c.border}15` }}>
1601
+ Step {(currentStep || 0) + 1}/{config.steps.length}: {step.label}
1602
+ </div>
1603
+ )}
1604
+
1605
+ {/* Error */}
1606
+ {state?.error && (
1607
+ <div className="px-3 pb-1 text-[8px] text-red-400 truncate" style={{ borderTop: `1px solid ${c.border}15` }}>
1608
+ {state.error}
1609
+ </div>
1610
+ )}
1611
+
1612
+ {/* Preview lines */}
1613
+ {previewLines.length > 0 && (
1614
+ <div className="px-3 pb-2 space-y-0.5 cursor-pointer" style={{ borderTop: `1px solid ${c.border}15` }}
1615
+ onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowLog(); }}>
1616
+ {previewLines.map((line, i) => (
1617
+ <div key={i} className="text-[8px] text-gray-500 font-mono truncate">{line}</div>
1618
+ ))}
1619
+ </div>
1620
+ )}
1621
+
1622
+ {/* Inbox — prominent, shows pending/failed counts */}
1623
+ {(inboxPending > 0 || inboxFailed > 0) && (
1624
+ <div className="px-2 py-1" style={{ borderTop: `1px solid ${c.border}15` }}>
1625
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
1626
+ className="w-full text-[9px] px-2 py-1 rounded flex items-center justify-center gap-1.5 bg-orange-600/15 text-orange-400 hover:bg-orange-600/25 border border-orange-600/30">
1627
+ 📨 Inbox
1628
+ {inboxPending > 0 && <span className="px-1 rounded-full bg-yellow-600/30 text-yellow-400 text-[8px]">{inboxPending} pending</span>}
1629
+ {inboxFailed > 0 && <span className="px-1 rounded-full bg-red-600/30 text-red-400 text-[8px]">{inboxFailed} failed</span>}
1630
+ </button>
1631
+ </div>
1632
+ )}
1633
+
1634
+ {/* Actions */}
1635
+ <div className="flex items-center gap-1 px-2 py-1.5" style={{ borderTop: `1px solid ${c.border}15` }}>
1636
+ {taskStatus === 'running' && (
1637
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onStop(); }}
1638
+ className="text-[9px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">■ Stop</button>
1639
+ )}
1640
+ {/* Message button — send instructions to agent */}
1641
+ {smithStatus === 'active' && taskStatus !== 'running' && (
1642
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onMessage(); }}
1643
+ className="text-[9px] px-1.5 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30">💬 Message</button>
1644
+ )}
1645
+ <div className="flex-1" />
1646
+ {taskStatus !== 'running' && (
1647
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
1648
+ className="text-[9px] text-gray-600 hover:text-green-400 px-1" title="Open terminal (manual mode)">⌨️</button>
1649
+ )}
1650
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
1651
+ className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
1652
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
1653
+ className="text-[9px] text-gray-600 hover:text-purple-400 px-1" title="Memory">🧠</button>
1654
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowLog(); }}
1655
+ className="text-[9px] text-gray-600 hover:text-gray-300 px-1" title="Logs">📋</button>
1656
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onEdit(); }}
1657
+ className="text-[9px] text-gray-600 hover:text-blue-400 px-1">✏️</button>
1658
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onRemove(); }}
1659
+ className="text-[9px] text-gray-600 hover:text-red-400 px-1">✕</button>
1660
+ </div>
1661
+ </div>
1662
+ );
1663
+ }
1664
+
1665
+ const nodeTypes = { agent: AgentFlowNode, input: InputFlowNode };
1666
+
1667
+ // ─── Main Workspace ──────────────────────────────────────
1668
+
1669
+ export interface WorkspaceViewHandle {
1670
+ focusAgent: (agentId: string) => void;
1671
+ }
1672
+
1673
+ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1674
+ projectPath: string;
1675
+ projectName: string;
1676
+ onClose: () => void;
1677
+ }, ref: React.Ref<WorkspaceViewHandle>) {
1678
+ const reactFlow = useReactFlow();
1679
+ const [workspaceId, setWorkspaceId] = useState<string | null>(null);
1680
+ const [rfNodes, setRfNodes] = useState<Node<any>[]>([]);
1681
+ const [modal, setModal] = useState<{ mode: 'add' | 'edit'; initial: Partial<AgentConfig>; editId?: string } | null>(null);
1682
+ const [messageTarget, setMessageTarget] = useState<{ id: string; label: string } | null>(null);
1683
+ const [logTarget, setLogTarget] = useState<{ id: string; label: string } | null>(null);
1684
+ const [runPromptTarget, setRunPromptTarget] = useState<{ id: string; label: string } | null>(null);
1685
+ const [userInputRequest, setUserInputRequest] = useState<{ agentId: string; fromAgent: string; question: string } | null>(null);
1686
+ const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
1687
+ const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
1688
+ const [showBusPanel, setShowBusPanel] = useState(false);
1689
+ const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string> }[]>([]);
1690
+ const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
1691
+
1692
+ // Expose focusAgent to parent
1693
+ useImperativeHandle(ref, () => ({
1694
+ focusAgent(agentId: string) {
1695
+ const node = rfNodes.find(n => n.id === agentId);
1696
+ if (node && node.measured?.width) {
1697
+ reactFlow.setCenter(
1698
+ node.position.x + (node.measured.width / 2),
1699
+ node.position.y + ((node.measured.height || 100) / 2),
1700
+ { zoom: 1.2, duration: 400 }
1701
+ );
1702
+ // Flash highlight via selection
1703
+ reactFlow.setNodes(nodes => nodes.map(n => ({ ...n, selected: n.id === agentId })));
1704
+ setTimeout(() => {
1705
+ reactFlow.setNodes(nodes => nodes.map(n => ({ ...n, selected: false })));
1706
+ }, 1500);
1707
+ }
1708
+ },
1709
+ }), [rfNodes, reactFlow]);
1710
+
1711
+ // Initialize workspace
1712
+ useEffect(() => {
1713
+ ensureWorkspace(projectPath, projectName).then(setWorkspaceId).catch(() => {});
1714
+ }, [projectPath, projectName]);
1715
+
1716
+ // SSE stream — server is the single source of truth
1717
+ const { agents, states, logPreview, busLog, daemonActive: daemonActiveFromStream, setDaemonActive: setDaemonActiveFromStream } = useWorkspaceStream(workspaceId, (event) => {
1718
+ if (event.type === 'user_input_request') {
1719
+ setUserInputRequest(event);
1720
+ }
1721
+ });
1722
+
1723
+ // Auto-open floating terminals for manual agents on page load
1724
+ const autoOpenDone = useRef(false);
1725
+ useEffect(() => {
1726
+ if (autoOpenDone.current || agents.length === 0 || Object.keys(states).length === 0) return;
1727
+ autoOpenDone.current = true;
1728
+ const manualAgents = agents.filter(a =>
1729
+ a.type !== 'input' && states[a.id]?.mode === 'manual' && states[a.id]?.tmuxSession
1730
+ );
1731
+ if (manualAgents.length > 0) {
1732
+ const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
1733
+ setFloatingTerminals(manualAgents.map(a => ({
1734
+ agentId: a.id,
1735
+ label: a.label,
1736
+ icon: a.icon,
1737
+ cliId: a.agentId || 'claude',
1738
+ workDir: a.workDir && a.workDir !== './' && a.workDir !== '.' ? a.workDir : undefined,
1739
+ tmuxSession: states[a.id].tmuxSession,
1740
+ sessionName: `mw-forge-${safeName(projectName)}-${safeName(a.label)}`,
1741
+ })));
1742
+ }
1743
+ }, [agents, states]);
1744
+
1745
+ // Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
1746
+ useEffect(() => {
1747
+ setRfNodes(prev => {
1748
+ const prevMap = new Map(prev.map(n => [n.id, n]));
1749
+ return agents.map((agent, i) => {
1750
+ const existing = prevMap.get(agent.id);
1751
+ const base = {
1752
+ id: agent.id,
1753
+ position: existing?.position ?? { x: i * 260, y: 60 },
1754
+ ...(existing?.measured ? { measured: existing.measured } : {}),
1755
+ ...(existing?.width ? { width: existing.width, height: existing.height } : {}),
1756
+ };
1757
+
1758
+ // Input node
1759
+ if (agent.type === 'input') {
1760
+ return {
1761
+ ...base,
1762
+ type: 'input' as const,
1763
+ data: {
1764
+ config: agent,
1765
+ state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
1766
+ onSubmit: (content: string) => {
1767
+ // Optimistic update
1768
+ wsApi(workspaceId!, 'complete_input', { agentId: agent.id, content });
1769
+ },
1770
+ onEdit: () => setModal({ mode: 'edit', initial: agent, editId: agent.id }),
1771
+ onRemove: () => {
1772
+ if (!confirm(`Remove "${agent.label}"?`)) return;
1773
+ wsApi(workspaceId!, 'remove', { agentId: agent.id });
1774
+ },
1775
+ } satisfies InputNodeData,
1776
+ };
1777
+ }
1778
+
1779
+ // Agent node
1780
+ return {
1781
+ ...base,
1782
+ type: 'agent' as const,
1783
+ data: {
1784
+ config: agent,
1785
+ state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
1786
+ colorIdx: i,
1787
+ previewLines: logPreview[agent.id] || [],
1788
+ onRun: () => {
1789
+ wsApi(workspaceId!, 'run', { agentId: agent.id });
1790
+ },
1791
+ onPause: () => wsApi(workspaceId!, 'pause', { agentId: agent.id }),
1792
+ onStop: () => wsApi(workspaceId!, 'stop', { agentId: agent.id }),
1793
+ onRetry: () => wsApi(workspaceId!, 'retry', { agentId: agent.id }),
1794
+ onEdit: () => setModal({ mode: 'edit', initial: agent, editId: agent.id }),
1795
+ onRemove: () => {
1796
+ if (!confirm(`Remove "${agent.label}"?`)) return;
1797
+ wsApi(workspaceId!, 'remove', { agentId: agent.id });
1798
+ },
1799
+ onMessage: () => setMessageTarget({ id: agent.id, label: agent.label }),
1800
+ onApprove: () => wsApi(workspaceId!, 'approve', { agentId: agent.id }),
1801
+ onShowLog: () => setLogTarget({ id: agent.id, label: agent.label }),
1802
+ onShowMemory: () => setMemoryTarget({ id: agent.id, label: agent.label }),
1803
+ onShowInbox: () => setInboxTarget({ id: agent.id, label: agent.label }),
1804
+ inboxPending: busLog.filter(m => m.to === agent.id && (m.status === 'pending' || m.status === 'pending_approval') && m.type !== 'ack').length,
1805
+ inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
1806
+ onOpenTerminal: async () => {
1807
+ if (!workspaceId) return;
1808
+ // Use current state via setState callback to avoid stale closure
1809
+ let alreadyOpen = false;
1810
+ setFloatingTerminals(prev => { alreadyOpen = prev.some(t => t.agentId === agent.id); return prev; });
1811
+ if (alreadyOpen) return;
1812
+
1813
+ const agentState = states[agent.id];
1814
+ const existingTmux = agentState?.tmuxSession;
1815
+
1816
+ // Build fixed session name: mw-forge-{project}-{agentLabel}
1817
+ const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
1818
+ const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
1819
+ const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.'
1820
+ ? agent.workDir : undefined;
1821
+
1822
+ // If already manual with a tmux session, just reopen (attach)
1823
+ if (agentState?.mode === 'manual' && existingTmux) {
1824
+ setFloatingTerminals(prev => [...prev, {
1825
+ agentId: agent.id, label: agent.label, icon: agent.icon,
1826
+ cliId: agent.agentId || 'claude', workDir,
1827
+ tmuxSession: existingTmux, sessionName: sessName,
1828
+ }]);
1829
+ return;
1830
+ }
1831
+
1832
+ // Resolve terminal launch info to determine supportsSession
1833
+ const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
1834
+ const supportsSession = resolveRes?.supportsSession ?? true;
1835
+
1836
+ // Show launch dialog with resolved info
1837
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession });
1838
+ },
1839
+ } satisfies AgentNodeData,
1840
+ };
1841
+ });
1842
+ });
1843
+ }, [agents, states, logPreview, workspaceId]); // eslint-disable-line react-hooks/exhaustive-deps
1844
+
1845
+ // Derive edges from dependsOn
1846
+ const rfEdges = useMemo(() => {
1847
+ const edges: any[] = [];
1848
+ for (const agent of agents) {
1849
+ for (const depId of agent.dependsOn) {
1850
+ const depState = states[depId];
1851
+ const targetState = states[agent.id];
1852
+ const depTask = depState?.taskStatus || 'idle';
1853
+ const targetTask = targetState?.taskStatus || 'idle';
1854
+ const isFlowing = depTask === 'running' || targetTask === 'running';
1855
+ const isCompleted = depTask === 'done';
1856
+ const color = isFlowing ? '#58a6ff70' : isCompleted ? '#58a6ff40' : '#30363d60';
1857
+
1858
+ // Find last bus message between these two agents
1859
+ const lastMsg = [...busLog].reverse().find(m =>
1860
+ (m.from === depId && m.to === agent.id) || (m.from === agent.id && m.to === depId)
1861
+ );
1862
+ const edgeLabel = lastMsg?.payload?.action && lastMsg.payload.action !== 'task_complete' && lastMsg.payload.action !== 'ack'
1863
+ ? `${lastMsg.payload.action}${lastMsg.payload.content ? ': ' + lastMsg.payload.content.slice(0, 30) : ''}`
1864
+ : undefined;
1865
+
1866
+ edges.push({
1867
+ id: `${depId}-${agent.id}`,
1868
+ source: depId,
1869
+ target: agent.id,
1870
+ animated: isFlowing,
1871
+ label: edgeLabel,
1872
+ labelStyle: { fill: '#8b949e', fontSize: 8 },
1873
+ labelBgStyle: { fill: '#0d1117', fillOpacity: 0.8 },
1874
+ labelBgPadding: [4, 2] as [number, number],
1875
+ style: { stroke: color, strokeWidth: isFlowing ? 2 : isCompleted ? 1.5 : 1 },
1876
+ markerEnd: { type: MarkerType.ArrowClosed, color },
1877
+ });
1878
+ }
1879
+ }
1880
+ return edges;
1881
+ }, [agents, states]);
1882
+
1883
+ // Let ReactFlow manage all node changes (position, dimensions, selection, etc.)
1884
+ const onNodesChange = useCallback((changes: NodeChange[]) => {
1885
+ setRfNodes(prev => applyNodeChanges(changes, prev) as Node<AgentNodeData>[]);
1886
+ }, []);
1887
+
1888
+ const handleAddAgent = async (cfg: Omit<AgentConfig, 'id'>) => {
1889
+ if (!workspaceId) return;
1890
+ const config: AgentConfig = { ...cfg, id: `${cfg.label.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}` };
1891
+ // Optimistic update — show immediately
1892
+ setModal(null);
1893
+ await wsApi(workspaceId, 'add', { config });
1894
+ };
1895
+
1896
+ const handleEditAgent = async (cfg: Omit<AgentConfig, 'id'>) => {
1897
+ if (!workspaceId || !modal?.editId) return;
1898
+ const config: AgentConfig = { ...cfg, id: modal.editId };
1899
+ // Optimistic update
1900
+ setModal(null);
1901
+ await wsApi(workspaceId, 'update', { agentId: modal.editId, config });
1902
+ };
1903
+
1904
+ const handleAddInput = async () => {
1905
+ if (!workspaceId) return;
1906
+ const config: AgentConfig = {
1907
+ id: `input-${Date.now()}`, label: 'Requirements', icon: '📝',
1908
+ type: 'input', content: '', entries: [], role: '', backend: 'cli',
1909
+ dependsOn: [], outputs: [], steps: [],
1910
+ };
1911
+ await wsApi(workspaceId, 'add', { config });
1912
+ };
1913
+
1914
+ const handleCreatePipeline = async () => {
1915
+ if (!workspaceId) return;
1916
+ // Create pipeline via API — server uses presets with full prompts
1917
+ const res = await fetch(`/api/workspace/${workspaceId}/agents`, {
1918
+ method: 'POST',
1919
+ headers: { 'Content-Type': 'application/json' },
1920
+ body: JSON.stringify({ action: 'create_pipeline' }),
1921
+ });
1922
+ const data = await res.json();
1923
+ if (!res.ok && data.error) alert(`Error: ${data.error}`);
1924
+ };
1925
+
1926
+ const handleExportTemplate = async () => {
1927
+ if (!workspaceId) return;
1928
+ try {
1929
+ const res = await fetch(`/api/workspace?export=${workspaceId}`);
1930
+ const template = await res.json();
1931
+ // Download as JSON file
1932
+ const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
1933
+ const url = URL.createObjectURL(blob);
1934
+ const a = document.createElement('a');
1935
+ a.href = url;
1936
+ a.download = `workspace-template-${projectName.replace(/\s+/g, '-')}.json`;
1937
+ a.click();
1938
+ URL.revokeObjectURL(url);
1939
+ } catch {
1940
+ alert('Export failed');
1941
+ }
1942
+ };
1943
+
1944
+ const handleImportTemplate = async (file: File) => {
1945
+ if (!workspaceId) return;
1946
+ try {
1947
+ const text = await file.text();
1948
+ const template = JSON.parse(text);
1949
+ await fetch('/api/workspace', {
1950
+ method: 'POST',
1951
+ headers: { 'Content-Type': 'application/json' },
1952
+ body: JSON.stringify({ projectPath, projectName, template }),
1953
+ });
1954
+ // Reload page to pick up new workspace
1955
+ window.location.reload();
1956
+ } catch {
1957
+ alert('Import failed — invalid template file');
1958
+ }
1959
+ };
1960
+
1961
+ const handleRunAll = () => { if (workspaceId) wsApi(workspaceId, 'run_all'); };
1962
+ const handleStartDaemon = async () => {
1963
+ if (!workspaceId) return;
1964
+ const result = await wsApi(workspaceId, 'start_daemon');
1965
+ if (result.ok) setDaemonActiveFromStream(true);
1966
+ };
1967
+ const handleStopDaemon = async () => {
1968
+ if (!workspaceId) return;
1969
+ const result = await wsApi(workspaceId, 'stop_daemon');
1970
+ if (result.ok) setDaemonActiveFromStream(false);
1971
+ };
1972
+
1973
+ return (
1974
+ <div className="flex-1 flex flex-col min-h-0" style={{ background: '#080810' }}>
1975
+ {/* Header */}
1976
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[#2a2a3a] shrink-0">
1977
+ <button onClick={onClose} className="text-gray-400 hover:text-white text-sm">←</button>
1978
+ <span className="text-xs font-bold text-white">Workspace</span>
1979
+ <span className="text-[9px] text-gray-500">{projectName}</span>
1980
+ {agents.length > 0 && !daemonActiveFromStream && (
1981
+ <>
1982
+ <button onClick={handleRunAll}
1983
+ className="text-[8px] px-2 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30 ml-2">
1984
+ ▶ Run All
1985
+ </button>
1986
+ <button onClick={handleStartDaemon}
1987
+ className="text-[8px] px-2 py-0.5 rounded bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30">
1988
+ ⚡ Start Daemon
1989
+ </button>
1990
+ </>
1991
+ )}
1992
+ {daemonActiveFromStream && (
1993
+ <>
1994
+ <span className="text-[8px] px-2 py-0.5 rounded bg-green-600/30 text-green-400 ml-2 animate-pulse">
1995
+ ● Daemon Active
1996
+ </span>
1997
+ <button onClick={handleStopDaemon}
1998
+ className="text-[8px] px-2 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
1999
+ ■ Stop
2000
+ </button>
2001
+ </>
2002
+ )}
2003
+ <div className="ml-auto flex items-center gap-2">
2004
+ <button onClick={() => setShowBusPanel(true)}
2005
+ className={`text-[8px] px-2 py-0.5 rounded border border-[#30363d] hover:border-[#58a6ff]/60 ${busLog.length > 0 ? 'text-[#58a6ff]' : 'text-gray-500'}`}>
2006
+ 📡 Logs{busLog.length > 0 ? ` (${busLog.length})` : ''}
2007
+ </button>
2008
+ {agents.length > 0 && (
2009
+ <button onClick={handleExportTemplate}
2010
+ className="text-[8px] px-2 py-0.5 rounded border border-[#30363d] text-gray-500 hover:text-white hover:border-[#58a6ff]/60">
2011
+ 📤 Export
2012
+ </button>
2013
+ )}
2014
+ <button onClick={handleAddInput}
2015
+ className="text-[8px] px-2 py-0.5 rounded border border-[#30363d] text-gray-400 hover:text-white hover:border-[#58a6ff]/60">
2016
+ 📝 + Input
2017
+ </button>
2018
+ <button onClick={() => setModal({ mode: 'add', initial: {} })}
2019
+ className="text-[8px] px-2 py-0.5 rounded border border-[#30363d] text-gray-400 hover:text-white hover:border-[#58a6ff]/60">
2020
+ + Add Agent
2021
+ </button>
2022
+ </div>
2023
+ </div>
2024
+
2025
+ {/* Graph area */}
2026
+ {agents.length === 0 ? (
2027
+ <div className="flex-1 flex flex-col items-center justify-center gap-3">
2028
+ <span className="text-3xl">🚀</span>
2029
+ <div className="text-sm text-gray-400">Add agents to start</div>
2030
+ <div className="flex gap-2 mt-2 flex-wrap justify-center">
2031
+ {PRESET_AGENTS.map((p, i) => (
2032
+ <button key={i} onClick={() => setModal({ mode: 'add', initial: p })}
2033
+ className="text-[10px] px-3 py-1.5 rounded border border-[#30363d] text-gray-300 hover:text-white hover:border-[#58a6ff]/60 flex items-center gap-1">
2034
+ {p.icon} {p.label}
2035
+ </button>
2036
+ ))}
2037
+ </div>
2038
+ <div className="flex gap-2 mt-1">
2039
+ <button onClick={() => setModal({ mode: 'add', initial: {} })}
2040
+ className="text-[10px] px-3 py-1.5 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-[#58a6ff]/60">
2041
+ ⚙️ Custom
2042
+ </button>
2043
+ <button onClick={handleCreatePipeline}
2044
+ className="text-[10px] px-3 py-1.5 rounded border border-[#238636] text-[#3fb950] hover:bg-[#238636]/20">
2045
+ 🚀 Dev Pipeline
2046
+ </button>
2047
+ <label className="text-[10px] px-3 py-1.5 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-[#58a6ff]/60 cursor-pointer">
2048
+ 📥 Import
2049
+ <input type="file" accept=".json" className="hidden" onChange={e => {
2050
+ const file = e.target.files?.[0];
2051
+ if (file) handleImportTemplate(file);
2052
+ e.target.value = '';
2053
+ }} />
2054
+ </label>
2055
+ </div>
2056
+ </div>
2057
+ ) : (
2058
+ <div className="flex-1 min-h-0">
2059
+ <ReactFlow
2060
+ nodes={rfNodes}
2061
+ edges={rfEdges}
2062
+ onNodesChange={onNodesChange}
2063
+ nodeTypes={nodeTypes}
2064
+ fitView
2065
+ fitViewOptions={{ padding: 0.3 }}
2066
+ minZoom={0.3}
2067
+ maxZoom={2}
2068
+ proOptions={{ hideAttribution: true }}
2069
+ >
2070
+ <Background color="#1a1a2e" gap={20} size={1} />
2071
+ <Controls style={{ background: '#0d1117', border: '1px solid #30363d' }} showInteractive={false} />
2072
+ </ReactFlow>
2073
+ </div>
2074
+ )}
2075
+
2076
+ {/* Config modal */}
2077
+ {modal && (
2078
+ <AgentConfigModal
2079
+ initial={modal.initial}
2080
+ mode={modal.mode}
2081
+ existingAgents={agents}
2082
+ projectPath={projectPath}
2083
+ onConfirm={modal.mode === 'add' ? handleAddAgent : handleEditAgent}
2084
+ onCancel={() => setModal(null)}
2085
+ />
2086
+ )}
2087
+
2088
+ {/* Run prompt dialog (for agents with no dependencies) */}
2089
+ {runPromptTarget && workspaceId && (
2090
+ <RunPromptDialog
2091
+ agentLabel={runPromptTarget.label}
2092
+ onRun={input => {
2093
+ wsApi(workspaceId, 'run', { agentId: runPromptTarget.id, input: input || undefined });
2094
+ setRunPromptTarget(null);
2095
+ }}
2096
+ onCancel={() => setRunPromptTarget(null)}
2097
+ />
2098
+ )}
2099
+
2100
+ {/* Message dialog */}
2101
+ {messageTarget && workspaceId && (
2102
+ <MessageDialog
2103
+ agentLabel={messageTarget.label}
2104
+ onSend={msg => {
2105
+ wsApi(workspaceId, 'message', { agentId: messageTarget.id, content: msg });
2106
+ setMessageTarget(null);
2107
+ }}
2108
+ onCancel={() => setMessageTarget(null)}
2109
+ />
2110
+ )}
2111
+
2112
+ {/* Log panel */}
2113
+ {logTarget && workspaceId && (
2114
+ <LogPanel
2115
+ agentId={logTarget.id}
2116
+ agentLabel={logTarget.label}
2117
+ workspaceId={workspaceId}
2118
+ onClose={() => setLogTarget(null)}
2119
+ />
2120
+ )}
2121
+
2122
+ {/* Bus message panel */}
2123
+ {showBusPanel && (
2124
+ <BusPanel busLog={busLog} agents={agents} onClose={() => setShowBusPanel(false)} />
2125
+ )}
2126
+
2127
+ {/* Memory panel */}
2128
+ {memoryTarget && workspaceId && (
2129
+ <MemoryPanel
2130
+ agentId={memoryTarget.id}
2131
+ agentLabel={memoryTarget.label}
2132
+ workspaceId={workspaceId}
2133
+ onClose={() => setMemoryTarget(null)}
2134
+ />
2135
+ )}
2136
+
2137
+ {/* Inbox panel */}
2138
+ {inboxTarget && workspaceId && (
2139
+ <InboxPanel
2140
+ agentId={inboxTarget.id}
2141
+ agentLabel={inboxTarget.label}
2142
+ busLog={busLog}
2143
+ agents={agents}
2144
+ workspaceId={workspaceId}
2145
+ onClose={() => setInboxTarget(null)}
2146
+ />
2147
+ )}
2148
+
2149
+ {/* Terminal launch dialog */}
2150
+ {termLaunchDialog && workspaceId && (
2151
+ <TerminalLaunchDialog
2152
+ agent={termLaunchDialog.agent}
2153
+ workDir={termLaunchDialog.workDir}
2154
+ sessName={termLaunchDialog.sessName}
2155
+ projectPath={projectPath}
2156
+ workspaceId={workspaceId}
2157
+ supportsSession={termLaunchDialog.supportsSession}
2158
+ onLaunch={async (resumeMode, sessionId) => {
2159
+ const { agent, sessName, workDir } = termLaunchDialog;
2160
+ setTermLaunchDialog(null);
2161
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2162
+ if (res.ok) {
2163
+ setFloatingTerminals(prev => [...prev, {
2164
+ agentId: agent.id, label: agent.label, icon: agent.icon,
2165
+ cliId: agent.agentId || 'claude',
2166
+ cliCmd: res.cliCmd || 'claude',
2167
+ cliType: res.cliType || 'claude-code',
2168
+ workDir,
2169
+ sessionName: sessName, resumeMode, resumeSessionId: sessionId,
2170
+ profileEnv: {
2171
+ ...(res.env || {}),
2172
+ ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
2173
+ FORGE_AGENT_ID: agent.id,
2174
+ FORGE_WORKSPACE_ID: workspaceId,
2175
+ FORGE_PORT: String(window.location.port || 8403),
2176
+ },
2177
+ }]);
2178
+ }
2179
+ }}
2180
+ onCancel={() => setTermLaunchDialog(null)}
2181
+ />
2182
+ )}
2183
+
2184
+ {/* Floating terminals for manual agents */}
2185
+ {floatingTerminals.map(ft => (
2186
+ <FloatingTerminal
2187
+ key={ft.agentId}
2188
+ agentLabel={ft.label}
2189
+ agentIcon={ft.icon}
2190
+ projectPath={projectPath}
2191
+ agentCliId={ft.cliId}
2192
+ cliCmd={ft.cliCmd}
2193
+ cliType={ft.cliType}
2194
+ workDir={ft.workDir}
2195
+ preferredSessionName={ft.sessionName}
2196
+ existingSession={ft.tmuxSession}
2197
+ resumeMode={ft.resumeMode}
2198
+ resumeSessionId={ft.resumeSessionId}
2199
+ profileEnv={ft.profileEnv}
2200
+ onSessionReady={(name) => {
2201
+ if (workspaceId) {
2202
+ wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2203
+ }
2204
+ setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
2205
+ }}
2206
+ onClose={() => {
2207
+ setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
2208
+ if (workspaceId) {
2209
+ wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId });
2210
+ }
2211
+ }}
2212
+ />
2213
+ ))}
2214
+
2215
+ {/* User input request from agent (via bus) */}
2216
+ {userInputRequest && workspaceId && (
2217
+ <RunPromptDialog
2218
+ agentLabel={`${agents.find(a => a.id === userInputRequest.fromAgent)?.label || 'Agent'} asks`}
2219
+ onRun={input => {
2220
+ // Send response to the requesting agent's target (Input node)
2221
+ wsApi(workspaceId, 'complete_input', {
2222
+ agentId: userInputRequest.agentId,
2223
+ content: input || userInputRequest.question,
2224
+ });
2225
+ setUserInputRequest(null);
2226
+ }}
2227
+ onCancel={() => setUserInputRequest(null)}
2228
+ />
2229
+ )}
2230
+ </div>
2231
+ );
2232
+ }
2233
+
2234
+ const WorkspaceViewWithRef = forwardRef(WorkspaceViewInner);
2235
+
2236
+ // Wrap with ReactFlowProvider so useReactFlow works
2237
+ export default forwardRef<WorkspaceViewHandle, { projectPath: string; projectName: string; onClose: () => void }>(
2238
+ function WorkspaceView(props, ref) {
2239
+ return (
2240
+ <ReactFlowProvider>
2241
+ <WorkspaceViewWithRef {...props} ref={ref} />
2242
+ </ReactFlowProvider>
2243
+ );
2244
+ }
2245
+ );