@aion0/forge 0.10.78 → 0.10.80

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 (38) hide show
  1. package/RELEASE_NOTES.md +9 -8
  2. package/app/api/code/route.ts +171 -54
  3. package/app/api/onboarding/route.ts +32 -0
  4. package/app/api/skills/local/route.ts +5 -4
  5. package/app/api/tasks/[id]/hook/stop/route.ts +15 -0
  6. package/app/api/tasks/route.ts +2 -1
  7. package/cli/mw.mjs +7 -5
  8. package/cli/mw.ts +8 -6
  9. package/components/CodeViewer.tsx +127 -41
  10. package/components/Dashboard.tsx +6 -2
  11. package/components/DocsViewer.tsx +34 -22
  12. package/components/HelpTerminal.tsx +9 -5
  13. package/components/OnboardingWizard.tsx +65 -1
  14. package/components/ProjectDetail.tsx +33 -7
  15. package/components/TaskDetail.tsx +28 -1
  16. package/components/TmuxTaskTerminal.tsx +105 -0
  17. package/components/WebTerminal.tsx +26 -8
  18. package/components/WorkspaceView.tsx +68 -47
  19. package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
  20. package/docs/design_automation_records/README.md +232 -0
  21. package/lib/agents/index.ts +9 -0
  22. package/lib/chat/agent-loop.ts +6 -0
  23. package/lib/chat/tool-dispatcher.ts +110 -9
  24. package/lib/fileTree.ts +28 -0
  25. package/lib/help-docs/01-settings.md +11 -0
  26. package/lib/help-docs/05-pipelines.md +31 -0
  27. package/lib/help-docs/07-projects.md +3 -1
  28. package/lib/help-docs/25-chat-tools.md +23 -0
  29. package/lib/pipeline.ts +27 -3
  30. package/lib/session-utils.ts +19 -0
  31. package/lib/task-manager.ts +73 -3
  32. package/lib/task-tmux-backend.ts +625 -0
  33. package/lib/terminal-standalone.ts +17 -0
  34. package/lib/workspace/skill-installer.ts +18 -8
  35. package/package.json +1 -1
  36. package/proxy.ts +5 -4
  37. package/src/core/db/database.ts +1 -0
  38. package/src/types/index.ts +3 -0
@@ -1,16 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
4
4
  import type { WebTerminalHandle, WebTerminalProps } from './WebTerminal';
5
5
  import { useSidebarResize } from '@/hooks/useSidebarResize';
6
+ import { updateTreeChildren } from '@/lib/fileTree';
6
7
 
7
8
  const WebTerminal = lazy(() => import('./WebTerminal'));
8
9
 
10
+ const MAX_SEARCH_RESULTS = 200;
11
+
9
12
  interface FileNode {
10
13
  name: string;
11
14
  path: string;
12
15
  type: 'file' | 'dir';
13
16
  children?: FileNode[];
17
+ hasChildren?: boolean;
14
18
  }
15
19
 
16
20
  // ─── File Tree ───────────────────────────────────────────
