@aion0/forge 0.4.9 → 0.4.10

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 CHANGED
@@ -1,11 +1,18 @@
1
- # Forge v0.4.9
1
+ # Forge v0.4.10
2
2
 
3
3
  Released: 2026-03-22
4
4
 
5
- ## Changes since v0.4.8
5
+ ## Changes since v0.4.9
6
6
 
7
- ### Other
8
- - add browser window
7
+ ### Features
8
+ - feat: browser panel refactor, pane close UX, help AI data dir, terminal resume fix
9
9
 
10
+ ### Bug Fixes
11
+ - fix: auto-retry failed issues, batch dedup, gh auth hint
12
+ - feat: browser panel refactor, pane close UX, help AI data dir, terminal resume fix
10
13
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.8...v0.4.9
14
+ ### Refactoring
15
+ - refactor: Browser as independent panel with float/left/right modes
16
+
17
+
18
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.9...v0.4.10
@@ -1,14 +1,14 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { getConfigDir } from '@/lib/dirs';
4
+ import { getConfigDir, getDataDir } from '@/lib/dirs';
5
5
  import { loadSettings } from '@/lib/settings';
6
6
  import { execSync } from 'node:child_process';
7
7
 
8
8
  const HELP_DIR = join(getConfigDir(), 'help');
9
9
  const SOURCE_HELP_DIR = join(process.cwd(), 'lib', 'help-docs');
10
10
 
11
- /** Ensure help docs are copied to ~/.forge/help/ */
11
+ /** Ensure help docs are copied to ~/.forge/help/ and CLAUDE.md to ~/.forge/data/ */
12
12
  function ensureHelpDocs() {
13
13
  if (!existsSync(HELP_DIR)) mkdirSync(HELP_DIR, { recursive: true });
14
14
  if (existsSync(SOURCE_HELP_DIR)) {
@@ -16,10 +16,16 @@ function ensureHelpDocs() {
16
16
  if (!file.endsWith('.md')) continue;
17
17
  const src = join(SOURCE_HELP_DIR, file);
18
18
  const dest = join(HELP_DIR, file);
19
- // Always overwrite to keep docs up to date
20
19
  writeFileSync(dest, readFileSync(src));
21
20
  }
22
21
  }
22
+ // Copy CLAUDE.md to data dir so Help AI (working in ~/.forge/data/) picks it up
23
+ const dataDir = getDataDir();
24
+ const claudeMdSrc = join(HELP_DIR, 'CLAUDE.md');
25
+ const claudeMdDest = join(dataDir, 'CLAUDE.md');
26
+ if (existsSync(claudeMdSrc)) {
27
+ writeFileSync(claudeMdDest, readFileSync(claudeMdSrc));
28
+ }
23
29
  }
24
30
 
25
31
  /** Check if any agent CLI is available */
@@ -51,7 +57,7 @@ export async function GET(req: Request) {
51
57
  const docs = existsSync(HELP_DIR)
52
58
  ? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort()
53
59
  : [];
54
- return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR });
60
+ return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR, dataDir: getDataDir() });
55
61
  }
56
62
 
