@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.
- package/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- 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
|
+
}
|