@aion0/forge 0.4.15 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/RELEASE_NOTES.md +170 -13
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +23 -4
- package/check-forge-status.sh +9 -0
- package/cli/mw.ts +2 -2
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +12 -4
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +13 -8
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +34 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +259 -45
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2224 -0
- package/docs/LOCAL-DEPLOY.md +15 -15
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/cloudflared.ts +1 -1
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +8 -2
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +95 -6
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +204 -0
- package/lib/help-docs/CLAUDE.md +5 -2
- package/lib/init.ts +62 -12
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/telegram-standalone.ts +1 -1
- package/lib/terminal-server.ts +2 -2
- package/lib/terminal-standalone.ts +1 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1804 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +790 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +5 -2
- package/src/config/index.ts +13 -2
- package/src/core/db/database.ts +1 -0
- package/start.sh +10 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useCallback, memo } from 'react';
|
|
3
|
+
import React, { useState, useEffect, useCallback, useRef, memo, lazy, Suspense } from 'react';
|
|
4
4
|
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
5
5
|
|
|
6
|
+
const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
|
|
7
|
+
const WorkspaceViewLazy = lazy(() => import('./WorkspaceView'));
|
|
8
|
+
const SessionViewLazy = lazy(() => import('./SessionView'));
|
|
9
|
+
|
|
6
10
|
// ─── Syntax highlighting ─────────────────────────────────
|
|
7
11
|
const KEYWORDS = new Set([
|
|
8
12
|
'import', 'export', 'from', 'const', 'let', 'var', 'function', 'return',
|
|
@@ -72,11 +76,14 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
72
76
|
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
73
77
|
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
74
78
|
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
75
|
-
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
79
|
+
const [projectTab, setProjectTab] = useState<'workspace' | 'sessions' | 'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
80
|
+
const wsViewRef = useRef<import('./WorkspaceView').WorkspaceViewHandle>(null);
|
|
76
81
|
// Pipeline bindings state
|
|
77
82
|
const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
|
|
78
83
|
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; dedupKey: string | null; createdAt: string }[]>([]);
|
|
79
|
-
const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean }[]>([]);
|
|
84
|
+
const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean; type?: string }[]>([]);
|
|
85
|
+
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
|
86
|
+
const [expandedPipeline, setExpandedPipeline] = useState<any>(null);
|
|
80
87
|
const [showAddPipeline, setShowAddPipeline] = useState(false);
|
|
81
88
|
const [triggerInput, setTriggerInput] = useState<Record<string, string>>({});
|
|
82
89
|
const [runMenu, setRunMenu] = useState<string | null>(null); // workflowName of open run menu
|
|
@@ -403,6 +410,15 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
403
410
|
if (projectTab === 'claudemd') fetchClaudeMd();
|
|
404
411
|
}, [projectTab, fetchProjectSkills, fetchPipelineBindings, fetchClaudeMd]);
|
|
405
412
|
|
|
413
|
+
// Auto-refresh pipeline runs while any is running
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
if (projectTab !== 'pipelines') return;
|
|
416
|
+
const hasRunning = pipelineRuns.some(r => r.status === 'running');
|
|
417
|
+
if (!hasRunning) return;
|
|
418
|
+
const timer = setInterval(fetchPipelineBindings, 4000);
|
|
419
|
+
return () => clearInterval(timer);
|
|
420
|
+
}, [projectTab, pipelineRuns, fetchPipelineBindings]);
|
|
421
|
+
|
|
406
422
|
return (
|
|
407
423
|
<>
|
|
408
424
|
{/* Project header */}
|
|
@@ -416,17 +432,8 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
416
432
|
{gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
|
|
417
433
|
{/* Action buttons */}
|
|
418
434
|
<div className="flex items-center gap-1.5 ml-auto">
|
|
419
|
-
{/* Open Terminal */}
|
|
420
|
-
<
|
|
421
|
-
onClick={() => {
|
|
422
|
-
const event = new CustomEvent('forge:open-terminal', { detail: { projectPath, projectName } });
|
|
423
|
-
window.dispatchEvent(event);
|
|
424
|
-
}}
|
|
425
|
-
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
426
|
-
title="Open terminal with claude -c"
|
|
427
|
-
>
|
|
428
|
-
Terminal
|
|
429
|
-
</button>
|
|
435
|
+
{/* Open Terminal with agent selection */}
|
|
436
|
+
<AgentTerminalButton projectPath={projectPath} projectName={projectName} />
|
|
430
437
|
<button
|
|
431
438
|
onClick={() => { fetchGitInfo(); fetchTree(); if (selectedFile) openFile(selectedFile); }}
|
|
432
439
|
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
@@ -451,6 +458,18 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
451
458
|
projectTab === 'code' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
452
459
|
}`}
|
|
453
460
|
>Code</button>
|
|
461
|
+
<button
|
|
462
|
+
onClick={() => setProjectTab('workspace')}
|
|
463
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
464
|
+
projectTab === 'workspace' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
465
|
+
}`}
|
|
466
|
+
>🔨 Workspace</button>
|
|
467
|
+
<button
|
|
468
|
+
onClick={() => setProjectTab('sessions')}
|
|
469
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
470
|
+
projectTab === 'sessions' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
471
|
+
}`}
|
|
472
|
+
>Sessions</button>
|
|
454
473
|
<button
|
|
455
474
|
onClick={() => setProjectTab('skills')}
|
|
456
475
|
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
@@ -508,6 +527,33 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
508
527
|
</div>
|
|
509
528
|
)}
|
|
510
529
|
|
|
530
|
+
{/* Workspace tab */}
|
|
531
|
+
{projectTab === 'workspace' && (
|
|
532
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
533
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
534
|
+
<WorkspaceViewLazy
|
|
535
|
+
ref={wsViewRef}
|
|
536
|
+
projectPath={projectPath}
|
|
537
|
+
projectName={projectName}
|
|
538
|
+
onClose={() => setProjectTab('code')}
|
|
539
|
+
/>
|
|
540
|
+
</Suspense>
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
543
|
+
|
|
544
|
+
{/* Sessions tab */}
|
|
545
|
+
{projectTab === 'sessions' && (
|
|
546
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
547
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
548
|
+
<SessionViewLazy
|
|
549
|
+
projectName={projectName}
|
|
550
|
+
projects={[{ name: projectName, path: projectPath, language: null }]}
|
|
551
|
+
singleProject
|
|
552
|
+
/>
|
|
553
|
+
</Suspense>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
|
|
511
557
|
{/* Code content area */}
|
|
512
558
|
{projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
|
|
513
559
|
{/* File tree */}
|
|
@@ -1056,60 +1102,101 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
1056
1102
|
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Execution History</div>
|
|
1057
1103
|
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
1058
1104
|
{pipelineRuns.map(run => (
|
|
1059
|
-
<div key={run.id} className="
|
|
1060
|
-
<
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
<div className="flex
|
|
1065
|
-
<
|
|
1066
|
-
|
|
1067
|
-
|
|
1105
|
+
<div key={run.id} className="border-b border-[var(--border)]/30 last:border-b-0">
|
|
1106
|
+
<div className="flex items-start gap-2 px-3 py-2 text-[10px]">
|
|
1107
|
+
<span className={`shrink-0 mt-0.5 ${
|
|
1108
|
+
run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : run.status === 'skipped' ? 'text-gray-400' : 'text-yellow-400'
|
|
1109
|
+
}`}>{run.status === 'running' ? '●' : '●'}</span>
|
|
1110
|
+
<div className="flex-1 min-w-0">
|
|
1111
|
+
<div className="flex items-center gap-2">
|
|
1112
|
+
<span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
|
|
1113
|
+
{run.dedupKey && (
|
|
1114
|
+
<span className="text-[8px] text-[var(--accent)] font-mono">{run.dedupKey.replace('issue:', '#')}</span>
|
|
1115
|
+
)}
|
|
1116
|
+
<button
|
|
1117
|
+
onClick={async () => {
|
|
1118
|
+
if (expandedRunId === run.pipelineId) {
|
|
1119
|
+
setExpandedRunId(null);
|
|
1120
|
+
setExpandedPipeline(null);
|
|
1121
|
+
} else {
|
|
1122
|
+
setExpandedRunId(run.pipelineId);
|
|
1123
|
+
const res = await fetch(`/api/pipelines/${run.pipelineId}`);
|
|
1124
|
+
if (res.ok) setExpandedPipeline(await res.json());
|
|
1125
|
+
}
|
|
1126
|
+
}}
|
|
1127
|
+
className={`text-[8px] font-mono hover:underline ${expandedRunId === run.pipelineId ? 'text-[var(--accent)] font-bold' : 'text-[var(--accent)]'}`}
|
|
1128
|
+
title="Expand / View in Pipelines"
|
|
1129
|
+
>{run.status === 'running' ? '▾ ' : ''}{run.pipelineId.slice(0, 8)}</button>
|
|
1130
|
+
<button
|
|
1131
|
+
onClick={() => window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: run.pipelineId } }))}
|
|
1132
|
+
className="text-[7px] text-[var(--text-secondary)] hover:text-[var(--accent)]"
|
|
1133
|
+
title="Open in Pipeline page"
|
|
1134
|
+
>↗</button>
|
|
1135
|
+
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">{new Date(run.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
|
1136
|
+
</div>
|
|
1137
|
+
{!expandedRunId && run.summary && (
|
|
1138
|
+
<pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
|
|
1068
1139
|
)}
|
|
1069
|
-
<button
|
|
1070
|
-
onClick={() => window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: run.pipelineId } }))}
|
|
1071
|
-
className="text-[8px] text-[var(--accent)] font-mono hover:underline"
|
|
1072
|
-
title="View in Pipelines"
|
|
1073
|
-
>{run.pipelineId.slice(0, 8)}</button>
|
|
1074
|
-
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">{new Date(run.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
|
1075
1140
|
</div>
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1141
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
1142
|
+
{run.status === 'running' && (
|
|
1143
|
+
<button
|
|
1144
|
+
onClick={async () => {
|
|
1145
|
+
await fetch(`/api/pipelines/${run.pipelineId}`, {
|
|
1146
|
+
method: 'POST',
|
|
1147
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1148
|
+
body: JSON.stringify({ action: 'cancel' }),
|
|
1149
|
+
});
|
|
1150
|
+
fetchPipelineBindings();
|
|
1151
|
+
}}
|
|
1152
|
+
className="text-[8px] text-red-400 hover:underline"
|
|
1153
|
+
>Cancel</button>
|
|
1154
|
+
)}
|
|
1155
|
+
{run.status === 'failed' && run.dedupKey && (
|
|
1156
|
+
<button
|
|
1157
|
+
onClick={async () => {
|
|
1158
|
+
await fetch('/api/project-pipelines', {
|
|
1159
|
+
method: 'POST',
|
|
1160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1161
|
+
body: JSON.stringify({ action: 'reset-dedup', projectPath, workflowName: run.workflowName, dedupKey: run.dedupKey }),
|
|
1162
|
+
});
|
|
1163
|
+
await fetch('/api/project-pipelines', {
|
|
1164
|
+
method: 'POST',
|
|
1165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1166
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1167
|
+
});
|
|
1168
|
+
fetchPipelineBindings();
|
|
1169
|
+
}}
|
|
1170
|
+
className="text-[8px] text-[var(--accent)] hover:underline"
|
|
1171
|
+
>Retry</button>
|
|
1172
|
+
)}
|
|
1082
1173
|
<button
|
|
1083
1174
|
onClick={async () => {
|
|
1084
|
-
|
|
1085
|
-
method: 'POST',
|
|
1086
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1087
|
-
body: JSON.stringify({ action: 'reset-dedup', projectPath, workflowName: run.workflowName, dedupKey: run.dedupKey }),
|
|
1088
|
-
});
|
|
1089
|
-
// Delete the failed run then re-scan
|
|
1175
|
+
if (!confirm('Delete this run?')) return;
|
|
1090
1176
|
await fetch('/api/project-pipelines', {
|
|
1091
1177
|
method: 'POST',
|
|
1092
1178
|
headers: { 'Content-Type': 'application/json' },
|
|
1093
1179
|
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1094
1180
|
});
|
|
1181
|
+
if (expandedRunId === run.pipelineId) { setExpandedRunId(null); setExpandedPipeline(null); }
|
|
1095
1182
|
fetchPipelineBindings();
|
|
1096
1183
|
}}
|
|
1097
|
-
className="text-[8px] text-[var(--
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
<button
|
|
1101
|
-
onClick={async () => {
|
|
1102
|
-
if (!confirm('Delete this run?')) return;
|
|
1103
|
-
await fetch('/api/project-pipelines', {
|
|
1104
|
-
method: 'POST',
|
|
1105
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1106
|
-
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1107
|
-
});
|
|
1108
|
-
fetchPipelineBindings();
|
|
1109
|
-
}}
|
|
1110
|
-
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
1111
|
-
>×</button>
|
|
1184
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
1185
|
+
>×</button>
|
|
1186
|
+
</div>
|
|
1112
1187
|
</div>
|
|
1188
|
+
{/* Expanded inline pipeline view */}
|
|
1189
|
+
{expandedRunId === run.pipelineId && expandedPipeline && (
|
|
1190
|
+
<Suspense fallback={<div className="p-2 text-[9px] text-[var(--text-secondary)]">Loading...</div>}>
|
|
1191
|
+
<InlinePipelineView
|
|
1192
|
+
pipeline={expandedPipeline}
|
|
1193
|
+
onRefresh={async () => {
|
|
1194
|
+
const res = await fetch(`/api/pipelines/${run.pipelineId}`);
|
|
1195
|
+
if (res.ok) setExpandedPipeline(await res.json());
|
|
1196
|
+
}}
|
|
1197
|
+
/>
|
|
1198
|
+
</Suspense>
|
|
1199
|
+
)}
|
|
1113
1200
|
</div>
|
|
1114
1201
|
))}
|
|
1115
1202
|
</div>
|
|
@@ -1239,3 +1326,174 @@ const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelec
|
|
|
1239
1326
|
</button>
|
|
1240
1327
|
);
|
|
1241
1328
|
});
|
|
1329
|
+
|
|
1330
|
+
// ─── Agent Terminal Button ───────────────────────────────
|
|
1331
|
+
|
|
1332
|
+
function AgentTerminalButton({ projectPath, projectName }: { projectPath: string; projectName: string }) {
|
|
1333
|
+
const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean; isProfile?: boolean; base?: string; backendType?: string; env?: Record<string, string>; model?: string }[]>([]);
|
|
1334
|
+
const [showMenu, setShowMenu] = useState(false);
|
|
1335
|
+
const [launchDialog, setLaunchDialog] = useState<{ agentId: string; agentName: string; env?: Record<string, string>; model?: string } | null>(null);
|
|
1336
|
+
const [sessions, setSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
|
|
1337
|
+
const [showSessions, setShowSessions] = useState(false);
|
|
1338
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
1339
|
+
|
|
1340
|
+
useEffect(() => {
|
|
1341
|
+
fetch('/api/agents').then(r => r.json())
|
|
1342
|
+
.then(d => setAgents(d.agents || []))
|
|
1343
|
+
.catch(() => {});
|
|
1344
|
+
}, []);
|
|
1345
|
+
|
|
1346
|
+
useEffect(() => {
|
|
1347
|
+
if (!showMenu) return;
|
|
1348
|
+
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as globalThis.Node)) setShowMenu(false); };
|
|
1349
|
+
document.addEventListener('mousedown', h);
|
|
1350
|
+
return () => document.removeEventListener('mousedown', h);
|
|
1351
|
+
}, [showMenu]);
|
|
1352
|
+
|
|
1353
|
+
// Fetch sessions when dialog opens (only for claude-code agents)
|
|
1354
|
+
useEffect(() => {
|
|
1355
|
+
if (!launchDialog) return;
|
|
1356
|
+
const pName = projectPath.replace(/\/+$/, '').split('/').pop() || '';
|
|
1357
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
|
|
1358
|
+
.then(r => r.json())
|
|
1359
|
+
.then(data => {
|
|
1360
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
1361
|
+
setSessions(data.map((s: any) => ({
|
|
1362
|
+
id: s.sessionId || s.id || '',
|
|
1363
|
+
modified: s.modified || '',
|
|
1364
|
+
size: s.fileSize || s.size || 0,
|
|
1365
|
+
})));
|
|
1366
|
+
}
|
|
1367
|
+
})
|
|
1368
|
+
.catch(() => {});
|
|
1369
|
+
}, [launchDialog, projectPath]);
|
|
1370
|
+
|
|
1371
|
+
const openWithAgent = (agentId: string, resumeMode?: boolean, sessionId?: string, env?: Record<string, string>, model?: string) => {
|
|
1372
|
+
setLaunchDialog(null);
|
|
1373
|
+
setShowMenu(false);
|
|
1374
|
+
// Build profile env for the event
|
|
1375
|
+
const profileEnv = env ? { ...env } : undefined;
|
|
1376
|
+
if (model && profileEnv) profileEnv.CLAUDE_MODEL = model;
|
|
1377
|
+
else if (model) {
|
|
1378
|
+
const pe: Record<string, string> = { CLAUDE_MODEL: model };
|
|
1379
|
+
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
1380
|
+
detail: { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv: pe },
|
|
1381
|
+
}));
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
1385
|
+
detail: { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv },
|
|
1386
|
+
}));
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
const handleAgentClick = async (a: typeof agents[0]) => {
|
|
1390
|
+
setShowMenu(false);
|
|
1391
|
+
// Resolve launch info from server (reads cliType + profile)
|
|
1392
|
+
try {
|
|
1393
|
+
const res = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
|
|
1394
|
+
const info = await res.json();
|
|
1395
|
+
if (info.supportsSession) {
|
|
1396
|
+
setSessions([]);
|
|
1397
|
+
setShowSessions(false);
|
|
1398
|
+
setLaunchDialog({ agentId: a.id, agentName: a.name, env: info.env, model: info.model });
|
|
1399
|
+
} else {
|
|
1400
|
+
openWithAgent(a.id, false, undefined, info.env, info.model);
|
|
1401
|
+
}
|
|
1402
|
+
} catch {
|
|
1403
|
+
// Fallback: open directly
|
|
1404
|
+
openWithAgent(a.id);
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
const formatTime = (iso: string) => {
|
|
1409
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
1410
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
1411
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
1412
|
+
return new Date(iso).toLocaleDateString();
|
|
1413
|
+
};
|
|
1414
|
+
const formatSize = (b: number) => b < 1024 ? `${b}B` : b < 1048576 ? `${(b/1024).toFixed(0)}KB` : `${(b/1048576).toFixed(1)}MB`;
|
|
1415
|
+
|
|
1416
|
+
const allAgents = agents.filter(a => a.detected !== false || a.isProfile);
|
|
1417
|
+
|
|
1418
|
+
return (
|
|
1419
|
+
<>
|
|
1420
|
+
<div ref={ref} className="relative">
|
|
1421
|
+
<div className="flex items-center">
|
|
1422
|
+
<button
|
|
1423
|
+
onClick={() => handleAgentClick({ id: 'claude', name: 'Claude' })}
|
|
1424
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded-l hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
1425
|
+
title="Open terminal"
|
|
1426
|
+
>
|
|
1427
|
+
Terminal
|
|
1428
|
+
</button>
|
|
1429
|
+
{allAgents.length > 1 && (
|
|
1430
|
+
<button
|
|
1431
|
+
onClick={() => setShowMenu(v => !v)}
|
|
1432
|
+
className="text-[9px] px-1 py-0.5 border border-l-0 border-[var(--accent)] text-[var(--accent)] rounded-r hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
1433
|
+
>
|
|
1434
|
+
▾
|
|
1435
|
+
</button>
|
|
1436
|
+
)}
|
|
1437
|
+
</div>
|
|
1438
|
+
{showMenu && (
|
|
1439
|
+
<div className="absolute right-0 top-full mt-1 w-44 rounded border border-[var(--border)] shadow-lg z-40 overflow-hidden" style={{ background: 'var(--bg-primary)' }}>
|
|
1440
|
+
{allAgents.map(a => (
|
|
1441
|
+
<button key={a.id} onClick={() => handleAgentClick(a)}
|
|
1442
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] text-left">
|
|
1443
|
+
<span className="font-bold w-4 text-center text-[var(--accent)]">
|
|
1444
|
+
{a.isProfile ? '●' : a.id === 'claude' ? 'C' : a.id === 'codex' ? 'X' : a.id === 'aider' ? 'A' : a.id.charAt(0).toUpperCase()}
|
|
1445
|
+
</span>
|
|
1446
|
+
<span>{a.name}</span>
|
|
1447
|
+
</button>
|
|
1448
|
+
))}
|
|
1449
|
+
</div>
|
|
1450
|
+
)}
|
|
1451
|
+
</div>
|
|
1452
|
+
|
|
1453
|
+
{/* Launch dialog — New / Resume / Sessions */}
|
|
1454
|
+
{launchDialog && (
|
|
1455
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
|
|
1456
|
+
onClick={e => { if (e.target === e.currentTarget) setLaunchDialog(null); }}>
|
|
1457
|
+
<div className="w-80 rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
|
|
1458
|
+
<div className="text-sm font-bold text-white mb-3">⌨️ {launchDialog.agentName}</div>
|
|
1459
|
+
<div className="space-y-2">
|
|
1460
|
+
<button onClick={() => openWithAgent(launchDialog.agentId, false, undefined, launchDialog.env, launchDialog.model)}
|
|
1461
|
+
className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors">
|
|
1462
|
+
<div className="text-xs text-white font-semibold">New Session</div>
|
|
1463
|
+
<div className="text-[9px] text-gray-500">Start fresh</div>
|
|
1464
|
+
</button>
|
|
1465
|
+
|
|
1466
|
+
{sessions.length > 0 && (
|
|
1467
|
+
<button onClick={() => openWithAgent(launchDialog.agentId, true, undefined, launchDialog.env, launchDialog.model)}
|
|
1468
|
+
className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#3fb950] hover:bg-[#161b22] transition-colors">
|
|
1469
|
+
<div className="text-xs text-white font-semibold">Resume Latest</div>
|
|
1470
|
+
<div className="text-[9px] text-gray-500">{sessions[0].id.slice(0, 8)} · {formatTime(sessions[0].modified)} · {formatSize(sessions[0].size)}</div>
|
|
1471
|
+
</button>
|
|
1472
|
+
)}
|
|
1473
|
+
|
|
1474
|
+
{sessions.length > 1 && (
|
|
1475
|
+
<button onClick={() => setShowSessions(!showSessions)}
|
|
1476
|
+
className="w-full text-[9px] text-gray-500 hover:text-white py-1">
|
|
1477
|
+
{showSessions ? '▼' : '▶'} More sessions ({sessions.length - 1})
|
|
1478
|
+
</button>
|
|
1479
|
+
)}
|
|
1480
|
+
|
|
1481
|
+
{showSessions && sessions.slice(1).map(s => (
|
|
1482
|
+
<button key={s.id} onClick={() => openWithAgent(launchDialog.agentId, true, s.id, launchDialog.env, launchDialog.model)}
|
|
1483
|
+
className="w-full text-left px-3 py-1.5 rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
|
|
1484
|
+
<div className="flex items-center gap-2">
|
|
1485
|
+
<span className="text-[9px] text-gray-400 font-mono">{s.id.slice(0, 8)}</span>
|
|
1486
|
+
<span className="text-[8px] text-gray-600">{formatTime(s.modified)}</span>
|
|
1487
|
+
<span className="text-[8px] text-gray-600">{formatSize(s.size)}</span>
|
|
1488
|
+
</div>
|
|
1489
|
+
</button>
|
|
1490
|
+
))}
|
|
1491
|
+
</div>
|
|
1492
|
+
<button onClick={() => setLaunchDialog(null)}
|
|
1493
|
+
className="w-full mt-3 text-[9px] text-gray-500 hover:text-white">Cancel</button>
|
|
1494
|
+
</div>
|
|
1495
|
+
</div>
|
|
1496
|
+
)}
|
|
1497
|
+
</>
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import TabBar from './TabBar';
|
|
5
5
|
import ProjectDetail from './ProjectDetail';
|
|
6
|
+
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
6
7
|
|
|
7
8
|
interface Project {
|
|
8
9
|
name: string;
|
|
@@ -24,6 +25,8 @@ const MAX_MOUNTED_TABS = 5;
|
|
|
24
25
|
function genTabId(): number { return Date.now() + Math.floor(Math.random() * 10000); }
|
|
25
26
|
|
|
26
27
|
export default function ProjectManager() {
|
|
28
|
+
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 240, minWidth: 140, maxWidth: 400 });
|
|
29
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
27
30
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
28
31
|
const [showClone, setShowClone] = useState(false);
|
|
29
32
|
const [cloneUrl, setCloneUrl] = useState('');
|
|
@@ -193,8 +196,10 @@ export default function ProjectManager() {
|
|
|
193
196
|
|
|
194
197
|
// Group projects by root
|
|
195
198
|
const [collapsedRoots, setCollapsedRoots] = useState<Set<string>>(new Set());
|
|
196
|
-
const roots = [...new Set(projects.map(p => p.root))];
|
|
197
|
-
const favoriteProjects = projects
|
|
199
|
+
const roots = [...new Set(projects.map(p => p.root))].sort();
|
|
200
|
+
const favoriteProjects = projects
|
|
201
|
+
.filter(p => favorites.includes(p.path))
|
|
202
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
198
203
|
|
|
199
204
|
const toggleRoot = (root: string) => {
|
|
200
205
|
setCollapsedRoots(prev => {
|
|
@@ -204,10 +209,40 @@ export default function ProjectManager() {
|
|
|
204
209
|
});
|
|
205
210
|
};
|
|
206
211
|
|
|
212
|
+
const activeTab = tabs.find(t => t.id === activeTabId);
|
|
213
|
+
|
|
207
214
|
return (
|
|
208
215
|
<div className="flex-1 flex min-h-0">
|
|
209
|
-
{/*
|
|
210
|
-
|
|
216
|
+
{/* Collapsed sidebar — narrow strip with project initials */}
|
|
217
|
+
{sidebarCollapsed && (
|
|
218
|
+
<div className="w-10 border-r border-[var(--border)] flex flex-col shrink-0 overflow-hidden">
|
|
219
|
+
<button onClick={() => setSidebarCollapsed(false)}
|
|
220
|
+
className="w-full text-sm text-[var(--text-secondary)] hover:text-[var(--accent)] py-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
|
221
|
+
title="Expand sidebar">
|
|
222
|
+
▶
|
|
223
|
+
</button>
|
|
224
|
+
<div className="flex-1 overflow-y-auto">
|
|
225
|
+
{[...projects].sort((a, b) => a.name.localeCompare(b.name)).map(p => {
|
|
226
|
+
const isActive = activeTab?.projectPath === p.path;
|
|
227
|
+
const initial = p.name.slice(0, 2).toUpperCase();
|
|
228
|
+
return (
|
|
229
|
+
<button key={p.path}
|
|
230
|
+
onClick={() => openProjectTab(p)}
|
|
231
|
+
title={p.name}
|
|
232
|
+
className={`w-full py-1.5 text-[9px] font-bold text-center ${
|
|
233
|
+
isActive ? 'text-[var(--accent)] bg-[var(--accent)]/10' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
234
|
+
}`}>
|
|
235
|
+
{initial}
|
|
236
|
+
</button>
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Full sidebar — project list */}
|
|
244
|
+
{!sidebarCollapsed && <>
|
|
245
|
+
<aside style={{ width: sidebarWidth }} className="border-r border-[var(--border)] flex flex-col shrink-0">
|
|
211
246
|
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
|
212
247
|
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Projects</span>
|
|
213
248
|
<button
|
|
@@ -308,6 +343,16 @@ export default function ProjectManager() {
|
|
|
308
343
|
})}
|
|
309
344
|
</div>
|
|
310
345
|
</aside>
|
|
346
|
+
{/* Resize handle + collapse button */}
|
|
347
|
+
<div className="flex flex-col shrink-0">
|
|
348
|
+
<button onClick={() => setSidebarCollapsed(true)}
|
|
349
|
+
className="text-sm text-[var(--text-secondary)] hover:text-[var(--accent)] py-2 px-1 hover:bg-[var(--bg-secondary)] transition-colors"
|
|
350
|
+
title="Collapse sidebar">
|
|
351
|
+
◀
|
|
352
|
+
</button>
|
|
353
|
+
<div onMouseDown={onSidebarDragStart} className="flex-1 w-1 cursor-col-resize hover:bg-[var(--accent)]/30 bg-[var(--border)]" />
|
|
354
|
+
</div>
|
|
355
|
+
</>}
|
|
311
356
|
|
|
312
357
|
{/* Main area */}
|
|
313
358
|
<div className="flex-1 flex flex-col min-w-0">
|
|
@@ -38,10 +38,12 @@ export default function SessionView({
|
|
|
38
38
|
projectName,
|
|
39
39
|
projects,
|
|
40
40
|
onOpenInTerminal,
|
|
41
|
+
singleProject,
|
|
41
42
|
}: {
|
|
42
43
|
projectName?: string;
|
|
43
44
|
projects: { name: string; path: string; language: string | null }[];
|
|
44
45
|
onOpenInTerminal?: (sessionId: string, projectPath: string) => void;
|
|
46
|
+
singleProject?: boolean; // hide project tree sidebar, show only the given project
|
|
45
47
|
}) {
|
|
46
48
|
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 288, minWidth: 160, maxWidth: 480 });
|
|
47
49
|
// Tree data: project → sessions
|
|
@@ -83,9 +85,14 @@ export default function SessionView({
|
|
|
83
85
|
}, []);
|
|
84
86
|
|
|
85
87
|
useEffect(() => {
|
|
86
|
-
|
|
88
|
+
// In single-project mode: load cached first (fast), then sync in background
|
|
89
|
+
if (singleProject) {
|
|
90
|
+
loadTree(false).then(() => loadTree(true));
|
|
91
|
+
} else {
|
|
92
|
+
loadTree(true);
|
|
93
|
+
}
|
|
87
94
|
loadWatchers();
|
|
88
|
-
}, [loadTree, loadWatchers]);
|
|
95
|
+
}, [loadTree, loadWatchers, singleProject]);
|
|
89
96
|
|
|
90
97
|
// Auto-expand project if only one or if pre-selected
|
|
91
98
|
useEffect(() => {
|
|
@@ -287,7 +294,7 @@ export default function SessionView({
|
|
|
287
294
|
return (
|
|
288
295
|
<div className="flex h-full">
|
|
289
296
|
{/* Left: tree view */}
|
|
290
|
-
<div style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
|
|
297
|
+
<div style={{ width: singleProject ? 200 : sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
|
|
291
298
|
{/* Header */}
|
|
292
299
|
<div className="flex items-center justify-between p-2 border-b border-[var(--border)]">
|
|
293
300
|
<span className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase">Sessions</span>
|
|
@@ -339,9 +346,13 @@ export default function SessionView({
|
|
|
339
346
|
</p>
|
|
340
347
|
)}
|
|
341
348
|
|
|
342
|
-
{Object.entries(sessionTree)
|
|
349
|
+
{Object.entries(sessionTree)
|
|
350
|
+
.filter(([project]) => !singleProject || project === projectName)
|
|
351
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
352
|
+
.map(([project, sessions]) => (
|
|
343
353
|
<div key={project}>
|
|
344
|
-
{/* Project node */}
|
|
354
|
+
{/* Project node (hidden in single-project mode) */}
|
|
355
|
+
{!singleProject && (
|
|
345
356
|
<div
|
|
346
357
|
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-[var(--bg-tertiary)] transition-colors border-b border-[var(--border)]/50 cursor-pointer"
|
|
347
358
|
onClick={() => toggleProject(project)}
|
|
@@ -364,15 +375,16 @@ export default function SessionView({
|
|
|
364
375
|
<span className="text-[9px] text-[var(--accent)]" title="Watching">👁</span>
|
|
365
376
|
)}
|
|
366
377
|
</div>
|
|
378
|
+
)}
|
|
367
379
|
|
|
368
|
-
{/* Session children */}
|
|
369
|
-
{expandedProjects.has(project) && sessions.map(s => {
|
|
380
|
+
{/* Session children (always visible in single-project mode) */}
|
|
381
|
+
{(singleProject || expandedProjects.has(project)) && sessions.map(s => {
|
|
370
382
|
const isActive = selectedProject === project && activeSessionId === s.sessionId;
|
|
371
383
|
const isWatched = watchedSessionIds.has(s.sessionId);
|
|
372
384
|
return (
|
|
373
385
|
<div
|
|
374
386
|
key={s.sessionId}
|
|
375
|
-
className={`group relative w-full text-left pl-6 pr-2 py-1.5 hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer ${
|
|
387
|
+
className={`group relative w-full text-left ${singleProject ? 'pl-2' : 'pl-6'} pr-2 py-1.5 hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer ${
|
|
376
388
|
isActive ? 'bg-[var(--bg-tertiary)] border-l-2 border-l-[var(--accent)]' : 'border-l-2 border-l-transparent'
|
|
377
389
|
}`}
|
|
378
390
|
onClick={() => batchMode ? toggleSelect(project, s.sessionId) : selectSession(project, s.sessionId)}
|
|
@@ -453,11 +465,13 @@ export default function SessionView({
|
|
|
453
465
|
</div>
|
|
454
466
|
</div>
|
|
455
467
|
|
|
456
|
-
{/* Sidebar resize handle */}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
468
|
+
{/* Sidebar resize handle (not in single-project mode) */}
|
|
469
|
+
{!singleProject && (
|
|
470
|
+
<div
|
|
471
|
+
onMouseDown={onSidebarDragStart}
|
|
472
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
|
|
473
|
+
/>
|
|
474
|
+
)}
|
|
461
475
|
|
|
462
476
|
{/* Right: session content */}
|
|
463
477
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|