@aion0/forge 0.4.9 → 0.4.11

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.
@@ -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` : ''}`}>
@@ -445,6 +558,12 @@ export default function Dashboard({ user }: { user: any }) {
445
558
  >
446
559
  Logs
447
560
  </button>
561
+ <a
562
+ href="/mobile"
563
+ className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
564
+ >
565
+ Mobile View
566
+ </a>
448
567
  <div className="border-t border-[var(--border)] my-1" />
449
568
  <button
450
569
  onClick={() => signOut({ callbackUrl: '/login' })}
@@ -581,12 +700,6 @@ export default function Dashboard({ user }: { user: any }) {
581
700
  </Suspense>
582
701
  )}
583
702
 
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
703
 
591
704
  {/* Skills */}
592
705
  {viewMode === 'skills' && (
@@ -616,6 +729,39 @@ export default function Dashboard({ user }: { user: any }) {
616
729
  </Suspense>
617
730
  </div>
618
731
  </div>
732
+ </div>{/* close Forge main area */}
733
+
734
+ {/* Browser — right side */}
735
+ {browserMode === 'right' && (
736
+ <>
737
+ <div
738
+ onMouseDown={(e) => {
739
+ e.preventDefault();
740
+ browserDragRef.current = { startX: e.clientX, startW: browserWidth };
741
+ setBrowserDragging(true);
742
+ const onMove = (ev: MouseEvent) => {
743
+ if (!browserDragRef.current) return;
744
+ setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
745
+ };
746
+ const onUp = () => { browserDragRef.current = null; setBrowserDragging(false); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
747
+ window.addEventListener('mousemove', onMove);
748
+ window.addEventListener('mouseup', onUp);
749
+ }}
750
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
751
+ />
752
+ <div style={{ width: browserWidth }} className="shrink-0 flex flex-col relative">
753
+ <Suspense fallback={null}><BrowserPanel onClose={() => setBrowserMode('none')} /></Suspense>
754
+ {browserDragging && <div className="absolute inset-0 z-10" />}
755
+ </div>
756
+ </>
757
+ )}
758
+
759
+ {/* Browser — floating window */}
760
+ {browserMode === 'float' && (
761
+ <Suspense fallback={null}>
762
+ <FloatingBrowser onClose={() => setBrowserMode('none')} />
763
+ </Suspense>
764
+ )}
619
765
 
620
766
  {showNewTask && (
621
767
  <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 }));
@@ -0,0 +1,365 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+
5
+ interface Project { name: string; path: string }
6
+ interface SessionInfo { sessionId: string; summary?: string; firstPrompt?: string; modified?: string }
7
+ interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; timestamp: string }
8
+
9
+ export default function MobileView() {
10
+ const [projects, setProjects] = useState<Project[]>([]);
11
+ const [selectedProject, setSelectedProject] = useState<Project | null>(null);
12
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
13
+ const [showSessions, setShowSessions] = useState(false);
14
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
15
+ const [input, setInput] = useState('');
16
+ const [loading, setLoading] = useState(false);
17
+ const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
18
+ const [debug, setDebug] = useState<string[]>([]);
19
+ const [debugLevel, setDebugLevel] = useState<'off' | 'simple' | 'verbose'>('off');
20
+ const debugLevelRef = useRef<'off' | 'simple' | 'verbose'>('off');
21
+ const [hasSession, setHasSession] = useState(false);
22
+ const scrollRef = useRef<HTMLDivElement>(null);
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+ const abortRef = useRef<AbortController | null>(null);
25
+
26
+ // Fetch projects
27
+ useEffect(() => {
28
+ fetch('/api/projects').then(r => r.json())
29
+ .then(data => { if (Array.isArray(data)) setProjects(data); })
30
+ .catch(() => {});
31
+ fetch('/api/tunnel').then(r => r.json())
32
+ .then(data => { setTunnelUrl(data.url || null); })
33
+ .catch(() => {});
34
+ }, []);
35
+
36
+ // Auto-scroll
37
+ useEffect(() => {
38
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
39
+ }, [messages]);
40
+
41
+ // Fetch sessions for project
42
+ const fetchSessions = useCallback(async (projectName: string) => {
43
+ try {
44
+ const res = await fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`);
45
+ const data = await res.json();
46
+ const list = Array.isArray(data) ? data : [];
47
+ setSessions(list);
48
+ setHasSession(list.length > 0);
49
+ return list;
50
+ } catch { setSessions([]); return []; }
51
+ }, []);
52
+
53
+ // Load session history
54
+ const loadHistory = useCallback(async (projectName: string, sessionId: string) => {
55
+ try {
56
+ const res = await fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}/entries?sessionId=${encodeURIComponent(sessionId)}`);
57
+ const data = await res.json();
58
+ const entries = data.entries || [];
59
+ // Convert entries to chat messages (only user + assistant_text)
60
+ const chatMessages: ChatMessage[] = [];
61
+ for (const e of entries) {
62
+ if (e.type === 'user') {
63
+ chatMessages.push({ role: 'user', content: e.content, timestamp: e.timestamp || '' });
64
+ } else if (e.type === 'assistant_text') {
65
+ chatMessages.push({ role: 'assistant', content: e.content, timestamp: e.timestamp || '' });
66
+ }
67
+ }
68
+ setMessages(chatMessages);
69
+ } catch {}
70
+ }, []);
71
+
72
+ // Select project
73
+ const selectProject = useCallback(async (project: Project) => {
74
+ setSelectedProject(project);
75
+ setShowSessions(false);
76
+ setMessages([]);
77
+
78
+ const sessionList = await fetchSessions(project.name);
79
+ // Load last session history if exists
80
+ if (sessionList.length > 0) {
81
+ await loadHistory(project.name, sessionList[0].sessionId);
82
+ }
83
+ }, [fetchSessions, loadHistory]);
84
+
85
+ // View specific session
86
+ const viewSession = useCallback(async (sessionId: string) => {
87
+ if (!selectedProject) return;
88
+ setShowSessions(false);
89
+ setMessages([]);
90
+ await loadHistory(selectedProject.name, sessionId);
91
+ }, [selectedProject, loadHistory]);
92
+
93
+ // Send message
94
+ const sendMessage = async () => {
95
+ const text = input.trim();
96
+ if (!text || !selectedProject || loading) return;
97
+
98
+ // Add user message
99
+ setMessages(prev => [...prev, { role: 'user', content: text, timestamp: new Date().toISOString() }]);
100
+ setInput('');
101
+ setLoading(true);
102
+ setDebug(d => [...d.slice(-20), `Send: "${text.slice(0, 40)}"`]);
103
+ inputRef.current?.focus();
104
+
105
+ // Stream response from API
106
+ const abort = new AbortController();
107
+ abortRef.current = abort;
108
+ let assistantText = '';
109
+ const startTime = Date.now();
110
+
111
+ try {
112
+ const res = await fetch('/api/mobile-chat', {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({
116
+ message: text,
117
+ projectPath: selectedProject.path,
118
+ resume: false,
119
+ }),
120
+ signal: abort.signal,
121
+ });
122
+
123
+ const reader = res.body?.getReader();
124
+ if (!reader) throw new Error('No reader');
125
+
126
+ const decoder = new TextDecoder();
127
+
128
+ // Add empty assistant message to fill in
129
+ setMessages(prev => [...prev, { role: 'assistant', content: '...', timestamp: new Date().toISOString() }]);
130
+
131
+ while (true) {
132
+ const { value, done } = await reader.read();
133
+ if (done) break;
134
+
135
+ const chunk = decoder.decode(value, { stream: true });
136
+ for (const line of chunk.split('\n')) {
137
+ if (!line.startsWith('data: ')) continue;
138
+ try {
139
+ const data = JSON.parse(line.slice(6));
140
+ if (data.type === 'chunk') {
141
+ assistantText += data.text;
142
+ if (debugLevelRef.current === 'verbose') {
143
+ // Show content preview in verbose mode
144
+ const preview = data.text.replace(/\n/g, '↵').slice(0, 80);
145
+ setDebug(d => [...d.slice(-50), `chunk: ${preview}`]);
146
+ }
147
+ } else if (data.type === 'stderr') {
148
+ if (debugLevelRef.current !== 'off') {
149
+ setDebug(d => [...d.slice(-50), `stderr: ${data.text.trim().slice(0, 100)}`]);
150
+ }
151
+ } else if (data.type === 'error') {
152
+ assistantText = `Error: ${data.message}`;
153
+ setDebug(d => [...d.slice(-50), `ERROR: ${data.message}`]);
154
+ } else if (data.type === 'done') {
155
+ if (debugLevelRef.current !== 'off') setDebug(d => [...d.slice(-50), `done: exit ${data.code}`]);
156
+ }
157
+ } catch {}
158
+ }
159
+
160
+ // Update assistant message with latest text
161
+ if (assistantText) {
162
+ let displayText = assistantText;
163
+ try {
164
+ const parsed = JSON.parse(assistantText);
165
+ if (parsed.result) displayText = parsed.result;
166
+ } catch {}
167
+ setMessages(prev => {
168
+ const updated = [...prev];
169
+ updated[updated.length - 1] = { role: 'assistant', content: displayText, timestamp: new Date().toISOString() };
170
+ return updated;
171
+ });
172
+ }
173
+ }
174
+
175
+ // Final parse
176
+ try {
177
+ const parsed = JSON.parse(assistantText);
178
+ const finalText = parsed.result || assistantText;
179
+ setMessages(prev => {
180
+ const updated = [...prev];
181
+ updated[updated.length - 1] = { role: 'assistant', content: finalText, timestamp: new Date().toISOString() };
182
+ return updated;
183
+ });
184
+ } catch {}
185
+
186
+ // After first message, future ones should use -c
187
+ setHasSession(true);
188
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
189
+ setDebug(d => [...d.slice(-50), `Response complete (${elapsed}s, ${assistantText.length} chars)`]);
190
+ } catch (e: any) {
191
+ if (e.name !== 'AbortError') {
192
+ setDebug(d => [...d.slice(-20), `Error: ${e.message}`]);
193
+ setMessages(prev => [...prev.slice(0, -1), { role: 'system', content: `Failed: ${e.message}`, timestamp: new Date().toISOString() }]);
194
+ }
195
+ }
196
+
197
+ setLoading(false);
198
+ abortRef.current = null;
199
+ };
200
+
201
+ // Stop generation
202
+ const stopGeneration = () => {
203
+ if (abortRef.current) abortRef.current.abort();
204
+ };
205
+
206
+ // Close tunnel
207
+ const closeTunnel = async () => {
208
+ if (!confirm('Close tunnel? You will lose remote access.')) return;
209
+ await fetch('/api/tunnel', {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ action: 'stop' }),
213
+ });
214
+ setTunnelUrl(null);
215
+ };
216
+
217
+ return (
218
+ <div className="h-[100dvh] flex flex-col bg-[#0d1117] text-[#e6edf3]">
219
+ {/* Header */}
220
+ <header className="shrink-0 flex items-center gap-1.5 px-2 py-2 bg-[#161b22] border-b border-[#30363d]">
221
+ <span className="text-xs font-bold text-[#7c5bf0]">Forge</span>
222
+ <select
223
+ value={selectedProject?.path || ''}
224
+ onChange={e => {
225
+ const p = projects.find(p => p.path === e.target.value);
226
+ if (p) selectProject(p);
227
+ }}
228
+ className="flex-1 bg-[#0d1117] border border-[#30363d] rounded px-2 py-1 text-xs text-[#e6edf3] min-w-0"
229
+ >
230
+ <option value="">Project</option>
231
+ {projects.map(p => (
232
+ <option key={p.path} value={p.path}>{p.name}</option>
233
+ ))}
234
+ </select>
235
+ {selectedProject && (
236
+ <>
237
+ <button
238
+ onClick={() => { setShowSessions(v => !v); if (!showSessions) fetchSessions(selectedProject.name); }}
239
+ className="text-xs px-2 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]"
240
+ >Sessions</button>
241
+ <button
242
+ onClick={async () => {
243
+ const list = await fetchSessions(selectedProject.name);
244
+ if (list.length > 0) {
245
+ await loadHistory(selectedProject.name, list[0].sessionId);
246
+ setDebug(d => [...d.slice(-20), `Refreshed: ${list[0].sessionId.slice(0, 8)}`]);
247
+ }
248
+ }}
249
+ className="text-sm px-3 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]"
250
+ >↻</button>
251
+ </>
252
+ )}
253
+ {tunnelUrl && (
254
+ <button onClick={closeTunnel} className="text-xs px-1.5 py-1 border border-green-700 rounded text-green-400" title={tunnelUrl}>●</button>
255
+ )}
256
+ <a href="/?force=desktop" className="text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
257
+ </header>
258
+
259
+ {/* Session list */}
260
+ {showSessions && (
261
+ <div className="shrink-0 max-h-[40vh] overflow-y-auto bg-[#161b22] border-b border-[#30363d]">
262
+ {sessions.length === 0 ? (
263
+ <div className="px-3 py-4 text-xs text-[#8b949e] text-center">No sessions found</div>
264
+ ) : sessions.map(s => (
265
+ <button
266
+ key={s.sessionId}
267
+ onClick={() => viewSession(s.sessionId)}
268
+ className="w-full text-left px-3 py-2 border-b border-[#30363d]/50 hover:bg-[#1c2128] text-xs"
269
+ >
270
+ <div className="flex items-center gap-2">
271
+ <span className="text-[#e6edf3] font-mono truncate">{s.sessionId.slice(0, 12)}</span>
272
+ {s.modified && <span className="text-[#8b949e] ml-auto shrink-0">{new Date(s.modified).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>}
273
+ </div>
274
+ {(s.summary || s.firstPrompt) && (
275
+ <div className="text-[#8b949e] mt-0.5 truncate">{s.summary || s.firstPrompt}</div>
276
+ )}
277
+ </button>
278
+ ))}
279
+ </div>
280
+ )}
281
+
282
+ {/* Messages */}
283
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 min-h-0 space-y-3">
284
+ {!selectedProject ? (
285
+ <div className="h-full flex items-center justify-center text-sm text-[#8b949e]">
286
+ Select a project to start
287
+ </div>
288
+ ) : messages.length === 0 ? (
289
+ <div className="h-full flex items-center justify-center text-sm text-[#8b949e]">
290
+ {hasSession ? 'Session loaded. Type a message.' : 'No sessions yet. Type a message to start.'}
291
+ </div>
292
+ ) : messages.map((msg, i) => (
293
+ <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
294
+ <div className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${
295
+ msg.role === 'user'
296
+ ? 'bg-[#7c5bf0] text-white rounded-br-sm'
297
+ : msg.role === 'system'
298
+ ? 'bg-red-900/30 text-red-300 rounded-bl-sm'
299
+ : 'bg-[#1c2128] text-[#e6edf3] rounded-bl-sm'
300
+ }`}>
301
+ {msg.content}
302
+ </div>
303
+ </div>
304
+ ))}
305
+ {loading && (
306
+ <div className="flex justify-start">
307
+ <div className="bg-[#1c2128] rounded-2xl rounded-bl-sm px-3 py-2 text-sm text-[#8b949e]">
308
+ Thinking...
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+
314
+ {/* Input */}
315
+ <div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#161b22] border-t border-[#30363d]">
316
+ <input
317
+ ref={inputRef}
318
+ type="text"
319
+ value={input}
320
+ onChange={e => setInput(e.target.value)}
321
+ onKeyDown={e => { if (e.key === 'Enter' && !loading) sendMessage(); }}
322
+ placeholder={selectedProject ? 'Type a message...' : 'Select a project first'}
323
+ disabled={!selectedProject}
324
+ 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"
325
+ autoComplete="off"
326
+ autoCorrect="off"
327
+ />
328
+ {loading ? (
329
+ <button
330
+ onClick={stopGeneration}
331
+ className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium shrink-0"
332
+ >Stop</button>
333
+ ) : (
334
+ <button
335
+ onClick={sendMessage}
336
+ disabled={!selectedProject || !input.trim()}
337
+ className="px-4 py-2 bg-[#7c5bf0] text-white rounded-lg text-sm font-medium disabled:opacity-50 shrink-0"
338
+ >Send</button>
339
+ )}
340
+ </div>
341
+
342
+ {/* Debug log */}
343
+ <div className="shrink-0 bg-[#0d1117] border-t border-[#30363d]">
344
+ <div className="flex items-center gap-2 px-3 py-1">
345
+ <span className="text-[9px] text-[#8b949e]">Debug:</span>
346
+ {(['off', 'simple', 'verbose'] as const).map(level => (
347
+ <button
348
+ key={level}
349
+ onClick={() => { setDebugLevel(level); debugLevelRef.current = level; if (level === 'off') setDebug([]); }}
350
+ className={`text-[9px] px-1.5 py-0.5 rounded ${debugLevel === level ? 'bg-[#30363d] text-[#e6edf3]' : 'text-[#8b949e]'}`}
351
+ >{level}</button>
352
+ ))}
353
+ {debug.length > 0 && (
354
+ <button onClick={() => setDebug([])} className="text-[9px] text-[#8b949e] ml-auto">Clear</button>
355
+ )}
356
+ </div>
357
+ {debugLevel !== 'off' && debug.length > 0 && (
358
+ <div className="px-3 py-1 max-h-32 overflow-y-auto border-t border-[#30363d]/50">
359
+ {debug.map((d, i) => <div key={i} className="text-[9px] text-[#8b949e] font-mono">{d}</div>)}
360
+ </div>
361
+ )}
362
+ </div>
363
+ </div>
364
+ );
365
+ }
@@ -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