@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.
- package/RELEASE_NOTES.md +9 -8
- package/app/api/code/route.ts +171 -54
- package/app/api/onboarding/route.ts +32 -0
- package/app/api/skills/local/route.ts +5 -4
- package/app/api/tasks/[id]/hook/stop/route.ts +15 -0
- package/app/api/tasks/route.ts +2 -1
- package/cli/mw.mjs +7 -5
- package/cli/mw.ts +8 -6
- package/components/CodeViewer.tsx +127 -41
- package/components/Dashboard.tsx +6 -2
- package/components/DocsViewer.tsx +34 -22
- package/components/HelpTerminal.tsx +9 -5
- package/components/OnboardingWizard.tsx +65 -1
- package/components/ProjectDetail.tsx +33 -7
- package/components/TaskDetail.tsx +28 -1
- package/components/TmuxTaskTerminal.tsx +105 -0
- package/components/WebTerminal.tsx +26 -8
- package/components/WorkspaceView.tsx +68 -47
- package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
- package/docs/design_automation_records/README.md +232 -0
- package/lib/agents/index.ts +9 -0
- package/lib/chat/agent-loop.ts +6 -0
- package/lib/chat/tool-dispatcher.ts +110 -9
- package/lib/fileTree.ts +28 -0
- package/lib/help-docs/01-settings.md +11 -0
- package/lib/help-docs/05-pipelines.md +31 -0
- package/lib/help-docs/07-projects.md +3 -1
- package/lib/help-docs/25-chat-tools.md +23 -0
- package/lib/pipeline.ts +27 -3
- package/lib/session-utils.ts +19 -0
- package/lib/task-manager.ts +73 -3
- package/lib/task-tmux-backend.ts +625 -0
- package/lib/terminal-standalone.ts +17 -0
- package/lib/workspace/skill-installer.ts +18 -8
- package/package.json +1 -1
- package/proxy.ts +5 -4
- package/src/core/db/database.ts +1 -0
- 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 ??
|
|
37
|
+
const expanded = manualExpanded ?? containsSelected;
|
|
33
38
|
|
|
34
39
|
if (node.type === 'dir') {
|
|
35
|
-
const dirHasChanges =
|
|
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={
|
|
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
|
-
|
|
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)
|
|
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(() =>
|
|
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
|
|
296
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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:
|
|
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(
|
|
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={
|
|
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
|
);
|