@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.
@@ -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
 
@@ -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.
@@ -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
+ }