@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.
Files changed (100) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/RELEASE_NOTES.md +170 -13
  4. package/app/api/agents/route.ts +17 -0
  5. package/app/api/delivery/[id]/route.ts +62 -0
  6. package/app/api/delivery/route.ts +40 -0
  7. package/app/api/mobile-chat/route.ts +13 -7
  8. package/app/api/monitor/route.ts +10 -6
  9. package/app/api/pipelines/[id]/route.ts +16 -3
  10. package/app/api/tasks/route.ts +2 -1
  11. package/app/api/workspace/[id]/agents/route.ts +35 -0
  12. package/app/api/workspace/[id]/memory/route.ts +23 -0
  13. package/app/api/workspace/[id]/smith/route.ts +22 -0
  14. package/app/api/workspace/[id]/stream/route.ts +28 -0
  15. package/app/api/workspace/route.ts +100 -0
  16. package/app/global-error.tsx +10 -4
  17. package/app/icon.ico +0 -0
  18. package/app/layout.tsx +2 -2
  19. package/app/login/LoginForm.tsx +96 -0
  20. package/app/login/page.tsx +7 -98
  21. package/app/page.tsx +2 -2
  22. package/bin/forge-server.mjs +23 -4
  23. package/check-forge-status.sh +9 -0
  24. package/cli/mw.ts +2 -2
  25. package/components/ConversationEditor.tsx +411 -0
  26. package/components/ConversationGraphView.tsx +347 -0
  27. package/components/ConversationTerminalView.tsx +303 -0
  28. package/components/Dashboard.tsx +36 -39
  29. package/components/DashboardWrapper.tsx +9 -0
  30. package/components/DeliveryFlowEditor.tsx +491 -0
  31. package/components/DeliveryList.tsx +230 -0
  32. package/components/DeliveryWorkspace.tsx +589 -0
  33. package/components/DocTerminal.tsx +12 -4
  34. package/components/DocsViewer.tsx +10 -2
  35. package/components/HelpTerminal.tsx +13 -8
  36. package/components/InlinePipelineView.tsx +111 -0
  37. package/components/MobileView.tsx +20 -0
  38. package/components/MonitorPanel.tsx +9 -4
  39. package/components/NewTaskModal.tsx +32 -0
  40. package/components/PipelineEditor.tsx +49 -6
  41. package/components/PipelineView.tsx +482 -64
  42. package/components/ProjectDetail.tsx +314 -56
  43. package/components/ProjectManager.tsx +49 -4
  44. package/components/SessionView.tsx +27 -13
  45. package/components/SettingsModal.tsx +790 -124
  46. package/components/SkillsPanel.tsx +34 -8
  47. package/components/TaskBoard.tsx +3 -0
  48. package/components/WebTerminal.tsx +259 -45
  49. package/components/WorkspaceTree.tsx +221 -0
  50. package/components/WorkspaceView.tsx +2224 -0
  51. package/docs/LOCAL-DEPLOY.md +15 -15
  52. package/install.sh +2 -2
  53. package/lib/agents/claude-adapter.ts +104 -0
  54. package/lib/agents/generic-adapter.ts +64 -0
  55. package/lib/agents/index.ts +242 -0
  56. package/lib/agents/types.ts +70 -0
  57. package/lib/artifacts.ts +106 -0
  58. package/lib/cloudflared.ts +1 -1
  59. package/lib/delivery.ts +787 -0
  60. package/lib/forge-skills/forge-inbox.md +37 -0
  61. package/lib/forge-skills/forge-send.md +40 -0
  62. package/lib/forge-skills/forge-status.md +32 -0
  63. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  64. package/lib/help-docs/00-overview.md +8 -2
  65. package/lib/help-docs/01-settings.md +159 -2
  66. package/lib/help-docs/05-pipelines.md +95 -6
  67. package/lib/help-docs/07-projects.md +35 -1
  68. package/lib/help-docs/11-workspace.md +204 -0
  69. package/lib/help-docs/CLAUDE.md +5 -2
  70. package/lib/init.ts +62 -12
  71. package/lib/pipeline.ts +537 -1
  72. package/lib/settings.ts +115 -22
  73. package/lib/skills.ts +249 -372
  74. package/lib/task-manager.ts +113 -33
  75. package/lib/telegram-bot.ts +33 -1
  76. package/lib/telegram-standalone.ts +1 -1
  77. package/lib/terminal-server.ts +2 -2
  78. package/lib/terminal-standalone.ts +1 -1
  79. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  80. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  81. package/lib/workspace/agent-bus.ts +416 -0
  82. package/lib/workspace/agent-worker.ts +667 -0
  83. package/lib/workspace/backends/api-backend.ts +262 -0
  84. package/lib/workspace/backends/cli-backend.ts +479 -0
  85. package/lib/workspace/index.ts +82 -0
  86. package/lib/workspace/manager.ts +136 -0
  87. package/lib/workspace/orchestrator.ts +1804 -0
  88. package/lib/workspace/persistence.ts +310 -0
  89. package/lib/workspace/presets.ts +170 -0
  90. package/lib/workspace/skill-installer.ts +188 -0
  91. package/lib/workspace/smith-memory.ts +498 -0
  92. package/lib/workspace/types.ts +231 -0
  93. package/lib/workspace/watch-manager.ts +288 -0
  94. package/lib/workspace-standalone.ts +790 -0
  95. package/middleware.ts +1 -0
  96. package/next-env.d.ts +1 -1
  97. package/package.json +5 -2
  98. package/src/config/index.ts +13 -2
  99. package/src/core/db/database.ts +1 -0
  100. 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
- <button
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="flex items-start gap-2 px-3 py-2 border-b border-[var(--border)]/30 last:border-b-0 text-[10px]">
1060
- <span className={`shrink-0 ${
1061
- run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : run.status === 'skipped' ? 'text-gray-400' : 'text-yellow-400'
1062
- }`}>●</span>
1063
- <div className="flex-1 min-w-0">
1064
- <div className="flex items-center gap-2">
1065
- <span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
1066
- {run.dedupKey && (
1067
- <span className="text-[8px] text-[var(--accent)] font-mono">{run.dedupKey.replace('issue:', '#')}</span>
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
- {run.summary && (
1077
- <pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
1078
- )}
1079
- </div>
1080
- <div className="flex items-center gap-1 shrink-0">
1081
- {run.status === 'failed' && run.dedupKey && (
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
- await fetch('/api/project-pipelines', {
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(--accent)] hover:underline"
1098
- >Retry</button>
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.filter(p => favorites.includes(p.path));
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
- {/* Left sidebar — project list */}
210
- <aside className="w-64 border-r border-[var(--border)] flex flex-col shrink-0">
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
- loadTree(true);
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).sort(([a], [b]) => a.localeCompare(b)).map(([project, sessions]) => (
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
- <div
458
- onMouseDown={onSidebarDragStart}
459
- className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
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">