@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,655 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import MarkdownContent from './MarkdownContent';
5
+
6
+ interface SessionEntry {
7
+ type: 'user' | 'assistant_text' | 'tool_use' | 'tool_result' | 'thinking' | 'system';
8
+ content: string;
9
+ toolName?: string;
10
+ model?: string;
11
+ timestamp?: string;
12
+ }
13
+
14
+ interface ClaudeSessionInfo {
15
+ sessionId: string;
16
+ summary?: string;
17
+ firstPrompt?: string;
18
+ messageCount?: number;
19
+ created?: string;
20
+ modified?: string;
21
+ gitBranch?: string;
22
+ fileSize: number;
23
+ }
24
+
25
+ interface Watcher {
26
+ id: string;
27
+ projectName: string;
28
+ sessionId: string | null;
29
+ label: string | null;
30
+ checkInterval: number;
31
+ active: boolean;
32
+ createdAt: string;
33
+ }
34
+
35
+ export default function SessionView({
36
+ projectName,
37
+ projects,
38
+ onOpenInTerminal,
39
+ }: {
40
+ projectName?: string;
41
+ projects: { name: string; path: string; language: string | null }[];
42
+ onOpenInTerminal?: (sessionId: string, projectPath: string) => void;
43
+ }) {
44
+ // Tree data: project → sessions
45
+ const [sessionTree, setSessionTree] = useState<Record<string, ClaudeSessionInfo[]>>({});
46
+ const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
47
+ const [selectedProject, setSelectedProject] = useState(projectName || '');
48
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
49
+ const [entries, setEntries] = useState<SessionEntry[]>([]);
50
+ const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
51
+ const [syncing, setSyncing] = useState(false);
52
+ const [watchers, setWatchers] = useState<Watcher[]>([]);
53
+ const [batchMode, setBatchMode] = useState(false);
54
+ const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map()); // project → sessionIds
55
+ const bottomRef = useRef<HTMLDivElement>(null);
56
+
57
+ // Load cached sessions tree
58
+ const loadTree = useCallback(async (force = false) => {
59
+ setSyncing(true);
60
+ try {
61
+ if (force) {
62
+ const res = await fetch('/api/claude-sessions/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
63
+ const data = await res.json();
64
+ setSessionTree(data.sessions);
65
+ } else {
66
+ const res = await fetch('/api/claude-sessions/sync');
67
+ const data = await res.json();
68
+ setSessionTree(data);
69
+ }
70
+ } catch {}
71
+ setSyncing(false);
72
+ }, []);
73
+
74
+ // Load watchers
75
+ const loadWatchers = useCallback(async () => {
76
+ try {
77
+ const res = await fetch('/api/watchers');
78
+ setWatchers(await res.json());
79
+ } catch {}
80
+ }, []);
81
+
82
+ useEffect(() => {
83
+ loadTree(true); // Initial sync
84
+ loadWatchers();
85
+ }, [loadTree, loadWatchers]);
86
+
87
+ // Auto-expand project if only one or if pre-selected
88
+ useEffect(() => {
89
+ const projectNames = Object.keys(sessionTree);
90
+ if (projectName && sessionTree[projectName]) {
91
+ setExpandedProjects(new Set([projectName]));
92
+ } else if (projectNames.length === 1) {
93
+ setExpandedProjects(new Set([projectNames[0]]));
94
+ }
95
+ }, [sessionTree, projectName]);
96
+
97
+ // SSE live stream
98
+ useEffect(() => {
99
+ if (!selectedProject || !activeSessionId) return;
100
+
101
+ setEntries([]);
102
+ const es = new EventSource(
103
+ `/api/claude-sessions/${encodeURIComponent(selectedProject)}/live?sessionId=${activeSessionId}`
104
+ );
105
+
106
+ es.onmessage = (event) => {
107
+ try {
108
+ const data = JSON.parse(event.data);
109
+ if (data.type === 'init') setEntries(data.entries);
110
+ else if (data.type === 'update') setEntries(prev => [...prev, ...data.entries]);
111
+ } catch {}
112
+ };
113
+
114
+ es.onerror = () => es.close();
115
+ return () => es.close();
116
+ }, [selectedProject, activeSessionId]);
117
+
118
+ // Auto-scroll
119
+ useEffect(() => {
120
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
121
+ }, [entries]);
122
+
123
+ const toggleProject = (name: string) => {
124
+ setExpandedProjects(prev => {
125
+ const next = new Set(prev);
126
+ next.has(name) ? next.delete(name) : next.add(name);
127
+ return next;
128
+ });
129
+ };
130
+
131
+ const selectSession = (project: string, sessionId: string) => {
132
+ setSelectedProject(project);
133
+ setActiveSessionId(sessionId);
134
+ setEntries([]);
135
+ setExpandedTools(new Set());
136
+ };
137
+
138
+ const toggleTool = (i: number) => {
139
+ setExpandedTools(prev => {
140
+ const next = new Set(prev);
141
+ next.has(i) ? next.delete(i) : next.add(i);
142
+ return next;
143
+ });
144
+ };
145
+
146
+ const addWatcher = async (project: string, sessionId?: string) => {
147
+ await fetch('/api/watchers', {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify({ projectName: project, sessionId, label: sessionId ? `${project}/${sessionId.slice(0, 8)}` : project }),
151
+ });
152
+ loadWatchers();
153
+ };
154
+
155
+ const removeWatcher = async (id: string) => {
156
+ await fetch('/api/watchers', {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/json' },
159
+ body: JSON.stringify({ action: 'delete', id }),
160
+ });
161
+ loadWatchers();
162
+ };
163
+
164
+ const deleteSessionById = async (project: string, sessionId: string) => {
165
+ if (!confirm(`Delete session ${sessionId.slice(0, 8)}? This cannot be undone.`)) return;
166
+ await fetch(`/api/claude-sessions/${encodeURIComponent(project)}`, {
167
+ method: 'DELETE',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({ sessionId }),
170
+ });
171
+ // Clear selection if deleted session was active
172
+ if (activeSessionId === sessionId) {
173
+ setActiveSessionId(null);
174
+ setEntries([]);
175
+ }
176
+ loadTree(false);
177
+ };
178
+
179
+ const createMonitorTask = async (project: string, sessionId: string) => {
180
+ const sessionLabel = sessionTree[project]?.find(s => s.sessionId === sessionId);
181
+ const label = sessionLabel?.summary || sessionLabel?.firstPrompt?.slice(0, 40) || sessionId.slice(0, 8);
182
+ await fetch('/api/tasks', {
183
+ method: 'POST',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({
186
+ projectName: project,
187
+ prompt: `Monitor session ${sessionId}`,
188
+ mode: 'monitor',
189
+ conversationId: sessionId,
190
+ watchConfig: {
191
+ condition: 'change',
192
+ action: 'notify',
193
+ repeat: true,
194
+ },
195
+ }),
196
+ });
197
+ alert(`Monitor task created for "${label}"`);
198
+ };
199
+
200
+ // ─── Batch helpers ────────────────────────────────────────
201
+ const totalSelected = Array.from(selectedIds.values()).reduce((n, s) => n + s.size, 0);
202
+
203
+ const toggleSelect = (project: string, sessionId: string) => {
204
+ setSelectedIds(prev => {
205
+ const next = new Map(prev);
206
+ const set = new Set(next.get(project) || []);
207
+ set.has(sessionId) ? set.delete(sessionId) : set.add(sessionId);
208
+ if (set.size === 0) next.delete(project); else next.set(project, set);
209
+ return next;
210
+ });
211
+ };
212
+
213
+ const toggleSelectAll = (project: string) => {
214
+ const sessions = sessionTree[project] || [];
215
+ setSelectedIds(prev => {
216
+ const next = new Map(prev);
217
+ const existing = next.get(project);
218
+ if (existing && existing.size === sessions.length) {
219
+ next.delete(project);
220
+ } else {
221
+ next.set(project, new Set(sessions.map(s => s.sessionId)));
222
+ }
223
+ return next;
224
+ });
225
+ };
226
+
227
+ const isSelected = (project: string, sessionId: string) =>
228
+ selectedIds.get(project)?.has(sessionId) ?? false;
229
+
230
+ const isAllSelected = (project: string) => {
231
+ const sessions = sessionTree[project] || [];
232
+ return sessions.length > 0 && (selectedIds.get(project)?.size ?? 0) === sessions.length;
233
+ };
234
+
235
+ const exitBatchMode = () => {
236
+ setBatchMode(false);
237
+ setSelectedIds(new Map());
238
+ };
239
+
240
+ const batchDelete = async () => {
241
+ if (totalSelected === 0) return;
242
+ if (!confirm(`Delete ${totalSelected} sessions? This cannot be undone.`)) return;
243
+ for (const [project, ids] of selectedIds) {
244
+ await fetch(`/api/claude-sessions/${encodeURIComponent(project)}`, {
245
+ method: 'DELETE',
246
+ headers: { 'Content-Type': 'application/json' },
247
+ body: JSON.stringify({ sessionIds: Array.from(ids) }),
248
+ });
249
+ }
250
+ // Clear active if it was deleted
251
+ if (activeSessionId && selectedIds.get(selectedProject)?.has(activeSessionId)) {
252
+ setActiveSessionId(null);
253
+ setEntries([]);
254
+ }
255
+ exitBatchMode();
256
+ loadTree(false);
257
+ };
258
+
259
+ const batchMonitor = async () => {
260
+ if (totalSelected === 0) return;
261
+ for (const [project, ids] of selectedIds) {
262
+ for (const sessionId of ids) {
263
+ await fetch('/api/tasks', {
264
+ method: 'POST',
265
+ headers: { 'Content-Type': 'application/json' },
266
+ body: JSON.stringify({
267
+ projectName: project,
268
+ prompt: `Monitor session ${sessionId}`,
269
+ mode: 'monitor',
270
+ conversationId: sessionId,
271
+ watchConfig: { condition: 'change', action: 'notify', repeat: true },
272
+ }),
273
+ });
274
+ }
275
+ }
276
+ alert(`Created ${totalSelected} monitor tasks`);
277
+ exitBatchMode();
278
+ };
279
+
280
+ const activeSession = sessionTree[selectedProject]?.find(s => s.sessionId === activeSessionId);
281
+ const watchedSessionIds = new Set(watchers.filter(w => w.active).map(w => w.sessionId));
282
+ const watchedProjects = new Set(watchers.filter(w => w.active && !w.sessionId).map(w => w.projectName));
283
+
284
+ return (
285
+ <div className="flex h-full">
286
+ {/* Left: tree view */}
287
+ <div className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
288
+ {/* Header */}
289
+ <div className="flex items-center justify-between p-2 border-b border-[var(--border)]">
290
+ <span className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase">Sessions</span>
291
+ <div className="flex items-center gap-2">
292
+ <button
293
+ onClick={() => batchMode ? exitBatchMode() : setBatchMode(true)}
294
+ className={`text-[9px] transition-colors ${batchMode ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
295
+ >
296
+ {batchMode ? 'Cancel' : 'Batch'}
297
+ </button>
298
+ <button
299
+ onClick={() => loadTree(true)}
300
+ disabled={syncing}
301
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
302
+ >
303
+ {syncing ? 'Syncing...' : 'Sync'}
304
+ </button>
305
+ </div>
306
+ </div>
307
+
308
+ {/* Batch action bar */}
309
+ {batchMode && (
310
+ <div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
311
+ <span className="text-[9px] text-[var(--text-secondary)] flex-1">
312
+ {totalSelected} selected
313
+ </span>
314
+ <button
315
+ onClick={batchMonitor}
316
+ disabled={totalSelected === 0}
317
+ className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20 disabled:opacity-30"
318
+ >
319
+ Monitor All
320
+ </button>
321
+ <button
322
+ onClick={batchDelete}
323
+ disabled={totalSelected === 0}
324
+ className="text-[8px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20 disabled:opacity-30"
325
+ >
326
+ Delete All
327
+ </button>
328
+ </div>
329
+ )}
330
+
331
+ {/* Tree */}
332
+ <div className="flex-1 overflow-y-auto">
333
+ {Object.keys(sessionTree).length === 0 && (
334
+ <p className="text-[10px] text-[var(--text-secondary)] p-3">
335
+ {syncing ? 'Loading sessions...' : 'No sessions found. Click Sync.'}
336
+ </p>
337
+ )}
338
+
339
+ {Object.entries(sessionTree).map(([project, sessions]) => (
340
+ <div key={project}>
341
+ {/* Project node */}
342
+ <div
343
+ className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-[var(--bg-tertiary)] transition-colors border-b border-[var(--border)]/50 cursor-pointer"
344
+ onClick={() => toggleProject(project)}
345
+ >
346
+ {batchMode && (
347
+ <input
348
+ type="checkbox"
349
+ checked={isAllSelected(project)}
350
+ onChange={(e) => { e.stopPropagation(); toggleSelectAll(project); }}
351
+ onClick={(e) => e.stopPropagation()}
352
+ className="shrink-0 accent-[var(--accent)]"
353
+ />
354
+ )}
355
+ <span className="text-[10px] text-[var(--text-secondary)]">
356
+ {expandedProjects.has(project) ? '▼' : '▶'}
357
+ </span>
358
+ <span className="text-[11px] font-medium text-[var(--text-primary)] truncate flex-1">{project}</span>
359
+ <span className="text-[9px] text-[var(--text-secondary)]">{sessions.length}</span>
360
+ {watchedProjects.has(project) && (
361
+ <span className="text-[9px] text-[var(--accent)]" title="Watching">👁</span>
362
+ )}
363
+ </div>
364
+
365
+ {/* Session children */}
366
+ {expandedProjects.has(project) && sessions.map(s => {
367
+ const isActive = selectedProject === project && activeSessionId === s.sessionId;
368
+ const isWatched = watchedSessionIds.has(s.sessionId);
369
+ return (
370
+ <div
371
+ key={s.sessionId}
372
+ className={`group relative w-full text-left pl-6 pr-2 py-1.5 hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer ${
373
+ isActive ? 'bg-[var(--bg-tertiary)] border-l-2 border-l-[var(--accent)]' : 'border-l-2 border-l-transparent'
374
+ }`}
375
+ onClick={() => batchMode ? toggleSelect(project, s.sessionId) : selectSession(project, s.sessionId)}
376
+ >
377
+ <div className="flex items-center gap-1">
378
+ {batchMode && (
379
+ <input
380
+ type="checkbox"
381
+ checked={isSelected(project, s.sessionId)}
382
+ onChange={() => toggleSelect(project, s.sessionId)}
383
+ onClick={(e) => e.stopPropagation()}
384
+ className="shrink-0 accent-[var(--accent)]"
385
+ />
386
+ )}
387
+ <span className="text-[10px] text-[var(--text-primary)] truncate flex-1">
388
+ {s.summary || s.firstPrompt?.slice(0, 40) || s.sessionId.slice(0, 8)}
389
+ </span>
390
+ {isWatched && <span className="text-[8px] text-[var(--accent)]">👁</span>}
391
+ {/* Hover actions — hide in batch mode */}
392
+ {!batchMode && (
393
+ <span className="hidden group-hover:flex items-center gap-0.5 shrink-0">
394
+ <button
395
+ onClick={(e) => { e.stopPropagation(); createMonitorTask(project, s.sessionId); }}
396
+ className="text-[8px] px-1 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20"
397
+ title="Create monitor task (notify via Telegram)"
398
+ >
399
+ monitor
400
+ </button>
401
+ <button
402
+ onClick={(e) => { e.stopPropagation(); deleteSessionById(project, s.sessionId); }}
403
+ className="text-[8px] px-1 py-0.5 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20"
404
+ title="Delete session"
405
+ >
406
+ del
407
+ </button>
408
+ </span>
409
+ )}
410
+ </div>
411
+ <div className="flex items-center gap-2 mt-0.5">
412
+ <span className="text-[8px] text-[var(--text-secondary)] font-mono">{s.sessionId.slice(0, 8)}</span>
413
+ {s.gitBranch && <span className="text-[8px] text-[var(--accent)]">{s.gitBranch}</span>}
414
+ {s.modified && (
415
+ <span className="text-[8px] text-[var(--text-secondary)]">
416
+ {timeAgo(s.modified)}
417
+ </span>
418
+ )}
419
+ </div>
420
+ </div>
421
+ );
422
+ })}
423
+ </div>
424
+ ))}
425
+
426
+ {/* Active watchers section */}
427
+ {watchers.length > 0 && (
428
+ <div className="border-t border-[var(--border)] mt-2 pt-2">
429
+ <div className="px-2 mb-1">
430
+ <span className="text-[9px] font-semibold text-[var(--text-secondary)] uppercase">Watchers</span>
431
+ </div>
432
+ {watchers.map(w => (
433
+ <div key={w.id} className="flex items-center gap-1 px-2 py-1 text-[10px]">
434
+ <span className={`${w.active ? 'text-green-400' : 'text-gray-500'}`}>
435
+ {w.active ? '●' : '○'}
436
+ </span>
437
+ <span className="text-[var(--text-secondary)] truncate flex-1">
438
+ {w.label || w.projectName}
439
+ </span>
440
+ <button
441
+ onClick={() => removeWatcher(w.id)}
442
+ className="text-[8px] text-gray-500 hover:text-red-400"
443
+ >
444
+ x
445
+ </button>
446
+ </div>
447
+ ))}
448
+ </div>
449
+ )}
450
+ </div>
451
+ </div>
452
+
453
+ {/* Right: session content */}
454
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
455
+ {activeSession && (
456
+ <div className="border-b border-[var(--border)] px-4 py-2 shrink-0">
457
+ <div className="flex items-center gap-2">
458
+ <span className="text-sm font-semibold">{selectedProject}</span>
459
+ <span className="text-[10px] text-[var(--text-secondary)] font-mono">{activeSessionId?.slice(0, 12)}</span>
460
+ {activeSession.gitBranch && (
461
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)]">
462
+ {activeSession.gitBranch}
463
+ </span>
464
+ )}
465
+ <div className="ml-auto flex items-center gap-2">
466
+ {activeSessionId && !watchedSessionIds.has(activeSessionId) && (
467
+ <button
468
+ onClick={() => addWatcher(selectedProject, activeSessionId!)}
469
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] hover:border-[var(--accent)]"
470
+ >
471
+ Watch
472
+ </button>
473
+ )}
474
+ {activeSessionId && (
475
+ <button
476
+ onClick={() => createMonitorTask(selectedProject, activeSessionId!)}
477
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--accent)] hover:border-[var(--accent)]"
478
+ title="Create a monitor task that sends Telegram notifications on changes"
479
+ >
480
+ Monitor
481
+ </button>
482
+ )}
483
+ {onOpenInTerminal && activeSessionId && (
484
+ <button
485
+ onClick={() => {
486
+ const proj = projects.find(p => p.name === selectedProject);
487
+ if (proj) onOpenInTerminal(activeSessionId!, proj.path);
488
+ }}
489
+ className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
490
+ >
491
+ Open in Terminal
492
+ </button>
493
+ )}
494
+ {activeSessionId && (
495
+ <button
496
+ onClick={() => deleteSessionById(selectedProject, activeSessionId!)}
497
+ className="text-[10px] px-2 py-0.5 border border-red-500/30 text-red-400 rounded hover:bg-red-500/10"
498
+ >
499
+ Delete
500
+ </button>
501
+ )}
502
+ </div>
503
+ </div>
504
+ {activeSession.summary && (
505
+ <p className="text-xs text-[var(--text-secondary)] mt-0.5">{activeSession.summary}</p>
506
+ )}
507
+ </div>
508
+ )}
509
+
510
+ <div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-2">
511
+ {!activeSessionId && (
512
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] h-full">
513
+ <p>Select a session from the tree to view</p>
514
+ </div>
515
+ )}
516
+
517
+ {entries.map((entry, i) => (
518
+ <SessionEntryView
519
+ key={i}
520
+ entry={entry}
521
+ expanded={expandedTools.has(i)}
522
+ onToggle={() => toggleTool(i)}
523
+ />
524
+ ))}
525
+
526
+ {entries.length > 0 && (
527
+ <div className="text-[10px] text-[var(--text-secondary)] pt-2">
528
+ {entries.length} entries — live updating
529
+ </div>
530
+ )}
531
+ <div ref={bottomRef} />
532
+ </div>
533
+ </div>
534
+ </div>
535
+ );
536
+ }
537
+
538
+ // ─── Time ago helper ─────────────────────────────────────────
539
+
540
+ function timeAgo(dateStr: string): string {
541
+ const diff = Date.now() - new Date(dateStr).getTime();
542
+ const mins = Math.floor(diff / 60000);
543
+ if (mins < 1) return 'just now';
544
+ if (mins < 60) return `${mins}m ago`;
545
+ const hours = Math.floor(mins / 60);
546
+ if (hours < 24) return `${hours}h ago`;
547
+ const days = Math.floor(hours / 24);
548
+ return `${days}d ago`;
549
+ }
550
+
551
+ // ─── Session entry renderer ─────────────────────────────────
552
+
553
+ function SessionEntryView({
554
+ entry,
555
+ expanded,
556
+ onToggle,
557
+ }: {
558
+ entry: SessionEntry;
559
+ expanded: boolean;
560
+ onToggle: () => void;
561
+ }) {
562
+ if (entry.type === 'user') {
563
+ return (
564
+ <div className="flex justify-end">
565
+ <div className="max-w-[80%] px-3 py-2 bg-[var(--accent)]/10 border border-[var(--accent)]/20 rounded-lg">
566
+ <p className="text-xs text-[var(--text-primary)] whitespace-pre-wrap break-all">{entry.content}</p>
567
+ {entry.timestamp && (
568
+ <span className="text-[9px] text-[var(--text-secondary)] mt-1 block">
569
+ {new Date(entry.timestamp).toLocaleTimeString()}
570
+ </span>
571
+ )}
572
+ </div>
573
+ </div>
574
+ );
575
+ }
576
+
577
+ if (entry.type === 'assistant_text') {
578
+ return (
579
+ <div className="py-1 overflow-hidden">
580
+ <MarkdownContent content={entry.content} />
581
+ </div>
582
+ );
583
+ }
584
+
585
+ if (entry.type === 'thinking') {
586
+ const isLong = entry.content.length > 100;
587
+ return (
588
+ <div className="border border-[var(--border)] rounded overflow-hidden opacity-60">
589
+ <button
590
+ onClick={onToggle}
591
+ className="w-full flex items-center gap-2 px-2 py-1 bg-[var(--bg-tertiary)] hover:bg-[var(--border)]/30 text-left"
592
+ >
593
+ <span className="text-[10px] text-[var(--text-secondary)] italic">thinking...</span>
594
+ {isLong && <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{expanded ? '▲' : '▼'}</span>}
595
+ </button>
596
+ {expanded && (
597
+ <pre className="px-3 py-2 text-[10px] text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all max-h-40 overflow-y-auto border-t border-[var(--border)]">
598
+ {entry.content}
599
+ </pre>
600
+ )}
601
+ </div>
602
+ );
603
+ }
604
+
605
+ if (entry.type === 'tool_use') {
606
+ const isLong = entry.content.length > 80;
607
+ return (
608
+ <div className="border border-[var(--border)] rounded overflow-hidden max-w-full">
609
+ <button
610
+ onClick={onToggle}
611
+ className="w-full flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-tertiary)] hover:bg-[var(--border)]/30 transition-colors text-left"
612
+ >
613
+ <span className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/15 text-[var(--accent)] rounded font-medium font-mono">
614
+ {entry.toolName || 'tool'}
615
+ </span>
616
+ <span className="text-[11px] text-[var(--text-secondary)] truncate flex-1 font-mono">
617
+ {isLong && !expanded ? entry.content.slice(0, 80) + '...' : (!isLong ? entry.content : '')}
618
+ </span>
619
+ {isLong && <span className="text-[9px] text-[var(--text-secondary)] shrink-0">{expanded ? '▲' : '▼'}</span>}
620
+ </button>
621
+ {(expanded || !isLong) && isLong && (
622
+ <pre className="px-3 py-2 text-[11px] text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all max-h-60 overflow-y-auto border-t border-[var(--border)]">
623
+ {entry.content}
624
+ </pre>
625
+ )}
626
+ </div>
627
+ );
628
+ }
629
+
630
+ if (entry.type === 'tool_result') {
631
+ const isLong = entry.content.length > 150;
632
+ return (
633
+ <div className="ml-4 border-l-2 border-[var(--accent)]/30 pl-3 overflow-hidden">
634
+ <pre className={`text-[11px] text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all ${isLong && !expanded ? 'max-h-16 overflow-hidden' : 'max-h-80 overflow-y-auto'}`}>
635
+ {entry.content}
636
+ </pre>
637
+ {isLong && !expanded && (
638
+ <button onClick={onToggle} className="text-[9px] text-[var(--accent)] hover:underline mt-0.5">
639
+ show more
640
+ </button>
641
+ )}
642
+ </div>
643
+ );
644
+ }
645
+
646
+ if (entry.type === 'system') {
647
+ return (
648
+ <div className="text-[10px] text-[var(--text-secondary)] py-0.5 flex items-center gap-1 opacity-50">
649
+ <span>--</span> {entry.content}
650
+ </div>
651
+ );
652
+ }
653
+
654
+ return null;
655
+ }