@aion0/forge 0.5.7 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.forge/mcp.json +8 -0
- package/RELEASE_NOTES.md +10 -6
- package/app/api/monitor/route.ts +12 -0
- package/app/api/project-sessions/route.ts +61 -0
- package/app/api/workspace/route.ts +1 -1
- package/check-forge-status.sh +9 -0
- package/components/MonitorPanel.tsx +15 -0
- package/components/ProjectDetail.tsx +99 -5
- package/components/SessionView.tsx +67 -19
- package/components/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +599 -109
- package/lib/claude-sessions.ts +26 -28
- package/lib/forge-mcp-server.ts +389 -0
- package/lib/forge-skills/forge-inbox.md +13 -12
- package/lib/forge-skills/forge-send.md +13 -6
- package/lib/forge-skills/forge-status.md +12 -12
- package/lib/project-sessions.ts +48 -0
- package/lib/session-utils.ts +49 -0
- package/lib/workspace/__tests__/state-machine.test.ts +2 -2
- package/lib/workspace/agent-worker.ts +2 -5
- package/lib/workspace/backends/cli-backend.ts +3 -0
- package/lib/workspace/orchestrator.ts +774 -90
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +10 -6
- package/lib/workspace/watch-manager.ts +17 -7
- package/lib/workspace-standalone.ts +83 -27
- package/next-env.d.ts +1 -1
- package/package.json +4 -2
- package/qa/.forge/mcp.json +8 -0
|
@@ -13,6 +13,7 @@ import '@xyflow/react/dist/style.css';
|
|
|
13
13
|
interface AgentConfig {
|
|
14
14
|
id: string; label: string; icon: string; role: string;
|
|
15
15
|
type?: 'agent' | 'input';
|
|
16
|
+
primary?: boolean;
|
|
16
17
|
content?: string;
|
|
17
18
|
entries?: { content: string; timestamp: number }[];
|
|
18
19
|
backend: 'api' | 'cli';
|
|
@@ -22,12 +23,14 @@ interface AgentConfig {
|
|
|
22
23
|
outputs: string[];
|
|
23
24
|
steps: { id: string; label: string; prompt: string }[];
|
|
24
25
|
requiresApproval?: boolean;
|
|
26
|
+
persistentSession?: boolean;
|
|
27
|
+
skipPermissions?: boolean;
|
|
28
|
+
boundSessionId?: string;
|
|
25
29
|
watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve' | 'send_message'; prompt?: string; sendTo?: string };
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface AgentState {
|
|
29
33
|
smithStatus: 'down' | 'active';
|
|
30
|
-
mode: 'auto' | 'manual';
|
|
31
34
|
taskStatus: 'idle' | 'running' | 'done' | 'failed';
|
|
32
35
|
currentStep?: number;
|
|
33
36
|
tmuxSession?: string;
|
|
@@ -237,7 +240,6 @@ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) =
|
|
|
237
240
|
[event.agentId]: {
|
|
238
241
|
...prev[event.agentId],
|
|
239
242
|
smithStatus: event.smithStatus,
|
|
240
|
-
mode: event.mode,
|
|
241
243
|
},
|
|
242
244
|
}));
|
|
243
245
|
}
|
|
@@ -282,6 +284,10 @@ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) =
|
|
|
282
284
|
));
|
|
283
285
|
}
|
|
284
286
|
|
|
287
|
+
if (event.type === 'bus_log_updated') {
|
|
288
|
+
setBusLog(event.log || []);
|
|
289
|
+
}
|
|
290
|
+
|
|
285
291
|
// Server pushed updated agents list + states (after add/remove/update/reset)
|
|
286
292
|
if (event.type === 'agents_changed') {
|
|
287
293
|
const newAgents = event.agents || [];
|
|
@@ -332,22 +338,24 @@ function SessionTargetSelector({ target, agents, projectPath, onChange }: {
|
|
|
332
338
|
}) {
|
|
333
339
|
const [sessions, setSessions] = useState<{ id: string; modified: string; label: string }[]>([]);
|
|
334
340
|
|
|
335
|
-
// Load sessions
|
|
341
|
+
// Load sessions and mark fixed session
|
|
336
342
|
useEffect(() => {
|
|
337
343
|
if (!projectPath) return;
|
|
338
344
|
const pName = (projectPath || '').replace(/\/+$/, '').split('/').pop() || '';
|
|
339
|
-
|
|
340
|
-
.then(r => r.json())
|
|
341
|
-
.then(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
345
|
+
Promise.all([
|
|
346
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`).then(r => r.json()).catch(() => []),
|
|
347
|
+
fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`).then(r => r.json()).catch(() => ({})),
|
|
348
|
+
]).then(([data, psData]) => {
|
|
349
|
+
const fixedId = psData?.fixedSessionId || '';
|
|
350
|
+
if (Array.isArray(data)) {
|
|
351
|
+
setSessions(data.map((s: any, i: number) => {
|
|
352
|
+
const sid = s.sessionId || s.id || '';
|
|
353
|
+
const isBound = sid === fixedId;
|
|
354
|
+
const label = isBound ? `${sid.slice(0, 8)} (fixed)` : i === 0 ? `${sid.slice(0, 8)} (latest)` : sid.slice(0, 8);
|
|
355
|
+
return { id: sid, modified: s.modified || '', label };
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
351
359
|
}, [projectPath]);
|
|
352
360
|
|
|
353
361
|
return (
|
|
@@ -371,6 +379,58 @@ function SessionTargetSelector({ target, agents, projectPath, onChange }: {
|
|
|
371
379
|
);
|
|
372
380
|
}
|
|
373
381
|
|
|
382
|
+
// ─── Fixed Session Picker ────────────────────────────────
|
|
383
|
+
|
|
384
|
+
function FixedSessionPicker({ projectPath, value, onChange }: { projectPath?: string; value: string; onChange: (v: string) => void }) {
|
|
385
|
+
const [sessions, setSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
|
|
386
|
+
const [copied, setCopied] = useState(false);
|
|
387
|
+
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!projectPath) return;
|
|
390
|
+
const pName = projectPath.replace(/\/+$/, '').split('/').pop() || '';
|
|
391
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
|
|
392
|
+
.then(r => r.json())
|
|
393
|
+
.then(data => { if (Array.isArray(data)) setSessions(data.map((s: any) => ({ id: s.sessionId || s.id || '', modified: s.modified || '', size: s.size || 0 }))); })
|
|
394
|
+
.catch(() => {});
|
|
395
|
+
}, [projectPath]);
|
|
396
|
+
|
|
397
|
+
const formatTime = (iso: string) => {
|
|
398
|
+
if (!iso) return '';
|
|
399
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
400
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
401
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
402
|
+
return new Date(iso).toLocaleDateString();
|
|
403
|
+
};
|
|
404
|
+
const formatSize = (b: number) => b < 1024 ? `${b}B` : b < 1048576 ? `${(b / 1024).toFixed(0)}KB` : `${(b / 1048576).toFixed(1)}MB`;
|
|
405
|
+
|
|
406
|
+
const copyId = () => {
|
|
407
|
+
if (!value) return;
|
|
408
|
+
navigator.clipboard.writeText(value).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<div className="flex flex-col gap-0.5">
|
|
413
|
+
<label className="text-[9px] text-gray-500">Bound Session {value ? '' : '(auto-detect on first start)'}</label>
|
|
414
|
+
<select value={value} onChange={e => onChange(e.target.value)}
|
|
415
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-gray-400 font-mono focus:outline-none focus:border-[#58a6ff]">
|
|
416
|
+
<option value="">Auto-detect (latest session)</option>
|
|
417
|
+
{sessions.map(s => (
|
|
418
|
+
<option key={s.id} value={s.id}>
|
|
419
|
+
{s.id.slice(0, 8)} · {formatTime(s.modified)} · {formatSize(s.size)}
|
|
420
|
+
</option>
|
|
421
|
+
))}
|
|
422
|
+
</select>
|
|
423
|
+
{value && (
|
|
424
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
425
|
+
<code className="text-[8px] text-gray-500 font-mono bg-[#0d1117] px-1.5 py-0.5 rounded border border-[#21262d] flex-1 overflow-hidden text-ellipsis select-all">{value}</code>
|
|
426
|
+
<button onClick={copyId} className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white shrink-0">{copied ? '✓' : 'Copy'}</button>
|
|
427
|
+
<button onClick={() => onChange('')} className="text-[8px] px-1.5 py-0.5 rounded text-gray-600 hover:text-red-400 shrink-0">Clear</button>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
374
434
|
// ─── Agent Config Modal ──────────────────────────────────
|
|
375
435
|
|
|
376
436
|
function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfirm, onCancel }: {
|
|
@@ -405,6 +465,10 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
405
465
|
(initial.steps || []).map(s => `${s.label}: ${s.prompt}`).join('\n') || ''
|
|
406
466
|
);
|
|
407
467
|
const [requiresApproval, setRequiresApproval] = useState(initial.requiresApproval || false);
|
|
468
|
+
const [isPrimary, setIsPrimary] = useState(initial.primary || false);
|
|
469
|
+
const hasPrimaryAlready = existingAgents.some(a => a.primary && a.id !== initial.id);
|
|
470
|
+
const [persistentSession, setPersistentSession] = useState(initial.persistentSession || initial.primary || false);
|
|
471
|
+
const [skipPermissions, setSkipPermissions] = useState(initial.skipPermissions !== false);
|
|
408
472
|
const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
|
|
409
473
|
const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
|
|
410
474
|
const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve' | 'send_message'>(initial.watch?.action || 'log');
|
|
@@ -580,8 +644,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
580
644
|
<div className="flex gap-2">
|
|
581
645
|
<div className="flex flex-col gap-1 w-28">
|
|
582
646
|
<label className="text-[9px] text-gray-500 uppercase">Work Dir</label>
|
|
583
|
-
<input value={workDirVal} onChange={e => setWorkDirVal(e.target.value)} placeholder={label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : 'engineer/'}
|
|
584
|
-
|
|
647
|
+
<input value={isPrimary ? './' : workDirVal} onChange={e => !isPrimary && setWorkDirVal(e.target.value)} placeholder={label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : 'engineer/'}
|
|
648
|
+
disabled={isPrimary}
|
|
649
|
+
className={`text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
|
|
585
650
|
<div className="text-[8px] text-gray-600 mt-0.5">
|
|
586
651
|
→ {'{project}/'}{(workDirVal || (label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : '')).replace(/^\.?\//, '')}
|
|
587
652
|
</div>
|
|
@@ -593,6 +658,22 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
593
658
|
</div>
|
|
594
659
|
</div>
|
|
595
660
|
|
|
661
|
+
{/* Primary Agent */}
|
|
662
|
+
<div className="flex items-center gap-2">
|
|
663
|
+
<input type="checkbox" id="primaryAgent" checked={isPrimary}
|
|
664
|
+
onChange={e => {
|
|
665
|
+
const v = e.target.checked;
|
|
666
|
+
setIsPrimary(v);
|
|
667
|
+
if (v) { setPersistentSession(true); setWorkDirVal('./'); }
|
|
668
|
+
}}
|
|
669
|
+
disabled={hasPrimaryAlready && !isPrimary}
|
|
670
|
+
className={`accent-[#f0883e] ${hasPrimaryAlready && !isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
|
|
671
|
+
<label htmlFor="primaryAgent" className={`text-[9px] ${isPrimary ? 'text-[#f0883e] font-medium' : 'text-gray-400'}`}>
|
|
672
|
+
Primary agent (terminal-only, root directory, fixed session)
|
|
673
|
+
{hasPrimaryAlready && !isPrimary && <span className="text-gray-600 ml-1">— already set on another agent</span>}
|
|
674
|
+
</label>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
596
677
|
{/* Requires Approval */}
|
|
597
678
|
<div className="flex items-center gap-2">
|
|
598
679
|
<input type="checkbox" id="requiresApproval" checked={requiresApproval} onChange={e => setRequiresApproval(e.target.checked)}
|
|
@@ -600,6 +681,25 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
600
681
|
<label htmlFor="requiresApproval" className="text-[9px] text-gray-400">Require approval before processing inbox messages</label>
|
|
601
682
|
</div>
|
|
602
683
|
|
|
684
|
+
{/* Persistent Session */}
|
|
685
|
+
<div className="flex items-center gap-2">
|
|
686
|
+
<input type="checkbox" id="persistentSession" checked={persistentSession} onChange={e => !isPrimary && setPersistentSession(e.target.checked)}
|
|
687
|
+
disabled={isPrimary}
|
|
688
|
+
className={`accent-[#3fb950] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
|
|
689
|
+
<label htmlFor="persistentSession" className={`text-[9px] text-gray-400 ${isPrimary ? 'opacity-50' : ''}`}>
|
|
690
|
+
Terminal mode {isPrimary ? '(required for primary)' : '— run in terminal instead of headless (claude -p)'}
|
|
691
|
+
</label>
|
|
692
|
+
</div>
|
|
693
|
+
{persistentSession && (
|
|
694
|
+
<div className="flex flex-col gap-1.5 ml-4">
|
|
695
|
+
<div className="flex items-center gap-2">
|
|
696
|
+
<input type="checkbox" id="skipPermissions" checked={skipPermissions} onChange={e => setSkipPermissions(e.target.checked)}
|
|
697
|
+
className="accent-[#f0883e]" />
|
|
698
|
+
<label htmlFor="skipPermissions" className="text-[9px] text-gray-400">Skip permissions (auto-approve all tool calls)</label>
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
)}
|
|
702
|
+
|
|
603
703
|
{/* Steps */}
|
|
604
704
|
<div className="flex flex-col gap-1">
|
|
605
705
|
<label className="text-[9px] text-gray-500 uppercase">Steps (one per line — Label: Prompt)</label>
|
|
@@ -760,10 +860,13 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
760
860
|
onConfirm({
|
|
761
861
|
label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
|
|
762
862
|
backend, agentId, dependsOn: Array.from(selectedDeps),
|
|
763
|
-
workDir: workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/',
|
|
863
|
+
workDir: isPrimary ? './' : (workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/'),
|
|
764
864
|
outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
|
|
765
865
|
steps: parseSteps(),
|
|
866
|
+
primary: isPrimary || undefined,
|
|
766
867
|
requiresApproval: requiresApproval || undefined,
|
|
868
|
+
persistentSession: isPrimary ? true : (persistentSession || undefined),
|
|
869
|
+
skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
|
|
767
870
|
watch: watchEnabled && watchTargets.length > 0 ? {
|
|
768
871
|
enabled: true,
|
|
769
872
|
interval: Math.max(10, parseInt(watchInterval) || 60),
|
|
@@ -1334,6 +1437,48 @@ function BusPanel({ busLog, agents, onClose }: {
|
|
|
1334
1437
|
|
|
1335
1438
|
// ─── Terminal Launch Dialog ───────────────────────────────
|
|
1336
1439
|
|
|
1440
|
+
function SessionItem({ session, formatTime, formatSize, onSelect }: {
|
|
1441
|
+
session: { id: string; modified: string; size: number };
|
|
1442
|
+
formatTime: (iso: string) => string;
|
|
1443
|
+
formatSize: (bytes: number) => string;
|
|
1444
|
+
onSelect: () => void;
|
|
1445
|
+
}) {
|
|
1446
|
+
const [expanded, setExpanded] = useState(false);
|
|
1447
|
+
const [copied, setCopied] = useState(false);
|
|
1448
|
+
|
|
1449
|
+
const copyId = (e: React.MouseEvent) => {
|
|
1450
|
+
e.stopPropagation();
|
|
1451
|
+
navigator.clipboard.writeText(session.id).then(() => {
|
|
1452
|
+
setCopied(true);
|
|
1453
|
+
setTimeout(() => setCopied(false), 1500);
|
|
1454
|
+
});
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
return (
|
|
1458
|
+
<div className="rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
|
|
1459
|
+
<div className="flex items-center gap-2 px-3 py-1.5 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
|
1460
|
+
<span className="text-[8px] text-gray-600">{expanded ? '▼' : '▶'}</span>
|
|
1461
|
+
<span className="text-[9px] text-gray-400 font-mono">{session.id.slice(0, 8)}</span>
|
|
1462
|
+
<span className="text-[8px] text-gray-600">{formatTime(session.modified)}</span>
|
|
1463
|
+
<span className="text-[8px] text-gray-600">{formatSize(session.size)}</span>
|
|
1464
|
+
<button onClick={(e) => { e.stopPropagation(); onSelect(); }}
|
|
1465
|
+
className="ml-auto text-[8px] px-1.5 py-0.5 rounded bg-[#238636]/20 text-[#3fb950] hover:bg-[#238636]/40">Resume</button>
|
|
1466
|
+
</div>
|
|
1467
|
+
{expanded && (
|
|
1468
|
+
<div className="px-3 pb-2 flex items-center gap-1.5">
|
|
1469
|
+
<code className="text-[8px] text-gray-500 font-mono bg-[#161b22] px-1.5 py-0.5 rounded border border-[#21262d] select-all flex-1 overflow-hidden text-ellipsis">
|
|
1470
|
+
{session.id}
|
|
1471
|
+
</code>
|
|
1472
|
+
<button onClick={copyId}
|
|
1473
|
+
className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white hover:bg-[#484f58] shrink-0">
|
|
1474
|
+
{copied ? '✓' : 'Copy'}
|
|
1475
|
+
</button>
|
|
1476
|
+
</div>
|
|
1477
|
+
)}
|
|
1478
|
+
</div>
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1337
1482
|
function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspaceId, supportsSession, onLaunch, onCancel }: {
|
|
1338
1483
|
agent: AgentConfig; workDir?: string; sessName: string; projectPath: string; workspaceId: string;
|
|
1339
1484
|
supportsSession?: boolean;
|
|
@@ -1349,7 +1494,7 @@ function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspace
|
|
|
1349
1494
|
if (!isClaude) return;
|
|
1350
1495
|
fetch(`/api/workspace/${workspaceId}/smith`, {
|
|
1351
1496
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1352
|
-
body: JSON.stringify({ action: 'sessions' }),
|
|
1497
|
+
body: JSON.stringify({ action: 'sessions', agentId: agent.id }),
|
|
1353
1498
|
}).then(r => r.json()).then(d => {
|
|
1354
1499
|
if (d.sessions?.length) setSessions(d.sessions);
|
|
1355
1500
|
}).catch(() => {});
|
|
@@ -1396,19 +1541,13 @@ function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspace
|
|
|
1396
1541
|
{isClaude && sessions.length > 1 && (
|
|
1397
1542
|
<button onClick={() => setShowSessions(!showSessions)}
|
|
1398
1543
|
className="w-full text-[9px] text-gray-500 hover:text-white py-1">
|
|
1399
|
-
{showSessions ? '▼' : '▶'}
|
|
1544
|
+
{showSessions ? '▼' : '▶'} All sessions ({sessions.length})
|
|
1400
1545
|
</button>
|
|
1401
1546
|
)}
|
|
1402
1547
|
|
|
1403
|
-
{showSessions && sessions.
|
|
1404
|
-
<
|
|
1405
|
-
|
|
1406
|
-
<div className="flex items-center gap-2">
|
|
1407
|
-
<span className="text-[9px] text-gray-400 font-mono">{s.id.slice(0, 8)}</span>
|
|
1408
|
-
<span className="text-[8px] text-gray-600">{formatTime(s.modified)}</span>
|
|
1409
|
-
<span className="text-[8px] text-gray-600">{formatSize(s.size)}</span>
|
|
1410
|
-
</div>
|
|
1411
|
-
</button>
|
|
1548
|
+
{showSessions && sessions.map(s => (
|
|
1549
|
+
<SessionItem key={s.id} session={s} formatTime={formatTime} formatSize={formatSize}
|
|
1550
|
+
onSelect={() => onLaunch(true, s.id)} />
|
|
1412
1551
|
))}
|
|
1413
1552
|
</div>
|
|
1414
1553
|
|
|
@@ -1430,7 +1569,217 @@ function getWsUrl() {
|
|
|
1430
1569
|
return `${p}//${h}:${port + 1}`;
|
|
1431
1570
|
}
|
|
1432
1571
|
|
|
1433
|
-
|
|
1572
|
+
// ─── Terminal Dock (right side panel with tabs) ──────────
|
|
1573
|
+
type TerminalEntry = { 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> };
|
|
1574
|
+
|
|
1575
|
+
function TerminalDock({ terminals, projectPath, workspaceId, onSessionReady, onClose }: {
|
|
1576
|
+
terminals: TerminalEntry[];
|
|
1577
|
+
projectPath: string;
|
|
1578
|
+
workspaceId: string | null;
|
|
1579
|
+
onSessionReady: (agentId: string, name: string) => void;
|
|
1580
|
+
onClose: (agentId: string) => void;
|
|
1581
|
+
}) {
|
|
1582
|
+
const [activeTab, setActiveTab] = useState(terminals[0]?.agentId || '');
|
|
1583
|
+
const [width, setWidth] = useState(520);
|
|
1584
|
+
const dragRef = useRef<{ startX: number; origW: number } | null>(null);
|
|
1585
|
+
|
|
1586
|
+
// Auto-select new tab when added
|
|
1587
|
+
useEffect(() => {
|
|
1588
|
+
if (terminals.length > 0 && !terminals.find(t => t.agentId === activeTab)) {
|
|
1589
|
+
setActiveTab(terminals[terminals.length - 1].agentId);
|
|
1590
|
+
}
|
|
1591
|
+
}, [terminals, activeTab]);
|
|
1592
|
+
|
|
1593
|
+
const active = terminals.find(t => t.agentId === activeTab);
|
|
1594
|
+
|
|
1595
|
+
return (
|
|
1596
|
+
<div className="flex shrink-0" style={{ width }}>
|
|
1597
|
+
{/* Resize handle */}
|
|
1598
|
+
<div
|
|
1599
|
+
className="w-1 cursor-col-resize hover:bg-[#58a6ff]/30 active:bg-[#58a6ff]/50 transition-colors"
|
|
1600
|
+
style={{ background: '#21262d' }}
|
|
1601
|
+
onMouseDown={(e) => {
|
|
1602
|
+
e.preventDefault();
|
|
1603
|
+
dragRef.current = { startX: e.clientX, origW: width };
|
|
1604
|
+
const onMove = (ev: MouseEvent) => {
|
|
1605
|
+
if (!dragRef.current) return;
|
|
1606
|
+
const newW = dragRef.current.origW - (ev.clientX - dragRef.current.startX);
|
|
1607
|
+
setWidth(Math.max(300, Math.min(1200, newW)));
|
|
1608
|
+
};
|
|
1609
|
+
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
1610
|
+
window.addEventListener('mousemove', onMove);
|
|
1611
|
+
window.addEventListener('mouseup', onUp);
|
|
1612
|
+
}}
|
|
1613
|
+
/>
|
|
1614
|
+
<div className="flex-1 flex flex-col min-w-0 bg-[#0d1117] border-l border-[#30363d]">
|
|
1615
|
+
{/* Tabs */}
|
|
1616
|
+
<div className="flex items-center bg-[#161b22] border-b border-[#30363d] overflow-x-auto shrink-0">
|
|
1617
|
+
{terminals.map(t => (
|
|
1618
|
+
<div
|
|
1619
|
+
key={t.agentId}
|
|
1620
|
+
onClick={() => setActiveTab(t.agentId)}
|
|
1621
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] border-r border-[#30363d] shrink-0 cursor-pointer ${
|
|
1622
|
+
t.agentId === activeTab
|
|
1623
|
+
? 'bg-[#0d1117] text-white border-b-2 border-b-[#58a6ff]'
|
|
1624
|
+
: 'text-gray-500 hover:text-gray-300 hover:bg-[#1c2128]'
|
|
1625
|
+
}`}
|
|
1626
|
+
>
|
|
1627
|
+
<span>{t.icon}</span>
|
|
1628
|
+
<span className="font-medium">{t.label}</span>
|
|
1629
|
+
<span
|
|
1630
|
+
onClick={(e) => { e.stopPropagation(); onClose(t.agentId); }}
|
|
1631
|
+
className="ml-1 text-gray-600 hover:text-red-400 text-[8px] cursor-pointer"
|
|
1632
|
+
>✕</span>
|
|
1633
|
+
</div>
|
|
1634
|
+
))}
|
|
1635
|
+
</div>
|
|
1636
|
+
{/* Active terminal */}
|
|
1637
|
+
{active && (
|
|
1638
|
+
<div className="flex-1 min-h-0" key={active.agentId}>
|
|
1639
|
+
<FloatingTerminalInline
|
|
1640
|
+
agentLabel={active.label}
|
|
1641
|
+
agentIcon={active.icon}
|
|
1642
|
+
projectPath={projectPath}
|
|
1643
|
+
agentCliId={active.cliId}
|
|
1644
|
+
cliCmd={active.cliCmd}
|
|
1645
|
+
cliType={active.cliType}
|
|
1646
|
+
workDir={active.workDir}
|
|
1647
|
+
preferredSessionName={active.sessionName}
|
|
1648
|
+
existingSession={active.tmuxSession}
|
|
1649
|
+
resumeMode={active.resumeMode}
|
|
1650
|
+
resumeSessionId={active.resumeSessionId}
|
|
1651
|
+
profileEnv={active.profileEnv}
|
|
1652
|
+
onSessionReady={(name) => onSessionReady(active.agentId, name)}
|
|
1653
|
+
/>
|
|
1654
|
+
</div>
|
|
1655
|
+
)}
|
|
1656
|
+
</div>
|
|
1657
|
+
</div>
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// ─── Inline Terminal (no drag/resize, fills parent) ──────
|
|
1662
|
+
function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, boundSessionId, onSessionReady }: {
|
|
1663
|
+
agentLabel: string;
|
|
1664
|
+
agentIcon: string;
|
|
1665
|
+
projectPath: string;
|
|
1666
|
+
agentCliId: string;
|
|
1667
|
+
cliCmd?: string;
|
|
1668
|
+
cliType?: string;
|
|
1669
|
+
workDir?: string;
|
|
1670
|
+
preferredSessionName?: string;
|
|
1671
|
+
existingSession?: string;
|
|
1672
|
+
resumeMode?: boolean;
|
|
1673
|
+
resumeSessionId?: string;
|
|
1674
|
+
profileEnv?: Record<string, string>;
|
|
1675
|
+
isPrimary?: boolean;
|
|
1676
|
+
skipPermissions?: boolean;
|
|
1677
|
+
boundSessionId?: string;
|
|
1678
|
+
onSessionReady?: (name: string) => void;
|
|
1679
|
+
}) {
|
|
1680
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1681
|
+
|
|
1682
|
+
useEffect(() => {
|
|
1683
|
+
const el = containerRef.current;
|
|
1684
|
+
if (!el) return;
|
|
1685
|
+
let disposed = false;
|
|
1686
|
+
|
|
1687
|
+
Promise.all([
|
|
1688
|
+
import('@xterm/xterm'),
|
|
1689
|
+
import('@xterm/addon-fit'),
|
|
1690
|
+
]).then(([{ Terminal }, { FitAddon }]) => {
|
|
1691
|
+
if (disposed) return;
|
|
1692
|
+
|
|
1693
|
+
const term = new Terminal({
|
|
1694
|
+
cursorBlink: true, fontSize: 13,
|
|
1695
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
1696
|
+
scrollback: 5000,
|
|
1697
|
+
theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
|
|
1698
|
+
});
|
|
1699
|
+
const fitAddon = new FitAddon();
|
|
1700
|
+
term.loadAddon(fitAddon);
|
|
1701
|
+
term.open(el);
|
|
1702
|
+
setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
|
|
1703
|
+
|
|
1704
|
+
const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
|
|
1705
|
+
ro.observe(el);
|
|
1706
|
+
|
|
1707
|
+
// Connect to terminal server
|
|
1708
|
+
const wsUrl = getWsUrl();
|
|
1709
|
+
const ws = new WebSocket(wsUrl);
|
|
1710
|
+
ws.binaryType = 'arraybuffer';
|
|
1711
|
+
const decoder = new TextDecoder();
|
|
1712
|
+
|
|
1713
|
+
ws.onopen = () => {
|
|
1714
|
+
ws.send(JSON.stringify({
|
|
1715
|
+
type: 'create',
|
|
1716
|
+
cols: term.cols, rows: term.rows,
|
|
1717
|
+
sessionName: existingSession || preferredSessionName,
|
|
1718
|
+
existingSession: existingSession || undefined,
|
|
1719
|
+
}));
|
|
1720
|
+
};
|
|
1721
|
+
ws.onmessage = async (event) => {
|
|
1722
|
+
try {
|
|
1723
|
+
const msg = JSON.parse(typeof event.data === 'string' ? event.data : decoder.decode(event.data));
|
|
1724
|
+
if (msg.type === 'data') {
|
|
1725
|
+
term.write(typeof msg.data === 'string' ? msg.data : new Uint8Array(Object.values(msg.data)));
|
|
1726
|
+
} else if (msg.type === 'created') {
|
|
1727
|
+
onSessionReady?.(msg.sessionName);
|
|
1728
|
+
// Auto-run CLI on newly created session
|
|
1729
|
+
if (!existingSession) {
|
|
1730
|
+
const cli = cliCmdProp || 'claude';
|
|
1731
|
+
const targetDir = workDir ? `${projectPath}/${workDir}` : projectPath;
|
|
1732
|
+
const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
|
|
1733
|
+
const isClaude = (cliType || 'claude-code') === 'claude-code';
|
|
1734
|
+
const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
1735
|
+
const envWithoutModel = profileEnv ? Object.fromEntries(
|
|
1736
|
+
Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
1737
|
+
) : {};
|
|
1738
|
+
// Unset old profile vars + set new ones
|
|
1739
|
+
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
1740
|
+
const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
|
|
1741
|
+
const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
|
|
1742
|
+
? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
|
|
1743
|
+
: '');
|
|
1744
|
+
// Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
|
|
1745
|
+
let resumeId = resumeSessionId || boundSessionId;
|
|
1746
|
+
if (isClaude && !resumeId && isPrimary) {
|
|
1747
|
+
try {
|
|
1748
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1749
|
+
resumeId = (await resolveFixedSession(projectPath)) || undefined;
|
|
1750
|
+
} catch {}
|
|
1751
|
+
}
|
|
1752
|
+
const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
|
|
1753
|
+
let mcpFlag = '';
|
|
1754
|
+
if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
|
|
1755
|
+
const sf = skipPermissions ? (cliType === 'codex' ? ' --full-auto' : cliType === 'aider' ? ' --yes' : ' --dangerously-skip-permissions') : '';
|
|
1756
|
+
const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
|
|
1757
|
+
setTimeout(() => {
|
|
1758
|
+
if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
1759
|
+
}, 300);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
} catch {}
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })); });
|
|
1766
|
+
term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })); });
|
|
1767
|
+
|
|
1768
|
+
return () => {
|
|
1769
|
+
disposed = true;
|
|
1770
|
+
ro.disconnect();
|
|
1771
|
+
ws.close();
|
|
1772
|
+
term.dispose();
|
|
1773
|
+
};
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
return () => { disposed = true; };
|
|
1777
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1778
|
+
|
|
1779
|
+
return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, onSessionReady, onClose }: {
|
|
1434
1783
|
agentLabel: string;
|
|
1435
1784
|
agentIcon: string;
|
|
1436
1785
|
projectPath: string;
|
|
@@ -1443,14 +1792,24 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1443
1792
|
resumeMode?: boolean;
|
|
1444
1793
|
resumeSessionId?: string;
|
|
1445
1794
|
profileEnv?: Record<string, string>;
|
|
1795
|
+
isPrimary?: boolean;
|
|
1796
|
+
skipPermissions?: boolean;
|
|
1797
|
+
persistentSession?: boolean;
|
|
1798
|
+
boundSessionId?: string;
|
|
1799
|
+
initialPos?: { x: number; y: number };
|
|
1446
1800
|
onSessionReady?: (name: string) => void;
|
|
1447
1801
|
onClose: (killSession: boolean) => void;
|
|
1448
1802
|
}) {
|
|
1449
1803
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
1450
1804
|
const wsRef = useRef<WebSocket | null>(null);
|
|
1451
1805
|
const sessionNameRef = useRef('');
|
|
1452
|
-
const [pos, setPos] = useState({ x: 80, y: 60 });
|
|
1453
|
-
const [
|
|
1806
|
+
const [pos, setPos] = useState(initialPos || { x: 80, y: 60 });
|
|
1807
|
+
const [userDragged, setUserDragged] = useState(false);
|
|
1808
|
+
// Follow node position unless user manually dragged the terminal
|
|
1809
|
+
useEffect(() => {
|
|
1810
|
+
if (initialPos && !userDragged) setPos(initialPos);
|
|
1811
|
+
}, [initialPos?.x, initialPos?.y]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1812
|
+
const [size, setSize] = useState({ w: 500, h: 300 });
|
|
1454
1813
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
|
1455
1814
|
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
1456
1815
|
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
|
@@ -1468,7 +1827,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1468
1827
|
if (disposed) return;
|
|
1469
1828
|
|
|
1470
1829
|
const term = new Terminal({
|
|
1471
|
-
cursorBlink: true, fontSize:
|
|
1830
|
+
cursorBlink: true, fontSize: 10,
|
|
1472
1831
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
1473
1832
|
scrollback: 5000,
|
|
1474
1833
|
theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
|
|
@@ -1478,7 +1837,15 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1478
1837
|
term.open(el);
|
|
1479
1838
|
setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
|
|
1480
1839
|
|
|
1481
|
-
|
|
1840
|
+
// Scale font: min 10 at small size, max 13 at large size
|
|
1841
|
+
const ro = new ResizeObserver(() => {
|
|
1842
|
+
try {
|
|
1843
|
+
const w = el.clientWidth;
|
|
1844
|
+
const newSize = Math.min(13, Math.max(10, Math.floor(w / 60)));
|
|
1845
|
+
if (term.options.fontSize !== newSize) term.options.fontSize = newSize;
|
|
1846
|
+
fitAddon.fit();
|
|
1847
|
+
} catch {}
|
|
1848
|
+
});
|
|
1482
1849
|
ro.observe(el);
|
|
1483
1850
|
|
|
1484
1851
|
// Connect WebSocket — attach to existing or create new
|
|
@@ -1501,7 +1868,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1501
1868
|
};
|
|
1502
1869
|
|
|
1503
1870
|
let launched = false;
|
|
1504
|
-
ws.onmessage = (event) => {
|
|
1871
|
+
ws.onmessage = async (event) => {
|
|
1505
1872
|
if (disposed) return;
|
|
1506
1873
|
try {
|
|
1507
1874
|
const msg = JSON.parse(event.data);
|
|
@@ -1543,18 +1910,30 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1543
1910
|
|
|
1544
1911
|
const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
|
|
1545
1912
|
const isClaude = (cliType || 'claude-code') === 'claude-code';
|
|
1546
|
-
const resumeFlag = isClaude
|
|
1547
|
-
? (resumeSessionId ? ` --resume ${resumeSessionId}` : resumeMode ? ' -c' : '')
|
|
1548
|
-
: '';
|
|
1549
1913
|
const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
1550
|
-
// Remove CLAUDE_MODEL from env exports (passed via --model flag instead)
|
|
1551
1914
|
const envWithoutModel = profileEnv ? Object.fromEntries(
|
|
1552
1915
|
Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
1553
1916
|
) : {};
|
|
1554
|
-
|
|
1917
|
+
// Unset old profile vars + set new ones (prevents leaking between agent switches)
|
|
1918
|
+
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
1919
|
+
const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
|
|
1920
|
+
const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
|
|
1555
1921
|
? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
|
|
1556
|
-
: '';
|
|
1557
|
-
|
|
1922
|
+
: '');
|
|
1923
|
+
// Primary: use fixed session. Non-primary: use explicit sessionId or -c
|
|
1924
|
+
// Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
|
|
1925
|
+
let resumeId = resumeSessionId || boundSessionId;
|
|
1926
|
+
if (isClaude && !resumeId && isPrimary) {
|
|
1927
|
+
try {
|
|
1928
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1929
|
+
resumeId = (await resolveFixedSession(projectPath)) || undefined;
|
|
1930
|
+
} catch {}
|
|
1931
|
+
}
|
|
1932
|
+
const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
|
|
1933
|
+
let mcpFlag = '';
|
|
1934
|
+
if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
|
|
1935
|
+
const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
|
|
1936
|
+
const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
|
|
1558
1937
|
setTimeout(() => {
|
|
1559
1938
|
if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
1560
1939
|
}, 300);
|
|
@@ -1591,6 +1970,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1591
1970
|
onMouseDown={(e) => {
|
|
1592
1971
|
e.preventDefault();
|
|
1593
1972
|
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
|
1973
|
+
setUserDragged(true);
|
|
1594
1974
|
const onMove = (ev: MouseEvent) => {
|
|
1595
1975
|
if (!dragRef.current) return;
|
|
1596
1976
|
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) });
|
|
@@ -1642,7 +2022,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1642
2022
|
<button onClick={() => { setShowCloseDialog(false); onClose(false); }}
|
|
1643
2023
|
className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white">
|
|
1644
2024
|
Suspend
|
|
1645
|
-
<span className="block text-[9px] text-gray-500 mt-0.5">
|
|
2025
|
+
<span className="block text-[9px] text-gray-500 mt-0.5">Hide panel, session keeps running</span>
|
|
1646
2026
|
</button>
|
|
1647
2027
|
<button onClick={() => {
|
|
1648
2028
|
setShowCloseDialog(false);
|
|
@@ -1651,9 +2031,11 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1651
2031
|
}
|
|
1652
2032
|
onClose(true);
|
|
1653
2033
|
}}
|
|
1654
|
-
className=
|
|
1655
|
-
Kill Session
|
|
1656
|
-
<span className=
|
|
2034
|
+
className={`flex-1 px-3 py-1.5 text-[11px] rounded ${persistentSession ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30' : 'bg-red-500/20 text-red-400 hover:bg-red-500/30'}`}>
|
|
2035
|
+
{persistentSession ? 'Restart Session' : 'Kill Session'}
|
|
2036
|
+
<span className={`block text-[9px] mt-0.5 ${persistentSession ? 'text-yellow-400/60' : 'text-red-400/60'}`}>
|
|
2037
|
+
{persistentSession ? 'Kill and restart with fresh env' : 'End session permanently'}
|
|
2038
|
+
</span>
|
|
1657
2039
|
</button>
|
|
1658
2040
|
</div>
|
|
1659
2041
|
<button onClick={() => setShowCloseDialog(false)}
|
|
@@ -1746,6 +2128,8 @@ interface AgentNodeData {
|
|
|
1746
2128
|
state: AgentState;
|
|
1747
2129
|
colorIdx: number;
|
|
1748
2130
|
previewLines: string[];
|
|
2131
|
+
projectPath: string;
|
|
2132
|
+
workspaceId: string | null;
|
|
1749
2133
|
onRun: () => void;
|
|
1750
2134
|
onPause: () => void;
|
|
1751
2135
|
onStop: () => void;
|
|
@@ -1758,22 +2142,26 @@ interface AgentNodeData {
|
|
|
1758
2142
|
onShowMemory: () => void;
|
|
1759
2143
|
onShowInbox: () => void;
|
|
1760
2144
|
onOpenTerminal: () => void;
|
|
2145
|
+
onSwitchSession: () => void;
|
|
1761
2146
|
inboxPending?: number;
|
|
1762
2147
|
inboxFailed?: number;
|
|
1763
2148
|
[key: string]: unknown;
|
|
1764
2149
|
}
|
|
1765
2150
|
|
|
2151
|
+
// PortalTerminal/NodeTerminal removed — xterm cannot render inside React Flow nodes
|
|
2152
|
+
// and createPortal causes event routing issues. Using FloatingTerminal instead.
|
|
2153
|
+
|
|
1766
2154
|
function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
1767
|
-
const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, inboxPending = 0, inboxFailed = 0 } = data;
|
|
2155
|
+
const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
|
|
1768
2156
|
const c = COLORS[colorIdx % COLORS.length];
|
|
1769
2157
|
const smithStatus = state?.smithStatus || 'down';
|
|
1770
2158
|
const taskStatus = state?.taskStatus || 'idle';
|
|
1771
|
-
const
|
|
2159
|
+
const hasTmux = !!state?.tmuxSession;
|
|
1772
2160
|
const smithInfo = SMITH_STATUS[smithStatus] || SMITH_STATUS.down;
|
|
1773
2161
|
const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
|
|
1774
2162
|
const currentStep = state?.currentStep;
|
|
1775
2163
|
const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
|
|
1776
|
-
const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
|
|
2164
|
+
const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
|
|
1777
2165
|
|
|
1778
2166
|
return (
|
|
1779
2167
|
<div className="w-52 flex flex-col rounded-lg select-none"
|
|
@@ -1782,6 +2170,9 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
1782
2170
|
<Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
1783
2171
|
<Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
1784
2172
|
|
|
2173
|
+
{/* Primary badge */}
|
|
2174
|
+
{config.primary && <div className="bg-[#f0883e]/20 text-[#f0883e] text-[7px] font-bold text-center py-0.5 rounded-t-lg">PRIMARY</div>}
|
|
2175
|
+
|
|
1785
2176
|
{/* Header */}
|
|
1786
2177
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
1787
2178
|
<span className="text-sm">{config.icon}</span>
|
|
@@ -1789,15 +2180,23 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
1789
2180
|
<div className="text-xs font-semibold text-white truncate">{config.label}</div>
|
|
1790
2181
|
<div className="text-[8px]" style={{ color: c.accent }}>{config.backend === 'api' ? config.provider || 'api' : config.agentId || 'cli'}</div>
|
|
1791
2182
|
</div>
|
|
1792
|
-
{/* Status: smith +
|
|
2183
|
+
{/* Status: smith + terminal + task */}
|
|
1793
2184
|
<div className="flex flex-col items-end gap-0.5">
|
|
1794
2185
|
<div className="flex items-center gap-1">
|
|
1795
2186
|
<div className="w-1.5 h-1.5 rounded-full" style={{ background: smithInfo.color, boxShadow: smithInfo.glow ? `0 0 4px ${smithInfo.color}` : 'none' }} />
|
|
1796
2187
|
<span className="text-[7px]" style={{ color: smithInfo.color }}>{smithInfo.label}</span>
|
|
1797
2188
|
</div>
|
|
1798
2189
|
<div className="flex items-center gap-1">
|
|
1799
|
-
|
|
1800
|
-
|
|
2190
|
+
{(() => {
|
|
2191
|
+
// Execution mode is determined by config, not tmux state
|
|
2192
|
+
const isTerminalMode = config.persistentSession;
|
|
2193
|
+
const color = isTerminalMode ? (hasTmux ? '#3fb950' : '#f0883e') : '#484f58';
|
|
2194
|
+
const label = isTerminalMode ? (hasTmux ? 'terminal' : 'terminal (down)') : 'headless';
|
|
2195
|
+
return (<>
|
|
2196
|
+
<div className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
|
|
2197
|
+
<span className="text-[7px] font-medium" style={{ color }}>{label}</span>
|
|
2198
|
+
</>);
|
|
2199
|
+
})()}
|
|
1801
2200
|
</div>
|
|
1802
2201
|
<div className="flex items-center gap-1">
|
|
1803
2202
|
<div className="w-1.5 h-1.5 rounded-full" style={{ background: taskInfo.color, boxShadow: taskInfo.glow ? `0 0 4px ${taskInfo.color}` : 'none' }} />
|
|
@@ -1861,10 +2260,15 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
1861
2260
|
className="text-[9px] px-1.5 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30">💬 Message</button>
|
|
1862
2261
|
)}
|
|
1863
2262
|
<div className="flex-1" />
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2263
|
+
<span className="flex items-center">
|
|
2264
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
|
|
2265
|
+
className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
|
|
2266
|
+
title="Open terminal">⌨️</button>
|
|
2267
|
+
{hasTmux && !config.primary && (
|
|
2268
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
|
|
2269
|
+
className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">▾</button>
|
|
2270
|
+
)}
|
|
2271
|
+
</span>
|
|
1868
2272
|
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
|
|
1869
2273
|
className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
|
|
1870
2274
|
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
|
|
@@ -1904,8 +2308,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
1904
2308
|
const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
|
|
1905
2309
|
const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
|
|
1906
2310
|
const [showBusPanel, setShowBusPanel] = useState(false);
|
|
1907
|
-
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
|
|
1908
|
-
const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
|
|
2311
|
+
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>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
|
|
2312
|
+
const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean; initialPos?: { x: number; y: number } } | null>(null);
|
|
1909
2313
|
|
|
1910
2314
|
// Expose focusAgent to parent
|
|
1911
2315
|
useImperativeHandle(ref, () => ({
|
|
@@ -1938,27 +2342,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
1938
2342
|
}
|
|
1939
2343
|
});
|
|
1940
2344
|
|
|
1941
|
-
// Auto-open
|
|
1942
|
-
|
|
1943
|
-
useEffect(() => {
|
|
1944
|
-
if (autoOpenDone.current || agents.length === 0 || Object.keys(states).length === 0) return;
|
|
1945
|
-
autoOpenDone.current = true;
|
|
1946
|
-
const manualAgents = agents.filter(a =>
|
|
1947
|
-
a.type !== 'input' && states[a.id]?.mode === 'manual' && states[a.id]?.tmuxSession
|
|
1948
|
-
);
|
|
1949
|
-
if (manualAgents.length > 0) {
|
|
1950
|
-
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
1951
|
-
setFloatingTerminals(manualAgents.map(a => ({
|
|
1952
|
-
agentId: a.id,
|
|
1953
|
-
label: a.label,
|
|
1954
|
-
icon: a.icon,
|
|
1955
|
-
cliId: a.agentId || 'claude',
|
|
1956
|
-
workDir: a.workDir && a.workDir !== './' && a.workDir !== '.' ? a.workDir : undefined,
|
|
1957
|
-
tmuxSession: states[a.id].tmuxSession,
|
|
1958
|
-
sessionName: `mw-forge-${safeName(projectName)}-${safeName(a.label)}`,
|
|
1959
|
-
})));
|
|
1960
|
-
}
|
|
1961
|
-
}, [agents, states]);
|
|
2345
|
+
// Auto-open terminals removed — persistent sessions run in background tmux.
|
|
2346
|
+
// User opens terminal via ⌨️ button when needed.
|
|
1962
2347
|
|
|
1963
2348
|
// Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
|
|
1964
2349
|
useEffect(() => {
|
|
@@ -1980,7 +2365,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
1980
2365
|
type: 'input' as const,
|
|
1981
2366
|
data: {
|
|
1982
2367
|
config: agent,
|
|
1983
|
-
state: states[agent.id] || { smithStatus: 'down',
|
|
2368
|
+
state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
|
|
1984
2369
|
onSubmit: (content: string) => {
|
|
1985
2370
|
// Optimistic update
|
|
1986
2371
|
wsApi(workspaceId!, 'complete_input', { agentId: agent.id, content });
|
|
@@ -2000,9 +2385,11 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2000
2385
|
type: 'agent' as const,
|
|
2001
2386
|
data: {
|
|
2002
2387
|
config: agent,
|
|
2003
|
-
state: states[agent.id] || { smithStatus: 'down',
|
|
2388
|
+
state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
|
|
2004
2389
|
colorIdx: i,
|
|
2005
2390
|
previewLines: logPreview[agent.id] || [],
|
|
2391
|
+
projectPath,
|
|
2392
|
+
workspaceId,
|
|
2006
2393
|
onRun: () => {
|
|
2007
2394
|
wsApi(workspaceId!, 'run', { agentId: agent.id });
|
|
2008
2395
|
},
|
|
@@ -2023,36 +2410,91 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2023
2410
|
inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
|
|
2024
2411
|
onOpenTerminal: async () => {
|
|
2025
2412
|
if (!workspaceId) return;
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2413
|
+
if (!daemonActiveFromStream) {
|
|
2414
|
+
alert('Start daemon first before opening terminal.');
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
// Close existing terminal (config may have changed)
|
|
2418
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
|
|
2419
|
+
|
|
2420
|
+
// Get node screen position for initial terminal placement
|
|
2421
|
+
const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
|
|
2422
|
+
const nodeRect = nodeEl?.getBoundingClientRect();
|
|
2423
|
+
const initialPos = nodeRect
|
|
2424
|
+
? { x: nodeRect.left, y: nodeRect.bottom + 4 }
|
|
2425
|
+
: { x: 80, y: 60 };
|
|
2030
2426
|
|
|
2031
2427
|
const agentState = states[agent.id];
|
|
2032
2428
|
const existingTmux = agentState?.tmuxSession;
|
|
2033
|
-
|
|
2034
|
-
// Build fixed session name: mw-forge-{project}-{agentLabel}
|
|
2035
2429
|
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2036
2430
|
const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
|
|
2037
|
-
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.'
|
|
2038
|
-
|
|
2431
|
+
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
|
|
2432
|
+
|
|
2433
|
+
// Always resolve launch info for this agent (cliCmd, env, model)
|
|
2434
|
+
const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
|
|
2435
|
+
const launchInfo = {
|
|
2436
|
+
cliCmd: resolveRes?.cliCmd || 'claude',
|
|
2437
|
+
cliType: resolveRes?.cliType || 'claude-code',
|
|
2438
|
+
profileEnv: {
|
|
2439
|
+
...(resolveRes?.env || {}),
|
|
2440
|
+
...(resolveRes?.model ? { CLAUDE_MODEL: resolveRes.model } : {}),
|
|
2441
|
+
FORGE_AGENT_ID: agent.id,
|
|
2442
|
+
FORGE_WORKSPACE_ID: workspaceId!,
|
|
2443
|
+
FORGE_PORT: String(window.location.port || 8403),
|
|
2444
|
+
},
|
|
2445
|
+
};
|
|
2039
2446
|
|
|
2040
|
-
// If
|
|
2041
|
-
if (
|
|
2447
|
+
// If tmux session exists → attach (primary or non-primary)
|
|
2448
|
+
if (existingTmux) {
|
|
2449
|
+
wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
|
|
2042
2450
|
setFloatingTerminals(prev => [...prev, {
|
|
2043
2451
|
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2044
|
-
cliId: agent.agentId || 'claude', workDir,
|
|
2452
|
+
cliId: agent.agentId || 'claude', ...launchInfo, workDir,
|
|
2045
2453
|
tmuxSession: existingTmux, sessionName: sessName,
|
|
2454
|
+
isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
|
|
2046
2455
|
}]);
|
|
2047
2456
|
return;
|
|
2048
2457
|
}
|
|
2049
2458
|
|
|
2050
|
-
//
|
|
2051
|
-
|
|
2052
|
-
|
|
2459
|
+
// Primary without session → open directly (no dialog)
|
|
2460
|
+
if (agent.primary) {
|
|
2461
|
+
const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
|
|
2462
|
+
setFloatingTerminals(prev => [...prev, {
|
|
2463
|
+
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2464
|
+
cliId: agent.agentId || 'claude', ...launchInfo, workDir,
|
|
2465
|
+
tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
|
|
2466
|
+
isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
|
|
2467
|
+
}]);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2053
2470
|
|
|
2054
|
-
//
|
|
2055
|
-
|
|
2471
|
+
// Non-primary: has boundSessionId → use it directly; no bound → show dialog
|
|
2472
|
+
if (agent.boundSessionId) {
|
|
2473
|
+
const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
|
|
2474
|
+
setFloatingTerminals(prev => [...prev, {
|
|
2475
|
+
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2476
|
+
cliId: agent.agentId || 'claude', ...launchInfo, workDir,
|
|
2477
|
+
tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
|
|
2478
|
+
resumeSessionId: agent.boundSessionId,
|
|
2479
|
+
isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
|
|
2480
|
+
}]);
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
// No bound session → show launch dialog (New / Resume / Select)
|
|
2484
|
+
setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos });
|
|
2485
|
+
},
|
|
2486
|
+
onSwitchSession: async () => {
|
|
2487
|
+
if (!workspaceId) return;
|
|
2488
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
|
|
2489
|
+
if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
|
|
2490
|
+
const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
|
|
2491
|
+
const nodeRect = nodeEl?.getBoundingClientRect();
|
|
2492
|
+
const switchPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
|
|
2493
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2494
|
+
const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
|
|
2495
|
+
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
|
|
2496
|
+
const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
|
|
2497
|
+
setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos: switchPos });
|
|
2056
2498
|
},
|
|
2057
2499
|
} satisfies AgentNodeData,
|
|
2058
2500
|
};
|
|
@@ -2244,8 +2686,22 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2244
2686
|
{agents.length === 0 ? (
|
|
2245
2687
|
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
|
2246
2688
|
<span className="text-3xl">🚀</span>
|
|
2247
|
-
<div className="text-sm text-gray-400">
|
|
2248
|
-
|
|
2689
|
+
<div className="text-sm text-gray-400">Set up your workspace</div>
|
|
2690
|
+
{/* Primary agent prompt */}
|
|
2691
|
+
<button onClick={() => setModal({ mode: 'add', initial: {
|
|
2692
|
+
label: 'Engineer', icon: '👨💻', primary: true, persistentSession: true,
|
|
2693
|
+
role: 'Primary engineer — handles coding tasks in the project root.',
|
|
2694
|
+
backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
|
|
2695
|
+
}})}
|
|
2696
|
+
className="flex items-center gap-3 px-5 py-3 rounded-lg border-2 border-dashed border-[#f0883e]/50 bg-[#f0883e]/5 hover:bg-[#f0883e]/10 hover:border-[#f0883e]/80 transition-colors">
|
|
2697
|
+
<span className="text-2xl">👨💻</span>
|
|
2698
|
+
<div className="text-left">
|
|
2699
|
+
<div className="text-[11px] font-semibold text-[#f0883e]">Add Primary Agent</div>
|
|
2700
|
+
<div className="text-[9px] text-gray-500">Terminal-only, root directory, fixed session</div>
|
|
2701
|
+
</div>
|
|
2702
|
+
</button>
|
|
2703
|
+
<div className="text-[9px] text-gray-600 mt-1">or add other agents:</div>
|
|
2704
|
+
<div className="flex gap-2 flex-wrap justify-center">
|
|
2249
2705
|
{PRESET_AGENTS.map((p, i) => (
|
|
2250
2706
|
<button key={i} onClick={() => setModal({ mode: 'add', initial: p })}
|
|
2251
2707
|
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">
|
|
@@ -2273,11 +2729,40 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2273
2729
|
</div>
|
|
2274
2730
|
</div>
|
|
2275
2731
|
) : (
|
|
2276
|
-
<div className="flex-1 min-h-0">
|
|
2732
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
2733
|
+
{/* No primary agent hint */}
|
|
2734
|
+
{!agents.some(a => a.primary) && (
|
|
2735
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-[#f0883e]/10 border-b border-[#f0883e]/20 shrink-0">
|
|
2736
|
+
<span className="text-[10px] text-[#f0883e]">No primary agent set.</span>
|
|
2737
|
+
<button onClick={() => setModal({ mode: 'add', initial: {
|
|
2738
|
+
label: 'Engineer', icon: '👨💻', primary: true, persistentSession: true,
|
|
2739
|
+
role: 'Primary engineer — handles coding tasks in the project root.',
|
|
2740
|
+
backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
|
|
2741
|
+
}})}
|
|
2742
|
+
className="text-[10px] text-[#f0883e] underline hover:text-white">Add one</button>
|
|
2743
|
+
<span className="text-[9px] text-gray-600">or edit an existing agent to set as primary.</span>
|
|
2744
|
+
</div>
|
|
2745
|
+
)}
|
|
2277
2746
|
<ReactFlow
|
|
2278
2747
|
nodes={rfNodes}
|
|
2279
2748
|
edges={rfEdges}
|
|
2280
2749
|
onNodesChange={onNodesChange}
|
|
2750
|
+
onNodeDragStop={() => {
|
|
2751
|
+
// Reposition terminals to follow their nodes
|
|
2752
|
+
setFloatingTerminals(prev => prev.map(ft => {
|
|
2753
|
+
const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
|
|
2754
|
+
const rect = nodeEl?.getBoundingClientRect();
|
|
2755
|
+
return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
|
|
2756
|
+
}));
|
|
2757
|
+
}}
|
|
2758
|
+
onMoveEnd={() => {
|
|
2759
|
+
// Reposition after pan/zoom
|
|
2760
|
+
setFloatingTerminals(prev => prev.map(ft => {
|
|
2761
|
+
const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
|
|
2762
|
+
const rect = nodeEl?.getBoundingClientRect();
|
|
2763
|
+
return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
|
|
2764
|
+
}));
|
|
2765
|
+
}}
|
|
2281
2766
|
nodeTypes={nodeTypes}
|
|
2282
2767
|
fitView
|
|
2283
2768
|
fitViewOptions={{ padding: 0.3 }}
|
|
@@ -2378,13 +2863,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2378
2863
|
setTermLaunchDialog(null);
|
|
2379
2864
|
const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
|
|
2380
2865
|
if (res.ok) {
|
|
2866
|
+
// Save selected session as boundSessionId if user chose a specific one
|
|
2867
|
+
if (sessionId) {
|
|
2868
|
+
wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
|
|
2869
|
+
}
|
|
2381
2870
|
setFloatingTerminals(prev => [...prev, {
|
|
2382
2871
|
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2383
2872
|
cliId: agent.agentId || 'claude',
|
|
2384
2873
|
cliCmd: res.cliCmd || 'claude',
|
|
2385
2874
|
cliType: res.cliType || 'claude-code',
|
|
2386
2875
|
workDir,
|
|
2387
|
-
sessionName: sessName, resumeMode, resumeSessionId: sessionId,
|
|
2876
|
+
sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
|
|
2388
2877
|
profileEnv: {
|
|
2389
2878
|
...(res.env || {}),
|
|
2390
2879
|
...(res.model ? { CLAUDE_MODEL: res.model } : {}),
|
|
@@ -2399,7 +2888,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2399
2888
|
/>
|
|
2400
2889
|
)}
|
|
2401
2890
|
|
|
2402
|
-
{/* Floating terminals
|
|
2891
|
+
{/* Floating terminals — positioned near their agent node */}
|
|
2403
2892
|
{floatingTerminals.map(ft => (
|
|
2404
2893
|
<FloatingTerminal
|
|
2405
2894
|
key={ft.agentId}
|
|
@@ -2415,17 +2904,18 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2415
2904
|
resumeMode={ft.resumeMode}
|
|
2416
2905
|
resumeSessionId={ft.resumeSessionId}
|
|
2417
2906
|
profileEnv={ft.profileEnv}
|
|
2907
|
+
isPrimary={ft.isPrimary}
|
|
2908
|
+
skipPermissions={ft.skipPermissions}
|
|
2909
|
+
persistentSession={ft.persistentSession}
|
|
2910
|
+
boundSessionId={ft.boundSessionId}
|
|
2911
|
+
initialPos={ft.initialPos}
|
|
2418
2912
|
onSessionReady={(name) => {
|
|
2419
|
-
if (workspaceId) {
|
|
2420
|
-
wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
|
|
2421
|
-
}
|
|
2913
|
+
if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
|
|
2422
2914
|
setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
|
|
2423
2915
|
}}
|
|
2424
|
-
onClose={() => {
|
|
2916
|
+
onClose={(killSession) => {
|
|
2425
2917
|
setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
|
|
2426
|
-
if (workspaceId) {
|
|
2427
|
-
wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId });
|
|
2428
|
-
}
|
|
2918
|
+
if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
|
|
2429
2919
|
}}
|
|
2430
2920
|
/>
|
|
2431
2921
|
))}
|