@@ -18,38 +22,47 @@ interface FileNode {
18
22
  type GitStatusMap = Map<string, string>; // path → status
19
23
  type GitRepoMap = Map<string, { branch: string; remote: string }>; // dir name → repo info
20
24
 
21
- function TreeNode({ node, depth, selected, onSelect, gitMap, repoMap }: {
25
+ function TreeNode({ node, depth, selected, onSelect, onLoadChildren, gitMap, repoMap }: {
22
26
  node: FileNode;
23
27
  depth: number;
24
28
  selected: string | null;
25
29
  onSelect: (path: string) => void;
30
+ onLoadChildren: (path: string) => Promise<void>;
26
31
  gitMap: GitStatusMap;
27
32
  repoMap: GitRepoMap;
28
33
  }) {
29
34
  // Auto-expand if selected file is under this directory
30
35
  const containsSelected = selected ? selected.startsWith(node.path + '/') : false;
31
36
  const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
32
- const expanded = manualExpanded ?? (depth < 1 || containsSelected);
37
+ const expanded = manualExpanded ?? containsSelected;
33
38
 
34
39
  if (node.type === 'dir') {
35
- const dirHasChanges = node.children?.some(c => hasGitChanges(c, gitMap));
40
+ const dirHasChanges = hasGitChanges(node, gitMap);
36
41
  const repo = repoMap.get(node.name);
42
+ const hasChildren = node.hasChildren || (node.children?.length ?? 0) > 0;
43
+ const toggleExpanded = async () => {
44
+ const nextExpanded = !expanded;
45
+ setManualExpanded(nextExpanded);
46
+ if (nextExpanded && hasChildren && !node.children) {
47
+ await onLoadChildren(node.path);
48
+ }
49
+ };
37
50
  return (
38
51
  <div>
39
52
  <button
40
- onClick={() => setManualExpanded(v => v === null ? !expanded : !v)}
53
+ onClick={toggleExpanded}
41
54
  className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs group"
42
55
  style={{ paddingLeft: depth * 12 + 4 }}
43
56
  title={repo ? `${repo.branch} · ${repo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}` : undefined}
44
57
  >
45
- <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
58
+ <span className="text-[10px] text-[var(--text-secondary)] w-3">{hasChildren ? (expanded ? '▾' : '▸') : ''}</span>
46
59
  <span className={dirHasChanges ? 'text-yellow-400' : 'text-[var(--text-primary)]'}>{node.name}</span>
47
60
  {repo && (
48
61
  <span className="text-[8px] text-[var(--accent)] opacity-60 group-hover:opacity-100 ml-auto shrink-0">{repo.branch}</span>
49
62
  )}
50
63
  </button>
51
64
  {expanded && node.children?.map(child => (
52
- <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} gitMap={gitMap} repoMap={repoMap} />
65
+ <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} onLoadChildren={onLoadChildren} gitMap={gitMap} repoMap={repoMap} />
53
66
  ))}
54
67
  </div>
55
68
  );
@@ -81,14 +94,31 @@ function TreeNode({ node, depth, selected, onSelect, gitMap, repoMap }: {
81
94
 
82
95
  function hasGitChanges(node: FileNode, gitMap: GitStatusMap): boolean {
83
96
  if (node.type === 'file') return gitMap.has(node.path);
84
- return node.children?.some(c => hasGitChanges(c, gitMap)) || false;
97
+ // A change under this directory has a path of `${node.path}/...`, so a prefix
98
+ // scan over the change set covers descendants without recursing the tree
99
+ // (whose children are loaded lazily and may not be present anyway).
100
+ for (const changedPath of gitMap.keys()) {
101
+ if (changedPath === node.path || changedPath.startsWith(node.path + '/')) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ // The server reports whether the search index dropped entries (global cap or
107
+ // per-directory cap), so search results may be incomplete. Fall back to the
108
+ // length heuristic for older servers that omit the flag.
109
+ function deriveIndexInfo(data: any): { truncated: boolean; limit: number } {
110
+ const fi = data?.fileIndex;
111
+ const limit = data?.fileIndexLimit || 0;
112
+ const truncated = typeof data?.indexTruncated === 'boolean'
113
+ ? data.indexTruncated
114
+ : (Array.isArray(fi) && limit > 0 && fi.length >= limit);
115
+ return { truncated, limit };
85
116
  }
86
117
 
87
- function flattenTree(nodes: FileNode[]): FileNode[] {
88
- const result: FileNode[] = [];
118
+ function flattenTree(nodes: FileNode[], result: FileNode[] = []): FileNode[] {
89
119
  for (const node of nodes) {
90
120
  if (node.type === 'file') result.push(node);
91
- if (node.children) result.push(...flattenTree(node.children));
121
+ if (node.children) flattenTree(node.children, result);
92
122
  }
93
123
  return result;
94
124
  }
@@ -175,6 +205,10 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
175
205
  const [currentDir, setCurrentDir] = useState<string | null>(null);
176
206
  const [dirName, setDirName] = useState('');
177
207
  const [tree, setTree] = useState<FileNode[]>([]);
208
+ const [fileIndex, setFileIndex] = useState<FileNode[]>([]);
209
+ // Truncation surfaced from /api/code (root-level tree cap and search-index cap).
210
+ const [treeInfo, setTreeInfo] = useState<{ truncated: boolean; limit: number }>({ truncated: false, limit: 0 });
211
+ const [indexInfo, setIndexInfo] = useState<{ truncated: boolean; limit: number }>({ truncated: false, limit: 0 });
178
212
  const [gitBranch, setGitBranch] = useState('');
179
213
  const [gitChanges, setGitChanges] = useState<{ path: string; status: string }[]>([]);
180
214
  const [gitRepos, setGitRepos] = useState<{ name: string; branch: string; remote: string; changes: { path: string; status: string }[] }[]>([]);
@@ -237,21 +271,36 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
237
271
  .then(r => r.json())
238
272
  .then(data => {
239
273
  setTree(data.tree || []);
274
+ setFileIndex(data.fileIndex || data.tree || []);
275
+ setTreeInfo({ truncated: !!data.truncated, limit: data.limit || 0 });
276
+ setIndexInfo(deriveIndexInfo(data));
240
277
  setDirName(data.dirName || currentDir.split('/').pop() || '');
241
278
  setGitBranch(data.gitBranch || '');
242
279
  setGitChanges(data.gitChanges || []);
243
280
  setGitRepos(data.gitRepos || []);
244
281
  })
245
- .catch(() => setTree([]));
282
+ .catch(() => {
283
+ setTree([]);
284
+ setFileIndex([]);
285
+ });
246
286
  };
247
287
  fetchDir();
248
288
  }, [currentDir]);
249
289
 
290
+ const loadChildren = useCallback(async (path: string) => {
291
+ if (!currentDir) return;
292
+ try {
293
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}&treePath=${encodeURIComponent(path)}`);
294
+ const data = await res.json();
295
+ setTree(prev => updateTreeChildren(prev, path, data.tree || []));
296
+ } catch {}
297
+ }, [currentDir]);
298
+
250
299
  // Task completion is notified via hook stop — no polling needed
251
300
 
252
301
  // Build git status map for tree coloring
253
- const gitMap: GitStatusMap = new Map(gitChanges.map(g => [g.path, g.status]));
254
- const repoMap: GitRepoMap = new Map(gitRepos.filter(r => r.name !== '.').map(r => [r.name, { branch: r.branch, remote: r.remote }]));
302
+ const gitMap: GitStatusMap = useMemo(() => new Map(gitChanges.map(g => [g.path, g.status])), [gitChanges]);
303
+ const repoMap: GitRepoMap = useMemo(() => new Map(gitRepos.filter(r => r.name !== '.').map(r => [r.name, { branch: r.branch, remote: r.remote }])), [gitRepos]);
255
304
 
256
305
  const openFile = useCallback(async (path: string, forceLoad?: boolean) => {
257
306
  if (!currentDir) return;
@@ -292,16 +341,27 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
292
341
  setLoading(false);
293
342
  }, [currentDir]);
294
343
 
295
- // Open file and auto-expand its parent dirs in tree
296
- const locateFile = useCallback((path: string) => {
344
+ // Open a file and reveal it in the tree. The server expands the ancestor
345
+ // chain in one request (bounded), so we never load the whole tree client-side.
346
+ const locateFile = useCallback(async (path: string) => {
297
347
  setSearch(''); // clear search so tree is visible
348
+ if (currentDir) {
349
+ try {
350
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}&expandTo=${encodeURIComponent(path)}`);
351
+ const data = await res.json();
352
+ if (Array.isArray(data.tree)) setTree(data.tree);
353
+ } catch {}
354
+ }
298
355
  openFile(path);
299
- }, [openFile]);
356
+ }, [currentDir, openFile]);
300
357
 
