@aion0/forge 0.2.1 → 0.2.3
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/README.md +166 -175
- package/app/api/code/route.ts +31 -4
- package/app/api/docs/route.ts +48 -3
- package/app/api/git/route.ts +131 -0
- package/app/api/online/route.ts +40 -0
- package/app/api/pipelines/[id]/route.ts +28 -0
- package/app/api/pipelines/route.ts +52 -0
- package/app/api/preview/[...path]/route.ts +64 -0
- package/app/api/preview/route.ts +135 -0
- package/app/api/tasks/[id]/route.ts +8 -2
- package/components/CodeViewer.tsx +205 -4
- package/components/Dashboard.tsx +85 -1
- package/components/DocsViewer.tsx +64 -6
- package/components/NewTaskModal.tsx +7 -7
- package/components/PipelineEditor.tsx +399 -0
- package/components/PipelineView.tsx +435 -0
- package/components/PreviewPanel.tsx +154 -0
- package/components/ProjectManager.tsx +410 -0
- package/components/SettingsModal.tsx +4 -1
- package/components/TaskDetail.tsx +1 -1
- package/components/WebTerminal.tsx +109 -9
- package/lib/pipeline.ts +514 -0
- package/lib/task-manager.ts +70 -12
- package/lib/telegram-bot.ts +99 -1
- package/package.json +2 -1
|
@@ -178,6 +178,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
178
178
|
const [content, setContent] = useState<string | null>(null);
|
|
179
179
|
const [language, setLanguage] = useState('');
|
|
180
180
|
const [loading, setLoading] = useState(false);
|
|
181
|
+
const [fileWarning, setFileWarning] = useState<{ type: 'binary' | 'large' | 'tooLarge'; label: string; fileType?: string } | null>(null);
|
|
181
182
|
const [search, setSearch] = useState('');
|
|
182
183
|
const [diffContent, setDiffContent] = useState<string | null>(null);
|
|
183
184
|
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
@@ -190,8 +191,10 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
190
191
|
}, []);
|
|
191
192
|
const [terminalHeight, setTerminalHeight] = useState(300);
|
|
192
193
|
const [activeSession, setActiveSession] = useState<string | null>(null);
|
|
194
|
+
const [taskNotification, setTaskNotification] = useState<{ id: string; status: string; prompt: string; sessionId?: string } | null>(null);
|
|
193
195
|
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
|
194
196
|
const lastDirRef = useRef<string | null>(null);
|
|
197
|
+
const lastTaskCheckRef = useRef<string>('');
|
|
195
198
|
|
|
196
199
|
// When active terminal session changes, query its cwd
|
|
197
200
|
useEffect(() => {
|
|
@@ -235,19 +238,65 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
235
238
|
fetchDir();
|
|
236
239
|
}, [currentDir]);
|
|
237
240
|
|
|
241
|
+
// Poll for task completions in the current project
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!currentDir) return;
|
|
244
|
+
const dirName = currentDir.split('/').pop() || '';
|
|
245
|
+
const check = async () => {
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch('/api/tasks?status=done');
|
|
248
|
+
const tasks = await res.json();
|
|
249
|
+
if (!Array.isArray(tasks) || tasks.length === 0) return;
|
|
250
|
+
const latest = tasks.find((t: any) => t.projectPath === currentDir || t.projectName === dirName);
|
|
251
|
+
if (latest && latest.id !== lastTaskCheckRef.current && latest.completedAt) {
|
|
252
|
+
// Only notify if completed in the last 30s
|
|
253
|
+
const age = Date.now() - new Date(latest.completedAt).getTime();
|
|
254
|
+
if (age < 30_000) {
|
|
255
|
+
lastTaskCheckRef.current = latest.id;
|
|
256
|
+
setTaskNotification({
|
|
257
|
+
id: latest.id,
|
|
258
|
+
status: latest.status,
|
|
259
|
+
prompt: latest.prompt,
|
|
260
|
+
sessionId: latest.conversationId,
|
|
261
|
+
});
|
|
262
|
+
setTimeout(() => setTaskNotification(null), 15_000);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {}
|
|
266
|
+
};
|
|
267
|
+
const timer = setInterval(check, 5000);
|
|
268
|
+
return () => clearInterval(timer);
|
|
269
|
+
}, [currentDir]);
|
|
270
|
+
|
|
238
271
|
// Build git status map for tree coloring
|
|
239
272
|
const gitMap: GitStatusMap = new Map(gitChanges.map(g => [g.path, g.status]));
|
|
240
273
|
const repoMap: GitRepoMap = new Map(gitRepos.filter(r => r.name !== '.').map(r => [r.name, { branch: r.branch, remote: r.remote }]));
|
|
241
274
|
|
|
242
|
-
const openFile = useCallback(async (path: string) => {
|
|
275
|
+
const openFile = useCallback(async (path: string, forceLoad?: boolean) => {
|
|
243
276
|
if (!currentDir) return;
|
|
244
277
|
setSelectedFile(path);
|
|
245
278
|
setViewMode('file');
|
|
279
|
+
setFileWarning(null);
|
|
246
280
|
setLoading(true);
|
|
247
|
-
|
|
281
|
+
|
|
282
|
+
const url = `/api/code?dir=${encodeURIComponent(currentDir)}&file=${encodeURIComponent(path)}${forceLoad ? '&force=1' : ''}`;
|
|
283
|
+
const res = await fetch(url);
|
|
248
284
|
const data = await res.json();
|
|
249
|
-
|
|
250
|
-
|
|
285
|
+
|
|
286
|
+
if (data.binary) {
|
|
287
|
+
setContent(null);
|
|
288
|
+
setFileWarning({ type: 'binary', label: data.sizeLabel, fileType: data.fileType });
|
|
289
|
+
} else if (data.tooLarge) {
|
|
290
|
+
setContent(null);
|
|
291
|
+
setFileWarning({ type: 'tooLarge', label: data.sizeLabel });
|
|
292
|
+
} else if (data.large && !forceLoad) {
|
|
293
|
+
setContent(null);
|
|
294
|
+
setFileWarning({ type: 'large', label: data.sizeLabel });
|
|
295
|
+
setLanguage(data.language || '');
|
|
296
|
+
} else {
|
|
297
|
+
setContent(data.content || null);
|
|
298
|
+
setLanguage(data.language || '');
|
|
299
|
+
}
|
|
251
300
|
setLoading(false);
|
|
252
301
|
}, [currentDir]);
|
|
253
302
|
|
|
@@ -290,12 +339,82 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
290
339
|
window.addEventListener('mouseup', onUp);
|
|
291
340
|
};
|
|
292
341
|
|
|
342
|
+
// Git operations
|
|
343
|
+
const [commitMsg, setCommitMsg] = useState('');
|
|
344
|
+
const [gitLoading, setGitLoading] = useState(false);
|
|
345
|
+
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
346
|
+
|
|
347
|
+
const gitAction = useCallback(async (action: string, extra?: any) => {
|
|
348
|
+
if (!currentDir) return;
|
|
349
|
+
setGitLoading(true);
|
|
350
|
+
setGitResult(null);
|
|
351
|
+
try {
|
|
352
|
+
const res = await fetch('/api/git', {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify({ action, dir: currentDir, ...extra }),
|
|
356
|
+
});
|
|
357
|
+
const data = await res.json();
|
|
358
|
+
setGitResult(data);
|
|
359
|
+
// Refresh git status
|
|
360
|
+
if (data.ok) {
|
|
361
|
+
const r = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}`);
|
|
362
|
+
const d = await r.json();
|
|
363
|
+
setGitChanges(d.gitChanges || []);
|
|
364
|
+
setGitRepos(d.gitRepos || []);
|
|
365
|
+
setGitBranch(d.gitBranch || '');
|
|
366
|
+
if (action === 'commit') setCommitMsg('');
|
|
367
|
+
}
|
|
368
|
+
} catch (e: any) {
|
|
369
|
+
setGitResult({ error: e.message });
|
|
370
|
+
}
|
|
371
|
+
setGitLoading(false);
|
|
372
|
+
setTimeout(() => setGitResult(null), 5000);
|
|
373
|
+
}, [currentDir]);
|
|
374
|
+
|
|
375
|
+
const refreshAll = useCallback(() => {
|
|
376
|
+
if (!currentDir) return;
|
|
377
|
+
// Refresh tree + git
|
|
378
|
+
fetch(`/api/code?dir=${encodeURIComponent(currentDir)}`)
|
|
379
|
+
.then(r => r.json())
|
|
380
|
+
.then(data => {
|
|
381
|
+
setTree(data.tree || []);
|
|
382
|
+
setDirName(data.dirName || currentDir.split('/').pop() || '');
|
|
383
|
+
setGitBranch(data.gitBranch || '');
|
|
384
|
+
setGitChanges(data.gitChanges || []);
|
|
385
|
+
setGitRepos(data.gitRepos || []);
|
|
386
|
+
})
|
|
387
|
+
.catch(() => {});
|
|
388
|
+
// Refresh open file
|
|
389
|
+
if (selectedFile) openFile(selectedFile);
|
|
390
|
+
}, [currentDir, selectedFile, openFile]);
|
|
391
|
+
|
|
293
392
|
const handleActiveSession = useCallback((session: string | null) => {
|
|
294
393
|
setActiveSession(session);
|
|
295
394
|
}, []);
|
|
296
395
|
|
|
297
396
|
return (
|
|
298
397
|
<div className="flex-1 flex flex-col min-h-0">
|
|
398
|
+
{/* Task completion notification */}
|
|
399
|
+
{taskNotification && (
|
|
400
|
+
<div className="shrink-0 px-3 py-1.5 bg-green-900/30 border-b border-green-800/50 flex items-center gap-2 text-xs">
|
|
401
|
+
<span className="text-green-400">{taskNotification.status === 'done' ? '✅' : '❌'}</span>
|
|
402
|
+
<span className="text-green-300 truncate">Task {taskNotification.id}: {taskNotification.prompt.slice(0, 60)}</span>
|
|
403
|
+
{taskNotification.sessionId && (
|
|
404
|
+
<button
|
|
405
|
+
onClick={() => {
|
|
406
|
+
// Send claude --resume to the active terminal
|
|
407
|
+
// The tmux display-message from backend already showed the notification
|
|
408
|
+
setTaskNotification(null);
|
|
409
|
+
}}
|
|
410
|
+
className="ml-auto text-[10px] text-green-400 hover:text-white shrink-0"
|
|
411
|
+
>
|
|
412
|
+
Dismiss
|
|
413
|
+
</button>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
|
|
299
418
|
{/* Terminal — top */}
|
|
300
419
|
<div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
|
|
301
420
|
<Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
|
|
@@ -327,6 +446,13 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
327
446
|
{gitBranch}
|
|
328
447
|
</span>
|
|
329
448
|
)}
|
|
449
|
+
<button
|
|
450
|
+
onClick={refreshAll}
|
|
451
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto shrink-0"
|
|
452
|
+
title="Refresh files & git status"
|
|
453
|
+
>
|
|
454
|
+
↻
|
|
455
|
+
</button>
|
|
330
456
|
</div>
|
|
331
457
|
{gitRepos.find(r => r.name === '.')?.remote && (
|
|
332
458
|
<div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={gitRepos.find(r => r.name === '.')!.remote}>
|
|
@@ -436,6 +562,49 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
436
562
|
))
|
|
437
563
|
)}
|
|
438
564
|
</div>
|
|
565
|
+
|
|
566
|
+
{/* Git actions — bottom of sidebar */}
|
|
567
|
+
{currentDir && (gitChanges.length > 0 || gitRepos.length > 0) && (
|
|
568
|
+
<div className="border-t border-[var(--border)] shrink-0 p-2 space-y-1.5">
|
|
569
|
+
<div className="flex gap-1.5">
|
|
570
|
+
<input
|
|
571
|
+
value={commitMsg}
|
|
572
|
+
onChange={e => setCommitMsg(e.target.value)}
|
|
573
|
+
onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
574
|
+
placeholder="Commit message..."
|
|
575
|
+
className="flex-1 text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
576
|
+
/>
|
|
577
|
+
<button
|
|
578
|
+
onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
579
|
+
disabled={gitLoading || !commitMsg.trim() || gitChanges.length === 0}
|
|
580
|
+
className="text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
|
|
581
|
+
>
|
|
582
|
+
Commit
|
|
583
|
+
</button>
|
|
584
|
+
</div>
|
|
585
|
+
<div className="flex gap-1.5">
|
|
586
|
+
<button
|
|
587
|
+
onClick={() => gitAction('push')}
|
|
588
|
+
disabled={gitLoading}
|
|
589
|
+
className="flex-1 text-[9px] py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
|
|
590
|
+
>
|
|
591
|
+
Push
|
|
592
|
+
</button>
|
|
593
|
+
<button
|
|
594
|
+
onClick={() => gitAction('pull')}
|
|
595
|
+
disabled={gitLoading}
|
|
596
|
+
className="flex-1 text-[9px] py-1 text-[var(--text-secondary)] border border-[var(--border)] rounded hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] disabled:opacity-50"
|
|
597
|
+
>
|
|
598
|
+
Pull
|
|
599
|
+
</button>
|
|
600
|
+
</div>
|
|
601
|
+
{gitResult && (
|
|
602
|
+
<div className={`text-[9px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
|
603
|
+
{gitResult.ok ? '✅ Done' : `❌ ${gitResult.error}`}
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
)}
|
|
439
608
|
</aside>
|
|
440
609
|
)}
|
|
441
610
|
|
|
@@ -469,6 +638,38 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
469
638
|
<div className="flex-1 flex items-center justify-center">
|
|
470
639
|
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
471
640
|
</div>
|
|
641
|
+
) : fileWarning ? (
|
|
642
|
+
<div className="flex-1 flex items-center justify-center">
|
|
643
|
+
<div className="text-center space-y-3 p-6">
|
|
644
|
+
{fileWarning.type === 'binary' && (
|
|
645
|
+
<>
|
|
646
|
+
<div className="text-3xl">🚫</div>
|
|
647
|
+
<p className="text-sm text-[var(--text-primary)]">Binary file cannot be displayed</p>
|
|
648
|
+
<p className="text-xs text-[var(--text-secondary)]">{fileWarning.fileType?.toUpperCase()} · {fileWarning.label}</p>
|
|
649
|
+
</>
|
|
650
|
+
)}
|
|
651
|
+
{fileWarning.type === 'tooLarge' && (
|
|
652
|
+
<>
|
|
653
|
+
<div className="text-3xl">⚠️</div>
|
|
654
|
+
<p className="text-sm text-[var(--text-primary)]">File too large to display</p>
|
|
655
|
+
<p className="text-xs text-[var(--text-secondary)]">{fileWarning.label} — exceeds 2 MB limit</p>
|
|
656
|
+
</>
|
|
657
|
+
)}
|
|
658
|
+
{fileWarning.type === 'large' && (
|
|
659
|
+
<>
|
|
660
|
+
<div className="text-3xl">📄</div>
|
|
661
|
+
<p className="text-sm text-[var(--text-primary)]">Large file: {fileWarning.label}</p>
|
|
662
|
+
<p className="text-xs text-[var(--text-secondary)]">This file may slow down the browser</p>
|
|
663
|
+
<button
|
|
664
|
+
onClick={() => selectedFile && openFile(selectedFile, true)}
|
|
665
|
+
className="text-xs px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 mt-2"
|
|
666
|
+
>
|
|
667
|
+
Open anyway
|
|
668
|
+
</button>
|
|
669
|
+
</>
|
|
670
|
+
)}
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
472
673
|
) : viewMode === 'diff' && diffContent ? (
|
|
473
674
|
<div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
|
|
474
675
|
<pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
package/components/Dashboard.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
import { signOut } from 'next-auth/react';
|
|
4
5
|
import TaskBoard from './TaskBoard';
|
|
5
6
|
import TaskDetail from './TaskDetail';
|
|
6
7
|
import SessionView from './SessionView';
|
|
@@ -13,6 +14,9 @@ import type { WebTerminalHandle } from './WebTerminal';
|
|
|
13
14
|
const WebTerminal = lazy(() => import('./WebTerminal'));
|
|
14
15
|
const DocsViewer = lazy(() => import('./DocsViewer'));
|
|
15
16
|
const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
17
|
+
const ProjectManager = lazy(() => import('./ProjectManager'));
|
|
18
|
+
const PreviewPanel = lazy(() => import('./PreviewPanel'));
|
|
19
|
+
const PipelineView = lazy(() => import('./PipelineView'));
|
|
16
20
|
|
|
17
21
|
interface UsageSummary {
|
|
18
22
|
provider: string;
|
|
@@ -35,7 +39,7 @@ interface ProjectInfo {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export default function Dashboard({ user }: { user: any }) {
|
|
38
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs'>('terminal');
|
|
42
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines'>('terminal');
|
|
39
43
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
40
44
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
41
45
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
@@ -43,8 +47,22 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
43
47
|
const [usage, setUsage] = useState<UsageSummary[]>([]);
|
|
44
48
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
45
49
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
50
|
+
const [onlineCount, setOnlineCount] = useState<{ total: number; remote: number }>({ total: 0, remote: 0 });
|
|
46
51
|
const terminalRef = useRef<WebTerminalHandle>(null);
|
|
47
52
|
|
|
53
|
+
// Heartbeat for online user tracking
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const ping = () => {
|
|
56
|
+
fetch('/api/online', { method: 'POST' })
|
|
57
|
+
.then(r => r.json())
|
|
58
|
+
.then(setOnlineCount)
|
|
59
|
+
.catch(() => {});
|
|
60
|
+
};
|
|
61
|
+
ping();
|
|
62
|
+
const id = setInterval(ping, 15_000); // every 15s
|
|
63
|
+
return () => clearInterval(id);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
48
66
|
const fetchData = useCallback(async () => {
|
|
49
67
|
const [tasksRes, statusRes, projectsRes] = await Promise.all([
|
|
50
68
|
fetch('/api/tasks'),
|
|
@@ -99,6 +117,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
99
117
|
>
|
|
100
118
|
Docs
|
|
101
119
|
</button>
|
|
120
|
+
<button
|
|
121
|
+
onClick={() => setViewMode('projects')}
|
|
122
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
123
|
+
viewMode === 'projects'
|
|
124
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
125
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
Projects
|
|
129
|
+
</button>
|
|
102
130
|
<button
|
|
103
131
|
onClick={() => setViewMode('tasks')}
|
|
104
132
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
@@ -109,6 +137,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
109
137
|
>
|
|
110
138
|
Tasks
|
|
111
139
|
</button>
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => setViewMode('pipelines')}
|
|
142
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
143
|
+
viewMode === 'pipelines'
|
|
144
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
145
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
146
|
+
}`}
|
|
147
|
+
>
|
|
148
|
+
Pipelines
|
|
149
|
+
</button>
|
|
112
150
|
<button
|
|
113
151
|
onClick={() => setViewMode('sessions')}
|
|
114
152
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
@@ -119,6 +157,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
119
157
|
>
|
|
120
158
|
Sessions
|
|
121
159
|
</button>
|
|
160
|
+
<button
|
|
161
|
+
onClick={() => setViewMode('preview')}
|
|
162
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
163
|
+
viewMode === 'preview'
|
|
164
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
165
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
166
|
+
}`}
|
|
167
|
+
>
|
|
168
|
+
Demo Preview
|
|
169
|
+
</button>
|
|
122
170
|
</div>
|
|
123
171
|
|
|
124
172
|
{viewMode === 'tasks' && (
|
|
@@ -137,6 +185,15 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
137
185
|
</button>
|
|
138
186
|
)}
|
|
139
187
|
<TunnelToggle />
|
|
188
|
+
{onlineCount.total > 0 && (
|
|
189
|
+
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
|
|
190
|
+
<span className="text-green-500">●</span>
|
|
191
|
+
{onlineCount.total}
|
|
192
|
+
{onlineCount.remote > 0 && (
|
|
193
|
+
<span className="text-[var(--accent)]">({onlineCount.remote} remote)</span>
|
|
194
|
+
)}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
140
197
|
<button
|
|
141
198
|
onClick={() => setShowSettings(true)}
|
|
142
199
|
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
@@ -144,6 +201,12 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
144
201
|
Settings
|
|
145
202
|
</button>
|
|
146
203
|
<span className="text-xs text-[var(--text-secondary)]">{user?.name || 'local'}</span>
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
206
|
+
className="text-xs text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
207
|
+
>
|
|
208
|
+
Logout
|
|
209
|
+
</button>
|
|
147
210
|
</div>
|
|
148
211
|
</header>
|
|
149
212
|
|
|
@@ -251,6 +314,27 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
251
314
|
/>
|
|
252
315
|
) : null}
|
|
253
316
|
|
|
317
|
+
{/* Projects */}
|
|
318
|
+
{viewMode === 'projects' && (
|
|
319
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
320
|
+
<ProjectManager />
|
|
321
|
+
</Suspense>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{/* Pipelines */}
|
|
325
|
+
{viewMode === 'pipelines' && (
|
|
326
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
327
|
+
<PipelineView />
|
|
328
|
+
</Suspense>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{/* Preview */}
|
|
332
|
+
{viewMode === 'preview' && (
|
|
333
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
334
|
+
<PreviewPanel />
|
|
335
|
+
</Suspense>
|
|
336
|
+
)}
|
|
337
|
+
|
|
254
338
|
{/* Docs — always mounted to keep terminal session alive */}
|
|
255
339
|
<div className={`flex-1 min-h-0 flex ${viewMode === 'docs' ? '' : 'hidden'}`}>
|
|
256
340
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
@@ -9,6 +9,7 @@ interface FileNode {
|
|
|
9
9
|
name: string;
|
|
10
10
|
path: string;
|
|
11
11
|
type: 'file' | 'dir';
|
|
12
|
+
fileType?: 'md' | 'image' | 'other';
|
|
12
13
|
children?: FileNode[];
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -41,16 +42,20 @@ function TreeNode({ node, depth, selected, onSelect }: {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
const isSelected = selected === node.path;
|
|
45
|
+
const canOpen = node.fileType === 'md' || node.fileType === 'image';
|
|
46
|
+
|
|
44
47
|
return (
|
|
45
48
|
<button
|
|
46
|
-
onClick={() => onSelect(node.path)}
|
|
49
|
+
onClick={() => canOpen && onSelect(node.path)}
|
|
47
50
|
className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
|
|
48
|
-
|
|
51
|
+
!canOpen ? 'text-[var(--text-secondary)]/40 cursor-default'
|
|
52
|
+
: isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
|
|
53
|
+
: 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
49
54
|
}`}
|
|
50
55
|
style={{ paddingLeft: depth * 12 + 16 }}
|
|
51
56
|
title={node.path}
|
|
52
57
|
>
|
|
53
|
-
{node.name.replace(/\.md$/, '')}
|
|
58
|
+
{node.fileType === 'image' ? '🖼 ' : ''}{node.name.replace(/\.md$/, '')}
|
|
54
59
|
</button>
|
|
55
60
|
);
|
|
56
61
|
}
|
|
@@ -92,13 +97,39 @@ export default function DocsViewer() {
|
|
|
92
97
|
|
|
93
98
|
useEffect(() => { fetchTree(activeRoot); }, [activeRoot, fetchTree]);
|
|
94
99
|
|
|
100
|
+
// Re-fetch when tab becomes visible (settings may have changed)
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const handleVisibility = () => {
|
|
103
|
+
if (!document.hidden) fetchTree(activeRoot);
|
|
104
|
+
};
|
|
105
|
+
document.addEventListener('visibilitychange', handleVisibility);
|
|
106
|
+
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
|
107
|
+
}, [activeRoot, fetchTree]);
|
|
108
|
+
|
|
109
|
+
const [fileWarning, setFileWarning] = useState<string | null>(null);
|
|
110
|
+
|
|
95
111
|
// Fetch file content
|
|
112
|
+
const isImageFile = (path: string) => /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i.test(path);
|
|
113
|
+
|
|
96
114
|
const openFile = useCallback(async (path: string) => {
|
|
97
115
|
setSelectedFile(path);
|
|
116
|
+
setFileWarning(null);
|
|
117
|
+
|
|
118
|
+
if (isImageFile(path)) {
|
|
119
|
+
setContent(null);
|
|
120
|
+
setLoading(false);
|
|
121
|
+
return; // images rendered directly via img tag
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
setLoading(true);
|
|
99
125
|
const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
|
|
100
126
|
const data = await res.json();
|
|
101
|
-
|
|
127
|
+
if (data.tooLarge) {
|
|
128
|
+
setContent(null);
|
|
129
|
+
setFileWarning(`File too large (${data.sizeLabel})`);
|
|
130
|
+
} else {
|
|
131
|
+
setContent(data.content || null);
|
|
132
|
+
}
|
|
102
133
|
setLoading(false);
|
|
103
134
|
}, [activeRoot]);
|
|
104
135
|
|
|
@@ -145,7 +176,7 @@ export default function DocsViewer() {
|
|
|
145
176
|
{sidebarOpen && (
|
|
146
177
|
<aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
147
178
|
{/* Root selector */}
|
|
148
|
-
{roots.length >
|
|
179
|
+
{roots.length > 0 && (
|
|
149
180
|
<div className="p-2 border-b border-[var(--border)]">
|
|
150
181
|
<select
|
|
151
182
|
value={activeRoot}
|
|
@@ -157,6 +188,18 @@ export default function DocsViewer() {
|
|
|
157
188
|
</div>
|
|
158
189
|
)}
|
|
159
190
|
|
|
191
|
+
{/* Header with refresh */}
|
|
192
|
+
<div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center">
|
|
193
|
+
<span className="text-[10px] text-[var(--text-secondary)] truncate">{roots[activeRoot] || 'Docs'}</span>
|
|
194
|
+
<button
|
|
195
|
+
onClick={() => { fetchTree(activeRoot); if (selectedFile) openFile(selectedFile); }}
|
|
196
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto shrink-0"
|
|
197
|
+
title="Refresh files"
|
|
198
|
+
>
|
|
199
|
+
↻
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
160
203
|
{/* Search */}
|
|
161
204
|
<div className="p-2 border-b border-[var(--border)]">
|
|
162
205
|
<input
|
|
@@ -219,7 +262,22 @@ export default function DocsViewer() {
|
|
|
219
262
|
</div>
|
|
220
263
|
|
|
221
264
|
{/* Content */}
|
|
222
|
-
{
|
|
265
|
+
{fileWarning ? (
|
|
266
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
267
|
+
<div className="text-center space-y-2">
|
|
268
|
+
<div className="text-3xl">⚠️</div>
|
|
269
|
+
<p className="text-sm">{fileWarning}</p>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
) : selectedFile && isImageFile(selectedFile) ? (
|
|
273
|
+
<div className="flex-1 overflow-auto flex items-center justify-center p-6 bg-[var(--bg-tertiary)]">
|
|
274
|
+
<img
|
|
275
|
+
src={`/api/docs?root=${activeRoot}&image=${encodeURIComponent(selectedFile)}`}
|
|
276
|
+
alt={selectedFile}
|
|
277
|
+
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
) : selectedFile && content ? (
|
|
223
281
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
|
224
282
|
{loading ? (
|
|
225
283
|
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
@@ -35,7 +35,7 @@ export default function NewTaskModal({
|
|
|
35
35
|
}: {
|
|
36
36
|
onClose: () => void;
|
|
37
37
|
onCreate: (data: TaskData) => void;
|
|
38
|
-
editTask?: { id: string; projectName: string; prompt: string; priority: number; mode: TaskMode };
|
|
38
|
+
editTask?: { id: string; projectName: string; prompt: string; priority: number; mode: TaskMode; scheduledAt?: string };
|
|
39
39
|
}) {
|
|
40
40
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
41
41
|
const [selectedProject, setSelectedProject] = useState(editTask?.projectName || '');
|
|
@@ -60,9 +60,9 @@ export default function NewTaskModal({
|
|
|
60
60
|
const [autoSessionId, setAutoSessionId] = useState<string | null>(null);
|
|
61
61
|
|
|
62
62
|
// Scheduling
|
|
63
|
-
const [scheduleMode, setScheduleMode] = useState<'now' | 'delay' | 'time'>('now');
|
|
63
|
+
const [scheduleMode, setScheduleMode] = useState<'now' | 'delay' | 'time'>(editTask?.scheduledAt ? 'time' : 'now');
|
|
64
64
|
const [delayMinutes, setDelayMinutes] = useState(30);
|
|
65
|
-
const [scheduledTime, setScheduledTime] = useState('');
|
|
65
|
+
const [scheduledTime, setScheduledTime] = useState(editTask?.scheduledAt ? new Date(editTask.scheduledAt).toISOString().slice(0, 16) : '');
|
|
66
66
|
|
|
67
67
|
useEffect(() => {
|
|
68
68
|
fetch('/api/projects').then(r => r.json()).then((p: Project[]) => {
|
|
@@ -88,15 +88,15 @@ export default function NewTaskModal({
|
|
|
88
88
|
.catch(() => setSessions([]));
|
|
89
89
|
}, [selectedProject]);
|
|
90
90
|
|
|
91
|
-
const getScheduledAt = (): string | undefined => {
|
|
92
|
-
if (scheduleMode === 'now') return undefined;
|
|
91
|
+
const getScheduledAt = (): string | null | undefined => {
|
|
92
|
+
if (scheduleMode === 'now') return editTask ? null : undefined; // null clears existing schedule
|
|
93
93
|
if (scheduleMode === 'delay') {
|
|
94
94
|
return new Date(Date.now() + delayMinutes * 60_000).toISOString();
|
|
95
95
|
}
|
|
96
96
|
if (scheduleMode === 'time' && scheduledTime) {
|
|
97
97
|
return new Date(scheduledTime).toISOString();
|
|
98
98
|
}
|
|
99
|
-
return undefined;
|
|
99
|
+
return editTask ? null : undefined;
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
const handleSubmit = (e: React.FormEvent) => {
|
|
@@ -110,7 +110,7 @@ export default function NewTaskModal({
|
|
|
110
110
|
projectName: selectedProject,
|
|
111
111
|
prompt: taskMode === 'monitor' ? `Monitor session ${selectedSessionId}` : prompt.trim(),
|
|
112
112
|
priority,
|
|
113
|
-
scheduledAt: getScheduledAt(),
|
|
113
|
+
scheduledAt: getScheduledAt() ?? undefined,
|
|
114
114
|
mode: taskMode,
|
|
115
115
|
};
|
|
116
116
|
|