@aion0/forge 0.3.4 → 0.3.6

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/CLAUDE.md CHANGED
@@ -47,6 +47,21 @@ forge watch <id> # live stream task output
47
47
  - npm package: `@aion0/forge`
48
48
  - GitHub: `github.com/aiwatching/forge`
49
49
 
50
+ ### Help Docs Rule
51
+ When adding or changing a feature, check if `lib/help-docs/` needs updating. Each file covers one module:
52
+ - `00-overview.md` — install, start, data paths
53
+ - `01-settings.md` — all settings fields
54
+ - `02-telegram.md` — bot setup and commands
55
+ - `03-tunnel.md` — remote access
56
+ - `04-tasks.md` — background tasks
57
+ - `05-pipelines.md` — DAG workflows
58
+ - `06-skills.md` — marketplace
59
+ - `07-projects.md` — project management
60
+ - `08-rules.md` — CLAUDE.md templates
61
+ - `09-issue-autofix.md` — GitHub issue scanner
62
+ - `10-troubleshooting.md` — common issues
63
+ If a feature change affects user-facing behavior, update the corresponding help doc in the same commit.
64
+
50
65
  ### Architecture
51
66
  - `forge-server.mjs` starts: Next.js + terminal-standalone + telegram-standalone
52
67
  - `pnpm dev` / `start.sh dev` starts: Next.js (init.ts spawns terminal + telegram)