301
- const allFiles = flattenTree(tree);
302
- const filtered = search
303
- ? allFiles.filter(f => f.name.toLowerCase().includes(search.toLowerCase()) || f.path.toLowerCase().includes(search.toLowerCase()))
304
- : null;
358
+ const allFiles = useMemo(() => fileIndex.length > 0 ? fileIndex : flattenTree(tree), [fileIndex, tree]);
359
+ const searchLower = search.toLowerCase();
360
+ const filtered = useMemo(() => searchLower
361
+ ? allFiles.filter(f => f.name.toLowerCase().includes(searchLower) || f.path.toLowerCase().includes(searchLower))
362
+ : null, [allFiles, searchLower]);
363
+ const visibleFiltered = filtered?.slice(0, MAX_SEARCH_RESULTS) ?? null;
364
+ const hiddenFilteredCount = filtered ? Math.max(0, filtered.length - MAX_SEARCH_RESULTS) : 0;
305
365
 
306
366
  const onDragStart = (e: React.MouseEvent) => {
307
367
  e.preventDefault();
@@ -344,6 +404,8 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
344
404
  setGitChanges(d.gitChanges || []);
345
405
  setGitRepos(d.gitRepos || []);
346
406
  setGitBranch(d.gitBranch || '');
407
+ setFileIndex(d.fileIndex || []);
408
+ setIndexInfo(deriveIndexInfo(d));
347
409
  if (action === 'commit') setCommitMsg('');
348
410
  }
