claude-code-watch 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/css/app.css +474 -0
- package/public/index.html +31 -2202
- package/public/js/app.js +490 -0
- package/public/js/shared.js +245 -0
- package/public/js/stream.js +1076 -0
- package/public/js/token.js +480 -0
- package/src/scanner/scanner.js +155 -0
- package/src/server/server.js +169 -11
- package/src/watcher/watcher.js +103 -65
package/public/index.html
CHANGED
|
@@ -6,403 +6,7 @@
|
|
|
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: 16px 24px; gap: 16px; background: var(--bg); }
|
|
213
|
-
.token-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; }
|
|
214
|
-
.token-card-title { font-size: 13px; font-weight: 600; color: var(--white); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
|
215
|
-
.token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
|
216
|
-
.token-item { display: flex; flex-direction: column; gap: 2px; }
|
|
217
|
-
.token-label { font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
218
|
-
.token-value { font-size: 16px; font-weight: 600; color: var(--white); font-family: monospace; }
|
|
219
|
-
.token-bar { height: 4px; border-radius: 2px; background: var(--bg3); margin-top: 4px; }
|
|
220
|
-
.token-bar-fill { height: 100%; border-radius: 2px; background: var(--purple); transition: width 0.3s; }
|
|
221
|
-
.token-bar-fill.warn { background: var(--yellow); }
|
|
222
|
-
.token-bar-fill.danger { background: var(--red); }
|
|
223
|
-
.token-section-title { font-size: 12px; font-weight: 600; color: var(--dim); margin: 8px 0 4px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
|
|
224
|
-
.token-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 4px; }
|
|
225
|
-
.token-table th { background: var(--bg3); color: var(--white); padding: 6px 8px; text-align: left; font-weight: 600; font-size: 11px; border-bottom: 1px solid var(--border); }
|
|
226
|
-
.token-table td { padding: 5px 8px; color: var(--text); border-bottom: 1px solid var(--border); font-family: monospace; }
|
|
227
|
-
.token-table tr:hover td { background: var(--bg3); }
|
|
228
|
-
.token-detail-row { display: flex; gap: 4px; margin-top: 6px; font-size: 12px; flex-wrap: wrap; }
|
|
229
|
-
.token-detail-row .token-kv { color: var(--dim); }
|
|
230
|
-
.token-detail-row .token-kv b { color: var(--text); font-family: monospace; }
|
|
231
|
-
.token-usage-line { display: flex; align-items: baseline; gap: 8px; margin-top: 2px; }
|
|
232
|
-
.token-usage-line .token-pct { font-size: 12px; font-weight: 600; }
|
|
233
|
-
.token-usage-line .token-pct.warn { color: var(--yellow); }
|
|
234
|
-
.token-usage-line .token-pct.danger { color: var(--red); }
|
|
235
|
-
.token-usage-line .token-ctx-info { font-size: 11px; color: var(--dim); }
|
|
236
|
-
.token-active-dot { font-size: 10px; }
|
|
237
|
-
|
|
238
|
-
/* ── Stream panel ── */
|
|
239
|
-
#stream-panel-wrap {
|
|
240
|
-
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
|
241
|
-
}
|
|
242
|
-
#stream-toolbar {
|
|
243
|
-
display: flex; gap: 4px; padding: 4px 8px;
|
|
244
|
-
border-bottom: 1px solid var(--border);
|
|
245
|
-
background: var(--bg); flex-shrink: 0;
|
|
246
|
-
}
|
|
247
|
-
#stream-panel {
|
|
248
|
-
flex: 1; overflow-y: auto; padding: 8px 12px;
|
|
249
|
-
font-size: 12px;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/* ── Stream lines ── */
|
|
253
|
-
.stream-line { white-space: pre-wrap; word-break: break-all; }
|
|
254
|
-
.stream-line.thinking { color: var(--magenta); }
|
|
255
|
-
.stream-line.tool-input { color: var(--yellow); }
|
|
256
|
-
.stream-line.tool-output { color: var(--green); }
|
|
257
|
-
.stream-line.text { color: var(--text); }
|
|
258
|
-
.stream-line.hook { color: var(--cyan); }
|
|
259
|
-
.stream-line .hook-label { color: var(--dim); }
|
|
260
|
-
.stream-line.diag { color: var(--red); }
|
|
261
|
-
.stream-line.debug { color: var(--gray); }
|
|
262
|
-
.stream-line.marker { color: var(--dim); }
|
|
263
|
-
.stream-line.agent-tag { font-weight: bold; display: flex; justify-content: space-between; align-items: baseline; white-space: nowrap; }
|
|
264
|
-
.stream-line.agent-main { color: var(--blue); }
|
|
265
|
-
.stream-line.agent-sub { color: var(--magenta); }
|
|
266
|
-
.stream-line.agent-tag .tag-label { flex-shrink: 0; }
|
|
267
|
-
.stream-line.agent-tag .timestamp { font-weight: normal; font-size: 0.85em; color: var(--dim); white-space: nowrap; }
|
|
268
|
-
.stream-line.separator { color: var(--dim); }
|
|
269
|
-
.stream-line.user-prompt-block {
|
|
270
|
-
background: rgba(59,130,246,0.08);
|
|
271
|
-
border-left: 8px solid var(--blue);
|
|
272
|
-
border-radius: 4px;
|
|
273
|
-
padding: 6px 10px;
|
|
274
|
-
margin: 4px 0;
|
|
275
|
-
color: var(--white);
|
|
276
|
-
line-height: 1.5;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/* ── Footer ── */
|
|
280
|
-
#footer {
|
|
281
|
-
height: 28px; background: var(--bg2);
|
|
282
|
-
border-top: 1px solid var(--border);
|
|
283
|
-
display: flex; align-items: center;
|
|
284
|
-
padding: 0 8px; gap: 6px;
|
|
285
|
-
font-size: 11px; flex-shrink: 0; flex-wrap: wrap;
|
|
286
|
-
}
|
|
287
|
-
#footer .sep { color: var(--dim); margin: 0 2px; }
|
|
288
|
-
|
|
289
|
-
/* ── Scrollbar ── */
|
|
290
|
-
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
291
|
-
::-webkit-scrollbar-track { background: transparent; }
|
|
292
|
-
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
293
|
-
|
|
294
|
-
/* ── Focus ring ── */
|
|
295
|
-
#stream-panel:focus { outline: none; }
|
|
296
|
-
|
|
297
|
-
/* ── Markdown & code blocks ── */
|
|
298
|
-
.md-content { line-height: 1.6; color: var(--text); }
|
|
299
|
-
.md-content p { margin: 4px 0; }
|
|
300
|
-
.md-content ul, .md-content ol { padding-left: 20px; margin: 4px 0; }
|
|
301
|
-
.md-content li { margin: 2px 0; }
|
|
302
|
-
.md-content strong { color: var(--white); }
|
|
303
|
-
.md-content a { color: var(--blue); text-decoration: underline; }
|
|
304
|
-
.md-content h1, .md-content h2, .md-content h3, .md-content h4,
|
|
305
|
-
.md-content h5, .md-content h6 { color: var(--white); margin: 8px 0 4px; font-size: inherit; font-weight: bold; }
|
|
306
|
-
.md-content blockquote { border-left: 3px solid var(--purple); padding-left: 12px; color: var(--dim); margin: 4px 0; }
|
|
307
|
-
.md-content hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
|
|
308
|
-
.md-content table { border-collapse: collapse; margin: 4px 0; width: 100%; }
|
|
309
|
-
.md-content th, .md-content td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
|
|
310
|
-
.md-content th { background: var(--bg3); color: var(--white); }
|
|
311
|
-
.md-content img { max-width: 100%; }
|
|
312
|
-
|
|
313
|
-
.code-block-wrapper { margin: 8px 0; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
|
|
314
|
-
.code-block-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: var(--bg3); font-size: 11px; color: var(--dim); }
|
|
315
|
-
.code-block-header .lang-tag { color: var(--blue); font-weight: bold; }
|
|
316
|
-
.code-block-header .copy-btn { cursor: pointer; opacity: 0.5; transition: opacity 0.2s; font-size: 11px; }
|
|
317
|
-
.code-block-header .copy-btn:hover { opacity: 1; }
|
|
318
|
-
.code-block-wrapper pre { margin: 0; padding: 12px; overflow-x: auto; font-size: 12px; line-height: 1.5; }
|
|
319
|
-
.code-block-wrapper pre code { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; font-size: 12px; }
|
|
320
|
-
|
|
321
|
-
/* Override highlight.js background to match our theme */
|
|
322
|
-
.hljs { background: #0d1117 !important; }
|
|
323
|
-
|
|
324
|
-
/* Light theme overrides */
|
|
325
|
-
:root[data-theme="light"] .btn.on { background: var(--purple); border-color: var(--purple); color: #fff; }
|
|
326
|
-
:root[data-theme="light"] .btn.on:hover { background: var(--purple2); color: #fff; }
|
|
327
|
-
:root[data-theme="light"] .btn.on:hover::after { background: var(--purple2); color: #fff; }
|
|
328
|
-
:root[data-theme="light"] .hljs { background: #f0f0f0 !important; }
|
|
329
|
-
:root[data-theme="light"] .tree-content:hover { background: rgba(0,0,0,0.06); }
|
|
330
|
-
:root[data-theme="light"] .tree-row.selected > .tree-content { background: rgba(124,58,237,0.2); }
|
|
331
|
-
:root[data-theme="light"] .tree-node .active-dot.off { color: #bbb; }
|
|
332
|
-
:root[data-theme="light"] #tree-resize-handle:hover,
|
|
333
|
-
:root[data-theme="light"] #tree-resize-handle.active { background: var(--purple); }
|
|
334
|
-
:root[data-theme="light"] .stream-line.text { color: var(--text); }
|
|
335
|
-
|
|
336
|
-
/* Theme toggle button */
|
|
337
|
-
#btn-theme { font-size: 14px; }
|
|
338
|
-
|
|
339
|
-
/* ── Export modal ── */
|
|
340
|
-
.modal-overlay {
|
|
341
|
-
position: fixed; inset: 0;
|
|
342
|
-
background: rgba(0, 0, 0, 0.6);
|
|
343
|
-
z-index: 10000;
|
|
344
|
-
display: flex; align-items: center; justify-content: center;
|
|
345
|
-
}
|
|
346
|
-
:root[data-theme="light"] .modal-overlay { background: rgba(0, 0, 0, 0.3); }
|
|
347
|
-
:root[data-theme="light"] .modal-session-row.selected { background: rgba(124, 58, 237, 0.2); }
|
|
348
|
-
|
|
349
|
-
.modal-box {
|
|
350
|
-
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
|
351
|
-
width: 480px; max-width: 90vw; max-height: 80vh;
|
|
352
|
-
display: flex; flex-direction: column; overflow: hidden;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
.modal-header {
|
|
356
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
357
|
-
padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
|
|
358
|
-
}
|
|
359
|
-
.modal-title { font-size: 13px; font-weight: 600; color: var(--white); }
|
|
360
|
-
|
|
361
|
-
.modal-toolbar {
|
|
362
|
-
display: flex; align-items: center; gap: 4px;
|
|
363
|
-
padding: 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
364
|
-
}
|
|
365
|
-
.modal-count { margin-left: auto; font-size: 11px; color: var(--dim); }
|
|
366
|
-
|
|
367
|
-
.modal-body { flex: 1; overflow-y: auto; padding: 6px 0; }
|
|
368
|
-
|
|
369
|
-
.modal-session-row {
|
|
370
|
-
display: flex; align-items: center; gap: 8px;
|
|
371
|
-
padding: 6px 12px; cursor: pointer; transition: background 0.1s; user-select: none;
|
|
372
|
-
}
|
|
373
|
-
.modal-session-row:hover { background: var(--bg2); }
|
|
374
|
-
.modal-session-row.selected { background: rgba(124, 58, 237, 0.15); }
|
|
375
|
-
|
|
376
|
-
.modal-checkbox {
|
|
377
|
-
appearance: none; width: 16px; height: 16px;
|
|
378
|
-
border: 1px solid var(--border); border-radius: 3px; background: var(--bg2);
|
|
379
|
-
cursor: pointer; position: relative; flex-shrink: 0; transition: all 0.15s;
|
|
380
|
-
}
|
|
381
|
-
.modal-checkbox:checked { background: var(--purple); border-color: var(--purple); }
|
|
382
|
-
.modal-checkbox:checked::after {
|
|
383
|
-
content: '✓'; position: absolute; inset: 0;
|
|
384
|
-
display: flex; align-items: center; justify-content: center;
|
|
385
|
-
color: var(--white); font-size: 11px; font-weight: bold;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
.modal-session-prefix {
|
|
389
|
-
font-family: monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; flex-shrink: 0;
|
|
390
|
-
}
|
|
391
|
-
.modal-session-info {
|
|
392
|
-
flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px; overflow: hidden;
|
|
393
|
-
}
|
|
394
|
-
.modal-session-project {
|
|
395
|
-
font-size: 12px; font-weight: 500; color: var(--text);
|
|
396
|
-
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
397
|
-
}
|
|
398
|
-
.modal-session-model { font-size: 10px; color: var(--dim); flex-shrink: 0; }
|
|
399
|
-
.modal-session-time { font-size: 10px; color: var(--dim); flex-shrink: 0; margin-left: auto; }
|
|
400
|
-
|
|
401
|
-
.modal-footer {
|
|
402
|
-
display: flex; align-items: center; justify-content: flex-end; gap: 6px;
|
|
403
|
-
padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
|
|
404
|
-
}
|
|
405
|
-
</style>
|
|
9
|
+
<link rel="stylesheet" href="css/app.css">
|
|
406
10
|
</head>
|
|
407
11
|
<body>
|
|
408
12
|
|
|
@@ -455,18 +59,32 @@ body {
|
|
|
455
59
|
</div>
|
|
456
60
|
|
|
457
61
|
<div id="tokens-page" style="display:none">
|
|
458
|
-
<div class="
|
|
459
|
-
<
|
|
460
|
-
<
|
|
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>
|
|
66
|
+
<div class="tp-top">
|
|
67
|
+
<div class="tp-left">
|
|
68
|
+
<div class="tp-box" id="tp-total-card"></div>
|
|
69
|
+
<div class="tp-box" id="tp-stats-grid"></div>
|
|
70
|
+
<div class="tp-box" id="tp-model-rank"></div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="tp-right">
|
|
73
|
+
<div class="tp-box" id="tp-trend-card"></div>
|
|
74
|
+
<div class="tp-box" id="tp-heatmap-card"></div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="tp-charts-row" id="tp-charts-row">
|
|
78
|
+
<div class="tp-chart-box" id="tp-weekly-chart" role="img" aria-label="Weekly token consumption chart"></div>
|
|
79
|
+
<div class="tp-chart-box" id="tp-monthly-chart" role="img" aria-label="Monthly token consumption chart"></div>
|
|
80
|
+
<div class="tp-chart-box" id="tp-model-pie" role="img" aria-label="Model token proportion chart"></div>
|
|
81
|
+
<div class="tp-chart-box" id="tp-hourly-chart" role="img" aria-label="Active time distribution chart"></div>
|
|
461
82
|
</div>
|
|
462
|
-
<div class="
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
<
|
|
467
|
-
<thead><tr><th>Agent</th><th>Model</th><th>Input</th><th>Output</th><th>Cache+</th><th>Cache Read</th><th>Context</th><th>%</th><th>I/O</th></tr></thead>
|
|
468
|
-
<tbody id="token-table-body"></tbody>
|
|
469
|
-
</table>
|
|
83
|
+
<div class="tp-box">
|
|
84
|
+
<div class="tp-tabs" id="tp-detail-tabs"></div>
|
|
85
|
+
<div id="tp-tc-daily" class="tp-tc"><div class="tp-st" id="tp-daily-table"></div></div>
|
|
86
|
+
<div id="tp-tc-weekly" class="tp-tc"><div class="tp-st" id="tp-weekly-table"></div></div>
|
|
87
|
+
<div id="tp-tc-monthly" class="tp-tc"><div class="tp-st" id="tp-monthly-table"></div></div>
|
|
470
88
|
</div>
|
|
471
89
|
</div>
|
|
472
90
|
|
|
@@ -476,7 +94,7 @@ body {
|
|
|
476
94
|
<span id="item-count">0 items</span>
|
|
477
95
|
<span class="sep">│</span>
|
|
478
96
|
<span id="footer-version" style="margin-left:auto;font-size:10px;color:var(--dim)"></span>
|
|
479
|
-
|
|
97
|
+
</div>
|
|
480
98
|
|
|
481
99
|
<div id="export-modal" class="modal-overlay" style="display:none">
|
|
482
100
|
<div class="modal-box">
|
|
@@ -500,1798 +118,9 @@ body {
|
|
|
500
118
|
<script src="vendor/highlight.min.js"></script>
|
|
501
119
|
<script src="vendor/marked.min.js"></script>
|
|
502
120
|
<script src="vendor/purify.min.js"></script>
|
|
503
|
-
<script>
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const streamEl = document.getElementById('stream-panel');
|
|
509
|
-
const treeEl = document.getElementById('tree-content');
|
|
510
|
-
const sessionInfo = document.getElementById('session-info');
|
|
511
|
-
const tokenInfo = document.getElementById('token-info');
|
|
512
|
-
const treeCursorInfo = document.getElementById('tree-cursor-info');
|
|
513
|
-
|
|
514
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
515
|
-
// State
|
|
516
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
517
|
-
|
|
518
|
-
let ws = null;
|
|
519
|
-
let reconnectTimer = null;
|
|
520
|
-
let reconnectDelay = 1000;
|
|
521
|
-
const MaxReconnectDelay = 30000;
|
|
522
|
-
const MaxReconnectAttempts = 20;
|
|
523
|
-
let reconnectAttempts = 0;
|
|
524
|
-
let showTree = true;
|
|
525
|
-
let autoScroll = true;
|
|
526
|
-
let lastMsgTime = 0;
|
|
527
|
-
let staleCheckTimer = null;
|
|
528
|
-
|
|
529
|
-
let sessions = [];
|
|
530
|
-
let sessionsMap = new Map(); // id -> session, for O(1) lookups
|
|
531
|
-
let treeNodes = [];
|
|
532
|
-
let treeCursor = 0;
|
|
533
|
-
let folderCollapsed = {}; // dateStr -> boolean, default collapsed
|
|
534
|
-
let streamItems = [];
|
|
535
|
-
let visibleItems = [];
|
|
536
|
-
let visibleDirty = true;
|
|
537
|
-
// LRU cache: recently accessed keys survive eviction, so a tool_input's ID
|
|
538
|
-
// stays alive long enough for its matching tool_output to arrive and merge.
|
|
539
|
-
class LRUCache {
|
|
540
|
-
constructor(max) { this.max = max; this.map = new Map(); }
|
|
541
|
-
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; }
|
|
542
|
-
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; }
|
|
543
|
-
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); } }
|
|
544
|
-
delete(key) { return this.map.delete(key); }
|
|
545
|
-
keys() { return this.map.keys(); }
|
|
546
|
-
}
|
|
547
|
-
const seenToolIDs = new LRUCache(20000);
|
|
548
|
-
const toolNameMap = new LRUCache(2000);
|
|
549
|
-
const agentActivity = new LRUCache(500); // "sessionID:agentID" → { toolName, content }
|
|
550
|
-
const taskDescriptions = new LRUCache(2000); // toolID → description string
|
|
551
|
-
const MAX_DESC_STORE = 200;
|
|
552
|
-
let filters = new Map();
|
|
553
|
-
let visibleFilterCount = 0;
|
|
554
|
-
|
|
555
|
-
let showThinking = true;
|
|
556
|
-
let showToolInput = true;
|
|
557
|
-
let showToolOutput = true;
|
|
558
|
-
let showText = true;
|
|
559
|
-
let showHook = true;
|
|
560
|
-
let showUserPrompt = true;
|
|
561
|
-
let showActivity = true;
|
|
562
|
-
let showTokenCount = true;
|
|
563
|
-
let autoDiscovery = true;
|
|
564
|
-
let appVersion = '';
|
|
565
|
-
let currentTab = 'stream';
|
|
566
|
-
|
|
567
|
-
const HIDDEN_KEY = 'claude-watch-hidden';
|
|
568
|
-
function loadHiddenSessions() {
|
|
569
|
-
try {
|
|
570
|
-
const data = JSON.parse(localStorage.getItem(HIDDEN_KEY) || '{}');
|
|
571
|
-
const now = Date.now();
|
|
572
|
-
for (const [id, ts] of Object.entries(data)) {
|
|
573
|
-
if (now - ts < 24 * 60 * 60 * 1000) hiddenSessionIDs.add(id);
|
|
574
|
-
}
|
|
575
|
-
_saveHiddenSessions();
|
|
576
|
-
} catch {}
|
|
577
|
-
}
|
|
578
|
-
function _saveHiddenSessions() {
|
|
579
|
-
const data = {};
|
|
580
|
-
for (const id of hiddenSessionIDs) data[id] = Date.now();
|
|
581
|
-
localStorage.setItem(HIDDEN_KEY, JSON.stringify(data));
|
|
582
|
-
}
|
|
583
|
-
const hiddenSessionIDs = new Set();
|
|
584
|
-
loadHiddenSessions();
|
|
585
|
-
|
|
586
|
-
let renderPending = false;
|
|
587
|
-
|
|
588
|
-
let totalInput = 0, totalOutput = 0, totalCacheCreate = 0, totalCacheRead = 0;
|
|
589
|
-
let contextData = {};
|
|
590
|
-
|
|
591
|
-
function computeTokensFromContext() {
|
|
592
|
-
totalInput = 0; totalOutput = 0; totalCacheCreate = 0; totalCacheRead = 0;
|
|
593
|
-
for (const ctx of Object.values(contextData)) {
|
|
594
|
-
totalInput += ctx.inputTokens || 0;
|
|
595
|
-
totalOutput += ctx.outputTokens || 0;
|
|
596
|
-
totalCacheCreate += ctx.cacheCreation || 0;
|
|
597
|
-
totalCacheRead += ctx.cacheRead || 0;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
let collapseAfter = 0;
|
|
602
|
-
let collapseTimer = null;
|
|
603
|
-
let activeRefreshTimer = null;
|
|
604
|
-
|
|
605
|
-
const MAX_ITEMS = 9999;
|
|
606
|
-
const MAX_LINES = 50;
|
|
607
|
-
let renderedItemCount = 0;
|
|
608
|
-
let needsFullRender = true;
|
|
609
|
-
let treeDirty = true;
|
|
610
|
-
let lastTreeCursor = -1;
|
|
611
|
-
|
|
612
|
-
// Cache highlight.js CSS for HTML export
|
|
613
|
-
let hljsDarkCSS = '', hljsLightCSS = '';
|
|
614
|
-
fetch('vendor/github-dark.min.css').then(r => r.text()).then(t => { hljsDarkCSS = t; }).catch(() => {});
|
|
615
|
-
fetch('vendor/github-light.min.css').then(r => r.text()).then(t => { hljsLightCSS = t; }).catch(() => {});
|
|
616
|
-
|
|
617
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
618
|
-
// Markdown renderer (marked + highlight.js)
|
|
619
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
620
|
-
|
|
621
|
-
const mdRenderer = new marked.Renderer();
|
|
622
|
-
mdRenderer.code = function (codeOrObj, langOrEsc) {
|
|
623
|
-
// marked v4: code(text, lang, escaped) — marked v5+: code({ text, lang })
|
|
624
|
-
const text = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
|
|
625
|
-
const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : langOrEsc;
|
|
626
|
-
let highlighted;
|
|
627
|
-
if (lang && hljs.getLanguage(lang)) {
|
|
628
|
-
try {
|
|
629
|
-
highlighted = hljs.highlight(text, { language: lang }).value;
|
|
630
|
-
} catch {
|
|
631
|
-
highlighted = hljs.highlightAuto(text).value;
|
|
632
|
-
}
|
|
633
|
-
} else {
|
|
634
|
-
highlighted = hljs.highlightAuto(text).value;
|
|
635
|
-
}
|
|
636
|
-
const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
|
|
637
|
-
return `<div class="code-block-wrapper">
|
|
638
|
-
<div class="code-block-header">${langTag}<span class="copy-btn" onclick="copyCode(this)">⎘</span></div>
|
|
639
|
-
<pre><code>${highlighted}</code></pre>
|
|
640
|
-
</div>`;
|
|
641
|
-
};
|
|
642
|
-
marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
|
|
643
|
-
|
|
644
|
-
function copyCode(btn) {
|
|
645
|
-
const wrapper = btn.closest('.code-block-wrapper');
|
|
646
|
-
const code = wrapper ? wrapper.querySelector('code') : null;
|
|
647
|
-
if (!code) return;
|
|
648
|
-
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
649
|
-
btn.innerHTML = '✓';
|
|
650
|
-
setTimeout(() => { btn.innerHTML = '⎘'; }, 1500);
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function mdRender(text) {
|
|
655
|
-
try {
|
|
656
|
-
return DOMPurify.sanitize(marked.parse(text));
|
|
657
|
-
} catch {
|
|
658
|
-
return esc(text);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
663
|
-
// WebSocket
|
|
664
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
665
|
-
|
|
666
|
-
function connect() {
|
|
667
|
-
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
668
|
-
ws = new WebSocket(`${proto}//${location.host}`);
|
|
669
|
-
|
|
670
|
-
ws.onopen = () => {
|
|
671
|
-
sessionInfo.textContent = 'Connected';
|
|
672
|
-
lastMsgTime = Date.now();
|
|
673
|
-
reconnectDelay = 1000;
|
|
674
|
-
reconnectAttempts = 0;
|
|
675
|
-
startStaleCheck();
|
|
676
|
-
startActiveRefresh();
|
|
677
|
-
};
|
|
678
|
-
ws.onclose = () => {
|
|
679
|
-
reconnectAttempts++;
|
|
680
|
-
if (reconnectAttempts >= MaxReconnectAttempts) {
|
|
681
|
-
sessionInfo.textContent = 'Disconnected. Please refresh to reconnect.';
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
sessionInfo.textContent = 'Disconnected, reconnecting...';
|
|
685
|
-
stopStaleCheck();
|
|
686
|
-
if (activeRefreshTimer) { clearInterval(activeRefreshTimer); activeRefreshTimer = null; }
|
|
687
|
-
reconnectTimer = setTimeout(connect, reconnectDelay);
|
|
688
|
-
reconnectDelay = Math.min(reconnectDelay * 2, MaxReconnectDelay);
|
|
689
|
-
};
|
|
690
|
-
ws.onerror = (e) => { console.warn('[ws] connection error', e); };
|
|
691
|
-
|
|
692
|
-
ws.onmessage = (e) => {
|
|
693
|
-
lastMsgTime = Date.now();
|
|
694
|
-
let msg;
|
|
695
|
-
try { msg = JSON.parse(e.data); } catch { return; }
|
|
696
|
-
handleMessage(msg);
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function startStaleCheck() {
|
|
701
|
-
if (staleCheckTimer) clearInterval(staleCheckTimer);
|
|
702
|
-
staleCheckTimer = setInterval(() => {
|
|
703
|
-
if (Date.now() - lastMsgTime > 45000) {
|
|
704
|
-
sessionInfo.textContent = 'Stale connection, reconnecting...';
|
|
705
|
-
stopStaleCheck();
|
|
706
|
-
try { ws.close(); } catch {}
|
|
707
|
-
// onclose handler will handle reconnect — no separate timer needed
|
|
708
|
-
}
|
|
709
|
-
}, 10000);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function stopStaleCheck() {
|
|
713
|
-
if (staleCheckTimer) { clearInterval(staleCheckTimer); staleCheckTimer = null; }
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
function handleMessage(msg) {
|
|
717
|
-
switch (msg.type) {
|
|
718
|
-
case 'snapshot': handleSnapshot(msg.payload); break;
|
|
719
|
-
case 'itemBatch': handleItemBatch(msg.payload); break;
|
|
720
|
-
case 'item': handleItem(msg.payload); break;
|
|
721
|
-
case 'newSession': handleNewSession(msg.payload); break;
|
|
722
|
-
case 'newAgent': handleNewAgent(msg.payload); break;
|
|
723
|
-
case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
|
|
724
|
-
case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
|
|
725
|
-
case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
|
|
726
|
-
case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); if (currentTab === 'tokens') renderTokenPage(); break;
|
|
727
|
-
case 'config':
|
|
728
|
-
if (msg.payload.version) appVersion = msg.payload.version;
|
|
729
|
-
if (msg.payload.collapseAfter > 0 && !collapseTimer) {
|
|
730
|
-
applyCollapsePolicy(msg.payload.collapseAfter);
|
|
731
|
-
}
|
|
732
|
-
break;
|
|
733
|
-
case 'heartbeat': break;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function sendCmd(action, extra = {}) {
|
|
738
|
-
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ action, ...extra }));
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
742
|
-
// Snapshot / Session management
|
|
743
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
744
|
-
|
|
745
|
-
function handleSnapshot(payload) {
|
|
746
|
-
autoDiscovery = payload.autoDiscovery;
|
|
747
|
-
const incomingIDs = new Set((payload.sessions || []).map(s => s.id));
|
|
748
|
-
for (let i = sessions.length - 1; i >= 0; i--) {
|
|
749
|
-
const s = sessions[i];
|
|
750
|
-
if (!incomingIDs.has(s.id) && !s.pinned) {
|
|
751
|
-
sessions.splice(i, 1);
|
|
752
|
-
sessionsMap.delete(s.id);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
for (const s of (payload.sessions || [])) {
|
|
756
|
-
if (hiddenSessionIDs.has(s.id)) continue;
|
|
757
|
-
let session = sessionsMap.get(s.id);
|
|
758
|
-
if (!session) {
|
|
759
|
-
session = {
|
|
760
|
-
id: s.id, projectPath: s.projectPath, title: '',
|
|
761
|
-
folder: folderName(s.projectPath), model: '',
|
|
762
|
-
agents: [], tasks: [], collapsed: false, pinned: false,
|
|
763
|
-
lastActivity: s.birthtimeMs || 0,
|
|
764
|
-
birthtimeMs: s.birthtimeMs || 0,
|
|
765
|
-
};
|
|
766
|
-
sessions.push(session);
|
|
767
|
-
sessionsMap.set(session.id, session);
|
|
768
|
-
session.agents.push({ id: '', name: 'Main', type: 'main' });
|
|
769
|
-
}
|
|
770
|
-
for (const [aid, atype] of Object.entries(s.subagents || {})) {
|
|
771
|
-
if (!session.agents.find(a => a.id === aid)) {
|
|
772
|
-
session.agents.push({ id: aid, name: agentDisplayName(aid, atype), type: 'agent' });
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
for (const t of (s.backgroundTasks || [])) {
|
|
776
|
-
if (!session.tasks.find(ta => ta.id === t.id)) {
|
|
777
|
-
session.tasks.push({
|
|
778
|
-
id: t.id, parentAgentID: t.parentAgentID,
|
|
779
|
-
toolName: t.toolName, outputPath: t.outputPath,
|
|
780
|
-
isComplete: t.isComplete,
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
// Initialize agentActivity from server-computed lastActivities (survives history skip)
|
|
786
|
-
for (const [key, val] of Object.entries(payload.lastActivities || {})) {
|
|
787
|
-
agentActivity.set(key, val);
|
|
788
|
-
}
|
|
789
|
-
updateFilters();
|
|
790
|
-
rebuildNodes();
|
|
791
|
-
needsFullRender = true;
|
|
792
|
-
visibleDirty = true;
|
|
793
|
-
// Don't render tree yet — wait for itemBatch to fill title/model/taskDescriptions
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function handleNewSession(payload) {
|
|
797
|
-
if (hiddenSessionIDs.has(payload.sessionID)) return;
|
|
798
|
-
if (sessionsMap.has(payload.sessionID)) return;
|
|
799
|
-
const session = {
|
|
800
|
-
id: payload.sessionID, projectPath: payload.projectPath,
|
|
801
|
-
title: '', folder: folderName(payload.projectPath), model: '',
|
|
802
|
-
agents: [{ id: '', name: 'Main', type: 'main' }],
|
|
803
|
-
tasks: [], collapsed: false, pinned: false,
|
|
804
|
-
lastActivity: payload.birthtimeMs || Date.now(),
|
|
805
|
-
birthtimeMs: payload.birthtimeMs || 0,
|
|
806
|
-
};
|
|
807
|
-
sessions.push(session);
|
|
808
|
-
sessionsMap.set(session.id, session);
|
|
809
|
-
updateFilters();
|
|
810
|
-
rebuildNodes();
|
|
811
|
-
needsFullRender = true;
|
|
812
|
-
visibleDirty = true;
|
|
813
|
-
scheduleRender();
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function handleNewAgent(payload) {
|
|
817
|
-
const s = sessionsMap.get(payload.sessionID);
|
|
818
|
-
if (!s || s.agents.find(a => a.id === payload.agentID)) return;
|
|
819
|
-
s.agents.push({
|
|
820
|
-
id: payload.agentID,
|
|
821
|
-
name: agentDisplayName(payload.agentID, payload.agentType),
|
|
822
|
-
type: 'agent',
|
|
823
|
-
});
|
|
824
|
-
updateFilters();
|
|
825
|
-
rebuildNodes();
|
|
826
|
-
needsFullRender = true;
|
|
827
|
-
visibleDirty = true;
|
|
828
|
-
scheduleRender();
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function handleNewBgTask(payload) {
|
|
832
|
-
const s = sessionsMap.get(payload.sessionID);
|
|
833
|
-
if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
|
|
834
|
-
s.tasks.push({
|
|
835
|
-
id: payload.toolID, parentAgentID: payload.parentAgentID,
|
|
836
|
-
toolName: payload.toolName, outputPath: payload.outputPath,
|
|
837
|
-
isComplete: payload.isComplete,
|
|
838
|
-
});
|
|
839
|
-
rebuildNodes();
|
|
840
|
-
scheduleRender();
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function handleSessionRemoved(payload) {
|
|
844
|
-
const sid = payload.sessionID;
|
|
845
|
-
const s = sessionsMap.get(sid);
|
|
846
|
-
if (s) {
|
|
847
|
-
for (const a of s.agents) agentActivity.delete(sid + ':' + a.id);
|
|
848
|
-
for (const t of s.tasks) taskDescriptions.delete(t.id);
|
|
849
|
-
}
|
|
850
|
-
const idx = sessions.findIndex(s => s.id === sid);
|
|
851
|
-
if (idx >= 0) {
|
|
852
|
-
sessions.splice(idx, 1);
|
|
853
|
-
sessionsMap.delete(sid);
|
|
854
|
-
}
|
|
855
|
-
updateFilters();
|
|
856
|
-
rebuildNodes();
|
|
857
|
-
needsFullRender = true;
|
|
858
|
-
visibleDirty = true;
|
|
859
|
-
scheduleRender();
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
863
|
-
// Stream items
|
|
864
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
865
|
-
|
|
866
|
-
function handleItem(item) {
|
|
867
|
-
if (item.type === 'session_title') {
|
|
868
|
-
const s = sessionsMap.get(item.sessionID);
|
|
869
|
-
if (s) { s.title = item.content.slice(0, 30); }
|
|
870
|
-
scheduleRender();
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
// Update activity
|
|
874
|
-
const s = sessionsMap.get(item.sessionID);
|
|
875
|
-
if (s) s.lastActivity = itemTime(item);
|
|
876
|
-
pushItem(item);
|
|
877
|
-
scheduleRender();
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function handleItemBatch(items) {
|
|
881
|
-
for (const item of items) {
|
|
882
|
-
if (item.type === 'session_title') {
|
|
883
|
-
const s = sessionsMap.get(item.sessionID);
|
|
884
|
-
if (s) { s.title = item.content.slice(0, 30); }
|
|
885
|
-
continue;
|
|
886
|
-
}
|
|
887
|
-
const s = sessionsMap.get(item.sessionID);
|
|
888
|
-
if (s) s.lastActivity = itemTime(item);
|
|
889
|
-
pushItem(item);
|
|
890
|
-
}
|
|
891
|
-
rebuildNodes();
|
|
892
|
-
scheduleRender();
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
function pushItem(item) {
|
|
896
|
-
if (hiddenSessionIDs.has(item.sessionID)) return;
|
|
897
|
-
// Token counts are sourced exclusively from server context messages
|
|
898
|
-
// to avoid divergence between frontend accumulation and server tracking
|
|
899
|
-
|
|
900
|
-
if (item.model) {
|
|
901
|
-
const s = sessionsMap.get(item.sessionID);
|
|
902
|
-
if (s) s.model = item.model;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
if (item.type === 'tool_input' && item.toolID && item.toolName) {
|
|
906
|
-
toolNameMap.set(item.toolID, item.toolName);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
if (item.type === 'tool_input') {
|
|
910
|
-
if (item.agentID) {
|
|
911
|
-
agentActivity.set(item.sessionID + ':' + item.agentID, { toolName: item.toolName || '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
|
|
912
|
-
}
|
|
913
|
-
if (item.toolID) {
|
|
914
|
-
taskDescriptions.set(item.toolID, (item.content || '').slice(0, MAX_DESC_STORE));
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
if (item.type === 'user_text') {
|
|
919
|
-
agentActivity.set(item.sessionID + ':' + (item.agentID || ''), { toolName: '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
if (item.toolID) {
|
|
923
|
-
const key = `${item.toolID}:${item.type}`;
|
|
924
|
-
if (seenToolIDs.has(key)) return;
|
|
925
|
-
seenToolIDs.set(key, true);
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
streamItems.push(item);
|
|
929
|
-
if (streamItems.length > MAX_ITEMS) {
|
|
930
|
-
streamItems = streamItems.slice(-MAX_ITEMS);
|
|
931
|
-
visibleDirty = true;
|
|
932
|
-
}
|
|
933
|
-
// Incrementally update visibleItems — no need to re-filter on every item
|
|
934
|
-
if (!visibleDirty && isItemVisible(item)) {
|
|
935
|
-
visibleItems.push(item);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
function isItemVisible(item) {
|
|
940
|
-
if (!filters.has(item.sessionID + ':' + (item.agentID || ''))) return false;
|
|
941
|
-
switch (item.type) {
|
|
942
|
-
case 'thinking': return showThinking;
|
|
943
|
-
case 'tool_input': return showToolInput;
|
|
944
|
-
case 'tool_output': return showToolOutput;
|
|
945
|
-
case 'text': return showText;
|
|
946
|
-
case 'hook_output': return showHook;
|
|
947
|
-
case 'user_text': return showUserPrompt;
|
|
948
|
-
default: return true;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
953
|
-
// Tree
|
|
954
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
955
|
-
|
|
956
|
-
function idColor(rank) {
|
|
957
|
-
const hue = (rank * 137.508) % 360;
|
|
958
|
-
return `hsl(${hue}, 75%, 60%)`;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function rebuildNodes() {
|
|
962
|
-
// Sort sessions by creation time, newest first
|
|
963
|
-
sessions.sort((a, b) => (b.birthtimeMs || 0) - (a.birthtimeMs || 0));
|
|
964
|
-
for (let i = 0; i < sessions.length; i++) sessions[i].colorRank = i;
|
|
965
|
-
|
|
966
|
-
computeAgentIdDisplayLengths();
|
|
967
|
-
|
|
968
|
-
const today = new Date();
|
|
969
|
-
const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
970
|
-
|
|
971
|
-
const flatSessions = [];
|
|
972
|
-
const olderByDate = new Map(); // dateStr -> [sessions]
|
|
973
|
-
|
|
974
|
-
for (const s of sessions) {
|
|
975
|
-
const dateStr = s.birthtimeMs ? formatTime(s.birthtimeMs).split(' ')[0] : null;
|
|
976
|
-
if (!dateStr || dateStr === todayStr || isSessionActive(s)) {
|
|
977
|
-
flatSessions.push(s);
|
|
978
|
-
} else {
|
|
979
|
-
if (!olderByDate.has(dateStr)) olderByDate.set(dateStr, []);
|
|
980
|
-
olderByDate.get(dateStr).push(s);
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
treeNodes = [];
|
|
985
|
-
|
|
986
|
-
function addSessionWithChildren(s, inFolder) {
|
|
987
|
-
treeNodes.push({ type: 'session', level: 0, isLast: false, inFolder: !!inFolder, ...s });
|
|
988
|
-
if (s.collapsed) return;
|
|
989
|
-
const agents = s.agents || [];
|
|
990
|
-
const lastAgentIdx = agents.length - 1;
|
|
991
|
-
for (let ai = 0; ai < agents.length; ai++) {
|
|
992
|
-
const a = agents[ai];
|
|
993
|
-
const isLastAgent = ai === lastAgentIdx;
|
|
994
|
-
const tasks = s.tasks.filter(t =>
|
|
995
|
-
(a.id === '' && !t.parentAgentID) || t.parentAgentID === a.id
|
|
996
|
-
);
|
|
997
|
-
const lastTaskIdx = tasks.length - 1;
|
|
998
|
-
const hasTasks = tasks.length > 0;
|
|
999
|
-
const actKey = s.id + ':' + a.id;
|
|
1000
|
-
const act = agentActivity.get(actKey);
|
|
1001
|
-
treeNodes.push({
|
|
1002
|
-
type: a.type, id: a.id, name: a.name, sessionID: s.id,
|
|
1003
|
-
level: 1, isLast: isLastAgent,
|
|
1004
|
-
activityTool: act ? act.toolName : '',
|
|
1005
|
-
activityDesc: act ? act.content : '',
|
|
1006
|
-
});
|
|
1007
|
-
for (let ti = 0; ti < tasks.length; ti++) {
|
|
1008
|
-
const t = tasks[ti];
|
|
1009
|
-
const tDesc = taskDescriptions.get(t.id);
|
|
1010
|
-
treeNodes.push({
|
|
1011
|
-
type: 'task', id: t.id, name: t.toolName,
|
|
1012
|
-
sessionID: s.id, parentAgentID: t.parentAgentID,
|
|
1013
|
-
outputPath: t.outputPath, isComplete: t.isComplete,
|
|
1014
|
-
level: 2, isLast: ti === lastTaskIdx,
|
|
1015
|
-
parentIsLast: isLastAgent,
|
|
1016
|
-
description: tDesc || '',
|
|
1017
|
-
});
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
// Today's + active sessions (expanded, not in date folders)
|
|
1023
|
-
for (const s of flatSessions) {
|
|
1024
|
-
addSessionWithChildren(s, false);
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
// Date folders (older dates, collapsed by default)
|
|
1028
|
-
const sortedDates = [...olderByDate.keys()].sort((a, b) => b.localeCompare(a));
|
|
1029
|
-
for (let di = 0; di < sortedDates.length; di++) {
|
|
1030
|
-
const dateStr = sortedDates[di];
|
|
1031
|
-
const folderSessions = olderByDate.get(dateStr);
|
|
1032
|
-
const collapsed = folderCollapsed[dateStr] !== false; // default collapsed
|
|
1033
|
-
const isLastFolder = di === sortedDates.length - 1;
|
|
1034
|
-
|
|
1035
|
-
treeNodes.push({
|
|
1036
|
-
type: 'date-folder', date: dateStr, level: 0, isLast: false,
|
|
1037
|
-
collapsed, sessionCount: folderSessions.length,
|
|
1038
|
-
});
|
|
1039
|
-
|
|
1040
|
-
if (!collapsed) {
|
|
1041
|
-
for (const s of folderSessions) {
|
|
1042
|
-
addSessionWithChildren(s, true);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// Mark last session among flat sessions
|
|
1048
|
-
const flatSessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
|
|
1049
|
-
if (flatSessionNodes.length > 0) flatSessionNodes[flatSessionNodes.length - 1].isLast = true;
|
|
1050
|
-
|
|
1051
|
-
// Mark last session inside each folder
|
|
1052
|
-
for (const dateStr of sortedDates) {
|
|
1053
|
-
if (folderCollapsed[dateStr] !== false) continue;
|
|
1054
|
-
// Find sessions belonging to this folder
|
|
1055
|
-
const thisFolder = [];
|
|
1056
|
-
let inThisFolder = false;
|
|
1057
|
-
for (const n of treeNodes) {
|
|
1058
|
-
if (n.type === 'date-folder' && n.date === dateStr) { inThisFolder = true; continue; }
|
|
1059
|
-
if (n.type === 'date-folder' && n.date !== dateStr) { inThisFolder = false; continue; }
|
|
1060
|
-
if (inThisFolder && n.type === 'session') thisFolder.push(n);
|
|
1061
|
-
}
|
|
1062
|
-
if (thisFolder.length > 0) thisFolder[thisFolder.length - 1].isLast = true;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
|
|
1066
|
-
treeDirty = true;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
function treePrefix(node) {
|
|
1070
|
-
if (node.level === 0) {
|
|
1071
|
-
return node.inFolder ? ' ' : '';
|
|
1072
|
-
}
|
|
1073
|
-
const branch = node.isLast ? '└──' : '├──';
|
|
1074
|
-
if (node.level === 1) return ' ' + branch;
|
|
1075
|
-
// Level 2: use pre-computed parentIsLast from rebuildNodes
|
|
1076
|
-
const parentIsLast = node.parentIsLast !== undefined ? node.parentIsLast : true;
|
|
1077
|
-
const stem = parentIsLast ? ' ' : '│ ';
|
|
1078
|
-
return ' ' + stem + branch;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
function getNodeHTML(node, idx) {
|
|
1082
|
-
const isSelected = idx === treeCursor;
|
|
1083
|
-
const selClass = isSelected ? ' selected' : '';
|
|
1084
|
-
|
|
1085
|
-
if (node.type === 'date-folder') {
|
|
1086
|
-
const icon = node.collapsed ? '▸' : '▾';
|
|
1087
|
-
return `<div class="tree-row tree-row-folder${selClass ? ' selected' : ''}">
|
|
1088
|
-
<div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1089
|
-
<div class="tree-node folder-node">
|
|
1090
|
-
${icon} 📁 ${node.date} <span style="font-size:10px;color:var(--dim);margin-left:4px">(${node.sessionCount})</span>
|
|
1091
|
-
</div>
|
|
1092
|
-
</div>
|
|
1093
|
-
</div>`;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
if (node.type === 'session') {
|
|
1097
|
-
const displayName = folderName(node.projectPath) || node.title || node.id.slice(0, 14);
|
|
1098
|
-
const parts = [];
|
|
1099
|
-
if (node.model) parts.push(`🧠 ${esc(node.model)}`);
|
|
1100
|
-
const activeDot = isSessionActive(node) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
|
|
1101
|
-
const subInfo = parts.length > 0 ? ` <span style="color:#6b7280;font-size:10px">${parts.join(' · ')}</span>` : '';
|
|
1102
|
-
const agentCount = node.agents ? node.agents.filter(a => a.type === 'agent').length : 0;
|
|
1103
|
-
const timeStr = formatTime(node.birthtimeMs);
|
|
1104
|
-
const timeHtml = timeStr ? `<span style="margin-left:auto;font-size:10px;color:var(--dim);flex-shrink:0">${timeStr}</span>` : '';
|
|
1105
|
-
return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
|
|
1106
|
-
<div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1107
|
-
<div class="tree-node">
|
|
1108
|
-
<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)}
|
|
1109
|
-
${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
|
|
1110
|
-
${subInfo}
|
|
1111
|
-
${timeHtml}
|
|
1112
|
-
</div>
|
|
1113
|
-
</div>
|
|
1114
|
-
<span class="tree-actions">
|
|
1115
|
-
<button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
|
|
1116
|
-
<button class="btn btn-icon danger" onclick="event.stopPropagation();selectIndex(${idx});removeSelectedSession()" data-tooltip="Remove">✕</button>
|
|
1117
|
-
</span>
|
|
1118
|
-
</div>`;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
if (node.type === 'main' || node.type === 'agent') {
|
|
1122
|
-
const icon = node.type === 'main' ? '💬' : '🤖';
|
|
1123
|
-
const enabled = filters.get(node.sessionID + ':' + node.id);
|
|
1124
|
-
const ctxKey = node.sessionID + ':' + node.id;
|
|
1125
|
-
const ctx = contextData[ctxKey];
|
|
1126
|
-
let ctxPct = '';
|
|
1127
|
-
if (ctx && ctx.contextWindow > 0 && ctx.inputTokens > 0) {
|
|
1128
|
-
const pct = Math.round(ctx.inputTokens / ctx.contextWindow * 100);
|
|
1129
|
-
const cls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
|
|
1130
|
-
if (showTokenCount) {
|
|
1131
|
-
ctxPct = `<span class="ctx-pct ${cls}">${fmtTok(ctx.inputTokens)}</span>`;
|
|
1132
|
-
} else {
|
|
1133
|
-
ctxPct = `<span class="ctx-pct ${cls}">${pct}%</span>`;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
const activeDot = ctx && (Date.now() - ctx.lastActivity < 120000) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
|
|
1137
|
-
const actIcon = node.type === 'main' ? '🗣' : '⚡';
|
|
1138
|
-
const actText = showActivity && (node.activityTool || node.activityDesc)
|
|
1139
|
-
? (node.activityTool && node.activityDesc ? `${node.activityTool}: ${node.activityDesc}` : (node.activityTool || node.activityDesc))
|
|
1140
|
-
: '';
|
|
1141
|
-
const indent = treePrefix(node).replace(/[├└]──/, ' ');
|
|
1142
|
-
const actPrefix = `<span class="tree-prefix">${indent}</span>`;
|
|
1143
|
-
const activityHTML = actText
|
|
1144
|
-
? `<div class="tree-activity">${actPrefix}<span class="act-text">${actIcon} ${esc(actText)}</span></div>`
|
|
1145
|
-
: '';
|
|
1146
|
-
return `<div class="tree-row${selClass ? ' selected' : ''}">
|
|
1147
|
-
<div class="tree-content${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1148
|
-
<div class="tree-node">
|
|
1149
|
-
<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}
|
|
1150
|
-
</div>
|
|
1151
|
-
${activityHTML}
|
|
1152
|
-
</div>
|
|
1153
|
-
<span class="tree-actions">
|
|
1154
|
-
<button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
|
|
1155
|
-
<button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});toggleNodeVisibility(${idx})" data-tooltip="${enabled ? 'Hide' : 'Show'}">${enabled ? '👁' : '─'}</button>
|
|
1156
|
-
</span>
|
|
1157
|
-
</div>`;
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
if (node.type === 'task') {
|
|
1161
|
-
const icon = node.isComplete ? '✓' : '⏳';
|
|
1162
|
-
const taskIndent = treePrefix(node).replace(/[├└]──/, ' ');
|
|
1163
|
-
const taskPrefix = `<span class="tree-prefix">${taskIndent}</span>`;
|
|
1164
|
-
const descHTML = showActivity && node.description
|
|
1165
|
-
? `<div class="tree-activity">${taskPrefix}<span class="act-text">📋 ${esc(node.description)}</span></div>`
|
|
1166
|
-
: '';
|
|
1167
|
-
return `<div class="tree-row${selClass ? ' selected' : ''}">
|
|
1168
|
-
<div class="tree-content dim" onclick="treeClick(${idx})" data-idx="${idx}">
|
|
1169
|
-
<div class="tree-node">
|
|
1170
|
-
<span class="tree-prefix">${treePrefix(node)}</span>${icon} ${esc(node.name || 'bg-task')}
|
|
1171
|
-
</div>
|
|
1172
|
-
${descHTML}
|
|
1173
|
-
</div>
|
|
1174
|
-
<span class="tree-actions">
|
|
1175
|
-
<button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});loadBgTask(${idx})" data-tooltip="Load output">▶</button>
|
|
1176
|
-
</span>
|
|
1177
|
-
</div>`;
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
return '';
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
function renderTree() {
|
|
1184
|
-
if (treeNodes.length === 0) {
|
|
1185
|
-
treeEl.innerHTML = '<div class="tree-node" style="padding:8px;color:var(--dim)">Waiting for sessions...</div>';
|
|
1186
|
-
treeCursorInfo.textContent = '';
|
|
1187
|
-
return;
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
const cursorChanged = treeCursor !== lastTreeCursor;
|
|
1191
|
-
if (treeDirty) {
|
|
1192
|
-
let html = '';
|
|
1193
|
-
for (let i = 0; i < treeNodes.length; i++) {
|
|
1194
|
-
html += getNodeHTML(treeNodes[i], i);
|
|
1195
|
-
}
|
|
1196
|
-
treeEl.innerHTML = html;
|
|
1197
|
-
treeDirty = false;
|
|
1198
|
-
} else if (cursorChanged) {
|
|
1199
|
-
const prevSel = treeEl.querySelector('.tree-row.selected');
|
|
1200
|
-
if (prevSel) prevSel.classList.remove('selected');
|
|
1201
|
-
const newContent = treeEl.querySelector('[data-idx="' + treeCursor + '"]');
|
|
1202
|
-
if (newContent) {
|
|
1203
|
-
const row = newContent.closest('.tree-row');
|
|
1204
|
-
if (row) row.classList.add('selected');
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
lastTreeCursor = treeCursor;
|
|
1208
|
-
|
|
1209
|
-
// Scroll selected into view
|
|
1210
|
-
const sel = treeEl.querySelector('.tree-row.selected');
|
|
1211
|
-
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
1212
|
-
|
|
1213
|
-
treeCursorInfo.textContent = `${treeCursor + 1}/${treeNodes.length}`;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
function updateTreeDots() {
|
|
1217
|
-
const dots = treeEl.querySelectorAll('.active-dot');
|
|
1218
|
-
const now = Date.now();
|
|
1219
|
-
for (const dot of dots) {
|
|
1220
|
-
const content = dot.closest('.tree-content');
|
|
1221
|
-
if (!content) continue;
|
|
1222
|
-
const idx = parseInt(content.getAttribute('data-idx'));
|
|
1223
|
-
if (isNaN(idx)) continue;
|
|
1224
|
-
const node = treeNodes[idx];
|
|
1225
|
-
if (!node) continue;
|
|
1226
|
-
let active = false;
|
|
1227
|
-
if (node.type === 'session') {
|
|
1228
|
-
active = isSessionActive(node);
|
|
1229
|
-
} else if (node.type === 'main' || node.type === 'agent') {
|
|
1230
|
-
const ctxKey = node.sessionID + ':' + node.id;
|
|
1231
|
-
const ctx = contextData[ctxKey];
|
|
1232
|
-
const threshold = node.type === 'main' ? 600000 : 180000;
|
|
1233
|
-
active = ctx && (now - ctx.lastActivity < threshold);
|
|
1234
|
-
}
|
|
1235
|
-
const newCls = active ? 'active-dot on' : 'active-dot off';
|
|
1236
|
-
const newHTML = active ? '🟢' : '⚪';
|
|
1237
|
-
if (dot.className !== newCls) {
|
|
1238
|
-
dot.className = newCls;
|
|
1239
|
-
dot.innerHTML = newHTML;
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
const ACTIVE_THRESHOLD = 600000; // 10 minutes
|
|
1245
|
-
|
|
1246
|
-
function itemTime(item) {
|
|
1247
|
-
if (item && item.timestamp) {
|
|
1248
|
-
const ts = item.timestamp instanceof Date ? item.timestamp : new Date(item.timestamp);
|
|
1249
|
-
if (!isNaN(ts.getTime())) return ts.getTime();
|
|
1250
|
-
}
|
|
1251
|
-
return Date.now();
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
function isSessionActive(session) {
|
|
1255
|
-
if (!session) return false;
|
|
1256
|
-
const now = Date.now();
|
|
1257
|
-
// Main agent: 10 minutes
|
|
1258
|
-
const mainCtx = contextData[session.id + ':'];
|
|
1259
|
-
if (mainCtx && (now - mainCtx.lastActivity) < 600000) return true;
|
|
1260
|
-
// Subagents: 3 minutes
|
|
1261
|
-
for (const a of session.agents) {
|
|
1262
|
-
if (a.id === '') continue;
|
|
1263
|
-
const ctx = contextData[session.id + ':' + a.id];
|
|
1264
|
-
if (ctx && (now - ctx.lastActivity) < 180000) return true;
|
|
1265
|
-
}
|
|
1266
|
-
// Session fallback: 10 minutes
|
|
1267
|
-
return (now - session.lastActivity) < 600000;
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1271
|
-
// Stream rendering
|
|
1272
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1273
|
-
|
|
1274
|
-
function renderStream() {
|
|
1275
|
-
// Rebuild visibleItems from scratch only when filters/toggles changed
|
|
1276
|
-
if (visibleDirty) {
|
|
1277
|
-
visibleItems = streamItems.filter(isItemVisible);
|
|
1278
|
-
visibleDirty = false;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
const visible = visibleItems;
|
|
1282
|
-
const wasAutoScroll = autoScroll;
|
|
1283
|
-
|
|
1284
|
-
if (needsFullRender || renderedItemCount > visible.length) {
|
|
1285
|
-
// Full rebuild: filter changed, items trimmed, or initial render
|
|
1286
|
-
const lines = [];
|
|
1287
|
-
for (const item of visible) {
|
|
1288
|
-
for (const l of renderItem(item)) lines.push(l);
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
let html;
|
|
1292
|
-
if (lines.length > 0) {
|
|
1293
|
-
html = lines.map(l => {
|
|
1294
|
-
const sidAttr = l.sessionID ? ` data-session-id="${esc(l.sessionID)}"` : '';
|
|
1295
|
-
if (l.html) return `<div class="${esc(l.cls)}"${sidAttr}>${l.text}</div>`;
|
|
1296
|
-
return `<div class="${esc(l.cls)}"${sidAttr}>${esc(l.text)}</div>`;
|
|
1297
|
-
}).join('\n');
|
|
1298
|
-
} else if (streamItems.length > 0) {
|
|
1299
|
-
html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
|
|
1300
|
-
} else {
|
|
1301
|
-
html = '<div style="color:#6b7280;padding:20px;text-align:center">Waiting for output...</div>';
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
streamEl.innerHTML = html;
|
|
1305
|
-
renderedItemCount = visible.length;
|
|
1306
|
-
needsFullRender = false;
|
|
1307
|
-
if (wasAutoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
|
|
1308
|
-
} else {
|
|
1309
|
-
// Incremental append: only add new items since last render
|
|
1310
|
-
for (let i = renderedItemCount; i < visible.length; i++) {
|
|
1311
|
-
for (const l of renderItem(visible[i])) {
|
|
1312
|
-
const div = document.createElement('div');
|
|
1313
|
-
div.className = l.cls;
|
|
1314
|
-
if (l.sessionID) div.dataset.sessionId = l.sessionID;
|
|
1315
|
-
div.innerHTML = l.html ? l.text : esc(l.text);
|
|
1316
|
-
streamEl.appendChild(div);
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
renderedItemCount = visible.length;
|
|
1320
|
-
if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
const maxScroll = streamEl.scrollHeight - streamEl.clientHeight;
|
|
1324
|
-
const pct = maxScroll > 0 ? Math.round(streamEl.scrollTop / maxScroll * 100) : 0;
|
|
1325
|
-
document.getElementById('scroll-pos').textContent = Math.min(100, pct) + '%';
|
|
1326
|
-
document.getElementById('item-count').textContent = streamItems.length + ' items';
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
function renderItem(item) {
|
|
1330
|
-
const lines = [];
|
|
1331
|
-
const isSub = !!item.agentID;
|
|
1332
|
-
const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
|
|
1333
|
-
const sep = ' » ';
|
|
1334
|
-
const sid = item.sessionID || '';
|
|
1335
|
-
|
|
1336
|
-
if (item.type === 'turn_marker') {
|
|
1337
|
-
return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──`, sessionID: sid }];
|
|
1338
|
-
}
|
|
1339
|
-
if (item.type === 'compact_marker') {
|
|
1340
|
-
const label = item.content ? `compacted (${item.content})` : 'compacted';
|
|
1341
|
-
return [{ cls: 'stream-line marker', text: `── ${label} ──`, sessionID: sid }];
|
|
1342
|
-
}
|
|
1343
|
-
if (item.type === 'pr_link') {
|
|
1344
|
-
return [{ cls: 'stream-line marker', text: `── ${item.content} ──`, sessionID: sid }];
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
const agentName = item.agentName || 'Main';
|
|
1348
|
-
const sForColor = sessionsMap.get(item.sessionID);
|
|
1349
|
-
const prefixTag = `<span class="session-prefix" style="color:${idColor(sForColor ? sForColor.colorRank : 0)}">[${esc(item.sessionID.split('-')[0].toUpperCase())}]</span>`;
|
|
1350
|
-
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>` : '';
|
|
1351
|
-
const agentLabel = prefixTag + agentIdTag + ' ' + esc(agentName);
|
|
1352
|
-
const tsHtml = item.timestamp ? `<span class="timestamp">${fmtTimestamp(item.timestamp)}</span>` : '';
|
|
1353
|
-
|
|
1354
|
-
switch (item.type) {
|
|
1355
|
-
case 'thinking':
|
|
1356
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1357
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l, sessionID: sid });
|
|
1358
|
-
break;
|
|
1359
|
-
case 'tool_input':
|
|
1360
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1361
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l, sessionID: sid });
|
|
1362
|
-
break;
|
|
1363
|
-
case 'tool_output': {
|
|
1364
|
-
let tn = '';
|
|
1365
|
-
if (item.toolID) {
|
|
1366
|
-
tn = toolNameMap.get(item.toolID) || '';
|
|
1367
|
-
}
|
|
1368
|
-
let label = tn ? `📤 ${tn} result` : '📤 Output';
|
|
1369
|
-
if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
|
|
1370
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1371
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l, sessionID: sid });
|
|
1372
|
-
break;
|
|
1373
|
-
}
|
|
1374
|
-
case 'text':
|
|
1375
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1376
|
-
lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true, sessionID: sid });
|
|
1377
|
-
break;
|
|
1378
|
-
case 'hook_output': {
|
|
1379
|
-
let label = '🪝 Hook';
|
|
1380
|
-
if (item.toolName) label += ' ' + item.toolName;
|
|
1381
|
-
if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
|
|
1382
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1383
|
-
if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true, sessionID: sid });
|
|
1384
|
-
if (item.hookContent) {
|
|
1385
|
-
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 });
|
|
1386
|
-
}
|
|
1387
|
-
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 });
|
|
1388
|
-
break;
|
|
1389
|
-
}
|
|
1390
|
-
case 'diagnostics': {
|
|
1391
|
-
let label = '⚠ Diagnostics';
|
|
1392
|
-
if (item.toolName) label += ' ' + item.toolName;
|
|
1393
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1394
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l, sessionID: sid });
|
|
1395
|
-
break;
|
|
1396
|
-
}
|
|
1397
|
-
case 'debug': {
|
|
1398
|
-
let label = '🔍 Debug';
|
|
1399
|
-
if (item.toolName) label += ' ' + item.toolName;
|
|
1400
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1401
|
-
for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
|
|
1402
|
-
break;
|
|
1403
|
-
}
|
|
1404
|
-
case 'user_text':
|
|
1405
|
-
lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}👤 User Prompt</span>${tsHtml}`, html: true, sessionID: sid });
|
|
1406
|
-
lines.push({ cls: 'stream-line user-prompt-block md-content', text: mdRender(item.content), html: true, sessionID: sid });
|
|
1407
|
-
break;
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
|
|
1411
|
-
return lines;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
function truncContent(content) {
|
|
1415
|
-
const raw = content.split('\n');
|
|
1416
|
-
return raw.length > MAX_LINES ? raw.slice(0, MAX_LINES).concat([`... (${raw.length - MAX_LINES} more lines)`]) : raw;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1420
|
-
// Button / header refresh
|
|
1421
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1422
|
-
|
|
1423
|
-
function refreshButtons() {
|
|
1424
|
-
document.getElementById('btn-thinking').classList.toggle('on', showThinking);
|
|
1425
|
-
document.getElementById('btn-tool-input').classList.toggle('on', showToolInput);
|
|
1426
|
-
document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
|
|
1427
|
-
document.getElementById('btn-text').classList.toggle('on', showText);
|
|
1428
|
-
document.getElementById('btn-hook').classList.toggle('on', showHook);
|
|
1429
|
-
document.getElementById('btn-user-prompt').classList.toggle('on', showUserPrompt);
|
|
1430
|
-
document.getElementById('btn-activity').classList.toggle('on', showActivity);
|
|
1431
|
-
const btnTokenDisplay = document.getElementById('btn-token-display');
|
|
1432
|
-
btnTokenDisplay.classList.toggle('on', true);
|
|
1433
|
-
btnTokenDisplay.textContent = showTokenCount ? 'T' : '%';
|
|
1434
|
-
btnTokenDisplay.setAttribute('data-tooltip', showTokenCount ? '上下文:Token数 ↔ 百分比切换' : '上下文:百分比 ↔ Token数切换');
|
|
1435
|
-
document.getElementById('btn-autoscroll').classList.toggle('on', autoScroll);
|
|
1436
|
-
document.getElementById('btn-tree-toggle').classList.toggle('on', showTree);
|
|
1437
|
-
document.getElementById('btn-autodisco').classList.toggle('on', autoDiscovery);
|
|
1438
|
-
|
|
1439
|
-
// Session info
|
|
1440
|
-
let info = '';
|
|
1441
|
-
if (sessions.length === 0) info = 'Waiting...';
|
|
1442
|
-
else if (sessions.length === 1) {
|
|
1443
|
-
const s = sessions[0];
|
|
1444
|
-
info = (folderName(s.projectPath) || s.title || s.id.slice(0, 14));
|
|
1445
|
-
} else info = sessions.length + ' sessions';
|
|
1446
|
-
if (!autoDiscovery) info += ' [paused]';
|
|
1447
|
-
sessionInfo.textContent = info;
|
|
1448
|
-
|
|
1449
|
-
// Token info
|
|
1450
|
-
computeTokensFromContext();
|
|
1451
|
-
let tokStr = '';
|
|
1452
|
-
if (totalInput > 0 || totalOutput > 0) {
|
|
1453
|
-
tokStr = `${fmtTok(totalInput)} in / ${fmtTok(totalOutput)} out`;
|
|
1454
|
-
if (totalCacheCreate > 0 || totalCacheRead > 0) {
|
|
1455
|
-
tokStr += ` · cache ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
tokenInfo.textContent = tokStr;
|
|
1459
|
-
|
|
1460
|
-
// Footer version
|
|
1461
|
-
const vEl = document.getElementById('footer-version');
|
|
1462
|
-
if (vEl) {
|
|
1463
|
-
const v = appVersion ? `v${appVersion}` : '';
|
|
1464
|
-
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>`;
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1469
|
-
// Session ID tooltip
|
|
1470
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1471
|
-
|
|
1472
|
-
let sessionIdTipTimer = null;
|
|
1473
|
-
let sessionIdTipEl = null;
|
|
1474
|
-
function showSessionIdTip(el) {
|
|
1475
|
-
hideAllSessionIdTips();
|
|
1476
|
-
const sid = el.getAttribute('data-sid');
|
|
1477
|
-
if (!sid) return;
|
|
1478
|
-
sessionIdTipTimer = setTimeout(() => {
|
|
1479
|
-
const rect = el.getBoundingClientRect();
|
|
1480
|
-
const tip = document.createElement('div');
|
|
1481
|
-
tip.className = 'session-id-tip';
|
|
1482
|
-
tip.style.top = (rect.bottom + 4) + 'px';
|
|
1483
|
-
tip.style.left = rect.left + 'px';
|
|
1484
|
-
tip.innerHTML = `<button class="tip-copy-btn" onclick="event.stopPropagation();copySessionId(this)">Copy</button><code>${esc(sid)}</code>`;
|
|
1485
|
-
tip.onmouseenter = () => clearTimeout(sessionIdTipTimer);
|
|
1486
|
-
tip.onmouseleave = () => { hideAllSessionIdTips(); };
|
|
1487
|
-
document.body.appendChild(tip);
|
|
1488
|
-
sessionIdTipEl = tip;
|
|
1489
|
-
el._tip = tip;
|
|
1490
|
-
}, 300);
|
|
1491
|
-
}
|
|
1492
|
-
function hideSessionIdTip(el) {
|
|
1493
|
-
sessionIdTipTimer = setTimeout(() => {
|
|
1494
|
-
if (el._tip) { el._tip.remove(); el._tip = null; }
|
|
1495
|
-
sessionIdTipEl = null;
|
|
1496
|
-
}, 200);
|
|
1497
|
-
}
|
|
1498
|
-
function hideAllSessionIdTips() {
|
|
1499
|
-
clearTimeout(sessionIdTipTimer);
|
|
1500
|
-
document.querySelectorAll('.session-id-tip').forEach(t => t.remove());
|
|
1501
|
-
sessionIdTipEl = null;
|
|
1502
|
-
}
|
|
1503
|
-
function copySessionId(btn) {
|
|
1504
|
-
const code = btn.parentElement.querySelector('code');
|
|
1505
|
-
if (!code) return;
|
|
1506
|
-
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
1507
|
-
btn.textContent = 'Copied!';
|
|
1508
|
-
setTimeout(() => { btn.closest('.session-id-tip')?.remove(); }, 800);
|
|
1509
|
-
});
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1513
|
-
// Actions
|
|
1514
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1515
|
-
|
|
1516
|
-
function selectIndex(idx) {
|
|
1517
|
-
if (idx >= 0 && idx < treeNodes.length) treeCursor = idx;
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
function treeClick(idx) {
|
|
1521
|
-
selectIndex(idx);
|
|
1522
|
-
const node = treeNodes[idx];
|
|
1523
|
-
if (!node) return;
|
|
1524
|
-
if (node.type === 'date-folder') {
|
|
1525
|
-
node.collapsed = !node.collapsed;
|
|
1526
|
-
folderCollapsed[node.date] = node.collapsed;
|
|
1527
|
-
rebuildNodes();
|
|
1528
|
-
} else if (node.type === 'session') {
|
|
1529
|
-
const session = sessions.find(s => s.id === node.id);
|
|
1530
|
-
if (session) {
|
|
1531
|
-
session.collapsed = !session.collapsed;
|
|
1532
|
-
if (!session.collapsed) session.pinned = true;
|
|
1533
|
-
}
|
|
1534
|
-
rebuildNodes();
|
|
1535
|
-
} else if (node.type === 'main' || node.type === 'agent') {
|
|
1536
|
-
toggleNodeVisibility(idx);
|
|
1537
|
-
return;
|
|
1538
|
-
} else if (node.type === 'task') {
|
|
1539
|
-
loadBgTask(idx);
|
|
1540
|
-
return;
|
|
1541
|
-
}
|
|
1542
|
-
renderAll();
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function toggleNodeVisibility(idx) {
|
|
1546
|
-
const node = treeNodes[idx];
|
|
1547
|
-
if (!node) return;
|
|
1548
|
-
const key = node.sessionID + ':' + node.id;
|
|
1549
|
-
const wasEnabled = filters.get(key);
|
|
1550
|
-
filters.set(key, !wasEnabled);
|
|
1551
|
-
if (wasEnabled) visibleFilterCount--;
|
|
1552
|
-
else visibleFilterCount++;
|
|
1553
|
-
renderAll();
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
function loadBgTask(idx) {
|
|
1557
|
-
const node = treeNodes[idx];
|
|
1558
|
-
if (!node || node.type !== 'task') return;
|
|
1559
|
-
if (!node.outputPath) return;
|
|
1560
|
-
|
|
1561
|
-
// Fetch the actual output file
|
|
1562
|
-
fetch(`/api/task-output?path=${encodeURIComponent(node.outputPath)}`)
|
|
1563
|
-
.then(r => r.json())
|
|
1564
|
-
.then(data => {
|
|
1565
|
-
const content = data.content || `[Error: ${data.error || 'unknown'}]`;
|
|
1566
|
-
const statusIcon = node.isComplete ? '✓' : '⏳';
|
|
1567
|
-
streamItems.push({
|
|
1568
|
-
type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
|
|
1569
|
-
agentName: '', toolName: `${statusIcon} ${node.name || 'bg-task'}`,
|
|
1570
|
-
content: content,
|
|
1571
|
-
timestamp: new Date(), toolID: '', durationMs: 0,
|
|
1572
|
-
inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
|
|
1573
|
-
});
|
|
1574
|
-
renderAll();
|
|
1575
|
-
})
|
|
1576
|
-
.catch(err => {
|
|
1577
|
-
streamItems.push({
|
|
1578
|
-
type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
|
|
1579
|
-
agentName: '', toolName: `⏳ ${node.name || 'bg-task'}`,
|
|
1580
|
-
content: `[Failed to load: ${err.message}]`,
|
|
1581
|
-
timestamp: new Date(), toolID: '', durationMs: 0,
|
|
1582
|
-
inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
|
|
1583
|
-
});
|
|
1584
|
-
renderAll();
|
|
1585
|
-
});
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
function soloSelected() {
|
|
1589
|
-
const node = treeNodes[treeCursor];
|
|
1590
|
-
if (!node || node.type === 'task') return;
|
|
1591
|
-
|
|
1592
|
-
if (isSoloed(node)) {
|
|
1593
|
-
updateFilters();
|
|
1594
|
-
} else {
|
|
1595
|
-
filters.clear();
|
|
1596
|
-
visibleFilterCount = 0;
|
|
1597
|
-
if (node.type === 'session') {
|
|
1598
|
-
const session = sessions.find(s => s.id === node.id);
|
|
1599
|
-
if (session && session.collapsed) {
|
|
1600
|
-
session.collapsed = false;
|
|
1601
|
-
session.pinned = true;
|
|
1602
|
-
rebuildNodes();
|
|
1603
|
-
}
|
|
1604
|
-
for (const a of node.agents) {
|
|
1605
|
-
filters.set(node.id + ':' + a.id, true);
|
|
1606
|
-
visibleFilterCount++;
|
|
1607
|
-
}
|
|
1608
|
-
} else if (node.type === 'main' || node.type === 'agent') {
|
|
1609
|
-
filters.set(node.sessionID + ':' + node.id, true);
|
|
1610
|
-
visibleFilterCount = 1;
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
renderAll();
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
function isSoloed(node) {
|
|
1617
|
-
if (node.type === 'session') {
|
|
1618
|
-
if (visibleFilterCount !== node.agents.length) return false;
|
|
1619
|
-
for (const a of node.agents) {
|
|
1620
|
-
if (!filters.get(node.id + ':' + a.id)) return false;
|
|
1621
|
-
}
|
|
1622
|
-
return true;
|
|
1623
|
-
}
|
|
1624
|
-
if (node.type === 'main' || node.type === 'agent') {
|
|
1625
|
-
const key = node.sessionID + ':' + node.id;
|
|
1626
|
-
return visibleFilterCount === 1 && filters.get(key);
|
|
1627
|
-
}
|
|
1628
|
-
return false;
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
function selectAll() {
|
|
1632
|
-
updateFilters();
|
|
1633
|
-
renderAll();
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
function removeSelectedSession() {
|
|
1637
|
-
const node = treeNodes[treeCursor];
|
|
1638
|
-
if (!node) return;
|
|
1639
|
-
let sid;
|
|
1640
|
-
if (node.type === 'session') sid = node.id;
|
|
1641
|
-
else sid = node.sessionID;
|
|
1642
|
-
if (!sid) return;
|
|
1643
|
-
if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
|
|
1644
|
-
hiddenSessionIDs.add(sid);
|
|
1645
|
-
_saveHiddenSessions();
|
|
1646
|
-
const idx = sessions.findIndex(s => s.id === sid);
|
|
1647
|
-
if (idx >= 0) {
|
|
1648
|
-
sessions.splice(idx, 1);
|
|
1649
|
-
sessionsMap.delete(sid);
|
|
1650
|
-
}
|
|
1651
|
-
sendCmd('removeSession', { sessionID: sid });
|
|
1652
|
-
updateFilters();
|
|
1653
|
-
rebuildNodes();
|
|
1654
|
-
renderAll();
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1658
|
-
// Toggles
|
|
1659
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1660
|
-
|
|
1661
|
-
function toggleThinking() { showThinking = !showThinking; needsFullRender = true;
|
|
1662
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1663
|
-
function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true;
|
|
1664
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1665
|
-
function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true;
|
|
1666
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1667
|
-
function toggleText() { showText = !showText; needsFullRender = true;
|
|
1668
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1669
|
-
function toggleHook() { showHook = !showHook; needsFullRender = true;
|
|
1670
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1671
|
-
function toggleUserPrompt() { showUserPrompt = !showUserPrompt; needsFullRender = true;
|
|
1672
|
-
visibleDirty = true; renderStream(); refreshButtons(); }
|
|
1673
|
-
function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
|
|
1674
|
-
function toggleTokenDisplay() {
|
|
1675
|
-
showTokenCount = !showTokenCount;
|
|
1676
|
-
treeDirty = true;
|
|
1677
|
-
scheduleRender();
|
|
1678
|
-
refreshButtons();
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1682
|
-
// Tab switching & Token page
|
|
1683
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1684
|
-
|
|
1685
|
-
function switchTab(tab) {
|
|
1686
|
-
currentTab = tab;
|
|
1687
|
-
document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
|
|
1688
|
-
document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
|
|
1689
|
-
document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
|
|
1690
|
-
document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
|
|
1691
|
-
// footer 只在 stream 模式下有意义
|
|
1692
|
-
document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
|
|
1693
|
-
if (tab === 'tokens') renderTokenPage();
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
let tokenTableVisible = false;
|
|
1697
|
-
function toggleTokenTable() {
|
|
1698
|
-
tokenTableVisible = !tokenTableVisible;
|
|
1699
|
-
document.getElementById('token-table-wrap').style.display = tokenTableVisible ? 'block' : 'none';
|
|
1700
|
-
document.getElementById('token-table-toggle').textContent = '明细表格 ' + (tokenTableVisible ? '▾' : '▸');
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
function renderTokenPage() {
|
|
1704
|
-
computeTokensFromContext();
|
|
1705
|
-
const entries = Object.entries(contextData);
|
|
1706
|
-
if (entries.length === 0) {
|
|
1707
|
-
document.getElementById('token-overview-grid').innerHTML = '<div style="color:var(--dim);padding:8px">暂无 Token 数据</div>';
|
|
1708
|
-
document.getElementById('token-agent-cards').innerHTML = '';
|
|
1709
|
-
document.getElementById('token-table-body').innerHTML = '';
|
|
1710
|
-
return;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
// ── Overview card ──
|
|
1714
|
-
const overviewGrid = document.getElementById('token-overview-grid');
|
|
1715
|
-
const overviewItems = [
|
|
1716
|
-
{ label: 'Input Tokens', value: fmtTok(totalInput), pct: null },
|
|
1717
|
-
{ label: 'Output Tokens', value: fmtTok(totalOutput), pct: null },
|
|
1718
|
-
{ label: 'Cache Creation', value: fmtTok(totalCacheCreate), pct: null },
|
|
1719
|
-
{ label: 'Cache Read', value: fmtTok(totalCacheRead), pct: null },
|
|
1720
|
-
{ label: 'I/O Ratio', value: totalOutput > 0 ? (totalInput / totalOutput).toFixed(1) + ' : 1' : '—', pct: null },
|
|
1721
|
-
];
|
|
1722
|
-
overviewGrid.innerHTML = overviewItems.map(it =>
|
|
1723
|
-
`<div class="token-item"><span class="token-label">${it.label}</span><span class="token-value">${it.value}</span></div>`
|
|
1724
|
-
).join('');
|
|
1725
|
-
|
|
1726
|
-
// ── Agent cards ──
|
|
1727
|
-
const agentCardsEl = document.getElementById('token-agent-cards');
|
|
1728
|
-
// Sort by lastActivity descending (active first)
|
|
1729
|
-
const sorted = entries.sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0));
|
|
1730
|
-
agentCardsEl.innerHTML = sorted.map(([key, ctx]) => {
|
|
1731
|
-
const [sid, agentId] = key.split(':');
|
|
1732
|
-
const isMain = agentId === 'main' || !agentId.includes('-');
|
|
1733
|
-
const icon = isMain ? '🗣' : '🤖';
|
|
1734
|
-
const agentName = isMain ? 'Main' : agentId;
|
|
1735
|
-
const active = ctx.lastActivity && (Date.now() - ctx.lastActivity < 180000);
|
|
1736
|
-
const activeDot = active ? '<span class="token-active-dot">🟢</span>' : '<span class="token-active-dot">⚪</span>';
|
|
1737
|
-
|
|
1738
|
-
const pct = ctx.contextWindow > 0 ? Math.round(ctx.inputTokens / ctx.contextWindow * 100) : 0;
|
|
1739
|
-
const pctCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
|
|
1740
|
-
const barCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
|
|
1741
|
-
const barWidth = Math.min(pct, 100);
|
|
1742
|
-
|
|
1743
|
-
const ioRatio = ctx.outputTokens > 0 ? (ctx.inputTokens / ctx.outputTokens).toFixed(1) + ' : 1' : '—';
|
|
1744
|
-
|
|
1745
|
-
return `<div class="token-card">
|
|
1746
|
-
<div class="token-card-title">${icon} ${esc(agentName)} ${ctx.model ? '· ' + esc(ctx.model) : ''} ${activeDot}</div>
|
|
1747
|
-
<div class="token-usage-line">
|
|
1748
|
-
<span class="token-pct ${pctCls}">${pct}%</span>
|
|
1749
|
-
<span class="token-ctx-info">${fmtTok(ctx.inputTokens)} / ${fmtTok(ctx.contextWindow)}</span>
|
|
1750
|
-
</div>
|
|
1751
|
-
<div class="token-bar"><div class="token-bar-fill ${barCls}" style="width:${barWidth}%"></div></div>
|
|
1752
|
-
<div class="token-detail-row">
|
|
1753
|
-
<span class="token-kv">Output: <b>${fmtTok(ctx.outputTokens)}</b></span>
|
|
1754
|
-
<span class="token-kv">Cache+: <b>${fmtTok(ctx.cacheCreation)}</b></span>
|
|
1755
|
-
<span class="token-kv">Cache Read: <b>${fmtTok(ctx.cacheRead)}</b></span>
|
|
1756
|
-
<span class="token-kv">I/O: <b>${ioRatio}</b></span>
|
|
1757
|
-
</div>
|
|
1758
|
-
</div>`;
|
|
1759
|
-
}).join('');
|
|
1760
|
-
|
|
1761
|
-
// ── Detail table ──
|
|
1762
|
-
const tbody = document.getElementById('token-table-body');
|
|
1763
|
-
tbody.innerHTML = sorted.map(([key, ctx]) => {
|
|
1764
|
-
const [sid, agentId] = key.split(':');
|
|
1765
|
-
const agentName = (agentId === 'main' || !agentId.includes('-')) ? 'Main' : agentId;
|
|
1766
|
-
const pct = ctx.contextWindow > 0 ? (ctx.inputTokens / ctx.contextWindow * 100).toFixed(1) + '%' : '—';
|
|
1767
|
-
const ioRatio = ctx.outputTokens > 0 ? (ctx.inputTokens / ctx.outputTokens).toFixed(1) : '—';
|
|
1768
|
-
return `<tr>
|
|
1769
|
-
<td>${esc(agentName)}</td>
|
|
1770
|
-
<td>${esc(ctx.model || '—')}</td>
|
|
1771
|
-
<td>${ctx.inputTokens}</td>
|
|
1772
|
-
<td>${ctx.outputTokens}</td>
|
|
1773
|
-
<td>${ctx.cacheCreation}</td>
|
|
1774
|
-
<td>${ctx.cacheRead}</td>
|
|
1775
|
-
<td>${ctx.contextWindow}</td>
|
|
1776
|
-
<td>${pct}</td>
|
|
1777
|
-
<td>${ioRatio}</td>
|
|
1778
|
-
</tr>`;
|
|
1779
|
-
}).join('');
|
|
1780
|
-
}
|
|
1781
|
-
function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
|
|
1782
|
-
function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
|
|
1783
|
-
function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
|
|
1784
|
-
|
|
1785
|
-
function scrollToTop() { streamEl.scrollTop = 0; autoScroll = false; renderAll(); }
|
|
1786
|
-
function scrollUp() { streamEl.scrollTop -= 80; autoScroll = false; renderAll(); }
|
|
1787
|
-
function scrollDown() { streamEl.scrollTop += 80; if (autoScroll) autoScroll = false; renderAll(); }
|
|
1788
|
-
function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; autoScroll = true; renderAll(); }
|
|
1789
|
-
|
|
1790
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1791
|
-
// Tree panel resize
|
|
1792
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1793
|
-
|
|
1794
|
-
(function setupResize() {
|
|
1795
|
-
const panel = document.getElementById('tree-panel');
|
|
1796
|
-
const handle = document.getElementById('tree-resize-handle');
|
|
1797
|
-
let startX, startWidth;
|
|
1798
|
-
|
|
1799
|
-
handle.addEventListener('mousedown', (e) => {
|
|
1800
|
-
e.preventDefault();
|
|
1801
|
-
startX = e.clientX;
|
|
1802
|
-
startWidth = panel.offsetWidth;
|
|
1803
|
-
handle.classList.add('active');
|
|
1804
|
-
document.body.style.cursor = 'col-resize';
|
|
1805
|
-
document.body.style.userSelect = 'none';
|
|
1806
|
-
});
|
|
1807
|
-
|
|
1808
|
-
document.addEventListener('mousemove', (e) => {
|
|
1809
|
-
if (!handle.classList.contains('active')) return;
|
|
1810
|
-
const dx = e.clientX - startX;
|
|
1811
|
-
const newWidth = startWidth + dx;
|
|
1812
|
-
if (newWidth >= 180 && newWidth <= window.innerWidth * 0.6) {
|
|
1813
|
-
panel.style.width = newWidth + 'px';
|
|
1814
|
-
}
|
|
1815
|
-
});
|
|
1816
|
-
|
|
1817
|
-
document.addEventListener('mouseup', () => {
|
|
1818
|
-
handle.classList.remove('active');
|
|
1819
|
-
document.body.style.cursor = '';
|
|
1820
|
-
document.body.style.userSelect = '';
|
|
1821
|
-
});
|
|
1822
|
-
})();
|
|
1823
|
-
|
|
1824
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1825
|
-
// Auto-collapse
|
|
1826
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1827
|
-
|
|
1828
|
-
function applyCollapsePolicy(duration) {
|
|
1829
|
-
collapseAfter = duration;
|
|
1830
|
-
if (collapseTimer) clearInterval(collapseTimer);
|
|
1831
|
-
if (duration <= 0) return;
|
|
1832
|
-
|
|
1833
|
-
collapseTimer = setInterval(() => {
|
|
1834
|
-
if (!collapseAfter) return;
|
|
1835
|
-
const now = Date.now();
|
|
1836
|
-
let changed = false;
|
|
1837
|
-
for (const s of sessions) {
|
|
1838
|
-
if (s.pinned || s.collapsed) continue;
|
|
1839
|
-
if ((now - s.lastActivity) > collapseAfter) {
|
|
1840
|
-
s.collapsed = true;
|
|
1841
|
-
changed = true;
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
if (changed) {
|
|
1845
|
-
rebuildNodes();
|
|
1846
|
-
renderAll();
|
|
1847
|
-
}
|
|
1848
|
-
}, 5000);
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
function startActiveRefresh() {
|
|
1852
|
-
if (activeRefreshTimer) clearInterval(activeRefreshTimer);
|
|
1853
|
-
activeRefreshTimer = setInterval(() => {
|
|
1854
|
-
updateTreeDots();
|
|
1855
|
-
refreshButtons();
|
|
1856
|
-
}, 15000);
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1860
|
-
// Scroll detection
|
|
1861
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1862
|
-
|
|
1863
|
-
streamEl.addEventListener('scroll', () => {
|
|
1864
|
-
const atBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
|
|
1865
|
-
if (atBottom && !autoScroll) autoScroll = true;
|
|
1866
|
-
if (!atBottom && autoScroll) autoScroll = false;
|
|
1867
|
-
refreshButtons();
|
|
1868
|
-
});
|
|
1869
|
-
|
|
1870
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1871
|
-
// Helpers
|
|
1872
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1873
|
-
|
|
1874
|
-
function updateFilters() {
|
|
1875
|
-
filters.clear();
|
|
1876
|
-
visibleFilterCount = 0;
|
|
1877
|
-
for (const s of sessions) {
|
|
1878
|
-
for (const a of s.agents) {
|
|
1879
|
-
filters.set(s.id + ':' + a.id, true);
|
|
1880
|
-
visibleFilterCount++;
|
|
1881
|
-
}
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
function agentDisplayName(id, type) {
|
|
1886
|
-
if (type) {
|
|
1887
|
-
const idx = type.lastIndexOf(':');
|
|
1888
|
-
if (idx >= 0 && idx < type.length - 1) return type.slice(idx + 1);
|
|
1889
|
-
return type;
|
|
1890
|
-
}
|
|
1891
|
-
if (!id) return 'Main';
|
|
1892
|
-
return 'Agent-' + id.slice(0, 7);
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
// Compute the minimum display length per agentID so that within each session,
|
|
1896
|
-
// no two agent IDs share the same truncated prefix. Minimum 7 chars.
|
|
1897
|
-
const agentIdDisplayLen = new Map();
|
|
1898
|
-
function computeAgentIdDisplayLengths() {
|
|
1899
|
-
agentIdDisplayLen.clear();
|
|
1900
|
-
for (const s of sessions) {
|
|
1901
|
-
const agentIds = s.agents.filter(a => a.id).map(a => a.id);
|
|
1902
|
-
if (agentIds.length === 0) continue;
|
|
1903
|
-
// Find minimum length that makes all prefixes unique
|
|
1904
|
-
let minLen = 7;
|
|
1905
|
-
while (minLen < 21) {
|
|
1906
|
-
const prefixes = agentIds.map(id => id.slice(0, minLen));
|
|
1907
|
-
const unique = new Set(prefixes);
|
|
1908
|
-
if (unique.size === agentIds.length) break;
|
|
1909
|
-
minLen++;
|
|
1910
|
-
}
|
|
1911
|
-
for (const id of agentIds) {
|
|
1912
|
-
agentIdDisplayLen.set(s.id + ':' + id, minLen);
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
function folderName(projectPath) {
|
|
1918
|
-
if (!projectPath) return '';
|
|
1919
|
-
const parts = projectPath.split('/');
|
|
1920
|
-
return parts[parts.length - 1] || projectPath;
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
function esc(s) {
|
|
1924
|
-
return (s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\\/g, '\');
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
function fmtDur(ms) {
|
|
1928
|
-
if (!ms || ms <= 0) return '';
|
|
1929
|
-
if (ms < 1000) return `(${ms}ms)`;
|
|
1930
|
-
if (ms < 60000) return `(${(ms / 1000).toFixed(1)}s)`;
|
|
1931
|
-
return `(${(ms / 60000).toFixed(1)}m)`;
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
function formatTime(ms) {
|
|
1935
|
-
if (!ms) return '';
|
|
1936
|
-
const d = new Date(ms);
|
|
1937
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
1938
|
-
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
function fmtTimestamp(ts) {
|
|
1942
|
-
if (!ts) return '';
|
|
1943
|
-
const d = ts instanceof Date ? ts : new Date(ts);
|
|
1944
|
-
if (isNaN(d.getTime())) return '';
|
|
1945
|
-
const pad = (n, len) => String(n).padStart(len, '0');
|
|
1946
|
-
const ms = pad(d.getMilliseconds(), 3);
|
|
1947
|
-
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}`;
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
function fmtTok(n) {
|
|
1951
|
-
if (!n) return '0';
|
|
1952
|
-
if (n < 1000) return String(n);
|
|
1953
|
-
if (n < 1000000) return (n / 1000).toFixed(2) + 'k';
|
|
1954
|
-
return (n / 1000000).toFixed(2) + 'm';
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
function renderAll() {
|
|
1958
|
-
needsFullRender = true;
|
|
1959
|
-
visibleDirty = true;
|
|
1960
|
-
renderTree();
|
|
1961
|
-
renderStream();
|
|
1962
|
-
refreshButtons();
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
function scheduleRender() {
|
|
1966
|
-
if (!renderPending) {
|
|
1967
|
-
renderPending = true;
|
|
1968
|
-
requestAnimationFrame(() => {
|
|
1969
|
-
renderPending = false;
|
|
1970
|
-
renderTree();
|
|
1971
|
-
renderStream();
|
|
1972
|
-
refreshButtons();
|
|
1973
|
-
});
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1978
|
-
// Export modal — session selection
|
|
1979
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
1980
|
-
|
|
1981
|
-
let exportModalSelected = new Set();
|
|
1982
|
-
|
|
1983
|
-
function openExportModal() {
|
|
1984
|
-
if (sessions.length === 0) {
|
|
1985
|
-
const btn = document.getElementById('btn-export');
|
|
1986
|
-
const orig = btn.textContent;
|
|
1987
|
-
btn.textContent = '✕ 无会话';
|
|
1988
|
-
setTimeout(() => { btn.textContent = orig; }, 2000);
|
|
1989
|
-
return;
|
|
1990
|
-
}
|
|
1991
|
-
exportModalSelected = new Set(sessions.map(s => s.id));
|
|
1992
|
-
renderModalSessionList();
|
|
1993
|
-
updateModalCount();
|
|
1994
|
-
document.getElementById('export-modal').style.display = 'flex';
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
function renderModalSessionList() {
|
|
1998
|
-
const listEl = document.getElementById('modal-session-list');
|
|
1999
|
-
const sorted = [...sessions].sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
|
|
2000
|
-
listEl.innerHTML = sorted.map(s => {
|
|
2001
|
-
const color = idColor(s.colorRank || 0);
|
|
2002
|
-
const project = folderName(s.projectPath) || s.projectPath || '';
|
|
2003
|
-
const prefix = s.id.split('-')[0].toUpperCase();
|
|
2004
|
-
const model = s.model || '';
|
|
2005
|
-
const time = formatTime(s.birthtimeMs);
|
|
2006
|
-
const checked = exportModalSelected.has(s.id) ? 'checked' : '';
|
|
2007
|
-
const selectedClass = exportModalSelected.has(s.id) ? ' selected' : '';
|
|
2008
|
-
return `<div class="modal-session-row${selectedClass}" data-sid="${esc(s.id)}" onclick="toggleModalSession('${esc(s.id)}', this)">
|
|
2009
|
-
<input type="checkbox" class="modal-checkbox" data-sid="${esc(s.id)}" ${checked} onclick="event.stopPropagation(); toggleModalSession('${esc(s.id)}', this.parentElement)">
|
|
2010
|
-
<span class="modal-session-prefix" style="color:${color}">${esc(prefix)}</span>
|
|
2011
|
-
<div class="modal-session-info">
|
|
2012
|
-
<span class="modal-session-project">${esc(project)}</span>
|
|
2013
|
-
${model ? `<span class="modal-session-model">${esc(model)}</span>` : ''}
|
|
2014
|
-
</div>
|
|
2015
|
-
${time ? `<span class="modal-session-time">${esc(time)}</span>` : ''}
|
|
2016
|
-
</div>`;
|
|
2017
|
-
}).join('\n');
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
function toggleModalSession(sid, rowEl) {
|
|
2021
|
-
if (exportModalSelected.has(sid)) {
|
|
2022
|
-
exportModalSelected.delete(sid);
|
|
2023
|
-
} else {
|
|
2024
|
-
exportModalSelected.add(sid);
|
|
2025
|
-
}
|
|
2026
|
-
const checkbox = rowEl.querySelector('.modal-checkbox');
|
|
2027
|
-
checkbox.checked = exportModalSelected.has(sid);
|
|
2028
|
-
rowEl.classList.toggle('selected', exportModalSelected.has(sid));
|
|
2029
|
-
updateModalCount();
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
function exportModalToggleAll(selectAll) {
|
|
2033
|
-
if (selectAll) {
|
|
2034
|
-
exportModalSelected = new Set(sessions.map(s => s.id));
|
|
2035
|
-
} else {
|
|
2036
|
-
exportModalSelected.clear();
|
|
2037
|
-
}
|
|
2038
|
-
document.querySelectorAll('#modal-session-list .modal-session-row').forEach(row => {
|
|
2039
|
-
const sid = row.dataset.sid;
|
|
2040
|
-
const checkbox = row.querySelector('.modal-checkbox');
|
|
2041
|
-
checkbox.checked = exportModalSelected.has(sid);
|
|
2042
|
-
row.classList.toggle('selected', exportModalSelected.has(sid));
|
|
2043
|
-
});
|
|
2044
|
-
updateModalCount();
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
function updateModalCount() {
|
|
2048
|
-
const total = sessions.length;
|
|
2049
|
-
const selected = exportModalSelected.size;
|
|
2050
|
-
document.getElementById('modal-selected-count').textContent = `已选 ${selected} / ${total}`;
|
|
2051
|
-
document.getElementById('modal-export-btn').disabled = selected === 0;
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
function closeExportModal() {
|
|
2055
|
-
document.getElementById('export-modal').style.display = 'none';
|
|
2056
|
-
exportModalSelected.clear();
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
// Esc key closes modal
|
|
2060
|
-
document.addEventListener('keydown', (e) => {
|
|
2061
|
-
if (e.key === 'Escape') {
|
|
2062
|
-
const modal = document.getElementById('export-modal');
|
|
2063
|
-
if (modal.style.display !== 'none') {
|
|
2064
|
-
closeExportModal();
|
|
2065
|
-
e.stopPropagation();
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
});
|
|
2069
|
-
|
|
2070
|
-
function confirmExport() {
|
|
2071
|
-
if (exportModalSelected.size === 0) return;
|
|
2072
|
-
const selectedIds = new Set(exportModalSelected);
|
|
2073
|
-
closeExportModal();
|
|
2074
|
-
exportHTML(selectedIds);
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2078
|
-
// Export HTML
|
|
2079
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2080
|
-
|
|
2081
|
-
function exportHTML(selectedIds = null) {
|
|
2082
|
-
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
2083
|
-
|
|
2084
|
-
// Collect sessions to export
|
|
2085
|
-
let sidsInExport;
|
|
2086
|
-
if (selectedIds) {
|
|
2087
|
-
sidsInExport = selectedIds;
|
|
2088
|
-
} else {
|
|
2089
|
-
sidsInExport = new Set();
|
|
2090
|
-
for (const item of visibleItems) {
|
|
2091
|
-
if (item.sessionID) sidsInExport.add(item.sessionID);
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
const exportSessions = [];
|
|
2095
|
-
for (const sid of sidsInExport) {
|
|
2096
|
-
const s = sessionsMap.get(sid);
|
|
2097
|
-
if (s) exportSessions.push(s);
|
|
2098
|
-
}
|
|
2099
|
-
// Sort by colorRank to match the order in the tree
|
|
2100
|
-
exportSessions.sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
|
|
2101
|
-
|
|
2102
|
-
// Build session list header
|
|
2103
|
-
let sessionListHTML = '';
|
|
2104
|
-
if (exportSessions.length > 0) {
|
|
2105
|
-
const items = exportSessions.map(s => {
|
|
2106
|
-
const color = idColor(s.colorRank || 0);
|
|
2107
|
-
const project = folderName(s.projectPath) || s.projectPath || '';
|
|
2108
|
-
const model = s.model || '';
|
|
2109
|
-
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>`;
|
|
2110
|
-
}).join('\n');
|
|
2111
|
-
sessionListHTML = `<div class="export-session-list">
|
|
2112
|
-
<div class="export-session-item export-all-btn active" onclick="filterBySession(null)">全部</div>
|
|
2113
|
-
${items}
|
|
2114
|
-
</div>`;
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
// Token info
|
|
2118
|
-
computeTokensFromContext();
|
|
2119
|
-
let tokenHTML = '';
|
|
2120
|
-
if (totalInput > 0 || totalOutput > 0) {
|
|
2121
|
-
let tokStr = `Input: ${fmtTok(totalInput)} · Output: ${fmtTok(totalOutput)}`;
|
|
2122
|
-
if (totalCacheCreate > 0 || totalCacheRead > 0) tokStr += ` · Cache: ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
|
|
2123
|
-
tokenHTML = `<div class="export-meta-line" style="color:var(--dim)">Tokens: ${tokStr}</div>`;
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
// Filter state
|
|
2127
|
-
const filterState = [];
|
|
2128
|
-
if (!showThinking) filterState.push('thinking hidden');
|
|
2129
|
-
if (!showToolInput) filterState.push('tools hidden');
|
|
2130
|
-
if (!showToolOutput) filterState.push('output hidden');
|
|
2131
|
-
if (!showText) filterState.push('text hidden');
|
|
2132
|
-
if (!showHook) filterState.push('hook hidden');
|
|
2133
|
-
let filterHTML = '';
|
|
2134
|
-
if (filterState.length > 0) filterHTML = `<div class="export-meta-line" style="color:var(--dim)">Filters: ${filterState.join(', ')}</div>`;
|
|
2135
|
-
|
|
2136
|
-
// Export timestamp
|
|
2137
|
-
const now = new Date();
|
|
2138
|
-
const exportTime = fmtTimestamp(now);
|
|
2139
|
-
const timeHTML = `<div class="export-meta-line" style="color:var(--dim)">Exported: ${exportTime}</div>`;
|
|
2140
|
-
|
|
2141
|
-
// Clone stream content and strip interactive elements
|
|
2142
|
-
const clone = streamEl.cloneNode(true);
|
|
2143
|
-
clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
|
|
2144
|
-
clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
|
|
2145
|
-
|
|
2146
|
-
// Filter out stream lines from non-selected sessions
|
|
2147
|
-
if (selectedIds) {
|
|
2148
|
-
clone.querySelectorAll('[data-session-id]').forEach(el => {
|
|
2149
|
-
if (!selectedIds.has(el.dataset.sessionId)) el.remove();
|
|
2150
|
-
});
|
|
2151
|
-
}
|
|
2152
|
-
|
|
2153
|
-
// Get the cleaned innerHTML
|
|
2154
|
-
const streamHTML = clone.innerHTML;
|
|
2155
|
-
|
|
2156
|
-
// Get page CSS
|
|
2157
|
-
const pageStyleEl = document.querySelector('style');
|
|
2158
|
-
const appCSS = pageStyleEl ? pageStyleEl.textContent : '';
|
|
2159
|
-
|
|
2160
|
-
// Get highlight.js CSS from cache
|
|
2161
|
-
const hlCSS = theme === 'dark' ? hljsDarkCSS : hljsLightCSS;
|
|
2162
|
-
|
|
2163
|
-
// Export-specific CSS
|
|
2164
|
-
const exportCSS = `
|
|
2165
|
-
.export-session-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
|
|
2166
|
-
.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; }
|
|
2167
|
-
.export-session-item:hover { opacity: 1; border-color: var(--dim); }
|
|
2168
|
-
.export-session-item.active { opacity: 1; border-color: var(--purple); background: var(--purple); color: var(--white); }
|
|
2169
|
-
.export-all-btn { font-weight: 600; align-items: center; }
|
|
2170
|
-
.export-item-top { display: flex; align-items: baseline; gap: 4px; }
|
|
2171
|
-
.export-item-sid { font-family: monospace; font-size: 10px; opacity: 0.8; }
|
|
2172
|
-
.export-session-item.active .export-item-sid { opacity: 1; color: var(--white); }
|
|
2173
|
-
.export-project { font-weight: 500; }
|
|
2174
|
-
.export-model { font-size: 11px; }
|
|
2175
|
-
.export-meta-line { padding: 2px 0; font-size: 11px; }
|
|
2176
|
-
.export-header { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
|
|
2177
|
-
.export-header h1 { margin: 0 0 4px 0; font-size: 16px; color: var(--white); }
|
|
2178
|
-
`;
|
|
2179
|
-
|
|
2180
|
-
// Export-specific JS for session filtering
|
|
2181
|
-
const exportJS = `
|
|
2182
|
-
let _activeSid = null;
|
|
2183
|
-
function filterBySession(sid) {
|
|
2184
|
-
_activeSid = sid;
|
|
2185
|
-
const lines = document.querySelectorAll('#export-stream [data-session-id]');
|
|
2186
|
-
lines.forEach(el => {
|
|
2187
|
-
el.style.display = (sid === null || el.dataset.sessionId === sid) ? '' : 'none';
|
|
2188
|
-
});
|
|
2189
|
-
document.querySelectorAll('.export-session-item[data-sid]').forEach(el => {
|
|
2190
|
-
el.classList.toggle('active', sid !== null && el.dataset.sid === sid);
|
|
2191
|
-
});
|
|
2192
|
-
document.querySelector('.export-all-btn').classList.toggle('active', sid === null);
|
|
2193
|
-
}
|
|
2194
|
-
`;
|
|
2195
|
-
|
|
2196
|
-
// Assemble complete HTML document
|
|
2197
|
-
const htmlAttrs = theme === 'light' ? ' lang="en" data-theme="light"' : ' lang="en"';
|
|
2198
|
-
const fullDoc = `<!DOCTYPE html>
|
|
2199
|
-
<html${htmlAttrs}>
|
|
2200
|
-
<head>
|
|
2201
|
-
<meta charset="UTF-8">
|
|
2202
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2203
|
-
<title>claude-watch Export</title>
|
|
2204
|
-
<style>
|
|
2205
|
-
${appCSS}
|
|
2206
|
-
${hlCSS}
|
|
2207
|
-
${exportCSS}
|
|
2208
|
-
</style>
|
|
2209
|
-
</head>
|
|
2210
|
-
<body style="overflow-y:auto;height:auto">
|
|
2211
|
-
<div class="export-header">
|
|
2212
|
-
<h1>claude-watch Export</h1>
|
|
2213
|
-
${sessionListHTML}
|
|
2214
|
-
${tokenHTML}
|
|
2215
|
-
${filterHTML}
|
|
2216
|
-
${timeHTML}
|
|
2217
|
-
</div>
|
|
2218
|
-
<div id="export-stream" style="padding:8px 12px;font-size:12px">
|
|
2219
|
-
${streamHTML}
|
|
2220
|
-
</div>
|
|
2221
|
-
<script>${exportJS}<\/script>
|
|
2222
|
-
</body>
|
|
2223
|
-
</html>`;
|
|
2224
|
-
|
|
2225
|
-
// Blob download
|
|
2226
|
-
const blob = new Blob([fullDoc], { type: 'text/html;charset=utf-8' });
|
|
2227
|
-
const url = URL.createObjectURL(blob);
|
|
2228
|
-
const a = document.createElement('a');
|
|
2229
|
-
|
|
2230
|
-
let filePrefix;
|
|
2231
|
-
if (sidsInExport.size === 1) {
|
|
2232
|
-
filePrefix = [...sidsInExport][0].split('-')[0].toUpperCase();
|
|
2233
|
-
} else {
|
|
2234
|
-
filePrefix = 'multi';
|
|
2235
|
-
}
|
|
2236
|
-
const pad = (n, len) => String(n).padStart(len, '0');
|
|
2237
|
-
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)}`;
|
|
2238
|
-
a.download = `claude-watch-${filePrefix}-${ts}.html`;
|
|
2239
|
-
a.href = url;
|
|
2240
|
-
document.body.appendChild(a);
|
|
2241
|
-
a.click();
|
|
2242
|
-
document.body.removeChild(a);
|
|
2243
|
-
URL.revokeObjectURL(url);
|
|
2244
|
-
|
|
2245
|
-
// Visual feedback
|
|
2246
|
-
const btn = document.getElementById('btn-export');
|
|
2247
|
-
const orig = btn.textContent;
|
|
2248
|
-
btn.textContent = '✓';
|
|
2249
|
-
setTimeout(() => { btn.textContent = orig; }, 2000);
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2253
|
-
// Theme toggle
|
|
2254
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2255
|
-
|
|
2256
|
-
function applyTheme(theme) {
|
|
2257
|
-
document.documentElement.setAttribute('data-theme', theme);
|
|
2258
|
-
const btn = document.getElementById('btn-theme');
|
|
2259
|
-
if (btn) {
|
|
2260
|
-
btn.textContent = theme === 'dark' ? '🌙' : '☀️';
|
|
2261
|
-
btn.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light' : 'Switch to dark');
|
|
2262
|
-
}
|
|
2263
|
-
// Swap highlight.js stylesheet for theme
|
|
2264
|
-
const hlLink = document.querySelector('link[rel="stylesheet"][href*="github"]');
|
|
2265
|
-
if (hlLink) {
|
|
2266
|
-
hlLink.href = theme === 'dark' ? 'vendor/github-dark.min.css' : 'vendor/github-light.min.css';
|
|
2267
|
-
}
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
function toggleTheme() {
|
|
2271
|
-
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
2272
|
-
const next = current === 'dark' ? 'light' : 'dark';
|
|
2273
|
-
localStorage.setItem('theme', next);
|
|
2274
|
-
applyTheme(next);
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
// Apply saved theme on load (default dark)
|
|
2278
|
-
(function() {
|
|
2279
|
-
const saved = localStorage.getItem('theme');
|
|
2280
|
-
applyTheme(saved || 'dark');
|
|
2281
|
-
})();
|
|
2282
|
-
|
|
2283
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2284
|
-
// Init
|
|
2285
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
2286
|
-
|
|
2287
|
-
// Apply collapse-after from URL param
|
|
2288
|
-
const urlParams = new URLSearchParams(location.search);
|
|
2289
|
-
const ca = urlParams.get('collapseAfter');
|
|
2290
|
-
if (ca) {
|
|
2291
|
-
applyCollapsePolicy(parseInt(ca) || 0);
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
connect();
|
|
2295
|
-
</script>
|
|
121
|
+
<script src="js/shared.js"></script>
|
|
122
|
+
<script src="js/stream.js"></script>
|
|
123
|
+
<script src="js/token.js"></script>
|
|
124
|
+
<script src="js/app.js"></script>
|
|
2296
125
|
</body>
|
|
2297
126
|
</html>
|