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/index.html
CHANGED
|
@@ -6,460 +6,31 @@
|
|
|
6
6
|
<title>claude-watch</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
8
8
|
<link rel="stylesheet" href="vendor/github-dark.min.css">
|
|
9
|
-
<
|
|
10
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
11
|
-
|
|
12
|
-
:root {
|
|
13
|
-
--bg: #111827;
|
|
14
|
-
--bg2: #1f2937;
|
|
15
|
-
--bg3: #374151;
|
|
16
|
-
--border: #4b5563;
|
|
17
|
-
--text: #d1d5db;
|
|
18
|
-
--dim: #6b7280;
|
|
19
|
-
--white: #f9fafb;
|
|
20
|
-
--purple: #7c3aed;
|
|
21
|
-
--purple2: #5b21b6;
|
|
22
|
-
--blue: #3b82f6;
|
|
23
|
-
--magenta: #c084fc;
|
|
24
|
-
--yellow: #fbbf24;
|
|
25
|
-
--yellow2: #92400e;
|
|
26
|
-
--green: #34d399;
|
|
27
|
-
--cyan: #22d3ee;
|
|
28
|
-
--red: #f87171;
|
|
29
|
-
--red2: #dc2626;
|
|
30
|
-
--gray: #9ca3af;
|
|
31
|
-
--orange: #fb923c;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
:root[data-theme="light"] {
|
|
35
|
-
--bg: #f8f9fa;
|
|
36
|
-
--bg2: #e9ecef;
|
|
37
|
-
--bg3: #ced4da;
|
|
38
|
-
--border: #adb5bd;
|
|
39
|
-
--text: #495057;
|
|
40
|
-
--dim: #868e96;
|
|
41
|
-
--white: #212529;
|
|
42
|
-
--purple: #6741d9;
|
|
43
|
-
--purple2: #5b21b6;
|
|
44
|
-
--blue: #2563eb;
|
|
45
|
-
--magenta: #9333ea;
|
|
46
|
-
--yellow: #d97706;
|
|
47
|
-
--yellow2: #92400e;
|
|
48
|
-
--green: #059669;
|
|
49
|
-
--cyan: #0891b2;
|
|
50
|
-
--red: #dc2626;
|
|
51
|
-
--red2: #b91c1c;
|
|
52
|
-
--gray: #6b7280;
|
|
53
|
-
--orange: #ea580c;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
body {
|
|
57
|
-
background: var(--bg);
|
|
58
|
-
color: var(--text);
|
|
59
|
-
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
60
|
-
font-size: 13px;
|
|
61
|
-
line-height: 1.5;
|
|
62
|
-
height: 100vh;
|
|
63
|
-
display: flex;
|
|
64
|
-
flex-direction: column;
|
|
65
|
-
overflow: hidden;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/* ── Header ── */
|
|
69
|
-
#header {
|
|
70
|
-
height: 32px;
|
|
71
|
-
background: var(--bg3);
|
|
72
|
-
display: flex;
|
|
73
|
-
align-items: center;
|
|
74
|
-
padding: 0 12px;
|
|
75
|
-
gap: 8px;
|
|
76
|
-
font-size: 12px;
|
|
77
|
-
color: var(--white);
|
|
78
|
-
flex-shrink: 0;
|
|
79
|
-
user-select: none;
|
|
80
|
-
}
|
|
81
|
-
#header .sep { color: var(--dim); margin: 0 4px; }
|
|
82
|
-
#header .auto { margin-left: auto; display: flex; gap: 4px; align-items: center; }
|
|
83
|
-
|
|
84
|
-
.btn {
|
|
85
|
-
position: relative;
|
|
86
|
-
display: inline-flex; align-items: center; gap: 3px;
|
|
87
|
-
padding: 2px 8px; border-radius: 4px; border: 1px solid var(--border);
|
|
88
|
-
background: var(--bg2); color: var(--text); cursor: pointer;
|
|
89
|
-
font-family: inherit; font-size: 11px; line-height: 1.5;
|
|
90
|
-
white-space: nowrap; user-select: none; transition: all 0.15s;
|
|
91
|
-
}
|
|
92
|
-
.btn[data-tooltip]:hover::after {
|
|
93
|
-
content: attr(data-tooltip);
|
|
94
|
-
position: absolute;
|
|
95
|
-
top: calc(100% + 5px);
|
|
96
|
-
left: 0;
|
|
97
|
-
background: var(--bg2); color: var(--white);
|
|
98
|
-
padding: 2px 8px; border-radius: 4px;
|
|
99
|
-
font-size: 10px; white-space: nowrap; z-index: 9999;
|
|
100
|
-
pointer-events: none; border: 1px solid var(--border);
|
|
101
|
-
font-family: inherit; line-height: 1.4;
|
|
102
|
-
}
|
|
103
|
-
.btn:hover { background: var(--bg3); border-color: var(--dim); }
|
|
104
|
-
.btn.on { background: var(--purple); border-color: var(--purple); color: var(--white); }
|
|
105
|
-
.btn.on:hover { background: var(--purple2); }
|
|
106
|
-
.btn.on:hover::after { background: var(--purple2); }
|
|
107
|
-
.btn.danger { border-color: var(--red2); color: var(--red); }
|
|
108
|
-
.btn.danger:hover { background: var(--red2); color: var(--white); }
|
|
109
|
-
.btn.accent { border-color: var(--yellow2); color: var(--yellow); }
|
|
110
|
-
.btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
111
|
-
.btn-icon { padding: 2px 6px; min-width: 28px; justify-content: center; }
|
|
112
|
-
|
|
113
|
-
/* ── Main area ── */
|
|
114
|
-
#main {
|
|
115
|
-
display: flex;
|
|
116
|
-
flex: 1;
|
|
117
|
-
overflow: hidden;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/* ── Tree panel ── */
|
|
121
|
-
#tree-panel {
|
|
122
|
-
width: 30%; min-width: 180px; max-width: 60%;
|
|
123
|
-
border-right: 1px solid var(--border);
|
|
124
|
-
background: var(--bg2);
|
|
125
|
-
overflow: hidden;
|
|
126
|
-
display: flex; flex-direction: column;
|
|
127
|
-
flex-shrink: 0;
|
|
128
|
-
position: relative;
|
|
129
|
-
}
|
|
130
|
-
#tree-panel.hidden { display: none; }
|
|
131
|
-
|
|
132
|
-
#tree-resize-handle {
|
|
133
|
-
position: absolute; right: -3px; top: 0; bottom: 0;
|
|
134
|
-
width: 6px; cursor: col-resize; z-index: 10;
|
|
135
|
-
}
|
|
136
|
-
#tree-resize-handle:hover, #tree-resize-handle.active { background: var(--purple); }
|
|
137
|
-
|
|
138
|
-
#tree-toolbar {
|
|
139
|
-
display: flex; gap: 4px; padding: 4px 6px;
|
|
140
|
-
border-bottom: 1px solid var(--border);
|
|
141
|
-
flex-shrink: 0; flex-wrap: wrap;
|
|
142
|
-
}
|
|
143
|
-
#tree-content {
|
|
144
|
-
flex: 1; overflow-y: auto; overflow-x: hidden; padding: 4px 0;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/* ── Tree node styles ── */
|
|
148
|
-
.tree-row {
|
|
149
|
-
display: flex; align-items: flex-start;
|
|
150
|
-
}
|
|
151
|
-
.tree-row-session {
|
|
152
|
-
margin-top: 6px;
|
|
153
|
-
}
|
|
154
|
-
.tree-row-folder {
|
|
155
|
-
margin-top: 6px;
|
|
156
|
-
}
|
|
157
|
-
.folder-node {
|
|
158
|
-
font-size: 13px; font-weight: 500; color: var(--yellow);
|
|
159
|
-
}
|
|
160
|
-
.tree-content {
|
|
161
|
-
flex: 1; min-width: 0;
|
|
162
|
-
}
|
|
163
|
-
.tree-content:hover { background: rgba(255,255,255,0.05); }
|
|
164
|
-
.tree-row.selected > .tree-content { background: rgba(124,58,237,0.3); }
|
|
165
|
-
.tree-content.dim { opacity: 0.4; }
|
|
166
|
-
.tree-node {
|
|
167
|
-
display: flex; align-items: center;
|
|
168
|
-
padding: 3px 2px 3px 0;
|
|
169
|
-
cursor: pointer; white-space: nowrap; gap: 4px;
|
|
170
|
-
overflow: hidden;
|
|
171
|
-
}
|
|
172
|
-
.tree-prefix { color: var(--dim); font-size: 12px; flex-shrink: 0; letter-spacing: 0; font-family: monospace; white-space: pre; }
|
|
173
|
-
.tree-activity {
|
|
174
|
-
font-size: 10px; color: var(--dim); white-space: pre;
|
|
175
|
-
overflow: hidden; line-height: 1.2;
|
|
176
|
-
padding: 0 2px 2px; cursor: pointer;
|
|
177
|
-
}
|
|
178
|
-
.tree-activity .act-text {
|
|
179
|
-
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
180
|
-
}
|
|
181
|
-
.tree-node .ctx-pct { font-size: 10px; margin-left: 4px; flex-shrink: 0; }
|
|
182
|
-
.tree-node .ctx-pct.warn { color: var(--yellow); }
|
|
183
|
-
.tree-node .ctx-pct.danger { color: var(--red); }
|
|
184
|
-
.tree-node .active-dot { flex-shrink: 0; }
|
|
185
|
-
.tree-node .active-dot.on { color: var(--green); text-shadow: 0 0 6px var(--green); }
|
|
186
|
-
.tree-node .active-dot.off { color: #555; opacity: 1; }
|
|
187
|
-
.tree-node .tree-agent-id { font-size: 10px; color: var(--dim); margin-left: 2px; font-family: monospace; }
|
|
188
|
-
|
|
189
|
-
.tree-node .session-prefix {
|
|
190
|
-
background: rgba(255,255,255,0.08);
|
|
191
|
-
padding: 0 3px; border-radius: 3px; flex-shrink: 0; font-family: monospace;
|
|
192
|
-
letter-spacing: 0.5px; vertical-align: middle; font-weight: 600;
|
|
193
|
-
position: relative; cursor: pointer;
|
|
194
|
-
}
|
|
195
|
-
.session-id-tip {
|
|
196
|
-
position: fixed; z-index: 10000;
|
|
197
|
-
background: var(--bg2); border: 1px solid var(--border); border-radius: 4px;
|
|
198
|
-
padding: 4px 8px; font-size: 11px; white-space: nowrap; color: var(--text);
|
|
199
|
-
box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: flex; align-items: center; gap: 6px;
|
|
200
|
-
}
|
|
201
|
-
.session-id-tip code { font-family: monospace; user-select: all; color: var(--white); }
|
|
202
|
-
.session-id-tip .tip-copy-btn {
|
|
203
|
-
background: var(--bg3); border: 1px solid var(--border); border-radius: 3px;
|
|
204
|
-
color: var(--text); font-size: 10px; padding: 1px 5px; cursor: pointer;
|
|
205
|
-
}
|
|
206
|
-
.session-id-tip .tip-copy-btn:hover { background: var(--accent); color: #fff; }
|
|
207
|
-
.tree-actions { display: none; gap: 2px; padding-right: 4px; }
|
|
208
|
-
.tree-row:hover .tree-actions { display: flex; }
|
|
209
|
-
.tree-row.selected>.tree-actions { display: flex; }
|
|
210
|
-
|
|
211
|
-
/* ── Tokens page ── */
|
|
212
|
-
#tokens-page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 20px 24px; gap: 16px; background: var(--bg); }
|
|
213
|
-
.tp-top { display: flex; gap: 16px; }
|
|
214
|
-
.tp-left { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
|
|
215
|
-
.tp-right { flex: 1; display: flex; flex-direction: column; gap: 12px; }
|
|
216
|
-
.tp-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
|
|
217
|
-
.tp-total-label { font-size: 11px; color: var(--dim); text-transform: uppercase; margin-bottom: 2px; }
|
|
218
|
-
.tp-total-value { font-size: 22px; font-weight: 700; color: var(--white); font-family: monospace; }
|
|
219
|
-
.tp-total-sub { font-size: 10px; color: var(--dim); margin-top: 2px; }
|
|
220
|
-
.tp-stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
|
221
|
-
.tp-stat { padding: 8px 10px; background: var(--bg3); border-radius: 6px; }
|
|
222
|
-
.tp-stat .tp-s-l { font-size: 10px; color: var(--dim); }
|
|
223
|
-
.tp-stat .tp-s-v { font-size: 13px; font-weight: 600; color: var(--white); font-family: monospace; }
|
|
224
|
-
.tp-rank-title { font-size: 12px; color: var(--dim); font-weight: 600; margin-bottom: 8px; }
|
|
225
|
-
.tp-rank-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); }
|
|
226
|
-
.tp-rank-num { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: var(--bg3); font-size: 10px; font-weight: 600; color: var(--dim); flex-shrink: 0; }
|
|
227
|
-
.tp-rank-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
228
|
-
.tp-rank-name { font-size: 12px; color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
229
|
-
.tp-rank-pct { font-size: 12px; font-weight: 600; color: var(--white); font-family: monospace; flex-shrink: 0; }
|
|
230
|
-
.tp-footer-stats { display: flex; justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--dim); }
|
|
231
|
-
.tp-footer-stats .tp-fv { color: var(--text); font-family: monospace; }
|
|
232
|
-
.tp-h3 { font-size: 12px; color: var(--dim); font-weight: 600; text-transform: uppercase; margin-bottom: 8px; }
|
|
233
|
-
|
|
234
|
-
/* ── Heatmap ── */
|
|
235
|
-
.tp-heatmap { overflow-x: auto; }
|
|
236
|
-
.tp-heatmap-inner { display: inline-flex; flex-direction: column; gap: 2px; }
|
|
237
|
-
.tp-hm-months { display: flex; gap: 0; font-size: 10px; color: var(--dim); margin-bottom: 2px; padding-left: 28px; }
|
|
238
|
-
.tp-hm-row { display: flex; align-items: center; gap: 2px; }
|
|
239
|
-
.tp-hm-day-label { width: 24px; font-size: 10px; color: var(--dim); text-align: right; flex-shrink: 0; }
|
|
240
|
-
.tp-hm-cell { width: 12px; height: 12px; border-radius: 2px; transition: transform 0.15s; cursor: pointer; position: relative; }
|
|
241
|
-
.tp-hm-cell:hover { transform: scale(1.6); z-index: 10; }
|
|
242
|
-
.tp-hm-cell[title]:hover::after { content: attr(title); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
|
|
243
|
-
.tp-hm-legend { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--dim); margin-top: 6px; justify-content: flex-end; }
|
|
244
|
-
.tp-hm-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
|
|
245
|
-
|
|
246
|
-
/* ── Trend bars ── */
|
|
247
|
-
.tp-trend-bars { display: flex; align-items: flex-end; gap: 3px; height: 140px; position: relative; padding-bottom: 20px; }
|
|
248
|
-
.tp-trend-bar-wrap { flex: 1; display: flex; flex-direction: column; position: relative; min-width: 0; }
|
|
249
|
-
.tp-trend-bar { position: relative; border-radius: 3px 3px 0 0; transition: all 0.15s; cursor: pointer; min-height: 4px; }
|
|
250
|
-
.tp-trend-bar:hover { filter: brightness(1.3); }
|
|
251
|
-
.tp-trend-bar:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
|
|
252
|
-
.tp-trend-label { font-size: 9px; color: var(--dim); text-align: center; position: absolute; bottom: 0; width: 100%; white-space: nowrap; overflow: hidden; }
|
|
253
|
-
.tp-trend-grid-lines { position: absolute; inset: 0 0 20px 0; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
|
|
254
|
-
.tp-trend-grid-line { width: 100%; border-top: 1px dashed var(--border); opacity: 0.4; }
|
|
255
|
-
|
|
256
|
-
/* ── Detail table ── */
|
|
257
|
-
.tp-tabs { display: flex; gap: 2px; }
|
|
258
|
-
.tp-tab { padding: 5px 12px; cursor: pointer; border-radius: 6px 6px 0 0; background: var(--bg3); color: var(--dim); border: 1px solid var(--border); border-bottom: none; font-size: 12px; font-weight: 500; }
|
|
259
|
-
.tp-tab.active { background: var(--bg2); color: var(--white); font-weight: 600; }
|
|
260
|
-
.tp-tc { display: none; }
|
|
261
|
-
.tp-tc.active { display: block; }
|
|
262
|
-
.tp-st { max-height: 480px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; }
|
|
263
|
-
.tp-table { width: 100%; border-collapse: collapse; font-size: 11px; background: var(--bg2); }
|
|
264
|
-
.tp-table th { background: var(--bg3); color: var(--white); padding: 7px 10px; text-align: left; font-weight: 600; white-space: nowrap; }
|
|
265
|
-
.tp-table td { padding: 6px 10px; border-top: 1px solid var(--border); white-space: nowrap; font-family: monospace; color: var(--text); }
|
|
266
|
-
.tp-table tr:hover td { background: var(--bg3); }
|
|
267
|
-
.tp-table tfoot td { font-weight: 700; background: var(--bg3); }
|
|
268
|
-
.tp-mtag { display: inline-block; padding: 1px 6px; border-radius: 3px; border: 1px solid; font-size: 10px; margin: 1px; font-weight: 500; }
|
|
269
|
-
.tp-mbreak { white-space: normal; min-width: 200px; }
|
|
270
|
-
|
|
271
|
-
/* ── Stream panel ── */
|
|
272
|
-
#stream-panel-wrap {
|
|
273
|
-
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
|
274
|
-
}
|
|
275
|
-
#stream-toolbar {
|
|
276
|
-
display: flex; gap: 4px; padding: 4px 8px;
|
|
277
|
-
border-bottom: 1px solid var(--border);
|
|
278
|
-
background: var(--bg); flex-shrink: 0;
|
|
279
|
-
}
|
|
280
|
-
#stream-panel {
|
|
281
|
-
flex: 1; overflow-y: auto; padding: 8px 12px;
|
|
282
|
-
font-size: 12px;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/* ── Stream lines ── */
|
|
286
|
-
.stream-line { white-space: pre-wrap; word-break: break-all; }
|
|
287
|
-
.stream-line.thinking { color: var(--magenta); }
|
|
288
|
-
.stream-line.tool-input { color: var(--yellow); }
|
|
289
|
-
.stream-line.tool-output { color: var(--green); }
|
|
290
|
-
.stream-line.text { color: var(--text); }
|
|
291
|
-
.stream-line.hook { color: var(--cyan); }
|
|
292
|
-
.stream-line .hook-label { color: var(--dim); }
|
|
293
|
-
.stream-line.diag { color: var(--red); }
|
|
294
|
-
.stream-line.debug { color: var(--gray); }
|
|
295
|
-
.stream-line.marker { color: var(--dim); }
|
|
296
|
-
.stream-line.agent-tag { font-weight: bold; display: flex; justify-content: space-between; align-items: baseline; white-space: nowrap; }
|
|
297
|
-
.stream-line.agent-main { color: var(--blue); }
|
|
298
|
-
.stream-line.agent-sub { color: var(--magenta); }
|
|
299
|
-
.stream-line.agent-tag .tag-label { flex-shrink: 0; }
|
|
300
|
-
.stream-line.agent-tag .timestamp { font-weight: normal; font-size: 0.85em; color: var(--dim); white-space: nowrap; }
|
|
301
|
-
.stream-line.separator { color: var(--dim); }
|
|
302
|
-
.stream-line.user-prompt-block {
|
|
303
|
-
background: rgba(59,130,246,0.08);
|
|
304
|
-
border-left: 8px solid var(--blue);
|
|
305
|
-
border-radius: 4px;
|
|
306
|
-
padding: 6px 10px;
|
|
307
|
-
margin: 4px 0;
|
|
308
|
-
color: var(--white);
|
|
309
|
-
line-height: 1.5;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/* ── Footer ── */
|
|
313
|
-
#footer {
|
|
314
|
-
height: 28px; background: var(--bg2);
|
|
315
|
-
border-top: 1px solid var(--border);
|
|
316
|
-
display: flex; align-items: center;
|
|
317
|
-
padding: 0 8px; gap: 6px;
|
|
318
|
-
font-size: 11px; flex-shrink: 0; flex-wrap: wrap;
|
|
319
|
-
}
|
|
320
|
-
#footer .sep { color: var(--dim); margin: 0 2px; }
|
|
321
|
-
|
|
322
|
-
/* ── Scrollbar ── */
|
|
323
|
-
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
324
|
-
::-webkit-scrollbar-track { background: transparent; }
|
|
325
|
-
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
326
|
-
|
|
327
|
-
/* ── Focus ring ── */
|
|
328
|
-
#stream-panel:focus { outline: none; }
|
|
329
|
-
|
|
330
|
-
/* ── Markdown & code blocks ── */
|
|
331
|
-
.md-content { line-height: 1.6; color: var(--text); }
|
|
332
|
-
.md-content p { margin: 4px 0; }
|
|
333
|
-
.md-content ul, .md-content ol { padding-left: 20px; margin: 4px 0; }
|
|
334
|
-
.md-content li { margin: 2px 0; }
|
|
335
|
-
.md-content strong { color: var(--white); }
|
|
336
|
-
.md-content a { color: var(--blue); text-decoration: underline; }
|
|
337
|
-
.md-content h1, .md-content h2, .md-content h3, .md-content h4,
|
|
338
|
-
.md-content h5, .md-content h6 { color: var(--white); margin: 8px 0 4px; font-size: inherit; font-weight: bold; }
|
|
339
|
-
.md-content blockquote { border-left: 3px solid var(--purple); padding-left: 12px; color: var(--dim); margin: 4px 0; }
|
|
340
|
-
.md-content hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
|
|
341
|
-
.md-content table { border-collapse: collapse; margin: 4px 0; width: 100%; }
|
|
342
|
-
.md-content th, .md-content td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
|
|
343
|
-
.md-content th { background: var(--bg3); color: var(--white); }
|
|
344
|
-
.md-content img { max-width: 100%; }
|
|
345
|
-
|
|
346
|
-
.code-block-wrapper { margin: 8px 0; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
|
|
347
|
-
.code-block-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: var(--bg3); font-size: 11px; color: var(--dim); }
|
|
348
|
-
.code-block-header .lang-tag { color: var(--blue); font-weight: bold; }
|
|
349
|
-
.code-block-header .copy-btn { cursor: pointer; opacity: 0.5; transition: opacity 0.2s; font-size: 11px; }
|
|
350
|
-
.code-block-header .copy-btn:hover { opacity: 1; }
|
|
351
|
-
.code-block-wrapper pre { margin: 0; padding: 12px; overflow-x: auto; font-size: 12px; line-height: 1.5; }
|
|
352
|
-
.code-block-wrapper pre code { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; font-size: 12px; }
|
|
353
|
-
|
|
354
|
-
/* Override highlight.js background to match our theme */
|
|
355
|
-
.hljs { background: #0d1117 !important; }
|
|
356
|
-
|
|
357
|
-
/* Light theme overrides */
|
|
358
|
-
:root[data-theme="light"] .btn.on { background: var(--purple); border-color: var(--purple); color: #fff; }
|
|
359
|
-
:root[data-theme="light"] .btn.on:hover { background: var(--purple2); color: #fff; }
|
|
360
|
-
:root[data-theme="light"] .btn.on:hover::after { background: var(--purple2); color: #fff; }
|
|
361
|
-
:root[data-theme="light"] .hljs { background: #f0f0f0 !important; }
|
|
362
|
-
:root[data-theme="light"] .tree-content:hover { background: rgba(0,0,0,0.06); }
|
|
363
|
-
:root[data-theme="light"] .tree-row.selected > .tree-content { background: rgba(124,58,237,0.2); }
|
|
364
|
-
:root[data-theme="light"] .tree-node .active-dot.off { color: #bbb; }
|
|
365
|
-
:root[data-theme="light"] #tree-resize-handle:hover,
|
|
366
|
-
:root[data-theme="light"] #tree-resize-handle.active { background: var(--purple); }
|
|
367
|
-
:root[data-theme="light"] .stream-line.text { color: var(--text); }
|
|
368
|
-
|
|
369
|
-
/* Theme toggle button */
|
|
370
|
-
#btn-theme { font-size: 14px; }
|
|
371
|
-
|
|
372
|
-
/* ── Export modal ── */
|
|
373
|
-
.modal-overlay {
|
|
374
|
-
position: fixed; inset: 0;
|
|
375
|
-
background: rgba(0, 0, 0, 0.6);
|
|
376
|
-
z-index: 10000;
|
|
377
|
-
display: flex; align-items: center; justify-content: center;
|
|
378
|
-
}
|
|
379
|
-
:root[data-theme="light"] .modal-overlay { background: rgba(0, 0, 0, 0.3); }
|
|
380
|
-
:root[data-theme="light"] .modal-session-row.selected { background: rgba(124, 58, 237, 0.2); }
|
|
381
|
-
|
|
382
|
-
.modal-box {
|
|
383
|
-
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
|
384
|
-
width: 480px; max-width: 90vw; max-height: 80vh;
|
|
385
|
-
display: flex; flex-direction: column; overflow: hidden;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
.modal-header {
|
|
389
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
390
|
-
padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
|
|
391
|
-
}
|
|
392
|
-
.modal-title { font-size: 13px; font-weight: 600; color: var(--white); }
|
|
393
|
-
|
|
394
|
-
.modal-toolbar {
|
|
395
|
-
display: flex; align-items: center; gap: 4px;
|
|
396
|
-
padding: 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
397
|
-
}
|
|
398
|
-
.modal-count { margin-left: auto; font-size: 11px; color: var(--dim); }
|
|
399
|
-
|
|
400
|
-
.modal-body { flex: 1; overflow-y: auto; padding: 6px 0; }
|
|
401
|
-
|
|
402
|
-
.modal-session-row {
|
|
403
|
-
display: flex; align-items: center; gap: 8px;
|
|
404
|
-
padding: 6px 12px; cursor: pointer; transition: background 0.1s; user-select: none;
|
|
405
|
-
}
|
|
406
|
-
.modal-session-row:hover { background: var(--bg2); }
|
|
407
|
-
.modal-session-row.selected { background: rgba(124, 58, 237, 0.15); }
|
|
408
|
-
|
|
409
|
-
.modal-checkbox {
|
|
410
|
-
appearance: none; width: 16px; height: 16px;
|
|
411
|
-
border: 1px solid var(--border); border-radius: 3px; background: var(--bg2);
|
|
412
|
-
cursor: pointer; position: relative; flex-shrink: 0; transition: all 0.15s;
|
|
413
|
-
}
|
|
414
|
-
.modal-checkbox:checked { background: var(--purple); border-color: var(--purple); }
|
|
415
|
-
.modal-checkbox:checked::after {
|
|
416
|
-
content: '✓'; position: absolute; inset: 0;
|
|
417
|
-
display: flex; align-items: center; justify-content: center;
|
|
418
|
-
color: var(--white); font-size: 11px; font-weight: bold;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
.modal-session-prefix {
|
|
422
|
-
font-family: monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; flex-shrink: 0;
|
|
423
|
-
}
|
|
424
|
-
.modal-session-info {
|
|
425
|
-
flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px; overflow: hidden;
|
|
426
|
-
}
|
|
427
|
-
.modal-session-project {
|
|
428
|
-
font-size: 12px; font-weight: 500; color: var(--text);
|
|
429
|
-
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
430
|
-
}
|
|
431
|
-
.modal-session-model { font-size: 10px; color: var(--dim); flex-shrink: 0; }
|
|
432
|
-
.modal-session-time { font-size: 10px; color: var(--dim); flex-shrink: 0; margin-left: auto; }
|
|
433
|
-
|
|
434
|
-
.modal-footer {
|
|
435
|
-
display: flex; align-items: center; justify-content: flex-end; gap: 6px;
|
|
436
|
-
padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
|
|
437
|
-
}
|
|
438
|
-
</style>
|
|
9
|
+
<link rel="stylesheet" href="css/app.css">
|
|
439
10
|
</head>
|
|
440
11
|
<body>
|
|
441
12
|
|
|
442
13
|
<div id="header">
|
|
443
14
|
<button class="btn on" id="tab-stream" onclick="switchTab('stream')">📡 Stream</button>
|
|
444
15
|
<button class="btn" id="tab-tokens" onclick="switchTab('tokens')">📊 Tokens</button>
|
|
445
|
-
<span class="sep">│</span>
|
|
446
|
-
<button class="btn on" id="btn-thinking" onclick="toggleThinking()" data-tooltip="Toggle thinking">🧠 Thinking</button>
|
|
447
|
-
<button class="btn on" id="btn-tool-input" onclick="toggleToolInput()" data-tooltip="Toggle tool input">🔧 Tools</button>
|
|
448
|
-
<button class="btn on" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
|
|
449
|
-
<button class="btn on" id="btn-text" onclick="toggleText()" data-tooltip="Toggle text responses">💬 Text</button>
|
|
450
|
-
<button class="btn on" id="btn-hook" onclick="toggleHook()" data-tooltip="Toggle hook output">🪝 Hook</button>
|
|
451
|
-
<button class="btn on" id="btn-user-prompt" onclick="toggleUserPrompt()" data-tooltip="Toggle user prompt">👤 Prompt</button>
|
|
452
|
-
<span class="sep">│</span>
|
|
453
|
-
<button class="btn on" id="btn-autoscroll" onclick="toggleAutoScroll()" data-tooltip="Auto-scroll">⇣ Auto</button>
|
|
454
|
-
<button class="btn" id="btn-tree-toggle" onclick="toggleTree()" data-tooltip="Toggle tree panel">📂 Tree</button>
|
|
455
|
-
<span class="sep">│</span>
|
|
456
|
-
<span id="session-info">Connecting...</span>
|
|
16
|
+
<span class="stream-only sep">│</span>
|
|
17
|
+
<button class="btn on stream-only" id="btn-thinking" onclick="toggleThinking()" data-tooltip="Toggle thinking">🧠 Thinking</button>
|
|
18
|
+
<button class="btn on stream-only" id="btn-tool-input" onclick="toggleToolInput()" data-tooltip="Toggle tool input">🔧 Tools</button>
|
|
19
|
+
<button class="btn on stream-only" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
|
|
20
|
+
<button class="btn on stream-only" id="btn-text" onclick="toggleText()" data-tooltip="Toggle text responses">💬 Text</button>
|
|
21
|
+
<button class="btn on stream-only" id="btn-hook" onclick="toggleHook()" data-tooltip="Toggle hook output">🪝 Hook</button>
|
|
22
|
+
<button class="btn on stream-only" id="btn-user-prompt" onclick="toggleUserPrompt()" data-tooltip="Toggle user prompt">👤 Prompt</button>
|
|
23
|
+
<span class="stream-only sep">│</span>
|
|
24
|
+
<button class="btn on stream-only" id="btn-autoscroll" onclick="toggleAutoScroll()" data-tooltip="Auto-scroll">⇣ Auto</button>
|
|
25
|
+
<button class="btn stream-only" id="btn-tree-toggle" onclick="toggleTree()" data-tooltip="Toggle tree panel">📂 Tree</button>
|
|
26
|
+
<span class="stream-only sep">│</span>
|
|
27
|
+
<span class="stream-only" id="session-info">Connecting...</span>
|
|
457
28
|
<div class="auto">
|
|
458
|
-
<button class="btn btn-icon" id="btn-export" onclick="openExportModal()" data-tooltip="导出 HTML">💾</button>
|
|
29
|
+
<button class="btn btn-icon stream-only" id="btn-export" onclick="openExportModal()" data-tooltip="导出 HTML">💾</button>
|
|
459
30
|
<button class="btn btn-icon" id="btn-theme" onclick="toggleTheme()" data-tooltip="Toggle theme">🌙</button>
|
|
460
|
-
<button class="btn on" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
|
|
461
|
-
<span class="sep">│</span>
|
|
462
|
-
<span id="token-info"></span>
|
|
31
|
+
<button class="btn on stream-only" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
|
|
32
|
+
<span class="stream-only sep">│</span>
|
|
33
|
+
<span class="stream-only" id="token-info"></span>
|
|
463
34
|
</div>
|
|
464
35
|
</div>
|
|
465
36
|
|
|
@@ -488,19 +59,25 @@ body {
|
|
|
488
59
|
</div>
|
|
489
60
|
|
|
490
61
|
<div id="tokens-page" style="display:none">
|
|
491
|
-
|
|
62
|
+
<div class="tp-refresh-bar">
|
|
63
|
+
<span class="tp-refresh-info" id="tp-refresh-info">Token 统计不会自动刷新,点击按钮手动更新</span>
|
|
64
|
+
<button class="btn" id="btn-refresh-tokens" onclick="refreshTokenStats()">🔄 刷新数据</button>
|
|
65
|
+
</div>
|
|
492
66
|
<div class="tp-top">
|
|
493
|
-
<div class="tp-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
<div class="tp-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
67
|
+
<div class="tp-box" id="tp-total-card"></div>
|
|
68
|
+
<div class="tp-box" id="tp-stats-grid"></div>
|
|
69
|
+
<div class="tp-box" id="tp-model-rank"></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="tp-charts-row" id="tp-charts-row">
|
|
72
|
+
<div class="tp-chart-box" id="tp-weekly-chart" role="img" aria-label="Weekly token consumption chart"></div>
|
|
73
|
+
<div class="tp-chart-box" id="tp-monthly-chart" role="img" aria-label="Monthly token consumption chart"></div>
|
|
74
|
+
<div class="tp-chart-box" id="tp-model-pie" role="img" aria-label="Model token proportion chart"></div>
|
|
75
|
+
<div class="tp-chart-box" id="tp-hourly-chart" role="img" aria-label="Active time distribution chart"></div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="tp-row2">
|
|
78
|
+
<div class="tp-box" id="tp-trend-card"></div>
|
|
79
|
+
<div class="tp-box" id="tp-heatmap-card"></div>
|
|
502
80
|
</div>
|
|
503
|
-
<!-- Detail table -->
|
|
504
81
|
<div class="tp-box">
|
|
505
82
|
<div class="tp-tabs" id="tp-detail-tabs"></div>
|
|
506
83
|
<div id="tp-tc-daily" class="tp-tc"><div class="tp-st" id="tp-daily-table"></div></div>
|
|
@@ -515,7 +92,7 @@ body {
|
|
|
515
92
|
<span id="item-count">0 items</span>
|
|
516
93
|
<span class="sep">│</span>
|
|
517
94
|
<span id="footer-version" style="margin-left:auto;font-size:10px;color:var(--dim)"></span>
|
|
518
|
-
|
|
95
|
+
</div>
|
|
519
96
|
|
|
520
97
|
<div id="export-modal" class="modal-overlay" style="display:none">
|
|
521
98
|
<div class="modal-box">
|
|
@@ -539,2059 +116,9 @@ body {
|
|
|
539
116
|
<script src="vendor/highlight.min.js"></script>
|
|
540
117
|
<script src="vendor/marked.min.js"></script>
|
|
541
118
|
<script src="vendor/purify.min.js"></script>
|
|
542
|
-
<script>
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const streamEl = document.getElementById('stream-panel');
|
|
548
|
-
const treeEl = document.getElementById('tree-content');
|
|
549
|
-
const sessionInfo = document.getElementById('session-info');
|
|
550
|
-
const tokenInfo = document.getElementById('token-info');
|
|
551
|
-
const treeCursorInfo = document.getElementById('tree-cursor-info');
|
|
552
|
-
|
|
553
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
554
|
-
// State
|
|
555
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
556
|
-
|
|
557
|
-
let ws = null;
|
|
558
|
-
let reconnectTimer = null;
|
|
559
|
-
let reconnectDelay = 1000;
|
|
560
|
-
const MaxReconnectDelay = 30000;
|
|
561
|
-
const MaxReconnectAttempts = 20;
|
|
562
|
-
let reconnectAttempts = 0;
|
|
563
|
-
let showTree = true;
|
|
564
|
-
let autoScroll = true;
|
|
565
|
-
let lastMsgTime = 0;
|
|
566
|
-
let staleCheckTimer = null;
|
|
567
|
-
|
|
568
|
-
let sessions = [];
|
|
569
|
-
let sessionsMap = new Map(); // id -> session, for O(1) lookups
|
|
570
|
-
let treeNodes = [];
|
|
571
|
-
let treeCursor = 0;
|
|
572
|
-
let folderCollapsed = {}; // dateStr -> boolean, default collapsed
|
|
573
|
-
let streamItems = [];
|
|
574
|
-
let visibleItems = [];
|
|
575
|
-
let visibleDirty = true;
|
|
576
|
-
// LRU cache: recently accessed keys survive eviction, so a tool_input's ID
|
|
577
|
-
// stays alive long enough for its matching tool_output to arrive and merge.
|
|
578
|
-
class LRUCache {
|
|
579
|
-
constructor(max) { this.max = max; this.map = new Map(); }
|
|
580
|
-
has(key) { if (!this.map.has(key)) return false; const v = this.map.get(key); this.map.delete(key); this.map.set(key, v); return true; }
|
|
581
|
-
get(key) { if (!this.map.has(key)) return undefined; const v = this.map.get(key); this.map.delete(key); this.map.set(key, v); return v; }
|
|
582
|
-
set(key, val) { if (this.map.has(key)) this.map.delete(key); this.map.set(key, val); if (this.map.size > this.max) { const oldest = this.map.keys().next().value; this.map.delete(oldest); } }
|
|
583
|
-
delete(key) { return this.map.delete(key); }
|
|
584
|
-
keys() { return this.map.keys(); }
|
|
585
|
-
}
|
|
586
|
-
const seenToolIDs = new LRUCache(20000);
|
|
587
|
-
const toolNameMap = new LRUCache(2000);
|
|
588
|
-
const agentActivity = new LRUCache(500); // "sessionID:agentID" → { toolName, content }
|
|
589
|
-
const taskDescriptions = new LRUCache(2000); // toolID → description string
|
|
590
|
-
const MAX_DESC_STORE = 200;
|
|
591
|
-
let filters = new Map();
|
|
592
|
-
let visibleFilterCount = 0;
|
|
593
|
-
|
|
594
|
-
let showThinking = true;
|
|
595
|
-
let showToolInput = true;
|
|
596
|
-
let showToolOutput = true;
|
|
597
|
-
let showText = true;
|
|
598
|
-
let showHook = true;
|
|
599
|
-
let showUserPrompt = true;
|
|
600
|
-
let showActivity = true;
|
|
601
|
-
let showTokenCount = true;
|
|
602
|
-
let autoDiscovery = true;
|
|
603
|
-
let appVersion = '';
|
|
604
|
-
let currentTab = 'stream';
|
|
605
|
-
let tokenStatsData = { totals: { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, days: 0 }, modelTotals: {}, daily: {} };
|
|
606
|
-
|
|
607
|
-
const HIDDEN_KEY = 'claude-watch-hidden';
|
|
608
|
-
function loadHiddenSessions() {
|
|
609
|
-
try {
|
|
610
|
-
const data = JSON.parse(localStorage.getItem(HIDDEN_KEY) || '{}');
|
|
611
|
-
const now = Date.now();
|
|
612
|
-
for (const [id, ts] of Object.entries(data)) {
|
|
613
|
-
if (now - ts < 24 * 60 * 60 * 1000) hiddenSessionIDs.add(id);
|
|
614
|
-
}
|
|
615
|
-
_saveHiddenSessions();
|
|
616
|
-
} catch {}
|
|
617
|
-
}
|
|
618
|
-
function _saveHiddenSessions() {
|
|
619
|
-
const data = {};
|
|
620
|
-
for (const id of hiddenSessionIDs) data[id] = Date.now();
|
|
621
|
-
localStorage.setItem(HIDDEN_KEY, JSON.stringify(data));
|
|
622
|
-
}
|
|
623
|
-
const hiddenSessionIDs = new Set();
|
|
624
|
-
loadHiddenSessions();
|
|
625
|
-
|
|
626
|
-
let renderPending = false;
|
|
627
|
-
|
|
628
|
-
let totalInput = 0, totalOutput = 0, totalCacheCreate = 0, totalCacheRead = 0;
|
|
629
|
-
let contextData = {};
|
|
630
|
-
|
|
631
|
-
function computeTokensFromContext() {
|
|
632
|
-
totalInput = 0; totalOutput = 0; totalCacheCreate = 0; totalCacheRead = 0;
|
|
633
|
-
for (const ctx of Object.values(contextData)) {
|
|
634
|
-
totalInput += ctx.inputTokens || 0;
|
|
635
|
-
totalOutput += ctx.outputTokens || 0;
|
|
636
|
-
totalCacheCreate += ctx.cacheCreation || 0;
|
|
637
|
-
totalCacheRead += ctx.cacheRead || 0;
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
let collapseAfter = 0;
|
|
642
|
-
let collapseTimer = null;
|
|
643
|
-
let activeRefreshTimer = null;
|
|
644
|
-
|
|
645
|
-
const MAX_ITEMS = 9999;
|
|
646
|
-
const MAX_LINES = 50;
|
|
647
|
-
let renderedItemCount = 0;
|
|
648
|
-
let needsFullRender = true;
|
|
649
|
-
let treeDirty = true;
|
|
650
|
-
let lastTreeCursor = -1;
|
|
651
|
-
|
|
652
|
-
// Cache highlight.js CSS for HTML export
|
|
653
|
-
let hljsDarkCSS = '', hljsLightCSS = '';
|
|
654
|
-
fetch('vendor/github-dark.min.css').then(r => r.text()).then(t => { hljsDarkCSS = t; }).catch(() => {});
|
|
655
|
-
fetch('vendor/github-light.min.css').then(r => r.text()).then(t => { hljsLightCSS = t; }).catch(() => {});
|
|
656
|
-
|
|
657
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
658
|
-
// Markdown renderer (marked + highlight.js)
|
|
659
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
660
|
-
|
|
661
|
-
const mdRenderer = new marked.Renderer();
|
|
662
|
-
mdRenderer.code = function (codeOrObj, langOrEsc) {
|
|
663
|
-
// marked v4: code(text, lang, escaped) — marked v5+: code({ text, lang })
|
|
664
|
-
const text = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
|
|
665
|
-
const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : langOrEsc;
|
|
666
|
-
let highlighted;
|
|
667
|
-
if (lang && hljs.getLanguage(lang)) {
|
|
668
|
-
try {
|
|
669
|
-
highlighted = hljs.highlight(text, { language: lang }).value;
|
|
670
|
-
} catch {
|
|
671
|
-
highlighted = hljs.highlightAuto(text).value;
|
|
672
|
-
}
|
|
673
|
-
} else {
|
|
674
|
-
highlighted = hljs.highlightAuto(text).value;
|
|
675
|
-
}
|
|
676
|
-
const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
|
|
677
|
-
return `<div class="code-block-wrapper">
|
|
678
|
-
<div class="code-block-header">${langTag}<span class="copy-btn" onclick="copyCode(this)">⎘</span></div>
|
|
679
|
-
<pre><code>${highlighted}</code></pre>
|
|
680
|
-
</div>`;
|
|
681
|
-
};
|
|
682
|
-
marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
|
|
683
|
-
|
|
684
|
-
function copyCode(btn) {
|
|
685
|
-
const wrapper = btn.closest('.code-block-wrapper');
|
|
686
|
-
const code = wrapper ? wrapper.querySelector('code') : null;
|
|
687
|
-
if (!code) return;
|
|
688
|
-
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
689
|
-
btn.innerHTML = '✓';
|
|
690
|
-
setTimeout(() => { btn.innerHTML = '⎘'; }, 1500);
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function mdRender(text) {
|
|
695
|
-
try {
|
|
696
|
-
return DOMPurify.sanitize(marked.parse(text));
|
|
697
|
-
} catch {
|
|
698
|
-
return esc(text);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
703
|
-
// WebSocket
|
|
704
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
705
|
-
|
|
706
|
-
function connect() {
|
|
707
|
-
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
708
|
-
ws = new WebSocket(`${proto}//${location.host}`);
|
|
709
|
-
|
|
710
|
-
ws.onopen = () => {
|
|
711
|
-
sessionInfo.textContent = 'Connected';
|
|
712
|
-
lastMsgTime = Date.now();
|
|
713
|
-
reconnectDelay = 1000;
|
|
714
|
-
reconnectAttempts = 0;
|
|
715
|
-
startStaleCheck();
|
|
716
|
-
startActiveRefresh();
|
|
717
|
-
};
|
|
718
|
-
ws.onclose = () => {
|
|
719
|
-
reconnectAttempts++;
|
|
720
|
-
if (reconnectAttempts >= MaxReconnectAttempts) {
|
|
721
|
-
sessionInfo.textContent = 'Disconnected. Please refresh to reconnect.';
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
sessionInfo.textContent = 'Disconnected, reconnecting...';
|
|
725
|
-
stopStaleCheck();
|
|
726
|
-
if (activeRefreshTimer) { clearInterval(activeRefreshTimer); activeRefreshTimer = null; }
|
|
727
|
-
reconnectTimer = setTimeout(connect, reconnectDelay);
|
|
728
|
-
reconnectDelay = Math.min(reconnectDelay * 2, MaxReconnectDelay);
|
|
729
|
-
};
|
|
730
|
-
ws.onerror = (e) => { console.warn('[ws] connection error', e); };
|
|
731
|
-
|
|
732
|
-
ws.onmessage = (e) => {
|
|
733
|
-
lastMsgTime = Date.now();
|
|
734
|
-
let msg;
|
|
735
|
-
try { msg = JSON.parse(e.data); } catch { return; }
|
|
736
|
-
handleMessage(msg);
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function startStaleCheck() {
|
|
741
|
-
if (staleCheckTimer) clearInterval(staleCheckTimer);
|
|
742
|
-
staleCheckTimer = setInterval(() => {
|
|
743
|
-
if (Date.now() - lastMsgTime > 45000) {
|
|
744
|
-
sessionInfo.textContent = 'Stale connection, reconnecting...';
|
|
745
|
-
stopStaleCheck();
|
|
746
|
-
try { ws.close(); } catch {}
|
|
747
|
-
// onclose handler will handle reconnect — no separate timer needed
|
|
748
|
-
}
|
|
749
|
-
}, 10000);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function stopStaleCheck() {
|
|
753
|
-
if (staleCheckTimer) { clearInterval(staleCheckTimer); staleCheckTimer = null; }
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function handleMessage(msg) {
|
|
757
|
-
switch (msg.type) {
|
|
758
|
-
case 'snapshot': handleSnapshot(msg.payload); break;
|
|
759
|
-
case 'itemBatch': handleItemBatch(msg.payload); break;
|
|
760
|
-
case 'item': handleItem(msg.payload); break;
|
|
761
|
-
case 'newSession': handleNewSession(msg.payload); break;
|
|
762
|
-
case 'newAgent': handleNewAgent(msg.payload); break;
|
|
763
|
-
case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
|
|
764
|
-
case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
|
|
765
|
-
case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
|
|
766
|
-
case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); break;
|
|
767
|
-
case 'tokenStats': tokenStatsData = msg.payload; if (currentTab === 'tokens') renderTokenPage(); break;
|
|
768
|
-
case 'config':
|
|
769
|
-
if (msg.payload.version) appVersion = msg.payload.version;
|
|
770
|
-
if (msg.payload.collapseAfter > 0 && !collapseTimer) {
|
|
771
|
-
applyCollapsePolicy(msg.payload.collapseAfter);
|
|
772
|
-
}
|
|
773
|
-
break;
|
|
774
|
-
case 'heartbeat': break;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function sendCmd(action, extra = {}) {
|
|
779
|
-
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ action, ...extra }));
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
783
|
-
// Snapshot / Session management
|
|
784
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
785
|
-
|
|
786
|
-
function handleSnapshot(payload) {
|
|
787
|
-
autoDiscovery = payload.autoDiscovery;
|
|
788
|
-
const incomingIDs = new Set((payload.sessions || []).map(s => s.id));
|
|
789
|
-
for (let i = sessions.length - 1; i >= 0; i--) {
|
|
790
|
-
const s = sessions[i];
|
|
791
|
-
if (!incomingIDs.has(s.id) && !s.pinned) {
|
|
792
|
-
sessions.splice(i, 1);
|
|
793
|
-
sessionsMap.delete(s.id);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
for (const s of (payload.sessions || [])) {
|
|
797
|
-
if (hiddenSessionIDs.has(s.id)) continue;
|
|
798
|
-
let session = sessionsMap.get(s.id);
|
|
799
|
-
if (!session) {
|
|
800
|
-
session = {
|
|
801
|
-
id: s.id, projectPath: s.projectPath, title: '',
|
|
802
|
-
folder: folderName(s.projectPath), model: '',
|
|
803
|
-
agents: [], tasks: [], collapsed: false, pinned: false,
|
|
804
|
-
lastActivity: s.birthtimeMs || 0,
|
|
805
|
-
birthtimeMs: s.birthtimeMs || 0,
|
|
806
|
-
};
|
|
807
|
-
sessions.push(session);
|
|
808
|
-
sessionsMap.set(session.id, session);
|
|
809
|
-
session.agents.push({ id: '', name: 'Main', type: 'main' });
|
|
810
|
-
}
|
|
811
|
-
for (const [aid, atype] of Object.entries(s.subagents || {})) {
|
|
812
|
-
if (!session.agents.find(a => a.id === aid)) {
|
|
813
|
-
session.agents.push({ id: aid, name: agentDisplayName(aid, atype), type: 'agent' });
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
for (const t of (s.backgroundTasks || [])) {
|
|
817
|
-
if (!session.tasks.find(ta => ta.id === t.id)) {
|
|
818
|
-
session.tasks.push({
|
|
819
|
-
id: t.id, parentAgentID: t.parentAgentID,
|
|
820
|
-
toolName: t.toolName, outputPath: t.outputPath,
|
|
821
|
-
isComplete: t.isComplete,
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
// Initialize agentActivity from server-computed lastActivities (survives history skip)
|
|
827
|
-
for (const [key, val] of Object.entries(payload.lastActivities || {})) {
|
|
828
|
-
agentActivity.set(key, val);
|
|
829
|
-
}
|
|
830
|
-
updateFilters();
|
|
831
|
-
rebuildNodes();
|
|
832
|
-
needsFullRender = true;
|
|
833
|
-
visibleDirty = true;
|
|
834
|
-
// Don't render tree yet — wait for itemBatch to fill title/model/taskDescriptions
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
function handleNewSession(payload) {
|
|
838
|
-
if (hiddenSessionIDs.has(payload.sessionID)) return;
|
|
839
|
-
if (sessionsMap.has(payload.sessionID)) return;
|
|
840
|
-
const session = {
|
|
841
|
-
id: payload.sessionID, projectPath: payload.projectPath,
|
|
842
|
-
title: '', folder: folderName(payload.projectPath), model: '',
|
|
843
|
-
agents: [{ id: '', name: 'Main', type: 'main' }],
|
|
844
|
-
tasks: [], collapsed: false, pinned: false,
|
|
845
|
-
lastActivity: payload.birthtimeMs || Date.now(),
|
|
846
|
-
birthtimeMs: payload.birthtimeMs || 0,
|
|
847
|
-
};
|
|
848
|
-
sessions.push(session);
|
|
849
|
-
sessionsMap.set(session.id, session);
|
|
850
|
-
updateFilters();
|
|
851
|
-
rebuildNodes();
|
|
852
|
-
needsFullRender = true;
|
|
853
|
-
visibleDirty = true;
|
|
854
|
-
scheduleRender();
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function handleNewAgent(payload) {
|
|
858
|
-
const s = sessionsMap.get(payload.sessionID);
|
|
859
|
-
if (!s || s.agents.find(a => a.id === payload.agentID)) return;
|
|
860
|
-
s.agents.push({
|
|
861
|
-
id: payload.agentID,
|
|
862
|
-
name: agentDisplayName(payload.agentID, payload.agentType),
|
|
863
|
-
type: 'agent',
|
|
864
|
-
});
|
|
865
|
-
updateFilters();
|
|
866
|
-
rebuildNodes();
|
|
867
|
-
needsFullRender = true;
|
|
868
|
-
visibleDirty = true;
|
|
869
|
-
scheduleRender();
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function handleNewBgTask(payload) {
|
|
873
|
-
const s = sessionsMap.get(payload.sessionID);
|
|
874
|
-
if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
|
|
875
|
-
s.tasks.push({
|
|
876
|
-
id: payload.toolID, parentAgentID: payload.parentAgentID,
|
|
877
|
-
toolName: payload.toolName, outputPath: payload.outputPath,
|
|
878
|
-
isComplete: payload.isComplete,
|
|
879
|
-
});
|
|
880
|
-
rebuildNodes();
|
|
881
|
-
scheduleRender();
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
function handleSessionRemoved(payload) {
|
|
885
|
-
const sid = payload.sessionID;
|
|
886
|
-
const s = sessionsMap.get(sid);
|
|
887
|
-
if (s) {
|
|
888
|
-
for (const a of s.agents) agentActivity.delete(sid + ':' + a.id);
|
|
889
|
-
for (const t of s.tasks) taskDescriptions.delete(t.id);
|
|
890
|
-
}
|
|
891
|
-
const idx = sessions.findIndex(s => s.id === sid);
|
|
892
|
-
if (idx >= 0) {
|
|
893
|
-
sessions.splice(idx, 1);
|
|
894
|
-
sessionsMap.delete(sid);
|
|
895
|
-
}
|
|
896
|
-
updateFilters();
|
|
897
|
-
rebuildNodes();
|
|
898
|
-
needsFullRender = true;
|
|
899
|
-
visibleDirty = true;
|
|
900
|
-
scheduleRender();
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
904
|
-
// Stream items
|
|
905
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
906
|
-
|
|
907
|
-
function handleItem(item) {
|
|
908
|
-
if (item.type === 'session_title') {
|
|
909
|
-
const s = sessionsMap.get(item.sessionID);
|
|
910
|
-
if (s) { s.title = item.content.slice(0, 30); }
|
|
911
|
-
scheduleRender();
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
// Update activity
|
|
915
|
-
const s = sessionsMap.get(item.sessionID);
|
|
916
|
-
if (s) s.lastActivity = itemTime(item);
|
|
917
|
-
pushItem(item);
|
|
918
|
-
scheduleRender();
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
function handleItemBatch(items) {
|
|
922
|
-
for (const item of items) {
|
|
923
|
-
if (item.type === 'session_title') {
|
|
924
|
-
const s = sessionsMap.get(item.sessionID);
|
|
925
|
-
if (s) { s.title = item.content.slice(0, 30); }
|
|
926
|
-
continue;
|
|
927
|
-
}
|
|
928
|
-
const s = sessionsMap.get(item.sessionID);
|
|
929
|
-
if (s) s.lastActivity = itemTime(item);
|
|
930
|
-
pushItem(item);
|
|
931
|
-
}
|
|
932
|
-
rebuildNodes();
|
|
933
|
-
scheduleRender();
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
function pushItem(item) {
|
|
937
|
-
if (hiddenSessionIDs.has(item.sessionID)) return;
|
|
938
|
-
// Token counts are sourced exclusively from server context messages
|
|
939
|
-
// to avoid divergence between frontend accumulation and server tracking
|
|
940
|
-
|
|
941
|
-
if (item.model) {
|
|
942
|
-
const s = sessionsMap.get(item.sessionID);
|
|
943
|
-
if (s) s.model = item.model;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
if (item.type === 'tool_input' && item.toolID && item.toolName) {
|
|
947
|
-
toolNameMap.set(item.toolID, item.toolName);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
if (item.type === 'tool_input') {
|
|
951
|
-
if (item.agentID) {
|
|
952
|
-
agentActivity.set(item.sessionID + ':' + item.agentID, { toolName: item.toolName || '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
|
|
953
|
-
}
|
|
954
|
-
if (item.toolID) {
|
|
955
|
-
taskDescriptions.set(item.toolID, (item.content || '').slice(0, MAX_DESC_STORE));
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
if (item.type === 'user_text') {
|
|
960
|
-
agentActivity.set(item.sessionID + ':' + (item.agentID || ''), { toolName: '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
if (item.toolID) {
|
|
964
|
-
const key = `${item.toolID}:${item.type}`;
|
|
965
|
-
if (seenToolIDs.has(key)) return;
|
|
966
|
-
seenToolIDs.set(key, true);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
streamItems.push(item);
|
|
970
|
-
if (streamItems.length > MAX_ITEMS) {
|
|
971
|
-
streamItems = streamItems.slice(-MAX_ITEMS);
|
|
972
|
-
visibleDirty = true;
|
|
973
|
-
}
|
|
974
|
-
// Incrementally update visibleItems — no need to re-filter on every item
|
|
975
|
-
if (!visibleDirty && isItemVisible(item)) {
|
|
976
|
-
visibleItems.push(item);
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function isItemVisible(item) {
|
|
981
|
-
if (!filters.has(item.sessionID + ':' + (item.agentID || ''))) return false;
|
|
982
|
-
switch (item.type) {
|
|
983
|
-
case 'thinking': return showThinking;
|
|
984
|
-
case 'tool_input': return showToolInput;
|
|
985
|
-
case 'tool_output': return showToolOutput;
|
|
986
|
-
case 'text': return showText;
|
|
987
|
-
case 'hook_output': return showHook;
|
|
988
|
-
case 'user_text': return showUserPrompt;
|
|
989
|
-
default: return true;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
994
|
-
// Tree
|
|
995
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
996
|
-
|
|
997
|
-
function idColor(rank) {
|
|
998
|
-
const hue = (rank * 137.508) % 360;
|
|
999
|
-
return `hsl(${hue}, 75%, 60%)`;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function rebuildNodes() {
|
|
1003
|
-
// Sort sessions by creation time, newest first
|
|
1004
|
-
sessions.sort((a, b) => (b.birthtimeMs || 0) - (a.birthtimeMs || 0));
|
|
1005
|
-
for (let i = 0; i < sessions.length; i++) sessions[i].colorRank = i;
|
|
1006
|
-
|
|
1007
|
-
computeAgentIdDisplayLengths();
|
|
1008
|
-
|
|
1009
|
-
const today = new Date();
|
|
1010
|
-
const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
1011
|
-
|
|
1012
|
-
const flatSessions = [];
|
|
1013
|
-
const olderByDate = new Map(); // dateStr -> [sessions]
|
|
1014
|
-
|
|
1015
|
-
for (const s of sessions) {
|
|
1016
|
-
const dateStr = s.birthtimeMs ? formatTime(s.birthtimeMs).split(' ')[0] : null;
|
|
1017
|
-
if (!dateStr || dateStr === todayStr || isSessionActive(s)) {
|
|
1018
|
-
flatSessions.push(s);
|
|
1019
|
-
} else {
|
|
1020
|
-
if (!olderByDate.has(dateStr)) olderByDate.set(dateStr, []);
|
|
1021
|
-
olderByDate.get(dateStr).push(s);
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
treeNodes = [];
|
|
1026
|
-
|
|
1027
|
-
function addSessionWithChildren(s, inFolder) {
|
|
1028
|
-
treeNodes.push({ type: 'session', level: 0, isLast: false, inFolder: !!inFolder, ...s });
|
|
1029
|
-
if (s.collapsed) return;
|
|
1030
|
-
const agents = s.agents || [];
|
|
1031
|
-
const lastAgentIdx = agents.length - 1;
|
|
1032
|
-
for (let ai = 0; ai < agents.length; ai++) {
|
|
1033
|
-
const a = agents[ai];
|
|
1034
|
-
const isLastAgent = ai === lastAgentIdx;
|
|
1035
|
-
const tasks = s.tasks.filter(t =>
|
|
1036
|
-
(a.id === '' && !t.parentAgentID) || t.parentAgentID === a.id
|
|
1037
|
-
);
|
|
1038
|
-
const lastTaskIdx = tasks.length - 1;
|
|
1039
|
-
const hasTasks = tasks.length > 0;
|
|
1040
|
-
const actKey = s.id + ':' + a.id;
|
|
1041
|
-
const act = agentActivity.get(actKey);
|
|
1042
|
-
treeNodes.push({
|
|
1043
|
-
type: a.type, id: a.id, name: a.name, sessionID: s.id,
|
|
1044
|
-
level: 1, isLast: isLastAgent,
|
|
1045
|
-
activityTool: act ? act.toolName : '',
|
|
1046
|
-
activityDesc: act ? act.content : '',
|
|
1047
|
-
});
|
|
1048
|
-
for (let ti = 0; ti < tasks.length; ti++) {
|
|
1049
|
-
const t = tasks[ti];
|
|
1050
|
-
const tDesc = taskDescriptions.get(t.id);
|
|
1051
|
-
treeNodes.push({
|
|
1052
|
-
type: 'task', id: t.id, name: t.toolName,
|
|
1053
|
-
sessionID: s.id, parentAgentID: t.parentAgentID,
|
|
1054
|
-
outputPath: t.outputPath, isComplete: t.isComplete,
|
|
1055
|
-
level: 2, isLast: ti === lastTaskIdx,
|
|
1056
|
-
parentIsLast: isLastAgent,
|
|
1057
|
-
description: tDesc || '',
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// Today's + active sessions (expanded, not in date folders)
|
|
1064
|
-
for (const s of flatSessions) {
|
|
1065
|
-
addSessionWithChildren(s, false);
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// Date folders (older dates, collapsed by default)
|
|
1069
|
-
const sortedDates = [...olderByDate.keys()].sort((a, b) => b.localeCompare(a));
|
|
1070
|
-
for (let di = 0; di < sortedDates.length; di++) {
|
|
1071
|
-
const dateStr = sortedDates[di];
|
|
1072
|
-
const folderSessions = olderByDate.get(dateStr);
|
|
1073
|
-
const collapsed = folderCollapsed[dateStr] !== false; // default collapsed
|
|
1074
|
-
const isLastFolder = di === sortedDates.length - 1;
|
|
1075
|
-
|
|
1076
|
-
treeNodes.push({
|
|
1077
|
-
type: 'date-folder', date: dateStr, level: 0, isLast: false,
|
|
1078
|
-
collapsed, sessionCount: folderSessions.length,
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
if (!collapsed) {
|
|
1082
|
-
for (const s of folderSessions) {
|
|
1083
|
-
addSessionWithChildren(s, true);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Mark last session among flat sessions
|
|
1089
|
-
const flatSessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
|
|
1090
|
-
if (flatSessionNodes.length > 0) flatSessionNodes[flatSessionNodes.length - 1].isLast = true;
|
|
1091
|
-
|
|
1092
|
-
// Mark last session inside each folder
|
|
1093
|
-
for (const dateStr of sortedDates) {
|
|
1094
|
-
if (folderCollapsed[dateStr] !== false) continue;
|
|
1095
|
-
// Find sessions belonging to this folder
|
|
1096
|
-
const thisFolder = [];
|
|
1097
|
-
let inThisFolder = false;
|
|
1098
|
-
for (const n of treeNodes) {
|
|
1099
|
-
if (n.type === 'date-folder' && n.date === dateStr) { inThisFolder = true; continue; }
|
|
1100
|
-
if (n.type === 'date-folder' && n.date !== dateStr) { inThisFolder = false; continue; }
|
|
1101
|
-
if (inThisFolder && n.type === 'session') thisFolder.push(n);
|
|
1102
|
-
}
|
|
1103
|
-
if (thisFolder.length > 0) thisFolder[thisFolder.length - 1].isLast = true;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
|
|
1107
|
-
treeDirty = true;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
function treePrefix(node) {
|
|
1111
|
-
if (node.level === 0) {
|
|
1112
|
-
return node.inFolder ? ' ' : '';
|
|
1113
|
-
}
|
|
1114
|
-
const branch = node.isLast ? '└──' : '├──';
|
|
1115
|
-
if (node.level === 1) return ' ' + branch;
|
|
1116
|
-
// Level 2: use pre-computed parentIsLast from rebuildNodes
|
|
1117
|
-
const parentIsLast = node.parentIsLast !== undefined ? node.parentIsLast : true;
|
|
1118
|
-
const stem = parentIsLast ? ' ' : '│ ';
|
|
1119
|
-
return ' ' + stem + branch;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function getNodeHTML(node, idx) {
|
|
1123
|
-
const isSelected = idx === treeCursor;
|
|
1124
|
-
const selClass = isSelected ? ' selected' : '';
|
|
1125
|
-
|
|
1126
|
-
if (node.type === 'date-folder') {
|
|
1127
|
-
const icon = node.collapsed ? '▸' : '▾';
|
|
1128
|
-
return `<div class="tree-row tree-row-folder${selClass ? ' selected' : ''}">
|
|
1129
|
-
<div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1130
|
-
<div class="tree-node folder-node">
|
|
1131
|
-
${icon} 📁 ${node.date} <span style="font-size:10px;color:var(--dim);margin-left:4px">(${node.sessionCount})</span>
|
|
1132
|
-
</div>
|
|
1133
|
-
</div>
|
|
1134
|
-
</div>`;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
if (node.type === 'session') {
|
|
1138
|
-
const displayName = folderName(node.projectPath) || node.title || node.id.slice(0, 14);
|
|
1139
|
-
const parts = [];
|
|
1140
|
-
if (node.model) parts.push(`🧠 ${esc(node.model)}`);
|
|
1141
|
-
const activeDot = isSessionActive(node) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
|
|
1142
|
-
const subInfo = parts.length > 0 ? ` <span style="color:#6b7280;font-size:10px">${parts.join(' · ')}</span>` : '';
|
|
1143
|
-
const agentCount = node.agents ? node.agents.filter(a => a.type === 'agent').length : 0;
|
|
1144
|
-
const timeStr = formatTime(node.birthtimeMs);
|
|
1145
|
-
const timeHtml = timeStr ? `<span style="margin-left:auto;font-size:10px;color:var(--dim);flex-shrink:0">${timeStr}</span>` : '';
|
|
1146
|
-
return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
|
|
1147
|
-
<div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1148
|
-
<div class="tree-node">
|
|
1149
|
-
<span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} <span class="session-prefix" style="color:${idColor(node.colorRank)}" data-sid="${esc(node.id)}" onmouseenter="showSessionIdTip(this)" onmouseleave="hideSessionIdTip(this)">${esc(node.id.split('-')[0].toUpperCase())}</span> ${esc(displayName)}
|
|
1150
|
-
${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
|
|
1151
|
-
${subInfo}
|
|
1152
|
-
${timeHtml}
|
|
1153
|
-
</div>
|
|
1154
|
-
</div>
|
|
1155
|
-
<span class="tree-actions">
|
|
1156
|
-
<button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
|
|
1157
|
-
<button class="btn btn-icon danger" onclick="event.stopPropagation();selectIndex(${idx});removeSelectedSession()" data-tooltip="Remove">✕</button>
|
|
1158
|
-
</span>
|
|
1159
|
-
</div>`;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
if (node.type === 'main' || node.type === 'agent') {
|
|
1163
|
-
const icon = node.type === 'main' ? '💬' : '🤖';
|
|
1164
|
-
const enabled = filters.get(node.sessionID + ':' + node.id);
|
|
1165
|
-
const ctxKey = node.sessionID + ':' + node.id;
|
|
1166
|
-
const ctx = contextData[ctxKey];
|
|
1167
|
-
let ctxPct = '';
|
|
1168
|
-
if (ctx && ctx.contextWindow > 0 && ctx.inputTokens > 0) {
|
|
1169
|
-
const pct = Math.round(ctx.inputTokens / ctx.contextWindow * 100);
|
|
1170
|
-
const cls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
|
|
1171
|
-
if (showTokenCount) {
|
|
1172
|
-
ctxPct = `<span class="ctx-pct ${cls}">${fmtTok(ctx.inputTokens)}</span>`;
|
|
1173
|
-
} else {
|
|
1174
|
-
ctxPct = `<span class="ctx-pct ${cls}">${pct}%</span>`;
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
const activeDot = ctx && (Date.now() - ctx.lastActivity < 120000) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
|
|
1178
|
-
const actIcon = node.type === 'main' ? '🗣' : '⚡';
|
|
1179
|
-
const actText = showActivity && (node.activityTool || node.activityDesc)
|
|
1180
|
-
? (node.activityTool && node.activityDesc ? `${node.activityTool}: ${node.activityDesc}` : (node.activityTool || node.activityDesc))
|
|
1181
|
-
: '';
|
|
1182
|
-
const indent = treePrefix(node).replace(/[├└]──/, ' ');
|
|
1183
|
-
const actPrefix = `<span class="tree-prefix">${indent}</span>`;
|
|
1184
|
-
const activityHTML = actText
|
|
1185
|
-
? `<div class="tree-activity">${actPrefix}<span class="act-text">${actIcon} ${esc(actText)}</span></div>`
|
|
1186
|
-
: '';
|
|
1187
|
-
return `<div class="tree-row${selClass ? ' selected' : ''}">
|
|
1188
|
-
<div class="tree-content${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1189
|
-
<div class="tree-node">
|
|
1190
|
-
<span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${icon} ${esc(node.name || '')}${node.type === 'agent' && node.id ? '<span class="tree-agent-id">(' + esc(node.id.slice(0, agentIdDisplayLen.get(node.sessionID + ':' + node.id) || 7)) + ')</span>' : ''}${ctxPct}
|
|
1191
|
-
</div>
|
|
1192
|
-
${activityHTML}
|
|
1193
|
-
</div>
|
|
1194
|
-
<span class="tree-actions">
|
|
1195
|
-
<button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
|
|
1196
|
-
<button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});toggleNodeVisibility(${idx})" data-tooltip="${enabled ? 'Hide' : 'Show'}">${enabled ? '👁' : '─'}</button>
|
|
1197
|
-
</span>
|
|
1198
|
-
</div>`;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
if (node.type === 'task') {
|
|
1202
|
-
const icon = node.isComplete ? '✓' : '⏳';
|
|
1203
|
-
const taskIndent = treePrefix(node).replace(/[├└]──/, ' ');
|
|
1204
|
-
const taskPrefix = `<span class="tree-prefix">${taskIndent}</span>`;
|
|
1205
|
-
const descHTML = showActivity && node.description
|
|
1206
|
-
? `<div class="tree-activity">${taskPrefix}<span class="act-text">📋 ${esc(node.description)}</span></div>`
|
|
1207
|
-
: '';
|
|
1208
|
-
return `<div class="tree-row${selClass ? ' selected' : ''}">
|
|
1209
|
-
<div class="tree-content dim" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1210
|
-
<div class="tree-node">
|
|
1211
|
-
<span class="tree-prefix">${treePrefix(node)}</span>${icon} ${esc(node.name || 'bg-task')}
|
|
1212
|
-
</div>
|
|
1213
|
-
${descHTML}
|
|
1214
|
-
</div>
|
|
1215
|
-
<span class="tree-actions">
|
|
1216
|
-
<button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});loadBgTask(${idx})" data-tooltip="Load output">▶</button>
|
|
1217
|
-
</span>
|
|
1218
|
-
</div>`;
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
return '';
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
function renderTree() {
|
|
1225
|
-
if (treeNodes.length === 0) {
|
|
1226
|
-
treeEl.innerHTML = '<div class="tree-node" style="padding:8px;color:var(--dim)">Waiting for sessions...</div>';
|
|
1227
|
-
treeCursorInfo.textContent = '';
|
|
1228
|
-
return;
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
const cursorChanged = treeCursor !== lastTreeCursor;
|
|
1232
|
-
if (treeDirty) {
|
|
1233
|
-
let html = '';
|
|
1234
|
-
for (let i = 0; i < treeNodes.length; i++) {
|
|
1235
|
-
html += getNodeHTML(treeNodes[i], i);
|
|
1236
|
-
}
|
|
1237
|
-
treeEl.innerHTML = html;
|
|
1238
|
-
treeDirty = false;
|
|
1239
|
-
} else if (cursorChanged) {
|
|
1240
|
-
const prevSel = treeEl.querySelector('.tree-row.selected');
|
|
1241
|
-
if (prevSel) prevSel.classList.remove('selected');
|
|
1242
|
-
const newContent = treeEl.querySelector('[data-idx="' + treeCursor + '"]');
|
|
1243
|
-
if (newContent) {
|
|
1244
|
-
const row = newContent.closest('.tree-row');
|
|
1245
|
-
if (row) row.classList.add('selected');
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
lastTreeCursor = treeCursor;
|
|
1249
|
-
|
|
1250
|
-
// Scroll selected into view
|
|
1251
|
-
const sel = treeEl.querySelector('.tree-row.selected');
|
|
1252
|
-
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
1253
|
-
|
|
1254
|
-
treeCursorInfo.textContent = `${treeCursor + 1}/${treeNodes.length}`;
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
function updateTreeDots() {
|
|
1258
|
-
const dots = treeEl.querySelectorAll('.active-dot');
|
|
1259
|
-
const now = Date.now();
|
|
1260
|
-
for (const dot of dots) {
|
|
1261
|
-
const content = dot.closest('.tree-content');
|
|
1262
|
-
if (!content) continue;
|
|
1263
|
-
const idx = parseInt(content.getAttribute('data-idx'));
|
|
1264
|
-
if (isNaN(idx)) continue;
|
|
1265
|
-
const node = treeNodes[idx];
|
|
1266
|
-
if (!node) continue;
|
|
1267
|
-
let active = false;
|
|
1268
|
-
if (node.type === 'session') {
|
|
1269
|
-
active = isSessionActive(node);
|
|
1270
|
-
} else if (node.type === 'main' || node.type === 'agent') {
|
|
1271
|
-
const ctxKey = node.sessionID + ':' + node.id;
|
|
1272
|
-
const ctx = contextData[ctxKey];
|
|
1273
|
-
const threshold = node.type === 'main' ? 600000 : 180000;
|
|
1274
|
-
active = ctx && (now - ctx.lastActivity < threshold);
|
|
1275
|
-
}
|
|
1276
|
-
const newCls = active ? 'active-dot on' : 'active-dot off';
|
|
1277
|
-
const newHTML = active ? '🟢' : '⚪';
|
|
1278
|
-
if (dot.className !== newCls) {
|
|
1279
|
-
dot.className = newCls;
|
|
1280
|
-
dot.innerHTML = newHTML;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
const ACTIVE_THRESHOLD = 600000; // 10 minutes
|
|
1286
|
-
|
|
1287
|
-
function itemTime(item) {
|
|
1288
|
-
if (item && item.timestamp) {
|
|
1289
|
-
const ts = item.timestamp instanceof Date ? item.timestamp : new Date(item.timestamp);
|
|
1290
|
-
if (!isNaN(ts.getTime())) return ts.getTime();
|
|
1291
|
-
}
|
|
1292
|
-
return Date.now();
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
function isSessionActive(session) {
|
|
1296
|
-
if (!session) return false;
|
|
1297
|
-
const now = Date.now();
|
|
1298
|
-
// Main agent: 10 minutes
|
|
1299
|
-
const mainCtx = contextData[session.id + ':'];
|
|
1300
|
-
if (mainCtx && (now - mainCtx.lastActivity) < 600000) return true;
|
|
1301
|
-
// Subagents: 3 minutes
|
|
1302
|
-
for (const a of session.agents) {
|
|
1303
|
-
if (a.id === '') continue;
|
|
1304
|
-
const ctx = contextData[session.id + ':' + a.id];
|
|
1305
|
-
if (ctx && (now - ctx.lastActivity) < 180000) return true;
|
|
1306
|
-
}
|
|
1307
|
-
// Session fallback: 10 minutes
|
|
1308
|
-
return (now - session.lastActivity) < 600000;
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1312
|
-
// Stream rendering
|
|
1313
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1314
|
-
|
|
1315
|
-
function renderStream() {
|
|
1316
|
-
// Rebuild visibleItems from scratch only when filters/toggles changed
|
|
1317
|
-
if (visibleDirty) {
|
|
1318
|
-
visibleItems = streamItems.filter(isItemVisible);
|
|
1319
|
-
visibleDirty = false;
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
const visible = visibleItems;
|
|
1323
|
-
const wasAutoScroll = autoScroll;
|
|
1324
|
-
|
|
1325
|
-
if (needsFullRender || renderedItemCount > visible.length) {
|
|
1326
|
-
// Full rebuild: filter changed, items trimmed, or initial render
|
|
1327
|
-
const lines = [];
|
|
1328
|
-
for (const item of visible) {
|
|
1329
|
-
for (const l of renderItem(item)) lines.push(l);
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
let html;
|
|
1333
|
-
if (lines.length > 0) {
|
|
1334
|
-
html = lines.map(l => {
|
|
1335
|
-
const sidAttr = l.sessionID ? ` data-session-id="${esc(l.sessionID)}"` : '';
|
|
1336
|
-
if (l.html) return `<div class="${esc(l.cls)}"${sidAttr}>${l.text}</div>`;
|
|
1337
|
-
return `<div class="${esc(l.cls)}"${sidAttr}>${esc(l.text)}</div>`;
|
|
1338
|
-
}).join('\n');
|
|
1339
|
-
} else if (streamItems.length > 0) {
|
|
1340
|
-
html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
|
|
1341
|
-
} else {
|
|
1342
|
-
html = '<div style="color:#6b7280;padding:20px;text-align:center">Waiting for output...</div>';
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
streamEl.innerHTML = html;
|
|
1346
|
-
renderedItemCount = visible.length;
|
|
1347
|
-
needsFullRender = false;
|
|
1348
|
-
if (wasAutoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
|
|
1349
|
-
} else {
|
|
1350
|
-
// Incremental append: only add new items since last render
|
|
1351
|
-
for (let i = renderedItemCount; i < visible.length; i++) {
|
|
1352
|
-
for (const l of renderItem(visible[i])) {
|
|
1353
|
-
const div = document.createElement('div');
|
|
1354
|
-
div.className = l.cls;
|
|
1355
|
-
if (l.sessionID) div.dataset.sessionId = l.sessionID;
|
|
1356
|
-
div.innerHTML = l.html ? l.text : esc(l.text);
|
|
1357
|
-
streamEl.appendChild(div);
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
renderedItemCount = visible.length;
|
|
1361
|
-
if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
const maxScroll = streamEl.scrollHeight - streamEl.clientHeight;
|
|
1365
|
-
const pct = maxScroll > 0 ? Math.round(streamEl.scrollTop / maxScroll * 100) : 0;
|
|
1366
|
-
document.getElementById('scroll-pos').textContent = Math.min(100, pct) + '%';
|
|
1367
|
-
document.getElementById('item-count').textContent = streamItems.length + ' items';
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
function renderItem(item) {
|
|
1371
|
-
const lines = [];
|
|
1372
|
-
const isSub = !!item.agentID;
|
|
1373
|
-
const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
|
|
1374
|
-
const sep = ' » ';
|
|
1375
|
-
const sid = item.sessionID || '';
|
|
1376
|
-
|
|
1377
|
-
if (item.type === 'turn_marker') {
|
|
1378
|
-
return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──`, sessionID: sid }];
|
|
1379
|
-
}
|
|
1380
|
-
if (item.type === 'compact_marker') {
|
|
1381
|
-
const label = item.content ? `compacted (${item.content})` : 'compacted';
|
|
1382
|
-
return [{ cls: 'stream-line marker', text: `── ${label} ──`, sessionID: sid }];
|
|
1383
|
-
}
|
|
1384
|
-
if (item.type === 'pr_link') {
|
|
1385
|
-
return [{ cls: 'stream-line marker', text: `── ${item.content} ──`, sessionID: sid }];
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
const agentName = item.agentName || 'Main';
|
|
1389
|
-
const sForColor = sessionsMap.get(item.sessionID);
|
|
1390
|
-
const prefixTag = `<span class="session-prefix" style="color:${idColor(sForColor ? sForColor.colorRank : 0)}">[${esc(item.sessionID.split('-')[0].toUpperCase())}]</span>`;
|
|
1391
|
-
const agentIdTag = item.agentID ? `<span class="session-prefix" style="color:var(--dim)">(</span><span class="session-prefix" style="color:var(--magenta)">${esc(item.agentID.slice(0, agentIdDisplayLen.get(item.sessionID + ':' + item.agentID) || 7))}</span><span class="session-prefix" style="color:var(--dim)">)</span>` : '';
|
|
1392
|
-
const agentLabel = prefixTag + agentIdTag + ' ' + esc(agentName);
|
|
1393
|
-
const tsHtml = item.timestamp ? `<span class="timestamp">${fmtTimestamp(item.timestamp)}</span>` : '';
|
|
1394
|
-
|
|
1395
|
-
switch (item.type) {
|
|
1396
|
-
case 'thinking':
|
|
1397
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1398
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l, sessionID: sid });
|
|
1399
|
-
break;
|
|
1400
|
-
case 'tool_input':
|
|
1401
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1402
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l, sessionID: sid });
|
|
1403
|
-
break;
|
|
1404
|
-
case 'tool_output': {
|
|
1405
|
-
let tn = '';
|
|
1406
|
-
if (item.toolID) {
|
|
1407
|
-
tn = toolNameMap.get(item.toolID) || '';
|
|
1408
|
-
}
|
|
1409
|
-
let label = tn ? `📤 ${tn} result` : '📤 Output';
|
|
1410
|
-
if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
|
|
1411
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1412
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l, sessionID: sid });
|
|
1413
|
-
break;
|
|
1414
|
-
}
|
|
1415
|
-
case 'text':
|
|
1416
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1417
|
-
lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true, sessionID: sid });
|
|
1418
|
-
break;
|
|
1419
|
-
case 'hook_output': {
|
|
1420
|
-
let label = '🪝 Hook';
|
|
1421
|
-
if (item.toolName) label += ' ' + item.toolName;
|
|
1422
|
-
if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
|
|
1423
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1424
|
-
if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true, sessionID: sid });
|
|
1425
|
-
if (item.hookContent) {
|
|
1426
|
-
for (const l of truncContent(item.hookContent)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">content:</span> ${esc(l)}`, html: true, sessionID: sid });
|
|
1427
|
-
}
|
|
1428
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">stdout:</span> ${esc(l)}`, html: true, sessionID: sid });
|
|
1429
|
-
break;
|
|
1430
|
-
}
|
|
1431
|
-
case 'diagnostics': {
|
|
1432
|
-
let label = '⚠ Diagnostics';
|
|
1433
|
-
if (item.toolName) label += ' ' + item.toolName;
|
|
1434
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1435
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l, sessionID: sid });
|
|
1436
|
-
break;
|
|
1437
|
-
}
|
|
1438
|
-
case 'debug': {
|
|
1439
|
-
let label = '🔍 Debug';
|
|
1440
|
-
if (item.toolName) label += ' ' + item.toolName;
|
|
1441
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1442
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
|
|
1443
|
-
break;
|
|
1444
|
-
}
|
|
1445
|
-
case 'user_text':
|
|
1446
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}👤 User Prompt</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1447
|
-
lines.push({ cls: 'stream-line user-prompt-block md-content', text: mdRender(item.content), html: true, sessionID: sid });
|
|
1448
|
-
break;
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
|
|
1452
|
-
return lines;
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
function truncContent(content) {
|
|
1456
|
-
const raw = content.split('\n');
|
|
1457
|
-
return raw.length > MAX_LINES ? raw.slice(0, MAX_LINES).concat([`... (${raw.length - MAX_LINES} more lines)`]) : raw;
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1461
|
-
// Button / header refresh
|
|
1462
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1463
|
-
|
|
1464
|
-
function refreshButtons() {
|
|
1465
|
-
document.getElementById('btn-thinking').classList.toggle('on', showThinking);
|
|
1466
|
-
document.getElementById('btn-tool-input').classList.toggle('on', showToolInput);
|
|
1467
|
-
document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
|
|
1468
|
-
document.getElementById('btn-text').classList.toggle('on', showText);
|
|
1469
|
-
document.getElementById('btn-hook').classList.toggle('on', showHook);
|
|
1470
|
-
document.getElementById('btn-user-prompt').classList.toggle('on', showUserPrompt);
|
|
1471
|
-
document.getElementById('btn-activity').classList.toggle('on', showActivity);
|
|
1472
|
-
const btnTokenDisplay = document.getElementById('btn-token-display');
|
|
1473
|
-
btnTokenDisplay.classList.toggle('on', true);
|
|
1474
|
-
btnTokenDisplay.textContent = showTokenCount ? 'T' : '%';
|
|
1475
|
-
btnTokenDisplay.setAttribute('data-tooltip', showTokenCount ? '上下文:Token数 ↔ 百分比切换' : '上下文:百分比 ↔ Token数切换');
|
|
1476
|
-
document.getElementById('btn-autoscroll').classList.toggle('on', autoScroll);
|
|
1477
|
-
document.getElementById('btn-tree-toggle').classList.toggle('on', showTree);
|
|
1478
|
-
document.getElementById('btn-autodisco').classList.toggle('on', autoDiscovery);
|
|
1479
|
-
|
|
1480
|
-
// Session info
|
|
1481
|
-
let info = '';
|
|
1482
|
-
if (sessions.length === 0) info = 'Waiting...';
|
|
1483
|
-
else if (sessions.length === 1) {
|
|
1484
|
-
const s = sessions[0];
|
|
1485
|
-
info = (folderName(s.projectPath) || s.title || s.id.slice(0, 14));
|
|
1486
|
-
} else info = sessions.length + ' sessions';
|
|
1487
|
-
if (!autoDiscovery) info += ' [paused]';
|
|
1488
|
-
sessionInfo.textContent = info;
|
|
1489
|
-
|
|
1490
|
-
// Token info
|
|
1491
|
-
computeTokensFromContext();
|
|
1492
|
-
let tokStr = '';
|
|
1493
|
-
if (totalInput > 0 || totalOutput > 0) {
|
|
1494
|
-
tokStr = `${fmtTok(totalInput)} in / ${fmtTok(totalOutput)} out`;
|
|
1495
|
-
if (totalCacheCreate > 0 || totalCacheRead > 0) {
|
|
1496
|
-
tokStr += ` · cache ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
tokenInfo.textContent = tokStr;
|
|
1500
|
-
|
|
1501
|
-
// Footer version
|
|
1502
|
-
const vEl = document.getElementById('footer-version');
|
|
1503
|
-
if (vEl) {
|
|
1504
|
-
const v = appVersion ? `v${appVersion}` : '';
|
|
1505
|
-
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>`;
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1510
|
-
// Session ID tooltip
|
|
1511
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1512
|
-
|
|
1513
|
-
let sessionIdTipTimer = null;
|
|
1514
|
-
let sessionIdTipEl = null;
|
|
1515
|
-
function showSessionIdTip(el) {
|
|
1516
|
-
hideAllSessionIdTips();
|
|
1517
|
-
const sid = el.getAttribute('data-sid');
|
|
1518
|
-
if (!sid) return;
|
|
1519
|
-
sessionIdTipTimer = setTimeout(() => {
|
|
1520
|
-
const rect = el.getBoundingClientRect();
|
|
1521
|
-
const tip = document.createElement('div');
|
|
1522
|
-
tip.className = 'session-id-tip';
|
|
1523
|
-
tip.style.top = (rect.bottom + 4) + 'px';
|
|
1524
|
-
tip.style.left = rect.left + 'px';
|
|
1525
|
-
tip.innerHTML = `<button class="tip-copy-btn" onclick="event.stopPropagation();copySessionId(this)">Copy</button><code>${esc(sid)}</code>`;
|
|
1526
|
-
tip.onmouseenter = () => clearTimeout(sessionIdTipTimer);
|
|
1527
|
-
tip.onmouseleave = () => { hideAllSessionIdTips(); };
|
|
1528
|
-
document.body.appendChild(tip);
|
|
1529
|
-
sessionIdTipEl = tip;
|
|
1530
|
-
el._tip = tip;
|
|
1531
|
-
}, 300);
|
|
1532
|
-
}
|
|
1533
|
-
function hideSessionIdTip(el) {
|
|
1534
|
-
sessionIdTipTimer = setTimeout(() => {
|
|
1535
|
-
if (el._tip) { el._tip.remove(); el._tip = null; }
|
|
1536
|
-
sessionIdTipEl = null;
|
|
1537
|
-
}, 200);
|
|
1538
|
-
}
|
|
1539
|
-
function hideAllSessionIdTips() {
|
|
1540
|
-
clearTimeout(sessionIdTipTimer);
|
|
1541
|
-
document.querySelectorAll('.session-id-tip').forEach(t => t.remove());
|
|
1542
|
-
sessionIdTipEl = null;
|
|
1543
|
-
}
|
|
1544
|
-
function copySessionId(btn) {
|
|
1545
|
-
const code = btn.parentElement.querySelector('code');
|
|
1546
|
-
if (!code) return;
|
|
1547
|
-
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
1548
|
-
btn.textContent = 'Copied!';
|
|
1549
|
-
setTimeout(() => { btn.closest('.session-id-tip')?.remove(); }, 800);
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1554
|
-
// Actions
|
|
1555
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1556
|
-
|
|
1557
|
-
function selectIndex(idx) {
|
|
1558
|
-
if (idx >= 0 && idx < treeNodes.length) treeCursor = idx;
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
function treeClick(idx) {
|
|
1562
|
-
selectIndex(idx);
|
|
1563
|
-
const node = treeNodes[idx];
|
|
1564
|
-
if (!node) return;
|
|
1565
|
-
if (node.type === 'date-folder') {
|
|
1566
|
-
node.collapsed = !node.collapsed;
|
|
1567
|
-
folderCollapsed[node.date] = node.collapsed;
|
|
1568
|
-
rebuildNodes();
|
|
1569
|
-
} else if (node.type === 'session') {
|
|
1570
|
-
const session = sessions.find(s => s.id === node.id);
|
|
1571
|
-
if (session) {
|
|
1572
|
-
session.collapsed = !session.collapsed;
|
|
1573
|
-
if (!session.collapsed) session.pinned = true;
|
|
1574
|
-
}
|
|
1575
|
-
rebuildNodes();
|
|
1576
|
-
} else if (node.type === 'main' || node.type === 'agent') {
|
|
1577
|
-
toggleNodeVisibility(idx);
|
|
1578
|
-
return;
|
|
1579
|
-
} else if (node.type === 'task') {
|
|
1580
|
-
loadBgTask(idx);
|
|
1581
|
-
return;
|
|
1582
|
-
}
|
|
1583
|
-
renderAll();
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
function toggleNodeVisibility(idx) {
|
|
1587
|
-
const node = treeNodes[idx];
|
|
1588
|
-
if (!node) return;
|
|
1589
|
-
const key = node.sessionID + ':' + node.id;
|
|
1590
|
-
const wasEnabled = filters.get(key);
|
|
1591
|
-
filters.set(key, !wasEnabled);
|
|
1592
|
-
if (wasEnabled) visibleFilterCount--;
|
|
1593
|
-
else visibleFilterCount++;
|
|
1594
|
-
renderAll();
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
function loadBgTask(idx) {
|
|
1598
|
-
const node = treeNodes[idx];
|
|
1599
|
-
if (!node || node.type !== 'task') return;
|
|
1600
|
-
if (!node.outputPath) return;
|
|
1601
|
-
|
|
1602
|
-
// Fetch the actual output file
|
|
1603
|
-
fetch(`/api/task-output?path=${encodeURIComponent(node.outputPath)}`)
|
|
1604
|
-
.then(r => r.json())
|
|
1605
|
-
.then(data => {
|
|
1606
|
-
const content = data.content || `[Error: ${data.error || 'unknown'}]`;
|
|
1607
|
-
const statusIcon = node.isComplete ? '✓' : '⏳';
|
|
1608
|
-
streamItems.push({
|
|
1609
|
-
type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
|
|
1610
|
-
agentName: '', toolName: `${statusIcon} ${node.name || 'bg-task'}`,
|
|
1611
|
-
content: content,
|
|
1612
|
-
timestamp: new Date(), toolID: '', durationMs: 0,
|
|
1613
|
-
inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
|
|
1614
|
-
});
|
|
1615
|
-
renderAll();
|
|
1616
|
-
})
|
|
1617
|
-
.catch(err => {
|
|
1618
|
-
streamItems.push({
|
|
1619
|
-
type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
|
|
1620
|
-
agentName: '', toolName: `⏳ ${node.name || 'bg-task'}`,
|
|
1621
|
-
content: `[Failed to load: ${err.message}]`,
|
|
1622
|
-
timestamp: new Date(), toolID: '', durationMs: 0,
|
|
1623
|
-
inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
|
|
1624
|
-
});
|
|
1625
|
-
renderAll();
|
|
1626
|
-
});
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
function soloSelected() {
|
|
1630
|
-
const node = treeNodes[treeCursor];
|
|
1631
|
-
if (!node || node.type === 'task') return;
|
|
1632
|
-
|
|
1633
|
-
if (isSoloed(node)) {
|
|
1634
|
-
updateFilters();
|
|
1635
|
-
} else {
|
|
1636
|
-
filters.clear();
|
|
1637
|
-
visibleFilterCount = 0;
|
|
1638
|
-
if (node.type === 'session') {
|
|
1639
|
-
const session = sessions.find(s => s.id === node.id);
|
|
1640
|
-
if (session && session.collapsed) {
|
|
1641
|
-
session.collapsed = false;
|
|
1642
|
-
session.pinned = true;
|
|
1643
|
-
rebuildNodes();
|
|
1644
|
-
}
|
|
1645
|
-
for (const a of node.agents) {
|
|
1646
|
-
filters.set(node.id + ':' + a.id, true);
|
|
1647
|
-
visibleFilterCount++;
|
|
1648
|
-
}
|
|
1649
|
-
} else if (node.type === 'main' || node.type === 'agent') {
|
|
1650
|
-
filters.set(node.sessionID + ':' + node.id, true);
|
|
1651
|
-
visibleFilterCount = 1;
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
renderAll();
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
function isSoloed(node) {
|
|
1658
|
-
if (node.type === 'session') {
|
|
1659
|
-
if (visibleFilterCount !== node.agents.length) return false;
|
|
1660
|
-
for (const a of node.agents) {
|
|
1661
|
-
if (!filters.get(node.id + ':' + a.id)) return false;
|
|
1662
|
-
}
|
|
1663
|
-
return true;
|
|
1664
|
-
}
|
|
1665
|
-
if (node.type === 'main' || node.type === 'agent') {
|
|
1666
|
-
const key = node.sessionID + ':' + node.id;
|
|
1667
|
-
return visibleFilterCount === 1 && filters.get(key);
|
|
1668
|
-
}
|
|
1669
|
-
return false;
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
function selectAll() {
|
|
1673
|
-
updateFilters();
|
|
1674
|
-
renderAll();
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
function removeSelectedSession() {
|
|
1678
|
-
const node = treeNodes[treeCursor];
|
|
1679
|
-
if (!node) return;
|
|
1680
|
-
let sid;
|
|
1681
|
-
if (node.type === 'session') sid = node.id;
|
|
1682
|
-
else sid = node.sessionID;
|
|
1683
|
-
if (!sid) return;
|
|
1684
|
-
if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
|
|
1685
|
-
hiddenSessionIDs.add(sid);
|
|
1686
|
-
_saveHiddenSessions();
|
|
1687
|
-
const idx = sessions.findIndex(s => s.id === sid);
|
|
1688
|
-
if (idx >= 0) {
|
|
1689
|
-
sessions.splice(idx, 1);
|
|
1690
|
-
sessionsMap.delete(sid);
|
|
1691
|
-
}
|
|
1692
|
-
sendCmd('removeSession', { sessionID: sid });
|
|
1693
|
-
updateFilters();
|
|
1694
|
-
rebuildNodes();
|
|
1695
|
-
renderAll();
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1699
|
-
// Toggles
|
|
1700
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1701
|
-
|
|
1702
|
-
function toggleThinking() { showThinking = !showThinking; needsFullRender = true;
|
|
1703
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1704
|
-
function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true;
|
|
1705
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1706
|
-
function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true;
|
|
1707
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1708
|
-
function toggleText() { showText = !showText; needsFullRender = true;
|
|
1709
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1710
|
-
function toggleHook() { showHook = !showHook; needsFullRender = true;
|
|
1711
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1712
|
-
function toggleUserPrompt() { showUserPrompt = !showUserPrompt; needsFullRender = true;
|
|
1713
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1714
|
-
function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
|
|
1715
|
-
function toggleTokenDisplay() {
|
|
1716
|
-
showTokenCount = !showTokenCount;
|
|
1717
|
-
treeDirty = true;
|
|
1718
|
-
scheduleRender();
|
|
1719
|
-
refreshButtons();
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1723
|
-
// Tab switching & Token stats page
|
|
1724
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1725
|
-
// Token Statistics (completely independent from stream/context)
|
|
1726
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1727
|
-
|
|
1728
|
-
const MODEL_COLORS = {
|
|
1729
|
-
'claude-opus-4-7': '#e74c3c', 'claude-opus-4-6': '#c0392b', 'claude-opus-4-8': '#e67e22',
|
|
1730
|
-
'claude-sonnet-4-6': '#3498db', 'claude-sonnet-4-5': '#2980b9',
|
|
1731
|
-
'claude-haiku-4-5': '#5dade2', 'claude-haiku-4': '#1abc9c',
|
|
1732
|
-
'glm-5.1': '#2980b9', 'glm-5': '#3498db', 'glm-4.7': '#5dade2',
|
|
1733
|
-
'qwen3.7-max': '#55efc4', 'qwen3.6-plus': '#2ecc71', 'qwen3.5-plus': '#27ae60',
|
|
1734
|
-
'qwen3-max': '#1abc9c',
|
|
1735
|
-
'deepseek-v4-pro': '#9b59b6',
|
|
1736
|
-
'kimi-k2.5': '#f39c12', 'kimi-k2.6': '#d35400', 'kimi-k2-thinking': '#d4a017',
|
|
1737
|
-
'MiniMax-M2.5': '#1abc9c',
|
|
1738
|
-
};
|
|
1739
|
-
let _modelColorIdx = 0;
|
|
1740
|
-
function modelColor(name) {
|
|
1741
|
-
if (MODEL_COLORS[name]) return MODEL_COLORS[name];
|
|
1742
|
-
const fallback = ['#e74c3c','#3498db','#2ecc71','#9b59b6','#f39c12','#1abc9c','#e67e22','#c0392b','#5dade2','#d35400','#55efc4','#d4a017'];
|
|
1743
|
-
return fallback[_modelColorIdx++ % fallback.length];
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
let tsDetailTab = 'daily';
|
|
1747
|
-
|
|
1748
|
-
function switchTab(tab) {
|
|
1749
|
-
currentTab = tab;
|
|
1750
|
-
document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
|
|
1751
|
-
document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
|
|
1752
|
-
document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
|
|
1753
|
-
document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
|
|
1754
|
-
document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
|
|
1755
|
-
if (tab === 'tokens') renderTokenPage();
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
function tsSwitchDetail(n) {
|
|
1759
|
-
tsDetailTab = n;
|
|
1760
|
-
document.querySelectorAll('.tp-tab').forEach(t => t.classList.remove('active'));
|
|
1761
|
-
document.querySelectorAll('.tp-tc').forEach(t => t.classList.remove('active'));
|
|
1762
|
-
document.querySelector(`.tp-tab[data-tab="${n}"]`)?.classList.add('active');
|
|
1763
|
-
document.getElementById('tp-tc-' + n)?.classList.add('active');
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
function fmtTS(n) {
|
|
1767
|
-
if (!n) return '0';
|
|
1768
|
-
return n.toLocaleString();
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
function fmtDateISO(d) {
|
|
1772
|
-
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
// ── Heatmap: 52-week × 7-day GitHub-style grid ──
|
|
1776
|
-
function buildHeatmap(daily) {
|
|
1777
|
-
const today = new Date();
|
|
1778
|
-
const dailyTotalsMap = {};
|
|
1779
|
-
for (const [k, d] of Object.entries(daily)) {
|
|
1780
|
-
dailyTotalsMap[k] = d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
// Start 52 weeks ago from Sunday
|
|
1784
|
-
const startSunday = new Date(today);
|
|
1785
|
-
startSunday.setDate(startSunday.getDate() - startSunday.getDay() - 52 * 7);
|
|
1786
|
-
const startStr = fmtDateISO(startSunday);
|
|
1787
|
-
|
|
1788
|
-
// Compute maxVal only from dates within the heatmap window
|
|
1789
|
-
let maxVal = 0;
|
|
1790
|
-
for (const [k, v] of Object.entries(dailyTotalsMap)) {
|
|
1791
|
-
if (k >= startStr && v > maxVal) maxVal = v;
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
const weeks = [];
|
|
1795
|
-
const monthLabels = [];
|
|
1796
|
-
let lastMonth = -1;
|
|
1797
|
-
let currentSunday = new Date(startSunday);
|
|
1798
|
-
|
|
1799
|
-
for (let w = 0; w < 53; w++) {
|
|
1800
|
-
const weekData = [];
|
|
1801
|
-
for (let dow = 0; dow < 7; dow++) {
|
|
1802
|
-
const d = new Date(currentSunday);
|
|
1803
|
-
d.setDate(d.getDate() + dow);
|
|
1804
|
-
const ds = fmtDateISO(d);
|
|
1805
|
-
const val = dailyTotalsMap[ds] || 0;
|
|
1806
|
-
weekData.push({ date: ds, val, future: d > today });
|
|
1807
|
-
if (dow === 0) {
|
|
1808
|
-
const m = d.getMonth();
|
|
1809
|
-
if (m !== lastMonth) { monthLabels.push({ month: m, week: w }); lastMonth = m; }
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
|
-
weeks.push(weekData);
|
|
1813
|
-
currentSunday.setDate(currentSunday.getDate() + 7);
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
function cellColor(val, future) {
|
|
1817
|
-
if (future) return 'var(--bg3)';
|
|
1818
|
-
if (val === 0) return '#0d423d';
|
|
1819
|
-
const pct = maxVal > 0 ? val / maxVal : 0;
|
|
1820
|
-
if (pct < 0.25) return '#0e6b5a';
|
|
1821
|
-
if (pct < 0.5) return '#12b886';
|
|
1822
|
-
if (pct < 0.75) return '#34d399';
|
|
1823
|
-
return '#6ee7b7';
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
1827
|
-
let monthsHTML = '<div class="tp-hm-months">';
|
|
1828
|
-
let prevWeek = 0;
|
|
1829
|
-
for (const ml of monthLabels) {
|
|
1830
|
-
const offset = ml.week - prevWeek;
|
|
1831
|
-
if (offset > 0) monthsHTML += `<span style="width:${offset * 14}px"></span>`;
|
|
1832
|
-
monthsHTML += `<span style="width:14px">${monthNames[ml.month]}</span>`;
|
|
1833
|
-
prevWeek = ml.week + 1;
|
|
1834
|
-
}
|
|
1835
|
-
monthsHTML += '</div>';
|
|
1836
|
-
|
|
1837
|
-
const dayLabels = ['','Mon','','Wed','','Fri',''];
|
|
1838
|
-
let gridHTML = '';
|
|
1839
|
-
for (let dow = 0; dow < 7; dow++) {
|
|
1840
|
-
gridHTML += `<div class="tp-hm-row"><span class="tp-hm-day-label">${dayLabels[dow]}</span>`;
|
|
1841
|
-
for (let w = 0; w < weeks.length; w++) {
|
|
1842
|
-
const cell = weeks[w][dow];
|
|
1843
|
-
const bg = cellColor(cell.val, cell.future);
|
|
1844
|
-
const tip = `${cell.date} · ${fmtTS(cell.val)} tokens`;
|
|
1845
|
-
gridHTML += `<span class="tp-hm-cell" style="background:${bg}" title="${tip}"></span>`;
|
|
1846
|
-
}
|
|
1847
|
-
gridHTML += '</div>';
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
let legendHTML = '<div class="tp-hm-legend"><span>Less</span>';
|
|
1851
|
-
legendHTML += '<span class="tp-hm-legend-cell" style="background:#0d423d"></span>';
|
|
1852
|
-
legendHTML += '<span class="tp-hm-legend-cell" style="background:#0e6b5a"></span>';
|
|
1853
|
-
legendHTML += '<span class="tp-hm-legend-cell" style="background:#12b886"></span>';
|
|
1854
|
-
legendHTML += '<span class="tp-hm-legend-cell" style="background:#34d399"></span>';
|
|
1855
|
-
legendHTML += '<span class="tp-hm-legend-cell" style="background:#6ee7b7"></span>';
|
|
1856
|
-
legendHTML += '<span>More</span></div>';
|
|
1857
|
-
|
|
1858
|
-
return `<div class="tp-heatmap"><div class="tp-heatmap-inner">${monthsHTML}${gridHTML}</div>${legendHTML}</div>`;
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
// ── Trend: bar chart for last 30 days ──
|
|
1862
|
-
function buildTrend(daily) {
|
|
1863
|
-
const keys = Object.keys(daily).sort();
|
|
1864
|
-
const recentKeys = keys.slice(-30);
|
|
1865
|
-
if (recentKeys.length === 0) return '<div style="color:var(--dim);padding:8px">暂无趋势数据</div>';
|
|
1866
|
-
|
|
1867
|
-
const values = recentKeys.map(k => {
|
|
1868
|
-
const d = daily[k];
|
|
1869
|
-
return d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
1870
|
-
});
|
|
1871
|
-
const maxVal = Math.max(...values);
|
|
1872
|
-
|
|
1873
|
-
let barsHTML = '';
|
|
1874
|
-
for (let i = 0; i < recentKeys.length; i++) {
|
|
1875
|
-
const k = recentKeys[i];
|
|
1876
|
-
const v = values[i];
|
|
1877
|
-
const pct = maxVal > 0 ? (v / maxVal * 100) : 0;
|
|
1878
|
-
const label = k.slice(5);
|
|
1879
|
-
const tip = `${k}: ${fmtTS(v)}`;
|
|
1880
|
-
const color = pct < 30 ? '#0e6b5a' : pct < 60 ? '#12b886' : pct < 80 ? '#34d399' : '#6ee7b7';
|
|
1881
|
-
barsHTML += `<div class="tp-trend-bar-wrap"><div class="tp-trend-bar" style="height:${Math.max(pct, 3)}%;background:${color}" data-tip="${tip}"></div><span class="tp-trend-label">${label}</span></div>`;
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
|
-
const gridLines = `<div class="tp-trend-grid-lines">
|
|
1885
|
-
<span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxVal)}</span>
|
|
1886
|
-
<div class="tp-trend-grid-line"></div>
|
|
1887
|
-
<div class="tp-trend-grid-line"></div>
|
|
1888
|
-
<span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(maxVal * 0.5)}</span>
|
|
1889
|
-
<div class="tp-trend-grid-line"></div>
|
|
1890
|
-
<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>
|
|
1891
|
-
</div>`;
|
|
1892
|
-
|
|
1893
|
-
return `<div class="tp-trend-bars">${gridLines}${barsHTML}</div>`;
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
// ── Model ranking sidebar ──
|
|
1897
|
-
function buildModelRank(mt, totalAll) {
|
|
1898
|
-
const sorted = Object.entries(mt).sort((a, b) => {
|
|
1899
|
-
const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
|
|
1900
|
-
const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
|
|
1901
|
-
return sB - sA;
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
let html = '<div class="tp-rank-title">🏆 Model Ranking</div>';
|
|
1905
|
-
for (let i = 0; i < Math.min(sorted.length, 5); i++) {
|
|
1906
|
-
const [name, m] = sorted[i];
|
|
1907
|
-
const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
1908
|
-
const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
|
|
1909
|
-
const c = modelColor(name);
|
|
1910
|
-
html += `<div class="tp-rank-item">
|
|
1911
|
-
<span class="tp-rank-num">${i + 1}</span>
|
|
1912
|
-
<span class="tp-rank-dot" style="background:${c}"></span>
|
|
1913
|
-
<span class="tp-rank-name">${esc(name)}</span>
|
|
1914
|
-
<span class="tp-rank-pct">${pct}%</span>
|
|
1915
|
-
</div>`;
|
|
1916
|
-
}
|
|
1917
|
-
return html;
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
// ── Render entire token page ──
|
|
1921
|
-
function renderTokenPage() {
|
|
1922
|
-
const t = tokenStatsData.totals;
|
|
1923
|
-
const mt = tokenStatsData.modelTotals;
|
|
1924
|
-
const daily = tokenStatsData.daily;
|
|
1925
|
-
const totalAll = t.input + t.output + t.cacheCreation + t.cacheRead;
|
|
1926
|
-
|
|
1927
|
-
if (totalAll === 0) {
|
|
1928
|
-
document.getElementById('tp-total-card').innerHTML = '<div style="color:var(--dim);padding:8px">暂无历史 Token 数据</div>';
|
|
1929
|
-
document.getElementById('tp-stats-grid').innerHTML = '';
|
|
1930
|
-
document.getElementById('tp-model-rank').innerHTML = '';
|
|
1931
|
-
document.getElementById('tp-trend-card').innerHTML = '';
|
|
1932
|
-
document.getElementById('tp-heatmap-card').innerHTML = '';
|
|
1933
|
-
document.getElementById('tp-detail-tabs').innerHTML = '';
|
|
1934
|
-
document.getElementById('tp-daily-table').innerHTML = '';
|
|
1935
|
-
document.getElementById('tp-weekly-table').innerHTML = '';
|
|
1936
|
-
document.getElementById('tp-monthly-table').innerHTML = '';
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
const inputPct = totalAll > 0 ? (t.input / totalAll * 100).toFixed(1) : '0';
|
|
1941
|
-
const outputPct = totalAll > 0 ? (t.output / totalAll * 100).toFixed(1) : '0';
|
|
1942
|
-
const crPct = totalAll > 0 ? (t.cacheRead / totalAll * 100).toFixed(1) : '0';
|
|
1943
|
-
const ccPct = totalAll > 0 ? (t.cacheCreation / totalAll * 100).toFixed(1) : '0';
|
|
1944
|
-
const dailyAvg = t.days > 0 ? Math.round(totalAll / t.days).toLocaleString() : '—';
|
|
1945
|
-
|
|
1946
|
-
// 1. Total tokens card
|
|
1947
|
-
document.getElementById('tp-total-card').innerHTML = `
|
|
1948
|
-
<div class="tp-total-label">TOTAL TOKENS</div>
|
|
1949
|
-
<div class="tp-total-value">${fmtTS(totalAll)}</div>
|
|
1950
|
-
<div class="tp-footer-stats">
|
|
1951
|
-
<span>Started <span class="tp-fv">${Object.keys(daily).sort()[0] || '—'}</span></span>
|
|
1952
|
-
<span>Active <span class="tp-fv">${t.days} DAY</span></span>
|
|
1953
|
-
<span>Models <span class="tp-fv">${Object.keys(mt).length}</span></span>
|
|
1954
|
-
</div>`;
|
|
1955
|
-
|
|
1956
|
-
// 2. Stats grid
|
|
1957
|
-
const stats = [
|
|
1958
|
-
{ l: 'Input', v: fmtTS(t.input), s: inputPct + '%' },
|
|
1959
|
-
{ l: 'Output', v: fmtTS(t.output), s: outputPct + '%' },
|
|
1960
|
-
{ l: 'Cache Read', v: fmtTS(t.cacheRead), s: crPct + '%' },
|
|
1961
|
-
{ l: 'Cache Create', v: fmtTS(t.cacheCreation), s: ccPct + '%' },
|
|
1962
|
-
{ l: 'Messages', v: fmtTS(t.messages), s: t.messages.toLocaleString() },
|
|
1963
|
-
{ l: 'Daily Avg', v: dailyAvg, s: 'tokens/day' },
|
|
1964
|
-
];
|
|
1965
|
-
document.getElementById('tp-stats-grid').innerHTML = `<div class="tp-stat-grid">${stats.map(s => `<div class="tp-stat"><div class="tp-s-l">${s.l}</div><div class="tp-s-v">${s.v}</div><div style="font-size:9px;color:var(--dim)">${s.s}</div></div>`).join('')}</div>`;
|
|
1966
|
-
|
|
1967
|
-
// 3. Model ranking
|
|
1968
|
-
document.getElementById('tp-model-rank').innerHTML = buildModelRank(mt, totalAll);
|
|
1969
|
-
|
|
1970
|
-
// 4. Usage Trend
|
|
1971
|
-
document.getElementById('tp-trend-card').innerHTML = `<div class="tp-h3">📊 Usage Trend</div>${buildTrend(daily)}`;
|
|
1972
|
-
|
|
1973
|
-
// 5. Activity Heatmap
|
|
1974
|
-
const tzOffset = -(new Date().getTimezoneOffset() / 60);
|
|
1975
|
-
document.getElementById('tp-heatmap-card').innerHTML = `<div class="tp-h3">🗓 Activity Heatmap</div><span style="font-size:10px;color:var(--dim);float:right">UTC+${tzOffset.toFixed(0)}</span>${buildHeatmap(daily)}`;
|
|
1976
|
-
|
|
1977
|
-
// 6. Detail tabs
|
|
1978
|
-
const dailyKeys = Object.keys(daily);
|
|
1979
|
-
const weeklyCount = weeklyKeysFromDaily(dailyKeys).length;
|
|
1980
|
-
const monthlyCount = monthlyKeysFromDaily(dailyKeys).length;
|
|
1981
|
-
document.getElementById('tp-detail-tabs').innerHTML = `<div class="tp-tab ${tsDetailTab === 'daily' ? 'active' : ''}" data-tab="daily" onclick="tsSwitchDetail('daily')">Daily Breakdown (${dailyKeys.length})</div><div class="tp-tab ${tsDetailTab === 'weekly' ? 'active' : ''}" data-tab="weekly" onclick="tsSwitchDetail('weekly')">Weekly (${weeklyCount})</div><div class="tp-tab ${tsDetailTab === 'monthly' ? 'active' : ''}" data-tab="monthly" onclick="tsSwitchDetail('monthly')">Monthly (${monthlyCount})</div>`;
|
|
1982
|
-
document.querySelectorAll('.tp-tc').forEach(tc => tc.classList.remove('active'));
|
|
1983
|
-
document.getElementById('tp-tc-' + tsDetailTab)?.classList.add('active');
|
|
1984
|
-
|
|
1985
|
-
document.getElementById('tp-daily-table').innerHTML = renderPeriodTable(dailyKeys, daily, 'daily');
|
|
1986
|
-
const weekly = aggregateWeekly(dailyKeys, daily);
|
|
1987
|
-
document.getElementById('tp-weekly-table').innerHTML = renderPeriodTable(Object.keys(weekly), weekly, 'weekly');
|
|
1988
|
-
const monthly = aggregateMonthly(dailyKeys, daily);
|
|
1989
|
-
document.getElementById('tp-monthly-table').innerHTML = renderPeriodTable(Object.keys(monthly), monthly, 'monthly');
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
function weeklyKeysFromDaily(keys) {
|
|
1993
|
-
const weeks = new Set();
|
|
1994
|
-
for (const k of keys) { const d = new Date(k); const wk = getWeekKey(d); weeks.add(wk); }
|
|
1995
|
-
return [...weeks];
|
|
1996
|
-
}
|
|
1997
|
-
function monthlyKeysFromDaily(keys) {
|
|
1998
|
-
const months = new Set();
|
|
1999
|
-
for (const k of keys) { months.add(k.slice(0, 7)); }
|
|
2000
|
-
return [...months];
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
function getWeekKey(d) {
|
|
2004
|
-
const dayNum = d.getDay() || 7; // Sunday (0) becomes 7 for ISO week calculation
|
|
2005
|
-
const thursday = new Date(d);
|
|
2006
|
-
thursday.setDate(d.getDate() + 4 - dayNum); // ISO Thursday offset
|
|
2007
|
-
const year = thursday.getFullYear();
|
|
2008
|
-
const jan1 = new Date(year, 0, 1);
|
|
2009
|
-
const wk = Math.ceil(((thursday - jan1) / 86400000 + jan1.getDay() + 1) / 7);
|
|
2010
|
-
return year + '-W' + String(wk).padStart(2, '0');
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
function aggregateWeekly(dailyKeys, daily) {
|
|
2014
|
-
const result = {};
|
|
2015
|
-
for (const k of dailyKeys) {
|
|
2016
|
-
const d = new Date(k);
|
|
2017
|
-
const wk = getWeekKey(d);
|
|
2018
|
-
if (!result[wk]) result[wk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
|
|
2019
|
-
else result[wk].dateRange += ' ~ ' + k;
|
|
2020
|
-
const day = daily[k];
|
|
2021
|
-
result[wk].messages += day.messages;
|
|
2022
|
-
result[wk].input += day.input;
|
|
2023
|
-
result[wk].output += day.output;
|
|
2024
|
-
result[wk].cacheCreation += day.cacheCreation;
|
|
2025
|
-
result[wk].cacheRead += day.cacheRead;
|
|
2026
|
-
for (const [mn, m] of Object.entries(day.models)) {
|
|
2027
|
-
if (!result[wk].models[mn]) result[wk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
2028
|
-
result[wk].models[mn].input += m.input;
|
|
2029
|
-
result[wk].models[mn].output += m.output;
|
|
2030
|
-
result[wk].models[mn].cacheCreation += m.cacheCreation;
|
|
2031
|
-
result[wk].models[mn].cacheRead += m.cacheRead;
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
return result;
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
function aggregateMonthly(dailyKeys, daily) {
|
|
2038
|
-
const result = {};
|
|
2039
|
-
for (const k of dailyKeys) {
|
|
2040
|
-
const mk = k.slice(0, 7);
|
|
2041
|
-
if (!result[mk]) result[mk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
|
|
2042
|
-
else result[mk].dateRange += ' ~ ' + k.slice(5);
|
|
2043
|
-
const day = daily[k];
|
|
2044
|
-
result[mk].messages += day.messages;
|
|
2045
|
-
result[mk].input += day.input;
|
|
2046
|
-
result[mk].output += day.output;
|
|
2047
|
-
result[mk].cacheCreation += day.cacheCreation;
|
|
2048
|
-
result[mk].cacheRead += day.cacheRead;
|
|
2049
|
-
for (const [mn, m] of Object.entries(day.models)) {
|
|
2050
|
-
if (!result[mk].models[mn]) result[mk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
2051
|
-
result[mk].models[mn].input += m.input;
|
|
2052
|
-
result[mk].models[mn].output += m.output;
|
|
2053
|
-
result[mk].models[mn].cacheCreation += m.cacheCreation;
|
|
2054
|
-
result[mk].models[mn].cacheRead += m.cacheRead;
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
return result;
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
function renderPeriodTable(keys, data, type) {
|
|
2061
|
-
const sorted = keys.sort((a, b) => b.localeCompare(a));
|
|
2062
|
-
let html = '<table class="tp-table"><thead><tr><th>Date</th><th>Total</th><th>Input</th><th>Output</th><th>Cache Read</th><th>Cache Create</th><th>Messages</th><th>Models</th></tr></thead><tbody>';
|
|
2063
|
-
for (const k of sorted) {
|
|
2064
|
-
const d = data[k];
|
|
2065
|
-
const total = d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
2066
|
-
const label = type === 'daily' ? k : k + '<br><small style="color:var(--dim)">' + esc(d.dateRange || '') + '</small>';
|
|
2067
|
-
const modelsHtml = Object.entries(d.models).sort((a, b) => {
|
|
2068
|
-
const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
|
|
2069
|
-
const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
|
|
2070
|
-
return sB - sA;
|
|
2071
|
-
}).slice(0, 4).map(([mn, m]) => {
|
|
2072
|
-
const mT = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
2073
|
-
const c = modelColor(mn);
|
|
2074
|
-
return `<span class="tp-mtag" style="background:${c}20;border-color:${c};color:${c}">${esc(mn)}: ${fmtTS(mT)}</span>`;
|
|
2075
|
-
}).join(' ');
|
|
2076
|
-
html += `<tr><td>${label}</td><td><b>${fmtTS(total)}</b></td><td>${fmtTS(d.input)}</td><td>${fmtTS(d.output)}</td><td>${fmtTS(d.cacheRead)}</td><td>${fmtTS(d.cacheCreation)}</td><td>${d.messages.toLocaleString()}</td><td class="tp-mbreak">${modelsHtml}</td></tr>`;
|
|
2077
|
-
}
|
|
2078
|
-
html += '</tbody></table>';
|
|
2079
|
-
return html;
|
|
2080
|
-
}
|
|
2081
|
-
function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
|
|
2082
|
-
function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
|
|
2083
|
-
function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
|
|
2084
|
-
|
|
2085
|
-
function scrollToTop() { streamEl.scrollTop = 0; autoScroll = false; renderAll(); }
|
|
2086
|
-
function scrollUp() { streamEl.scrollTop -= 80; autoScroll = false; renderAll(); }
|
|
2087
|
-
function scrollDown() { streamEl.scrollTop += 80; if (autoScroll) autoScroll = false; renderAll(); }
|
|
2088
|
-
function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; autoScroll = true; renderAll(); }
|
|
2089
|
-
|
|
2090
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2091
|
-
// Tree panel resize
|
|
2092
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2093
|
-
|
|
2094
|
-
(function setupResize() {
|
|
2095
|
-
const panel = document.getElementById('tree-panel');
|
|
2096
|
-
const handle = document.getElementById('tree-resize-handle');
|
|
2097
|
-
let startX, startWidth;
|
|
2098
|
-
|
|
2099
|
-
handle.addEventListener('mousedown', (e) => {
|
|
2100
|
-
e.preventDefault();
|
|
2101
|
-
startX = e.clientX;
|
|
2102
|
-
startWidth = panel.offsetWidth;
|
|
2103
|
-
handle.classList.add('active');
|
|
2104
|
-
document.body.style.cursor = 'col-resize';
|
|
2105
|
-
document.body.style.userSelect = 'none';
|
|
2106
|
-
});
|
|
2107
|
-
|
|
2108
|
-
document.addEventListener('mousemove', (e) => {
|
|
2109
|
-
if (!handle.classList.contains('active')) return;
|
|
2110
|
-
const dx = e.clientX - startX;
|
|
2111
|
-
const newWidth = startWidth + dx;
|
|
2112
|
-
if (newWidth >= 180 && newWidth <= window.innerWidth * 0.6) {
|
|
2113
|
-
panel.style.width = newWidth + 'px';
|
|
2114
|
-
}
|
|
2115
|
-
});
|
|
2116
|
-
|
|
2117
|
-
document.addEventListener('mouseup', () => {
|
|
2118
|
-
handle.classList.remove('active');
|
|
2119
|
-
document.body.style.cursor = '';
|
|
2120
|
-
document.body.style.userSelect = '';
|
|
2121
|
-
});
|
|
2122
|
-
})();
|
|
2123
|
-
|
|
2124
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2125
|
-
// Auto-collapse
|
|
2126
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2127
|
-
|
|
2128
|
-
function applyCollapsePolicy(duration) {
|
|
2129
|
-
collapseAfter = duration;
|
|
2130
|
-
if (collapseTimer) clearInterval(collapseTimer);
|
|
2131
|
-
if (duration <= 0) return;
|
|
2132
|
-
|
|
2133
|
-
collapseTimer = setInterval(() => {
|
|
2134
|
-
if (!collapseAfter) return;
|
|
2135
|
-
const now = Date.now();
|
|
2136
|
-
let changed = false;
|
|
2137
|
-
for (const s of sessions) {
|
|
2138
|
-
if (s.pinned || s.collapsed) continue;
|
|
2139
|
-
if ((now - s.lastActivity) > collapseAfter) {
|
|
2140
|
-
s.collapsed = true;
|
|
2141
|
-
changed = true;
|
|
2142
|
-
}
|
|
2143
|
-
}
|
|
2144
|
-
if (changed) {
|
|
2145
|
-
rebuildNodes();
|
|
2146
|
-
renderAll();
|
|
2147
|
-
}
|
|
2148
|
-
}, 5000);
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
function startActiveRefresh() {
|
|
2152
|
-
if (activeRefreshTimer) clearInterval(activeRefreshTimer);
|
|
2153
|
-
activeRefreshTimer = setInterval(() => {
|
|
2154
|
-
updateTreeDots();
|
|
2155
|
-
refreshButtons();
|
|
2156
|
-
}, 15000);
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2160
|
-
// Scroll detection
|
|
2161
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2162
|
-
|
|
2163
|
-
streamEl.addEventListener('scroll', () => {
|
|
2164
|
-
const atBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
|
|
2165
|
-
if (atBottom && !autoScroll) autoScroll = true;
|
|
2166
|
-
if (!atBottom && autoScroll) autoScroll = false;
|
|
2167
|
-
refreshButtons();
|
|
2168
|
-
});
|
|
2169
|
-
|
|
2170
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2171
|
-
// Helpers
|
|
2172
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2173
|
-
|
|
2174
|
-
function updateFilters() {
|
|
2175
|
-
filters.clear();
|
|
2176
|
-
visibleFilterCount = 0;
|
|
2177
|
-
for (const s of sessions) {
|
|
2178
|
-
for (const a of s.agents) {
|
|
2179
|
-
filters.set(s.id + ':' + a.id, true);
|
|
2180
|
-
visibleFilterCount++;
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
function agentDisplayName(id, type) {
|
|
2186
|
-
if (type) {
|
|
2187
|
-
const idx = type.lastIndexOf(':');
|
|
2188
|
-
if (idx >= 0 && idx < type.length - 1) return type.slice(idx + 1);
|
|
2189
|
-
return type;
|
|
2190
|
-
}
|
|
2191
|
-
if (!id) return 'Main';
|
|
2192
|
-
return 'Agent-' + id.slice(0, 7);
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
// Compute the minimum display length per agentID so that within each session,
|
|
2196
|
-
// no two agent IDs share the same truncated prefix. Minimum 7 chars.
|
|
2197
|
-
const agentIdDisplayLen = new Map();
|
|
2198
|
-
function computeAgentIdDisplayLengths() {
|
|
2199
|
-
agentIdDisplayLen.clear();
|
|
2200
|
-
for (const s of sessions) {
|
|
2201
|
-
const agentIds = s.agents.filter(a => a.id).map(a => a.id);
|
|
2202
|
-
if (agentIds.length === 0) continue;
|
|
2203
|
-
// Find minimum length that makes all prefixes unique
|
|
2204
|
-
let minLen = 7;
|
|
2205
|
-
while (minLen < 21) {
|
|
2206
|
-
const prefixes = agentIds.map(id => id.slice(0, minLen));
|
|
2207
|
-
const unique = new Set(prefixes);
|
|
2208
|
-
if (unique.size === agentIds.length) break;
|
|
2209
|
-
minLen++;
|
|
2210
|
-
}
|
|
2211
|
-
for (const id of agentIds) {
|
|
2212
|
-
agentIdDisplayLen.set(s.id + ':' + id, minLen);
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
function folderName(projectPath) {
|
|
2218
|
-
if (!projectPath) return '';
|
|
2219
|
-
const parts = projectPath.split('/');
|
|
2220
|
-
return parts[parts.length - 1] || projectPath;
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
function esc(s) {
|
|
2224
|
-
return (s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\\/g, '\');
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
function fmtDur(ms) {
|
|
2228
|
-
if (!ms || ms <= 0) return '';
|
|
2229
|
-
if (ms < 1000) return `(${ms}ms)`;
|
|
2230
|
-
if (ms < 60000) return `(${(ms / 1000).toFixed(1)}s)`;
|
|
2231
|
-
return `(${(ms / 60000).toFixed(1)}m)`;
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
function formatTime(ms) {
|
|
2235
|
-
if (!ms) return '';
|
|
2236
|
-
const d = new Date(ms);
|
|
2237
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
2238
|
-
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
2239
|
-
}
|
|
2240
|
-
|
|
2241
|
-
function fmtTimestamp(ts) {
|
|
2242
|
-
if (!ts) return '';
|
|
2243
|
-
const d = ts instanceof Date ? ts : new Date(ts);
|
|
2244
|
-
if (isNaN(d.getTime())) return '';
|
|
2245
|
-
const pad = (n, len) => String(n).padStart(len, '0');
|
|
2246
|
-
const ms = pad(d.getMilliseconds(), 3);
|
|
2247
|
-
return `${pad(d.getFullYear(),4)}-${pad(d.getMonth()+1,2)}-${pad(d.getDate(),2)} ${pad(d.getHours(),2)}:${pad(d.getMinutes(),2)}:${pad(d.getSeconds(),2)}.${ms}`;
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
function fmtTok(n) {
|
|
2251
|
-
if (!n) return '0';
|
|
2252
|
-
if (n < 1000) return String(n);
|
|
2253
|
-
if (n < 1000000) return (n / 1000).toFixed(2) + 'k';
|
|
2254
|
-
return (n / 1000000).toFixed(2) + 'm';
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
function renderAll() {
|
|
2258
|
-
needsFullRender = true;
|
|
2259
|
-
visibleDirty = true;
|
|
2260
|
-
renderTree();
|
|
2261
|
-
renderStream();
|
|
2262
|
-
refreshButtons();
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
function scheduleRender() {
|
|
2266
|
-
if (!renderPending) {
|
|
2267
|
-
renderPending = true;
|
|
2268
|
-
requestAnimationFrame(() => {
|
|
2269
|
-
renderPending = false;
|
|
2270
|
-
renderTree();
|
|
2271
|
-
renderStream();
|
|
2272
|
-
refreshButtons();
|
|
2273
|
-
});
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2278
|
-
// Export modal — session selection
|
|
2279
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2280
|
-
|
|
2281
|
-
let exportModalSelected = new Set();
|
|
2282
|
-
|
|
2283
|
-
function openExportModal() {
|
|
2284
|
-
if (sessions.length === 0) {
|
|
2285
|
-
const btn = document.getElementById('btn-export');
|
|
2286
|
-
const orig = btn.textContent;
|
|
2287
|
-
btn.textContent = '✕ 无会话';
|
|
2288
|
-
setTimeout(() => { btn.textContent = orig; }, 2000);
|
|
2289
|
-
return;
|
|
2290
|
-
}
|
|
2291
|
-
exportModalSelected = new Set(sessions.map(s => s.id));
|
|
2292
|
-
renderModalSessionList();
|
|
2293
|
-
updateModalCount();
|
|
2294
|
-
document.getElementById('export-modal').style.display = 'flex';
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
function renderModalSessionList() {
|
|
2298
|
-
const listEl = document.getElementById('modal-session-list');
|
|
2299
|
-
const sorted = [...sessions].sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
|
|
2300
|
-
listEl.innerHTML = sorted.map(s => {
|
|
2301
|
-
const color = idColor(s.colorRank || 0);
|
|
2302
|
-
const project = folderName(s.projectPath) || s.projectPath || '';
|
|
2303
|
-
const prefix = s.id.split('-')[0].toUpperCase();
|
|
2304
|
-
const model = s.model || '';
|
|
2305
|
-
const time = formatTime(s.birthtimeMs);
|
|
2306
|
-
const checked = exportModalSelected.has(s.id) ? 'checked' : '';
|
|
2307
|
-
const selectedClass = exportModalSelected.has(s.id) ? ' selected' : '';
|
|
2308
|
-
return `<div class="modal-session-row${selectedClass}" data-sid="${esc(s.id)}" onclick="toggleModalSession('${esc(s.id)}', this)">
|
|
2309
|
-
<input type="checkbox" class="modal-checkbox" data-sid="${esc(s.id)}" ${checked} onclick="event.stopPropagation(); toggleModalSession('${esc(s.id)}', this.parentElement)">
|
|
2310
|
-
<span class="modal-session-prefix" style="color:${color}">${esc(prefix)}</span>
|
|
2311
|
-
<div class="modal-session-info">
|
|
2312
|
-
<span class="modal-session-project">${esc(project)}</span>
|
|
2313
|
-
${model ? `<span class="modal-session-model">${esc(model)}</span>` : ''}
|
|
2314
|
-
</div>
|
|
2315
|
-
${time ? `<span class="modal-session-time">${esc(time)}</span>` : ''}
|
|
2316
|
-
</div>`;
|
|
2317
|
-
}).join('\n');
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
function toggleModalSession(sid, rowEl) {
|
|
2321
|
-
if (exportModalSelected.has(sid)) {
|
|
2322
|
-
exportModalSelected.delete(sid);
|
|
2323
|
-
} else {
|
|
2324
|
-
exportModalSelected.add(sid);
|
|
2325
|
-
}
|
|
2326
|
-
const checkbox = rowEl.querySelector('.modal-checkbox');
|
|
2327
|
-
checkbox.checked = exportModalSelected.has(sid);
|
|
2328
|
-
rowEl.classList.toggle('selected', exportModalSelected.has(sid));
|
|
2329
|
-
updateModalCount();
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
function exportModalToggleAll(selectAll) {
|
|
2333
|
-
if (selectAll) {
|
|
2334
|
-
exportModalSelected = new Set(sessions.map(s => s.id));
|
|
2335
|
-
} else {
|
|
2336
|
-
exportModalSelected.clear();
|
|
2337
|
-
}
|
|
2338
|
-
document.querySelectorAll('#modal-session-list .modal-session-row').forEach(row => {
|
|
2339
|
-
const sid = row.dataset.sid;
|
|
2340
|
-
const checkbox = row.querySelector('.modal-checkbox');
|
|
2341
|
-
checkbox.checked = exportModalSelected.has(sid);
|
|
2342
|
-
row.classList.toggle('selected', exportModalSelected.has(sid));
|
|
2343
|
-
});
|
|
2344
|
-
updateModalCount();
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
function updateModalCount() {
|
|
2348
|
-
const total = sessions.length;
|
|
2349
|
-
const selected = exportModalSelected.size;
|
|
2350
|
-
document.getElementById('modal-selected-count').textContent = `已选 ${selected} / ${total}`;
|
|
2351
|
-
document.getElementById('modal-export-btn').disabled = selected === 0;
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
function closeExportModal() {
|
|
2355
|
-
document.getElementById('export-modal').style.display = 'none';
|
|
2356
|
-
exportModalSelected.clear();
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
// Esc key closes modal
|
|
2360
|
-
document.addEventListener('keydown', (e) => {
|
|
2361
|
-
if (e.key === 'Escape') {
|
|
2362
|
-
const modal = document.getElementById('export-modal');
|
|
2363
|
-
if (modal.style.display !== 'none') {
|
|
2364
|
-
closeExportModal();
|
|
2365
|
-
e.stopPropagation();
|
|
2366
|
-
}
|
|
2367
|
-
}
|
|
2368
|
-
});
|
|
2369
|
-
|
|
2370
|
-
function confirmExport() {
|
|
2371
|
-
if (exportModalSelected.size === 0) return;
|
|
2372
|
-
const selectedIds = new Set(exportModalSelected);
|
|
2373
|
-
closeExportModal();
|
|
2374
|
-
exportHTML(selectedIds);
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2378
|
-
// Export HTML
|
|
2379
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2380
|
-
|
|
2381
|
-
function exportHTML(selectedIds = null) {
|
|
2382
|
-
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
2383
|
-
|
|
2384
|
-
// Collect sessions to export
|
|
2385
|
-
let sidsInExport;
|
|
2386
|
-
if (selectedIds) {
|
|
2387
|
-
sidsInExport = selectedIds;
|
|
2388
|
-
} else {
|
|
2389
|
-
sidsInExport = new Set();
|
|
2390
|
-
for (const item of visibleItems) {
|
|
2391
|
-
if (item.sessionID) sidsInExport.add(item.sessionID);
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
const exportSessions = [];
|
|
2395
|
-
for (const sid of sidsInExport) {
|
|
2396
|
-
const s = sessionsMap.get(sid);
|
|
2397
|
-
if (s) exportSessions.push(s);
|
|
2398
|
-
}
|
|
2399
|
-
// Sort by colorRank to match the order in the tree
|
|
2400
|
-
exportSessions.sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
|
|
2401
|
-
|
|
2402
|
-
// Build session list header
|
|
2403
|
-
let sessionListHTML = '';
|
|
2404
|
-
if (exportSessions.length > 0) {
|
|
2405
|
-
const items = exportSessions.map(s => {
|
|
2406
|
-
const color = idColor(s.colorRank || 0);
|
|
2407
|
-
const project = folderName(s.projectPath) || s.projectPath || '';
|
|
2408
|
-
const model = s.model || '';
|
|
2409
|
-
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>`;
|
|
2410
|
-
}).join('\n');
|
|
2411
|
-
sessionListHTML = `<div class="export-session-list">
|
|
2412
|
-
<div class="export-session-item export-all-btn active" onclick="filterBySession(null)">全部</div>
|
|
2413
|
-
${items}
|
|
2414
|
-
</div>`;
|
|
2415
|
-
}
|
|
2416
|
-
|
|
2417
|
-
// Token info
|
|
2418
|
-
computeTokensFromContext();
|
|
2419
|
-
let tokenHTML = '';
|
|
2420
|
-
if (totalInput > 0 || totalOutput > 0) {
|
|
2421
|
-
let tokStr = `Input: ${fmtTok(totalInput)} · Output: ${fmtTok(totalOutput)}`;
|
|
2422
|
-
if (totalCacheCreate > 0 || totalCacheRead > 0) tokStr += ` · Cache: ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
|
|
2423
|
-
tokenHTML = `<div class="export-meta-line" style="color:var(--dim)">Tokens: ${tokStr}</div>`;
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
|
-
// Filter state
|
|
2427
|
-
const filterState = [];
|
|
2428
|
-
if (!showThinking) filterState.push('thinking hidden');
|
|
2429
|
-
if (!showToolInput) filterState.push('tools hidden');
|
|
2430
|
-
if (!showToolOutput) filterState.push('output hidden');
|
|
2431
|
-
if (!showText) filterState.push('text hidden');
|
|
2432
|
-
if (!showHook) filterState.push('hook hidden');
|
|
2433
|
-
let filterHTML = '';
|
|
2434
|
-
if (filterState.length > 0) filterHTML = `<div class="export-meta-line" style="color:var(--dim)">Filters: ${filterState.join(', ')}</div>`;
|
|
2435
|
-
|
|
2436
|
-
// Export timestamp
|
|
2437
|
-
const now = new Date();
|
|
2438
|
-
const exportTime = fmtTimestamp(now);
|
|
2439
|
-
const timeHTML = `<div class="export-meta-line" style="color:var(--dim)">Exported: ${exportTime}</div>`;
|
|
2440
|
-
|
|
2441
|
-
// Clone stream content and strip interactive elements
|
|
2442
|
-
const clone = streamEl.cloneNode(true);
|
|
2443
|
-
clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
|
|
2444
|
-
clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
|
|
2445
|
-
|
|
2446
|
-
// Filter out stream lines from non-selected sessions
|
|
2447
|
-
if (selectedIds) {
|
|
2448
|
-
clone.querySelectorAll('[data-session-id]').forEach(el => {
|
|
2449
|
-
if (!selectedIds.has(el.dataset.sessionId)) el.remove();
|
|
2450
|
-
});
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
// Get the cleaned innerHTML
|
|
2454
|
-
const streamHTML = clone.innerHTML;
|
|
2455
|
-
|
|
2456
|
-
// Get page CSS
|
|
2457
|
-
const pageStyleEl = document.querySelector('style');
|
|
2458
|
-
const appCSS = pageStyleEl ? pageStyleEl.textContent : '';
|
|
2459
|
-
|
|
2460
|
-
// Get highlight.js CSS from cache
|
|
2461
|
-
const hlCSS = theme === 'dark' ? hljsDarkCSS : hljsLightCSS;
|
|
2462
|
-
|
|
2463
|
-
// Export-specific CSS
|
|
2464
|
-
const exportCSS = `
|
|
2465
|
-
.export-session-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
|
|
2466
|
-
.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; }
|
|
2467
|
-
.export-session-item:hover { opacity: 1; border-color: var(--dim); }
|
|
2468
|
-
.export-session-item.active { opacity: 1; border-color: var(--purple); background: var(--purple); color: var(--white); }
|
|
2469
|
-
.export-all-btn { font-weight: 600; align-items: center; }
|
|
2470
|
-
.export-item-top { display: flex; align-items: baseline; gap: 4px; }
|
|
2471
|
-
.export-item-sid { font-family: monospace; font-size: 10px; opacity: 0.8; }
|
|
2472
|
-
.export-session-item.active .export-item-sid { opacity: 1; color: var(--white); }
|
|
2473
|
-
.export-project { font-weight: 500; }
|
|
2474
|
-
.export-model { font-size: 11px; }
|
|
2475
|
-
.export-meta-line { padding: 2px 0; font-size: 11px; }
|
|
2476
|
-
.export-header { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
|
|
2477
|
-
.export-header h1 { margin: 0 0 4px 0; font-size: 16px; color: var(--white); }
|
|
2478
|
-
`;
|
|
2479
|
-
|
|
2480
|
-
// Export-specific JS for session filtering
|
|
2481
|
-
const exportJS = `
|
|
2482
|
-
let _activeSid = null;
|
|
2483
|
-
function filterBySession(sid) {
|
|
2484
|
-
_activeSid = sid;
|
|
2485
|
-
const lines = document.querySelectorAll('#export-stream [data-session-id]');
|
|
2486
|
-
lines.forEach(el => {
|
|
2487
|
-
el.style.display = (sid === null || el.dataset.sessionId === sid) ? '' : 'none';
|
|
2488
|
-
});
|
|
2489
|
-
document.querySelectorAll('.export-session-item[data-sid]').forEach(el => {
|
|
2490
|
-
el.classList.toggle('active', sid !== null && el.dataset.sid === sid);
|
|
2491
|
-
});
|
|
2492
|
-
document.querySelector('.export-all-btn').classList.toggle('active', sid === null);
|
|
2493
|
-
}
|
|
2494
|
-
`;
|
|
2495
|
-
|
|
2496
|
-
// Assemble complete HTML document
|
|
2497
|
-
const htmlAttrs = theme === 'light' ? ' lang="en" data-theme="light"' : ' lang="en"';
|
|
2498
|
-
const fullDoc = `<!DOCTYPE html>
|
|
2499
|
-
<html${htmlAttrs}>
|
|
2500
|
-
<head>
|
|
2501
|
-
<meta charset="UTF-8">
|
|
2502
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2503
|
-
<title>claude-watch Export</title>
|
|
2504
|
-
<style>
|
|
2505
|
-
${appCSS}
|
|
2506
|
-
${hlCSS}
|
|
2507
|
-
${exportCSS}
|
|
2508
|
-
</style>
|
|
2509
|
-
</head>
|
|
2510
|
-
<body style="overflow-y:auto;height:auto">
|
|
2511
|
-
<div class="export-header">
|
|
2512
|
-
<h1>claude-watch Export</h1>
|
|
2513
|
-
${sessionListHTML}
|
|
2514
|
-
${tokenHTML}
|
|
2515
|
-
${filterHTML}
|
|
2516
|
-
${timeHTML}
|
|
2517
|
-
</div>
|
|
2518
|
-
<div id="export-stream" style="padding:8px 12px;font-size:12px">
|
|
2519
|
-
${streamHTML}
|
|
2520
|
-
</div>
|
|
2521
|
-
<script>${exportJS}<\/script>
|
|
2522
|
-
</body>
|
|
2523
|
-
</html>`;
|
|
2524
|
-
|
|
2525
|
-
// Blob download
|
|
2526
|
-
const blob = new Blob([fullDoc], { type: 'text/html;charset=utf-8' });
|
|
2527
|
-
const url = URL.createObjectURL(blob);
|
|
2528
|
-
const a = document.createElement('a');
|
|
2529
|
-
|
|
2530
|
-
let filePrefix;
|
|
2531
|
-
if (sidsInExport.size === 1) {
|
|
2532
|
-
filePrefix = [...sidsInExport][0].split('-')[0].toUpperCase();
|
|
2533
|
-
} else {
|
|
2534
|
-
filePrefix = 'multi';
|
|
2535
|
-
}
|
|
2536
|
-
const pad = (n, len) => String(n).padStart(len, '0');
|
|
2537
|
-
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)}`;
|
|
2538
|
-
a.download = `claude-watch-${filePrefix}-${ts}.html`;
|
|
2539
|
-
a.href = url;
|
|
2540
|
-
document.body.appendChild(a);
|
|
2541
|
-
a.click();
|
|
2542
|
-
document.body.removeChild(a);
|
|
2543
|
-
URL.revokeObjectURL(url);
|
|
2544
|
-
|
|
2545
|
-
// Visual feedback
|
|
2546
|
-
const btn = document.getElementById('btn-export');
|
|
2547
|
-
const orig = btn.textContent;
|
|
2548
|
-
btn.textContent = '✓';
|
|
2549
|
-
setTimeout(() => { btn.textContent = orig; }, 2000);
|
|
2550
|
-
}
|
|
2551
|
-
|
|
2552
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2553
|
-
// Theme toggle
|
|
2554
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2555
|
-
|
|
2556
|
-
function applyTheme(theme) {
|
|
2557
|
-
document.documentElement.setAttribute('data-theme', theme);
|
|
2558
|
-
const btn = document.getElementById('btn-theme');
|
|
2559
|
-
if (btn) {
|
|
2560
|
-
btn.textContent = theme === 'dark' ? '🌙' : '☀️';
|
|
2561
|
-
btn.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light' : 'Switch to dark');
|
|
2562
|
-
}
|
|
2563
|
-
// Swap highlight.js stylesheet for theme
|
|
2564
|
-
const hlLink = document.querySelector('link[rel="stylesheet"][href*="github"]');
|
|
2565
|
-
if (hlLink) {
|
|
2566
|
-
hlLink.href = theme === 'dark' ? 'vendor/github-dark.min.css' : 'vendor/github-light.min.css';
|
|
2567
|
-
}
|
|
2568
|
-
}
|
|
2569
|
-
|
|
2570
|
-
function toggleTheme() {
|
|
2571
|
-
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
2572
|
-
const next = current === 'dark' ? 'light' : 'dark';
|
|
2573
|
-
localStorage.setItem('theme', next);
|
|
2574
|
-
applyTheme(next);
|
|
2575
|
-
}
|
|
2576
|
-
|
|
2577
|
-
// Apply saved theme on load (default dark)
|
|
2578
|
-
(function() {
|
|
2579
|
-
const saved = localStorage.getItem('theme');
|
|
2580
|
-
applyTheme(saved || 'dark');
|
|
2581
|
-
})();
|
|
2582
|
-
|
|
2583
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2584
|
-
// Init
|
|
2585
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2586
|
-
|
|
2587
|
-
// Apply collapse-after from URL param
|
|
2588
|
-
const urlParams = new URLSearchParams(location.search);
|
|
2589
|
-
const ca = urlParams.get('collapseAfter');
|
|
2590
|
-
if (ca) {
|
|
2591
|
-
applyCollapsePolicy(parseInt(ca) || 0);
|
|
2592
|
-
}
|
|
2593
|
-
|
|
2594
|
-
connect();
|
|
2595
|
-
</script>
|
|
119
|
+
<script src="js/shared.js"></script>
|
|
120
|
+
<script src="js/stream.js"></script>
|
|
121
|
+
<script src="js/token.js"></script>
|
|
122
|
+
<script src="js/app.js"></script>
|
|
2596
123
|
</body>
|
|
2597
124
|
</html>
|