claude-code-watch 0.1.5 → 0.2.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,500 @@
1
+ // ══════════════════════════════════════════════════════════════════════════════
2
+ // app.js — Coordinator: WebSocket, theme, export, tab switching
3
+ // ══════════════════════════════════════════════════════════════════════════════
4
+
5
+ // ── DOM refs ──
6
+ const sessionInfo = document.getElementById('session-info');
7
+ const tokenInfo = document.getElementById('token-info');
8
+
9
+ // ── App State ──
10
+ let ws = null;
11
+ let reconnectTimer = null;
12
+ let reconnectDelay = 1000;
13
+ const MaxReconnectDelay = 30000;
14
+ const MaxReconnectAttempts = 20;
15
+ let reconnectAttempts = 0;
16
+ let lastMsgTime = 0;
17
+ let staleCheckTimer = null;
18
+ let currentTab = 'stream';
19
+ let appVersion = '';
20
+ let latestVersion = '';
21
+
22
+ // Cache highlight.js CSS for HTML export
23
+ let hljsDarkCSS = '', hljsLightCSS = '';
24
+ fetch('vendor/github-dark.min.css').then(r => r.text()).then(t => { hljsDarkCSS = t; }).catch(() => {});
25
+ fetch('vendor/github-light.min.css').then(r => r.text()).then(t => { hljsLightCSS = t; }).catch(() => {});
26
+
27
+ // Cache app CSS for HTML export
28
+ let appCSS = '';
29
+ fetch('css/app.css').then(r => r.text()).then(t => { appCSS = t; }).catch(() => {});
30
+
31
+ // ══════════════════════════════════════════════════════════════════════════════
32
+ // WebSocket
33
+ // ══════════════════════════════════════════════════════════════════════════════
34
+
35
+ function connect() {
36
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
37
+ ws = new WebSocket(`${proto}//${location.host}`);
38
+
39
+ ws.onopen = () => {
40
+ sessionInfo.textContent = 'Connected';
41
+ lastMsgTime = Date.now();
42
+ reconnectDelay = 1000;
43
+ reconnectAttempts = 0;
44
+ startStaleCheck();
45
+ startActiveRefresh();
46
+ };
47
+ ws.onclose = () => {
48
+ reconnectAttempts++;
49
+ if (reconnectAttempts >= MaxReconnectAttempts) {
50
+ sessionInfo.textContent = 'Disconnected. Please refresh to reconnect.';
51
+ return;
52
+ }
53
+ sessionInfo.textContent = 'Disconnected, reconnecting...';
54
+ stopStaleCheck();
55
+ reconnectTimer = setTimeout(connect, reconnectDelay);
56
+ reconnectDelay = Math.min(reconnectDelay * 2, MaxReconnectDelay);
57
+ };
58
+ ws.onerror = (e) => { console.warn('[ws] connection error', e); };
59
+
60
+ ws.onmessage = (e) => {
61
+ lastMsgTime = Date.now();
62
+ let msg;
63
+ try { msg = JSON.parse(e.data); } catch { return; }
64
+ handleMessage(msg);
65
+ };
66
+ }
67
+
68
+ function startStaleCheck() {
69
+ if (staleCheckTimer) clearInterval(staleCheckTimer);
70
+ staleCheckTimer = setInterval(() => {
71
+ if (Date.now() - lastMsgTime > 45000) {
72
+ sessionInfo.textContent = 'Stale connection, reconnecting...';
73
+ stopStaleCheck();
74
+ try { ws.close(); } catch {}
75
+ }
76
+ }, 10000);
77
+ }
78
+
79
+ function stopStaleCheck() {
80
+ if (staleCheckTimer) { clearInterval(staleCheckTimer); staleCheckTimer = null; }
81
+ }
82
+
83
+ function handleMessage(msg) {
84
+ switch (msg.type) {
85
+ case 'snapshot': handleSnapshot(msg.payload); break;
86
+ case 'itemBatch': handleItemBatch(msg.payload); break;
87
+ case 'item': handleItem(msg.payload); break;
88
+ case 'newSession': handleNewSession(msg.payload); break;
89
+ case 'newAgent': handleNewAgent(msg.payload); break;
90
+ case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
91
+ case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
92
+ case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
93
+ case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); scheduleRender(); break;
94
+ case 'tokenStats': handleTokenStats(msg.payload); break;
95
+ case 'config':
96
+ if (msg.payload.version) appVersion = msg.payload.version;
97
+ if (msg.payload.latestVersion) { latestVersion = msg.payload.latestVersion; renderFooterVersion(); }
98
+ if (msg.payload.collapseAfter > 0) {
99
+ applyCollapsePolicy(msg.payload.collapseAfter);
100
+ }
101
+ break;
102
+ case 'heartbeat': break;
103
+ }
104
+ }
105
+
106
+ function sendCmd(action, extra = {}) {
107
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ action, ...extra }));
108
+ }
109
+
110
+ // ══════════════════════════════════════════════════════════════════════════════
111
+ // Button / header refresh
112
+ // ══════════════════════════════════════════════════════════════════════════════
113
+
114
+ function refreshButtons() {
115
+ updateStreamButtons();
116
+
117
+ document.getElementById('btn-autodisco').classList.toggle('on', autoDiscovery);
118
+
119
+ // Session info
120
+ let info = '';
121
+ if (sessions.length === 0) info = 'Waiting...';
122
+ else if (sessions.length === 1) {
123
+ const s = sessions[0];
124
+ info = (folderName(s.projectPath) || s.title || s.id.slice(0, 14));
125
+ } else info = sessions.length + ' sessions';
126
+ if (!autoDiscovery) info += ' [paused]';
127
+ sessionInfo.textContent = info;
128
+
129
+ // Token info
130
+ computeTokensFromContext();
131
+ let tokStr = '';
132
+ if (totalInput > 0 || totalOutput > 0) {
133
+ tokStr = `${fmtTok(totalInput)} in / ${fmtTok(totalOutput)} out`;
134
+ if (totalCacheCreate > 0 || totalCacheRead > 0) {
135
+ tokStr += ` · cache ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
136
+ }
137
+ }
138
+ tokenInfo.textContent = tokStr;
139
+
140
+ // Footer version
141
+ renderFooterVersion();
142
+ }
143
+
144
+ function renderFooterVersion() {
145
+ const vEl = document.getElementById('footer-version');
146
+ if (vEl) {
147
+ const v = appVersion ? `v${appVersion}` : '';
148
+ const hasUpdate = latestVersion && appVersion && latestVersion !== appVersion;
149
+ const updateBadge = hasUpdate
150
+ ? `<a href="https://www.npmjs.com/package/claude-code-watch" target="_blank" rel="noopener" class="version-update-badge" data-tooltip="New version available! Click to view on npm"><span class="version-update-dot"></span>v${latestVersion} ↑</a>`
151
+ : '';
152
+ vEl.innerHTML = `${v ? v + ' ' : ''}${updateBadge}${updateBadge ? ' · ' : ''}<a href="https://github.com/shuxuecode/claude-watch" target="_blank" rel="noopener" style="color:var(--dim);display:inline-flex;align-items:center;gap:3px"><svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" style="vertical-align:middle"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>claude-watch</a>`;
153
+ }
154
+ }
155
+
156
+ // ══════════════════════════════════════════════════════════════════════════════
157
+ // Tab switching
158
+ // ══════════════════════════════════════════════════════════════════════════════
159
+
160
+ function switchTab(tab) {
161
+ currentTab = tab;
162
+ // Sync URL hash without triggering hashchange loop
163
+ if (location.hash !== '#' + tab) {
164
+ history.replaceState(null, '', '#' + tab);
165
+ }
166
+ document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
167
+ document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
168
+ document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
169
+ document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
170
+ document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
171
+ // Toggle stream-only header controls
172
+ document.querySelectorAll('.stream-only').forEach(el => {
173
+ el.style.display = tab === 'stream' ? '' : 'none';
174
+ });
175
+ if (tab === 'tokens' && !tokenStatsRendered && tokenStatsData.totals.messages > 0) {
176
+ tokenStatsRendered = true;
177
+ renderTokenPage();
178
+ }
179
+ }
180
+
181
+ // Hash routing: browser back/forward and manual URL editing
182
+ window.addEventListener('hashchange', () => {
183
+ const tab = (location.hash.slice(1) || 'stream');
184
+ if (tab === 'stream' || tab === 'tokens') {
185
+ switchTab(tab);
186
+ }
187
+ });
188
+
189
+ function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
190
+
191
+ // ══════════════════════════════════════════════════════════════════════════════
192
+ // Theme toggle
193
+ // ══════════════════════════════════════════════════════════════════════════════
194
+
195
+ function applyTheme(theme) {
196
+ document.documentElement.setAttribute('data-theme', theme);
197
+ const btn = document.getElementById('btn-theme');
198
+ if (btn) {
199
+ btn.textContent = theme === 'dark' ? '🌙' : '☀️';
200
+ btn.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light' : 'Switch to dark');
201
+ }
202
+ const hlLink = document.querySelector('link[rel="stylesheet"][href*="github"]');
203
+ if (hlLink) {
204
+ hlLink.href = theme === 'dark' ? 'vendor/github-dark.min.css' : 'vendor/github-light.min.css';
205
+ }
206
+ }
207
+
208
+ function toggleTheme() {
209
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
210
+ const next = current === 'dark' ? 'light' : 'dark';
211
+ localStorage.setItem('theme', next);
212
+ applyTheme(next);
213
+ }
214
+
215
+ // ══════════════════════════════════════════════════════════════════════════════
216
+ // Export modal — session selection
217
+ // ══════════════════════════════════════════════════════════════════════════════
218
+
219
+ let exportModalSelected = new Set();
220
+
221
+ function openExportModal() {
222
+ if (sessions.length === 0) {
223
+ const btn = document.getElementById('btn-export');
224
+ const orig = btn.textContent;
225
+ btn.textContent = '✕ 无会话';
226
+ setTimeout(() => { btn.textContent = orig; }, 2000);
227
+ return;
228
+ }
229
+ exportModalSelected = new Set(sessions.map(s => s.id));
230
+ renderModalSessionList();
231
+ updateModalCount();
232
+ document.getElementById('export-modal').style.display = 'flex';
233
+ }
234
+
235
+ function renderModalSessionList() {
236
+ const listEl = document.getElementById('modal-session-list');
237
+ const sorted = [...sessions].sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
238
+ listEl.innerHTML = sorted.map(s => {
239
+ const color = idColor(s.colorRank || 0);
240
+ const project = folderName(s.projectPath) || s.projectPath || '';
241
+ const prefix = s.id.split('-')[0].toUpperCase();
242
+ const model = s.model || '';
243
+ const time = formatTime(s.birthtimeMs);
244
+ const checked = exportModalSelected.has(s.id) ? 'checked' : '';
245
+ const selectedClass = exportModalSelected.has(s.id) ? ' selected' : '';
246
+ return `<div class="modal-session-row${selectedClass}" data-sid="${esc(s.id)}" onclick="toggleModalSession('${esc(s.id)}', this)">
247
+ <input type="checkbox" class="modal-checkbox" data-sid="${esc(s.id)}" ${checked} onclick="event.stopPropagation(); toggleModalSession('${esc(s.id)}', this.parentElement)">
248
+ <span class="modal-session-prefix" style="color:${color}">${esc(prefix)}</span>
249
+ <div class="modal-session-info">
250
+ <span class="modal-session-project">${esc(project)}</span>
251
+ ${model ? `<span class="modal-session-model">${esc(model)}</span>` : ''}
252
+ </div>
253
+ ${time ? `<span class="modal-session-time">${esc(time)}</span>` : ''}
254
+ </div>`;
255
+ }).join('\n');
256
+ }
257
+
258
+ function toggleModalSession(sid, rowEl) {
259
+ if (exportModalSelected.has(sid)) {
260
+ exportModalSelected.delete(sid);
261
+ } else {
262
+ exportModalSelected.add(sid);
263
+ }
264
+ const checkbox = rowEl.querySelector('.modal-checkbox');
265
+ checkbox.checked = exportModalSelected.has(sid);
266
+ rowEl.classList.toggle('selected', exportModalSelected.has(sid));
267
+ updateModalCount();
268
+ }
269
+
270
+ function exportModalToggleAll(selectAll) {
271
+ if (selectAll) {
272
+ exportModalSelected = new Set(sessions.map(s => s.id));
273
+ } else {
274
+ exportModalSelected.clear();
275
+ }
276
+ document.querySelectorAll('#modal-session-list .modal-session-row').forEach(row => {
277
+ const sid = row.dataset.sid;
278
+ const checkbox = row.querySelector('.modal-checkbox');
279
+ checkbox.checked = exportModalSelected.has(sid);
280
+ row.classList.toggle('selected', exportModalSelected.has(sid));
281
+ });
282
+ updateModalCount();
283
+ }
284
+
285
+ function updateModalCount() {
286
+ const total = sessions.length;
287
+ const selected = exportModalSelected.size;
288
+ document.getElementById('modal-selected-count').textContent = `已选 ${selected} / ${total}`;
289
+ document.getElementById('modal-export-btn').disabled = selected === 0;
290
+ }
291
+
292
+ function closeExportModal() {
293
+ document.getElementById('export-modal').style.display = 'none';
294
+ exportModalSelected.clear();
295
+ }
296
+
297
+ // Esc key closes modal
298
+ document.addEventListener('keydown', (e) => {
299
+ if (e.key === 'Escape') {
300
+ const modal = document.getElementById('export-modal');
301
+ if (modal.style.display !== 'none') {
302
+ closeExportModal();
303
+ e.stopPropagation();
304
+ }
305
+ }
306
+ });
307
+
308
+ function confirmExport() {
309
+ if (exportModalSelected.size === 0) return;
310
+ const selectedIds = new Set(exportModalSelected);
311
+ closeExportModal();
312
+ exportHTML(selectedIds);
313
+ }
314
+
315
+ // ══════════════════════════════════════════════════════════════════════════════
316
+ // Export HTML
317
+ // ══════════════════════════════════════════════════════════════════════════════
318
+
319
+ function exportHTML(selectedIds = null) {
320
+ const theme = document.documentElement.getAttribute('data-theme') || 'dark';
321
+
322
+ let sidsInExport;
323
+ if (selectedIds) {
324
+ sidsInExport = selectedIds;
325
+ } else {
326
+ sidsInExport = new Set();
327
+ for (const item of visibleItems) {
328
+ if (item.sessionID) sidsInExport.add(item.sessionID);
329
+ }
330
+ }
331
+ const exportSessions = [];
332
+ for (const sid of sidsInExport) {
333
+ const s = sessionsMap.get(sid);
334
+ if (s) exportSessions.push(s);
335
+ }
336
+ exportSessions.sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
337
+
338
+ let sessionListHTML = '';
339
+ if (exportSessions.length > 0) {
340
+ const items = exportSessions.map(s => {
341
+ const color = idColor(s.colorRank || 0);
342
+ const project = folderName(s.projectPath) || s.projectPath || '';
343
+ const model = s.model || '';
344
+ return `<div class="export-session-item" data-sid="${esc(s.id)}" onclick="filterBySession('${esc(s.id)}')"><div class="export-item-top"><span class="export-project">${esc(project)}</span>${model ? ` <span class="export-model" style="color:var(--dim)">${esc(model)}</span>` : ''}</div><div class="export-item-sid" style="color:${color}">${esc(s.id)}</div></div>`;
345
+ }).join('\n');
346
+ sessionListHTML = `<div class="export-session-list">
347
+ <div class="export-session-item export-all-btn active" onclick="filterBySession(null)">全部</div>
348
+ ${items}
349
+ </div>`;
350
+ }
351
+
352
+ computeTokensFromContext();
353
+ let tokenHTML = '';
354
+ if (totalInput > 0 || totalOutput > 0) {
355
+ let tokStr = `Input: ${fmtTok(totalInput)} · Output: ${fmtTok(totalOutput)}`;
356
+ if (totalCacheCreate > 0 || totalCacheRead > 0) tokStr += ` · Cache: ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
357
+ tokenHTML = `<div class="export-meta-line" style="color:var(--dim)">Tokens: ${tokStr}</div>`;
358
+ }
359
+
360
+ const filterState = [];
361
+ if (!showThinking) filterState.push('thinking hidden');
362
+ if (!showToolInput) filterState.push('tools hidden');
363
+ if (!showToolOutput) filterState.push('output hidden');
364
+ if (!showText) filterState.push('text hidden');
365
+ if (!showHook) filterState.push('hook hidden');
366
+ let filterHTML = '';
367
+ if (filterState.length > 0) filterHTML = `<div class="export-meta-line" style="color:var(--dim)">Filters: ${filterState.join(', ')}</div>`;
368
+
369
+ const now = new Date();
370
+ const exportTime = fmtTimestamp(now);
371
+ const timeHTML = `<div class="export-meta-line" style="color:var(--dim)">Exported: ${exportTime}</div>`;
372
+
373
+ const clone = streamEl.cloneNode(true);
374
+ clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
375
+ clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
376
+
377
+ if (selectedIds) {
378
+ clone.querySelectorAll('[data-session-id]').forEach(el => {
379
+ if (!selectedIds.has(el.dataset.sessionId)) el.remove();
380
+ });
381
+ }
382
+
383
+ const streamHTML = clone.innerHTML;
384
+ const hlCSS = theme === 'dark' ? hljsDarkCSS : hljsLightCSS;
385
+
386
+ const exportCSS = `
387
+ .export-session-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
388
+ .export-session-item { cursor: pointer; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--border); opacity: 0.7; transition: all 0.15s; font-size: 12px; display: flex; flex-direction: column; gap: 2px; }
389
+ .export-session-item:hover { opacity: 1; border-color: var(--dim); }
390
+ .export-session-item.active { opacity: 1; border-color: var(--purple); background: var(--purple); color: var(--white); }
391
+ .export-all-btn { font-weight: 600; align-items: center; }
392
+ .export-item-top { display: flex; align-items: baseline; gap: 4px; }
393
+ .export-item-sid { font-family: monospace; font-size: 10px; opacity: 0.8; }
394
+ .export-session-item.active .export-item-sid { opacity: 1; color: var(--white); }
395
+ .export-project { font-weight: 500; }
396
+ .export-model { font-size: 11px; }
397
+ .export-meta-line { padding: 2px 0; font-size: 11px; }
398
+ .export-header { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
399
+ .export-header h1 { margin: 0 0 4px 0; font-size: 16px; color: var(--white); }
400
+ `;
401
+
402
+ const exportJS = `
403
+ let _activeSid = null;
404
+ function filterBySession(sid) {
405
+ _activeSid = sid;
406
+ const lines = document.querySelectorAll('#export-stream [data-session-id]');
407
+ lines.forEach(el => {
408
+ el.style.display = (sid === null || el.dataset.sessionId === sid) ? '' : 'none';
409
+ });
410
+ document.querySelectorAll('.export-session-item[data-sid]').forEach(el => {
411
+ el.classList.toggle('active', sid !== null && el.dataset.sid === sid);
412
+ });
413
+ document.querySelector('.export-all-btn').classList.toggle('active', sid === null);
414
+ }
415
+ `;
416
+
417
+ const htmlAttrs = theme === 'light' ? ' lang="en" data-theme="light"' : ' lang="en"';
418
+ const fullDoc = `<!DOCTYPE html>
419
+ <html${htmlAttrs}>
420
+ <head>
421
+ <meta charset="UTF-8">
422
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
423
+ <title>claude-watch Export</title>
424
+ <style>
425
+ ${appCSS}
426
+ ${hlCSS}
427
+ ${exportCSS}
428
+ </style>
429
+ </head>
430
+ <body style="overflow-y:auto;height:auto">
431
+ <div class="export-header">
432
+ <h1>claude-watch Export</h1>
433
+ ${sessionListHTML}
434
+ ${tokenHTML}
435
+ ${filterHTML}
436
+ ${timeHTML}
437
+ </div>
438
+ <div id="export-stream" style="padding:8px 12px;font-size:12px">
439
+ ${streamHTML}
440
+ </div>
441
+ <script>${exportJS}<\/script>
442
+ </body>
443
+ </html>`;
444
+
445
+ const blob = new Blob([fullDoc], { type: 'text/html;charset=utf-8' });
446
+ const url = URL.createObjectURL(blob);
447
+ const a = document.createElement('a');
448
+
449
+ let filePrefix;
450
+ if (sidsInExport.size === 1) {
451
+ filePrefix = [...sidsInExport][0].split('-')[0].toUpperCase();
452
+ } else {
453
+ filePrefix = 'multi';
454
+ }
455
+ const pad = (n, len) => String(n).padStart(len, '0');
456
+ const ts = `${pad(now.getFullYear(),4)}${pad(now.getMonth()+1,2)}${pad(now.getDate(),2)}-${pad(now.getHours(),2)}${pad(now.getMinutes(),2)}${pad(now.getSeconds(),2)}`;
457
+ a.download = `claude-watch-${filePrefix}-${ts}.html`;
458
+ a.href = url;
459
+ document.body.appendChild(a);
460
+ a.click();
461
+ document.body.removeChild(a);
462
+ URL.revokeObjectURL(url);
463
+
464
+ const btn = document.getElementById('btn-export');
465
+ const orig = btn.textContent;
466
+ btn.textContent = '✓';
467
+ setTimeout(() => { btn.textContent = orig; }, 2000);
468
+ }
469
+
470
+ // ══════════════════════════════════════════════════════════════════════════════
471
+ // Init
472
+ // ══════════════════════════════════════════════════════════════════════════════
473
+
474
+ // Apply saved theme on load (default dark)
475
+ (function() {
476
+ const saved = localStorage.getItem('theme');
477
+ applyTheme(saved || 'dark');
478
+ })();
479
+
480
+ // Setup tree panel resize & scroll detection
481
+ setupTreeResize();
482
+ setupScrollDetection();
483
+
484
+ // Apply collapse-after from URL param
485
+ const urlParams = new URLSearchParams(location.search);
486
+ const ca = urlParams.get('collapseAfter');
487
+ if (ca) {
488
+ applyCollapsePolicy(parseInt(ca) || 0);
489
+ }
490
+
491
+ // Apply initial tab from URL hash (default: stream)
492
+ (function() {
493
+ const hash = location.hash.slice(1);
494
+ if (hash === 'tokens' || hash === 'stream') {
495
+ switchTab(hash);
496
+ }
497
+ })();
498
+
499
+ // Connect WebSocket
500
+ connect();