349
411
  } catch (e: any) {
@@ -360,6 +422,9 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
360
422
  .then(r => r.json())
361
423
  .then(data => {
362
424
  setTree(data.tree || []);
425
+ setFileIndex(data.fileIndex || data.tree || []);
426
+ setTreeInfo({ truncated: !!data.truncated, limit: data.limit || 0 });
427
+ setIndexInfo(deriveIndexInfo(data));
363
428
  setDirName(data.dirName || currentDir.split('/').pop() || '');
364
429
  setGitBranch(data.gitBranch || '');
365
430
  setGitChanges(data.gitChanges || []);
@@ -520,27 +585,48 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
520
585
  {!currentDir ? (
521
586
  <div className="text-xs text-[var(--text-secondary)] p-2">Open a terminal to see files</div>
522
587
  ) : filtered ? (
523
- filtered.length === 0 ? (
524
- <div className="text-xs text-[var(--text-secondary)] p-2">No matches</div>
525
- ) : (
526
- filtered.map(f => (
527
- <button
528
- key={f.path}
529
- onClick={() => { openFile(f.path); setSearch(''); }}
530
- className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
531
- selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
532
- }`}
533
- title={f.path}
534
- >
535
- <span className="text-[var(--text-primary)]">{f.name}</span>
536
- <span className="text-[9px] text-[var(--text-secondary)] ml-1">{f.path.split('/').slice(0, -1).join('/')}</span>
537
- </button>
538
- ))
539
- )
588
+ <>
589
+ {filtered.length === 0 ? (
590
+ <div className="text-xs text-[var(--text-secondary)] p-2">No matches</div>
591
+ ) : (
592
+ <>
593
+ {visibleFiltered!.map(f => (
594
+ <button
595
+ key={f.path}
596
+ onClick={() => { openFile(f.path); setSearch(''); }}
597
+ className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
598
+ selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
599
+ }`}
600
+ title={f.path}
601
+ >
602
+ <span className="text-[var(--text-primary)]">{f.name}</span>
603
+ <span className="text-[9px] text-[var(--text-secondary)] ml-1">{f.path.split('/').slice(0, -1).join('/')}</span>
604
+ </button>
605
+ ))}
606
+ {hiddenFilteredCount > 0 && (
607
+ <div className="text-[10px] text-[var(--text-secondary)] p-2">
608
+ Showing first {MAX_SEARCH_RESULTS} of {filtered.length} matches
609
+ </div>
610
+ )}
611
+ </>
612
+ )}
613
+ {indexInfo.truncated && (
614
+ <div className="text-[10px] text-[var(--text-secondary)] p-2">
615
+ Search index is partial (large directories are sampled) — some files may not appear.
616
+ </div>
617
+ )}
618
+ </>
540
619
  ) : (
541
- tree.map(node => (
542
- <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} repoMap={repoMap} />
543
- ))
620
+ <>
621
+ {tree.map(node => (
622
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} onLoadChildren={loadChildren} gitMap={gitMap} repoMap={repoMap} />
623
+ ))}
624
+ {treeInfo.truncated && (
625
+ <div className="text-[10px] text-[var(--text-secondary)] p-2">
626
+ Showing first {treeInfo.limit.toLocaleString()} entries in this folder.
627
+ </div>
628
+ )}
629
+ </>
544
630
  )}
545
631
  </div>
546
632
 
