@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.
Files changed (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. 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
+ }