@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,366 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Settings {
|
|
6
|
+
projectRoots: string[];
|
|
7
|
+
claudePath: string;
|
|
8
|
+
telegramBotToken: string;
|
|
9
|
+
telegramChatId: string;
|
|
10
|
+
notifyOnComplete: boolean;
|
|
11
|
+
notifyOnFailure: boolean;
|
|
12
|
+
tunnelAutoStart: boolean;
|
|
13
|
+
telegramTunnelPassword: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TunnelStatus {
|
|
17
|
+
status: 'stopped' | 'starting' | 'running' | 'error';
|
|
18
|
+
url: string | null;
|
|
19
|
+
error: string | null;
|
|
20
|
+
installed: boolean;
|
|
21
|
+
log: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
25
|
+
const [settings, setSettings] = useState<Settings>({
|
|
26
|
+
projectRoots: [],
|
|
27
|
+
claudePath: '',
|
|
28
|
+
telegramBotToken: '',
|
|
29
|
+
telegramChatId: '',
|
|
30
|
+
notifyOnComplete: true,
|
|
31
|
+
notifyOnFailure: true,
|
|
32
|
+
tunnelAutoStart: false,
|
|
33
|
+
telegramTunnelPassword: '',
|
|
34
|
+
});
|
|
35
|
+
const [newRoot, setNewRoot] = useState('');
|
|
36
|
+
const [saved, setSaved] = useState(false);
|
|
37
|
+
const [tunnel, setTunnel] = useState<TunnelStatus>({
|
|
38
|
+
status: 'stopped', url: null, error: null, installed: false, log: [],
|
|
39
|
+
});
|
|
40
|
+
const [tunnelLoading, setTunnelLoading] = useState(false);
|
|
41
|
+
const [confirmStopTunnel, setConfirmStopTunnel] = useState(false);
|
|
42
|
+
|
|
43
|
+
const refreshTunnel = useCallback(() => {
|
|
44
|
+
fetch('/api/tunnel').then(r => r.json()).then(setTunnel).catch(() => {});
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
fetch('/api/settings').then(r => r.json()).then(setSettings);
|
|
49
|
+
refreshTunnel();
|
|
50
|
+
}, [refreshTunnel]);
|
|
51
|
+
|
|
52
|
+
// Poll tunnel status while starting
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (tunnel.status !== 'starting') return;
|
|
55
|
+
const id = setInterval(refreshTunnel, 2000);
|
|
56
|
+
return () => clearInterval(id);
|
|
57
|
+
}, [tunnel.status, refreshTunnel]);
|
|
58
|
+
|
|
59
|
+
const save = async () => {
|
|
60
|
+
await fetch('/api/settings', {
|
|
61
|
+
method: 'PUT',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify(settings),
|
|
64
|
+
});
|
|
65
|
+
setSaved(true);
|
|
66
|
+
setTimeout(() => setSaved(false), 2000);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const addRoot = () => {
|
|
70
|
+
const path = newRoot.trim();
|
|
71
|
+
if (!path || settings.projectRoots.includes(path)) return;
|
|
72
|
+
setSettings({ ...settings, projectRoots: [...settings.projectRoots, path] });
|
|
73
|
+
setNewRoot('');
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const removeRoot = (path: string) => {
|
|
77
|
+
setSettings({
|
|
78
|
+
...settings,
|
|
79
|
+
projectRoots: settings.projectRoots.filter(r => r !== path),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
|
85
|
+
<div
|
|
86
|
+
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[500px] max-h-[80vh] overflow-y-auto p-5 space-y-5"
|
|
87
|
+
onClick={e => e.stopPropagation()}
|
|
88
|
+
>
|
|
89
|
+
<h2 className="text-sm font-bold">Settings</h2>
|
|
90
|
+
|
|
91
|
+
{/* Project Roots */}
|
|
92
|
+
<div className="space-y-2">
|
|
93
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
94
|
+
Project Directories
|
|
95
|
+
</label>
|
|
96
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
97
|
+
Add directories containing your projects. Each subdirectory is treated as a project.
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
{settings.projectRoots.map(root => (
|
|
101
|
+
<div key={root} className="flex items-center gap-2">
|
|
102
|
+
<span className="flex-1 text-xs px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded font-mono truncate">
|
|
103
|
+
{root}
|
|
104
|
+
</span>
|
|
105
|
+
<button
|
|
106
|
+
onClick={() => removeRoot(root)}
|
|
107
|
+
className="text-[10px] px-2 py-1 text-[var(--red)] hover:bg-[var(--red)] hover:text-white rounded transition-colors"
|
|
108
|
+
>
|
|
109
|
+
Remove
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
|
|
114
|
+
<div className="flex gap-2">
|
|
115
|
+
<input
|
|
116
|
+
value={newRoot}
|
|
117
|
+
onChange={e => setNewRoot(e.target.value)}
|
|
118
|
+
onKeyDown={e => e.key === 'Enter' && addRoot()}
|
|
119
|
+
placeholder="/Users/you/projects"
|
|
120
|
+
className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
121
|
+
/>
|
|
122
|
+
<button
|
|
123
|
+
onClick={addRoot}
|
|
124
|
+
className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
125
|
+
>
|
|
126
|
+
Add
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Claude Path */}
|
|
132
|
+
<div className="space-y-2">
|
|
133
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
134
|
+
Claude Code Path
|
|
135
|
+
</label>
|
|
136
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
137
|
+
Full path to the claude binary. Run `which claude` to find it.
|
|
138
|
+
</p>
|
|
139
|
+
<input
|
|
140
|
+
value={settings.claudePath}
|
|
141
|
+
onChange={e => setSettings({ ...settings, claudePath: e.target.value })}
|
|
142
|
+
placeholder="/usr/local/bin/claude"
|
|
143
|
+
className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Telegram Notifications */}
|
|
148
|
+
<div className="space-y-2">
|
|
149
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
150
|
+
Telegram Notifications
|
|
151
|
+
</label>
|
|
152
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
153
|
+
Get notified when tasks complete or fail. Create a bot via @BotFather, then send /start to it and use the test button below to get your chat ID.
|
|
154
|
+
</p>
|
|
155
|
+
<input
|
|
156
|
+
value={settings.telegramBotToken}
|
|
157
|
+
onChange={e => setSettings({ ...settings, telegramBotToken: e.target.value })}
|
|
158
|
+
placeholder="Bot token (from @BotFather)"
|
|
159
|
+
className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
160
|
+
/>
|
|
161
|
+
<input
|
|
162
|
+
value={settings.telegramChatId}
|
|
163
|
+
onChange={e => setSettings({ ...settings, telegramChatId: e.target.value })}
|
|
164
|
+
placeholder="Chat ID (your numeric user ID)"
|
|
165
|
+
className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
166
|
+
/>
|
|
167
|
+
<div className="flex items-center gap-4">
|
|
168
|
+
<label className="flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
|
|
169
|
+
<input
|
|
170
|
+
type="checkbox"
|
|
171
|
+
checked={settings.notifyOnComplete}
|
|
172
|
+
onChange={e => setSettings({ ...settings, notifyOnComplete: e.target.checked })}
|
|
173
|
+
className="rounded"
|
|
174
|
+
/>
|
|
175
|
+
Notify on complete
|
|
176
|
+
</label>
|
|
177
|
+
<label className="flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
|
|
178
|
+
<input
|
|
179
|
+
type="checkbox"
|
|
180
|
+
checked={settings.notifyOnFailure}
|
|
181
|
+
onChange={e => setSettings({ ...settings, notifyOnFailure: e.target.checked })}
|
|
182
|
+
className="rounded"
|
|
183
|
+
/>
|
|
184
|
+
Notify on failure
|
|
185
|
+
</label>
|
|
186
|
+
{settings.telegramBotToken && settings.telegramChatId && (
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={async () => {
|
|
190
|
+
// Save first, then test
|
|
191
|
+
await fetch('/api/settings', {
|
|
192
|
+
method: 'PUT',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
body: JSON.stringify(settings),
|
|
195
|
+
});
|
|
196
|
+
const res = await fetch('/api/notify/test', { method: 'POST' });
|
|
197
|
+
const data = await res.json();
|
|
198
|
+
alert(data.ok ? 'Test message sent!' : `Failed: ${data.error}`);
|
|
199
|
+
}}
|
|
200
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
201
|
+
>
|
|
202
|
+
Test
|
|
203
|
+
</button>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Remote Access (Cloudflare Tunnel) */}
|
|
209
|
+
<div className="space-y-2">
|
|
210
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
211
|
+
Remote Access
|
|
212
|
+
</label>
|
|
213
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
214
|
+
Expose this instance to the internet via Cloudflare Tunnel. No account needed — generates a temporary public URL.
|
|
215
|
+
{!tunnel.installed && ' First use will download cloudflared (~30MB).'}
|
|
216
|
+
</p>
|
|
217
|
+
|
|
218
|
+
<div className="flex items-center gap-2">
|
|
219
|
+
{tunnel.status === 'stopped' || tunnel.status === 'error' ? (
|
|
220
|
+
<button
|
|
221
|
+
disabled={tunnelLoading}
|
|
222
|
+
onClick={async () => {
|
|
223
|
+
setTunnelLoading(true);
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch('/api/tunnel', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({ action: 'start' }),
|
|
229
|
+
});
|
|
230
|
+
const data = await res.json();
|
|
231
|
+
setTunnel(data);
|
|
232
|
+
} catch {}
|
|
233
|
+
setTunnelLoading(false);
|
|
234
|
+
}}
|
|
235
|
+
className="text-[10px] px-3 py-1.5 bg-[var(--green)] text-black rounded hover:opacity-90 disabled:opacity-50"
|
|
236
|
+
>
|
|
237
|
+
{tunnelLoading ? (tunnel.installed ? 'Starting...' : 'Downloading...') : 'Start Tunnel'}
|
|
238
|
+
</button>
|
|
239
|
+
) : confirmStopTunnel ? (
|
|
240
|
+
<div className="flex items-center gap-2">
|
|
241
|
+
<span className="text-[10px] text-[var(--text-secondary)]">Stop tunnel?</span>
|
|
242
|
+
<button
|
|
243
|
+
onClick={async () => {
|
|
244
|
+
await fetch('/api/tunnel', {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
247
|
+
body: JSON.stringify({ action: 'stop' }),
|
|
248
|
+
});
|
|
249
|
+
refreshTunnel();
|
|
250
|
+
setConfirmStopTunnel(false);
|
|
251
|
+
}}
|
|
252
|
+
className="text-[10px] px-2 py-1 bg-[var(--red)] text-white rounded hover:opacity-90"
|
|
253
|
+
>
|
|
254
|
+
Confirm
|
|
255
|
+
</button>
|
|
256
|
+
<button
|
|
257
|
+
onClick={() => setConfirmStopTunnel(false)}
|
|
258
|
+
className="text-[10px] px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
259
|
+
>
|
|
260
|
+
Cancel
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
) : (
|
|
264
|
+
<button
|
|
265
|
+
onClick={() => setConfirmStopTunnel(true)}
|
|
266
|
+
className="text-[10px] px-3 py-1.5 bg-[var(--red)] text-white rounded hover:opacity-90"
|
|
267
|
+
>
|
|
268
|
+
Stop Tunnel
|
|
269
|
+
</button>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
273
|
+
{tunnel.status === 'running' && (
|
|
274
|
+
<span className="text-[var(--green)]">Running</span>
|
|
275
|
+
)}
|
|
276
|
+
{tunnel.status === 'starting' && (
|
|
277
|
+
<span className="text-[var(--yellow)]">Starting...</span>
|
|
278
|
+
)}
|
|
279
|
+
{tunnel.status === 'error' && (
|
|
280
|
+
<span className="text-[var(--red)]">Error</span>
|
|
281
|
+
)}
|
|
282
|
+
{tunnel.status === 'stopped' && 'Stopped'}
|
|
283
|
+
</span>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
{tunnel.url && (
|
|
287
|
+
<div className="flex items-center gap-2">
|
|
288
|
+
<input
|
|
289
|
+
readOnly
|
|
290
|
+
value={tunnel.url}
|
|
291
|
+
className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--green)] font-mono focus:outline-none cursor-text select-all"
|
|
292
|
+
onClick={e => (e.target as HTMLInputElement).select()}
|
|
293
|
+
/>
|
|
294
|
+
<button
|
|
295
|
+
onClick={() => {
|
|
296
|
+
navigator.clipboard.writeText(tunnel.url!);
|
|
297
|
+
}}
|
|
298
|
+
className="text-[10px] px-2 py-1.5 border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
299
|
+
>
|
|
300
|
+
Copy
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{tunnel.error && (
|
|
306
|
+
<p className="text-[10px] text-[var(--red)]">{tunnel.error}</p>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{tunnel.log.length > 0 && tunnel.status !== 'stopped' && (
|
|
310
|
+
<details className="text-[10px]">
|
|
311
|
+
<summary className="text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]">
|
|
312
|
+
Logs ({tunnel.log.length} lines)
|
|
313
|
+
</summary>
|
|
314
|
+
<pre className="mt-1 p-2 bg-[var(--bg-primary)] border border-[var(--border)] rounded text-[9px] text-[var(--text-secondary)] max-h-[120px] overflow-auto font-mono whitespace-pre-wrap">
|
|
315
|
+
{tunnel.log.join('\n')}
|
|
316
|
+
</pre>
|
|
317
|
+
</details>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
<label className="flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
|
|
321
|
+
<input
|
|
322
|
+
type="checkbox"
|
|
323
|
+
checked={settings.tunnelAutoStart}
|
|
324
|
+
onChange={e => setSettings({ ...settings, tunnelAutoStart: e.target.checked })}
|
|
325
|
+
className="rounded"
|
|
326
|
+
/>
|
|
327
|
+
Auto-start tunnel on server startup
|
|
328
|
+
</label>
|
|
329
|
+
|
|
330
|
+
<div className="space-y-1">
|
|
331
|
+
<label className="text-[10px] text-[var(--text-secondary)]">
|
|
332
|
+
Telegram tunnel password (for /tunnel_password command)
|
|
333
|
+
</label>
|
|
334
|
+
<input
|
|
335
|
+
value={settings.telegramTunnelPassword}
|
|
336
|
+
onChange={e => setSettings({ ...settings, telegramTunnelPassword: e.target.value })}
|
|
337
|
+
placeholder="Set a password to get login credentials via Telegram"
|
|
338
|
+
className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{/* Actions */}
|
|
344
|
+
<div className="flex items-center justify-between pt-2 border-t border-[var(--border)]">
|
|
345
|
+
<span className="text-[10px] text-[var(--green)]">
|
|
346
|
+
{saved ? '✓ Saved' : ''}
|
|
347
|
+
</span>
|
|
348
|
+
<div className="flex gap-2">
|
|
349
|
+
<button
|
|
350
|
+
onClick={onClose}
|
|
351
|
+
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
352
|
+
>
|
|
353
|
+
Close
|
|
354
|
+
</button>
|
|
355
|
+
<button
|
|
356
|
+
onClick={save}
|
|
357
|
+
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
358
|
+
>
|
|
359
|
+
Save
|
|
360
|
+
</button>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Session } from '@/src/types';
|
|
4
|
+
|
|
5
|
+
const providerLabels: Record<string, string> = {
|
|
6
|
+
anthropic: 'Claude',
|
|
7
|
+
google: 'Gemini',
|
|
8
|
+
openai: 'OpenAI',
|
|
9
|
+
grok: 'Grok',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function StatusBar({
|
|
13
|
+
providers,
|
|
14
|
+
usage,
|
|
15
|
+
sessions,
|
|
16
|
+
}: {
|
|
17
|
+
providers: any[];
|
|
18
|
+
usage: any[];
|
|
19
|
+
sessions: Session[];
|
|
20
|
+
}) {
|
|
21
|
+
const running = sessions.filter(s => s.status === 'running').length;
|
|
22
|
+
const idle = sessions.filter(s => s.status === 'idle').length;
|
|
23
|
+
const errored = sessions.filter(s => s.status === 'error').length;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-col p-3 space-y-4 text-xs overflow-y-auto">
|
|
27
|
+
{/* Overview */}
|
|
28
|
+
<div>
|
|
29
|
+
<h3 className="font-semibold text-[var(--text-secondary)] uppercase mb-2">Status</h3>
|
|
30
|
+
<div className="space-y-1">
|
|
31
|
+
<div className="flex justify-between">
|
|
32
|
+
<span className="text-[var(--green)]">Running</span>
|
|
33
|
+
<span>{running}</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="flex justify-between">
|
|
36
|
+
<span className="text-[var(--accent)]">Idle</span>
|
|
37
|
+
<span>{idle}</span>
|
|
38
|
+
</div>
|
|
39
|
+
{errored > 0 && (
|
|
40
|
+
<div className="flex justify-between">
|
|
41
|
+
<span className="text-[var(--red)]">Error</span>
|
|
42
|
+
<span>{errored}</span>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Providers */}
|
|
49
|
+
<div>
|
|
50
|
+
<h3 className="font-semibold text-[var(--text-secondary)] uppercase mb-2">Providers</h3>
|
|
51
|
+
<div className="space-y-2">
|
|
52
|
+
{providers.filter(p => p.enabled).map(p => {
|
|
53
|
+
const u = usage.find((u: any) => u.provider === p.name);
|
|
54
|
+
const totalTokens = u ? u.totalInput + u.totalOutput : 0;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div key={p.name} className="space-y-0.5">
|
|
58
|
+
<div className="flex items-center justify-between">
|
|
59
|
+
<div className="flex items-center gap-1.5">
|
|
60
|
+
<span className={`text-[10px] ${p.hasKey ? 'text-[var(--green)]' : 'text-[var(--yellow)]'}`}>
|
|
61
|
+
{p.hasKey ? '●' : '○'}
|
|
62
|
+
</span>
|
|
63
|
+
<span>{p.displayName}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<span className="text-[var(--text-secondary)]">
|
|
66
|
+
{totalTokens > 0 ? `${(totalTokens / 1000).toFixed(1)}k` : '—'}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
{totalTokens > 0 && (
|
|
70
|
+
<div className="ml-4 h-1 bg-[var(--bg-primary)] rounded overflow-hidden">
|
|
71
|
+
<div
|
|
72
|
+
className="h-full bg-[var(--accent)] rounded"
|
|
73
|
+
style={{ width: `${Math.min((totalTokens / 100000) * 100, 100)}%` }}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Quick info */}
|
|
84
|
+
<div>
|
|
85
|
+
<h3 className="font-semibold text-[var(--text-secondary)] uppercase mb-2">Info</h3>
|
|
86
|
+
<div className="space-y-1 text-[var(--text-secondary)]">
|
|
87
|
+
<div className="flex justify-between">
|
|
88
|
+
<span>Sessions</span>
|
|
89
|
+
<span>{sessions.length}</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex justify-between">
|
|
92
|
+
<span>Total msgs</span>
|
|
93
|
+
<span>{sessions.reduce((a, s) => a + s.messageCount, 0)}</span>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { Task, TaskStatus } from '@/src/types';
|
|
5
|
+
|
|
6
|
+
const STATUS_COLORS: Record<TaskStatus, string> = {
|
|
7
|
+
queued: 'text-yellow-500',
|
|
8
|
+
running: 'text-[var(--green)]',
|
|
9
|
+
done: 'text-blue-400',
|
|
10
|
+
failed: 'text-[var(--red)]',
|
|
11
|
+
cancelled: 'text-[var(--text-secondary)]',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const STATUS_LABELS: Record<TaskStatus, string> = {
|
|
15
|
+
queued: 'queued',
|
|
16
|
+
running: 'running',
|
|
17
|
+
done: 'done',
|
|
18
|
+
failed: 'failed',
|
|
19
|
+
cancelled: 'cancelled',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default function TaskBoard({
|
|
23
|
+
tasks,
|
|
24
|
+
activeId,
|
|
25
|
+
onSelect,
|
|
26
|
+
onRefresh,
|
|
27
|
+
}: {
|
|
28
|
+
tasks: Task[];
|
|
29
|
+
activeId: string | null;
|
|
30
|
+
onSelect: (id: string) => void;
|
|
31
|
+
onRefresh: () => void;
|
|
32
|
+
}) {
|
|
33
|
+
const [filter, setFilter] = useState<TaskStatus | 'all'>('all');
|
|
34
|
+
|
|
35
|
+
const filtered = filter === 'all' ? tasks : tasks.filter(t => t.status === filter);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex flex-col h-full">
|
|
39
|
+
{/* Filter tabs */}
|
|
40
|
+
<div className="p-2 border-b border-[var(--border)] flex gap-1 flex-wrap">
|
|
41
|
+
{(['all', 'running', 'queued', 'done', 'failed'] as const).map(f => (
|
|
42
|
+
<button
|
|
43
|
+
key={f}
|
|
44
|
+
onClick={() => setFilter(f)}
|
|
45
|
+
className={`text-[10px] px-2 py-0.5 rounded transition-colors ${
|
|
46
|
+
filter === f ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
|
|
47
|
+
}`}
|
|
48
|
+
>
|
|
49
|
+
{f} {f !== 'all' ? `(${tasks.filter(t => t.status === f).length})` : `(${tasks.length})`}
|
|
50
|
+
</button>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{/* Task list */}
|
|
55
|
+
<div className="flex-1 overflow-y-auto">
|
|
56
|
+
{filtered.map(task => (
|
|
57
|
+
<button
|
|
58
|
+
key={task.id}
|
|
59
|
+
onClick={() => onSelect(task.id)}
|
|
60
|
+
className={`w-full text-left px-3 py-2 border-b border-[var(--border)] hover:bg-[var(--bg-tertiary)] transition-colors ${
|
|
61
|
+
activeId === task.id ? 'bg-[var(--bg-tertiary)]' : ''
|
|
62
|
+
}`}
|
|
63
|
+
>
|
|
64
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
65
|
+
<span className={`text-[10px] ${STATUS_COLORS[task.status]}`}>●</span>
|
|
66
|
+
<span className="text-xs font-medium truncate">{task.projectName}</span>
|
|
67
|
+
<span className={`text-[9px] ml-auto ${STATUS_COLORS[task.status]}`}>
|
|
68
|
+
{task.scheduledAt && task.status === 'queued'
|
|
69
|
+
? `⏰ ${new Date(task.scheduledAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`
|
|
70
|
+
: STATUS_LABELS[task.status]}
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
<p className="text-[11px] text-[var(--text-secondary)] truncate pl-4">
|
|
74
|
+
{task.prompt.slice(0, 80)}{task.prompt.length > 80 ? '...' : ''}
|
|
75
|
+
</p>
|
|
76
|
+
<div className="flex items-center gap-2 pl-4 mt-0.5">
|
|
77
|
+
<span className="text-[9px] text-[var(--text-secondary)]">
|
|
78
|
+
{timeAgo(task.createdAt)}
|
|
79
|
+
</span>
|
|
80
|
+
{task.costUSD != null && (
|
|
81
|
+
<span className="text-[9px] text-[var(--text-secondary)]">
|
|
82
|
+
${task.costUSD.toFixed(3)}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
{filtered.length === 0 && (
|
|
89
|
+
<div className="p-4 text-center text-xs text-[var(--text-secondary)]">
|
|
90
|
+
No tasks
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="p-2 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)] text-center">
|
|
96
|
+
{tasks.length} tasks total
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function timeAgo(dateStr: string): string {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const then = new Date(dateStr).getTime();
|
|
105
|
+
const diff = now - then;
|
|
106
|
+
if (diff < 60000) return 'just now';
|
|
107
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
108
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
109
|
+
return `${Math.floor(diff / 86400000)}d ago`;
|
|
110
|
+
}
|