@@ -282,10 +282,14 @@ export default function Dashboard({ user }: { user: any }) {
282
282
  // Listen for open-terminal events from ProjectManager
283
283
  useEffect(() => {
284
284
  const handler = (e: Event) => {
285
- const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv } = (e as CustomEvent).detail;
285
+ const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv, tmuxSession, tmuxLabel } = (e as CustomEvent).detail;
286
286
  setViewMode('terminal');
287
287
  setTimeout(() => {
288
- terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
288
+ if (tmuxSession) {
289
+ terminalRef.current?.openExistingSession?.(tmuxSession, tmuxLabel || tmuxSession);
290
+ } else {
291
+ terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
292
+ }
289
293
  }, 300);
290
294
  };
291
295
  window.addEventListener('forge:open-terminal', handler);
@@ -1,12 +1,14 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
4
4
  import { useSidebarResize } from '@/hooks/useSidebarResize';
5
5
  import MarkdownContent from './MarkdownContent';
6
6
  import TabBar from './TabBar';
7
7
 
8
8
  const DocTerminal = lazy(() => import('./DocTerminal'));
9
9
 
10
+ const MAX_SEARCH_RESULTS = 200;
11
+
10
12
  interface DocTab {
11
13
  id: number;
12
14
  filePath: string;
@@ -78,11 +80,10 @@ function TreeNode({ node, depth, selected, onSelect, collapseVersion = 0 }: {
78
80
 
79
81
  // ─── Search ──────────────────────────────────────────────
80
82
 
81
- function flattenTree(nodes: FileNode[]): FileNode[] {
82
- const result: FileNode[] = [];
83
+ function flattenTree(nodes: FileNode[], result: FileNode[] = []): FileNode[] {
83
84
  for (const node of nodes) {
84
85
  if (node.type === 'file') result.push(node);
85
- if (node.children) result.push(...flattenTree(node.children));
86
+ if (node.children) flattenTree(node.children, result);
86
87
  }
87
88
  return result;
88
89
  }
@@ -319,10 +320,14 @@ export default function DocsViewer() {
319
320
  }, [activeRoot]);
320
321
 
321
322
  // Search filter
322
- const allFiles = flattenTree(tree);
323
- const filtered = search
324
- ? allFiles.filter(f => f.name.toLowerCase().includes(search.toLowerCase()) || f.path.toLowerCase().includes(search.toLowerCase()))
325
- : null;
323
+ const visibleTree = useMemo(() => hideUnsupported ? filterTree(tree) : tree, [hideUnsupported, tree]);
324
+ const allFiles = useMemo(() => flattenTree(visibleTree), [visibleTree]);
325
+ const searchLower = search.toLowerCase();
326
+ const filtered = useMemo(() => searchLower
327
+ ? allFiles.filter(f => f.name.toLowerCase().includes(searchLower) || f.path.toLowerCase().includes(searchLower))
328
+ : null, [allFiles, searchLower]);
329
+ const visibleFiltered = filtered?.slice(0, MAX_SEARCH_RESULTS) ?? null;
330
+ const hiddenFilteredCount = filtered ? Math.max(0, filtered.length - MAX_SEARCH_RESULTS) : 0;
326
331
 
327
332
  // Drag to resize terminal
328
333
  const onDragStart = (e: React.MouseEvent) => {
@@ -414,22 +419,29 @@ export default function DocsViewer() {
414
419
  filtered.length === 0 ? (
415
420
  <div className="text-xs text-[var(--text-secondary)] p-2">No matches</div>
416
421
  ) : (
417
- filtered.map(f => (
418
- <button
419
- key={f.path}
420
- onClick={() => { openFileInTab(f.path); setSearch(''); }}
421
- className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
422
- selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
423
- }`}
424
- title={f.path}
425
- >
426
- <span className="text-[var(--text-primary)]">{f.name.replace(/\.md$/, '')}</span>
427
- <span className="text-[9px] text-[var(--text-secondary)] ml-1">{f.path.split('/').slice(0, -1).join('/')}</span>
428
- </button>
429
- ))
422
+ <>
423
+ {visibleFiltered!.map(f => (
424
+ <button
425
+ key={f.path}
426
+ onClick={() => { openFileInTab(f.path); setSearch(''); }}
427
+ className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
428
+ selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
429
+ }`}
430
+ title={f.path}
431
+ >
432
+ <span className="text-[var(--text-primary)]">{f.name.replace(/\.md$/, '')}</span>
433
+ <span className="text-[9px] text-[var(--text-secondary)] ml-1">{f.path.split('/').slice(0, -1).join('/')}</span>
434
+ </button>
435
+ ))}
436
+ {hiddenFilteredCount > 0 && (
437
+ <div className="text-[10px] text-[var(--text-secondary)] p-2">
438
+ Showing first {MAX_SEARCH_RESULTS} of {filtered.length} matches
439
+ </div>
440
+ )}
441
+ </>
430
442
  )
431
443
  ) : (
432
- (hideUnsupported ? filterTree(tree) : tree).map(node => (
444
+ visibleTree.map(node => (
433
445
  <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} collapseVersion={treeCollapseVersion} />
434
446
  ))
435
447
  )}
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { Terminal } from '@xterm/xterm';
5
5
  import { FitAddon } from '@xterm/addon-fit';
6
6
  import '@xterm/xterm/css/xterm.css';
7
+ import { tmuxEnvPrefix } from '@/lib/session-utils';
7
8
 
8
9
  const SESSION_NAME = 'mw-forge-help';
9
10
 
@@ -31,6 +32,7 @@ export default function HelpTerminal() {
31
32
  // `help` scene (binary path + --model models.help + env exports). Falls back
32
33
  // to bare `claude` if resolution fails.
33
34
  let launchCmd = 'claude';
35
+ let helpEnv: Record<string, string> = {};
34
36
 
35
37
  const cs = getComputedStyle(document.documentElement);
36
38
  const tv = (name: string) => cs.getPropertyValue(name).trim();
@@ -77,6 +79,9 @@ export default function HelpTerminal() {
77
79
  setConnected(true);
78
80
  if (isNewSession) {
79
81
  isNewSession = false;
82
+ if (Object.keys(helpEnv).length && socket.readyState === WebSocket.OPEN) {
83
+ socket.send(JSON.stringify({ type: 'setenv', sessionName: SESSION_NAME, env: helpEnv }));
84
+ }
80
85
  setTimeout(() => {
81
86
  if (socket.readyState === WebSocket.OPEN) {
82
87
  socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && ${launchCmd}\n` }));
@@ -110,11 +115,10 @@ export default function HelpTerminal() {
110
115
  const info = await fetch(`/api/agents?resolve=${encodeURIComponent(defaultId)}&scene=help`).then(r => r.json());
111
116
  const bin = info?.cliCmd || 'claude';
112
117
  const modelFlag = info?.model ? ` --model ${info.model}` : '';
113
- const envPrefix = info?.env
114
- ? Object.entries(info.env as Record<string, string>)
115
- .map(([k, v]) => `export ${k}=${JSON.stringify(v)}`)
116
- .join(' && ') + ' && '
117
- : '';
118
+ // Secret env injected via `tmux set-environment` on connect — the
119
+ // prefix only names the vars, so API keys never echo into the pane.
120
+ helpEnv = (info?.env as Record<string, string>) || {};
121
+ const envPrefix = tmuxEnvPrefix(Object.keys(helpEnv));
118
122
  launchCmd = `${envPrefix}${bin}${modelFlag}`;
119
123
  } catch {
120
124
  // Fallback to bare path from the agent list if resolve fails.
@@ -50,6 +50,8 @@ interface OnboardingState {
50
50
  projectRoots: string[];
51
51
  };
52
52
  detected_cli: Array<{ name: string; path: string; version: string }>;
53
+ /** Deployment shape — 'container' deploys can't run `claude login`. */
54
+ deployment?: 'container' | 'host';
53
55
  /** All non-meta connector ids in the template, in template order. */
54
56
  template_connectors: Array<{
55
57
  id: string;
@@ -216,6 +218,11 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
216
218
  const [cliAgentTool, setCliAgentTool] = useState<'claude' | 'codex' | 'aider' | 'opencode'>('claude');
217
219
  const [cliAgentPath, setCliAgentPath] = useState('');
218
220
  const [cliSetAsDefault, setCliSetAsDefault] = useState(true);
221
+ // Claude auth: 'login' (run `claude login` on host) vs 'token' (paste
222
+ // CLAUDE_CODE_OAUTH_TOKEN — required in containers where login can't reach
223
+ // a keychain). Defaults from detected deployment; user can override.
224
+ const [claudeAuthMode, setClaudeAuthMode] = useState<'login' | 'token'>('login');
225
+ const [claudeOauthToken, setClaudeOauthToken] = useState('');
219
226
 
220
227
  // Section 3: Connector template values (one per ${key})
221
228
  const [connectorValues, setConnectorValues] = useState<Record<string, string>>({});
@@ -354,7 +361,8 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
354
361
  if (!initializedRef.current) { initializedRef.current = true; return; }
355
362
  setDirty(true);
356
363
  }, [displayName, displayEmail, apiKey, apiBaseUrl, apiModel, apiProfileId, apiProvider,
357
- connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
364
+ connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath,
365
+ claudeAuthMode, claudeOauthToken]);
358
366
  // Inline marketplace sync (so user doesn't have to leave the wizard
359
367
  // to populate the pipeline list when it's empty / stale).
360
368
  const [syncingMarket, setSyncingMarket] = useState(false);
@@ -469,6 +477,9 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
469
477
  setCliAgentTool(first.name as any);
470
478
  setCliAgentPath(first.path);
471
479
  }
480
+
481
+ // Container deploys can't run `claude login` — default to pasting a token.
482
+ if (s.deployment === 'container') setClaudeAuthMode('token');
472
483
  }).catch(() => setState({
473
484
  ok: false, onboardingCompleted: false,
474
485
  current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
@@ -605,6 +616,11 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
605
616
  tool: cliAgentTool,
606
617
  ...(cliAgentPath ? { path: cliAgentPath } : {}),
607
618
  setAsDefault: cliSetAsDefault,
619
+ // Container claude auth: pasted OAuth token → encrypted agent env.
620
+ // Blank is fine (backend keeps any existing token).
621
+ ...(cliAgentTool === 'claude' && claudeAuthMode === 'token' && claudeOauthToken.trim()
622
+ ? { env: { CLAUDE_CODE_OAUTH_TOKEN: claudeOauthToken.trim() } }
623
+ : {}),
608
624
  };
609
625
  }
610
626
  const r = await fetch('/api/onboarding', {
@@ -1132,6 +1148,54 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
1132
1148
  Use this CLI agent as the default for terminal sessions
1133
1149
  </label>
1134
1150
  )}
1151
+
1152
+ {/* Claude auth — deployment-aware. `claude login` needs a desktop
1153
+ keychain, which containers lack; there you paste a token from
1154
+ `claude setup-token` run on a machine that CAN log in. */}
1155
+ {cliAgentTool === 'claude' && (
1156
+ <div className="mt-2 pt-2 border-t border-[var(--border)]">
1157
+ <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1 flex items-center gap-1.5">
1158
+ Claude.ai auth
1159
+ {state.deployment === 'container'
1160
+ ? <span className="text-[9px] text-amber-500">📦 container detected</span>
1161
+ : <span className="text-[9px] text-emerald-500">🖥 host detected</span>}
1162
+ </div>
1163
+ <div className="flex gap-1 mb-1.5">
1164
+ {(['login', 'token'] as const).map(m => (
1165
+ <button
1166
+ key={m}
1167
+ onClick={() => setClaudeAuthMode(m)}
1168
+ className={`text-[10px] px-2 py-0.5 rounded ${claudeAuthMode === m ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'}`}
1169
+ >
1170
+ {m === 'login' ? 'claude login' : 'paste token'}
1171
+ </button>
1172
+ ))}
1173
+ </div>
1174
+ {claudeAuthMode === 'login' ? (
1175
+ <p className="text-[10px] text-[var(--text-secondary)]">
1176
+ After onboarding, open a terminal and run <code>claude login</code> once.
1177
+ {state.deployment === 'container' && (
1178
+ <span className="text-amber-500"> ⚠ This deployment looks like a container — <code>claude login</code> usually can't reach a keychain here. Prefer “paste token”.</span>
1179
+ )}
1180
+ </p>
1181
+ ) : (
1182
+ <div>
1183
+ <p className="text-[10px] text-[var(--text-secondary)] mb-1">
1184
+ On a machine where you CAN log in to Claude, run <code>claude setup-token</code> and paste the result. Stored encrypted as <code>CLAUDE_CODE_OAUTH_TOKEN</code>.
1185
+ </p>
1186
+ <Field label="CLAUDE_CODE_OAUTH_TOKEN (leave blank to keep existing)">
1187
+ <input
1188
+ type="password"
1189
+ className={inputCls + ' font-mono'}
1190
+ value={claudeOauthToken}
1191
+ onChange={e => setClaudeOauthToken(e.target.value)}
1192
+ placeholder="sk-ant-oat..."
1193
+ />
1194
+ </Field>
1195
+ </div>
1196
+ )}
1197
+ </div>
1198
+ )}
1135
1199
  </Section>
1136
1200
  )}
1137
1201
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useEffect, useCallback, useRef, memo, lazy, Suspense } from 'react';
4
4
  import { useSidebarResize } from '@/hooks/useSidebarResize';
5
+ import { updateTreeChildren } from '@/lib/fileTree';
5
6
 
6
7
  import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLauncher';
7
8
  const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
@@ -65,6 +66,14 @@ interface GitInfo {
65
66
  log: { hash: string; message: string; author: string; date: string }[];
66
67
  }
67
68
 
69
+ interface FileTreeNodeData {
70
+ name: string;
71
+ path: string;
72
+ type: string;
73
+ children?: FileTreeNodeData[];
74
+ hasChildren?: boolean;
75
+ }
76
+
68
77
  export default memo(function ProjectDetail({ projectPath, projectName, hasGit }: { projectPath: string; projectName: string; hasGit: boolean }) {
69
78
  const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 208, minWidth: 120, maxWidth: 400 });
70
79
  const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
@@ -167,6 +176,14 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
167
176
  } catch { setFileTree([]); }
