@aion0/forge 0.2.1 → 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.
@@ -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
- 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);
248
284
  const data = await res.json();
249
- setContent(data.content || null);
250
- 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
+ }
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 }}>
@@ -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,7 +38,7 @@ 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);
@@ -43,8 +46,22 @@ export default function Dashboard({ user }: { user: any }) {
43
46
  const [usage, setUsage] = useState<UsageSummary[]>([]);
44
47
  const [providers, setProviders] = useState<ProviderInfo[]>([]);
45
48
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
49
+ const [onlineCount, setOnlineCount] = useState<{ total: number; remote: number }>({ total: 0, remote: 0 });
46
50
  const terminalRef = useRef<WebTerminalHandle>(null);
47
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
+
48
65
  const fetchData = useCallback(async () => {
49
66
  const [tasksRes, statusRes, projectsRes] = await Promise.all([
50
67
  fetch('/api/tasks'),
@@ -99,6 +116,16 @@ export default function Dashboard({ user }: { user: any }) {
99
116
  >
100
117
  Docs
101
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>
102
129
  <button
103
130
  onClick={() => setViewMode('tasks')}
104
131
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
@@ -119,6 +146,16 @@ export default function Dashboard({ user }: { user: any }) {
119
146
  >
120
147
  Sessions
121
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>
122
159
  </div>
123
160
 
124
161
  {viewMode === 'tasks' && (
@@ -137,6 +174,15 @@ export default function Dashboard({ user }: { user: any }) {
137
174
  </button>
138
175
  )}
139
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
+ )}
140
186
  <button
141
187
  onClick={() => setShowSettings(true)}
142
188
  className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
@@ -144,6 +190,12 @@ export default function Dashboard({ user }: { user: any }) {
144
190
  Settings
145
191
  </button>
146
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>
147
199
  </div>
148
200
  </header>
149
201
 
@@ -251,6 +303,20 @@ export default function Dashboard({ user }: { user: any }) {
251
303
  />
252
304
  ) : null}
253
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
+
254
320
  {/* Docs — always mounted to keep terminal session alive */}
255
321
  <div className={`flex-1 min-h-0 flex ${viewMode === 'docs' ? '' : 'hidden'}`}>
256
322
  <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
- isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
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,30 @@ export default function DocsViewer() {
92
97
 
93
98
  useEffect(() => { fetchTree(activeRoot); }, [activeRoot, fetchTree]);
94
99
 
100
+ const [fileWarning, setFileWarning] = useState<string | null>(null);
101
+
95
102
  // Fetch file content
103
+ const isImageFile = (path: string) => /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i.test(path);
104
+
96
105
  const openFile = useCallback(async (path: string) => {
97
106
  setSelectedFile(path);
107
+ setFileWarning(null);
108
+
109
+ if (isImageFile(path)) {
110
+ setContent(null);
111
+ setLoading(false);
112
+ return; // images rendered directly via img tag
113
+ }
114
+
98
115
  setLoading(true);
99
116
  const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
100
117
  const data = await res.json();
101
- setContent(data.content || null);
118
+ if (data.tooLarge) {
119
+ setContent(null);
120
+ setFileWarning(`File too large (${data.sizeLabel})`);
121
+ } else {
122
+ setContent(data.content || null);
123
+ }
102
124
  setLoading(false);
103
125
  }, [activeRoot]);
104
126
 
@@ -157,6 +179,18 @@ export default function DocsViewer() {
157
179
  </div>
158
180
  )}
159
181
 
182
+ {/* Header with refresh */}
183
+ <div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center">
184
+ <span className="text-[10px] text-[var(--text-secondary)] truncate">{roots[activeRoot] || 'Docs'}</span>
185
+ <button
186
+ onClick={() => { fetchTree(activeRoot); if (selectedFile) openFile(selectedFile); }}
187
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto shrink-0"
188
+ title="Refresh files"
189
+ >
190
+
191
+ </button>
192
+ </div>
193
+
160
194
  {/* Search */}
161
195
  <div className="p-2 border-b border-[var(--border)]">
162
196
  <input
@@ -219,7 +253,22 @@ export default function DocsViewer() {
219
253
  </div>
220
254
 
221
255
  {/* Content */}
222
- {selectedFile && content ? (
256
+ {fileWarning ? (
257
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
258
+ <div className="text-center space-y-2">
259
+ <div className="text-3xl">⚠️</div>
260
+ <p className="text-sm">{fileWarning}</p>
261
+ </div>
262
+ </div>
263
+ ) : selectedFile && isImageFile(selectedFile) ? (
264
+ <div className="flex-1 overflow-auto flex items-center justify-center p-6 bg-[var(--bg-tertiary)]">
265
+ <img
266
+ src={`/api/docs?root=${activeRoot}&image=${encodeURIComponent(selectedFile)}`}
267
+ alt={selectedFile}
268
+ className="max-w-full max-h-full object-contain rounded shadow-lg"
269
+ />
270
+ </div>
271
+ ) : selectedFile && content ? (
223
272
  <div className="flex-1 overflow-y-auto px-8 py-6">
224
273
  {loading ? (
225
274
  <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