claude-code-watch 0.0.1

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.
@@ -0,0 +1,1206 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>claude-watch</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+
11
+ :root {
12
+ --bg: #111827;
13
+ --bg2: #1f2937;
14
+ --bg3: #374151;
15
+ --border: #4b5563;
16
+ --text: #d1d5db;
17
+ --dim: #6b7280;
18
+ --white: #f9fafb;
19
+ --purple: #7c3aed;
20
+ --purple2: #5b21b6;
21
+ --blue: #3b82f6;
22
+ --magenta: #c084fc;
23
+ --yellow: #fbbf24;
24
+ --yellow2: #92400e;
25
+ --green: #34d399;
26
+ --cyan: #22d3ee;
27
+ --red: #f87171;
28
+ --red2: #dc2626;
29
+ --gray: #9ca3af;
30
+ --orange: #fb923c;
31
+ }
32
+
33
+ body {
34
+ background: var(--bg);
35
+ color: var(--text);
36
+ font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
37
+ font-size: 13px;
38
+ line-height: 1.5;
39
+ height: 100vh;
40
+ display: flex;
41
+ flex-direction: column;
42
+ overflow: hidden;
43
+ }
44
+
45
+ /* ── Header ── */
46
+ #header {
47
+ height: 32px;
48
+ background: var(--bg3);
49
+ display: flex;
50
+ align-items: center;
51
+ padding: 0 12px;
52
+ gap: 8px;
53
+ font-size: 12px;
54
+ color: var(--white);
55
+ flex-shrink: 0;
56
+ user-select: none;
57
+ }
58
+ #header .sep { color: var(--dim); margin: 0 4px; }
59
+ #header .auto { margin-left: auto; display: flex; gap: 4px; align-items: center; }
60
+
61
+ .btn {
62
+ position: relative;
63
+ display: inline-flex; align-items: center; gap: 3px;
64
+ padding: 2px 8px; border-radius: 4px; border: 1px solid var(--border);
65
+ background: var(--bg2); color: var(--text); cursor: pointer;
66
+ font-family: inherit; font-size: 11px; line-height: 1.5;
67
+ white-space: nowrap; user-select: none; transition: all 0.15s;
68
+ }
69
+ .btn[data-tooltip]:hover::after {
70
+ content: attr(data-tooltip);
71
+ position: absolute;
72
+ bottom: calc(100% + 5px);
73
+ left: 50%;
74
+ transform: translateX(-50%);
75
+ background: var(--bg2); color: var(--white);
76
+ padding: 2px 8px; border-radius: 4px;
77
+ font-size: 10px; white-space: nowrap; z-index: 9999;
78
+ pointer-events: none; border: 1px solid var(--border);
79
+ font-family: inherit; line-height: 1.4;
80
+ }
81
+ .btn:hover { background: var(--bg3); border-color: var(--dim); }
82
+ .btn.on { background: var(--purple); border-color: var(--purple); color: var(--white); }
83
+ .btn.on:hover { background: var(--purple2); }
84
+ .btn.on:hover::after { background: var(--purple2); }
85
+ .btn.danger { border-color: var(--red2); color: var(--red); }
86
+ .btn.danger:hover { background: var(--red2); color: var(--white); }
87
+ .btn.accent { border-color: var(--yellow2); color: var(--yellow); }
88
+ .btn:disabled { opacity: 0.3; cursor: not-allowed; }
89
+ .btn-icon { padding: 2px 6px; min-width: 28px; justify-content: center; }
90
+
91
+ /* ── Main area ── */
92
+ #main {
93
+ display: flex;
94
+ flex: 1;
95
+ overflow: hidden;
96
+ }
97
+
98
+ /* ── Tree panel ── */
99
+ #tree-panel {
100
+ width: 30%; min-width: 180px; max-width: 60%;
101
+ border-right: 1px solid var(--border);
102
+ background: var(--bg2);
103
+ overflow: hidden;
104
+ display: flex; flex-direction: column;
105
+ flex-shrink: 0;
106
+ position: relative;
107
+ }
108
+ #tree-panel.hidden { display: none; }
109
+
110
+ #tree-resize-handle {
111
+ position: absolute; right: -3px; top: 0; bottom: 0;
112
+ width: 6px; cursor: col-resize; z-index: 10;
113
+ }
114
+ #tree-resize-handle:hover, #tree-resize-handle.active { background: var(--purple); }
115
+
116
+ #tree-toolbar {
117
+ display: flex; gap: 4px; padding: 4px 6px;
118
+ border-bottom: 1px solid var(--border);
119
+ flex-shrink: 0; flex-wrap: wrap;
120
+ }
121
+ #tree-content {
122
+ flex: 1; overflow-y: auto; overflow-x: hidden; padding: 4px 0;
123
+ }
124
+
125
+ /* ── Tree node styles ── */
126
+ .tree-row {
127
+ display: flex; align-items: center;
128
+ }
129
+ .tree-node {
130
+ flex: 1; display: flex; align-items: center;
131
+ padding: 3px 2px 3px 0;
132
+ cursor: pointer; white-space: nowrap; gap: 4px;
133
+ min-width: 0; overflow: hidden;
134
+ }
135
+ .tree-node:hover { background: rgba(255,255,255,0.05); }
136
+ .tree-node.selected { background: rgba(124,58,237,0.3); }
137
+ .tree-node.dim { opacity: 0.4; }
138
+ .tree-prefix { color: var(--dim); font-size: 12px; flex-shrink: 0; letter-spacing: 0; font-family: monospace; }
139
+ .tree-node .ctx-pct { font-size: 10px; margin-left: 4px; flex-shrink: 0; }
140
+ .tree-node .ctx-pct.warn { color: var(--yellow); }
141
+ .tree-node .ctx-pct.danger { color: var(--red); }
142
+ .tree-node .active-dot { flex-shrink: 0; }
143
+ .tree-node .active-dot.on { color: var(--green); text-shadow: 0 0 6px var(--green); }
144
+ .tree-node .active-dot.off { color: #555; opacity: 1; }
145
+
146
+ .tree-actions { display: none; gap: 2px; padding-right: 4px; }
147
+ .tree-row:hover .tree-actions { display: flex; }
148
+ .tree-row.selected>.tree-actions { display: flex; }
149
+
150
+ /* ── Stream panel ── */
151
+ #stream-panel-wrap {
152
+ flex: 1; display: flex; flex-direction: column; overflow: hidden;
153
+ }
154
+ #stream-toolbar {
155
+ display: flex; gap: 4px; padding: 4px 8px;
156
+ border-bottom: 1px solid var(--border);
157
+ background: var(--bg); flex-shrink: 0;
158
+ }
159
+ #stream-panel {
160
+ flex: 1; overflow-y: auto; padding: 8px 12px;
161
+ font-size: 12px;
162
+ }
163
+
164
+ /* ── Stream lines ── */
165
+ .stream-line { white-space: pre-wrap; word-break: break-all; }
166
+ .stream-line.thinking { color: var(--magenta); }
167
+ .stream-line.tool-input { color: var(--yellow); }
168
+ .stream-line.tool-output { color: var(--green); }
169
+ .stream-line.text { color: var(--text); }
170
+ .stream-line.hook { color: var(--cyan); }
171
+ .stream-line.diag { color: var(--red); }
172
+ .stream-line.debug { color: var(--gray); }
173
+ .stream-line.marker { color: var(--dim); }
174
+ .stream-line.agent-tag { font-weight: bold; }
175
+ .stream-line.agent-main { color: var(--blue); }
176
+ .stream-line.agent-sub { color: var(--magenta); }
177
+ .stream-line.separator { color: var(--dim); }
178
+
179
+ /* ── Footer ── */
180
+ #footer {
181
+ height: 28px; background: var(--bg2);
182
+ border-top: 1px solid var(--border);
183
+ display: flex; align-items: center;
184
+ padding: 0 8px; gap: 6px;
185
+ font-size: 11px; flex-shrink: 0; flex-wrap: wrap;
186
+ }
187
+ #footer .sep { color: var(--dim); margin: 0 2px; }
188
+
189
+ /* ── Scrollbar ── */
190
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
191
+ ::-webkit-scrollbar-track { background: transparent; }
192
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
193
+
194
+ /* ── Focus ring ── */
195
+ #stream-panel:focus { outline: none; }
196
+
197
+ /* ── Markdown & code blocks ── */
198
+ .md-content { line-height: 1.6; color: var(--text); }
199
+ .md-content p { margin: 4px 0; }
200
+ .md-content ul, .md-content ol { padding-left: 20px; margin: 4px 0; }
201
+ .md-content li { margin: 2px 0; }
202
+ .md-content strong { color: var(--white); }
203
+ .md-content a { color: var(--blue); text-decoration: underline; }
204
+ .md-content h1, .md-content h2, .md-content h3, .md-content h4,
205
+ .md-content h5, .md-content h6 { color: var(--white); margin: 8px 0 4px; font-size: inherit; font-weight: bold; }
206
+ .md-content blockquote { border-left: 3px solid var(--purple); padding-left: 12px; color: var(--dim); margin: 4px 0; }
207
+ .md-content hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
208
+ .md-content table { border-collapse: collapse; margin: 4px 0; width: 100%; }
209
+ .md-content th, .md-content td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
210
+ .md-content th { background: var(--bg3); color: var(--white); }
211
+ .md-content img { max-width: 100%; }
212
+
213
+ .code-block-wrapper { margin: 8px 0; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
214
+ .code-block-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: var(--bg3); font-size: 11px; color: var(--dim); }
215
+ .code-block-header .lang-tag { color: var(--blue); font-weight: bold; }
216
+ .code-block-wrapper pre { margin: 0; padding: 12px; overflow-x: auto; font-size: 12px; line-height: 1.5; }
217
+ .code-block-wrapper pre code { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; font-size: 12px; }
218
+
219
+ /* Override highlight.js background to match our theme */
220
+ .hljs { background: #0d1117 !important; }
221
+ </style>
222
+ </head>
223
+ <body>
224
+
225
+ <div id="header">
226
+ <button class="btn on" id="btn-thinking" onclick="toggleThinking()" data-tooltip="Toggle thinking">🧠 Thinking</button>
227
+ <button class="btn on" id="btn-tool-input" onclick="toggleToolInput()" data-tooltip="Toggle tool input">🔧 Tools</button>
228
+ <button class="btn on" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
229
+ <button class="btn on" id="btn-text" onclick="toggleText()" data-tooltip="Toggle text responses">💬 Text</button>
230
+ <span class="sep">│</span>
231
+ <button class="btn on" id="btn-autoscroll" onclick="toggleAutoScroll()" data-tooltip="Auto-scroll">⇣ Auto</button>
232
+ <button class="btn" id="btn-tree-toggle" onclick="toggleTree()" data-tooltip="Toggle tree panel">📂 Tree</button>
233
+ <span class="sep">│</span>
234
+ <span id="session-info">Connecting...</span>
235
+ <div class="auto">
236
+ <button class="btn on" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
237
+ <span class="sep">│</span>
238
+ <span id="token-info"></span>
239
+ </div>
240
+ </div>
241
+
242
+ <div id="main">
243
+ <div id="tree-panel">
244
+ <div id="tree-resize-handle"></div>
245
+ <div id="tree-toolbar">
246
+ <button class="btn btn-icon" onclick="selectAll()" data-tooltip="Show all sessions/agents">⊞</button>
247
+ <button class="btn btn-icon accent" onclick="soloSelected()" data-tooltip="Solo selected">⊙</button>
248
+ <button class="btn btn-icon danger" onclick="removeSelectedSession()" data-tooltip="Remove session">✕</button>
249
+ <span style="flex:1"></span>
250
+ <span id="tree-cursor-info" style="font-size:10px;color:var(--dim)"></span>
251
+ </div>
252
+ <div id="tree-content"></div>
253
+ </div>
254
+ <div id="stream-panel-wrap">
255
+ <div id="stream-toolbar">
256
+ <button class="btn btn-icon" onclick="scrollToTop()" data-tooltip="Top">⏫</button>
257
+ <button class="btn btn-icon" onclick="scrollUp()" data-tooltip="Up">↑</button>
258
+ <button class="btn btn-icon" onclick="scrollDown()" data-tooltip="Down">↓</button>
259
+ <button class="btn btn-icon" onclick="scrollToBottom()" data-tooltip="Bottom">⏬</button>
260
+ </div>
261
+ <div id="stream-panel" tabindex="0"></div>
262
+ </div>
263
+ </div>
264
+
265
+ <div id="footer">
266
+ <span id="scroll-pos">0%</span>
267
+ <span class="sep">│</span>
268
+ <span id="item-count">0 items</span>
269
+ <span class="sep">│</span>
270
+ </div>
271
+
272
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
273
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
274
+ <script>
275
+ // ══════════════════════════════════════════════════════════════════════════════
276
+ // DOM refs
277
+ // ══════════════════════════════════════════════════════════════════════════════
278
+
279
+ const streamEl = document.getElementById('stream-panel');
280
+ const treeEl = document.getElementById('tree-content');
281
+ const sessionInfo = document.getElementById('session-info');
282
+ const tokenInfo = document.getElementById('token-info');
283
+ const treeCursorInfo = document.getElementById('tree-cursor-info');
284
+
285
+ // ══════════════════════════════════════════════════════════════════════════════
286
+ // State
287
+ // ══════════════════════════════════════════════════════════════════════════════
288
+
289
+ let ws = null;
290
+ let reconnectTimer = null;
291
+ let reconnectDelay = 1000;
292
+ const MaxReconnectDelay = 30000;
293
+ let showTree = true;
294
+ let autoScroll = true;
295
+ let lastMsgTime = 0;
296
+ let staleCheckTimer = null;
297
+
298
+ let sessions = [];
299
+ let treeNodes = [];
300
+ let treeCursor = 0;
301
+ let streamItems = [];
302
+ let seenToolIDs = new Set();
303
+ let toolNameMap = new Map(); // toolID -> toolName
304
+ let filters = new Map();
305
+
306
+ let showThinking = true;
307
+ let showToolInput = true;
308
+ let showToolOutput = true;
309
+ let showText = true;
310
+ let autoDiscovery = true;
311
+
312
+ let renderPending = false;
313
+
314
+ let totalInput = 0, totalOutput = 0, totalCacheCreate = 0, totalCacheRead = 0;
315
+ let contextData = {};
316
+
317
+ let collapseAfter = 0;
318
+ let collapseTimer = null;
319
+ let activeRefreshTimer = null;
320
+
321
+ const MAX_ITEMS = 1000;
322
+ const MAX_LINES = 50;
323
+ let renderedItemCount = 0;
324
+ let needsFullRender = true;
325
+
326
+ // ══════════════════════════════════════════════════════════════════════════════
327
+ // Markdown renderer (marked + highlight.js)
328
+ // ══════════════════════════════════════════════════════════════════════════════
329
+
330
+ const mdRenderer = new marked.Renderer();
331
+ mdRenderer.code = function (code, lang) {
332
+ let highlighted;
333
+ if (lang && hljs.getLanguage(lang)) {
334
+ try {
335
+ highlighted = hljs.highlight(code, { language: lang }).value;
336
+ } catch {
337
+ highlighted = hljs.highlightAuto(code).value;
338
+ }
339
+ } else {
340
+ highlighted = hljs.highlightAuto(code).value;
341
+ }
342
+ const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
343
+ return `<div class="code-block-wrapper">
344
+ <div class="code-block-header">${langTag}<span></span></div>
345
+ <pre><code>${highlighted}</code></pre>
346
+ </div>`;
347
+ };
348
+ marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
349
+
350
+ function mdRender(text) {
351
+ try {
352
+ return marked.parse(text);
353
+ } catch {
354
+ return esc(text);
355
+ }
356
+ }
357
+
358
+ // ══════════════════════════════════════════════════════════════════════════════
359
+ // WebSocket
360
+ // ══════════════════════════════════════════════════════════════════════════════
361
+
362
+ function connect() {
363
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
364
+ ws = new WebSocket(`${proto}//${location.host}`);
365
+
366
+ ws.onopen = () => {
367
+ sessionInfo.textContent = 'Connected';
368
+ lastMsgTime = Date.now();
369
+ reconnectDelay = 1000;
370
+ startStaleCheck();
371
+ startActiveRefresh();
372
+ };
373
+ ws.onclose = () => {
374
+ sessionInfo.textContent = 'Disconnected, reconnecting...';
375
+ stopStaleCheck();
376
+ if (activeRefreshTimer) { clearInterval(activeRefreshTimer); activeRefreshTimer = null; }
377
+ reconnectTimer = setTimeout(connect, reconnectDelay);
378
+ reconnectDelay = Math.min(reconnectDelay * 2, MaxReconnectDelay);
379
+ };
380
+ ws.onerror = () => {};
381
+
382
+ ws.onmessage = (e) => {
383
+ lastMsgTime = Date.now();
384
+ let msg;
385
+ try { msg = JSON.parse(e.data); } catch { return; }
386
+ handleMessage(msg);
387
+ };
388
+ }
389
+
390
+ function startStaleCheck() {
391
+ if (staleCheckTimer) clearInterval(staleCheckTimer);
392
+ staleCheckTimer = setInterval(() => {
393
+ if (Date.now() - lastMsgTime > 45000) {
394
+ sessionInfo.textContent = 'Stale connection, reconnecting...';
395
+ stopStaleCheck();
396
+ try { ws.close(); } catch {}
397
+ // onclose handler will handle reconnect — no separate timer needed
398
+ }
399
+ }, 10000);
400
+ }
401
+
402
+ function stopStaleCheck() {
403
+ if (staleCheckTimer) { clearInterval(staleCheckTimer); staleCheckTimer = null; }
404
+ }
405
+
406
+ function handleMessage(msg) {
407
+ switch (msg.type) {
408
+ case 'snapshot': handleSnapshot(msg.payload); break;
409
+ case 'itemBatch': handleItemBatch(msg.payload); break;
410
+ case 'item': handleItem(msg.payload); break;
411
+ case 'newSession': handleNewSession(msg.payload); break;
412
+ case 'newAgent': handleNewAgent(msg.payload); break;
413
+ case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
414
+ case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
415
+ case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
416
+ case 'context': contextData = msg.payload; scheduleRender(); break;
417
+ case 'config':
418
+ if (msg.payload.collapseAfter > 0 && !collapseTimer) {
419
+ applyCollapsePolicy(msg.payload.collapseAfter);
420
+ }
421
+ break;
422
+ case 'heartbeat': break;
423
+ }
424
+ }
425
+
426
+ function sendCmd(action, extra = {}) {
427
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ action, ...extra }));
428
+ }
429
+
430
+ // ══════════════════════════════════════════════════════════════════════════════
431
+ // Snapshot / Session management
432
+ // ══════════════════════════════════════════════════════════════════════════════
433
+
434
+ function handleSnapshot(payload) {
435
+ autoDiscovery = payload.autoDiscovery;
436
+ for (const s of (payload.sessions || [])) {
437
+ let session = sessions.find(x => x.id === s.id);
438
+ if (!session) {
439
+ session = {
440
+ id: s.id, projectPath: s.projectPath, title: '',
441
+ folder: folderName(s.projectPath), model: '',
442
+ agents: [], tasks: [], collapsed: false, pinned: false,
443
+ lastActivity: Date.now(),
444
+ };
445
+ sessions.push(session);
446
+ session.agents.push({ id: '', name: 'Main', type: 'main' });
447
+ }
448
+ session.lastActivity = Date.now();
449
+ for (const [aid, atype] of Object.entries(s.subagents || {})) {
450
+ if (!session.agents.find(a => a.id === aid)) {
451
+ session.agents.push({ id: aid, name: agentDisplayName(aid, atype), type: 'agent' });
452
+ }
453
+ }
454
+ for (const t of (s.backgroundTasks || [])) {
455
+ if (!session.tasks.find(ta => ta.id === t.id)) {
456
+ session.tasks.push({
457
+ id: t.id, parentAgentID: t.parentAgentID,
458
+ toolName: t.toolName, outputPath: t.outputPath,
459
+ isComplete: t.isComplete,
460
+ });
461
+ }
462
+ }
463
+ }
464
+ updateFilters();
465
+ rebuildNodes();
466
+ scheduleRender();
467
+ }
468
+
469
+ function handleNewSession(payload) {
470
+ if (sessions.find(s => s.id === payload.sessionID)) return;
471
+ sessions.push({
472
+ id: payload.sessionID, projectPath: payload.projectPath,
473
+ title: '', folder: folderName(payload.projectPath), model: '',
474
+ agents: [{ id: '', name: 'Main', type: 'main' }],
475
+ tasks: [], collapsed: false, pinned: false,
476
+ lastActivity: Date.now(),
477
+ });
478
+ updateFilters();
479
+ rebuildNodes();
480
+ scheduleRender();
481
+ }
482
+
483
+ function handleNewAgent(payload) {
484
+ const s = sessions.find(s => s.id === payload.sessionID);
485
+ if (!s || s.agents.find(a => a.id === payload.agentID)) return;
486
+ s.agents.push({
487
+ id: payload.agentID,
488
+ name: agentDisplayName(payload.agentID, payload.agentType),
489
+ type: 'agent',
490
+ });
491
+ updateFilters();
492
+ rebuildNodes();
493
+ scheduleRender();
494
+ }
495
+
496
+ function handleNewBgTask(payload) {
497
+ const s = sessions.find(s => s.id === payload.sessionID);
498
+ if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
499
+ s.tasks.push({
500
+ id: payload.toolID, parentAgentID: payload.parentAgentID,
501
+ toolName: payload.toolName, outputPath: payload.outputPath,
502
+ isComplete: payload.isComplete,
503
+ });
504
+ rebuildNodes();
505
+ scheduleRender();
506
+ }
507
+
508
+ function handleSessionRemoved(payload) {
509
+ const idx = sessions.findIndex(s => s.id === payload.sessionID);
510
+ if (idx >= 0) sessions.splice(idx, 1);
511
+ updateFilters();
512
+ rebuildNodes();
513
+ scheduleRender();
514
+ }
515
+
516
+ // ══════════════════════════════════════════════════════════════════════════════
517
+ // Stream items
518
+ // ══════════════════════════════════════════════════════════════════════════════
519
+
520
+ function handleItem(item) {
521
+ if (item.type === 'session_title') {
522
+ const s = sessions.find(s => s.id === item.sessionID);
523
+ if (s) { s.title = item.content.slice(0, 30); }
524
+ scheduleRender();
525
+ return;
526
+ }
527
+ // Update activity
528
+ const s = sessions.find(s => s.id === item.sessionID);
529
+ if (s) s.lastActivity = Date.now();
530
+ pushItem(item);
531
+ scheduleRender();
532
+ }
533
+
534
+ function handleItemBatch(items) {
535
+ for (const item of items) {
536
+ if (item.type === 'session_title') {
537
+ const s = sessions.find(s => s.id === item.sessionID);
538
+ if (s) { s.title = item.content.slice(0, 30); }
539
+ continue;
540
+ }
541
+ pushItem(item);
542
+ }
543
+ scheduleRender();
544
+ }
545
+
546
+ function pushItem(item) {
547
+ if (item.inputTokens > 0) totalInput += item.inputTokens;
548
+ if (item.outputTokens > 0) totalOutput += item.outputTokens;
549
+ if (item.cacheCreationTokens > 0) totalCacheCreate += item.cacheCreationTokens;
550
+ if (item.cacheReadTokens > 0) totalCacheRead += item.cacheReadTokens;
551
+
552
+ if (item.model) {
553
+ const s = sessions.find(s => s.id === item.sessionID);
554
+ if (s) s.model = item.model;
555
+ }
556
+
557
+ if (item.type === 'tool_input' && item.toolID && item.toolName) {
558
+ toolNameMap.set(item.toolID, item.toolName);
559
+ }
560
+
561
+ if (item.toolID) {
562
+ const key = `${item.toolID}:${item.type}`;
563
+ if (seenToolIDs.has(key)) return;
564
+ seenToolIDs.add(key);
565
+ if (seenToolIDs.size > 5000) seenToolIDs.clear();
566
+ }
567
+
568
+ streamItems.push(item);
569
+ if (streamItems.length > MAX_ITEMS) {
570
+ streamItems = streamItems.slice(-MAX_ITEMS);
571
+ needsFullRender = true;
572
+ }
573
+ }
574
+
575
+ function isItemVisible(item) {
576
+ if (!filters.has(item.sessionID + ':' + (item.agentID || ''))) return false;
577
+ switch (item.type) {
578
+ case 'thinking': return showThinking;
579
+ case 'tool_input': return showToolInput;
580
+ case 'tool_output': return showToolOutput;
581
+ case 'text': return showText;
582
+ default: return true;
583
+ }
584
+ }
585
+
586
+ // ══════════════════════════════════════════════════════════════════════════════
587
+ // Tree
588
+ // ══════════════════════════════════════════════════════════════════════════════
589
+
590
+ function rebuildNodes() {
591
+ treeNodes = [];
592
+ for (const s of sessions) {
593
+ treeNodes.push({ type: 'session', level: 0, isLast: false, ...s });
594
+ if (s.collapsed) continue;
595
+ const agents = s.agents || [];
596
+ const lastAgentIdx = agents.length - 1;
597
+ for (let ai = 0; ai < agents.length; ai++) {
598
+ const a = agents[ai];
599
+ const isLastAgent = ai === lastAgentIdx;
600
+ const tasks = s.tasks.filter(t =>
601
+ (a.id === '' && !t.parentAgentID) || t.parentAgentID === a.id
602
+ );
603
+ const lastTaskIdx = tasks.length - 1;
604
+ const hasTasks = tasks.length > 0;
605
+ treeNodes.push({ type: a.type, id: a.id, name: a.name, sessionID: s.id, level: 1, isLast: isLastAgent && !hasTasks });
606
+ for (let ti = 0; ti < tasks.length; ti++) {
607
+ const t = tasks[ti];
608
+ treeNodes.push({
609
+ type: 'task', id: t.id, name: t.toolName,
610
+ sessionID: s.id, parentAgentID: t.parentAgentID,
611
+ outputPath: t.outputPath, isComplete: t.isComplete,
612
+ level: 2, isLast: isLastAgent && ti === lastTaskIdx,
613
+ });
614
+ }
615
+ }
616
+ }
617
+ // Mark last session
618
+ const sessionNodes = treeNodes.filter(n => n.type === 'session');
619
+ if (sessionNodes.length > 0) sessionNodes[sessionNodes.length - 1].isLast = true;
620
+
621
+ if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
622
+ }
623
+
624
+ function treePrefix(node) {
625
+ if (node.level === 0) return '';
626
+ const branch = node.isLast ? '└── ' : '├── ';
627
+ if (node.level === 1) return branch;
628
+ // Level 2: need to check if parent agent is last
629
+ const agentNode = treeNodes.find(n => n.sessionID === node.sessionID && (n.type === 'main' || n.type === 'agent') && n.id === (node.parentAgentID || ''));
630
+ const parentIsLast = agentNode ? agentNode.isLast : true;
631
+ const stem = parentIsLast ? ' ' : '│ ';
632
+ return stem + branch;
633
+ }
634
+
635
+ function getNodeHTML(node, idx) {
636
+ const isSelected = idx === treeCursor;
637
+ const selClass = isSelected ? ' selected' : '';
638
+
639
+ if (node.type === 'session') {
640
+ const displayName = node.title || folderName(node.projectPath) || node.id.slice(0, 14);
641
+ const parts = [];
642
+ if (node.folder) parts.push(`📂 ${esc(node.folder)}`);
643
+ if (node.model) parts.push(`🧠 ${esc(node.model)}`);
644
+ const activeDot = isSessionActive(node) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
645
+ const subInfo = parts.length > 0 ? ` <span style="color:#6b7280;font-size:10px">${parts.join(' · ')}</span>` : '';
646
+ const agentCount = node.agents ? node.agents.filter(a => a.type === 'agent').length : 0;
647
+ return `<div class="tree-row${selClass ? ' selected' : ''}">
648
+ <div class="tree-node" onclick="treeClick(${idx})" data-idx="${idx}">
649
+ <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} ${esc(displayName)}
650
+ ${node.collapsed && agentCount > 0 ? `(${agentCount})` : ''}
651
+ ${subInfo}
652
+ </div>
653
+ <span class="tree-actions">
654
+ <button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
655
+ <button class="btn btn-icon danger" onclick="event.stopPropagation();selectIndex(${idx});removeSelectedSession()" data-tooltip="Remove">✕</button>
656
+ </span>
657
+ </div>`;
658
+ }
659
+
660
+ if (node.type === 'main' || node.type === 'agent') {
661
+ const icon = node.type === 'main' ? '💬' : '🤖';
662
+ const enabled = filters.get(node.sessionID + ':' + node.id);
663
+ const ctxKey = node.sessionID + ':' + node.id;
664
+ const ctx = contextData[ctxKey];
665
+ let ctxPct = '';
666
+ if (ctx && ctx.contextWindow > 0 && ctx.inputTokens > 0) {
667
+ const pct = Math.round(ctx.inputTokens / ctx.contextWindow * 100);
668
+ const cls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
669
+ ctxPct = `<span class="ctx-pct ${cls}">${pct}%</span>`;
670
+ }
671
+ const activeDot = ctx && (Date.now() - ctx.lastActivity < 120000) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
672
+ return `<div class="tree-row${selClass ? ' selected' : ''}">
673
+ <div class="tree-node${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
674
+ <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${icon} ${esc(node.name || '')}${ctxPct}
675
+ </div>
676
+ <span class="tree-actions">
677
+ <button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
678
+ <button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});toggleNodeVisibility(${idx})" data-tooltip="${enabled ? 'Hide' : 'Show'}">${enabled ? '👁' : '─'}</button>
679
+ </span>
680
+ </div>`;
681
+ }
682
+
683
+ if (node.type === 'task') {
684
+ const icon = node.isComplete ? '✓' : '⏳';
685
+ return `<div class="tree-row${selClass ? ' selected' : ''}">
686
+ <div class="tree-node dim" onclick="treeClick(${idx})" data-idx="${idx}">
687
+ <span class="tree-prefix">${treePrefix(node)}</span>${icon} ${esc(node.name || 'bg-task')}
688
+ </div>
689
+ <span class="tree-actions">
690
+ <button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});loadBgTask(${idx})" data-tooltip="Load output">▶</button>
691
+ </span>
692
+ </div>`;
693
+ }
694
+
695
+ return '';
696
+ }
697
+
698
+ function renderTree() {
699
+ if (treeNodes.length === 0) {
700
+ treeEl.innerHTML = '<div class="tree-node" style="padding:8px;color:var(--dim)">Waiting for sessions...</div>';
701
+ treeCursorInfo.textContent = '';
702
+ return;
703
+ }
704
+ let html = '';
705
+ for (let i = 0; i < treeNodes.length; i++) {
706
+ html += getNodeHTML(treeNodes[i], i);
707
+ }
708
+ treeEl.innerHTML = html;
709
+
710
+ // Scroll selected into view
711
+ const sel = treeEl.querySelector('.tree-node.selected');
712
+ if (sel) sel.scrollIntoView({ block: 'nearest' });
713
+
714
+ treeCursorInfo.textContent = `${treeCursor + 1}/${treeNodes.length}`;
715
+ }
716
+
717
+ function isSessionActive(session) {
718
+ if (!session) return false;
719
+ const now = Date.now();
720
+ // Check main agent
721
+ const mainCtx = contextData[session.id + ':'];
722
+ if (mainCtx && (now - mainCtx.lastActivity) < 120000) return true;
723
+ // Check subagents
724
+ for (const a of session.agents) {
725
+ if (a.id === '') continue;
726
+ const ctx = contextData[session.id + ':' + a.id];
727
+ if (ctx && (now - ctx.lastActivity) < 120000) return true;
728
+ }
729
+ // Fallback: check own lastActivity
730
+ return (now - session.lastActivity) < 120000;
731
+ }
732
+
733
+ // ══════════════════════════════════════════════════════════════════════════════
734
+ // Stream rendering
735
+ // ══════════════════════════════════════════════════════════════════════════════
736
+
737
+ function renderStream() {
738
+ const visible = streamItems.filter(isItemVisible);
739
+
740
+ if (needsFullRender || renderedItemCount > visible.length) {
741
+ // Full rebuild: filter changed, items trimmed, or initial render
742
+ const lines = [];
743
+ for (const item of visible) {
744
+ for (const l of renderItem(item)) lines.push(l);
745
+ }
746
+
747
+ let html;
748
+ if (lines.length > 0) {
749
+ html = lines.map(l => {
750
+ if (l.html) return `<div class="${l.cls}">${l.text}</div>`;
751
+ return `<div class="${l.cls}">${esc(l.text)}</div>`;
752
+ }).join('\n');
753
+ } else if (streamItems.length > 0) {
754
+ html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
755
+ } else {
756
+ html = '<div style="color:#6b7280;padding:20px;text-align:center">Waiting for output...</div>';
757
+ }
758
+
759
+ streamEl.innerHTML = html;
760
+ renderedItemCount = visible.length;
761
+ needsFullRender = false;
762
+ if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
763
+ } else {
764
+ // Incremental append: only add new items since last render
765
+ const wasAtBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
766
+ for (let i = renderedItemCount; i < visible.length; i++) {
767
+ for (const l of renderItem(visible[i])) {
768
+ const div = document.createElement('div');
769
+ div.className = l.cls;
770
+ div.innerHTML = l.html ? l.text : esc(l.text);
771
+ streamEl.appendChild(div);
772
+ }
773
+ }
774
+ renderedItemCount = visible.length;
775
+ if (autoScroll && wasAtBottom) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
776
+ }
777
+
778
+ const maxScroll = streamEl.scrollHeight - streamEl.clientHeight;
779
+ const pct = maxScroll > 0 ? Math.round(streamEl.scrollTop / maxScroll * 100) : 0;
780
+ document.getElementById('scroll-pos').textContent = Math.min(100, pct) + '%';
781
+ document.getElementById('item-count').textContent = streamItems.length + ' items';
782
+ }
783
+
784
+ function renderItem(item) {
785
+ const lines = [];
786
+ const isSub = !!item.agentID;
787
+ const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
788
+ const sep = ' » ';
789
+
790
+ if (item.type === 'turn_marker') {
791
+ return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──` }];
792
+ }
793
+ if (item.type === 'compact_marker') {
794
+ const label = item.content ? `compacted (${item.content})` : 'compacted';
795
+ return [{ cls: 'stream-line marker', text: `── ${label} ──` }];
796
+ }
797
+ if (item.type === 'pr_link') {
798
+ return [{ cls: 'stream-line marker', text: `── ${item.content} ──` }];
799
+ }
800
+
801
+ const agentName = item.agentName || 'Main';
802
+
803
+ switch (item.type) {
804
+ case 'thinking':
805
+ lines.push({ cls: agentTagCls, text: agentName + sep + '🧠 Thinking' });
806
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l });
807
+ break;
808
+ case 'tool_input':
809
+ lines.push({ cls: agentTagCls, text: agentName + sep + `🔧 ${item.toolName || ''}` });
810
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l });
811
+ break;
812
+ case 'tool_output': {
813
+ let tn = '';
814
+ if (item.toolID) {
815
+ tn = toolNameMap.get(item.toolID) || '';
816
+ }
817
+ let label = tn ? `📤 ${tn} result` : '📤 Output';
818
+ if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
819
+ lines.push({ cls: agentTagCls, text: agentName + sep + label });
820
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l });
821
+ break;
822
+ }
823
+ case 'text':
824
+ lines.push({ cls: agentTagCls, text: agentName + sep + '💬 Response' });
825
+ lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true });
826
+ break;
827
+ case 'hook_output': {
828
+ let label = '🪝 Hook';
829
+ if (item.toolName) label += ' ' + item.toolName;
830
+ if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
831
+ lines.push({ cls: agentTagCls, text: agentName + sep + label });
832
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line hook', text: l });
833
+ break;
834
+ }
835
+ case 'diagnostics': {
836
+ let label = '⚠ Diagnostics';
837
+ if (item.toolName) label += ' ' + item.toolName;
838
+ lines.push({ cls: agentTagCls, text: agentName + sep + label });
839
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l });
840
+ break;
841
+ }
842
+ case 'debug': {
843
+ let label = '🔍 Debug';
844
+ if (item.toolName) label += ' ' + item.toolName;
845
+ lines.push({ cls: agentTagCls, text: agentName + sep + label });
846
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l });
847
+ break;
848
+ }
849
+ }
850
+
851
+ lines.push({ cls: 'stream-line separator', text: '─'.repeat(60) });
852
+ return lines;
853
+ }
854
+
855
+ function truncContent(content) {
856
+ const raw = content.split('\n');
857
+ return raw.length > MAX_LINES ? raw.slice(0, MAX_LINES).concat([`... (${raw.length - MAX_LINES} more lines)`]) : raw;
858
+ }
859
+
860
+ // ══════════════════════════════════════════════════════════════════════════════
861
+ // Button / header refresh
862
+ // ══════════════════════════════════════════════════════════════════════════════
863
+
864
+ function refreshButtons() {
865
+ document.getElementById('btn-thinking').classList.toggle('on', showThinking);
866
+ document.getElementById('btn-tool-input').classList.toggle('on', showToolInput);
867
+ document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
868
+ document.getElementById('btn-text').classList.toggle('on', showText);
869
+ document.getElementById('btn-autoscroll').classList.toggle('on', autoScroll);
870
+ document.getElementById('btn-tree-toggle').classList.toggle('on', showTree);
871
+ document.getElementById('btn-autodisco').classList.toggle('on', autoDiscovery);
872
+
873
+ // Session info
874
+ let info = '';
875
+ if (sessions.length === 0) info = 'Waiting...';
876
+ else if (sessions.length === 1) {
877
+ const s = sessions[0];
878
+ info = (s.title || folderName(s.projectPath) || s.id.slice(0, 14));
879
+ } else info = sessions.length + ' sessions';
880
+ if (!autoDiscovery) info += ' [paused]';
881
+ sessionInfo.textContent = info;
882
+
883
+ // Token info
884
+ let tokStr = '';
885
+ if (totalInput > 0 || totalOutput > 0) {
886
+ tokStr = `${fmtTok(totalInput)} in / ${fmtTok(totalOutput)} out`;
887
+ if (totalCacheCreate > 0 || totalCacheRead > 0) {
888
+ tokStr += ` · cache ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
889
+ }
890
+ }
891
+ tokenInfo.textContent = tokStr;
892
+ }
893
+
894
+ // ══════════════════════════════════════════════════════════════════════════════
895
+ // Actions
896
+ // ══════════════════════════════════════════════════════════════════════════════
897
+
898
+ function selectIndex(idx) {
899
+ if (idx >= 0 && idx < treeNodes.length) treeCursor = idx;
900
+ }
901
+
902
+ function treeClick(idx) {
903
+ selectIndex(idx);
904
+ const node = treeNodes[idx];
905
+ if (!node) return;
906
+ if (node.type === 'session') {
907
+ const session = sessions.find(s => s.id === node.id);
908
+ if (session) {
909
+ session.collapsed = !session.collapsed;
910
+ if (!session.collapsed) session.pinned = true;
911
+ }
912
+ rebuildNodes();
913
+ } else if (node.type === 'main' || node.type === 'agent') {
914
+ toggleNodeVisibility(idx);
915
+ return;
916
+ } else if (node.type === 'task') {
917
+ loadBgTask(idx);
918
+ return;
919
+ }
920
+ renderAll();
921
+ }
922
+
923
+ function toggleNodeVisibility(idx) {
924
+ const node = treeNodes[idx];
925
+ if (!node) return;
926
+ const key = node.sessionID + ':' + node.id;
927
+ filters.set(key, !filters.get(key));
928
+ renderAll();
929
+ }
930
+
931
+ function loadBgTask(idx) {
932
+ const node = treeNodes[idx];
933
+ if (!node || node.type !== 'task') return;
934
+ if (!node.outputPath) return;
935
+
936
+ // Fetch the actual output file
937
+ fetch(`/api/task-output?path=${encodeURIComponent(node.outputPath)}`)
938
+ .then(r => r.json())
939
+ .then(data => {
940
+ const content = data.content || `[Error: ${data.error || 'unknown'}]`;
941
+ const statusIcon = node.isComplete ? '✓' : '⏳';
942
+ streamItems.push({
943
+ type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
944
+ agentName: '', toolName: `${statusIcon} ${node.name || 'bg-task'}`,
945
+ content: content,
946
+ timestamp: new Date(), toolID: '', durationMs: 0,
947
+ inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
948
+ });
949
+ renderAll();
950
+ })
951
+ .catch(err => {
952
+ streamItems.push({
953
+ type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
954
+ agentName: '', toolName: `⏳ ${node.name || 'bg-task'}`,
955
+ content: `[Failed to load: ${err.message}]`,
956
+ timestamp: new Date(), toolID: '', durationMs: 0,
957
+ inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
958
+ });
959
+ renderAll();
960
+ });
961
+ }
962
+
963
+ function soloSelected() {
964
+ const node = treeNodes[treeCursor];
965
+ if (!node || node.type === 'task') return;
966
+
967
+ if (isSoloed(node)) {
968
+ updateFilters();
969
+ } else {
970
+ filters.clear();
971
+ if (node.type === 'session') {
972
+ const session = sessions.find(s => s.id === node.id);
973
+ if (session && session.collapsed) {
974
+ session.collapsed = false;
975
+ session.pinned = true;
976
+ rebuildNodes();
977
+ }
978
+ for (const a of node.agents) filters.set(node.id + ':' + a.id, true);
979
+ } else if (node.type === 'main' || node.type === 'agent') {
980
+ filters.set(node.sessionID + ':' + node.id, true);
981
+ }
982
+ }
983
+ renderAll();
984
+ }
985
+
986
+ function isSoloed(node) {
987
+ if (node.type === 'session') {
988
+ for (const s of sessions) {
989
+ if (s.id === node.id) continue;
990
+ for (const a of s.agents) {
991
+ if (filters.get(s.id + ':' + a.id)) return false;
992
+ }
993
+ }
994
+ return true;
995
+ }
996
+ if (node.type === 'main' || node.type === 'agent') {
997
+ const key = node.sessionID + ':' + node.id;
998
+ if (!filters.get(key)) return false;
999
+ for (const s of sessions) {
1000
+ for (const a of s.agents) {
1001
+ if ((s.id + ':' + a.id) !== key && filters.get(s.id + ':' + a.id)) return false;
1002
+ }
1003
+ }
1004
+ return true;
1005
+ }
1006
+ return false;
1007
+ }
1008
+
1009
+ function selectAll() {
1010
+ updateFilters();
1011
+ renderAll();
1012
+ }
1013
+
1014
+ function removeSelectedSession() {
1015
+ const node = treeNodes[treeCursor];
1016
+ if (!node) return;
1017
+ let sid;
1018
+ if (node.type === 'session') sid = node.id;
1019
+ else sid = node.sessionID;
1020
+ if (!sid) return;
1021
+ if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1022
+ sessions = sessions.filter(s => s.id !== sid);
1023
+ sendCmd('removeSession', { sessionID: sid });
1024
+ updateFilters();
1025
+ rebuildNodes();
1026
+ renderAll();
1027
+ }
1028
+
1029
+ // ══════════════════════════════════════════════════════════════════════════════
1030
+ // Toggles
1031
+ // ══════════════════════════════════════════════════════════════════════════════
1032
+
1033
+ function toggleThinking() { showThinking = !showThinking; needsFullRender = true; renderStream(); refreshButtons(); }
1034
+ function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true; renderStream(); refreshButtons(); }
1035
+ function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true; renderStream(); refreshButtons(); }
1036
+ function toggleText() { showText = !showText; needsFullRender = true; renderStream(); refreshButtons(); }
1037
+ function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1038
+ function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
1039
+ function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
1040
+
1041
+ function scrollToTop() { streamEl.scrollTop = 0; autoScroll = false; renderAll(); }
1042
+ function scrollUp() { streamEl.scrollTop -= 80; autoScroll = false; renderAll(); }
1043
+ function scrollDown() { streamEl.scrollTop += 80; renderAll(); }
1044
+ function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; autoScroll = true; renderAll(); }
1045
+
1046
+ // ══════════════════════════════════════════════════════════════════════════════
1047
+ // Tree panel resize
1048
+ // ══════════════════════════════════════════════════════════════════════════════
1049
+
1050
+ (function setupResize() {
1051
+ const panel = document.getElementById('tree-panel');
1052
+ const handle = document.getElementById('tree-resize-handle');
1053
+ let startX, startWidth;
1054
+
1055
+ handle.addEventListener('mousedown', (e) => {
1056
+ e.preventDefault();
1057
+ startX = e.clientX;
1058
+ startWidth = panel.offsetWidth;
1059
+ handle.classList.add('active');
1060
+ document.body.style.cursor = 'col-resize';
1061
+ document.body.style.userSelect = 'none';
1062
+ });
1063
+
1064
+ document.addEventListener('mousemove', (e) => {
1065
+ if (!handle.classList.contains('active')) return;
1066
+ const dx = e.clientX - startX;
1067
+ const newWidth = startWidth + dx;
1068
+ if (newWidth >= 180 && newWidth <= window.innerWidth * 0.6) {
1069
+ panel.style.width = newWidth + 'px';
1070
+ }
1071
+ });
1072
+
1073
+ document.addEventListener('mouseup', () => {
1074
+ handle.classList.remove('active');
1075
+ document.body.style.cursor = '';
1076
+ document.body.style.userSelect = '';
1077
+ });
1078
+ })();
1079
+
1080
+ // ══════════════════════════════════════════════════════════════════════════════
1081
+ // Auto-collapse
1082
+ // ══════════════════════════════════════════════════════════════════════════════
1083
+
1084
+ function applyCollapsePolicy(duration) {
1085
+ collapseAfter = duration;
1086
+ if (collapseTimer) clearInterval(collapseTimer);
1087
+ if (duration <= 0) return;
1088
+
1089
+ collapseTimer = setInterval(() => {
1090
+ if (!collapseAfter) return;
1091
+ const now = Date.now();
1092
+ let changed = false;
1093
+ for (const s of sessions) {
1094
+ if (s.pinned || s.collapsed) continue;
1095
+ if ((now - s.lastActivity) > collapseAfter) {
1096
+ s.collapsed = true;
1097
+ changed = true;
1098
+ }
1099
+ }
1100
+ if (changed) {
1101
+ rebuildNodes();
1102
+ renderAll();
1103
+ }
1104
+ }, 5000);
1105
+ }
1106
+
1107
+ function startActiveRefresh() {
1108
+ if (activeRefreshTimer) clearInterval(activeRefreshTimer);
1109
+ activeRefreshTimer = setInterval(() => {
1110
+ const prevHTML = treeEl.innerHTML;
1111
+ rebuildNodes();
1112
+ renderTree();
1113
+ if (treeEl.innerHTML !== prevHTML) renderAll();
1114
+ }, 15000);
1115
+ }
1116
+
1117
+ // ══════════════════════════════════════════════════════════════════════════════
1118
+ // Scroll detection
1119
+ // ══════════════════════════════════════════════════════════════════════════════
1120
+
1121
+ streamEl.addEventListener('scroll', () => {
1122
+ const atBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
1123
+ if (atBottom && !autoScroll) autoScroll = true;
1124
+ if (!atBottom && autoScroll) autoScroll = false;
1125
+ refreshButtons();
1126
+ });
1127
+
1128
+ // ══════════════════════════════════════════════════════════════════════════════
1129
+ // Helpers
1130
+ // ══════════════════════════════════════════════════════════════════════════════
1131
+
1132
+ function updateFilters() {
1133
+ filters.clear();
1134
+ for (const s of sessions) {
1135
+ for (const a of s.agents) {
1136
+ filters.set(s.id + ':' + a.id, true);
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ function agentDisplayName(id, type) {
1142
+ if (type) {
1143
+ const idx = type.lastIndexOf(':');
1144
+ if (idx >= 0 && idx < type.length - 1) return type.slice(idx + 1);
1145
+ return type;
1146
+ }
1147
+ if (!id) return 'Main';
1148
+ return 'Agent-' + id.slice(0, 7);
1149
+ }
1150
+
1151
+ function folderName(projectPath) {
1152
+ if (!projectPath) return '';
1153
+ const parts = projectPath.split('/');
1154
+ return parts[parts.length - 1] || projectPath;
1155
+ }
1156
+
1157
+ function esc(s) {
1158
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1159
+ }
1160
+
1161
+ function fmtDur(ms) {
1162
+ if (!ms || ms <= 0) return '';
1163
+ if (ms < 1000) return `(${ms}ms)`;
1164
+ if (ms < 60000) return `(${(ms / 1000).toFixed(1)}s)`;
1165
+ return `(${(ms / 60000).toFixed(1)}m)`;
1166
+ }
1167
+
1168
+ function fmtTok(n) {
1169
+ if (!n) return '0';
1170
+ if (n < 1000) return String(n);
1171
+ if (n < 1000000) return (n / 1000).toFixed(1) + 'k';
1172
+ return (n / 1000000).toFixed(1) + 'm';
1173
+ }
1174
+
1175
+ function renderAll() {
1176
+ needsFullRender = true;
1177
+ renderTree();
1178
+ renderStream();
1179
+ refreshButtons();
1180
+ }
1181
+
1182
+ function scheduleRender() {
1183
+ if (!renderPending) {
1184
+ renderPending = true;
1185
+ requestAnimationFrame(() => {
1186
+ renderPending = false;
1187
+ renderAll();
1188
+ });
1189
+ }
1190
+ }
1191
+
1192
+ // ══════════════════════════════════════════════════════════════════════════════
1193
+ // Init
1194
+ // ══════════════════════════════════════════════════════════════════════════════
1195
+
1196
+ // Apply collapse-after from URL param
1197
+ const urlParams = new URLSearchParams(location.search);
1198
+ const ca = urlParams.get('collapseAfter');
1199
+ if (ca) {
1200
+ applyCollapsePolicy(parseInt(ca) || 0);
1201
+ }
1202
+
1203
+ connect();
1204
+ </script>
1205
+ </body>
1206
+ </html>