168
177
  }, [projectPath]);
169
178
 
179
+ const loadTreeChildren = useCallback(async (path: string) => {
180
+ try {
181
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&treePath=${encodeURIComponent(path)}`);
182
+ const data = await res.json();
183
+ setFileTree(prev => updateTreeChildren(prev, path, data.tree || []));
184
+ } catch {}
185
+ }, [projectPath]);
186
+
170
187
  const openFile = useCallback(async (path: string) => {
171
188
  setSelectedFile(path);
172
189
  setDiffContent(null);
@@ -771,7 +788,7 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
771
788
  {/* File tree */}
772
789
  <div className="overflow-y-auto flex-1 p-1">
773
790
  {fileTree.map((node: any) => (
774
- <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} collapseVersion={treeCollapseVersion} />
791
+ <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} onLoadChildren={loadTreeChildren} collapseVersion={treeCollapseVersion} />
775
792
  ))}
776
793
  </div>
777
794
  </div>
@@ -1582,14 +1599,15 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
1582
1599
  });
1583
1600
 
1584
1601
  // Simple file tree node
1585
- const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect, collapseVersion }: {
1586
- node: { name: string; path: string; type: string; children?: any[] };
1602
+ const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect, onLoadChildren, collapseVersion }: {
1603
+ node: FileTreeNodeData;
1587
1604
  depth: number;
1588
1605
  selected: string | null;
1589
1606
  onSelect: (path: string) => void;
1607
+ onLoadChildren: (path: string) => Promise<void>;
1590
1608
  collapseVersion: number;
1591
1609
  }) {
1592
- const [expanded, setExpanded] = useState(depth < 1);
1610
+ const [expanded, setExpanded] = useState(false);
1593
1611
 
1594
1612
  // When parent bumps collapseVersion, collapse this node
1595
1613
  useEffect(() => {
@@ -1597,18 +1615,26 @@ const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelec
1597
1615
  }, [collapseVersion]);
1598
1616
 
1599
1617
  if (node.type === 'dir') {
1618
+ const hasChildren = node.hasChildren || (node.children?.length ?? 0) > 0;
1619
+ const toggleExpanded = async () => {
1620
+ const nextExpanded = !expanded;
1621
+ setExpanded(nextExpanded);
1622
+ if (nextExpanded && hasChildren && !node.children) {
1623
+ await onLoadChildren(node.path);
1624
+ }
1625
+ };
1600
1626
  return (
1601
1627
  <div>
1602
1628
  <button
1603
- onClick={() => setExpanded(v => !v)}
1629
+ onClick={toggleExpanded}
1604
1630
  className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
1605
1631
  style={{ paddingLeft: depth * 12 + 4 }}
1606
1632
  >
1607
- <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
1633
+ <span className="text-[10px] text-[var(--text-secondary)] w-3">{hasChildren ? (expanded ? '▾' : '▸') : ''}</span>
1608
1634
  <span className="text-[var(--text-primary)]">{node.name}</span>
1609
1635
  </button>
1610
1636
  {expanded && node.children?.map((child: any) => (
1611
- <FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} collapseVersion={collapseVersion} />
1637
+ <FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} onLoadChildren={onLoadChildren} collapseVersion={collapseVersion} />
1612
1638
  ))}
1613
1639
  </div>
1614
1640
  );