@aion0/forge 0.2.0 → 0.2.2

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.
@@ -15,13 +15,15 @@ interface FileNode {
15
15
  // ─── File Tree ───────────────────────────────────────────
16
16
 
17
17
  type GitStatusMap = Map<string, string>; // path → status
18
+ type GitRepoMap = Map<string, { branch: string; remote: string }>; // dir name → repo info
18
19
 
19
- function TreeNode({ node, depth, selected, onSelect, gitMap }: {
20
+ function TreeNode({ node, depth, selected, onSelect, gitMap, repoMap }: {
20
21
  node: FileNode;
21
22
  depth: number;
22
23
  selected: string | null;
23
24
  onSelect: (path: string) => void;
24
25
  gitMap: GitStatusMap;
26
+ repoMap: GitRepoMap;
25
27
  }) {
26
28
  // Auto-expand if selected file is under this directory
27
29
  const containsSelected = selected ? selected.startsWith(node.path + '/') : false;
@@ -30,18 +32,23 @@ function TreeNode({ node, depth, selected, onSelect, gitMap }: {
30
32
 
31
33
  if (node.type === 'dir') {
32
34
  const dirHasChanges = node.children?.some(c => hasGitChanges(c, gitMap));
35
+ const repo = repoMap.get(node.name);
33
36
  return (
34
37
  <div>
35
38
  <button
36
39
  onClick={() => setManualExpanded(v => v === null ? !expanded : !v)}
37
- className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
40
+ className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs group"
38
41
  style={{ paddingLeft: depth * 12 + 4 }}
42
+ title={repo ? `${repo.branch} · ${repo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}` : undefined}
39
43
  >
40
44
  <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
41
45
  <span className={dirHasChanges ? 'text-yellow-400' : 'text-[var(--text-primary)]'}>{node.name}</span>
46
+ {repo && (
47
+ <span className="text-[8px] text-[var(--accent)] opacity-60 group-hover:opacity-100 ml-auto shrink-0">{repo.branch}</span>
48
+ )}
42
49
  </button>
43
50
  {expanded && node.children?.map(child => (
44
- <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} gitMap={gitMap} />
51
+ <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} gitMap={gitMap} repoMap={repoMap} />
45
52
  ))}
46
53
  </div>
47
54
  );
@@ -159,27 +166,35 @@ function highlightLine(line: string, lang: string): React.ReactNode {
159
166
 
160
167
  // ─── Main Component ──────────────────────────────────────
161
168
 
162
- export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef: React.RefObject<WebTerminalHandle | null>; onToggleCode?: () => void }) {
169
+ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObject<WebTerminalHandle | null> }) {
163
170
  const [currentDir, setCurrentDir] = useState<string | null>(null);
164
171
  const [dirName, setDirName] = useState('');
165
172
  const [tree, setTree] = useState<FileNode[]>([]);
166
173
  const [gitBranch, setGitBranch] = useState('');
167
174
  const [gitChanges, setGitChanges] = useState<{ path: string; status: string }[]>([]);
175
+ const [gitRepos, setGitRepos] = useState<{ name: string; branch: string; remote: string; changes: { path: string; status: string }[] }[]>([]);
168
176
  const [showGit, setShowGit] = useState(false);
169
177
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
170
178
  const [content, setContent] = useState<string | null>(null);
171
179
  const [language, setLanguage] = useState('');
172
180
  const [loading, setLoading] = useState(false);
181
+ const [fileWarning, setFileWarning] = useState<{ type: 'binary' | 'large' | 'tooLarge'; label: string; fileType?: string } | null>(null);
173
182
  const [search, setSearch] = useState('');
174
183
  const [diffContent, setDiffContent] = useState<string | null>(null);
175
184
  const [diffFile, setDiffFile] = useState<string | null>(null);
176
185
  const [viewMode, setViewMode] = useState<'file' | 'diff'>('file');
177
186
  const [sidebarOpen, setSidebarOpen] = useState(true);
178
187
  const [codeOpen, setCodeOpen] = useState(false);
188
+
189
+ const handleCodeOpenChange = useCallback((open: boolean) => {
190
+ setCodeOpen(open);
191
+ }, []);
179
192
  const [terminalHeight, setTerminalHeight] = useState(300);
180
193
  const [activeSession, setActiveSession] = useState<string | null>(null);
194
+ const [taskNotification, setTaskNotification] = useState<{ id: string; status: string; prompt: string; sessionId?: string } | null>(null);
181
195
  const dragRef = useRef<{ startY: number; startH: number } | null>(null);
182
196
  const lastDirRef = useRef<string | null>(null);
197
+ const lastTaskCheckRef = useRef<string>('');
183
198
 
184
199
  // When active terminal session changes, query its cwd
185
200
  useEffect(() => {
@@ -216,24 +231,72 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
216
231
  setDirName(data.dirName || currentDir.split('/').pop() || '');
217
232
  setGitBranch(data.gitBranch || '');
218
233
  setGitChanges(data.gitChanges || []);
234
+ setGitRepos(data.gitRepos || []);
219
235
  })
220
236
  .catch(() => setTree([]));
221
237
  };
222
238
  fetchDir();
223
239
  }, [currentDir]);
224
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
+
225
271
  // Build git status map for tree coloring
226
272
  const gitMap: GitStatusMap = new Map(gitChanges.map(g => [g.path, g.status]));
273
+ const repoMap: GitRepoMap = new Map(gitRepos.filter(r => r.name !== '.').map(r => [r.name, { branch: r.branch, remote: r.remote }]));
227
274
 
228
- const openFile = useCallback(async (path: string) => {
275
+ const openFile = useCallback(async (path: string, forceLoad?: boolean) => {
229
276
  if (!currentDir) return;
230
277
  setSelectedFile(path);
231
278
  setViewMode('file');
279
+ setFileWarning(null);
232
280
  setLoading(true);
233
- const res = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}&file=${encodeURIComponent(path)}`);
281
+
282
+ const url = `/api/code?dir=${encodeURIComponent(currentDir)}&file=${encodeURIComponent(path)}${forceLoad ? '&force=1' : ''}`;
283
+ const res = await fetch(url);
234
284
  const data = await res.json();
235
- setContent(data.content || null);
236
- setLanguage(data.language || '');
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
+ }
237
300
  setLoading(false);
238
301
  }, [currentDir]);
239
302
 
@@ -276,16 +339,86 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
276
339
  window.addEventListener('mouseup', onUp);
277
340
  };
278
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
+
279
392
  const handleActiveSession = useCallback((session: string | null) => {
280
393
  setActiveSession(session);
281
394
  }, []);
282
395
 
283
396
  return (
284
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
+
285
418
  {/* Terminal — top */}
286
419
  <div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
287
420
  <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
288
- <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} codeOpen={codeOpen} onToggleCode={() => setCodeOpen(v => !v)} />
421
+ <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
289
422
  </Suspense>
290
423
  </div>
291
424
 
@@ -313,7 +446,19 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
313
446
  {gitBranch}
314
447
  </span>
315
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>
316
456
  </div>
457
+ {gitRepos.find(r => r.name === '.')?.remote && (
458
+ <div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={gitRepos.find(r => r.name === '.')!.remote}>
459
+ {gitRepos.find(r => r.name === '.')!.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}
460
+ </div>
461
+ )}
317
462
  {gitChanges.length > 0 && (
318
463
  <button
319
464
  onClick={() => setShowGit(v => !v)}
@@ -324,38 +469,55 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
324
469
  )}
325
470
  </div>
326
471
 
327
- {/* Git changes */}
472
+ {/* Git changes — grouped by repo */}
328
473
  {showGit && gitChanges.length > 0 && (
329
474
  <div className="border-b border-[var(--border)] max-h-48 overflow-y-auto">
330
- {gitChanges.map(g => (
331
- <div
332
- key={g.path}
333
- className={`flex items-center px-2 py-1 text-xs hover:bg-[var(--bg-tertiary)] ${
334
- diffFile === g.path && viewMode === 'diff' ? 'bg-[var(--accent)]/10' : ''
335
- }`}
336
- >
337
- <span className={`text-[10px] font-mono w-4 shrink-0 ${
338
- g.status.includes('M') ? 'text-yellow-500' :
339
- g.status.includes('A') || g.status.includes('?') ? 'text-green-500' :
340
- g.status.includes('D') ? 'text-red-500' :
341
- 'text-[var(--text-secondary)]'
342
- }`}>
343
- {g.status.includes('?') ? '+' : g.status[0]}
344
- </span>
345
- <button
346
- onClick={() => openDiff(g.path)}
347
- className="flex-1 text-left truncate text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-1"
348
- title="View diff"
349
- >
350
- {g.path}
351
- </button>
352
- <button
353
- onClick={(e) => { e.stopPropagation(); locateFile(g.path); }}
354
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)] hover:bg-[var(--accent)]/10 px-1.5 py-0.5 rounded shrink-0"
355
- title="Locate in file tree"
475
+ {gitRepos.map(repo => (
476
+ <div key={repo.name}>
477
+ {/* Repo header — only show if multiple repos */}
478
+ {gitRepos.length > 1 && (
479
+ <div className="px-2 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0" title={repo.remote}>
480
+ <div className="flex items-center gap-1.5">
481
+ <span className="font-semibold text-[var(--text-primary)]">{repo.name}</span>
482
+ <span className="text-[var(--accent)]">{repo.branch}</span>
483
+ <span className="ml-auto">{repo.changes.length}</span>
484
+ </div>
485
+ {repo.remote && (
486
+ <div className="text-[8px] truncate mt-0.5">{repo.remote.replace(/^https?:\/\//, '').replace(/\.git$/, '')}</div>
487
+ )}
488
+ </div>
489
+ )}
490
+ {repo.changes.map(g => (
491
+ <div
492
+ key={g.path}
493
+ className={`flex items-center px-2 py-1 text-xs hover:bg-[var(--bg-tertiary)] ${
494
+ diffFile === g.path && viewMode === 'diff' ? 'bg-[var(--accent)]/10' : ''
495
+ }`}
496
+ >
497
+ <span className={`text-[10px] font-mono w-4 shrink-0 ${
498
+ g.status.includes('M') ? 'text-yellow-500' :
499
+ g.status.includes('A') || g.status.includes('?') ? 'text-green-500' :
500
+ g.status.includes('D') ? 'text-red-500' :
501
+ 'text-[var(--text-secondary)]'
502
+ }`}>
503
+ {g.status.includes('?') ? '+' : g.status[0]}
504
+ </span>
505
+ <button
506
+ onClick={() => openDiff(g.path)}
507
+ className="flex-1 text-left truncate text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-1 group relative"
508
+ title={`${g.path}${gitRepos.length > 1 ? ` (${repo.name} · ${repo.branch})` : ''}`}
509
+ >
510
+ {gitRepos.length > 1 ? g.path.replace(repo.name + '/', '') : g.path}
511
+ </button>
512
+ <button
513
+ onClick={(e) => { e.stopPropagation(); locateFile(g.path); }}
514
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)] hover:bg-[var(--accent)]/10 px-1.5 py-0.5 rounded shrink-0"
515
+ title="Locate in file tree"
356
516
  >
357
517
  file
358
518
  </button>
519
+ </div>
520
+ ))}
359
521
  </div>
360
522
  ))}
361
523
  </div>
@@ -396,10 +558,53 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
396
558
  )
397
559
  ) : (
398
560
  tree.map(node => (
399
- <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} />
561
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} repoMap={repoMap} />
400
562
  ))
401
563
  )}
402
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
+ )}
403
608
  </aside>
404
609
  )}
405
610
 
@@ -433,6 +638,38 @@ export default function CodeViewer({ terminalRef, onToggleCode }: { terminalRef:
433
638
  <div className="flex-1 flex items-center justify-center">
434
639
  <div className="text-xs text-[var(--text-secondary)]">Loading...</div>
435
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>
436
673
  ) : viewMode === 'diff' && diffContent ? (
437
674
  <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
438
675
  <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
@@ -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,8 @@ 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'));
16
19
 
17
20
  interface UsageSummary {
18
21
  provider: string;
@@ -35,17 +38,30 @@ interface ProjectInfo {
35
38
  }
36
39
 
37
40
  export default function Dashboard({ user }: { user: any }) {
38
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs'>('terminal');
41
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview'>('terminal');
39
42
  const [tasks, setTasks] = useState<Task[]>([]);
40
43
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
41
44
  const [showNewTask, setShowNewTask] = useState(false);
42
45
  const [showSettings, setShowSettings] = useState(false);
43
- const [showCode, setShowCode] = useState(true);
44
46
  const [usage, setUsage] = useState<UsageSummary[]>([]);
45
47
  const [providers, setProviders] = useState<ProviderInfo[]>([]);
46
48
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
49
+ const [onlineCount, setOnlineCount] = useState<{ total: number; remote: number }>({ total: 0, remote: 0 });
47
50
  const terminalRef = useRef<WebTerminalHandle>(null);
48
51
 
52
+ // Heartbeat for online user tracking
53
+ useEffect(() => {
54
+ const ping = () => {
55
+ fetch('/api/online', { method: 'POST' })
56
+ .then(r => r.json())
57
+ .then(setOnlineCount)
58
+ .catch(() => {});
59
+ };
60
+ ping();
61
+ const id = setInterval(ping, 15_000); // every 15s
62
+ return () => clearInterval(id);
63
+ }, []);
64
+
49
65
  const fetchData = useCallback(async () => {
50
66
  const [tasksRes, statusRes, projectsRes] = await Promise.all([
51
67
  fetch('/api/tasks'),
@@ -100,6 +116,16 @@ export default function Dashboard({ user }: { user: any }) {
100
116
  >
101
117
  Docs
102
118
  </button>
119
+ <button
120
+ onClick={() => setViewMode('projects')}
121
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
122
+ viewMode === 'projects'
123
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
124
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
125
+ }`}
126
+ >
127
+ Projects
128
+ </button>
103
129
  <button
104
130
  onClick={() => setViewMode('tasks')}
105
131
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
@@ -120,6 +146,16 @@ export default function Dashboard({ user }: { user: any }) {
120
146
  >
121
147
  Sessions
122
148
  </button>
149
+ <button
150
+ onClick={() => setViewMode('preview')}
151
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
152
+ viewMode === 'preview'
153
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
154
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
155
+ }`}
156
+ >
157
+ Demo Preview
158
+ </button>
123
159
  </div>
124
160
 
125
161
  {viewMode === 'tasks' && (
@@ -138,6 +174,15 @@ export default function Dashboard({ user }: { user: any }) {
138
174
  </button>
139
175
  )}
140
176
  <TunnelToggle />
177
+ {onlineCount.total > 0 && (
178
+ <span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
179
+ <span className="text-green-500">●</span>
180
+ {onlineCount.total}
181
+ {onlineCount.remote > 0 && (
182
+ <span className="text-[var(--accent)]">({onlineCount.remote} remote)</span>
183
+ )}
184
+ </span>
185
+ )}
141
186
  <button
142
187
  onClick={() => setShowSettings(true)}
143
188
  className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
@@ -145,6 +190,12 @@ export default function Dashboard({ user }: { user: any }) {
145
190
  Settings
146
191
  </button>
147
192
  <span className="text-xs text-[var(--text-secondary)]">{user?.name || 'local'}</span>
193
+ <button
194
+ onClick={() => signOut({ callbackUrl: '/login' })}
195
+ className="text-xs text-[var(--text-secondary)] hover:text-[var(--red)]"
196
+ >
197
+ Logout
198
+ </button>
148
199
  </div>
149
200
  </header>
150
201
 
@@ -252,6 +303,20 @@ export default function Dashboard({ user }: { user: any }) {
252
303
  />
253
304
  ) : null}
254
305
 
306
+ {/* Projects */}
307
+ {viewMode === 'projects' && (
308
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
309
+ <ProjectManager />
310
+ </Suspense>
311
+ )}
312
+
313
+ {/* Preview */}
314
+ {viewMode === 'preview' && (
315
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
316
+ <PreviewPanel />
317
+ </Suspense>
318
+ )}
319
+
255
320
  {/* Docs — always mounted to keep terminal session alive */}
256
321
  <div className={`flex-1 min-h-0 flex ${viewMode === 'docs' ? '' : 'hidden'}`}>
257
322
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -262,7 +327,7 @@ export default function Dashboard({ user }: { user: any }) {
262
327
  {/* Code — terminal + file browser, always mounted to keep terminal sessions alive */}
263
328
  <div className={`flex-1 min-h-0 flex ${viewMode === 'terminal' ? '' : 'hidden'}`}>
264
329
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
265
- <CodeViewer terminalRef={terminalRef} onToggleCode={() => setShowCode(v => !v)} />
330
+ <CodeViewer terminalRef={terminalRef} />
266
331
  </Suspense>
267
332
  </div>
268
333
  </div>