@@ -0,0 +1,26 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/src/core/db/database';
3
+ import { getDbPath } from '@/src/config';
4
+
5
+ function db() { return getDb(getDbPath()); }
6
+
7
+ // GET /api/favorites — list all favorites
8
+ export async function GET() {
9
+ const rows = db().prepare('SELECT project_path FROM project_favorites ORDER BY created_at ASC').all() as any[];
10
+ return NextResponse.json(rows.map(r => r.project_path));
11
+ }
12
+
13
+ // POST /api/favorites — add or remove
14
+ export async function POST(req: Request) {
15
+ const { action, projectPath } = await req.json();
16
+ if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
17
+
18
+ if (action === 'add') {
19
+ db().prepare('INSERT OR IGNORE INTO project_favorites (project_path) VALUES (?)').run(projectPath);
20
+ } else if (action === 'remove') {
21
+ db().prepare('DELETE FROM project_favorites WHERE project_path = ?').run(projectPath);
22
+ }
23
+
24
+ const rows = db().prepare('SELECT project_path FROM project_favorites ORDER BY created_at ASC').all() as any[];
25
+ return NextResponse.json(rows.map(r => r.project_path));
26
+ }
@@ -1,8 +1,9 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import { execSync } from 'node:child_process';
2
+ import { execSync, exec } from 'node:child_process';
3
3
  import { existsSync, readdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
+ import { promisify } from 'node:util';
6
7
  import { loadSettings } from '@/lib/settings';
7
8
 
8
9
  function isUnderProjectRoot(dir: string): boolean {
@@ -11,8 +12,17 @@ function isUnderProjectRoot(dir: string): boolean {
11
12
  return roots.some(root => dir.startsWith(root) || dir === root);
12
13
  }
13
14
 
14
- function git(cmd: string, cwd: string): string {
15
- return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
15
+ const execAsync = promisify(exec);
16
+
17
+ function gitSync(cmd: string, cwd: string): string {
18
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
19
+ }
20
+
21
+ async function gitAsync(cmd: string, cwd: string): Promise<string> {
22
+ try {
23
+ const { stdout } = await execAsync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 });
24
+ return stdout.trim();
25
+ } catch { return ''; }
16
26
  }
17
27
 
18
28
  // GET /api/git?dir=<path> — git status for a project
@@ -23,38 +33,33 @@ export async function GET(req: NextRequest) {
23
33
  }
24
34
 
25
35
  try {
26
- const branch = git('rev-parse --abbrev-ref HEAD', dir);
27
- const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
28
- const changes = statusRaw.replace(/\n$/, '').split('\n').filter(Boolean).map(line => ({
36
+ // Run all git commands in parallel
37
+ const [branchOut, statusOut, remoteOut, lastCommitOut, logOut] = await Promise.all([
38
+ gitAsync('rev-parse --abbrev-ref HEAD', dir),
39
+ gitAsync('status --porcelain -u', dir),
40
+ gitAsync('remote get-url origin', dir),
41
+ gitAsync('log -1 --format="%h %s"', dir),
42
+ gitAsync('log --format="%h||%s||%an||%ar" -10', dir),
43
+ ]);
44
+
45
+ const branch = branchOut;
46
+ const changes = statusOut ? statusOut.split('\n').filter(Boolean).map(line => ({
29
47
  status: line.substring(0, 2).trim() || 'M',
30
48
  path: line.substring(3).replace(/\/$/, ''),
31
- }));
32
-
33
- let remote = '';
34
- try { remote = git('remote get-url origin', dir); } catch {}
49
+ })) : [];
35
50
 
36
- let ahead = 0;
37
- let behind = 0;
51
+ let ahead = 0, behind = 0;
38
52
  try {
39
- const counts = git(`rev-list --left-right --count HEAD...origin/${branch}`, dir);
40
- const [a, b] = counts.split('\t');
41
- ahead = parseInt(a) || 0;
42
- behind = parseInt(b) || 0;
53
+ const counts = await gitAsync(`rev-list --left-right --count HEAD...origin/${branch}`, dir);
54
+ if (counts) { const [a, b] = counts.split('\t'); ahead = parseInt(a) || 0; behind = parseInt(b) || 0; }
43
55
  } catch {}
44
56
 
45
- const lastCommit = git('log -1 --format="%h %s" 2>/dev/null || echo ""', dir);
46
-
47
- // Git log recent commits
48
- let log: { hash: string; message: string; author: string; date: string }[] = [];
49
- try {
50
- const logOut = git('log --format="%h||%s||%an||%ar" -20', dir);
51
- log = logOut.split('\n').filter(Boolean).map(line => {
52
- const [hash, message, author, date] = line.split('||');
53
- return { hash, message, author, date };
54
- });
55
- } catch {}
57
+ const log = logOut ? logOut.split('\n').filter(Boolean).map(line => {
58
+ const [hash, message, author, date] = line.split('||');
59
+ return { hash, message, author, date };
60
+ }) : [];
56
61
 
57
- return NextResponse.json({ branch, changes, remote, ahead, behind, lastCommit, log });
62
+ return NextResponse.json({ branch, changes, remote: remoteOut, ahead, behind, lastCommit: lastCommitOut, log });
58
63
  } catch (e: any) {
59
64
  return NextResponse.json({ error: e.message }, { status: 500 });
60
65
  }
@@ -96,30 +101,30 @@ export async function POST(req: NextRequest) {
96
101
  if (!message) return NextResponse.json({ error: 'message required' }, { status: 400 });
97
102
  if (files && files.length > 0) {
98
103
  for (const f of files) {
99
- git(`add "${f}"`, dir);
104
+ gitSync(`add "${f}"`, dir);
100
105
  }
101
106
  } else {
102
- git('add -A', dir);
107
+ gitSync('add -A', dir);
103
108
  }
104
- git(`commit -m "${message.replace(/"/g, '\\"')}"`, dir);
109
+ gitSync(`commit -m "${message.replace(/"/g, '\\"')}"`, dir);
105
110
  return NextResponse.json({ ok: true });
106
111
  }
107
112
 
108
113
  if (action === 'push') {
109
- const output = git('push', dir);
114
+ const output = gitSync('push', dir);
110
115
  return NextResponse.json({ ok: true, output });
111
116
  }
112
117
 
113
118
  if (action === 'pull') {
114
- const output = git('pull', dir);
119
+ const output = gitSync('pull', dir);
115
120
  return NextResponse.json({ ok: true, output });
116
121
  }
117
122
 
118
123
  if (action === 'stage') {
119
124
  if (files && files.length > 0) {
120
- for (const f of files) git(`add "${f}"`, dir);
125
+ for (const f of files) gitSync(`add "${f}"`, dir);
121
126
  } else {
122
- git('add -A', dir);
127
+ gitSync('add -A', dir);
123
128
  }
124
129
  return NextResponse.json({ ok: true });
125
130
  }
@@ -0,0 +1,78 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getConfigDir } from '@/lib/dirs';
5
+ import { loadSettings } from '@/lib/settings';
6
+ import { execSync } from 'node:child_process';
7
+
8
+ const HELP_DIR = join(getConfigDir(), 'help');
9
+ const SOURCE_HELP_DIR = join(process.cwd(), 'lib', 'help-docs');
10
+
11
+ /** Ensure help docs are copied to ~/.forge/help/ */
12
+ function ensureHelpDocs() {
13
+ if (!existsSync(HELP_DIR)) mkdirSync(HELP_DIR, { recursive: true });
14
+ if (existsSync(SOURCE_HELP_DIR)) {
15
+ for (const file of readdirSync(SOURCE_HELP_DIR)) {
16
+ if (!file.endsWith('.md')) continue;
17
+ const src = join(SOURCE_HELP_DIR, file);
18
+ const dest = join(HELP_DIR, file);
19
+ // Always overwrite to keep docs up to date
20
+ writeFileSync(dest, readFileSync(src));
21
+ }
22
+ }
23
+ }
24
+
25
+ /** Check if any agent CLI is available */
26
+ function detectAgent(): { name: string; path: string } | null {
27
+ const settings = loadSettings();
28
+ if (settings.claudePath) {
29
+ try {
30
+ execSync(`"${settings.claudePath}" --version`, { timeout: 5000, stdio: 'pipe' });
31
+ return { name: 'claude', path: settings.claudePath };
32
+ } catch {}
33
+ }
34
+ for (const agent of ['claude', 'codex', 'aider']) {
35
+ try {
36
+ const path = execSync(`which ${agent}`, { encoding: 'utf-8', timeout: 3000, stdio: 'pipe' }).trim();
37
+ if (path) return { name: agent, path };
38
+ } catch {}
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // GET /api/help
44
+ export async function GET(req: Request) {
45
+ const { searchParams } = new URL(req.url);
46
+ const action = searchParams.get('action') || 'status';
47
+
48
+ if (action === 'status') {
49
+ const agent = detectAgent();
50
+ ensureHelpDocs();
51
+ const docs = existsSync(HELP_DIR)
52
+ ? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort()
53
+ : [];
54
+ return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR });
55
+ }
56
+
57
+ if (action === 'docs') {
58
+ ensureHelpDocs();
59
+ const docs = existsSync(HELP_DIR)
60
+ ? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort().map(f => ({
61
+ name: f,
62
+ title: f.replace(/^\d+-/, '').replace(/\.md$/, '').replace(/-/g, ' '),
63
+ }))
64
+ : [];
65
+ return NextResponse.json({ docs });
66
+ }
67
+
68
+ if (action === 'doc') {
69
+ const name = searchParams.get('name');
70
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
71
+ ensureHelpDocs();
72
+ const file = join(HELP_DIR, name);
73
+ if (!existsSync(file)) return NextResponse.json({ error: 'Not found' }, { status: 404 });
74
+ return NextResponse.json({ content: readFileSync(file, 'utf-8') });
75
+ }
76
+
77
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
78
+ }
@@ -99,8 +99,6 @@ export async function GET(req: Request) {
99
99
  return NextResponse.json({ content: '(Failed to load)' });
100
100
  }