57
63
  if (action === 'docs') {
@@ -62,6 +62,14 @@ export async function POST(req: Request) {
62
62
  const entry = state.entries.get(body.port);
63
63
  if (entry?.process) {
64
64
  entry.process.kill('SIGTERM');
65
+ } else {
66
+ // Process ref lost (hot-reload) — kill by port match
67
+ try {
68
+ const pids = execSync(`pgrep -f 'cloudflared tunnel.*localhost:${body.port}'`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
69
+ for (const pid of pids.split('\n').filter(Boolean)) {
70
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
71
+ }
72
+ } catch {}
65
73
  }
66
74
  state.entries.delete(body.port);
67
75
  syncConfig();
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+
5
+ interface PreviewEntry {
6
+ port: number;
7
+ url: string | null;
8
+ status: string;
9
+ label?: string;
10
+ }
11
+
12
+ export default function BrowserPanel({ onClose }: { onClose?: () => void }) {
13
+ const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
14
+ const [browserKey, setBrowserKey] = useState(0);
15
+ const [previews, setPreviews] = useState<PreviewEntry[]>([]);
16
+ const [tunnelStarting, setTunnelStarting] = useState(false);
17
+ const browserUrlRef = useRef<HTMLInputElement>(null);
18
+ const isRemote = typeof window !== 'undefined' && !['localhost', '127.0.0.1'].includes(window.location.hostname);
19
+
20
+ const fetchPreviews = useCallback(() => {
21
+ fetch('/api/preview').then(r => r.json()).then(data => {
22
+ if (Array.isArray(data)) setPreviews(data.filter((p: any) => p.status === 'running'));
23
+ }).catch(() => {});
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ fetchPreviews();
28
+ const timer = setInterval(fetchPreviews, 10000);
29
+ return () => clearInterval(timer);
30
+ }, [fetchPreviews]);
31
+
32
+ const navigate = (url: string) => {
33
+ setBrowserUrl(url);
34
+ localStorage.setItem('forge-browser-url', url);
35
+ if (browserUrlRef.current) browserUrlRef.current.value = url;
36
+ setBrowserKey(k => k + 1);
37
+ };
38
+
39
+ const handleTunnel = async () => {
40
+ const input = prompt('Enter port(s) to create tunnel (e.g. 3100 or 3100,8080):');
41
+ if (!input) return;
42
+ const ports = input.split(',').map(s => parseInt(s.trim())).filter(p => p > 0 && p <= 65535);
43
+ if (ports.length === 0) { alert('Invalid port(s)'); return; }
44
+ setTunnelStarting(true);
45
+ const results: string[] = [];
46
+ for (const port of ports) {
47
+ try {
48
+ const res = await fetch('/api/preview', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ action: 'start', port }),
52
+ });
53
+ const data = await res.json();
54
+ if (data.url) {
55
+ results.push(data.url);
56
+ setPreviews(prev => {
57
+ const exists = prev.find(p => p.port === port);
58
+ if (exists) return prev.map(p => p.port === port ? { ...p, url: data.url, status: 'running' } : p);
59
+ return [...prev, { port, url: data.url, status: 'running' }];
60
+ });
61
+ } else if (data.status === 'starting' || data.status === 'stopped') {
62
+ // Tunnel started but URL not ready yet or exited
63
+ results.push('');
64
+ } else {
65
+ alert(`Port ${port}: ${data.error || 'Failed'}`);
66
+ }
67
+ } catch { alert(`Port ${port}: Failed to start tunnel`); }
68
+ }
69
+ // Navigate to first successful URL
70
+ const firstUrl = results.find(u => u);
71
+ if (firstUrl) navigate(firstUrl);
72
+ // Refresh list to pick up any that were still starting
73
+ setTimeout(fetchPreviews, 3000);
74
+ setTunnelStarting(false);
75
+ };
76
+
77
+ const stopTunnel = async (port: number) => {
78
+ await fetch('/api/preview', {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ action: 'stop', port }),
82
+ });
83
+ setPreviews(prev => prev.filter(x => x.port !== port));
84
+ };
85
+
86
+ return (
87
+ <div className="flex-1 flex flex-col min-h-0">
88
+ {/* URL bar */}
89
+ <div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
90
+ <input
91
+ ref={browserUrlRef}
92
+ type="text"
93
+ defaultValue={browserUrl}
94
+ placeholder="Enter URL"
95
+ onKeyDown={e => {
96
+ if (e.key === 'Enter') {
97
+ const val = (e.target as HTMLInputElement).value.trim();
98
+ if (!val) return;
99
+ const url = /^\d+$/.test(val) ? `http://localhost:${val}` : val;
100
+ navigate(url);
101
+ }
102
+ }}
103
+ className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
104
+ />
105
+ <button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
106
+ <button onClick={() => window.open(browserUrl, '_blank')} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Open in new tab">↗</button>
107
+ <button
108
+ disabled={tunnelStarting}
109
+ onClick={handleTunnel}
110
+ className="text-[9px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--accent)] disabled:opacity-50"
111
+ title="Create tunnel for a port (remote access)"
112
+ >{tunnelStarting ? 'Starting...' : 'Tunnel'}</button>
113
+ {onClose && (
114
+ <button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
115
+ )}
116
+ </div>
117
+ {/* Active tunnels bar */}
118
+ {previews.length > 0 && (
119
+ <div className="flex items-center gap-1 px-2 py-0.5 border-b border-[var(--border)]/50 bg-[var(--bg-secondary)] shrink-0 overflow-x-auto">
120
+ {previews.map(p => (
121
+ <div key={p.port} className="flex items-center gap-1 shrink-0">
122
+ <button
123
+ onClick={() => {
124
+ const url = isRemote && p.url ? p.url : `http://localhost:${p.port}`;
125
+ navigate(url);
126
+ }}
127
+ className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
128
+ >
129
+ <span className="text-green-400 mr-0.5">●</span>
130
+ :{p.port}
131
+ </button>
132
+ {p.url && (
133
+ <button
134
+ onClick={() => navigator.clipboard.writeText(p.url!).then(() => alert('Tunnel URL copied'))}
135
+ className="text-[8px] text-green-400 hover:underline truncate max-w-[120px]"
136
+ title={p.url}
137
+ >{p.url.replace('https://', '').slice(0, 20)}...</button>
138
+ )}
139
+ <button onClick={() => stopTunnel(p.port)} className="text-[8px] text-red-400 hover:text-red-300">✕</button>
140
+ </div>
141
+ ))}
142
+ </div>
143
+ )}
144
+ {/* Content */}
145
+ <div className="flex-1 relative">
146
+ {tunnelStarting && (
147
+ <div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs z-10 bg-[var(--bg-primary)]/80">
148
+ Creating tunnel... this may take up to 30 seconds
149
+ </div>
150
+ )}
151
+ {browserUrl ? (
152
+ <iframe
153
+ key={browserKey}
154
+ src={browserUrl}
155
+ className="absolute inset-0 w-full h-full border-0 bg-white"
156
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
157
+ />
158
+ ) : (
159
+ <div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
160
+ <div className="text-center space-y-1">
161
+ <p>Enter a URL or port number and press Enter</p>
162
+ <p className="text-[9px]">Click Tunnel to create a public URL for remote access</p>
163
+ </div>
164
+ </div>
165
+ )}
166
+ </div>
167
+ </div>
168
+ );
169
+ }
@@ -189,12 +189,6 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
189
189
  const [editing, setEditing] = useState(false);
