claude-code-watch 0.1.4 → 0.2.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/package.json +1 -1
- package/public/css/app.css +474 -0
- package/public/index.html +31 -2202
- package/public/js/app.js +490 -0
- package/public/js/shared.js +245 -0
- package/public/js/stream.js +1076 -0
- package/public/js/token.js +480 -0
- package/src/scanner/scanner.js +155 -0
- package/src/server/server.js +169 -11
- package/src/watcher/watcher.js +103 -65
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// stream.js — Stream & Tree panel page
|
|
3
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
// ── DOM refs ──
|
|
6
|
+
const streamEl = document.getElementById('stream-panel');
|
|
7
|
+
const treeEl = document.getElementById('tree-content');
|
|
8
|
+
const treeCursorInfo = document.getElementById('tree-cursor-info');
|
|
9
|
+
|
|
10
|
+
// ── Stream State ──
|
|
11
|
+
let showThinking = true;
|
|
12
|
+
let showToolInput = true;
|
|
13
|
+
let showToolOutput = true;
|
|
14
|
+
let showText = true;
|
|
15
|
+
let showHook = true;
|
|
16
|
+
let showUserPrompt = true;
|
|
17
|
+
let showActivity = true;
|
|
18
|
+
let showTokenCount = true;
|
|
19
|
+
let autoScroll = true;
|
|
20
|
+
|
|
21
|
+
// ── Render State ──
|
|
22
|
+
let treeDirty = true;
|
|
23
|
+
let treeNeedsRebuild = false;
|
|
24
|
+
let needsFullRender = true;
|
|
25
|
+
let renderedItemCount = 0;
|
|
26
|
+
let lastTreeCursor = -1;
|
|
27
|
+
|
|
28
|
+
const MAX_ITEMS = 9999;
|
|
29
|
+
const MAX_LINES = 50;
|
|
30
|
+
|
|
31
|
+
// ── Tree State ──
|
|
32
|
+
let collapseAfter = 0;
|
|
33
|
+
let collapseTimer = null;
|
|
34
|
+
let activeRefreshTimer = null;
|
|
35
|
+
const ACTIVE_THRESHOLD = 600000;
|
|
36
|
+
|
|
37
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
38
|
+
// Markdown renderer (marked + highlight.js)
|
|
39
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
const mdRenderer = new marked.Renderer();
|
|
42
|
+
mdRenderer.code = function (codeOrObj, langOrEsc) {
|
|
43
|
+
const text = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
|
|
44
|
+
const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : langOrEsc;
|
|
45
|
+
let highlighted;
|
|
46
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
47
|
+
try {
|
|
48
|
+
highlighted = hljs.highlight(text, { language: lang }).value;
|
|
49
|
+
} catch {
|
|
50
|
+
highlighted = hljs.highlightAuto(text).value;
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
highlighted = hljs.highlightAuto(text).value;
|
|
54
|
+
}
|
|
55
|
+
const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
|
|
56
|
+
return `<div class="code-block-wrapper">
|
|
57
|
+
<div class="code-block-header">${langTag}<span class="copy-btn" onclick="copyCode(this)">⎘</span></div>
|
|
58
|
+
<pre><code>${highlighted}</code></pre>
|
|
59
|
+
</div>`;
|
|
60
|
+
};
|
|
61
|
+
marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
|
|
62
|
+
|
|
63
|
+
function copyCode(btn) {
|
|
64
|
+
const wrapper = btn.closest('.code-block-wrapper');
|
|
65
|
+
const code = wrapper ? wrapper.querySelector('code') : null;
|
|
66
|
+
if (!code) return;
|
|
67
|
+
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
68
|
+
btn.innerHTML = '✓';
|
|
69
|
+
setTimeout(() => { btn.innerHTML = '⎘'; }, 1500);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mdRender(text) {
|
|
74
|
+
try {
|
|
75
|
+
return DOMPurify.sanitize(marked.parse(text));
|
|
76
|
+
} catch {
|
|
77
|
+
return esc(text);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
82
|
+
// Snapshot / Session management
|
|
83
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
84
|
+
|
|
85
|
+
function handleSnapshot(payload) {
|
|
86
|
+
autoDiscovery = payload.autoDiscovery;
|
|
87
|
+
const incomingIDs = new Set((payload.sessions || []).map(s => s.id));
|
|
88
|
+
for (let i = sessions.length - 1; i >= 0; i--) {
|
|
89
|
+
const s = sessions[i];
|
|
90
|
+
if (!incomingIDs.has(s.id) && !s.pinned) {
|
|
91
|
+
sessions.splice(i, 1);
|
|
92
|
+
sessionsMap.delete(s.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const s of (payload.sessions || [])) {
|
|
96
|
+
if (hiddenSessionIDs.has(s.id)) continue;
|
|
97
|
+
let session = sessionsMap.get(s.id);
|
|
98
|
+
if (!session) {
|
|
99
|
+
session = {
|
|
100
|
+
id: s.id, projectPath: s.projectPath, title: '',
|
|
101
|
+
folder: folderName(s.projectPath), model: '',
|
|
102
|
+
agents: [], tasks: [], collapsed: false, pinned: false,
|
|
103
|
+
lastActivity: s.birthtimeMs || 0,
|
|
104
|
+
birthtimeMs: s.birthtimeMs || 0,
|
|
105
|
+
};
|
|
106
|
+
sessions.push(session);
|
|
107
|
+
sessionsMap.set(session.id, session);
|
|
108
|
+
session.agents.push({ id: '', name: 'Main', type: 'main' });
|
|
109
|
+
}
|
|
110
|
+
for (const [aid, adata] of Object.entries(s.subagents || {})) {
|
|
111
|
+
if (!session.agents.find(a => a.id === aid)) {
|
|
112
|
+
const atype = typeof adata === 'string' ? adata : adata.type;
|
|
113
|
+
const abirth = typeof adata === 'object' ? (adata.birthtimeMs || 0) : 0;
|
|
114
|
+
session.agents.push({ id: aid, name: agentDisplayName(aid, atype), type: 'agent', birthtimeMs: abirth });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const t of (s.backgroundTasks || [])) {
|
|
118
|
+
if (!session.tasks.find(ta => ta.id === t.id)) {
|
|
119
|
+
session.tasks.push({
|
|
120
|
+
id: t.id, parentAgentID: t.parentAgentID,
|
|
121
|
+
toolName: t.toolName, outputPath: t.outputPath,
|
|
122
|
+
isComplete: t.isComplete,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const [key, val] of Object.entries(payload.lastActivities || {})) {
|
|
128
|
+
agentActivity.set(key, val);
|
|
129
|
+
}
|
|
130
|
+
updateFilters();
|
|
131
|
+
scheduleRebuildNodes();
|
|
132
|
+
needsFullRender = true;
|
|
133
|
+
visibleDirty = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleNewSession(payload) {
|
|
137
|
+
if (hiddenSessionIDs.has(payload.sessionID)) return;
|
|
138
|
+
if (sessionsMap.has(payload.sessionID)) return;
|
|
139
|
+
const session = {
|
|
140
|
+
id: payload.sessionID, projectPath: payload.projectPath,
|
|
141
|
+
title: '', folder: folderName(payload.projectPath), model: '',
|
|
142
|
+
agents: [{ id: '', name: 'Main', type: 'main' }],
|
|
143
|
+
tasks: [], collapsed: false, pinned: false,
|
|
144
|
+
lastActivity: payload.birthtimeMs || Date.now(),
|
|
145
|
+
birthtimeMs: payload.birthtimeMs || 0,
|
|
146
|
+
};
|
|
147
|
+
sessions.push(session);
|
|
148
|
+
sessionsMap.set(session.id, session);
|
|
149
|
+
updateFilters();
|
|
150
|
+
scheduleRebuildNodes();
|
|
151
|
+
needsFullRender = true;
|
|
152
|
+
visibleDirty = true;
|
|
153
|
+
scheduleRender();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function handleNewAgent(payload) {
|
|
157
|
+
const s = sessionsMap.get(payload.sessionID);
|
|
158
|
+
if (!s || s.agents.find(a => a.id === payload.agentID)) return;
|
|
159
|
+
s.agents.push({
|
|
160
|
+
id: payload.agentID,
|
|
161
|
+
name: agentDisplayName(payload.agentID, payload.agentType),
|
|
162
|
+
type: 'agent',
|
|
163
|
+
birthtimeMs: payload.birthtimeMs || 0,
|
|
164
|
+
});
|
|
165
|
+
updateFilters();
|
|
166
|
+
scheduleRebuildNodes();
|
|
167
|
+
needsFullRender = true;
|
|
168
|
+
visibleDirty = true;
|
|
169
|
+
scheduleRender();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function handleNewBgTask(payload) {
|
|
173
|
+
const s = sessionsMap.get(payload.sessionID);
|
|
174
|
+
if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
|
|
175
|
+
s.tasks.push({
|
|
176
|
+
id: payload.toolID, parentAgentID: payload.parentAgentID,
|
|
177
|
+
toolName: payload.toolName, outputPath: payload.outputPath,
|
|
178
|
+
isComplete: payload.isComplete,
|
|
179
|
+
});
|
|
180
|
+
scheduleRebuildNodes();
|
|
181
|
+
scheduleRender();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function handleSessionRemoved(payload) {
|
|
185
|
+
const sid = payload.sessionID;
|
|
186
|
+
const s = sessionsMap.get(sid);
|
|
187
|
+
if (s) {
|
|
188
|
+
for (const a of s.agents) agentActivity.delete(sid + ':' + a.id);
|
|
189
|
+
for (const t of s.tasks) taskDescriptions.delete(t.id);
|
|
190
|
+
}
|
|
191
|
+
const idx = sessions.findIndex(s => s.id === sid);
|
|
192
|
+
if (idx >= 0) {
|
|
193
|
+
sessions.splice(idx, 1);
|
|
194
|
+
sessionsMap.delete(sid);
|
|
195
|
+
}
|
|
196
|
+
updateFilters();
|
|
197
|
+
scheduleRebuildNodes();
|
|
198
|
+
needsFullRender = true;
|
|
199
|
+
visibleDirty = true;
|
|
200
|
+
scheduleRender();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
204
|
+
// Stream items
|
|
205
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
206
|
+
|
|
207
|
+
function handleItem(item) {
|
|
208
|
+
if (item.type === 'session_title') {
|
|
209
|
+
const s = sessionsMap.get(item.sessionID);
|
|
210
|
+
if (s) { s.title = item.content.slice(0, 30); }
|
|
211
|
+
scheduleRender();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const s = sessionsMap.get(item.sessionID);
|
|
215
|
+
if (s) s.lastActivity = itemTime(item);
|
|
216
|
+
pushItem(item);
|
|
217
|
+
scheduleRender();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function handleItemBatch(items) {
|
|
221
|
+
for (const item of items) {
|
|
222
|
+
if (item.type === 'session_title') {
|
|
223
|
+
const s = sessionsMap.get(item.sessionID);
|
|
224
|
+
if (s) { s.title = item.content.slice(0, 30); }
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const s = sessionsMap.get(item.sessionID);
|
|
228
|
+
if (s) s.lastActivity = itemTime(item);
|
|
229
|
+
pushItem(item);
|
|
230
|
+
}
|
|
231
|
+
scheduleRebuildNodes();
|
|
232
|
+
scheduleRender();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function pushItem(item) {
|
|
236
|
+
if (hiddenSessionIDs.has(item.sessionID)) return;
|
|
237
|
+
|
|
238
|
+
if (item.model) {
|
|
239
|
+
const s = sessionsMap.get(item.sessionID);
|
|
240
|
+
if (s) s.model = item.model;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (item.type === 'tool_input' && item.toolID && item.toolName) {
|
|
244
|
+
toolNameMap.set(item.toolID, item.toolName);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (item.type === 'tool_input') {
|
|
248
|
+
if (item.agentID) {
|
|
249
|
+
agentActivity.set(item.sessionID + ':' + item.agentID, { toolName: item.toolName || '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
|
|
250
|
+
}
|
|
251
|
+
if (item.toolID) {
|
|
252
|
+
taskDescriptions.set(item.toolID, (item.content || '').slice(0, MAX_DESC_STORE));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (item.type === 'user_text') {
|
|
257
|
+
agentActivity.set(item.sessionID + ':' + (item.agentID || ''), { toolName: '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (item.toolID) {
|
|
261
|
+
const key = `${item.toolID}:${item.type}`;
|
|
262
|
+
if (seenToolIDs.has(key)) return;
|
|
263
|
+
seenToolIDs.set(key, true);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
streamItems.push(item);
|
|
267
|
+
if (streamItems.length > MAX_ITEMS) {
|
|
268
|
+
streamItems.splice(0, streamItems.length - MAX_ITEMS);
|
|
269
|
+
visibleDirty = true;
|
|
270
|
+
}
|
|
271
|
+
if (!visibleDirty && isItemVisible(item)) {
|
|
272
|
+
visibleItems.push(item);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isItemVisible(item) {
|
|
277
|
+
if (!filters.has(item.sessionID + ':' + (item.agentID || ''))) return false;
|
|
278
|
+
switch (item.type) {
|
|
279
|
+
case 'thinking': return showThinking;
|
|
280
|
+
case 'tool_input': return showToolInput;
|
|
281
|
+
case 'tool_output': return showToolOutput;
|
|
282
|
+
case 'text': return showText;
|
|
283
|
+
case 'hook_output': return showHook;
|
|
284
|
+
case 'user_text': return showUserPrompt;
|
|
285
|
+
default: return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
290
|
+
// Tree
|
|
291
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
292
|
+
|
|
293
|
+
function rebuildNodes() {
|
|
294
|
+
sessions.sort((a, b) => (b.birthtimeMs || 0) - (a.birthtimeMs || 0));
|
|
295
|
+
for (let i = 0; i < sessions.length; i++) sessions[i].colorRank = i;
|
|
296
|
+
|
|
297
|
+
computeAgentIdDisplayLengths();
|
|
298
|
+
|
|
299
|
+
const today = new Date();
|
|
300
|
+
const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
301
|
+
|
|
302
|
+
const flatSessions = [];
|
|
303
|
+
const olderByDate = new Map();
|
|
304
|
+
|
|
305
|
+
for (const s of sessions) {
|
|
306
|
+
const dateStr = s.birthtimeMs ? formatTime(s.birthtimeMs).split(' ')[0] : null;
|
|
307
|
+
if (!dateStr || dateStr === todayStr || isSessionActive(s)) {
|
|
308
|
+
flatSessions.push(s);
|
|
309
|
+
} else {
|
|
310
|
+
if (!olderByDate.has(dateStr)) olderByDate.set(dateStr, []);
|
|
311
|
+
olderByDate.get(dateStr).push(s);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
treeNodes.length = 0;
|
|
316
|
+
|
|
317
|
+
function addSessionWithChildren(s, inFolder) {
|
|
318
|
+
treeNodes.push({ type: 'session', level: 0, isLast: false, inFolder: !!inFolder, ...s });
|
|
319
|
+
if (s.collapsed) return;
|
|
320
|
+
const agents = (s.agents || []).slice().sort((a, b) => {
|
|
321
|
+
if (a.type === 'main') return -1;
|
|
322
|
+
if (b.type === 'main') return 1;
|
|
323
|
+
return (a.birthtimeMs || 0) - (b.birthtimeMs || 0);
|
|
324
|
+
});
|
|
325
|
+
const lastAgentIdx = agents.length - 1;
|
|
326
|
+
for (let ai = 0; ai < agents.length; ai++) {
|
|
327
|
+
const a = agents[ai];
|
|
328
|
+
const isLastAgent = ai === lastAgentIdx;
|
|
329
|
+
const tasks = s.tasks.filter(t =>
|
|
330
|
+
(a.id === '' && !t.parentAgentID) || t.parentAgentID === a.id
|
|
331
|
+
);
|
|
332
|
+
const lastTaskIdx = tasks.length - 1;
|
|
333
|
+
const actKey = s.id + ':' + a.id;
|
|
334
|
+
const act = agentActivity.get(actKey);
|
|
335
|
+
treeNodes.push({
|
|
336
|
+
type: a.type, id: a.id, name: a.name, sessionID: s.id,
|
|
337
|
+
level: 1, isLast: isLastAgent,
|
|
338
|
+
activityTool: act ? act.toolName : '',
|
|
339
|
+
activityDesc: act ? act.content : '',
|
|
340
|
+
});
|
|
341
|
+
for (let ti = 0; ti < tasks.length; ti++) {
|
|
342
|
+
const t = tasks[ti];
|
|
343
|
+
const tDesc = taskDescriptions.get(t.id);
|
|
344
|
+
treeNodes.push({
|
|
345
|
+
type: 'task', id: t.id, name: t.toolName,
|
|
346
|
+
sessionID: s.id, parentAgentID: t.parentAgentID,
|
|
347
|
+
outputPath: t.outputPath, isComplete: t.isComplete,
|
|
348
|
+
level: 2, isLast: ti === lastTaskIdx,
|
|
349
|
+
parentIsLast: isLastAgent,
|
|
350
|
+
description: tDesc || '',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const s of flatSessions) {
|
|
357
|
+
addSessionWithChildren(s, false);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const sortedDates = [...olderByDate.keys()].sort((a, b) => b.localeCompare(a));
|
|
361
|
+
for (let di = 0; di < sortedDates.length; di++) {
|
|
362
|
+
const dateStr = sortedDates[di];
|
|
363
|
+
const folderSessions = olderByDate.get(dateStr);
|
|
364
|
+
const collapsed = folderCollapsed[dateStr] !== false;
|
|
365
|
+
treeNodes.push({
|
|
366
|
+
type: 'date-folder', date: dateStr, level: 0, isLast: false,
|
|
367
|
+
collapsed, sessionCount: folderSessions.length,
|
|
368
|
+
});
|
|
369
|
+
if (!collapsed) {
|
|
370
|
+
for (const s of folderSessions) {
|
|
371
|
+
addSessionWithChildren(s, true);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const flatSessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
|
|
377
|
+
if (flatSessionNodes.length > 0) flatSessionNodes[flatSessionNodes.length - 1].isLast = true;
|
|
378
|
+
|
|
379
|
+
for (const dateStr of sortedDates) {
|
|
380
|
+
if (folderCollapsed[dateStr] !== false) continue;
|
|
381
|
+
const thisFolder = [];
|
|
382
|
+
let inThisFolder = false;
|
|
383
|
+
for (const n of treeNodes) {
|
|
384
|
+
if (n.type === 'date-folder' && n.date === dateStr) { inThisFolder = true; continue; }
|
|
385
|
+
if (n.type === 'date-folder' && n.date !== dateStr) { inThisFolder = false; continue; }
|
|
386
|
+
if (inThisFolder && n.type === 'session') thisFolder.push(n);
|
|
387
|
+
}
|
|
388
|
+
if (thisFolder.length > 0) thisFolder[thisFolder.length - 1].isLast = true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
|
|
392
|
+
treeDirty = true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function treePrefix(node) {
|
|
396
|
+
if (node.level === 0) {
|
|
397
|
+
return node.inFolder ? ' ' : '';
|
|
398
|
+
}
|
|
399
|
+
const branch = node.isLast ? '└──' : '├──';
|
|
400
|
+
if (node.level === 1) return ' ' + branch;
|
|
401
|
+
const parentIsLast = node.parentIsLast !== undefined ? node.parentIsLast : true;
|
|
402
|
+
const stem = parentIsLast ? ' ' : '│ ';
|
|
403
|
+
return ' ' + stem + branch;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function getNodeHTML(node, idx) {
|
|
407
|
+
const isSelected = idx === treeCursor;
|
|
408
|
+
const selClass = isSelected ? ' selected' : '';
|
|
409
|
+
|
|
410
|
+
if (node.type === 'date-folder') {
|
|
411
|
+
const icon = node.collapsed ? '▸' : '▾';
|
|
412
|
+
return `<div class="tree-row tree-row-folder${selClass ? ' selected' : ''}">
|
|
413
|
+
<div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
414
|
+
<div class="tree-node folder-node">
|
|
415
|
+
${icon} 📁 ${node.date} <span style="font-size:10px;color:var(--dim);margin-left:4px">(${node.sessionCount})</span>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
</div>`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (node.type === 'session') {
|
|
422
|
+
const displayName = folderName(node.projectPath) || node.title || node.id.slice(0, 14);
|
|
423
|
+
const parts = [];
|
|
424
|
+
if (node.model) parts.push(`🧠 ${esc(node.model)}`);
|
|
425
|
+
const activeDot = isSessionActive(node) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
|
|
426
|
+
const subInfo = parts.length > 0 ? ` <span style="color:#6b7280;font-size:10px">${parts.join(' · ')}</span>` : '';
|
|
427
|
+
const agentCount = node.agents ? node.agents.filter(a => a.type === 'agent').length : 0;
|
|
428
|
+
const timeStr = formatTime(node.birthtimeMs);
|
|
429
|
+
const timeHtml = timeStr ? `<span style="margin-left:auto;font-size:10px;color:var(--dim);flex-shrink:0">${timeStr}</span>` : '';
|
|
430
|
+
return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
|
|
431
|
+
<div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
432
|
+
<div class="tree-node">
|
|
433
|
+
<span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} <span class="session-prefix" style="color:${idColor(node.colorRank)}" data-sid="${esc(node.id)}" onmouseenter="showSessionIdTip(this)" onmouseleave="hideSessionIdTip(this)">${esc(node.id.split('-')[0].toUpperCase())}</span> ${esc(displayName)}
|
|
434
|
+
${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
|
|
435
|
+
${subInfo}
|
|
436
|
+
${timeHtml}
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
<span class="tree-actions">
|
|
440
|
+
<button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
|
|
441
|
+
<button class="btn btn-icon danger" onclick="event.stopPropagation();selectIndex(${idx});removeSelectedSession()" data-tooltip="Remove">✕</button>
|
|
442
|
+
</span>
|
|
443
|
+
</div>`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (node.type === 'main' || node.type === 'agent') {
|
|
447
|
+
const icon = node.type === 'main' ? '💬' : '🤖';
|
|
448
|
+
const enabled = filters.get(node.sessionID + ':' + node.id);
|
|
449
|
+
const ctxKey = node.sessionID + ':' + node.id;
|
|
450
|
+
const ctx = contextData[ctxKey];
|
|
451
|
+
let ctxPct = '';
|
|
452
|
+
if (ctx && ctx.contextWindow > 0 && ctx.inputTokens > 0) {
|
|
453
|
+
const pct = Math.round(ctx.inputTokens / ctx.contextWindow * 100);
|
|
454
|
+
const cls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
|
|
455
|
+
if (showTokenCount) {
|
|
456
|
+
ctxPct = `<span class="ctx-pct ${cls}">${fmtTok(ctx.inputTokens)}</span>`;
|
|
457
|
+
} else {
|
|
458
|
+
ctxPct = `<span class="ctx-pct ${cls}">${pct}%</span>`;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const activeDot = ctx && (Date.now() - ctx.lastActivity < 120000) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
|
|
462
|
+
const actIcon = node.type === 'main' ? '🗣' : '⚡';
|
|
463
|
+
const actText = showActivity && (node.activityTool || node.activityDesc)
|
|
464
|
+
? (node.activityTool && node.activityDesc ? `${node.activityTool}: ${node.activityDesc}` : (node.activityTool || node.activityDesc))
|
|
465
|
+
: '';
|
|
466
|
+
const indent = treePrefix(node).replace(/[├└]──/, ' ');
|
|
467
|
+
const actPrefix = `<span class="tree-prefix">${indent}</span>`;
|
|
468
|
+
const activityHTML = actText
|
|
469
|
+
? `<div class="tree-activity">${actPrefix}<span class="act-text">${actIcon} ${esc(actText)}</span></div>`
|
|
470
|
+
: '';
|
|
471
|
+
return `<div class="tree-row${selClass ? ' selected' : ''}">
|
|
472
|
+
<div class="tree-content${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
473
|
+
<div class="tree-node">
|
|
474
|
+
<span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${icon} ${esc(node.name || '')}${node.type === 'agent' && node.id ? '<span class="tree-agent-id">(' + esc(node.id.slice(0, agentIdDisplayLen.get(node.sessionID + ':' + node.id) || 7)) + ')</span>' : ''}${ctxPct}
|
|
475
|
+
</div>
|
|
476
|
+
${activityHTML}
|
|
477
|
+
</div>
|
|
478
|
+
<span class="tree-actions">
|
|
479
|
+
<button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
|
|
480
|
+
<button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});toggleNodeVisibility(${idx})" data-tooltip="${enabled ? 'Hide' : 'Show'}">${enabled ? '👁' : '─'}</button>
|
|
481
|
+
</span>
|
|
482
|
+
</div>`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (node.type === 'task') {
|
|
486
|
+
const icon = node.isComplete ? '✓' : '⏳';
|
|
487
|
+
const taskIndent = treePrefix(node).replace(/[├└]──/, ' ');
|
|
488
|
+
const taskPrefix = `<span class="tree-prefix">${taskIndent}</span>`;
|
|
489
|
+
const descHTML = showActivity && node.description
|
|
490
|
+
? `<div class="tree-activity">${taskPrefix}<span class="act-text">📋 ${esc(node.description)}</span></div>`
|
|
491
|
+
: '';
|
|
492
|
+
return `<div class="tree-row${selClass ? ' selected' : ''}">
|
|
493
|
+
<div class="tree-content dim" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
494
|
+
<div class="tree-node">
|
|
495
|
+
<span class="tree-prefix">${treePrefix(node)}</span>${icon} ${esc(node.name || 'bg-task')}
|
|
496
|
+
</div>
|
|
497
|
+
${descHTML}
|
|
498
|
+
</div>
|
|
499
|
+
<span class="tree-actions">
|
|
500
|
+
<button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});loadBgTask(${idx})" data-tooltip="Load output">▶</button>
|
|
501
|
+
</span>
|
|
502
|
+
</div>`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return '';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderTree() {
|
|
509
|
+
if (treeNodes.length === 0) {
|
|
510
|
+
treeEl.innerHTML = '<div class="tree-node" style="padding:8px;color:var(--dim)">Waiting for sessions...</div>';
|
|
511
|
+
treeCursorInfo.textContent = '';
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const cursorChanged = treeCursor !== lastTreeCursor;
|
|
516
|
+
if (treeDirty) {
|
|
517
|
+
const parts = new Array(treeNodes.length);
|
|
518
|
+
for (let i = 0; i < treeNodes.length; i++) {
|
|
519
|
+
parts[i] = getNodeHTML(treeNodes[i], i);
|
|
520
|
+
}
|
|
521
|
+
treeEl.innerHTML = parts.join('');
|
|
522
|
+
treeDirty = false;
|
|
523
|
+
} else if (cursorChanged) {
|
|
524
|
+
const prevSel = treeEl.querySelector('.tree-row.selected');
|
|
525
|
+
if (prevSel) prevSel.classList.remove('selected');
|
|
526
|
+
const newContent = treeEl.querySelector('[data-idx="' + treeCursor + '"]');
|
|
527
|
+
if (newContent) {
|
|
528
|
+
const row = newContent.closest('.tree-row');
|
|
529
|
+
if (row) row.classList.add('selected');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
lastTreeCursor = treeCursor;
|
|
533
|
+
|
|
534
|
+
const sel = treeEl.querySelector('.tree-row.selected');
|
|
535
|
+
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
536
|
+
|
|
537
|
+
treeCursorInfo.textContent = `${treeCursor + 1}/${treeNodes.length}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function updateTreeDots() {
|
|
541
|
+
const dots = treeEl.querySelectorAll('.active-dot');
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
for (const dot of dots) {
|
|
544
|
+
const content = dot.closest('.tree-content');
|
|
545
|
+
if (!content) continue;
|
|
546
|
+
const idx = parseInt(content.getAttribute('data-idx'));
|
|
547
|
+
if (isNaN(idx)) continue;
|
|
548
|
+
const node = treeNodes[idx];
|
|
549
|
+
if (!node) continue;
|
|
550
|
+
let active = false;
|
|
551
|
+
if (node.type === 'session') {
|
|
552
|
+
active = isSessionActive(node);
|
|
553
|
+
} else if (node.type === 'main' || node.type === 'agent') {
|
|
554
|
+
const ctxKey = node.sessionID + ':' + node.id;
|
|
555
|
+
const ctx = contextData[ctxKey];
|
|
556
|
+
const threshold = node.type === 'main' ? 600000 : 180000;
|
|
557
|
+
active = ctx && (now - ctx.lastActivity < threshold);
|
|
558
|
+
}
|
|
559
|
+
const newCls = active ? 'active-dot on' : 'active-dot off';
|
|
560
|
+
const newHTML = active ? '🟢' : '⚪';
|
|
561
|
+
if (dot.className !== newCls) {
|
|
562
|
+
dot.className = newCls;
|
|
563
|
+
dot.innerHTML = newHTML;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function isSessionActive(session) {
|
|
569
|
+
if (!session) return false;
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
const mainCtx = contextData[session.id + ':'];
|
|
572
|
+
if (mainCtx && (now - mainCtx.lastActivity) < 600000) return true;
|
|
573
|
+
for (const a of session.agents) {
|
|
574
|
+
if (a.id === '') continue;
|
|
575
|
+
const ctx = contextData[session.id + ':' + a.id];
|
|
576
|
+
if (ctx && (now - ctx.lastActivity) < 180000) return true;
|
|
577
|
+
}
|
|
578
|
+
return (now - session.lastActivity) < 600000;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
582
|
+
// Stream rendering
|
|
583
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
584
|
+
|
|
585
|
+
function renderStream() {
|
|
586
|
+
if (visibleDirty) {
|
|
587
|
+
visibleItems = streamItems.filter(isItemVisible);
|
|
588
|
+
visibleDirty = false;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const visible = visibleItems;
|
|
592
|
+
const wasAutoScroll = autoScroll;
|
|
593
|
+
|
|
594
|
+
if (needsFullRender || renderedItemCount > visible.length) {
|
|
595
|
+
const lines = [];
|
|
596
|
+
for (const item of visible) {
|
|
597
|
+
for (const l of renderItem(item)) lines.push(l);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
let html;
|
|
601
|
+
if (lines.length > 0) {
|
|
602
|
+
html = lines.map(l => {
|
|
603
|
+
const sidAttr = l.sessionID ? ` data-session-id="${esc(l.sessionID)}"` : '';
|
|
604
|
+
if (l.html) return `<div class="${esc(l.cls)}"${sidAttr}>${l.text}</div>`;
|
|
605
|
+
return `<div class="${esc(l.cls)}"${sidAttr}>${esc(l.text)}</div>`;
|
|
606
|
+
}).join('\n');
|
|
607
|
+
} else if (streamItems.length > 0) {
|
|
608
|
+
html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
|
|
609
|
+
} else {
|
|
610
|
+
html = '<div style="color:#6b7280;padding:20px;text-align:center">Waiting for output...</div>';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
streamEl.innerHTML = html;
|
|
614
|
+
renderedItemCount = visible.length;
|
|
615
|
+
needsFullRender = false;
|
|
616
|
+
if (wasAutoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
|
|
617
|
+
} else {
|
|
618
|
+
for (let i = renderedItemCount; i < visible.length; i++) {
|
|
619
|
+
for (const l of renderItem(visible[i])) {
|
|
620
|
+
const div = document.createElement('div');
|
|
621
|
+
div.className = l.cls;
|
|
622
|
+
if (l.sessionID) div.dataset.sessionId = l.sessionID;
|
|
623
|
+
div.innerHTML = l.html ? l.text : esc(l.text);
|
|
624
|
+
streamEl.appendChild(div);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
renderedItemCount = visible.length;
|
|
628
|
+
if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const maxScroll = streamEl.scrollHeight - streamEl.clientHeight;
|
|
632
|
+
const pct = maxScroll > 0 ? Math.round(streamEl.scrollTop / maxScroll * 100) : 0;
|
|
633
|
+
document.getElementById('scroll-pos').textContent = Math.min(100, pct) + '%';
|
|
634
|
+
document.getElementById('item-count').textContent = streamItems.length + ' items';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function renderItem(item) {
|
|
638
|
+
const lines = [];
|
|
639
|
+
const isSub = !!item.agentID;
|
|
640
|
+
const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
|
|
641
|
+
const sep = ' » ';
|
|
642
|
+
const sid = item.sessionID || '';
|
|
643
|
+
|
|
644
|
+
if (item.type === 'turn_marker') {
|
|
645
|
+
return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──`, sessionID: sid }];
|
|
646
|
+
}
|
|
647
|
+
if (item.type === 'compact_marker') {
|
|
648
|
+
const label = item.content ? `compacted (${item.content})` : 'compacted';
|
|
649
|
+
return [{ cls: 'stream-line marker', text: `── ${label} ──`, sessionID: sid }];
|
|
650
|
+
}
|
|
651
|
+
if (item.type === 'pr_link') {
|
|
652
|
+
return [{ cls: 'stream-line marker', text: `── ${item.content} ──`, sessionID: sid }];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const agentName = item.agentName || 'Main';
|
|
656
|
+
const sForColor = sessionsMap.get(item.sessionID);
|
|
657
|
+
const prefixTag = `<span class="session-prefix" style="color:${idColor(sForColor ? sForColor.colorRank : 0)}">[${esc(item.sessionID.split('-')[0].toUpperCase())}]</span>`;
|
|
658
|
+
const agentIdTag = item.agentID ? `<span class="session-prefix" style="color:var(--dim)">(</span><span class="session-prefix" style="color:var(--magenta)">${esc(item.agentID.slice(0, agentIdDisplayLen.get(item.sessionID + ':' + item.agentID) || 7))}</span><span class="session-prefix" style="color:var(--dim)">)</span>` : '';
|
|
659
|
+
const agentLabel = prefixTag + agentIdTag + ' ' + esc(agentName);
|
|
660
|
+
const tsHtml = item.timestamp ? `<span class="timestamp">${fmtTimestamp(item.timestamp)}</span>` : '';
|
|
661
|
+
|
|
662
|
+
switch (item.type) {
|
|
663
|
+
case 'thinking':
|
|
664
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true, sessionID: sid });
|
|
665
|
+
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l, sessionID: sid });
|
|
666
|
+
break;
|
|
667
|
+
case 'tool_input':
|
|
668
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
669
|
+
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l, sessionID: sid });
|
|
670
|
+
break;
|
|
671
|
+
case 'tool_output': {
|
|
672
|
+
let tn = '';
|
|
673
|
+
if (item.toolID) {
|
|
674
|
+
tn = toolNameMap.get(item.toolID) || '';
|
|
675
|
+
}
|
|
676
|
+
let label = tn ? `📤 ${tn} result` : '📤 Output';
|
|
677
|
+
if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
|
|
678
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
679
|
+
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l, sessionID: sid });
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
case 'text':
|
|
683
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true, sessionID: sid });
|
|
684
|
+
lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true, sessionID: sid });
|
|
685
|
+
break;
|
|
686
|
+
case 'hook_output': {
|
|
687
|
+
let label = '🪝 Hook';
|
|
688
|
+
if (item.toolName) label += ' ' + item.toolName;
|
|
689
|
+
if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
|
|
690
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
691
|
+
if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true, sessionID: sid });
|
|
692
|
+
if (item.hookContent) {
|
|
693
|
+
for (const l of truncContent(item.hookContent)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">content:</span> ${esc(l)}`, html: true, sessionID: sid });
|
|
694
|
+
}
|
|
695
|
+
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">stdout:</span> ${esc(l)}`, html: true, sessionID: sid });
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case 'diagnostics': {
|
|
699
|
+
let label = '⚠ Diagnostics';
|
|
700
|
+
if (item.toolName) label += ' ' + item.toolName;
|
|
701
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
702
|
+
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l, sessionID: sid });
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
case 'debug': {
|
|
706
|
+
let label = '🔍 Debug';
|
|
707
|
+
if (item.toolName) label += ' ' + item.toolName;
|
|
708
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
709
|
+
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
case 'user_text':
|
|
713
|
+
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}👤 User Prompt</span>${tsHtml}`, html: true, sessionID: sid });
|
|
714
|
+
lines.push({ cls: 'stream-line user-prompt-block md-content', text: mdRender(item.content), html: true, sessionID: sid });
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
|
|
719
|
+
return lines;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function truncContent(content) {
|
|
723
|
+
const raw = content.split('\n');
|
|
724
|
+
return raw.length > MAX_LINES ? raw.slice(0, MAX_LINES).concat([`... (${raw.length - MAX_LINES} more lines)`]) : raw;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
728
|
+
// Stream button updates
|
|
729
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
730
|
+
|
|
731
|
+
function updateStreamButtons() {
|
|
732
|
+
document.getElementById('btn-thinking').classList.toggle('on', showThinking);
|
|
733
|
+
document.getElementById('btn-tool-input').classList.toggle('on', showToolInput);
|
|
734
|
+
document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
|
|
735
|
+
document.getElementById('btn-text').classList.toggle('on', showText);
|
|
736
|
+
document.getElementById('btn-hook').classList.toggle('on', showHook);
|
|
737
|
+
document.getElementById('btn-user-prompt').classList.toggle('on', showUserPrompt);
|
|
738
|
+
document.getElementById('btn-activity').classList.toggle('on', showActivity);
|
|
739
|
+
const btnTokenDisplay = document.getElementById('btn-token-display');
|
|
740
|
+
btnTokenDisplay.classList.toggle('on', true);
|
|
741
|
+
btnTokenDisplay.textContent = showTokenCount ? 'T' : '%';
|
|
742
|
+
btnTokenDisplay.setAttribute('data-tooltip', showTokenCount ? '上下文:Token数 ↔ 百分比切换' : '上下文:百分比 ↔ Token数切换');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
746
|
+
// Session ID tooltip
|
|
747
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
748
|
+
|
|
749
|
+
let sessionIdTipTimer = null;
|
|
750
|
+
let sessionIdTipEl = null;
|
|
751
|
+
|
|
752
|
+
function showSessionIdTip(el) {
|
|
753
|
+
hideAllSessionIdTips();
|
|
754
|
+
const sid = el.getAttribute('data-sid');
|
|
755
|
+
if (!sid) return;
|
|
756
|
+
sessionIdTipTimer = setTimeout(() => {
|
|
757
|
+
const rect = el.getBoundingClientRect();
|
|
758
|
+
const tip = document.createElement('div');
|
|
759
|
+
tip.className = 'session-id-tip';
|
|
760
|
+
tip.style.top = (rect.bottom + 4) + 'px';
|
|
761
|
+
tip.style.left = rect.left + 'px';
|
|
762
|
+
tip.innerHTML = `<button class="tip-copy-btn" onclick="event.stopPropagation();copySessionId(this)">Copy</button><code>${esc(sid)}</code>`;
|
|
763
|
+
tip.onmouseenter = () => clearTimeout(sessionIdTipTimer);
|
|
764
|
+
tip.onmouseleave = () => { hideAllSessionIdTips(); };
|
|
765
|
+
document.body.appendChild(tip);
|
|
766
|
+
sessionIdTipEl = tip;
|
|
767
|
+
el._tip = tip;
|
|
768
|
+
}, 300);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function hideSessionIdTip(el) {
|
|
772
|
+
sessionIdTipTimer = setTimeout(() => {
|
|
773
|
+
if (el._tip) { el._tip.remove(); el._tip = null; }
|
|
774
|
+
sessionIdTipEl = null;
|
|
775
|
+
}, 200);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function hideAllSessionIdTips() {
|
|
779
|
+
clearTimeout(sessionIdTipTimer);
|
|
780
|
+
document.querySelectorAll('.session-id-tip').forEach(t => t.remove());
|
|
781
|
+
sessionIdTipEl = null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function copySessionId(btn) {
|
|
785
|
+
const code = btn.parentElement.querySelector('code');
|
|
786
|
+
if (!code) return;
|
|
787
|
+
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
788
|
+
btn.textContent = 'Copied!';
|
|
789
|
+
setTimeout(() => { btn.closest('.session-id-tip')?.remove(); }, 800);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
794
|
+
// Actions
|
|
795
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
796
|
+
|
|
797
|
+
function selectIndex(idx) {
|
|
798
|
+
if (idx >= 0 && idx < treeNodes.length) treeCursor = idx;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function treeClick(idx) {
|
|
802
|
+
selectIndex(idx);
|
|
803
|
+
const node = treeNodes[idx];
|
|
804
|
+
if (!node) return;
|
|
805
|
+
if (node.type === 'date-folder') {
|
|
806
|
+
node.collapsed = !node.collapsed;
|
|
807
|
+
folderCollapsed[node.date] = node.collapsed;
|
|
808
|
+
rebuildNodes();
|
|
809
|
+
} else if (node.type === 'session') {
|
|
810
|
+
const session = sessions.find(s => s.id === node.id);
|
|
811
|
+
if (session) {
|
|
812
|
+
session.collapsed = !session.collapsed;
|
|
813
|
+
if (!session.collapsed) session.pinned = true;
|
|
814
|
+
}
|
|
815
|
+
rebuildNodes();
|
|
816
|
+
} else if (node.type === 'main' || node.type === 'agent') {
|
|
817
|
+
toggleNodeVisibility(idx);
|
|
818
|
+
return;
|
|
819
|
+
} else if (node.type === 'task') {
|
|
820
|
+
loadBgTask(idx);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
renderAll();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function toggleNodeVisibility(idx) {
|
|
827
|
+
const node = treeNodes[idx];
|
|
828
|
+
if (!node) return;
|
|
829
|
+
const key = node.sessionID + ':' + node.id;
|
|
830
|
+
const wasEnabled = filters.get(key);
|
|
831
|
+
filters.set(key, !wasEnabled);
|
|
832
|
+
if (wasEnabled) visibleFilterCount--;
|
|
833
|
+
else visibleFilterCount++;
|
|
834
|
+
renderAll();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function loadBgTask(idx) {
|
|
838
|
+
const node = treeNodes[idx];
|
|
839
|
+
if (!node || node.type !== 'task') return;
|
|
840
|
+
if (!node.outputPath) return;
|
|
841
|
+
|
|
842
|
+
fetch(`/api/task-output?path=${encodeURIComponent(node.outputPath)}`)
|
|
843
|
+
.then(r => r.json())
|
|
844
|
+
.then(data => {
|
|
845
|
+
const content = data.content || `[Error: ${data.error || 'unknown'}]`;
|
|
846
|
+
const statusIcon = node.isComplete ? '✓' : '⏳';
|
|
847
|
+
streamItems.push({
|
|
848
|
+
type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
|
|
849
|
+
agentName: '', toolName: `${statusIcon} ${node.name || 'bg-task'}`,
|
|
850
|
+
content: content,
|
|
851
|
+
timestamp: new Date(), toolID: '', durationMs: 0,
|
|
852
|
+
inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
|
|
853
|
+
});
|
|
854
|
+
renderAll();
|
|
855
|
+
})
|
|
856
|
+
.catch(err => {
|
|
857
|
+
streamItems.push({
|
|
858
|
+
type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
|
|
859
|
+
agentName: '', toolName: `⏳ ${node.name || 'bg-task'}`,
|
|
860
|
+
content: `[Failed to load: ${err.message}]`,
|
|
861
|
+
timestamp: new Date(), toolID: '', durationMs: 0,
|
|
862
|
+
inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
|
|
863
|
+
});
|
|
864
|
+
renderAll();
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function soloSelected() {
|
|
869
|
+
const node = treeNodes[treeCursor];
|
|
870
|
+
if (!node || node.type === 'task') return;
|
|
871
|
+
|
|
872
|
+
if (isSoloed(node)) {
|
|
873
|
+
updateFilters();
|
|
874
|
+
} else {
|
|
875
|
+
filters.clear();
|
|
876
|
+
visibleFilterCount = 0;
|
|
877
|
+
if (node.type === 'session') {
|
|
878
|
+
const session = sessions.find(s => s.id === node.id);
|
|
879
|
+
if (session && session.collapsed) {
|
|
880
|
+
session.collapsed = false;
|
|
881
|
+
session.pinned = true;
|
|
882
|
+
rebuildNodes();
|
|
883
|
+
}
|
|
884
|
+
for (const a of node.agents) {
|
|
885
|
+
filters.set(node.id + ':' + a.id, true);
|
|
886
|
+
visibleFilterCount++;
|
|
887
|
+
}
|
|
888
|
+
} else if (node.type === 'main' || node.type === 'agent') {
|
|
889
|
+
filters.set(node.sessionID + ':' + node.id, true);
|
|
890
|
+
visibleFilterCount = 1;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
renderAll();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function isSoloed(node) {
|
|
897
|
+
if (node.type === 'session') {
|
|
898
|
+
if (visibleFilterCount !== node.agents.length) return false;
|
|
899
|
+
for (const a of node.agents) {
|
|
900
|
+
if (!filters.get(node.id + ':' + a.id)) return false;
|
|
901
|
+
}
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
if (node.type === 'main' || node.type === 'agent') {
|
|
905
|
+
const key = node.sessionID + ':' + node.id;
|
|
906
|
+
return visibleFilterCount === 1 && filters.get(key);
|
|
907
|
+
}
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function removeSelectedSession() {
|
|
912
|
+
const node = treeNodes[treeCursor];
|
|
913
|
+
if (!node) return;
|
|
914
|
+
let sid;
|
|
915
|
+
if (node.type === 'session') sid = node.id;
|
|
916
|
+
else sid = node.sessionID;
|
|
917
|
+
if (!sid) return;
|
|
918
|
+
if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
|
|
919
|
+
hiddenSessionIDs.add(sid);
|
|
920
|
+
_saveHiddenSessions();
|
|
921
|
+
const idx = sessions.findIndex(s => s.id === sid);
|
|
922
|
+
if (idx >= 0) {
|
|
923
|
+
sessions.splice(idx, 1);
|
|
924
|
+
sessionsMap.delete(sid);
|
|
925
|
+
}
|
|
926
|
+
sendCmd('removeSession', { sessionID: sid });
|
|
927
|
+
updateFilters();
|
|
928
|
+
rebuildNodes();
|
|
929
|
+
renderAll();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
933
|
+
// Toggles
|
|
934
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
935
|
+
|
|
936
|
+
function toggleThinking() { showThinking = !showThinking; needsFullRender = true; visibleDirty = true; renderStream(); refreshButtons(); }
|
|
937
|
+
function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true; visibleDirty = true; renderStream(); refreshButtons(); }
|
|
938
|
+
function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true; visibleDirty = true; renderStream(); refreshButtons(); }
|
|
939
|
+
function toggleText() { showText = !showText; needsFullRender = true; visibleDirty = true; renderStream(); refreshButtons(); }
|
|
940
|
+
function toggleHook() { showHook = !showHook; needsFullRender = true; visibleDirty = true; renderStream(); refreshButtons(); }
|
|
941
|
+
function toggleUserPrompt() { showUserPrompt = !showUserPrompt; needsFullRender = true; visibleDirty = true; renderStream(); refreshButtons(); }
|
|
942
|
+
function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
|
|
943
|
+
function toggleTokenDisplay() { showTokenCount = !showTokenCount; treeDirty = true; scheduleRender(); refreshButtons(); }
|
|
944
|
+
|
|
945
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
946
|
+
// Scroll & Tree panel resize
|
|
947
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
948
|
+
|
|
949
|
+
function scrollToTop() { streamEl.scrollTop = 0; autoScroll = false; renderAll(); }
|
|
950
|
+
function scrollUp() { streamEl.scrollTop -= 80; autoScroll = false; renderAll(); }
|
|
951
|
+
function scrollDown() { streamEl.scrollTop += 80; if (autoScroll) autoScroll = false; renderAll(); }
|
|
952
|
+
function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; autoScroll = true; renderAll(); }
|
|
953
|
+
|
|
954
|
+
function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
|
|
955
|
+
function toggleTree() {
|
|
956
|
+
const showTree = !document.getElementById('tree-panel').classList.contains('hidden');
|
|
957
|
+
document.getElementById('tree-panel').classList.toggle('hidden', showTree);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function setupTreeResize() {
|
|
961
|
+
const panel = document.getElementById('tree-panel');
|
|
962
|
+
const handle = document.getElementById('tree-resize-handle');
|
|
963
|
+
let startX, startWidth;
|
|
964
|
+
|
|
965
|
+
handle.addEventListener('mousedown', (e) => {
|
|
966
|
+
e.preventDefault();
|
|
967
|
+
startX = e.clientX;
|
|
968
|
+
startWidth = panel.offsetWidth;
|
|
969
|
+
handle.classList.add('active');
|
|
970
|
+
document.body.style.cursor = 'col-resize';
|
|
971
|
+
document.body.style.userSelect = 'none';
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
document.addEventListener('mousemove', (e) => {
|
|
975
|
+
if (!handle.classList.contains('active')) return;
|
|
976
|
+
const dx = e.clientX - startX;
|
|
977
|
+
const newWidth = startWidth + dx;
|
|
978
|
+
if (newWidth >= 180 && newWidth <= window.innerWidth * 0.6) {
|
|
979
|
+
panel.style.width = newWidth + 'px';
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
document.addEventListener('mouseup', () => {
|
|
984
|
+
handle.classList.remove('active');
|
|
985
|
+
document.body.style.cursor = '';
|
|
986
|
+
document.body.style.userSelect = '';
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function setupScrollDetection() {
|
|
991
|
+
streamEl.addEventListener('scroll', () => {
|
|
992
|
+
const atBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
|
|
993
|
+
if (atBottom && !autoScroll) autoScroll = true;
|
|
994
|
+
if (!atBottom && autoScroll) autoScroll = false;
|
|
995
|
+
refreshButtons();
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1000
|
+
// Auto-collapse
|
|
1001
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1002
|
+
|
|
1003
|
+
function applyCollapsePolicy(duration) {
|
|
1004
|
+
collapseAfter = duration;
|
|
1005
|
+
if (collapseTimer) clearInterval(collapseTimer);
|
|
1006
|
+
if (duration <= 0) return;
|
|
1007
|
+
|
|
1008
|
+
collapseTimer = setInterval(() => {
|
|
1009
|
+
if (!collapseAfter) return;
|
|
1010
|
+
const now = Date.now();
|
|
1011
|
+
let changed = false;
|
|
1012
|
+
for (const s of sessions) {
|
|
1013
|
+
if (s.pinned || s.collapsed) continue;
|
|
1014
|
+
if ((now - s.lastActivity) > collapseAfter) {
|
|
1015
|
+
s.collapsed = true;
|
|
1016
|
+
changed = true;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (changed) {
|
|
1020
|
+
scheduleRebuildNodes();
|
|
1021
|
+
renderAll();
|
|
1022
|
+
}
|
|
1023
|
+
}, 5000);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function startActiveRefresh() {
|
|
1027
|
+
if (activeRefreshTimer) clearInterval(activeRefreshTimer);
|
|
1028
|
+
activeRefreshTimer = setInterval(() => {
|
|
1029
|
+
updateTreeDots();
|
|
1030
|
+
refreshButtons();
|
|
1031
|
+
}, 15000);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1035
|
+
// Filters & Render coordination
|
|
1036
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1037
|
+
|
|
1038
|
+
function updateFilters() {
|
|
1039
|
+
filters.clear();
|
|
1040
|
+
visibleFilterCount = 0;
|
|
1041
|
+
for (const s of sessions) {
|
|
1042
|
+
for (const a of s.agents) {
|
|
1043
|
+
filters.set(s.id + ':' + a.id, true);
|
|
1044
|
+
visibleFilterCount++;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function renderAll() {
|
|
1050
|
+
needsFullRender = true;
|
|
1051
|
+
visibleDirty = true;
|
|
1052
|
+
renderTree();
|
|
1053
|
+
renderStream();
|
|
1054
|
+
refreshButtons();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function scheduleRebuildNodes() {
|
|
1058
|
+
treeNeedsRebuild = true;
|
|
1059
|
+
scheduleRender();
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function scheduleRender() {
|
|
1063
|
+
if (!renderPending) {
|
|
1064
|
+
renderPending = true;
|
|
1065
|
+
requestAnimationFrame(() => {
|
|
1066
|
+
renderPending = false;
|
|
1067
|
+
if (treeNeedsRebuild) {
|
|
1068
|
+
treeNeedsRebuild = false;
|
|
1069
|
+
rebuildNodes();
|
|
1070
|
+
}
|
|
1071
|
+
renderTree();
|
|
1072
|
+
renderStream();
|
|
1073
|
+
refreshButtons();
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|