@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.
- package/RELEASE_NOTES.md +7 -5
- package/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
- package/app/api/help/route.ts +10 -4
- package/app/api/mobile-chat/route.ts +87 -0
- package/app/api/preview/route.ts +8 -0
- package/app/mobile/page.tsx +9 -0
- package/app/page.tsx +13 -1
- package/components/BrowserPanel.tsx +175 -0
- package/components/CodeViewer.tsx +4 -74
- package/components/Dashboard.tsx +166 -20
- package/components/HelpTerminal.tsx +8 -2
- package/components/MobileView.tsx +365 -0
- package/components/ProjectDetail.tsx +5 -5
- package/components/WebTerminal.tsx +47 -26
- package/lib/claude-sessions.ts +2 -2
- package/lib/init.ts +18 -1
- package/lib/pipeline-scheduler.ts +18 -6
- package/package.json +1 -1
- package/components/PreviewPanel.tsx +0 -167
package/components/Dashboard.tsx
CHANGED
|
@@ -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
|
|
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' | '
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
Scheduled mode: auto-scans GitHub issues and fixes new ones
|
|
1005
|
-
|
|
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
|