190
190
  const [editContent, setEditContent] = useState('');
191
191
  const [saving, setSaving] = useState(false);
192
- const [browserOpen, setBrowserOpen] = useState(false);
193
- const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
194
- const [browserKey, setBrowserKey] = useState(0);
195
- const [browserWidth, setBrowserWidth] = useState(640);
196
- const browserDragRef = useRef<{ startX: number; startW: number } | null>(null);
197
- const [browserDragging, setBrowserDragging] = useState(false);
198
192
 
199
193
  const handleCodeOpenChange = useCallback((open: boolean) => {
200
194
  setCodeOpen(open);
@@ -427,74 +421,10 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
427
421
  )}
428
422
 
429
423
  {/* Terminal + Browser — main area */}
430
- <div className={`flex ${codeOpen ? 'shrink-0' : 'flex-1'}`} style={codeOpen ? { height: terminalHeight } : undefined}>
431
- <div className="flex-1 min-w-0">
432
- <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
433
- <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} browserOpen={browserOpen} onBrowserToggle={() => { setBrowserOpen(v => !v); if (!browserOpen) setBrowserKey(k => k + 1); }} />
434
- </Suspense>
435
- </div>
436
- {browserOpen && (
437
- <>
438
- <div
439
- onMouseDown={(e) => {
440
- e.preventDefault();
441
- browserDragRef.current = { startX: e.clientX, startW: browserWidth };
442
- setBrowserDragging(true);
443
- const onMove = (ev: MouseEvent) => {
444
- if (!browserDragRef.current) return;
445
- setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
446
- };
447
- const onUp = () => {
448
- browserDragRef.current = null;
449
- setBrowserDragging(false);
450
- window.removeEventListener('mousemove', onMove);
451
- window.removeEventListener('mouseup', onUp);
452
- };
453
- window.addEventListener('mousemove', onMove);
454
- window.addEventListener('mouseup', onUp);
455
- }}
456
- className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
457
- />
458
- <div style={{ width: browserWidth }} className="shrink-0 flex flex-col">
459
- <div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
460
- <input
461
- type="text"
462
- defaultValue={browserUrl}
463
- placeholder="http://localhost:3000"
464
- onKeyDown={e => {
465
- if (e.key === 'Enter') {
466
- const url = (e.target as HTMLInputElement).value.trim();
467
- if (url) {
468
- setBrowserUrl(url);
469
- localStorage.setItem('forge-browser-url', url);
470
- setBrowserKey(k => k + 1);
471
- }
472
- }
473
- }}
474
- className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
475
- />
476
- <button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
477
- <button onClick={() => window.open(browserUrl, '_blank')} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Open in new tab">↗</button>
478
- <button onClick={() => setBrowserOpen(false)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
479
- </div>
480
- <div className="flex-1 relative">
481
- {browserUrl ? (
482
- <iframe
483
- key={browserKey}
484
- src={browserUrl}
485
- className="absolute inset-0 w-full h-full border-0 bg-white"
486
- sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
487
- />
488
- ) : (
489
- <div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
490
- Enter a URL and press Enter
491
- </div>
492
- )}
493
- {browserDragging && <div className="absolute inset-0 z-10" />}
494
- </div>
495
- </div>
496
- </>
497
- )}
424
+ <div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
425
+ <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
426
+ <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
427
+ </Suspense>
498
428
  </div>
499
429
 
500
430
  {/* Resize handle */}
@@ -16,7 +16,7 @@ const WebTerminal = lazy(() => import('./WebTerminal'));
16
16
  const DocsViewer = lazy(() => import('./DocsViewer'));
17
17
  const CodeViewer = lazy(() => import('./CodeViewer'));
18
18
  const ProjectManager = lazy(() => import('./ProjectManager'));
19
- const PreviewPanel = lazy(() => import('./PreviewPanel'));
19
+ const BrowserPanel = lazy(() => import('./BrowserPanel'));
20
20
  const PipelineView = lazy(() => import('./PipelineView'));
21
21
  const HelpDialog = lazy(() => import('./HelpDialog'));
22
22
  const LogViewer = lazy(() => import('./LogViewer'));
@@ -42,8 +42,64 @@ interface ProjectInfo {
42
42
  language: string | null;
43
43
  }
44
44
 
45
+ function FloatingBrowser({ onClose }: { onClose: () => void }) {
46
+ const [pos, setPos] = useState({ x: 60, y: 60 });
47
+ const [size, setSize] = useState({ w: 700, h: 500 });
48
+ const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
49
+ const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
50
+
51
+ return (
52
+ <div
53
+ className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-2xl flex flex-col overflow-hidden"
54
+ style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
55
+ >
56
+ <div
57
+ className="flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-tertiary)] border-b border-[var(--border)] cursor-move shrink-0 select-none"
58
+ onMouseDown={(e) => {
59
+ e.preventDefault();
60
+ dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
61
+ const onMove = (ev: MouseEvent) => {
62
+ if (!dragRef.current) return;
63
+ setPos({ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY) });
64
+ };
65
+ const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
66
+ window.addEventListener('mousemove', onMove);
67
+ window.addEventListener('mouseup', onUp);
68
+ }}
69
+ >
70
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">Browser</span>
71
+ <button onClick={onClose} className="ml-auto text-[var(--text-secondary)] hover:text-[var(--red)] text-sm leading-none">✕</button>
72
+ </div>
73
+ <div className="flex-1 min-h-0 flex flex-col">
74
+ <BrowserPanel />
75
+ </div>
76
+ <div
77
+ onMouseDown={(e) => {
78
+ e.preventDefault();
79
+ e.stopPropagation();
80
+ resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
81
+ const onMove = (ev: MouseEvent) => {
82
+ if (!resizeRef.current) return;
83
+ setSize({ w: Math.max(400, resizeRef.current.origW + ev.clientX - resizeRef.current.startX), h: Math.max(300, resizeRef.current.origH + ev.clientY - resizeRef.current.startY) });
84
+ };
85
+ const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
86
+ window.addEventListener('mousemove', onMove);
87
+ window.addEventListener('mouseup', onUp);
88
+ }}
89
+ className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
90
+ style={{ background: 'linear-gradient(135deg, transparent 50%, var(--border) 50%)' }}
91
+ />
92
+ </div>
93
+ );
94
+ }
95
+
45
96
  export default function Dashboard({ user }: { user: any }) {
46
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills' | 'logs'>('terminal');
97
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'skills' | 'logs'>('terminal');
98
+ const [browserMode, setBrowserMode] = useState<'none' | 'float' | 'right' | 'left'>('none');
99
+ const [showBrowserMenu, setShowBrowserMenu] = useState(false);
100
+ const [browserWidth, setBrowserWidth] = useState(600);
101
+ const browserDragRef = useRef<{ startX: number; startW: number } | null>(null);
102
+ const [browserDragging, setBrowserDragging] = useState(false);
47
103
  const [tasks, setTasks] = useState<Task[]>([]);
48
104
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
49
105
  const [showNewTask, setShowNewTask] = useState(false);
@@ -172,7 +228,34 @@ export default function Dashboard({ user }: { user: any }) {
172
228
  const queued = tasks.filter(t => t.status === 'queued');
173
229
 
174
230
  return (
175
- <div className="h-screen flex flex-col">
231
+ <div className="h-screen flex">
232
+ {/* Browser — left side */}
233
+ {browserMode === 'left' && (
234
+ <>
235
+ <div style={{ width: browserWidth }} className="shrink-0 flex flex-col relative">
236
+ <Suspense fallback={null}><BrowserPanel onClose={() => setBrowserMode('none')} /></Suspense>
237
+ {browserDragging && <div className="absolute inset-0 z-10" />}
238
+ </div>
239
+ <div
240
+ onMouseDown={(e) => {
241
+ e.preventDefault();
242
+ browserDragRef.current = { startX: e.clientX, startW: browserWidth };
243
+ setBrowserDragging(true);
244
+ const onMove = (ev: MouseEvent) => {
245
+ if (!browserDragRef.current) return;
246
+ setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW + (ev.clientX - browserDragRef.current.startX))));
247
+ };
248
+ const onUp = () => { browserDragRef.current = null; setBrowserDragging(false); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
249
+ window.addEventListener('mousemove', onMove);
250
+ window.addEventListener('mouseup', onUp);
251
+ }}
252
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
253
+ />
254
+ </>
255
+ )}
256
+
257
+ {/* Forge main area */}
258
+ <div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
176
259
  {/* Top bar */}
177
260
  <header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
178
261
  <div className="flex items-center gap-4">
@@ -285,17 +368,47 @@ export default function Dashboard({ user }: { user: any }) {
285
368
  : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
286
369
  }`}
287
370
  >?</button>
288
- {/* Preview + Tunnel */}
289
- <button
290
- onClick={() => setViewMode('preview')}
291
- className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
292
- viewMode === 'preview'
293
- ? 'border-[var(--accent)] text-[var(--accent)]'
294
- : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
295
- }`}
296
- >
297
- Preview
298
- </button>
371
+ <div className="relative">
372
+ <button
373
+ onClick={() => setShowBrowserMenu(v => !v)}
374
+ className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
375
+ browserMode !== 'none'
376
+ ? 'border-blue-500 text-blue-400'
377
+ : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
378
+ }`}
379
+ >
380
+ Browser
381
+ </button>
382
+ {showBrowserMenu && (
383
+ <>
384
+ <div className="fixed inset-0 z-40" onClick={() => setShowBrowserMenu(false)} />
385
+ <div className="absolute top-full right-0 mt-1 z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 min-w-[140px]">
386
+ {browserMode !== 'none' && (
387
+ <button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); }} className="w-full text-left px-3 py-1.5 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
388
+ Close Browser
389
+ </button>
390
+ )}
391
+ <button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
392
+ Floating Window
393
+ </button>
394
+ <button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
395
+ Right Side
396
+ </button>
397
+ <button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
398
+ Left Side
399
+ </button>
400
+ <button onClick={() => {
401
+ const url = localStorage.getItem('forge-browser-url');
402
+ if (url) window.open(url, '_blank');
403
+ else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
404
+ setShowBrowserMenu(false);
405
+ }} className="w-full text-left px-3 py-1.5 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
406
+ New Tab
407
+ </button>
408
+ </div>
409
+ </>
410
+ )}
411
+ </div>
299
412
  <TunnelToggle />
300
413
  {onlineCount.total > 0 && (
301
414
  <span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
@@ -581,12 +694,6 @@ export default function Dashboard({ user }: { user: any }) {
581
694
  </Suspense>
582
695
  )}
583
696
 
584
- {/* Preview */}
585
- {viewMode === 'preview' && (
586
- <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
587
- <PreviewPanel />
588
- </Suspense>
589
- )}
590
697
 
591
698
  {/* Skills */}
592
699
  {viewMode === 'skills' && (
@@ -616,6 +723,39 @@ export default function Dashboard({ user }: { user: any }) {
616
723
  </Suspense>
617
724
  </div>
618
725
  </div>
726
+ </div>{/* close Forge main area */}
727
+
728
+ {/* Browser — right side */}
729
+ {browserMode === 'right' && (
730
+ <>
731
+ <div
732
+ onMouseDown={(e) => {
733
+ e.preventDefault();
734
+ browserDragRef.current = { startX: e.clientX, startW: browserWidth };
735
+ setBrowserDragging(true);
736
+ const onMove = (ev: MouseEvent) => {
737
+ if (!browserDragRef.current) return;
738
+ setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
739
+ };
740
+ const onUp = () => { browserDragRef.current = null; setBrowserDragging(false); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
741
+ window.addEventListener('mousemove', onMove);
742
+ window.addEventListener('mouseup', onUp);
743
+ }}
744
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
745
+ />
746
+ <div style={{ width: browserWidth }} className="shrink-0 flex flex-col relative">
747
+ <Suspense fallback={null}><BrowserPanel onClose={() => setBrowserMode('none')} /></Suspense>
748
+ {browserDragging && <div className="absolute inset-0 z-10" />}
749
+ </div>
750
+ </>
751
+ )}
752
+
753
+ {/* Browser — floating window */}
754
+ {browserMode === 'float' && (
755
+ <Suspense fallback={null}>
756
+ <FloatingBrowser onClose={() => setBrowserMode('none')} />
757
+ </Suspense>
758
+ )}
619
759
 
620
760
  {showNewTask && (
621
761
  <NewTaskModal
@@ -26,6 +26,8 @@ export default function HelpTerminal() {
26
26
  if (!containerRef.current) return;
27
27
 
28
28
  let disposed = false;
29
+ let dataDir = '~/.forge/data';
30
+
29
31
  const cs = getComputedStyle(document.documentElement);
30
32
  const tv = (name: string) => cs.getPropertyValue(name).trim();
31
33
  const term = new Terminal({
@@ -73,7 +75,7 @@ export default function HelpTerminal() {
73
75
  isNewSession = false;
74
76
  setTimeout(() => {
75
77
  if (socket.readyState === WebSocket.OPEN) {
76
- socket.send(JSON.stringify({ type: 'input', data: `cd ~/.forge/help 2>/dev/null && claude\n` }));
78
+ socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && claude\n` }));
77
79
  }