101
101
  }
102
- // Refresh install state from filesystem
103
- refreshInstallState(getProjectPaths());
104
102
  const skills = listSkills();
105
103
  const projects = getProjectPaths().map(p => ({ path: p, name: p.split('/').pop() || p }));
106
104
  return NextResponse.json({ skills, projects });
@@ -0,0 +1,25 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getDb } from '@/src/core/db/database';
3
+ import { getDbPath } from '@/src/config';
4
+
5
+ function db() { return getDb(getDbPath()); }
6
+
7
+ export async function GET(req: NextRequest) {
8
+ const type = req.nextUrl.searchParams.get('type') || 'projects';
9
+ try {
10
+ const row = db().prepare('SELECT data FROM tab_state WHERE type = ?').get(type) as any;
11
+ if (row?.data) return NextResponse.json(JSON.parse(row.data));
12
+ } catch {}
13
+ return NextResponse.json({ tabs: [], activeTabId: 0 });
14
+ }
15
+
16
+ export async function POST(req: NextRequest) {
17
+ const type = req.nextUrl.searchParams.get('type') || 'projects';
18
+ try {
19
+ const body = await req.json();
20
+ db().prepare('INSERT OR REPLACE INTO tab_state (type, data) VALUES (?, ?)').run(type, JSON.stringify(body));
21
+ return NextResponse.json({ ok: true });
22
+ } catch (e: any) {
23
+ return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
24
+ }
25
+ }
@@ -189,7 +189,7 @@ if (!isStop) {
189
189
  writeFileSync(settingsFile, YAML.stringify(settings), 'utf-8');
190
190
  console.log('[forge] Admin password saved');
191
191
 
192
- if (resetPassword && !isDev && !isBackground && !isRestart) {
192
+ if (resetPassword) {
193
193
  process.exit(0);
194
194
  }
195
195
  }
@@ -18,6 +18,7 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
18
18
  const ProjectManager = lazy(() => import('./ProjectManager'));
19
19
  const PreviewPanel = lazy(() => import('./PreviewPanel'));
20
20
  const PipelineView = lazy(() => import('./PipelineView'));
21
+ const HelpDialog = lazy(() => import('./HelpDialog'));
21
22
  const SkillsPanel = lazy(() => import('./SkillsPanel'));
22
23
 
23
24
  interface UsageSummary {
@@ -47,6 +48,7 @@ export default function Dashboard({ user }: { user: any }) {
47
48
  const [showNewTask, setShowNewTask] = useState(false);
48
49
  const [showSettings, setShowSettings] = useState(false);
49
50
  const [showMonitor, setShowMonitor] = useState(false);
51
+ const [showHelp, setShowHelp] = useState(false);
50
52
  const [usage, setUsage] = useState<UsageSummary[]>([]);
51
53
  const [providers, setProviders] = useState<ProviderInfo[]>([]);
52
54
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
@@ -262,6 +264,15 @@ export default function Dashboard({ user }: { user: any }) {
262
264
  + New Task
263
265
  </button>
264
266
  )}
267
+ {/* Help */}
268
+ <button
269
+ onClick={() => setShowHelp(v => !v)}
270
+ className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
271
+ showHelp
272
+ ? 'border-[var(--accent)] text-[var(--accent)]'
273
+ : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
274
+ }`}
275
+ >?</button>
265
276
  {/* Preview + Tunnel */}
266
277
  <button
267
278
  onClick={() => setViewMode('preview')}
@@ -597,6 +608,11 @@ export default function Dashboard({ user }: { user: any }) {
597
608
  {showSettings && (
598
609
  <SettingsModal onClose={() => { setShowSettings(false); fetchData(); refreshDisplayName(); }} />
599
610
  )}
611
+ {showHelp && (
612
+ <Suspense fallback={null}>
613
+ <HelpDialog onClose={() => setShowHelp(false)} />
614
+ </Suspense>
615
+ )}
600
616
  </div>
601
617
  );
602
618
  }
@@ -2,9 +2,21 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
4
  import MarkdownContent from './MarkdownContent';
5
+ import TabBar from './TabBar';
5
6
 
6
7
  const DocTerminal = lazy(() => import('./DocTerminal'));
7
8
 
9
+ interface DocTab {
10
+ id: number;
11
+ filePath: string;
12
+ fileName: string;
13
+ rootIdx: number;
14
+ content: string | null;
15
+ isImage: boolean;
16
+ }
17
+
18
+ function genTabId(): number { return Date.now() + Math.floor(Math.random() * 10000); }
19
+
8
20
  interface FileNode {
9
21
  name: string;
10
22
  path: string;
@@ -89,6 +101,141 @@ export default function DocsViewer() {
89
101
  const [saving, setSaving] = useState(false);
90
102
  const dragRef = useRef<{ startY: number; startH: number } | null>(null);
91
103
 
104
+ // Doc tabs
105
+ const [docTabs, setDocTabs] = useState<DocTab[]>([]);
106
+ const [activeDocTabId, setActiveDocTabId] = useState(0);
107
+ const saveTimerRef = useRef<any>(null);
108
+
109
+ // Load tabs from DB on mount
110
+ useEffect(() => {
111
+ fetch('/api/tabs?type=docs').then(r => r.json())
112
+ .then(data => {
113
+ if (Array.isArray(data.tabs) && data.tabs.length > 0) {
114
+ setDocTabs(data.tabs);
115
+ setActiveDocTabId(data.activeTabId || data.tabs[0].id);
116
+ // Set selectedFile to active tab's file
117
+ const activeId = data.activeTabId || data.tabs[0].id;
118
+ const active = data.tabs.find((t: any) => t.id === activeId);
119
+ if (active) {
120
+ setSelectedFile(active.filePath);
121
+ // Content not stored in DB, fetch it
122
+ if (!active.isImage) {
123
+ fetch(`/api/docs?root=${active.rootIdx}&file=${encodeURIComponent(active.filePath)}`)
124
+ .then(r => r.json())
125
+ .then(d => { setContent(d.content || null); })
126
+ .catch(() => {});
127
+ }
128
+ }
129
+ }
130
+ }).catch(() => {});
131
+ }, []);
132
+
133
+ // Persist tabs (debounced)
134
+ const persistDocTabs = useCallback((tabs: DocTab[], activeId: number) => {
135
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
136
+ saveTimerRef.current = setTimeout(() => {
137
+ fetch('/api/tabs?type=docs', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({
141
+ tabs: tabs.map(t => ({ id: t.id, filePath: t.filePath, fileName: t.fileName, rootIdx: t.rootIdx, isImage: t.isImage })),
142
+ activeTabId: activeId,
143
+ }),
144
+ }).catch(() => {});
145
+ }, 500);
146
+ }, []);
147
+
148
+ // Open file in tab
149
+ const openFileInTab = useCallback(async (path: string) => {
150
+ setSelectedFile(path);
151
+ setEditing(false);
152
+ setFileWarning(null);
153
+
154
+ const isImg = isImageFile(path);
155
+ const fileName = path.split('/').pop() || path;
156
+
157
+ // Check if tab already exists (use functional update to get latest state)
158
+ let found = false;
159
+ setDocTabs(prev => {
160
+ const existing = prev.find(t => t.filePath === path);
161
+ if (existing) {
162
+ found = true;
163
+ setActiveDocTabId(existing.id);
164
+ setContent(existing.content);
165
+ persistDocTabs(prev, existing.id);
166
+ }
167
+ return prev;
168
+ });
169
+ if (found) return;
170
+
171
+ // Fetch content
172
+ let fileContent: string | null = null;
173
+ if (!isImg) {
174
+ setLoading(true);
175
+ const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
176
+ const data = await res.json();
177
+ if (data.tooLarge) {
178
+ setFileWarning(`File too large (${data.sizeLabel})`);
179
+ } else {
180
+ fileContent = data.content || null;
181
+ }
182
+ setLoading(false);
183
+ }
184
+ setContent(fileContent);
185
+
186
+ const newTab: DocTab = { id: genTabId(), filePath: path, fileName, rootIdx: activeRoot, isImage: isImg, content: fileContent };
187
+ setDocTabs(prev => {
188
+ // Double-check no duplicate
189
+ if (prev.find(t => t.filePath === path)) return prev;
190
+ const updated = [...prev, newTab];
191
+ setActiveDocTabId(newTab.id);
192
+ persistDocTabs(updated, newTab.id);
193
+ return updated;
194
+ });
195
+ }, [activeRoot, persistDocTabs]);
196
+
197
+ const closeDocTab = useCallback((tabId: number) => {
198
+ setDocTabs(prev => {
199
+ const updated = prev.filter(t => t.id !== tabId);
200
+ let newActiveId = activeDocTabId;
201
+ if (tabId === activeDocTabId) {
202
+ const idx = prev.findIndex(t => t.id === tabId);
203
+ const next = updated[Math.min(idx, updated.length - 1)];
204
+ newActiveId = next?.id || 0;
205
+ if (next) { setSelectedFile(next.filePath); setContent(next.content); }
206
+ else { setSelectedFile(null); setContent(null); }
207
+ }
208
+ setActiveDocTabId(newActiveId);
209
+ persistDocTabs(updated, newActiveId);
210
+ return updated;
211
+ });
212
+ }, [activeDocTabId, persistDocTabs]);
213
+
214
+ const activateDocTab = useCallback(async (tabId: number) => {
215
+ const tab = docTabs.find(t => t.id === tabId);
216
+ if (tab) {
217
+ setActiveDocTabId(tabId);
218
+ setSelectedFile(tab.filePath);
219
+ setEditing(false);
220
+ if (tab.rootIdx !== activeRoot) setActiveRoot(tab.rootIdx);
221
+ persistDocTabs(docTabs, tabId);
222
+
223
+ // Use cached content or re-fetch
224
+ if (tab.content) {
225
+ setContent(tab.content);
226
+ } else if (!tab.isImage) {
227
+ setLoading(true);
228
+ const res = await fetch(`/api/docs?root=${tab.rootIdx}&file=${encodeURIComponent(tab.filePath)}`);
229
+ const data = await res.json();
230
+ const fetched = data.content || null;
231
+ setContent(fetched);
232
+ // Cache in tab
233
+ setDocTabs(prev => prev.map(t => t.id === tabId ? { ...t, content: fetched } : t));
234
+ setLoading(false);
235
+ }
236
+ }
237
+ }, [docTabs, activeRoot, persistDocTabs]);
238
+
92
239
  // Fetch tree
93
240
  const fetchTree = useCallback(async (rootIdx: number) => {
94
241
  const res = await fetch(`/api/docs?root=${rootIdx}`);
@@ -223,7 +370,7 @@ export default function DocsViewer() {
223
370
  filtered.map(f => (
224
371
  <button
225
372
  key={f.path}
226
- onClick={() => { openFile(f.path); setSearch(''); }}
373
+ onClick={() => { openFileInTab(f.path); setSearch(''); }}
227
374
  className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
228
375
  selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
229
376
  }`}
@@ -236,15 +383,25 @@ export default function DocsViewer() {
236
383
  )
237
384
  ) : (
238
385
  tree.map(node => (
239
- <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
386
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
240
387
  ))
241
388
  )}
242
389
  </div>
243
390
  </aside>
244
391
  )}
245
392
 
246
- {/* Main content — full width markdown */}
393
+ {/* Main content */}
247
394
  <main className="flex-1 flex flex-col min-w-0">
395
+ {/* Doc tab bar */}
396
+ {docTabs.length > 0 && (
397
+ <TabBar
398
+ tabs={docTabs.map(t => ({ id: t.id, label: t.fileName.replace(/\.md$/, '') }))}
399
+ activeId={activeDocTabId}
400
+ onActivate={activateDocTab}
401
+ onClose={closeDocTab}
402
+ />
403
+ )}
404
+
248
405
  {/* Top bar */}
249
406
  <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
250
407
  <button