@aion0/forge 0.4.10 → 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 +8 -13
- package/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
- package/app/api/mobile-chat/route.ts +87 -0
- package/app/mobile/page.tsx +9 -0
- package/app/page.tsx +13 -1
- package/components/BrowserPanel.tsx +11 -5
- package/components/Dashboard.tsx +6 -0
- package/components/MobileView.tsx +365 -0
- package/lib/claude-sessions.ts +2 -2
- package/lib/init.ts +1 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.11
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-23
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.10
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
-
|
|
7
|
+
### Other
|
|
8
|
+
- mobile page
|
|
9
|
+
- init version for mobile page
|
|
10
|
+
- change sync period time
|
|
9
11
|
|
|
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
|
|
13
12
|
|
|
14
|
-
|
|
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
|
|
13
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.10...v0.4.11
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { getSessionFilePath, readSessionEntries } from '@/lib/claude-sessions';
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export async function GET(req: Request, { params }: { params: Promise<{ projectName: string }> }) {
|
|
6
|
+
const { projectName } = await params;
|
|
7
|
+
const url = new URL(req.url);
|
|
8
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
9
|
+
|
|
10
|
+
if (!sessionId) {
|
|
11
|
+
return NextResponse.json({ error: 'sessionId required' }, { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const filePath = getSessionFilePath(decodeURIComponent(projectName), sessionId);
|
|
15
|
+
if (!filePath) {
|
|
16
|
+
return NextResponse.json({ entries: [], count: 0, fileSize: 0 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const entries = readSessionEntries(filePath);
|
|
20
|
+
let fileSize = 0;
|
|
21
|
+
try { fileSize = statSync(filePath).size; } catch {}
|
|
22
|
+
return NextResponse.json({ entries, count: entries.length, fileSize });
|
|
23
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { loadSettings } from '@/lib/settings';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
export const runtime = 'nodejs';
|
|
7
|
+
|
|
8
|
+
// POST /api/mobile-chat — send a message to claude and stream response
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
const { message, projectPath, resume } = await req.json() as {
|
|
11
|
+
message: string;
|
|
12
|
+
projectPath: string;
|
|
13
|
+
resume?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (!message || !projectPath) {
|
|
17
|
+
return NextResponse.json({ error: 'message and projectPath required' }, { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const settings = loadSettings();
|
|
21
|
+
const claudePath = settings.claudePath || 'claude';
|
|
22
|
+
|
|
23
|
+
const args = ['-p', '--dangerously-skip-permissions', '--output-format', 'json'];
|
|
24
|
+
if (resume) args.push('-c');
|
|
25
|
+
|
|
26
|
+
const child = spawn(claudePath, args, {
|
|
27
|
+
cwd: projectPath,
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
child.stdin.write(message);
|
|
33
|
+
child.stdin.end();
|
|
34
|
+
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
let closed = false;
|
|
37
|
+
|
|
38
|
+
const stream = new ReadableStream({
|
|
39
|
+
start(controller) {
|
|
40
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
41
|
+
if (closed) return;
|
|
42
|
+
try {
|
|
43
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text: chunk.toString() })}\n\n`));
|
|
44
|
+
} catch {}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
const text = chunk.toString();
|
|
50
|
+
if (text.includes('npm update') || text.includes('WARN')) return;
|
|
51
|
+
try {
|
|
52
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'stderr', text })}\n\n`));
|
|
53
|
+
} catch {}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
child.on('exit', (code) => {
|
|
57
|
+
if (closed) return;
|
|
58
|
+
closed = true;
|
|
59
|
+
try {
|
|
60
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', code })}\n\n`));
|
|
61
|
+
controller.close();
|
|
62
|
+
} catch {}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
child.on('error', (err) => {
|
|
66
|
+
if (closed) return;
|
|
67
|
+
closed = true;
|
|
68
|
+
try {
|
|
69
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`));
|
|
70
|
+
controller.close();
|
|
71
|
+
} catch {}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
cancel() {
|
|
75
|
+
closed = true;
|
|
76
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return new Response(stream, {
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'text/event-stream',
|
|
83
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
84
|
+
Connection: 'keep-alive',
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { auth } from '@/lib/auth';
|
|
2
|
+
import { redirect } from 'next/navigation';
|
|
3
|
+
import MobileView from '@/components/MobileView';
|
|
4
|
+
|
|
5
|
+
export default async function MobilePage() {
|
|
6
|
+
const session = await auth();
|
|
7
|
+
if (!session) redirect('/login');
|
|
8
|
+
return <MobileView />;
|
|
9
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { auth } from '@/lib/auth';
|
|
2
2
|
import { redirect } from 'next/navigation';
|
|
3
|
+
import { headers } from 'next/headers';
|
|
3
4
|
import Dashboard from '@/components/Dashboard';
|
|
4
5
|
|
|
5
|
-
export default async function Home() {
|
|
6
|
+
export default async function Home({ searchParams }: { searchParams: Promise<{ force?: string }> }) {
|
|
6
7
|
const session = await auth();
|
|
7
8
|
if (!session) redirect('/login');
|
|
9
|
+
|
|
10
|
+
const params = await searchParams;
|
|
11
|
+
|
|
12
|
+
// Auto-detect mobile and redirect (skip if ?force=desktop)
|
|
13
|
+
if (params.force !== 'desktop') {
|
|
14
|
+
const headersList = await headers();
|
|
15
|
+
const ua = headersList.get('user-agent') || '';
|
|
16
|
+
const isMobile = /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
|
17
|
+
if (isMobile) redirect('/mobile');
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
return <Dashboard user={session.user} />;
|
|
9
21
|
}
|
|
@@ -29,10 +29,17 @@ export default function BrowserPanel({ onClose }: { onClose?: () => void }) {
|
|
|
29
29
|
return () => clearInterval(timer);
|
|
30
30
|
}, [fetchPreviews]);
|
|
31
31
|
|
|
32
|
+
const normalizeUrl = (val: string): string => {
|
|
33
|
+
if (/^\d+$/.test(val)) return `http://localhost:${val}`;
|
|
34
|
+
if (!/^https?:\/\//i.test(val)) return `http://${val}`;
|
|
35
|
+
return val;
|
|
36
|
+
};
|
|
37
|
+
|
|
32
38
|
const navigate = (url: string) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
const normalized = normalizeUrl(url);
|
|
40
|
+
setBrowserUrl(normalized);
|
|
41
|
+
localStorage.setItem('forge-browser-url', normalized);
|
|
42
|
+
if (browserUrlRef.current) browserUrlRef.current.value = normalized;
|
|
36
43
|
setBrowserKey(k => k + 1);
|
|
37
44
|
};
|
|
38
45
|
|
|
@@ -96,8 +103,7 @@ export default function BrowserPanel({ onClose }: { onClose?: () => void }) {
|
|
|
96
103
|
if (e.key === 'Enter') {
|
|
97
104
|
const val = (e.target as HTMLInputElement).value.trim();
|
|
98
105
|
if (!val) return;
|
|
99
|
-
|
|
100
|
-
navigate(url);
|
|
106
|
+
navigate(val);
|
|
101
107
|
}
|
|
102
108
|
}}
|
|
103
109
|
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"
|
package/components/Dashboard.tsx
CHANGED
|
@@ -558,6 +558,12 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
558
558
|
>
|
|
559
559
|
Logs
|
|
560
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>
|
|
561
567
|
<div className="border-t border-[var(--border)] my-1" />
|
|
562
568
|
<button
|
|
563
569
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
@@ -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
|
+
}
|
package/lib/claude-sessions.ts
CHANGED
|
@@ -257,8 +257,8 @@ export function tailSessionFile(
|
|
|
257
257
|
|
|
258
258
|
watcher.on('error', (err) => onError?.(err));
|
|
259
259
|
|
|
260
|
-
// Poll every
|
|
261
|
-
const pollTimer = setInterval(readNewBytes,
|
|
260
|
+
// Poll every 1 second as fallback (fs.watch is unreliable on macOS)
|
|
261
|
+
const pollTimer = setInterval(readNewBytes, 1000);
|
|
262
262
|
|
|
263
263
|
return () => {
|
|
264
264
|
watcher.close();
|
package/lib/init.ts
CHANGED
|
@@ -103,7 +103,7 @@ export function ensureInitialized() {
|
|
|
103
103
|
try {
|
|
104
104
|
const { syncSkills } = require('./skills');
|
|
105
105
|
syncSkills().catch(() => {});
|
|
106
|
-
setInterval(() => { syncSkills().catch(() => {}); },
|
|
106
|
+
setInterval(() => { syncSkills().catch(() => {}); }, 60 * 60 * 1000);
|
|
107
107
|
} catch {}
|
|
108
108
|
|
|
109
109
|
// Task runner is safe in every worker (DB-level coordination)
|
package/package.json
CHANGED