78
80
  }, 300);
79
81
  }
@@ -94,7 +96,11 @@ export default function HelpTerminal() {
94
96
  socket.onerror = () => {};
95
97
  }
96
98
 
97
- connect();
99
+ // Fetch data dir then connect
100
+ fetch('/api/help?action=status').then(r => r.json())
101
+ .then(data => { if (data.dataDir) dataDir = data.dataDir; })
102
+ .catch(() => {})
103
+ .finally(() => { if (!disposed) connect(); });
98
104
 
99
105
  term.onData((data) => {
100
106
  if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
@@ -999,11 +999,11 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
999
999
  {/* Issue scan config (for issue-fix-and-review workflow) */}
1000
1000
  {(b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review') && (
1001
1001
  <div className="space-y-1.5 pt-1 border-t border-[var(--border)]/30">
1002
- {b.config.interval > 0 && (
1003
- <div className="text-[8px] text-[var(--text-secondary)]">
1004
- Scheduled mode: auto-scans GitHub issues and fixes new ones
1005
- </div>
1006
- )}
1002
+ <div className="text-[8px] text-[var(--text-secondary)]">
1003
+ {b.config.interval > 0
1004
+ ? 'Scheduled mode: auto-scans GitHub issues and fixes new ones'
1005
+ : 'Requires: gh auth login (run in terminal first)'}
1006
+ </div>
1007
1007
  <div className="flex items-center gap-2 text-[9px]">
1008
1008
  <label className="text-[var(--text-secondary)]">Labels:</label>
1009
1009
  <input
@@ -15,8 +15,6 @@ export interface WebTerminalHandle {
15
15
  export interface WebTerminalProps {
16
16
  onActiveSession?: (sessionName: string | null) => void;
17
17
  onCodeOpenChange?: (open: boolean) => void;
18
- browserOpen?: boolean;
19
- onBrowserToggle?: () => void;
20
18
  }
21
19
 
22
20
  // ─── Types ───────────────────────────────────────────────────
@@ -166,7 +164,7 @@ let globalDragging = false;
166
164
 
167
165
  // ─── Main component ─────────────────────────────────────────
168
166
 
169
- const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange, browserOpen, onBrowserToggle }, ref) {
167
+ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
170
168
  const [tabs, setTabs] = useState<TabState[]>(() => {
171
169
  const tree = makeTerminal();
172
170
  return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
@@ -478,6 +476,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
478
476
  });
479
477
  }, [activeTab, updateActiveTab]);
480
478
 
479
+ const closePaneById = useCallback((id: number) => {
480
+ updateActiveTab(t => {
481
+ if (countTerminals(t.tree) <= 1) return t;
482
+ const newTree = removeNodeById(t.tree, id) || t.tree;
483
+ const newActiveId = t.activeId === id ? firstTerminalId(newTree) : t.activeId;
484
+ return { ...t, tree: newTree, activeId: newActiveId };
485
+ });
486
+ }, [updateActiveTab]);
487
+
481
488
  const setActiveId = useCallback((id: number) => {
482
489
  updateActiveTab(t => ({ ...t, activeId: id }));
483
490
  }, [updateActiveTab]);
@@ -636,20 +643,6 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
636
643
  Code
637
644
  </button>
638
645
  )}
