@aion0/forge 0.10.77 → 0.10.79
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 +6 -6
- 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/chat/page.tsx +53 -1
- package/components/CodeViewer.tsx +127 -41
- package/components/DocsViewer.tsx +34 -22
- package/components/HelpTerminal.tsx +9 -5
- package/components/MobileChat.tsx +225 -0
- package/components/MobileView.tsx +22 -2
- package/components/OnboardingWizard.tsx +65 -1
- package/components/ProjectDetail.tsx +33 -7
- package/components/WebTerminal.tsx +19 -8
- package/components/WorkspaceView.tsx +68 -47
- package/lib/agents/index.ts +9 -0
- package/lib/chat/telegram-bridge.ts +15 -0
- package/lib/fileTree.ts +28 -0
- package/lib/help-docs/00-overview.md +2 -0
- package/lib/help-docs/01-settings.md +11 -0
- package/lib/help-docs/02-telegram.md +3 -0
- package/lib/help-docs/07-projects.md +3 -1
- package/lib/projects.ts +15 -5
- package/lib/session-utils.ts +19 -0
- package/lib/telegram-bot.ts +74 -26
- package/lib/terminal-standalone.ts +17 -0
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -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.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mobile chat view — talks to chat-standalone via /api/chat-proxy, the same
|
|
5
|
+
* backend as the web /chat and the Telegram chat bridge. Self-contained:
|
|
6
|
+
* session picker (+ new), message history, streaming SSE. Rendered by
|
|
7
|
+
* MobileView when viewMode === 'chat' (the default).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
11
|
+
|
|
12
|
+
const PROXY = '/api/chat-proxy';
|
|
13
|
+
|
|
14
|
+
interface ChatSession { id: string; title?: string; updated_at?: string }
|
|
15
|
+
interface Block { type: string; text?: string; name?: string }
|
|
16
|
+
interface ChatMsg { id?: string; role: string; blocks?: Block[]; content?: string; error?: string }
|
|
17
|
+
|
|
18
|
+
function msgText(m: ChatMsg): string {
|
|
19
|
+
if (typeof m.content === 'string' && m.content) return m.content;
|
|
20
|
+
if (Array.isArray(m.blocks)) {
|
|
21
|
+
return m.blocks
|
|
22
|
+
.map((b) => (b.type === 'text' ? (b.text || '') : b.type === 'tool_use' ? `⚙ ${b.name || 'tool'}` : ''))
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join('\n');
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function MobileChat() {
|
|
30
|
+
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
31
|
+
const [activeId, setActiveId] = useState<string>('');
|
|
32
|
+
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
|
33
|
+
const [input, setInput] = useState('');
|
|
34
|
+
const [streaming, setStreaming] = useState(false);
|
|
35
|
+
const [partial, setPartial] = useState('');
|
|
36
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
37
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
const srcRef = useRef<EventSource | null>(null);
|
|
39
|
+
|
|
40
|
+
const refreshSessions = useCallback(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const r = await fetch(`${PROXY}/sessions?limit=50`);
|
|
43
|
+
const j = await r.json();
|
|
44
|
+
setSessions(Array.isArray(j?.sessions) ? j.sessions : []);
|
|
45
|
+
} catch { /* keep */ }
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const loadMessages = useCallback(async (id: string) => {
|
|
49
|
+
try {
|
|
50
|
+
const r = await fetch(`${PROXY}/sessions/${encodeURIComponent(id)}?limit=500`);
|
|
51
|
+
const j = await r.json();
|
|
52
|
+
setMessages(Array.isArray(j?.messages) ? j.messages : []);
|
|
53
|
+
} catch { /* keep */ }
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
// First load: pick the main session (or the most recent / a fresh one).
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
(async () => {
|
|
59
|
+
await refreshSessions();
|
|
60
|
+
try {
|
|
61
|
+
const r = await fetch(`${PROXY}/sessions/main`);
|
|
62
|
+
if (r.ok) {
|
|
63
|
+
const j = await r.json();
|
|
64
|
+
const id = j?.session?.id || j?.id;
|
|
65
|
+
if (id) { setActiveId(id); return; }
|
|
66
|
+
}
|
|
67
|
+
} catch { /* fall through */ }
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch(`${PROXY}/sessions?limit=1`);
|
|
70
|
+
const j = await r.json();
|
|
71
|
+
const id = j?.sessions?.[0]?.id;
|
|
72
|
+
if (id) setActiveId(id);
|
|
73
|
+
} catch { /* none */ }
|
|
74
|
+
})();
|
|
75
|
+
}, [refreshSessions]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => { if (activeId) loadMessages(activeId); }, [activeId, loadMessages]);
|
|
78
|
+
|
|
79
|
+
// Auto-scroll
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
82
|
+
}, [messages, partial]);
|
|
83
|
+
|
|
84
|
+
// SSE subscription per active session.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!activeId) return;
|
|
87
|
+
srcRef.current?.close();
|
|
88
|
+
const src = new EventSource(`${PROXY}/sessions/${encodeURIComponent(activeId)}/events`);
|
|
89
|
+
srcRef.current = src;
|
|
90
|
+
src.onmessage = (ev) => {
|
|
91
|
+
let p: { type?: string; data?: any };
|
|
92
|
+
try { p = JSON.parse(ev.data); } catch { return; }
|
|
93
|
+
const data = p.data || {};
|
|
94
|
+
if (p.type === 'text_delta') {
|
|
95
|
+
setPartial((s) => s + (data.delta || ''));
|
|
96
|
+
} else if (p.type === 'message_saved') {
|
|
97
|
+
loadMessages(activeId);
|
|
98
|
+
setPartial('');
|
|
99
|
+
} else if (p.type === 'turn_done') {
|
|
100
|
+
setStreaming(false);
|
|
101
|
+
setPartial('');
|
|
102
|
+
loadMessages(activeId);
|
|
103
|
+
refreshSessions();
|
|
104
|
+
} else if (p.type === 'error') {
|
|
105
|
+
setStreaming(false);
|
|
106
|
+
setPartial('');
|
|
107
|
+
setMessages((m) => [...m, { role: 'system', content: `Error: ${data.error || 'unknown'}` }]);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return () => { src.close(); };
|
|
111
|
+
}, [activeId, loadMessages, refreshSessions]);
|
|
112
|
+
|
|
113
|
+
const send = async () => {
|
|
114
|
+
const text = input.trim();
|
|
115
|
+
if (!text || !activeId || streaming) return;
|
|
116
|
+
setInput('');
|
|
117
|
+
setStreaming(true);
|
|
118
|
+
// Optimistic user bubble; turn_done reloads the real thread from DB.
|
|
119
|
+
setMessages((m) => [...m, { role: 'user', content: text }]);
|
|
120
|
+
try {
|
|
121
|
+
await fetch(`${PROXY}/sessions/${encodeURIComponent(activeId)}/messages`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'content-type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ text }),
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setStreaming(false);
|
|
128
|
+
setMessages((m) => [...m, { role: 'system', content: `Send failed: ${(e as Error).message}` }]);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const newSession = async () => {
|
|
133
|
+
try {
|
|
134
|
+
const r = await fetch(`${PROXY}/sessions`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ title: 'Mobile chat' }),
|
|
138
|
+
});
|
|
139
|
+
const j = await r.json();
|
|
140
|
+
const id = j?.session?.id || j?.id;
|
|
141
|
+
if (id) { setActiveId(id); setMessages([]); setShowPicker(false); await refreshSessions(); }
|
|
142
|
+
} catch { /* ignore */ }
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const pickSession = (id: string) => { setActiveId(id); setMessages([]); setShowPicker(false); };
|
|
146
|
+
|
|
147
|
+
const activeTitle = sessions.find((s) => s.id === activeId)?.title || (activeId ? activeId.slice(0, 8) : 'No session');
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
151
|
+
{/* Session bar */}
|
|
152
|
+
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d]">
|
|
153
|
+
<button
|
|
154
|
+
onClick={() => { setShowPicker((v) => !v); if (!showPicker) refreshSessions(); }}
|
|
155
|
+
className="flex-1 text-left text-xs text-[#e6edf3] truncate active:opacity-70"
|
|
156
|
+
>💬 {activeTitle} ▾</button>
|
|
157
|
+
<button onClick={newSession} className="text-xs px-2 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]">+ New</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{showPicker && (
|
|
161
|
+
<div className="shrink-0 max-h-[40vh] overflow-y-auto bg-[#161b22] border-b border-[#30363d]">
|
|
162
|
+
{sessions.length === 0 ? (
|
|
163
|
+
<div className="px-3 py-4 text-xs text-[#8b949e] text-center">No sessions</div>
|
|
164
|
+
) : sessions.map((s) => (
|
|
165
|
+
<button
|
|
166
|
+
key={s.id}
|
|
167
|
+
onClick={() => pickSession(s.id)}
|
|
168
|
+
className={`w-full text-left px-3 py-2 border-b border-[#30363d]/50 text-xs active:bg-[#1c2128] ${s.id === activeId ? 'text-[#7c5bf0]' : 'text-[#e6edf3]'}`}
|
|
169
|
+
>
|
|
170
|
+
<span className="truncate">{s.title || s.id.slice(0, 12)}</span>
|
|
171
|
+
</button>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Messages */}
|
|
177
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 min-h-0 space-y-3">
|
|
178
|
+
{messages.length === 0 && !partial ? (
|
|
179
|
+
<div className="h-full flex items-center justify-center text-sm text-[#8b949e]">Send a message to start.</div>
|
|
180
|
+
) : messages.map((m, i) => {
|
|
181
|
+
const t = msgText(m);
|
|
182
|
+
if (!t) return null;
|
|
183
|
+
return (
|
|
184
|
+
<div key={m.id || i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
185
|
+
<div className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
|
186
|
+
m.role === 'user' ? 'bg-[#7c5bf0] text-white rounded-br-sm'
|
|
187
|
+
: m.role === 'system' ? 'bg-red-900/30 text-red-300 rounded-bl-sm'
|
|
188
|
+
: 'bg-[#1c2128] text-[#e6edf3] rounded-bl-sm'
|
|
189
|
+
}`}>{t}</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
{partial && (
|
|
194
|
+
<div className="flex justify-start">
|
|
195
|
+
<div className="max-w-[85%] rounded-2xl rounded-bl-sm px-3 py-2 text-sm whitespace-pre-wrap break-words bg-[#1c2128] text-[#e6edf3]">{partial}</div>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
{streaming && !partial && (
|
|
199
|
+
<div className="flex justify-start">
|
|
200
|
+
<div className="bg-[#1c2128] rounded-2xl rounded-bl-sm px-3 py-2 text-sm text-[#8b949e]">Thinking…</div>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Input */}
|
|
206
|
+
<div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#161b22] border-t border-[#30363d]">
|
|
207
|
+
<input
|
|
208
|
+
type="text"
|
|
209
|
+
value={input}
|
|
210
|
+
onChange={(e) => setInput(e.target.value)}
|
|
211
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && !streaming) send(); }}
|
|
212
|
+
placeholder={activeId ? 'Type a message…' : 'No session'}
|
|
213
|
+
disabled={!activeId}
|
|
214
|
+
className="flex-1 bg-[#0d1117] border border-[#30363d] rounded-lg px-3 py-2 text-sm text-[#e6edf3] focus:outline-none focus:border-[#7c5bf0] disabled:opacity-50 min-w-0"
|
|
215
|
+
autoComplete="off" autoCorrect="off"
|
|
216
|
+
/>
|
|
217
|
+
<button
|
|
218
|
+
onClick={send}
|
|
219
|
+
disabled={!activeId || !input.trim() || streaming}
|
|
220
|
+
className="px-4 py-2 bg-[#7c5bf0] text-white rounded-lg text-sm font-medium disabled:opacity-50 shrink-0"
|
|
221
|
+
>Send</button>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|