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.
- package/package.json +1 -1
- package/public/css/app.css +500 -0
- package/public/index.html +39 -2512
- package/public/js/app.js +500 -0
- package/public/js/shared.js +245 -0
- package/public/js/stream.js +1076 -0
- package/public/js/token.js +458 -0
- package/src/scanner/scanner.js +18 -9
- package/src/server/server.js +87 -14
- package/src/watcher/watcher.js +103 -65
package/public/js/app.js
ADDED
|
@@ -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();
|