639
- {onBrowserToggle && (
640
- <button
641
- onClick={onBrowserToggle}
642
- className={`text-[11px] px-3 py-1 rounded font-bold ${browserOpen ? 'text-white bg-blue-500 hover:bg-blue-400' : 'text-blue-400 border border-blue-500 hover:bg-blue-500 hover:text-white'}`}
643
- title={browserOpen ? 'Close browser' : 'Open browser'}
644
- >
645
- Browser
646
- </button>
647
- )}
648
- {activeTab && countTerminals(activeTab.tree) > 1 && (
649
- <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:text-red-400 hover:bg-[var(--term-border)] rounded font-medium">
650
- Close Pane
651
- </button>
652
- )}
653
646
  </div>
654
647
  </div>
655
648
 
@@ -876,6 +869,8 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
876
869
  onSessionConnected={onSessionConnected}
877
870
  refreshKeys={refreshKeys}
878
871
  skipPermissions={skipPermissions}
872
+ canClose={countTerminals(tab.tree) > 1}
873
+ onClosePane={tab.id === activeTabId ? closePaneById : undefined}
879
874
  />
880
875
  </div>
881
876
  ))}
@@ -888,7 +883,7 @@ export default WebTerminal;
888
883
  // ─── Pane renderer ───────────────────────────────────────────
