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