@aion0/forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ClaudeMessage {
|
|
6
|
+
type: 'system' | 'assistant' | 'result';
|
|
7
|
+
subtype?: string;
|
|
8
|
+
content: string;
|
|
9
|
+
tool?: string;
|
|
10
|
+
costUSD?: number;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ClaudeProcess {
|
|
16
|
+
id: string;
|
|
17
|
+
projectName: string;
|
|
18
|
+
projectPath: string;
|
|
19
|
+
status: string;
|
|
20
|
+
conversationId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function ClaudeTerminal({
|
|
24
|
+
process: proc,
|
|
25
|
+
onKill,
|
|
26
|
+
}: {
|
|
27
|
+
process: ClaudeProcess;
|
|
28
|
+
onKill: (id: string) => void;
|
|
29
|
+
}) {
|
|
30
|
+
const [messages, setMessages] = useState<ClaudeMessage[]>([]);
|
|
31
|
+
const [input, setInput] = useState('');
|
|
32
|
+
const [isRunning, setIsRunning] = useState(false);
|
|
33
|
+
const [conversationId, setConversationId] = useState<string | undefined>(proc.conversationId);
|
|
34
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const eventSourceRef = useRef<EventSource | null>(null);
|
|
36
|
+
|
|
37
|
+
// Connect SSE stream with auto-reconnect
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
let es: EventSource | null = null;
|
|
41
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
42
|
+
|
|
43
|
+
function connect() {
|
|
44
|
+
if (cancelled) return;
|
|
45
|
+
es = new EventSource(`/api/claude/${proc.id}/stream`);
|
|
46
|
+
eventSourceRef.current = es;
|
|
47
|
+
|
|
48
|
+
es.onmessage = (event) => {
|
|
49
|
+
try {
|
|
50
|
+
const msg: ClaudeMessage = JSON.parse(event.data);
|
|
51
|
+
setMessages(prev => {
|
|
52
|
+
// Deduplicate by timestamp+content on reconnect
|
|
53
|
+
if (prev.some(m => m.timestamp === msg.timestamp && m.content === msg.content)) {
|
|
54
|
+
return prev;
|
|
55
|
+
}
|
|
56
|
+
return [...prev, msg];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (msg.sessionId) {
|
|
60
|
+
setConversationId(msg.sessionId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (msg.subtype === 'complete') {
|
|
64
|
+
setIsRunning(false);
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
es.onerror = () => {
|
|
70
|
+
es?.close();
|
|
71
|
+
eventSourceRef.current = null;
|
|
72
|
+
// Auto-reconnect after 2s
|
|
73
|
+
if (!cancelled) {
|
|
74
|
+
reconnectTimer = setTimeout(connect, 2000);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
connect();
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
84
|
+
es?.close();
|
|
85
|
+
eventSourceRef.current = null;
|
|
86
|
+
};
|
|
87
|
+
}, [proc.id]);
|
|
88
|
+
|
|
89
|
+
// Auto-scroll
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
92
|
+
}, [messages]);
|
|
93
|
+
|
|
94
|
+
const sendMessage = async () => {
|
|
95
|
+
const text = input.trim();
|
|
96
|
+
if (!text || isRunning) return;
|
|
97
|
+
|
|
98
|
+
setInput('');
|
|
99
|
+
setIsRunning(true);
|
|
100
|
+
|
|
101
|
+
await fetch(`/api/claude/${proc.id}`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ type: 'message', content: text, conversationId }),
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
109
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
sendMessage();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="flex flex-col h-full">
|
|
117
|
+
{/* Header */}
|
|
118
|
+
<div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-3 shrink-0">
|
|
119
|
+
<div className="flex items-center gap-2">
|
|
120
|
+
<span className={`text-[10px] ${proc.status === 'running' || isRunning ? 'text-[var(--green)]' : 'text-[var(--text-secondary)]'}`}>●</span>
|
|
121
|
+
<span className="text-xs font-semibold">Claude Code</span>
|
|
122
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{proc.projectName}</span>
|
|
123
|
+
{isRunning && <span className="text-[10px] text-[var(--accent)] animate-pulse">thinking...</span>}
|
|
124
|
+
</div>
|
|
125
|
+
<button
|
|
126
|
+
onClick={() => onKill(proc.id)}
|
|
127
|
+
className="text-[10px] px-2 py-0.5 text-[var(--red)] hover:bg-[var(--red)] hover:text-white rounded transition-colors"
|
|
128
|
+
>
|
|
129
|
+
Kill
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Messages */}
|
|
134
|
+
<div className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
|
135
|
+
{messages.length === 0 && (
|
|
136
|
+
<div className="text-center text-[var(--text-secondary)] py-8">
|
|
137
|
+
<p>Send a message to start working with Claude Code</p>
|
|
138
|
+
<p className="text-[10px] mt-1">Working in: {proc.projectPath}</p>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{messages.map((msg, i) => (
|
|
143
|
+
<MessageBubble key={i} msg={msg} />
|
|
144
|
+
))}
|
|
145
|
+
<div ref={messagesEndRef} />
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Input */}
|
|
149
|
+
<div className="border-t border-[var(--border)] p-3">
|
|
150
|
+
<div className="flex gap-2">
|
|
151
|
+
<textarea
|
|
152
|
+
value={input}
|
|
153
|
+
onChange={e => setInput(e.target.value)}
|
|
154
|
+
onKeyDown={handleKeyDown}
|
|
155
|
+
placeholder={isRunning ? 'Waiting for response...' : 'Send a message to Claude Code...'}
|
|
156
|
+
disabled={isRunning}
|
|
157
|
+
rows={2}
|
|
158
|
+
className="flex-1 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
159
|
+
/>
|
|
160
|
+
<button
|
|
161
|
+
onClick={sendMessage}
|
|
162
|
+
disabled={isRunning || !input.trim()}
|
|
163
|
+
className="px-4 py-2 bg-[var(--accent)] text-white rounded text-xs hover:opacity-90 disabled:opacity-50 self-end"
|
|
164
|
+
>
|
|
165
|
+
Send
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function MessageBubble({ msg }: { msg: ClaudeMessage }) {
|
|
174
|
+
// User input
|
|
175
|
+
if (msg.type === 'system' && msg.subtype === 'user_input') {
|
|
176
|
+
return (
|
|
177
|
+
<div className="flex justify-end">
|
|
178
|
+
<div className="max-w-[80%] px-3 py-2 bg-[var(--accent)] text-white rounded-lg rounded-br-sm">
|
|
179
|
+
<pre className="whitespace-pre-wrap break-words">{msg.content}</pre>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// System init — show model info subtly
|
|
186
|
+
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
187
|
+
return (
|
|
188
|
+
<div className="text-center text-[10px] text-[var(--text-secondary)] py-1">
|
|
189
|
+
{msg.content}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Error
|
|
195
|
+
if (msg.subtype === 'error') {
|
|
196
|
+
return (
|
|
197
|
+
<div className="px-3 py-2 bg-red-900/20 border border-red-800/30 rounded text-[var(--red)]">
|
|
198
|
+
<pre className="whitespace-pre-wrap break-words">{msg.content}</pre>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Completion notice
|
|
204
|
+
if (msg.subtype === 'complete') {
|
|
205
|
+
return (
|
|
206
|
+
<div className="text-center text-[10px] text-[var(--text-secondary)] py-1">
|
|
207
|
+
{msg.content}
|
|
208
|
+
{msg.costUSD != null && ` · $${msg.costUSD.toFixed(4)}`}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Tool use
|
|
214
|
+
if (msg.subtype === 'tool_use') {
|
|
215
|
+
return (
|
|
216
|
+
<div className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded">
|
|
217
|
+
<div className="flex items-center gap-2 mb-1">
|
|
218
|
+
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/20 text-[var(--accent)] rounded">
|
|
219
|
+
{msg.tool || 'tool'}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
<pre className="whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-40 overflow-y-auto">
|
|
223
|
+
{formatToolContent(msg.content)}
|
|
224
|
+
</pre>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Tool result
|
|
230
|
+
if (msg.subtype === 'tool_result') {
|
|
231
|
+
return (
|
|
232
|
+
<div className="px-3 py-2 bg-[var(--bg-tertiary)] border-l-2 border-[var(--accent)] rounded-r">
|
|
233
|
+
<pre className="whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-60 overflow-y-auto">
|
|
234
|
+
{formatToolContent(msg.content)}
|
|
235
|
+
</pre>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Final result
|
|
241
|
+
if (msg.type === 'result') {
|
|
242
|
+
return (
|
|
243
|
+
<div className="px-3 py-2 bg-green-900/10 border border-green-800/20 rounded">
|
|
244
|
+
<pre className="whitespace-pre-wrap break-words">{msg.content}</pre>
|
|
245
|
+
{msg.costUSD != null && (
|
|
246
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-1">Cost: ${msg.costUSD.toFixed(4)}</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Regular assistant text
|
|
253
|
+
return (
|
|
254
|
+
<div className="px-3 py-2">
|
|
255
|
+
<pre className="whitespace-pre-wrap break-words text-[var(--text-primary)]">{msg.content}</pre>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatToolContent(content: string): string {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(content);
|
|
263
|
+
return JSON.stringify(parsed, null, 2);
|
|
264
|
+
} catch {
|
|
265
|
+
return content;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
import TaskBoard from './TaskBoard';
|
|
5
|
+
import TaskDetail from './TaskDetail';
|
|
6
|
+
import SessionView from './SessionView';
|
|
7
|
+
import NewTaskModal from './NewTaskModal';
|
|
8
|
+
import SettingsModal from './SettingsModal';
|
|
9
|
+
import TunnelToggle from './TunnelToggle';
|
|
10
|
+
import type { Task } from '@/src/types';
|
|
11
|
+
import type { WebTerminalHandle } from './WebTerminal';
|
|
12
|
+
|
|
13
|
+
const WebTerminal = lazy(() => import('./WebTerminal'));
|
|
14
|
+
|
|
15
|
+
interface UsageSummary {
|
|
16
|
+
provider: string;
|
|
17
|
+
totalInput: number;
|
|
18
|
+
totalOutput: number;
|
|
19
|
+
totalCost: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ProviderInfo {
|
|
23
|
+
name: string;
|
|
24
|
+
displayName: string;
|
|
25
|
+
hasKey: boolean;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ProjectInfo {
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
language: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function Dashboard({ user }: { user: any }) {
|
|
36
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal'>('tasks');
|
|
37
|
+
const [tasks, setTasks] = useState<Task[]>([]);
|
|
38
|
+
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
39
|
+
const [showNewTask, setShowNewTask] = useState(false);
|
|
40
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
41
|
+
const [usage, setUsage] = useState<UsageSummary[]>([]);
|
|
42
|
+
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
43
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
44
|
+
const terminalRef = useRef<WebTerminalHandle>(null);
|
|
45
|
+
|
|
46
|
+
const fetchData = useCallback(async () => {
|
|
47
|
+
const [tasksRes, statusRes, projectsRes] = await Promise.all([
|
|
48
|
+
fetch('/api/tasks'),
|
|
49
|
+
fetch('/api/status'),
|
|
50
|
+
fetch('/api/projects'),
|
|
51
|
+
]);
|
|
52
|
+
const tasksData = await tasksRes.json();
|
|
53
|
+
const statusData = await statusRes.json();
|
|
54
|
+
const projectsData = await projectsRes.json();
|
|
55
|
+
setTasks(tasksData);
|
|
56
|
+
setProviders(statusData.providers);
|
|
57
|
+
setUsage(statusData.usage);
|
|
58
|
+
setProjects(projectsData);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
fetchData();
|
|
63
|
+
const interval = setInterval(fetchData, 5000);
|
|
64
|
+
return () => clearInterval(interval);
|
|
65
|
+
}, [fetchData]);
|
|
66
|
+
|
|
67
|
+
const activeTask = tasks.find(t => t.id === activeTaskId);
|
|
68
|
+
const running = tasks.filter(t => t.status === 'running');
|
|
69
|
+
const queued = tasks.filter(t => t.status === 'queued');
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="h-screen flex flex-col">
|
|
73
|
+
{/* Top bar */}
|
|
74
|
+
<header className="h-10 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0">
|
|
75
|
+
<div className="flex items-center gap-4">
|
|
76
|
+
<span className="text-sm font-bold text-[var(--accent)]">Forge</span>
|
|
77
|
+
|
|
78
|
+
{/* View mode toggle */}
|
|
79
|
+
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
80
|
+
<button
|
|
81
|
+
onClick={() => setViewMode('tasks')}
|
|
82
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
83
|
+
viewMode === 'tasks'
|
|
84
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
85
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
86
|
+
}`}
|
|
87
|
+
>
|
|
88
|
+
Tasks
|
|
89
|
+
</button>
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => setViewMode('sessions')}
|
|
92
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
93
|
+
viewMode === 'sessions'
|
|
94
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
95
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
96
|
+
}`}
|
|
97
|
+
>
|
|
98
|
+
Sessions
|
|
99
|
+
</button>
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => setViewMode('terminal')}
|
|
102
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
103
|
+
viewMode === 'terminal'
|
|
104
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
105
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
106
|
+
}`}
|
|
107
|
+
>
|
|
108
|
+
Terminal
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{viewMode === 'tasks' && (
|
|
113
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
114
|
+
{running.length} running · {queued.length} queued · {tasks.filter(t => t.status === 'done').length} done
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex items-center gap-3">
|
|
119
|
+
{viewMode === 'tasks' && (
|
|
120
|
+
<button
|
|
121
|
+
onClick={() => setShowNewTask(true)}
|
|
122
|
+
className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
123
|
+
>
|
|
124
|
+
+ New Task
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
<TunnelToggle />
|
|
128
|
+
<button
|
|
129
|
+
onClick={() => setShowSettings(true)}
|
|
130
|
+
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
131
|
+
>
|
|
132
|
+
Settings
|
|
133
|
+
</button>
|
|
134
|
+
<span className="text-xs text-[var(--text-secondary)]">{user?.name || 'local'}</span>
|
|
135
|
+
</div>
|
|
136
|
+
</header>
|
|
137
|
+
|
|
138
|
+
{/* Main content */}
|
|
139
|
+
<div className="flex-1 flex min-h-0">
|
|
140
|
+
{viewMode === 'tasks' ? (
|
|
141
|
+
<>
|
|
142
|
+
{/* Left — Task list */}
|
|
143
|
+
<aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
144
|
+
<TaskBoard tasks={tasks} activeId={activeTaskId} onSelect={setActiveTaskId} onRefresh={fetchData} />
|
|
145
|
+
</aside>
|
|
146
|
+
|
|
147
|
+
{/* Center — Task detail / empty state */}
|
|
148
|
+
<main className="flex-1 flex flex-col min-w-0">
|
|
149
|
+
{activeTask ? (
|
|
150
|
+
<TaskDetail
|
|
151
|
+
task={activeTask}
|
|
152
|
+
onRefresh={fetchData}
|
|
153
|
+
onFollowUp={async (data) => {
|
|
154
|
+
const res = await fetch('/api/tasks', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify(data),
|
|
158
|
+
});
|
|
159
|
+
const newTask = await res.json();
|
|
160
|
+
setActiveTaskId(newTask.id);
|
|
161
|
+
fetchData();
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
) : (
|
|
165
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
166
|
+
<div className="text-center space-y-2">
|
|
167
|
+
<p className="text-lg">Select a task or create a new one</p>
|
|
168
|
+
<p className="text-xs">Submit tasks for Claude Code to work on autonomously</p>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</main>
|
|
173
|
+
|
|
174
|
+
{/* Right — Status panel */}
|
|
175
|
+
<aside className="w-56 border-l border-[var(--border)] flex flex-col shrink-0 p-3 space-y-4">
|
|
176
|
+
{/* Providers */}
|
|
177
|
+
<div>
|
|
178
|
+
<h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Providers</h3>
|
|
179
|
+
<div className="space-y-1">
|
|
180
|
+
{providers.map(p => (
|
|
181
|
+
<div key={p.name} className="flex items-center justify-between text-xs">
|
|
182
|
+
<span className={p.hasKey && p.enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}>
|
|
183
|
+
{p.displayName}
|
|
184
|
+
</span>
|
|
185
|
+
<span className={`text-[10px] ${p.hasKey && p.enabled ? 'text-[var(--green)]' : 'text-[var(--red)]'}`}>
|
|
186
|
+
{p.hasKey && p.enabled ? '● active' : '○ off'}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Usage */}
|
|
194
|
+
{usage.length > 0 && (
|
|
195
|
+
<div>
|
|
196
|
+
<h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Usage (30d)</h3>
|
|
197
|
+
<div className="space-y-1">
|
|
198
|
+
{usage.map((u, i) => (
|
|
199
|
+
<div key={i} className="text-xs">
|
|
200
|
+
<div className="flex justify-between">
|
|
201
|
+
<span className="text-[var(--text-secondary)]">{u.provider}</span>
|
|
202
|
+
<span className="text-[var(--text-primary)]">{((u.totalInput + u.totalOutput) / 1000).toFixed(0)}k tokens</span>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{/* Running tasks */}
|
|
211
|
+
{running.length > 0 && (
|
|
212
|
+
<div>
|
|
213
|
+
<h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Running</h3>
|
|
214
|
+
<div className="space-y-1">
|
|
215
|
+
{running.map(t => (
|
|
216
|
+
<button
|
|
217
|
+
key={t.id}
|
|
218
|
+
onClick={() => { setViewMode('tasks'); setActiveTaskId(t.id); }}
|
|
219
|
+
className="w-full text-left px-2 py-1 rounded text-xs hover:bg-[var(--bg-tertiary)]"
|
|
220
|
+
>
|
|
221
|
+
<span className="text-[var(--green)] text-[10px]">● </span>
|
|
222
|
+
<span className="truncate">{t.projectName}</span>
|
|
223
|
+
</button>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</aside>
|
|
229
|
+
</>
|
|
230
|
+
) : viewMode === 'sessions' ? (
|
|
231
|
+
<SessionView
|
|
232
|
+
projects={projects}
|
|
233
|
+
onOpenInTerminal={(sessionId, projectPath) => {
|
|
234
|
+
setViewMode('terminal');
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
terminalRef.current?.openSessionInTerminal(sessionId, projectPath);
|
|
237
|
+
}, 100);
|
|
238
|
+
}}
|
|
239
|
+
/>
|
|
240
|
+
) : null}
|
|
241
|
+
|
|
242
|
+
{/* Terminal — always mounted, hidden when not active to keep sessions alive */}
|
|
243
|
+
<div className={`flex-1 min-h-0 ${viewMode === 'terminal' ? '' : 'hidden'}`}>
|
|
244
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading terminal...</div>}>
|
|
245
|
+
<WebTerminal ref={terminalRef} />
|
|
246
|
+
</Suspense>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{showNewTask && (
|
|
251
|
+
<NewTaskModal
|
|
252
|
+
onClose={() => setShowNewTask(false)}
|
|
253
|
+
onCreate={async (data) => {
|
|
254
|
+
await fetch('/api/tasks', {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
257
|
+
body: JSON.stringify(data),
|
|
258
|
+
});
|
|
259
|
+
setShowNewTask(false);
|
|
260
|
+
fetchData();
|
|
261
|
+
}}
|
|
262
|
+
/>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
{showSettings && (
|
|
266
|
+
<SettingsModal onClose={() => { setShowSettings(false); fetchData(); }} />
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Markdown from 'react-markdown';
|
|
4
|
+
|
|
5
|
+
export default function MarkdownContent({ content }: { content: string }) {
|
|
6
|
+
return (
|
|
7
|
+
<Markdown
|
|
8
|
+
components={{
|
|
9
|
+
h1: ({ children }) => <h1 className="text-base font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h1>,
|
|
10
|
+
h2: ({ children }) => <h2 className="text-sm font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h2>,
|
|
11
|
+
h3: ({ children }) => <h3 className="text-xs font-bold text-[var(--text-primary)] mt-2 mb-1">{children}</h3>,
|
|
12
|
+
p: ({ children }) => <p className="text-xs text-[var(--text-primary)] mb-1.5 leading-relaxed">{children}</p>,
|
|
13
|
+
ul: ({ children }) => <ul className="text-xs text-[var(--text-primary)] mb-1.5 ml-4 list-disc space-y-0.5">{children}</ul>,
|
|
14
|
+
ol: ({ children }) => <ol className="text-xs text-[var(--text-primary)] mb-1.5 ml-4 list-decimal space-y-0.5">{children}</ol>,
|
|
15
|
+
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
|
16
|
+
strong: ({ children }) => <strong className="font-semibold text-[var(--text-primary)]">{children}</strong>,
|
|
17
|
+
em: ({ children }) => <em className="italic text-[var(--text-secondary)]">{children}</em>,
|
|
18
|
+
a: ({ href, children }) => <a href={href} className="text-[var(--accent)] hover:underline" target="_blank" rel="noopener">{children}</a>,
|
|
19
|
+
blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)]/40 pl-3 my-1.5 text-[var(--text-secondary)] text-xs italic">{children}</blockquote>,
|
|
20
|
+
code: ({ className, children }) => {
|
|
21
|
+
const isBlock = className?.includes('language-');
|
|
22
|
+
if (isBlock) {
|
|
23
|
+
const lang = className?.replace('language-', '') || '';
|
|
24
|
+
return (
|
|
25
|
+
<div className="my-2 rounded border border-[var(--border)] overflow-hidden max-w-full">
|
|
26
|
+
{lang && (
|
|
27
|
+
<div className="px-3 py-1 bg-[var(--bg-tertiary)] border-b border-[var(--border)] text-[9px] text-[var(--text-secondary)] font-mono">
|
|
28
|
+
{lang}
|
|
29
|
+
</div>
|
|
30
|
+
)}
|
|
31
|
+
<pre className="p-3 bg-[var(--bg-tertiary)] overflow-x-auto max-w-full">
|
|
32
|
+
<code className="text-[11px] font-mono text-[var(--text-primary)] break-all">{children}</code>
|
|
33
|
+
</pre>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<code className="text-[11px] font-mono bg-[var(--bg-tertiary)] text-[var(--accent)] px-1 py-0.5 rounded">
|
|
39
|
+
{children}
|
|
40
|
+
</code>
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
pre: ({ children }) => <>{children}</>,
|
|
44
|
+
hr: () => <hr className="my-3 border-[var(--border)]" />,
|
|
45
|
+
table: ({ children }) => (
|
|
46
|
+
<div className="my-2 overflow-x-auto">
|
|
47
|
+
<table className="text-xs border-collapse w-full">{children}</table>
|
|
48
|
+
</div>
|
|
49
|
+
),
|
|
50
|
+
th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 bg-[var(--bg-tertiary)] text-left font-semibold text-[11px]">{children}</th>,
|
|
51
|
+
td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1 text-[11px]">{children}</td>,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{content}
|
|
55
|
+
</Markdown>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import type { SessionTemplate } from '@/src/types';
|
|
5
|
+
|
|
6
|
+
export default function NewSessionModal({
|
|
7
|
+
onClose,
|
|
8
|
+
onCreate,
|
|
9
|
+
}: {
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
onCreate: (data: { name: string; templateId: string }) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const [templates, setTemplates] = useState<SessionTemplate[]>([]);
|
|
14
|
+
const [selectedTemplate, setSelectedTemplate] = useState('');
|
|
15
|
+
const [name, setName] = useState('');
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
fetch('/api/templates')
|
|
19
|
+
.then(r => r.json())
|
|
20
|
+
.then((t: SessionTemplate[]) => {
|
|
21
|
+
setTemplates(t);
|
|
22
|
+
if (t.length > 0) setSelectedTemplate(t[0].id);
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const handleCreate = () => {
|
|
27
|
+
if (!selectedTemplate) return;
|
|
28
|
+
const finalName = name || `${selectedTemplate}-${Date.now().toString(36)}`;
|
|
29
|
+
onCreate({ name: finalName, templateId: selectedTemplate });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
|
34
|
+
<div
|
|
35
|
+
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-96 p-5 space-y-4"
|
|
36
|
+
onClick={e => e.stopPropagation()}
|
|
37
|
+
>
|
|
38
|
+
<h2 className="text-sm font-bold">New Session</h2>
|
|
39
|
+
|
|
40
|
+
{/* Template selection */}
|
|
41
|
+
<div className="space-y-2">
|
|
42
|
+
<label className="text-xs text-[var(--text-secondary)]">Template</label>
|
|
43
|
+
<div className="grid grid-cols-2 gap-2">
|
|
44
|
+
{templates.map(t => (
|
|
45
|
+
<button
|
|
46
|
+
key={t.id}
|
|
47
|
+
onClick={() => setSelectedTemplate(t.id)}
|
|
48
|
+
className={`p-2 rounded border text-left text-xs transition-colors ${
|
|
49
|
+
selectedTemplate === t.id
|
|
50
|
+
? 'border-[var(--accent)] bg-[var(--bg-tertiary)]'
|
|
51
|
+
: 'border-[var(--border)] hover:border-[var(--text-secondary)]'
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
<div className="font-medium">{t.ui?.icon} {t.name}</div>
|
|
55
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
|
56
|
+
{t.provider} · {t.memory.strategy}
|
|
57
|
+
</div>
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Name */}
|
|
64
|
+
<div className="space-y-1">
|
|
65
|
+
<label className="text-xs text-[var(--text-secondary)]">Session Name (optional)</label>
|
|
66
|
+
<input
|
|
67
|
+
value={name}
|
|
68
|
+
onChange={e => setName(e.target.value)}
|
|
69
|
+
placeholder={selectedTemplate || 'auto-generated'}
|
|
70
|
+
className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Actions */}
|
|
75
|
+
<div className="flex justify-end gap-2">
|
|
76
|
+
<button
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
79
|
+
>
|
|
80
|
+
Cancel
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
onClick={handleCreate}
|
|
84
|
+
disabled={!selectedTemplate}
|
|
85
|
+
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
86
|
+
>
|
|
87
|
+
Create
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|