889
884
 
890
885
  function PaneRenderer({
891
- node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions,
886
+ node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions, canClose, onClosePane,
892
887
  }: {
893
888
  node: SplitNode;
894
889
  activeId: number;
@@ -898,11 +893,20 @@ function PaneRenderer({
898
893
  onSessionConnected: (paneId: number, sessionName: string) => void;
899
894
  refreshKeys: Record<number, number>;
900
895
  skipPermissions?: boolean;
896
+ canClose?: boolean;
897
+ onClosePane?: (id: number) => void;
901
898
  }) {
902
899
  if (node.type === 'terminal') {
903
900
  return (
904
- <div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
901
+ <div className={`h-full w-full relative group/pane ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
905
902
  <MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} projectPath={node.projectPath} skipPermissions={skipPermissions} onSessionConnected={onSessionConnected} />
903
+ {canClose && onClosePane && (
904
+ <button
905
+ onClick={(e) => { e.stopPropagation(); if (confirm('Close this pane?')) onClosePane(node.id); }}
906
+ className="absolute top-1.5 right-1.5 z-10 w-6 h-6 flex items-center justify-center rounded bg-red-500/80 text-white hover:bg-red-500 opacity-0 group-hover/pane:opacity-100 transition-opacity text-xs font-bold shadow"
907
+ title="Close this pane"
908
+ >✕</button>
909
+ )}
906
910
  </div>
907
911
  );
908
912
  }
@@ -911,8 +915,8 @@ function PaneRenderer({
911
915
 
912
916
  return (
913
917
  <DraggableSplit splitId={node.id} direction={node.direction} ratio={ratio} setRatios={setRatios}>
914
- <PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} />
915
- <PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} />
918
+ <PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
919
+ <PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
916
920
  </DraggableSplit>
917
921
  );
918
922
  }
@@ -1198,12 +1202,29 @@ const MemoTerminalPane = memo(function TerminalPane({
1198
1202
  // Auto-run claude for project tabs (only if no pendingCommand already set)
1199
1203
  if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
1200
1204
  isNewlyCreated = false;
1201
- setTimeout(() => {
1202
- if (!disposed && ws?.readyState === WebSocket.OPEN) {
1205
+ // Check if project has existing claude sessions to decide -c flag
1206
+ const pp = projectPathRef.current;
1207
+ const pName = pp.replace(/\/+$/, '').split('/').pop() || '';
1208
+ fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
1209
+ .then(r => r.json())
1210
+ .then(sData => {
1211
+ const hasSession = Array.isArray(sData) ? sData.length > 0 : false;
1212
+ const resumeFlag = hasSession ? ' -c' : '';
1203
1213
  const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1204
- ws.send(JSON.stringify({ type: 'input', data: `cd "${projectPathRef.current}" && claude${skipFlag}\n` }));
1205
- }
1206
- }, 300);
1214
+ setTimeout(() => {
1215
+ if (!disposed && ws?.readyState === WebSocket.OPEN) {
1216
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${resumeFlag}${skipFlag}\n` }));
1217
+ }
1218
+ }, 300);
1219
+ })
1220
+ .catch(() => {
1221
+ const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1222
+ setTimeout(() => {
1223
+ if (!disposed && ws?.readyState === WebSocket.OPEN) {
1224
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${skipFlag}\n` }));
1225
+ }
1226
+ }, 300);
1227
+ });
1207
1228
  }
1208
1229
  isNewlyCreated = false;
1209
1230
  // Force tmux to redraw by toggling size, then send reset
package/lib/init.ts CHANGED
@@ -82,6 +82,23 @@ export function ensureInitialized() {
82
82
  // Auto-detect claude path if not configured
83
83
  autoDetectClaude();
84
84
 
85
+ // Sync help docs + CLAUDE.md to data dir on startup
86
+ try {
87
+ const { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } = require('node:fs');
88
+ const { join: joinPath } = require('node:path');
89
+ const { getConfigDir, getDataDir } = require('./dirs');
90
+ const helpDir = joinPath(getConfigDir(), 'help');
91
+ const sourceDir = joinPath(process.cwd(), 'lib', 'help-docs');
92
+ if (existsSync(sourceDir)) {
93
+ if (!existsSync(helpDir)) mkdirSync(helpDir, { recursive: true });
94
+ for (const f of readdirSync(sourceDir)) {
95
+ if (f.endsWith('.md')) writeFileSync(joinPath(helpDir, f), readFileSync(joinPath(sourceDir, f)));
96
+ }
97
+ const claudeMd = joinPath(helpDir, 'CLAUDE.md');
98
+ if (existsSync(claudeMd)) writeFileSync(joinPath(getDataDir(), 'CLAUDE.md'), readFileSync(claudeMd));
99
+ }
100
+ } catch {}
101
+
85
102
  // Sync skills registry (async, non-blocking) — on startup + every 30 min
86
103
  try {
87
104
  const { syncSkills } = require('./skills');
@@ -150,9 +150,11 @@ export function deleteRun(id: string): void {
150
150
 
151
151
  function isDuplicate(projectPath: string, workflowName: string, dedupKey: string): boolean {
152
152
  const row = db().prepare(
153
- 'SELECT 1 FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ?'
154
- ).get(projectPath, workflowName, dedupKey);
155
- return !!row;
153
+ 'SELECT status FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ? ORDER BY created_at DESC LIMIT 1'
154
+ ).get(projectPath, workflowName, dedupKey) as { status: string } | undefined;
155
+ if (!row) return false;
156
+ // Failed runs are not duplicates — allow retry
157
+ return row.status !== 'failed';
156
158
  }
157
159
 
158
160
  export function resetDedup(projectPath: string, workflowName: string, dedupKey: string): void {
@@ -222,7 +224,7 @@ function fetchOpenIssues(projectPath: string, labels: string[]): { number: numbe
222
224
  if (!repo) return [{ number: -1, title: '', error: 'Could not detect GitHub repo. Run: gh auth login' }];
223
225
  try {
224
226
  const labelFilter = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
225
- const out = execSync(`gh issue list --state open --json number,title${labelFilter} -R ${repo}`, {
227
+ const out = execSync(`gh issue list --state open --limit 30 --json number,title${labelFilter} -R ${repo}`, {
226
228
  cwd: projectPath, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
227
229
  });
228
230
  return JSON.parse(out) || [];
@@ -246,11 +248,18 @@ export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { trigger
246
248
  const recentRuns = getRuns(binding.projectPath, binding.workflowName, 5);
247
249
  const hasRunning = recentRuns.some(r => r.status === 'running');
248
250
 
251
+ // Batch dedup check — one query instead of N
252
+ const processedKeys = new Set(
253
+ (db().prepare(
254
+ 'SELECT dedup_key FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key IS NOT NULL AND status != ?'
255
+ ).all(binding.projectPath, binding.workflowName, 'failed') as { dedup_key: string }[])
256
+ .map(r => r.dedup_key)
257
+ );
258
+
249
259
  const newIssues: { number: number; title: string }[] = [];
250
260
  for (const issue of issues) {
251
261
  if (issue.number < 0) continue;
252
- const dedupKey = `issue:${issue.number}`;
253
- if (!isDuplicate(binding.projectPath, binding.workflowName, dedupKey)) {
262
+ if (!processedKeys.has(`issue:${issue.number}`)) {
254
263
  newIssues.push(issue);
255
264
  }
256
265
  }
@@ -269,6 +278,9 @@ export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { trigger
269
278
 
270
279
  const issue = newIssues[0];
271
280
  const dedupKey = `issue:${issue.number}`;
281
+ // Remove old failed run so new dedup_key insert won't conflict
282
+ db().prepare('DELETE FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ? AND status = ?')
283
+ .run(binding.projectPath, binding.workflowName, dedupKey, 'failed');
272
284
  try {
273
285
  triggerPipeline(
274
286
  binding.projectPath, binding.projectName, binding.workflowName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,167 +0,0 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useCallback } from 'react';
4
-
5
- interface PreviewEntry {
6
- port: number;
7
- url: string | null;
8
- status: string;
9
- label?: string;
10
- }
11
-
12
- export default function PreviewPanel() {
13
- const [previews, setPreviews] = useState<PreviewEntry[]>([]);
14
- const [inputPort, setInputPort] = useState('');
15
- const [inputLabel, setInputLabel] = useState('');
16
- const [starting, setStarting] = useState(false);
17
- const [error, setError] = useState('');
18
- const [activePreview, setActivePreview] = useState<number | null>(null);
19
- const [isRemote, setIsRemote] = useState(false);
20
-
21
- useEffect(() => {
22
- setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
23
- }, []);
24
-
25
- const fetchPreviews = useCallback(async () => {
26
- try {
27
- const res = await fetch('/api/preview');
28
- const data = await res.json();
29
- if (Array.isArray(data)) setPreviews(data);
30
- } catch {}
31
- }, []);
32
-
33
- useEffect(() => {
34
- fetchPreviews();
35
- const timer = setInterval(fetchPreviews, 5000);
36
- return () => clearInterval(timer);
37
- }, [fetchPreviews]);
38
-
39
- const handleStart = async () => {
40
- const p = parseInt(inputPort);
41
- if (!p || p < 1 || p > 65535) { setError('Invalid port'); return; }
42
- setError('');
43
- setStarting(true);
44
- try {
45
- const res = await fetch('/api/preview', {
46
- method: 'POST',
47
- headers: { 'Content-Type': 'application/json' },
48
- body: JSON.stringify({ action: 'start', port: p, label: inputLabel || undefined }),
49
- });
50
- const data = await res.json();
51
- if (data.error) setError(data.error);
52
- else {
53
- setInputPort('');
54
- setInputLabel('');
55
- setActivePreview(p);
56
- }
57
- fetchPreviews();
58
- } catch { setError('Failed'); }
59
- setStarting(false);
60
- };
61
-
62
- const handleStop = async (port: number) => {
63
- await fetch('/api/preview', {
64
- method: 'POST',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ action: 'stop', port }),
67
- });
68
- if (activePreview === port) setActivePreview(null);
69
- fetchPreviews();
70
- };
71
-
72
- const active = previews.find(p => p.port === activePreview);
73
- const previewSrc = active
74
- ? (isRemote ? active.url : `http://localhost:${active.port}`)
75
- : null;
76
-
77
- return (
78
- <div className="flex-1 flex flex-col min-h-0">
79
- {/* Top bar */}
80
- <div className="px-4 py-2 border-b border-[var(--border)] shrink-0 space-y-2">
81
- {/* Preview list */}
82
- <div className="flex items-center gap-2 flex-wrap">
83
- <span className="text-[11px] font-semibold text-[var(--text-primary)]">Demo Preview</span>
84
- {previews.map(p => (
85
- <div key={p.port} className="flex items-center gap-1">
86
- <button
87
- onClick={() => setActivePreview(p.port)}
88
- className={`text-[10px] px-2 py-0.5 rounded ${activePreview === p.port ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
89
- >
90
- <span className={`mr-1 ${p.status === 'running' ? 'text-green-400' : 'text-gray-500'}`}>●</span>
91
- {p.label || `:${p.port}`}
92
- </button>
93
- {p.url && (
94
- <button
95
- onClick={() => navigator.clipboard.writeText(p.url!)}
96
- className="text-[8px] text-green-400 hover:text-green-300 truncate max-w-[150px]"
97
- title={`Copy: ${p.url}`}
98
- >
99
- {p.url.replace('https://', '').slice(0, 20)}...
100
- </button>
101
- )}
102
- <button
103
- onClick={() => handleStop(p.port)}
104
- className="text-[9px] text-red-400 hover:text-red-300"
105
- >
106
- x
107
- </button>
108
- </div>
109
- ))}
110
- </div>
111
-
112
- {/* Add new */}
113
- <div className="flex items-center gap-2">
114
- <input
115
- type="number"
116
- value={inputPort}
117
- onChange={e => setInputPort(e.target.value)}
118
- onKeyDown={e => e.key === 'Enter' && handleStart()}
119
- placeholder="Port"
120
- className="w-20 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
121
- />
122
- <input
123
- value={inputLabel}
124
- onChange={e => setInputLabel(e.target.value)}
125
- onKeyDown={e => e.key === 'Enter' && handleStart()}
126
- placeholder="Label (optional)"
127
- className="w-32 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
128
- />
129
- <button
130
- onClick={handleStart}
131
- disabled={!inputPort || starting}
132
- className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
133
- >
134
- {starting ? 'Starting...' : '+ Add'}
135
- </button>
136
- {active && (
137
- <a
138
- href={previewSrc || '#'}
139
- target="_blank"
140
- rel="noopener"
141
- className="text-[10px] text-[var(--accent)] hover:underline ml-auto"
142
- >
143
- Open ↗
144
- </a>
145
- )}
146
- {error && <span className="text-[10px] text-red-400">{error}</span>}
147
- </div>
148
- </div>
149
-
150
- {/* Preview iframe */}
151
- {previewSrc && active?.status === 'running' ? (
152
- <iframe
153
- src={previewSrc}
154
- className="flex-1 w-full border-0 bg-white"
155
- title="Preview"
156
- />
157
- ) : (
158
- <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
159
- <div className="text-center space-y-3 max-w-md">
160
- <p className="text-sm">{previews.length > 0 ? 'Select a preview to display' : 'Preview local dev servers'}</p>
161
- <p className="text-xs">Enter a port, add a label, and click Add. Each preview gets its own Cloudflare Tunnel URL.</p>
162
- </div>
163
- </div>
164
- )}
165
- </div>
166
- );
167
- }