claude-mission-control 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2045 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Claude Dashboard</title>
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <link rel="manifest" href="/manifest.webmanifest">
9
+ <meta name="theme-color" content="#0d1215" media="(prefers-color-scheme: dark)">
10
+ <meta name="theme-color" content="#f5f4f0" media="(prefers-color-scheme: light)">
11
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='2' fill='%230d1215'/%3E%3Crect y='7.6' width='16' height='0.8' fill='%23000'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23ffb020' font-family='monospace'%3E%E2%9D%AF%3C/text%3E%3C/svg%3E">
12
+ <style>
13
+ @font-face {
14
+ font-family: 'JetBrains Mono';
15
+ src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
16
+ font-weight: 400; font-style: normal; font-display: swap;
17
+ }
18
+ @font-face {
19
+ font-family: 'JetBrains Mono';
20
+ src: url('/fonts/JetBrainsMono-Medium.woff2') format('woff2');
21
+ font-weight: 500; font-style: normal; font-display: swap;
22
+ }
23
+ @font-face {
24
+ font-family: 'JetBrains Mono';
25
+ src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
26
+ font-weight: 700; font-style: normal; font-display: swap;
27
+ }
28
+ @font-face {
29
+ font-family: 'Oswald';
30
+ src: url('/fonts/Oswald-Variable.woff2') format('woff2');
31
+ font-weight: 200 700; font-style: normal; font-display: swap;
32
+ }
33
+ /* Departures board: blue-black enamel, ivory flap letters, amber is the
34
+ loudest color (needs you), green is a small "boarding" lamp, red means
35
+ broken. Oswald condensed caps carry names; JetBrains Mono carries data.
36
+ Light theme is the same timetable, printed on paper. */
37
+ :root {
38
+ --bg: #f5f4f0;
39
+ --surface: #ecebe5;
40
+ --surface2: #e0dfd8;
41
+ --ink: #1b2022;
42
+ --muted: #596264;
43
+ --faint: #8d9496;
44
+ --line: #d5d4cc;
45
+ --accent: #1d7a44;
46
+ --warn: #a66a00;
47
+ --bad: #bd3524;
48
+ --chip-bg: #e0dfd8;
49
+ --warn-bg: rgba(166, 106, 0, 0.10);
50
+ --accent-bg: rgba(29, 122, 68, 0.08);
51
+ --glow: none;
52
+ --flap: #ecebe5;
53
+ --flap-hi: #f2f1ec;
54
+ --flap-split: rgba(0, 0, 0, 0.10);
55
+ --amber-ink: #f5f4f0;
56
+ }
57
+ @media (prefers-color-scheme: dark) {
58
+ :root {
59
+ --bg: #0d1215;
60
+ --surface: #151d22;
61
+ --surface2: #1f292f;
62
+ --ink: #e9e4d6;
63
+ --muted: #93a09e;
64
+ --faint: #5d6b6b;
65
+ --line: #202b31;
66
+ --accent: #46c073;
67
+ --warn: #ffb020;
68
+ --bad: #ff5f45;
69
+ --chip-bg: #1f292f;
70
+ --warn-bg: rgba(255, 176, 32, 0.12);
71
+ --accent-bg: rgba(70, 192, 115, 0.10);
72
+ --glow: 0 0 7px;
73
+ --flap: #151d22;
74
+ --flap-hi: #1c262c;
75
+ --flap-split: rgba(0, 0, 0, 0.45);
76
+ --amber-ink: #0d1215;
77
+ }
78
+ }
79
+ * { box-sizing: border-box; margin: 0; }
80
+ html { -webkit-text-size-adjust: 100%; }
81
+ body {
82
+ background: var(--bg);
83
+ color: var(--ink);
84
+ font: 12.5px/1.5 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, monospace;
85
+ padding-bottom: 48px;
86
+ }
87
+ .mono { font-family: inherit; }
88
+ ::selection { background: var(--warn); color: var(--amber-ink); }
89
+
90
+ /* Display face: condensed caps, the board's letter-painted voice. */
91
+ .disp {
92
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
93
+ text-transform: uppercase; letter-spacing: 0.05em; font-weight: 500;
94
+ }
95
+ /* Split-flap cell: gradient face with the horizontal split line. */
96
+ .flap {
97
+ position: relative; display: inline-flex; align-items: center;
98
+ padding: 2px 9px; border-radius: 2px;
99
+ background: linear-gradient(var(--flap-hi) 0%, var(--flap) 46%, var(--flap) 54%, var(--flap-hi) 100%);
100
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.14);
101
+ white-space: nowrap;
102
+ }
103
+ .flap::after {
104
+ content: ''; position: absolute; left: 0; right: 0; top: 50%;
105
+ height: 1px; background: var(--flap-split); pointer-events: none;
106
+ }
107
+ @keyframes flapflip { 0% { transform: rotateX(85deg); } 100% { transform: rotateX(0); } }
108
+ .flap.flip { animation: flapflip 0.3s ease-out; transform-origin: 50% 50%; }
109
+ @media (prefers-reduced-motion: reduce) { .flap.flip { animation: none; } }
110
+
111
+ /* ---------- header: the station sign ---------- */
112
+ header {
113
+ display: flex; align-items: center; gap: 16px;
114
+ padding: 12px 20px;
115
+ border-bottom: 2px solid var(--line);
116
+ background: var(--surface);
117
+ }
118
+ header h1 {
119
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
120
+ font-size: 19px; font-weight: 600; letter-spacing: 0.10em;
121
+ text-transform: uppercase; white-space: nowrap;
122
+ }
123
+ header h1 .tilde { color: var(--warn); }
124
+ #clock {
125
+ font-size: 14px; font-weight: 700; color: var(--ink);
126
+ letter-spacing: 0.08em; font-variant-numeric: tabular-nums;
127
+ }
128
+ #conn {
129
+ width: 7px; height: 7px; border-radius: 50%;
130
+ background: var(--faint); flex: none;
131
+ }
132
+ #conn.on { background: var(--accent); box-shadow: var(--glow) var(--accent); }
133
+ #conn.retry { background: var(--warn); }
134
+ .spacer { flex: 1; }
135
+
136
+ #quota { display: flex; gap: 18px; align-items: center; font-size: 12px; color: var(--muted); }
137
+ #quota.stale { opacity: 0.45; }
138
+ .meter { display: flex; align-items: center; gap: 7px; }
139
+ .meter .bar {
140
+ width: 72px; height: 5px; border-radius: 3px;
141
+ background: var(--surface2); overflow: hidden;
142
+ }
143
+ .meter .fill { height: 100%; border-radius: 3px; background: var(--accent); }
144
+ .meter .fill.hot { background: var(--warn); }
145
+ .meter .fill.crit { background: var(--bad); }
146
+
147
+ /* ---------- live rail: the departures rows ---------- */
148
+ #live {
149
+ position: sticky; top: 0; z-index: 5;
150
+ background: var(--bg);
151
+ padding: 14px 20px 14px;
152
+ border-bottom: 2px solid var(--line);
153
+ }
154
+ #live .rail-label {
155
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
156
+ font-size: 12px; text-transform: uppercase; letter-spacing: 0.22em;
157
+ color: var(--faint); margin-bottom: 10px;
158
+ }
159
+ #live .rows { display: flex; flex-direction: column; gap: 5px; }
160
+ .dep-row {
161
+ display: grid;
162
+ grid-template-columns: 52px minmax(150px, 230px) 1fr max-content;
163
+ gap: 2px 16px; align-items: center;
164
+ background: var(--surface);
165
+ border-radius: 2px;
166
+ padding: 8px 14px;
167
+ }
168
+ .dep-row.waiting { background: var(--warn-bg); box-shadow: inset 2px 0 0 var(--warn); }
169
+ .dep-time { color: var(--faint); font-size: 12px; font-variant-numeric: tabular-nums; }
170
+ .dep-proj {
171
+ font-size: 19px; line-height: 1.15; color: var(--ink);
172
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
173
+ min-width: 0;
174
+ }
175
+ .dep-proj .badge { vertical-align: 3px; }
176
+ .dep-mid { min-width: 0; }
177
+ .dep-title {
178
+ font-size: 12.5px; color: var(--muted); cursor: pointer;
179
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
180
+ }
181
+ .dep-title:hover { color: var(--ink); }
182
+ .dep-task {
183
+ margin-top: 2px; font-size: 11.5px; color: var(--ink);
184
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
185
+ }
186
+ .dep-task .prompt-ch { color: var(--accent); margin-right: 6px; }
187
+ .dep-task .count { color: var(--faint); margin-left: 6px; }
188
+ .dep-right { display: flex; align-items: center; gap: 10px; justify-self: end; }
189
+ .dep-elapsed { color: var(--faint); font-size: 12px; }
190
+ .st { font-size: 11.5px; letter-spacing: 0.14em; padding: 3px 10px 2px; }
191
+ .st.st-busy { color: var(--accent); }
192
+ .st.st-waiting {
193
+ background: var(--warn); color: var(--amber-ink); font-weight: 600;
194
+ box-shadow: var(--glow) var(--warn);
195
+ }
196
+ .st.st-waiting::after { background: rgba(0, 0, 0, 0.22); }
197
+ .st.st-quiet { color: var(--warn); }
198
+ @media (max-width: 700px) {
199
+ .dep-row { grid-template-columns: 44px 1fr max-content; }
200
+ .dep-mid { grid-column: 1 / -1; }
201
+ }
202
+ .live-none { color: var(--faint); font-size: 13px; }
203
+ .live-none code { color: var(--muted); }
204
+
205
+ /* ---------- project grid ---------- */
206
+ main { padding: 18px 20px; }
207
+ #grid {
208
+ display: grid; gap: 14px;
209
+ grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
210
+ }
211
+ .card {
212
+ background: var(--surface);
213
+ border-radius: 2px;
214
+ border-top: 2px solid var(--line);
215
+ padding: 12px 16px 10px;
216
+ display: flex; flex-direction: column;
217
+ }
218
+ .card .head { display: flex; align-items: center; gap: 8px; }
219
+ .card .name {
220
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
221
+ text-transform: uppercase; letter-spacing: 0.05em;
222
+ font-weight: 500; font-size: 15px;
223
+ }
224
+ .card .live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; box-shadow: var(--glow) var(--accent); }
225
+ .card .spark { margin-left: auto; }
226
+ .card .meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 7px; font-size: 11px; }
227
+ .badge {
228
+ padding: 0 6px; border-radius: 2px; background: var(--chip-bg); color: var(--muted);
229
+ font-family: inherit; font-size: 11px;
230
+ }
231
+ .badge.dirty { color: var(--warn); }
232
+ .badge.ahead { color: var(--accent); }
233
+ .badge.err { color: var(--bad); }
234
+ .badge.missing { color: var(--bad); }
235
+ .card .when { color: var(--faint); font-size: 11px; margin-left: auto; }
236
+ .sessions { list-style: none; padding: 0; margin: 10px 0 0; border-top: 1px solid var(--line); }
237
+ .sessions li {
238
+ display: flex; align-items: center; gap: 8px;
239
+ padding: 7px 0; border-bottom: 1px solid var(--line);
240
+ font-size: 12.5px;
241
+ }
242
+ .sessions li:last-child { border-bottom: none; }
243
+ .sessions .s-title {
244
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
245
+ color: var(--ink); flex: 1;
246
+ }
247
+ .sessions .s-title .wt { color: var(--accent); font-size: 11px; margin-right: 5px; }
248
+ .sessions .s-when { color: var(--faint); font-size: 11px; flex: none; }
249
+ .copy {
250
+ flex: none; border: none; background: none; color: var(--faint);
251
+ font-size: 11px; cursor: pointer; padding: 2px 6px; border-radius: 5px;
252
+ font-family: inherit;
253
+ }
254
+ .copy:hover { background: var(--surface2); color: var(--ink); }
255
+ .copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
256
+ .open-btn { color: var(--accent); }
257
+ #open-pref-wrap { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
258
+ #open-pref {
259
+ font: inherit; color: var(--ink);
260
+ background: var(--surface2); border: 1px solid var(--line);
261
+ border-radius: 2px; padding: 2px 6px;
262
+ }
263
+ #search {
264
+ font: inherit; color: var(--ink);
265
+ background: var(--surface2); border: 1px solid var(--line);
266
+ border-radius: 2px; padding: 4px 10px; width: 190px; margin-left: 14px;
267
+ }
268
+ #search:focus { outline: 2px solid var(--accent); outline-offset: 0; }
269
+ #search::placeholder { color: var(--faint); }
270
+ #gear, #mc-btn {
271
+ border: none; background: none; color: var(--muted); cursor: pointer;
272
+ font-size: 15px; padding: 2px 6px; border-radius: 2px; margin-left: 4px;
273
+ }
274
+ #gear:hover, #mc-btn:hover { background: var(--surface2); color: var(--ink); }
275
+
276
+ /* settings drawer */
277
+ .set-row {
278
+ display: flex; align-items: center; gap: 10px;
279
+ padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 13px;
280
+ }
281
+ .set-row:last-child { border-bottom: none; }
282
+ .set-row .set-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
283
+ .set-row .set-sub { color: var(--faint); font-size: 11px; display: block;
284
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
285
+ .set-row input[type="text"] {
286
+ font: inherit; color: var(--ink);
287
+ background: var(--surface2); border: 1px solid var(--line);
288
+ border-radius: 2px; padding: 4px 8px; width: 170px;
289
+ }
290
+ .set-row select {
291
+ font: inherit; color: var(--ink); background: var(--surface2);
292
+ border: 1px solid var(--line); border-radius: 2px; padding: 3px 6px;
293
+ }
294
+ .set-row input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; }
295
+
296
+ /* clickable session titles */
297
+ .s-title[data-session], .di-title[data-session] { cursor: pointer; }
298
+ .s-title[data-session]:hover, .di-title[data-session]:hover { color: var(--accent); }
299
+
300
+ /* transcript rendering */
301
+ .t-turn { margin: 10px 0; }
302
+ .t-user {
303
+ background: var(--accent-bg); border: 1px solid var(--line);
304
+ border-radius: 3px; padding: 8px 12px; font-size: 13px;
305
+ white-space: pre-wrap; overflow-wrap: break-word;
306
+ }
307
+ .t-user .t-who { color: var(--accent); font-size: 10px; text-transform: uppercase;
308
+ letter-spacing: 0.08em; display: block; margin-bottom: 3px; }
309
+ .t-tool {
310
+ font-family: inherit; font-size: 11.5px;
311
+ color: var(--muted); padding: 1px 0 1px 10px;
312
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
313
+ }
314
+ .t-tool .t-tname { color: var(--accent); }
315
+ .t-note {
316
+ border-left: 3px solid var(--warn); padding: 6px 10px; margin: 10px 0;
317
+ color: var(--muted); font-size: 12.5px; background: var(--warn-bg); border-radius: 0 2px 2px 0;
318
+ }
319
+ .t-ts { color: var(--faint); font-size: 10px; margin: 12px 0 2px; }
320
+
321
+ /* header weekly trend */
322
+ #wk-chart rect:hover { opacity: 0.75; }
323
+ .none-yet { color: var(--faint); font-size: 12px; padding: 10px 0 4px; }
324
+
325
+ /* ---------- pinned strip ---------- */
326
+ #pinned { margin-bottom: 16px; }
327
+ #pinned .pin-label {
328
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
329
+ font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.22em;
330
+ color: var(--faint); margin-bottom: 6px;
331
+ }
332
+ #pinned .pin-row {
333
+ display: flex; align-items: baseline; gap: 10px;
334
+ background: var(--surface); border-radius: 2px;
335
+ padding: 7px 14px; margin-bottom: 4px;
336
+ }
337
+ #pinned .pin-row .star { color: var(--warn); flex: none; }
338
+ .s-role { color: var(--faint); margin-right: 4px; }
339
+
340
+ /* ---------- git attention strip ---------- */
341
+ #git-attn {
342
+ display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 14px;
343
+ background: var(--warn-bg);
344
+ border: 1px solid var(--line); border-radius: 3px;
345
+ padding: 8px 14px; margin-bottom: 16px;
346
+ font-size: 12.5px;
347
+ }
348
+ #git-attn .ga-label { color: var(--warn); font-weight: 600; white-space: nowrap; }
349
+ #git-attn .ga-item { color: var(--ink); white-space: nowrap; }
350
+ #git-attn .ga-item .badge { margin-left: 4px; }
351
+
352
+ /* ---------- digest ---------- */
353
+ #digest { margin-bottom: 20px; }
354
+ #digest summary {
355
+ cursor: pointer; user-select: none; list-style: none;
356
+ display: flex; align-items: center; gap: 12px;
357
+ }
358
+ #digest summary::-webkit-details-marker { display: none; }
359
+ #digest-label {
360
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
361
+ font-size: 12px; text-transform: uppercase; letter-spacing: 0.22em;
362
+ color: var(--faint);
363
+ }
364
+ #digest-label::before { content: '▸ '; }
365
+ #digest[open] #digest-label::before { content: '▾ '; }
366
+ #digest-range { display: none; gap: 2px; }
367
+ #digest[open] #digest-range { display: inline-flex; }
368
+ #digest-range button {
369
+ font: 11px 'JetBrains Mono', ui-monospace, monospace; color: var(--muted);
370
+ background: none; border: 1px solid var(--line); cursor: pointer;
371
+ padding: 2px 9px;
372
+ }
373
+ #digest-range button:first-child { border-radius: 6px 0 0 6px; }
374
+ #digest-range button:last-child { border-radius: 0 2px 2px 0; }
375
+ #digest-range button.sel { background: var(--surface2); color: var(--ink); }
376
+ #digest-body { margin-top: 10px; }
377
+ .digest-day {
378
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
379
+ color: var(--faint); font-size: 11.5px; text-transform: uppercase;
380
+ letter-spacing: 0.22em; margin: 16px 0 6px;
381
+ }
382
+ .digest-item {
383
+ background: var(--surface); border-radius: 2px;
384
+ padding: 9px 14px; margin-bottom: 5px;
385
+ }
386
+ .digest-item .di-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
387
+ .digest-item .di-proj {
388
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
389
+ text-transform: uppercase; letter-spacing: 0.05em;
390
+ font-weight: 500; font-size: 14.5px;
391
+ }
392
+ .digest-item .di-title { color: var(--muted); font-size: 13px;
393
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 140px; }
394
+ .digest-item .di-when { color: var(--faint); font-size: 11px; }
395
+ .digest-item .di-summary {
396
+ margin-top: 6px; font-size: 12.5px; color: var(--ink); line-height: 1.5;
397
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
398
+ cursor: pointer;
399
+ }
400
+ .digest-item .di-summary.expanded { display: block; }
401
+ .digest-item .di-tasks { margin-top: 5px; font-size: 11px; color: var(--faint); }
402
+ .digest-item .di-tasks .done { color: var(--accent); }
403
+ .digest-empty { color: var(--faint); font-size: 12.5px; padding: 6px 0; }
404
+
405
+ /* ---------- dormant ---------- */
406
+ #dormant { margin-top: 26px; }
407
+ #dormant summary {
408
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
409
+ cursor: pointer; color: var(--faint); font-size: 12px;
410
+ text-transform: uppercase; letter-spacing: 0.22em; user-select: none;
411
+ }
412
+ #dormant .cards { margin-top: 12px; display: grid; gap: 14px;
413
+ grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); }
414
+ #dormant .card { opacity: 0.75; }
415
+
416
+ /* ---------- mission control ---------- */
417
+ #mc {
418
+ position: fixed; inset: 0; z-index: 15;
419
+ background: var(--bg);
420
+ display: flex; flex-direction: column;
421
+ }
422
+ #mc[hidden] { display: none !important; }
423
+ .mc-head {
424
+ display: flex; align-items: center; gap: 14px;
425
+ padding: 12px 20px; border-bottom: 2px solid var(--line);
426
+ background: var(--surface); flex: none;
427
+ }
428
+ .mc-title {
429
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
430
+ font-size: 16px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase;
431
+ }
432
+ .mc-sub { color: var(--faint); font-size: 12px; }
433
+ #mc-grid {
434
+ flex: 1; overflow-y: auto;
435
+ display: grid; gap: 10px; padding: 14px 20px;
436
+ grid-template-columns: repeat(auto-fit, minmax(430px, 1fr));
437
+ align-content: start;
438
+ }
439
+ #mc-grid .mc-empty { color: var(--faint); font-size: 13px; padding: 24px 0; }
440
+ .mc-pane {
441
+ display: flex; flex-direction: column;
442
+ background: var(--surface); border-radius: 2px;
443
+ height: min(46vh, 480px); min-width: 0;
444
+ }
445
+ .mc-pane.waiting { box-shadow: inset 0 2px 0 var(--warn); }
446
+ .mc-pane-head {
447
+ display: flex; align-items: center; gap: 10px;
448
+ padding: 8px 12px; border-bottom: 1px solid var(--line); flex: none;
449
+ min-width: 0;
450
+ }
451
+ .mc-pane-head .mc-proj {
452
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
453
+ text-transform: uppercase; letter-spacing: 0.05em;
454
+ font-weight: 500; font-size: 15px; cursor: pointer;
455
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
456
+ }
457
+ .mc-pane-head .mc-proj:hover { color: var(--warn); }
458
+ .mc-pane-head .mc-meta { color: var(--faint); font-size: 11px; white-space: nowrap; }
459
+ .mc-pane-head .spacer { flex: 1; min-width: 6px; }
460
+ .mc-pane-task {
461
+ padding: 4px 12px; border-bottom: 1px solid var(--line); flex: none;
462
+ font-size: 11.5px; color: var(--muted);
463
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
464
+ }
465
+ .mc-pane-task .prompt-ch { color: var(--accent); margin-right: 6px; }
466
+ .mc-pane-body { flex: 1; overflow-y: auto; padding: 6px 12px 10px; font-size: 12px; }
467
+ .mc-pane-body .t-turn { margin-top: 8px; }
468
+
469
+ /* ---------- project detail drawer ---------- */
470
+ #drawer-backdrop {
471
+ position: fixed; inset: 0; z-index: 20;
472
+ background: rgba(0, 0, 0, 0.35);
473
+ }
474
+ /* display:flex below would otherwise override the hidden attribute */
475
+ #drawer[hidden], #drawer-backdrop[hidden] { display: none !important; }
476
+ #drawer {
477
+ position: fixed; top: 0; right: 0; bottom: 0; z-index: 21;
478
+ width: min(760px, 92vw);
479
+ background: var(--bg); border-left: 1px solid var(--line);
480
+ display: flex; flex-direction: column;
481
+ box-shadow: -16px 0 48px rgba(0, 0, 0, 0.35);
482
+ }
483
+ #drawer .d-head {
484
+ display: flex; align-items: baseline; gap: 10px;
485
+ padding: 16px 22px 12px; border-bottom: 1px solid var(--line);
486
+ }
487
+ #drawer .d-head h2 {
488
+ font-family: 'Oswald', 'Arial Narrow', sans-serif;
489
+ font-size: 17px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
490
+ }
491
+ #drawer .d-head .d-path {
492
+ color: var(--faint); font-size: 11px;
493
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
494
+ }
495
+ #drawer .d-actions { display: flex; gap: 2px; align-items: center; flex: none; }
496
+ #drawer .d-close {
497
+ border: none; background: none; color: var(--muted); cursor: pointer;
498
+ font-size: 18px; padding: 2px 8px; border-radius: 2px;
499
+ }
500
+ #drawer .d-close:hover { background: var(--surface2); color: var(--ink); }
501
+ #drawer .d-body { overflow-y: auto; padding: 6px 22px 40px; }
502
+ .d-section { margin-top: 18px; }
503
+ .d-section > summary {
504
+ cursor: pointer; user-select: none; list-style: none;
505
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
506
+ color: var(--faint); padding: 4px 0;
507
+ }
508
+ .d-section > summary::before { content: '▸ '; }
509
+ .d-section[open] > summary::before { content: '▾ '; }
510
+ .d-section .d-empty { color: var(--faint); font-size: 12.5px; padding: 4px 0; }
511
+ .d-file {
512
+ background: var(--surface); border: 1px solid var(--line); border-radius: 3px;
513
+ padding: 12px 16px; margin-top: 8px;
514
+ }
515
+ .d-file .d-file-name {
516
+ font-family: inherit;
517
+ font-size: 12px; color: var(--accent); margin-bottom: 6px;
518
+ }
519
+ .d-sessions li { display: flex; align-items: center; gap: 8px;
520
+ padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 12.5px; }
521
+ .d-sessions li:last-child { border-bottom: none; }
522
+ .d-sessions { list-style: none; padding: 0; margin: 6px 0 0; }
523
+
524
+ /* rendered markdown */
525
+ .md { font-size: 13px; line-height: 1.55; color: var(--ink); overflow-wrap: break-word; }
526
+ .md h1, .md h2, .md h3, .md h4 { margin: 12px 0 4px; line-height: 1.3; }
527
+ .md h1 { font-size: 15px; } .md h2 { font-size: 14px; } .md h3, .md h4 { font-size: 13px; }
528
+ .md p { margin: 6px 0; }
529
+ .md ul, .md ol { margin: 6px 0; padding-left: 22px; }
530
+ .md li { margin: 2px 0; }
531
+ .md code {
532
+ font-family: inherit; font-size: 12px;
533
+ background: var(--surface2); padding: 1px 4px; border-radius: 4px;
534
+ }
535
+ .md pre {
536
+ background: var(--surface2); border-radius: 2px; padding: 10px 12px;
537
+ overflow-x: auto; margin: 8px 0;
538
+ }
539
+ .md pre code { background: none; padding: 0; }
540
+ .md blockquote { border-left: 3px solid var(--line); margin: 6px 0; padding-left: 10px; color: var(--muted); }
541
+ .md a { color: var(--accent); }
542
+ .md hr { border: none; border-top: 1px solid var(--line); margin: 10px 0; }
543
+
544
+ .hl-flash { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
545
+
546
+ /* ---------- stats drawer ---------- */
547
+ #wk-chart { cursor: pointer; }
548
+ .heat {
549
+ display: grid; grid-auto-flow: column;
550
+ grid-template-rows: repeat(7, 11px); gap: 3px;
551
+ margin-top: 10px; overflow-x: auto; padding-bottom: 4px;
552
+ }
553
+ .heat .hc { width: 11px; height: 11px; border-radius: 2px; background: var(--surface2); }
554
+ .wh { display: grid; grid-template-columns: 28px repeat(24, 1fr); gap: 3px; margin-top: 10px; align-items: center; }
555
+ .wh .wh-day { color: var(--faint); font-size: 10px; }
556
+ .wh .hc { height: 11px; border-radius: 2px; background: var(--surface2); }
557
+ .hours { display: flex; align-items: flex-end; gap: 3px; height: 46px; margin-top: 10px; }
558
+ .hours .hb { flex: 1; background: var(--accent); border-radius: 1px 1px 0 0; min-height: 2px; }
559
+ .hours-labels { display: flex; justify-content: space-between; color: var(--faint); font-size: 10px; margin-top: 3px; }
560
+ .stat-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 12px; }
561
+ .stat-table td { padding: 5px 0; border-bottom: 1px solid var(--line); }
562
+ .stat-table td:last-child, .stat-table td:nth-child(2) { text-align: right; color: var(--muted); }
563
+ .stat-totals { color: var(--muted); font-size: 12px; margin-top: 12px; }
564
+ .stat-totals strong { color: var(--ink); }
565
+
566
+ /* ---------- command palette ---------- */
567
+ #palette-backdrop { position: fixed; inset: 0; z-index: 30; background: rgba(0,0,0,0.4); }
568
+ #palette {
569
+ position: fixed; top: 12vh; left: 50%; transform: translateX(-50%); z-index: 31;
570
+ width: min(620px, 92vw);
571
+ background: var(--surface); border: 1px solid var(--line); border-radius: 4px;
572
+ box-shadow: 0 24px 64px rgba(0,0,0,0.45);
573
+ }
574
+ #palette input {
575
+ width: 100%; font: 14px 'JetBrains Mono', ui-monospace, monospace;
576
+ background: none; border: none; outline: none; color: var(--ink);
577
+ padding: 14px 16px; border-bottom: 1px solid var(--line);
578
+ }
579
+ #palette-list { list-style: none; margin: 0; padding: 6px 0; max-height: 46vh; overflow-y: auto; }
580
+ #palette-list li {
581
+ display: flex; align-items: baseline; gap: 10px;
582
+ padding: 7px 16px; font-size: 12.5px; cursor: pointer;
583
+ }
584
+ #palette-list li .pk { color: var(--accent); flex: none; width: 14px; }
585
+ #palette-list li .pl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
586
+ #palette-list li .ph { color: var(--faint); font-size: 11px; flex: none; }
587
+ #palette-list li.sel { background: var(--accent-bg); }
588
+ #palette-list li.sel .pl { color: var(--accent); }
589
+ #palette-empty { color: var(--faint); font-size: 12px; padding: 12px 16px; }
590
+
591
+ /* welcome card for fresh installs */
592
+ #welcome {
593
+ grid-column: 1 / -1; background: var(--surface); border: 1px solid var(--line);
594
+ border-radius: 3px; padding: 26px 30px; line-height: 1.7;
595
+ }
596
+ #welcome h2 { font-size: 14px; margin-bottom: 8px; }
597
+ #welcome h2 .tilde { color: var(--accent); }
598
+ #welcome code { background: var(--surface2); padding: 1px 5px; border-radius: 2px; }
599
+ #welcome p { color: var(--muted); font-size: 12.5px; }
600
+
601
+ #toast {
602
+ position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
603
+ background: var(--ink); color: var(--bg);
604
+ padding: 7px 14px; border-radius: 2px; font-size: 12.5px;
605
+ opacity: 0; transition: opacity 0.18s; pointer-events: none;
606
+ }
607
+ #toast.show { opacity: 1; }
608
+ </style>
609
+ </head>
610
+ <body>
611
+ <header>
612
+ <div id="conn" title="connection"></div>
613
+ <h1>Claude<span class="tilde"> · </span>Dashboard</h1>
614
+ <input id="search" type="search" placeholder="Search sessions… ⏎" autocomplete="off">
615
+ <div class="spacer"></div>
616
+ <label id="open-pref-wrap">Open in
617
+ <select id="open-pref">
618
+ <option value="terminal">Terminal</option>
619
+ </select>
620
+ </label>
621
+ <div id="quota"></div>
622
+ <span id="clock" class="flap" title="board time"></span>
623
+ <button id="mc-btn" title="Mission Control — every live session at once">▦</button>
624
+ <button id="gear" title="Settings">⚙</button>
625
+ </header>
626
+
627
+ <section id="live"></section>
628
+ <main>
629
+ <div id="pinned" hidden></div>
630
+ <div id="git-attn" hidden></div>
631
+ <details id="digest" open>
632
+ <summary>
633
+ <span id="digest-label">Digest</span>
634
+ <span id="digest-range" role="group" aria-label="digest window">
635
+ <button data-days="1">day</button><button data-days="3">3 days</button><button data-days="7">week</button>
636
+ </span>
637
+ </summary>
638
+ <div id="digest-body"></div>
639
+ </details>
640
+ <div id="grid"></div>
641
+ <details id="dormant" hidden>
642
+ <summary></summary>
643
+ <div class="cards"></div>
644
+ </details>
645
+ </main>
646
+ <div id="toast"></div>
647
+ <div id="palette-backdrop" hidden></div>
648
+ <div id="palette" hidden>
649
+ <input id="palette-input" type="text" placeholder="Jump to a project, session, or action…" autocomplete="off">
650
+ <ul id="palette-list"></ul>
651
+ </div>
652
+ <div id="mc" hidden>
653
+ <div class="mc-head">
654
+ <span class="mc-title">Mission Control</span>
655
+ <span class="mc-sub" id="mc-sub"></span>
656
+ <span class="spacer"></span>
657
+ <button class="d-close" id="mc-close" title="Close (Esc)">✕</button>
658
+ </div>
659
+ <div id="mc-grid"></div>
660
+ </div>
661
+ <div id="drawer-backdrop" hidden></div>
662
+ <aside id="drawer" hidden>
663
+ <div class="d-head">
664
+ <h2 id="d-title"></h2>
665
+ <span class="d-path" id="d-path"></span>
666
+ <span class="d-actions" id="d-actions"></span>
667
+ <button class="d-close" id="d-close" title="Close (Esc)">✕</button>
668
+ </div>
669
+ <div class="d-body" id="d-body"></div>
670
+ </aside>
671
+
672
+ <script>
673
+ 'use strict';
674
+
675
+ const $ = (s, el) => (el || document).querySelector(s);
676
+
677
+ // Write innerHTML only when the generated HTML actually changed — rebuilding
678
+ // identical DOM every SSE tick is what caused hover flicker.
679
+ function patch(el, html) {
680
+ if (el._h !== html) {
681
+ el._h = html;
682
+ el.innerHTML = html;
683
+ }
684
+ }
685
+
686
+ // Board clock. The cell flips on the minute, like everything else up there.
687
+ function tickClock() {
688
+ const el = $('#clock');
689
+ const t = new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
690
+ if (el.textContent !== t) {
691
+ el.textContent = t;
692
+ el.classList.remove('flip');
693
+ void el.offsetWidth;
694
+ el.classList.add('flip');
695
+ }
696
+ }
697
+ setInterval(tickClock, 1000);
698
+ tickClock();
699
+
700
+ function esc(s) {
701
+ return String(s ?? '')
702
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
703
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
704
+ }
705
+
706
+ function relTime(ms) {
707
+ if (!ms) return '';
708
+ const d = Date.now() - ms;
709
+ const m = Math.floor(d / 60000);
710
+ if (m < 1) return 'now';
711
+ if (m < 60) return m + 'm ago';
712
+ const h = Math.floor(m / 60);
713
+ if (h < 24) return h + 'h ago';
714
+ const days = Math.floor(h / 24);
715
+ if (days < 30) return days + 'd ago';
716
+ const mo = Math.floor(days / 30);
717
+ return mo + 'mo ago';
718
+ }
719
+
720
+ function elapsed(ms) {
721
+ if (!ms) return '';
722
+ const s = Math.floor((Date.now() - ms) / 1000);
723
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
724
+ return h ? `${h}h ${m}m` : `${m}m`;
725
+ }
726
+
727
+ // 14-day activity sparkline: thin columns, native tooltips per day.
728
+ function sparkSVG(counts) {
729
+ const w = 84, h = 26, n = counts.length, bw = 4, gap = (w - n * bw) / (n - 1);
730
+ const max = Math.max(1, ...counts);
731
+ let bars = '';
732
+ for (let i = 0; i < n; i++) {
733
+ const v = counts[i];
734
+ const bh = v ? Math.max(3, Math.round((v / max) * (h - 4))) : 2;
735
+ const x = Math.round(i * (bw + gap));
736
+ const day = new Date(Date.now() - (n - 1 - i) * 86400000);
737
+ const label = `${day.getMonth() + 1}/${day.getDate()}: ${v} prompt${v === 1 ? '' : 's'}`;
738
+ bars += `<rect x="${x}" y="${h - bh}" width="${bw}" height="${bh}" rx="1.5"
739
+ fill="${v ? 'var(--accent)' : 'var(--line)'}"><title>${esc(label)}</title></rect>`;
740
+ }
741
+ return `<svg class="spark" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img"
742
+ aria-label="prompts per day, last 14 days">${bars}</svg>`;
743
+ }
744
+
745
+ // 8-week cost trend, one bar per week, oldest left.
746
+ function weeklyChart(weeks) {
747
+ if (!weeks || !weeks.some((v) => v > 0)) return '';
748
+ const w = 62, h = 20, bw = 5, n = weeks.length, gap = (w - n * bw) / (n - 1);
749
+ const max = Math.max(...weeks);
750
+ let bars = '';
751
+ for (let i = 0; i < n; i++) {
752
+ const v = weeks[i];
753
+ const bh = v ? Math.max(2, Math.round((v / max) * (h - 2))) : 1;
754
+ const x = Math.round(i * (bw + gap));
755
+ const ago = n - 1 - i;
756
+ const label = `${ago === 0 ? 'this week' : ago === 1 ? 'last week' : ago + ' weeks ago'}: ≈$${v.toFixed(0)}`;
757
+ bars += `<rect x="${x}" y="${h - bh}" width="${bw}" height="${bh}" rx="1.5"
758
+ fill="${v ? 'var(--accent)' : 'var(--line)'}"><title>${esc(label)}</title></rect>`;
759
+ }
760
+ return `<svg id="wk-chart" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img"
761
+ aria-label="estimated cost per week, last 8 weeks">${bars}</svg>`;
762
+ }
763
+
764
+ function meter(label, pct, title) {
765
+ const cls = pct >= 90 ? 'crit' : pct >= 75 ? 'hot' : '';
766
+ return `<div class="meter" title="${esc(title || '')}">
767
+ <span>${esc(label)}</span>
768
+ <span class="bar"><span class="fill ${cls}" style="width:${Math.min(100, pct)}%"></span></span>
769
+ <span>${Math.round(pct)}%</span>
770
+ </div>`;
771
+ }
772
+
773
+ // Status cell: a split-flap that flips when the status actually changes.
774
+ // keyPrefix keeps rail and mission-control flips independent.
775
+ const prevStatusUI = new Map(); // key -> status
776
+ function statusFlap(s, keyPrefix = '') {
777
+ const key = keyPrefix + s.sessionId;
778
+ const changed = prevStatusUI.has(key) && prevStatusUI.get(key) !== s.status;
779
+ prevStatusUI.set(key, s.status);
780
+ const flip = changed ? ' flip' : '';
781
+ if (s.status === 'waiting') {
782
+ return `<span class="flap disp st st-waiting${flip}" title="${esc(s.waitingFor || 'Claude is waiting for your input')}">needs you</span>`;
783
+ }
784
+ if (s.status === 'busy') {
785
+ const quiet = s.quietMin
786
+ ? ` <span class="flap disp st st-quiet" title="Busy but no transcript output for ${s.quietMin} minutes — may be stalled">quiet ${s.quietMin}m</span>`
787
+ : '';
788
+ return `<span class="flap disp st st-busy${flip}">busy</span>${quiet}`;
789
+ }
790
+ return `<span class="flap disp st${flip}">${esc(s.status)}</span>`;
791
+ }
792
+
793
+ // "claude-fable-5" -> "fable-5"; date-suffixed ids lose the date.
794
+ function modelShort(m) {
795
+ if (!m) return '';
796
+ return m.replace(/^claude-/, '').replace(/-\d{8}$/, '');
797
+ }
798
+
799
+ function modelChip(m) {
800
+ return m ? `<span class="badge" title="${esc(m)}">${esc(modelShort(m))}</span>` : '';
801
+ }
802
+
803
+ function fmtCost(usd) {
804
+ if (!usd || usd < 0.005) return '';
805
+ return usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`;
806
+ }
807
+
808
+ function copyBtn(cmd) {
809
+ return `<button class="copy" data-cmd="${esc(cmd)}" title="Copy resume command">⧉</button>`;
810
+ }
811
+
812
+ // Live sessions are already open somewhere; only offer open on idle ones.
813
+ let liveIds = new Set();
814
+ function openBtn(sessionId) {
815
+ if (liveIds.has(sessionId)) return '';
816
+ return `<button class="copy open-btn" data-open="${esc(sessionId)}" title="Open this session">open ⬈</button>`;
817
+ }
818
+
819
+ function gitBadges(git, missing) {
820
+ const out = [];
821
+ if (missing) out.push('<span class="badge missing">path missing</span>');
822
+ if (!git || !git.isRepo) return out.join('');
823
+ if (git.branch) out.push(`<span class="badge">${esc(git.branch)}</span>`);
824
+ const changes = (git.dirty || 0) + (git.untracked || 0);
825
+ if (changes) out.push(`<span class="badge dirty">●${changes}</span>`);
826
+ if (git.ahead) out.push(`<span class="badge ahead">↑${git.ahead}</span>`);
827
+ if (git.behind) out.push(`<span class="badge">↓${git.behind}</span>`);
828
+ if (git.error) out.push(`<span class="badge err" title="${esc(git.error)}">git !</span>`);
829
+ return out.join('');
830
+ }
831
+
832
+ function liveCard(s) {
833
+ const task = s.currentTask
834
+ ? `<div class="dep-task mono"><span class="prompt-ch">❯</span>${esc(s.currentTask.activeForm)}${
835
+ s.tasksSummary ? `<span class="count">${s.tasksSummary.completed}/${
836
+ s.tasksSummary.completed + s.tasksSummary.inProgress + s.tasksSummary.pending}</span>` : ''}</div>`
837
+ : '';
838
+ const dep = s.startedAt
839
+ ? new Date(s.startedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
840
+ : '';
841
+ return `<div class="dep-row ${s.status === 'waiting' ? 'waiting' : ''}">
842
+ <span class="dep-time" title="session started">${esc(dep)}</span>
843
+ <span class="dep-proj disp">${esc(s.projectName)}${s.isWorktree ? ' <span class="badge">worktree</span>' : ''}</span>
844
+ <div class="dep-mid">
845
+ <div class="dep-title" data-session="${esc(s.sessionId)}" title="Click to watch the transcript live">${esc(s.title || '')}</div>
846
+ ${task}
847
+ </div>
848
+ <div class="dep-right">
849
+ ${modelChip(s.model)}
850
+ <span class="dep-elapsed">${elapsed(s.startedAt)}</span>
851
+ ${statusFlap(s)}
852
+ ${copyBtn(s.resumeCommand)}
853
+ </div>
854
+ </div>`;
855
+ }
856
+
857
+ function projectCard(p) {
858
+ const sessions = p.sessions.length
859
+ ? `<ul class="sessions">${p.sessions.map((sess) => `
860
+ <li>
861
+ <span class="s-title" data-session="${esc(sess.sessionId)}" title="Click to read the transcript">${
862
+ sess.worktree ? `<span class="wt mono">⎇ ${esc(sess.worktree)}</span>` : ''}${esc(sess.title)}</span>
863
+ ${modelChip(sess.model)}
864
+ <span class="s-when">${relTime(sess.lastActivityAt)}</span>
865
+ ${openBtn(sess.sessionId)}
866
+ ${copyBtn(sess.resumeCommand)}
867
+ </li>`).join('')}</ul>`
868
+ : '<div class="none-yet">No sessions recorded.</div>';
869
+ return `<div class="card">
870
+ <div class="head">
871
+ ${p.isLive ? '<span class="live-dot" title="session running"></span>' : ''}
872
+ <span class="name" data-detail="${esc(p.path)}" style="cursor:pointer" title="Click for project details">${esc(p.name)}</span>
873
+ <button class="copy" data-new="${esc(p.path)}" title="Start a new Claude session here (Ghostty)">⊕</button>
874
+ ${sparkSVG(p.activity.counts)}
875
+ </div>
876
+ <div class="meta">
877
+ ${gitBadges(p.git, p.missing)}
878
+ ${fmtCost(p.spend7d) ? `<span class="badge" title="Estimated list-price value of the last 7 days of sessions">≈${fmtCost(p.spend7d)}/7d</span>` : ''}
879
+ <span class="when" title="${esc(p.path)}">${relTime(p.lastActivityAt)}</span>
880
+ </div>
881
+ ${sessions}
882
+ </div>`;
883
+ }
884
+
885
+ function renderPinned(state) {
886
+ const el = $('#pinned');
887
+ const pins = state.pinned || [];
888
+ el.hidden = !pins.length;
889
+ if (!pins.length) { patch(el, ''); return; }
890
+ patch(el, `<div class="pin-label">Pinned</div>` + pins.map((p) => `
891
+ <div class="pin-row">
892
+ <span class="star">★</span>
893
+ <span class="s-title" data-session="${esc(p.sessionId)}" title="Click to read the transcript">${esc(p.title)}</span>
894
+ ${modelChip(p.model)}
895
+ <span class="s-when">${esc(p.projectName)}</span>
896
+ <span class="s-when">${relTime(p.lastActivityAt)}</span>
897
+ <button class="copy" data-pin="${esc(p.sessionId)}" title="Unpin">unpin</button>
898
+ </div>`).join(''));
899
+ }
900
+
901
+ const DORMANT_MS = 60 * 24 * 60 * 60 * 1000;
902
+
903
+ // ---------- git attention strip: repos with work sitting unpushed ----------
904
+ function renderGitAttention(state) {
905
+ const el = $('#git-attn');
906
+ const items = [];
907
+ for (const p of state.projects || []) {
908
+ const g = p.git;
909
+ if (!g || !g.isRepo) continue;
910
+ const changes = (g.dirty || 0) + (g.untracked || 0);
911
+ const ahead = g.ahead || 0;
912
+ if (!changes && !ahead) continue;
913
+ items.push({ name: p.name, changes, ahead, when: p.lastActivityAt });
914
+ }
915
+ if (!items.length) {
916
+ el.hidden = true;
917
+ return;
918
+ }
919
+ items.sort((a, b) => (b.when || 0) - (a.when || 0));
920
+ el.hidden = false;
921
+ patch(el,
922
+ `<span class="ga-label">Unpushed work — ${items.length} repo${items.length === 1 ? '' : 's'}</span>` +
923
+ items.map((it) => `<span class="ga-item">${esc(it.name)}${
924
+ it.changes ? `<span class="badge dirty" title="${it.changes} uncommitted/untracked files">●${it.changes}</span>` : ''}${
925
+ it.ahead ? `<span class="badge ahead" title="${it.ahead} commits not pushed">↑${it.ahead}</span>` : ''}</span>`).join(''));
926
+ }
927
+
928
+ // ---------- digest: what happened recently, across every project ----------
929
+ let digestDays = Number(localStorage.getItem('digestDays')) || 1;
930
+
931
+ function dayLabel(ms) {
932
+ const d = new Date(ms), today = new Date();
933
+ today.setHours(0, 0, 0, 0);
934
+ const dayStart = new Date(d); dayStart.setHours(0, 0, 0, 0);
935
+ const diff = Math.round((today - dayStart) / 86400000);
936
+ if (diff === 0) return 'Today';
937
+ if (diff === 1) return 'Yesterday';
938
+ return d.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
939
+ }
940
+
941
+ function digestEntries(state) {
942
+ const cutoff = Date.now() - digestDays * 86400000;
943
+ const items = [];
944
+ for (const p of state.projects || []) {
945
+ for (const sess of p.sessions) {
946
+ if (!sess.lastActivityAt || sess.lastActivityAt < cutoff) continue;
947
+ items.push({ project: p.name, ...sess });
948
+ }
949
+ }
950
+ items.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
951
+ return items;
952
+ }
953
+
954
+ function digestItem(it) {
955
+ const t = it.tasksSummary;
956
+ const total = t ? t.completed + t.inProgress + t.pending : 0;
957
+ const running = liveIds.has(it.sessionId);
958
+ const time = new Date(it.lastActivityAt).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
959
+ return `<div class="digest-item">
960
+ <div class="di-top">
961
+ <span class="di-proj">${esc(it.project)}</span>
962
+ <span class="di-title" data-session="${esc(it.sessionId)}" title="Click to read the transcript">${it.worktree ? `<span class="wt mono">⎇</span> ` : ''}${esc(it.title)}</span>
963
+ ${running ? '<span class="flap disp st st-busy">running</span>' : ''}
964
+ ${modelChip(it.model)}
965
+ ${fmtCost(it.estCost) ? `<span class="di-when" title="Estimated list-price value of this session">≈${fmtCost(it.estCost)}</span>` : ''}
966
+ <span class="di-when">${time}</span>
967
+ ${openBtn(it.sessionId)}
968
+ </div>
969
+ ${it.awaySummary ? `<div class="di-summary" title="Click to expand">${esc(it.awaySummary)}</div>` : ''}
970
+ ${total ? `<div class="di-tasks"><span class="done">${t.completed}</span>/${total} tasks done${
971
+ t.inProgress ? `, ${t.inProgress} in progress` : ''}${
972
+ t.pending ? `, ${t.pending} left` : ''}</div>` : ''}
973
+ </div>`;
974
+ }
975
+
976
+ function renderDigest(state) {
977
+ const items = digestEntries(state);
978
+ const label = $('#digest-label');
979
+ label.textContent = `Digest — ${items.length} session${items.length === 1 ? '' : 's'}`;
980
+ for (const b of document.querySelectorAll('#digest-range button')) {
981
+ b.classList.toggle('sel', Number(b.dataset.days) === digestDays);
982
+ }
983
+ if (!items.length) {
984
+ patch($('#digest-body'), `<div class="digest-empty">Quiet — no sessions in the last ${
985
+ digestDays === 1 ? 'day' : digestDays + ' days'}.</div>`);
986
+ return;
987
+ }
988
+ let html = '', lastDay = '';
989
+ for (const it of items) {
990
+ const day = dayLabel(it.lastActivityAt);
991
+ if (day !== lastDay) { html += `<div class="digest-day">${esc(day)}</div>`; lastDay = day; }
992
+ html += digestItem(it);
993
+ }
994
+ patch($('#digest-body'), html);
995
+ }
996
+
997
+ function render(state) {
998
+ liveIds = new Set((state.liveSessions || []).map((s) => s.sessionId));
999
+ // quota
1000
+ const q = state.quota;
1001
+ const qEl = $('#quota');
1002
+ const spend = (state.projects || []).reduce((s, p) => s + (p.spend7d || 0), 0);
1003
+ const plan = state.plan;
1004
+ const planChip = plan && plan.label
1005
+ ? `<span class="badge" style="color:var(--accent)" title="${esc([
1006
+ plan.organization, plan.tier && `rate-limit tier: ${plan.tier}`,
1007
+ plan.extraUsage ? 'extra usage enabled' : null,
1008
+ ].filter(Boolean).join(' · '))}">${esc(plan.label)}</span>`
1009
+ : '';
1010
+ const b = state.budget;
1011
+ const budgetMeter = b
1012
+ ? meter('budget', (b.spent / b.limit) * 100, `≈$${b.spent.toFixed(0)} of your $${b.limit} weekly budget (7-day estimate)`)
1013
+ : '';
1014
+ const spendChip = planChip + (fmtCost(spend)
1015
+ ? `${weeklyChart(state.weeklyCost)}<span title="Estimated list-price value of all sessions in the last 7 days — relative weight, not billed dollars">7d ≈${fmtCost(spend)}</span>`
1016
+ : '') + budgetMeter;
1017
+ if (q) {
1018
+ qEl.className = q.stale ? 'stale' : '';
1019
+ patch(qEl, spendChip +
1020
+ (q.utilization != null ? meter('session', q.utilization, q.resetsAt ? 'resets ' + q.resetsAt : '') : '') +
1021
+ (q.weeklyUtilization != null ? meter('week', q.weeklyUtilization, q.weeklyResetsAt ? 'resets ' + q.weeklyResetsAt : '') : '') +
1022
+ (q.costUsed != null && q.costLimit ? `<span title="extra usage">$${q.costUsed.toFixed(0)}/$${q.costLimit.toFixed(0)}</span>` : ''));
1023
+ } else {
1024
+ qEl.className = '';
1025
+ patch(qEl, spendChip);
1026
+ }
1027
+
1028
+ // live rail
1029
+ const live = state.liveSessions || [];
1030
+ patch($('#live'),
1031
+ `<div class="rail-label">Departures — ${live.length || 'none'} running</div>` +
1032
+ (live.length
1033
+ ? `<div class="rows">${live.map(liveCard).join('')}</div>`
1034
+ : `<div class="live-none">No sessions running. Start one with <code class="mono">claude</code> in any project.</div>`));
1035
+
1036
+ renderGitAttention(state);
1037
+ renderPinned(state);
1038
+ if (typeof mcSync === 'function') mcSync();
1039
+ renderDigest(state);
1040
+
1041
+ // grid + dormant
1042
+ const now = Date.now();
1043
+ const active = [], dormant = [];
1044
+ for (const p of state.projects || []) {
1045
+ const isDormant = !p.isLive && (!p.lastActivityAt || now - p.lastActivityAt > DORMANT_MS);
1046
+ (isDormant ? dormant : active).push(p);
1047
+ }
1048
+ patch($('#grid'), active.length || dormant.length
1049
+ ? active.map(projectCard).join('')
1050
+ : `<div id="welcome">
1051
+ <h2><span class="tilde">❯</span> Welcome to Claude Dashboard</h2>
1052
+ <p>No Claude Code projects found yet. Run <code>claude</code> in any folder and it appears here automatically — live status, transcripts, git state, costs, the lot.</p>
1053
+ <p>Tip: press <code>⌘K</code> to jump anywhere, <code>/</code> to search, and click ⚙ for settings.</p>
1054
+ </div>`);
1055
+ const d = $('#dormant');
1056
+ d.hidden = dormant.length === 0;
1057
+ $('summary', d).textContent = `Dormant — ${dormant.length} project${dormant.length === 1 ? '' : 's'}`;
1058
+ patch($('.cards', d), dormant.map(projectCard).join(''));
1059
+
1060
+ // tab title = attention signal
1061
+ // collection errors get a subtle amber gear, details live in settings
1062
+ $('#gear').style.color = (state.errors || []).length ? 'var(--warn)' : '';
1063
+ $('#gear').title = (state.errors || []).length
1064
+ ? `${state.errors.length} collection errors — see Diagnostics in settings`
1065
+ : 'Settings';
1066
+
1067
+ const waiting = live.filter((s) => s.status === 'waiting').length;
1068
+ document.title = waiting ? `(${waiting} waiting) Claude Dashboard`
1069
+ : live.length ? `(${live.length} running) Claude Dashboard`
1070
+ : 'Claude Dashboard';
1071
+ }
1072
+
1073
+ function toast(msg) {
1074
+ const t = $('#toast');
1075
+ t.textContent = msg;
1076
+ t.classList.add('show');
1077
+ clearTimeout(t._timer);
1078
+ t._timer = setTimeout(() => t.classList.remove('show'), 1800);
1079
+ }
1080
+
1081
+ // Where "open" launches sessions: the configured terminal (label reflects
1082
+ // what will actually launch) or the Claude desktop app when installed.
1083
+ const prefEl = $('#open-pref');
1084
+ prefEl.addEventListener('change', () => localStorage.setItem('openTarget', prefEl.value));
1085
+
1086
+ async function loadOpenPref() {
1087
+ try {
1088
+ const c = await (await fetch('/api/config')).json();
1089
+ const opts = [`<option value="terminal">${esc(c.resolvedTerminal.label)}</option>`];
1090
+ if (c.claudeApp) opts.push('<option value="app">Claude app</option>');
1091
+ prefEl.innerHTML = opts.join('');
1092
+ const stored = localStorage.getItem('openTarget') || 'terminal';
1093
+ prefEl.value = stored === 'app' && !c.claudeApp ? 'terminal' : stored;
1094
+ } catch {
1095
+ prefEl.value = 'terminal';
1096
+ }
1097
+ }
1098
+ loadOpenPref();
1099
+
1100
+ // digest window buttons + expandable summaries + collapsed-state memory
1101
+ document.addEventListener('click', (e) => {
1102
+ const rangeBtn = e.target.closest('#digest-range button');
1103
+ if (rangeBtn) {
1104
+ e.preventDefault(); // don't toggle the <details>
1105
+ digestDays = Number(rangeBtn.dataset.days);
1106
+ localStorage.setItem('digestDays', String(digestDays));
1107
+ if (lastState) renderDigest(lastState);
1108
+ return;
1109
+ }
1110
+ const sum = e.target.closest('.di-summary');
1111
+ if (sum) sum.classList.toggle('expanded');
1112
+ });
1113
+ const digestEl = $('#digest');
1114
+ digestEl.open = localStorage.getItem('digestOpen') !== '0';
1115
+ digestEl.addEventListener('toggle', () => localStorage.setItem('digestOpen', digestEl.open ? '1' : '0'));
1116
+
1117
+ document.addEventListener('click', async (e) => {
1118
+ const btn = e.target.closest('.copy');
1119
+ if (!btn) return;
1120
+ if (btn.dataset.open) {
1121
+ const target = prefEl.value;
1122
+ btn.disabled = true;
1123
+ try {
1124
+ const r = await fetch('/api/open', {
1125
+ method: 'POST',
1126
+ headers: { 'Content-Type': 'application/json' },
1127
+ body: JSON.stringify({ sessionId: btn.dataset.open, target }),
1128
+ });
1129
+ const out = await r.json();
1130
+ toast(out.ok
1131
+ ? `Opening in ${target === 'app' ? 'Claude app' : 'Ghostty'}…`
1132
+ : `Couldn't open: ${out.error}`);
1133
+ } catch {
1134
+ toast("Couldn't reach the server");
1135
+ }
1136
+ btn.disabled = false;
1137
+ return;
1138
+ }
1139
+ navigator.clipboard.writeText(btn.dataset.cmd).then(() => toast('Resume command copied'));
1140
+ });
1141
+
1142
+ // ---------- data flow: first paint from /api/state, then SSE, 60s poll fallback ----------
1143
+ let lastState = null;
1144
+
1145
+ async function poll() {
1146
+ try {
1147
+ const r = await fetch('/api/state');
1148
+ lastState = await r.json();
1149
+ render(lastState);
1150
+ } catch { /* server briefly down; SSE retry will recover */ }
1151
+ }
1152
+
1153
+ function connect() {
1154
+ const es = new EventSource('/api/events');
1155
+ es.addEventListener('state', (e) => {
1156
+ $('#conn').className = 'on';
1157
+ lastState = JSON.parse(e.data);
1158
+ render(lastState);
1159
+ });
1160
+ es.onopen = () => { $('#conn').className = 'on'; };
1161
+ es.onerror = () => { $('#conn').className = 'retry'; };
1162
+ }
1163
+
1164
+ // ---------- project detail drawer ----------
1165
+ // Minimal markdown renderer: escape everything first, then transform.
1166
+ // Covers headings, fenced code, inline code, bold/italic, links, lists,
1167
+ // blockquotes, and hr — enough for CLAUDE.md and memory files.
1168
+ function mdInline(s) {
1169
+ return s
1170
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
1171
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
1172
+ .replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
1173
+ .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
1174
+ }
1175
+
1176
+ function renderMd(raw) {
1177
+ // Frontmatter is metadata, not prose — drop it before rendering.
1178
+ raw = raw.replace(/^---\n[\s\S]*?\n---\n/, '');
1179
+ const lines = esc(raw).split('\n');
1180
+ const out = [];
1181
+ let list = null, para = [], code = null;
1182
+ const flushPara = () => { if (para.length) { out.push(`<p>${mdInline(para.join(' '))}</p>`); para = []; } };
1183
+ const flushList = () => { if (list) { out.push(`<${list.tag}>${list.items.join('')}</${list.tag}>`); list = null; } };
1184
+ for (const line of lines) {
1185
+ if (code !== null) {
1186
+ if (/^```/.test(line)) { out.push(`<pre><code>${code.join('\n')}</code></pre>`); code = null; }
1187
+ else code.push(line);
1188
+ continue;
1189
+ }
1190
+ if (/^```/.test(line)) { flushPara(); flushList(); code = []; continue; }
1191
+ const h = line.match(/^(#{1,4})\s+(.*)/);
1192
+ if (h) { flushPara(); flushList(); out.push(`<h${h[1].length}>${mdInline(h[2])}</h${h[1].length}>`); continue; }
1193
+ if (/^\s*(---|\*\*\*)\s*$/.test(line)) { flushPara(); flushList(); out.push('<hr>'); continue; }
1194
+ const li = line.match(/^\s*(?:[-*+]|(\d+)\.)\s+(.*)/);
1195
+ if (li) {
1196
+ flushPara();
1197
+ const tag = li[1] ? 'ol' : 'ul';
1198
+ if (!list || list.tag !== tag) { flushList(); list = { tag, items: [] }; }
1199
+ list.items.push(`<li>${mdInline(li[2])}</li>`);
1200
+ continue;
1201
+ }
1202
+ if (/^\s*&gt;\s?/.test(line)) { flushPara(); flushList(); out.push(`<blockquote>${mdInline(line.replace(/^\s*&gt;\s?/, ''))}</blockquote>`); continue; }
1203
+ if (!line.trim()) { flushPara(); flushList(); continue; }
1204
+ para.push(line.trim());
1205
+ }
1206
+ flushPara(); flushList();
1207
+ if (code !== null) out.push(`<pre><code>${code.join('\n')}</code></pre>`);
1208
+ return `<div class="md">${out.join('')}</div>`;
1209
+ }
1210
+
1211
+ function dSection(title, inner, empty) {
1212
+ return `<details class="d-section" open><summary>${esc(title)}</summary>${
1213
+ inner || `<div class="d-empty">${esc(empty)}</div>`}</details>`;
1214
+ }
1215
+
1216
+ function dFiles(files, nameKey) {
1217
+ return files.map((f) => `<div class="d-file">
1218
+ <div class="d-file-name">${esc(f[nameKey] || f.name)}${f.description ? ` — <span style="color:var(--muted)">${esc(f.description)}</span>` : ''}</div>
1219
+ ${f.content ? renderMd(f.content) : ''}
1220
+ </div>`).join('');
1221
+ }
1222
+
1223
+ function renderDetail(d, name) {
1224
+ $('#d-title').textContent = name;
1225
+ $('#d-path').textContent = d.path;
1226
+ const sessions = d.sessions.length
1227
+ ? `<ul class="d-sessions">${d.sessions.map((s) => `
1228
+ <li>
1229
+ <span class="s-title" data-session="${esc(s.sessionId)}" title="Click to read the transcript">${
1230
+ s.worktree ? `<span class="wt mono">⎇ ${esc(s.worktree)}</span> ` : ''}${esc(s.title)}</span>
1231
+ ${fmtCost(s.estCost) ? `<span class="s-when">≈${fmtCost(s.estCost)}</span>` : ''}
1232
+ <span class="s-when">${relTime(s.lastActivityAt)}</span>
1233
+ ${openBtn(s.sessionId)}
1234
+ ${copyBtn(s.resumeCommand)}
1235
+ </li>`).join('')}</ul>`
1236
+ : null;
1237
+ const caps = [...d.skills, ...d.agents.map((a) => ({ ...a, name: `agent: ${a.name}` })),
1238
+ ...d.commands.map((c) => ({ ...c, name: `/${c.name}` }))];
1239
+ const settingsEntries = [];
1240
+ if (d.settings.mcpServers) settingsEntries.push(`<div class="d-file"><div class="d-file-name">MCP servers</div><div class="md"><p>${d.settings.mcpServers.map(esc).join(', ')}</p></div></div>`);
1241
+ if (d.settings.allowedTools) settingsEntries.push(`<div class="d-file"><div class="d-file-name">allowed tools</div><div class="md"><p>${d.settings.allowedTools.map((t) => `<code>${esc(t)}</code>`).join(' ')}</p></div></div>`);
1242
+ for (const [key, label] of [['settings', '.claude/settings.json'], ['settingsLocal', '.claude/settings.local.json'], ['mcpJson', '.mcp.json']]) {
1243
+ if (d.settings[key]) settingsEntries.push(`<div class="d-file"><div class="d-file-name">${label}</div><div class="md"><pre><code>${esc(d.settings[key])}</code></pre></div></div>`);
1244
+ }
1245
+ const muteBtn = `<div style="margin-bottom:10px"><button class="copy" data-mute="${esc(d.path)}" data-muted="${d.muted ? '1' : ''}" title="Per-project desktop notifications">${
1246
+ d.muted ? '🔕 muted — click to unmute' : '🔔 notifying — click to mute'}</button></div>`;
1247
+ $('#d-body').innerHTML =
1248
+ muteBtn +
1249
+ dSection(`Sessions — ${d.sessions.length}`, sessions, 'No sessions recorded.') +
1250
+ dSection('CLAUDE.md', d.claudeMd.length ? dFiles(d.claudeMd, 'name') : null, 'No CLAUDE.md — sessions here start without project instructions.') +
1251
+ dSection(`Memory — ${d.memory.length} file${d.memory.length === 1 ? '' : 's'}`, d.memory.length ? dFiles(d.memory, 'name') : null, 'No memory yet.') +
1252
+ dSection(`Skills, agents & commands — ${caps.length}`, caps.length ? dFiles(caps, 'name') : null, 'None in .claude/skills, .claude/agents, or .claude/commands.') +
1253
+ dSection('Settings', settingsEntries.length ? settingsEntries.join('') : null, 'No project-level settings or MCP servers.');
1254
+ }
1255
+
1256
+ async function openDetail(path, name) {
1257
+ const drawer = $('#drawer'), backdrop = $('#drawer-backdrop');
1258
+ drawer.hidden = false;
1259
+ backdrop.hidden = false;
1260
+ $('#d-title').textContent = name;
1261
+ $('#d-path').textContent = path;
1262
+ $('#d-actions').innerHTML = '';
1263
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Loading…</div>';
1264
+ try {
1265
+ const r = await fetch(`/api/project?path=${encodeURIComponent(path)}`);
1266
+ if (!r.ok) throw new Error();
1267
+ renderDetail(await r.json(), name);
1268
+ } catch {
1269
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load project details.</div>';
1270
+ }
1271
+ }
1272
+
1273
+ function closeDetail() {
1274
+ if (typeof stopFollow === 'function') stopFollow();
1275
+ if (typeof stopReplay === 'function') stopReplay(false);
1276
+ $('#drawer').hidden = true;
1277
+ $('#drawer-backdrop').hidden = true;
1278
+ }
1279
+
1280
+ function openDrawerShell(title, sub) {
1281
+ if (typeof stopFollow === 'function') stopFollow();
1282
+ if (typeof stopReplay === 'function') stopReplay(false);
1283
+ $('#drawer').hidden = false;
1284
+ $('#drawer-backdrop').hidden = false;
1285
+ $('#d-title').textContent = title;
1286
+ $('#d-path').textContent = sub || '';
1287
+ $('#d-actions').innerHTML = '';
1288
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Loading…</div>';
1289
+ }
1290
+
1291
+ // ---------- transcript viewer, with live follow for running sessions ----------
1292
+ const follow = { id: null, offset: 0, lastDay: '', eventCount: 0, projectName: '', timer: null };
1293
+
1294
+ function stopFollow() {
1295
+ clearInterval(follow.timer);
1296
+ follow.timer = null;
1297
+ follow.id = null;
1298
+ }
1299
+
1300
+ function transcriptHtml(events, st = follow) {
1301
+ const parts = [];
1302
+ for (const ev of events) {
1303
+ if (ev.ts) {
1304
+ const day = new Date(ev.ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
1305
+ if (day !== st.lastDay) { parts.push(`<div class="t-ts">${esc(day)}</div>`); st.lastDay = day; }
1306
+ }
1307
+ if (ev.kind === 'user') {
1308
+ parts.push(`<div class="t-turn t-user"><span class="t-who">you${
1309
+ ev.ts ? ' · ' + new Date(ev.ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : ''
1310
+ }</span>${esc(ev.text)}${ev.truncated ? ' …' : ''}</div>`);
1311
+ } else if (ev.kind === 'assistant') {
1312
+ parts.push(`<div class="t-turn">${renderMd(ev.text + (ev.truncated ? ' …' : ''))}</div>`);
1313
+ } else if (ev.kind === 'tool') {
1314
+ parts.push(`<div class="t-tool">⚙ <span class="t-tname">${esc(ev.name)}</span> ${esc(ev.input || '')}</div>`);
1315
+ } else if (ev.kind === 'note') {
1316
+ parts.push(`<div class="t-note">${esc(ev.text)}</div>`);
1317
+ }
1318
+ }
1319
+ return parts.join('');
1320
+ }
1321
+
1322
+ let exportableSession = null;
1323
+ function transcriptStatus() {
1324
+ const liveNow = follow.id && liveIds.has(follow.id);
1325
+ const sid = exportableSession && exportableSession.id;
1326
+ const isPinned = sid && (lastState?.pinned || []).some((p) => p.sessionId === sid);
1327
+ $('#d-path').textContent = `${follow.projectName} · ${follow.eventCount} events${liveNow ? ' · ● live' : ''}`;
1328
+ $('#d-actions').innerHTML = `
1329
+ <button class="copy" data-export title="Download this transcript as markdown">export ⇩</button>
1330
+ ${sid ? `<button class="copy" data-pin="${esc(sid)}" title="${isPinned ? 'Unpin this session' : 'Pin this session to the top of the dashboard'}">${isPinned ? '★ pinned' : '☆ pin'}</button>` : ''}
1331
+ ${!liveNow ? '<button class="copy" data-replay title="Play the conversation back turn by turn">replay ▸</button>' : ''}`;
1332
+ }
1333
+
1334
+ // ---------- session replay ----------
1335
+ let replay = null;
1336
+ function stopReplay(reveal = true) {
1337
+ if (!replay) return;
1338
+ clearInterval(replay.timer);
1339
+ if (reveal) for (const n of replay.nodes) n.style.display = '';
1340
+ replay = null;
1341
+ const b = document.querySelector('[data-replay]');
1342
+ if (b) b.textContent = 'replay ▸';
1343
+ }
1344
+ function startReplay() {
1345
+ const nodes = [...$('#d-body').children];
1346
+ if (!nodes.length) return;
1347
+ for (const n of nodes) n.style.display = 'none';
1348
+ $('#d-body').scrollTop = 0;
1349
+ let i = 0;
1350
+ replay = {
1351
+ nodes,
1352
+ timer: setInterval(() => {
1353
+ if (i >= nodes.length) { stopReplay(false); return; }
1354
+ nodes[i].style.display = '';
1355
+ nodes[i].scrollIntoView({ block: 'nearest' });
1356
+ i++;
1357
+ }, 280),
1358
+ };
1359
+ const b = document.querySelector('[data-replay]');
1360
+ if (b) b.textContent = 'stop ■';
1361
+ }
1362
+
1363
+ async function pollFollow() {
1364
+ if (!follow.id) return;
1365
+ try {
1366
+ const r = await fetch(`/api/session?id=${encodeURIComponent(follow.id)}&after=${follow.offset}`);
1367
+ if (!r.ok) return;
1368
+ const t = await r.json();
1369
+ if (follow.id !== t.sessionId) return; // drawer moved on meanwhile
1370
+ follow.offset = t.nextOffset;
1371
+ if (t.events.length) {
1372
+ const body = $('#d-body');
1373
+ const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 200;
1374
+ follow.eventCount += t.events.length;
1375
+ body.insertAdjacentHTML('beforeend', transcriptHtml(t.events));
1376
+ if (nearBottom) body.scrollTop = body.scrollHeight;
1377
+ }
1378
+ transcriptStatus();
1379
+ if (!liveIds.has(follow.id) && !t.events.length) stopFollow(); // session ended and drained
1380
+ } catch { /* transient; next tick retries */ }
1381
+ }
1382
+
1383
+ async function openTranscript(sessionId, highlight) {
1384
+ openDrawerShell('Transcript', sessionId);
1385
+ stopFollow();
1386
+ try {
1387
+ const r = await fetch(`/api/session?id=${encodeURIComponent(sessionId)}`);
1388
+ if (!r.ok) throw new Error();
1389
+ const t = await r.json();
1390
+ exportableSession = { id: sessionId, title: t.title };
1391
+ follow.id = sessionId;
1392
+ follow.offset = t.nextOffset;
1393
+ follow.lastDay = '';
1394
+ follow.eventCount = t.events.length;
1395
+ follow.projectName = t.projectName;
1396
+ $('#d-title').textContent = t.title;
1397
+ const parts = [];
1398
+ if (t.truncatedTurns) parts.push(`<div class="d-empty">… ${t.truncatedTurns} earlier events not shown</div>`);
1399
+ parts.push(transcriptHtml(t.events));
1400
+ $('#d-body').innerHTML = parts.join('') || '<div class="d-empty">Empty transcript.</div>';
1401
+ transcriptStatus();
1402
+ if (liveIds.has(sessionId)) {
1403
+ $('#d-body').scrollTop = $('#d-body').scrollHeight; // start at the newest
1404
+ follow.timer = setInterval(pollFollow, 2500);
1405
+ } else {
1406
+ follow.id = null;
1407
+ if (highlight) {
1408
+ const needle = highlight.toLowerCase();
1409
+ const hit = [...$('#d-body').querySelectorAll('.t-turn')]
1410
+ .find((el) => el.textContent.toLowerCase().includes(needle));
1411
+ if (hit) {
1412
+ hit.scrollIntoView({ block: 'center' });
1413
+ hit.classList.add('hl-flash');
1414
+ setTimeout(() => hit.classList.remove('hl-flash'), 2400);
1415
+ }
1416
+ }
1417
+ }
1418
+ } catch {
1419
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load the transcript.</div>';
1420
+ }
1421
+ }
1422
+
1423
+ // ---------- mission control: every live session streaming side by side ----------
1424
+ const mc = { open: false, panes: new Map(), timer: null, polling: false };
1425
+
1426
+ function openMissionControl() {
1427
+ if (mc.open) return;
1428
+ mc.open = true;
1429
+ $('#mc').hidden = false;
1430
+ mcSync();
1431
+ mcPoll();
1432
+ mc.timer = setInterval(mcPoll, 2500);
1433
+ }
1434
+
1435
+ function closeMissionControl() {
1436
+ if (!mc.open) return;
1437
+ mc.open = false;
1438
+ $('#mc').hidden = true;
1439
+ clearInterval(mc.timer);
1440
+ mc.timer = null;
1441
+ mc.panes.clear();
1442
+ $('#mc-grid').innerHTML = '';
1443
+ }
1444
+
1445
+ function mcPaneHead(s) {
1446
+ const subs = s.subagents
1447
+ ? `<span class="mc-meta" title="subagent transcripts · their tokens">⑂ ${s.subagents.count}${
1448
+ s.subagents.mtok ? ` · ${s.subagents.mtok}M tok` : ''}</span>`
1449
+ : '';
1450
+ return `<span class="mc-proj" data-session="${esc(s.sessionId)}" title="Open the full transcript">${esc(s.projectName)}</span>
1451
+ ${modelChip(s.model)}${subs}<span class="spacer"></span>
1452
+ <span class="mc-meta">${elapsed(s.startedAt)}</span>${statusFlap(s, 'mc:')}`;
1453
+ }
1454
+
1455
+ // Reconcile panes with the latest state: add new sessions, drop ended ones,
1456
+ // refresh headers, and reorder only when the order actually changed (a DOM
1457
+ // move resets the pane's scroll position).
1458
+ function mcSync() {
1459
+ if (!mc.open || !lastState) return;
1460
+ const live = lastState.liveSessions || [];
1461
+ $('#mc-sub').textContent = live.length ? `${live.length} session${live.length === 1 ? '' : 's'} live` : '';
1462
+ const grid = $('#mc-grid');
1463
+ if (!live.length) {
1464
+ mc.panes.clear();
1465
+ grid.innerHTML = '<div class="mc-empty">No sessions running. Start one with <code class="mono">claude</code> in any project.</div>';
1466
+ return;
1467
+ }
1468
+ const empty = grid.querySelector('.mc-empty');
1469
+ if (empty) empty.remove();
1470
+ const seen = new Set();
1471
+ for (const s of live) {
1472
+ seen.add(s.sessionId);
1473
+ let pane = mc.panes.get(s.sessionId);
1474
+ if (!pane) {
1475
+ const el = document.createElement('div');
1476
+ el.className = 'mc-pane';
1477
+ el.dataset.sid = s.sessionId;
1478
+ el.innerHTML = '<div class="mc-pane-head"></div><div class="mc-pane-task" hidden></div><div class="mc-pane-body"><div class="d-empty">Loading…</div></div>';
1479
+ pane = { el, offset: 0, st: { lastDay: '' }, loaded: false };
1480
+ mc.panes.set(s.sessionId, pane);
1481
+ grid.appendChild(el);
1482
+ }
1483
+ pane.el.classList.toggle('waiting', s.status === 'waiting');
1484
+ const head = pane.el.querySelector('.mc-pane-head');
1485
+ patch(head, mcPaneHead(s));
1486
+ const taskEl = pane.el.querySelector('.mc-pane-task');
1487
+ const taskHtml = s.currentTask ? `<span class="prompt-ch">❯</span>${esc(s.currentTask.activeForm)}` : '';
1488
+ patch(taskEl, taskHtml);
1489
+ taskEl.hidden = !taskHtml;
1490
+ }
1491
+ for (const [id, pane] of mc.panes) {
1492
+ if (!seen.has(id)) { pane.el.remove(); mc.panes.delete(id); }
1493
+ }
1494
+ const desired = live.map((s) => s.sessionId);
1495
+ const current = [...grid.children].map((c) => c.dataset.sid);
1496
+ if (desired.join() !== current.join()) {
1497
+ const scrolls = new Map(desired.map((id) => [id, mc.panes.get(id).el.querySelector('.mc-pane-body').scrollTop]));
1498
+ for (const id of desired) grid.appendChild(mc.panes.get(id).el);
1499
+ for (const id of desired) mc.panes.get(id).el.querySelector('.mc-pane-body').scrollTop = scrolls.get(id);
1500
+ }
1501
+ }
1502
+
1503
+ async function mcPoll() {
1504
+ if (!mc.open || mc.polling) return;
1505
+ mc.polling = true;
1506
+ try {
1507
+ await Promise.all([...mc.panes.entries()].map(async ([id, pane]) => {
1508
+ try {
1509
+ const url = `/api/session?id=${encodeURIComponent(id)}${pane.loaded ? `&after=${pane.offset}` : ''}`;
1510
+ const r = await fetch(url);
1511
+ if (!r.ok) return;
1512
+ const t = await r.json();
1513
+ pane.offset = t.nextOffset;
1514
+ const body = pane.el.querySelector('.mc-pane-body');
1515
+ if (!pane.loaded) {
1516
+ pane.loaded = true;
1517
+ body.innerHTML = transcriptHtml(t.events.slice(-40), pane.st) || '<div class="d-empty">Nothing yet.</div>';
1518
+ body.scrollTop = body.scrollHeight;
1519
+ } else if (t.events.length) {
1520
+ const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 160;
1521
+ body.insertAdjacentHTML('beforeend', transcriptHtml(t.events, pane.st));
1522
+ while (body.children.length > 300) body.firstChild.remove();
1523
+ if (nearBottom) body.scrollTop = body.scrollHeight;
1524
+ }
1525
+ } catch { /* transient; next tick retries */ }
1526
+ }));
1527
+ } finally {
1528
+ mc.polling = false;
1529
+ }
1530
+ }
1531
+
1532
+ $('#mc-btn').addEventListener('click', openMissionControl);
1533
+ $('#mc-close').addEventListener('click', closeMissionControl);
1534
+
1535
+ // ---------- search ----------
1536
+ async function runSearch(q, deep) {
1537
+ openDrawerShell(`Search — "${q}"`, deep ? 'searching full transcripts…' : '');
1538
+ try {
1539
+ const r = await fetch(`/api/search?q=${encodeURIComponent(q)}${deep ? '&deep=1' : ''}`);
1540
+ if (!r.ok) throw new Error();
1541
+ const d = await r.json();
1542
+ const hl = q.replace(/\b(project|since):\S+/gi, '').replace(/\s+/g, ' ').trim();
1543
+ const t = d.transcripts;
1544
+ const deepRows = t ? t.matches.map((m) => `
1545
+ <li>
1546
+ <span class="s-title" data-session="${esc(m.sessionId)}" data-hl="${esc(hl)}" title="Click to open at this match"><span class="s-role">${m.role === 'you' ? 'you' : '❯'}</span> ${esc(m.text)}</span>
1547
+ <span class="s-when">${esc(m.projectName)}</span>
1548
+ <span class="s-when">${m.ts ? relTime(m.ts) : ''}</span>
1549
+ </li>`).join('') : '';
1550
+ const deepSection = t
1551
+ ? dSection(`Inside transcripts — ${t.matches.length}${t.complete ? '' : ` (newest ${t.scanned} of ${t.total} sessions)`}`,
1552
+ deepRows ? `<ul class="d-sessions">${deepRows}</ul>` : null, 'No matches inside transcripts.')
1553
+ : `<div style="margin:14px 0"><button class="copy" data-deep-search="${esc(q)}">search inside full transcripts →</button>
1554
+ <span class="s-when" style="margin-left:8px">reads every conversation, not just your prompts</span></div>`;
1555
+ $('#d-path').textContent = `${d.prompts.length + d.titles.length + (t ? t.matches.length : 0)} results — narrow with project:name or since:7d`;
1556
+ const titleRows = d.titles.map((t) => `
1557
+ <li>
1558
+ <span class="s-title" data-session="${esc(t.sessionId)}" title="Click to read the transcript">${esc(t.title)}</span>
1559
+ <span class="s-when">${esc(t.projectName)}</span>
1560
+ <span class="s-when">${relTime(t.lastActivityAt)}</span>
1561
+ </li>`).join('');
1562
+ const promptRows = d.prompts.map((p) => `
1563
+ <li>
1564
+ <span class="s-title" ${p.sessionId ? `data-session="${esc(p.sessionId)}" data-hl="${esc(hl)}" title="Click to read the transcript"` : ''}>${esc(p.snippet)}</span>
1565
+ <span class="s-when">${esc(p.projectName)}</span>
1566
+ <span class="s-when">${relTime(p.timestamp)}</span>
1567
+ </li>`).join('');
1568
+ $('#d-body').innerHTML =
1569
+ dSection(`Session titles — ${d.titles.length}`, titleRows ? `<ul class="d-sessions">${titleRows}</ul>` : null, 'No title matches.') +
1570
+ dSection(`Prompts — ${d.prompts.length}`, promptRows ? `<ul class="d-sessions">${promptRows}</ul>` : null, 'No prompt matches.') +
1571
+ deepSection;
1572
+ } catch {
1573
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Search failed.</div>';
1574
+ }
1575
+ }
1576
+
1577
+ // ---------- settings ----------
1578
+ async function saveSetting(endpoint, payload, note) {
1579
+ try {
1580
+ const r = await fetch(endpoint, {
1581
+ method: 'POST',
1582
+ headers: { 'Content-Type': 'application/json' },
1583
+ body: JSON.stringify(payload),
1584
+ });
1585
+ const out = await r.json();
1586
+ toast(out.ok ? note : `Couldn't save: ${out.error}`);
1587
+ return out.ok;
1588
+ } catch {
1589
+ toast("Couldn't reach the server");
1590
+ return false;
1591
+ }
1592
+ }
1593
+
1594
+ async function openSettings() {
1595
+ openDrawerShell('Settings', 'changes save instantly');
1596
+ try {
1597
+ const r = await fetch('/api/config');
1598
+ if (!r.ok) throw new Error();
1599
+ const c = await r.json();
1600
+ const nameFor = (p) => {
1601
+ for (const [k, v] of Object.entries(c.names || {})) {
1602
+ if (k.toLowerCase() === p.toLowerCase()) return v;
1603
+ }
1604
+ return '';
1605
+ };
1606
+ const general = `
1607
+ <div class="set-row">
1608
+ <span class="set-label">Notifications<span class="set-sub">waiting-for-input and stuck-session alerts</span></span>
1609
+ <input type="checkbox" id="set-notify" ${c.notifications !== false ? 'checked' : ''}>
1610
+ </div>
1611
+ <div class="set-row">
1612
+ <span class="set-label">Open sessions in<span class="set-sub">terminal used by open ⬈ and ⊕</span></span>
1613
+ <select id="set-terminal">${(c.terminals || [])
1614
+ .map((t) => `<option value="${esc(t.id)}" ${t.id === c.resolvedTerminal.id ? 'selected' : ''}>${esc(t.label)}</option>`)
1615
+ .join('')}</select>
1616
+ </div>
1617
+ <div class="set-row">
1618
+ <span class="set-label">Claude desktop app<span class="set-sub">lets "open" import sessions into the Claude app</span></span>
1619
+ ${c.claudeApp
1620
+ ? '<span style="color:var(--accent);font-size:12px">installed</span>'
1621
+ : '<span style="color:var(--faint);font-size:12px">not installed</span> <a href="https://claude.com/download" target="_blank" rel="noopener" class="copy" style="text-decoration:none">get it ⬈</a>'}
1622
+ </div>
1623
+ <div class="set-row">
1624
+ <span class="set-label">Usage meters<span class="set-sub">fetches your own usage from Anthropic's API using your Claude Code sign-in (kept in memory) — the dashboard's only automatic network call</span></span>
1625
+ <input type="checkbox" id="set-usage" ${c.usageApi !== false ? 'checked' : ''}>
1626
+ </div>
1627
+ <div class="set-row">
1628
+ <span class="set-label">Weekly budget<span class="set-sub">estimated $ per trailing 7 days — header meter plus alerts at 75/90/100%; 0 turns it off</span></span>
1629
+ <input type="text" id="set-budget" inputmode="numeric" style="width:70px;text-align:right"
1630
+ value="${c.weeklyBudget || 0}" placeholder="0">
1631
+ </div>
1632
+ <div class="set-row">
1633
+ <span class="set-label">Version ${esc(c.version || '?')}<span class="set-sub">checks GitHub only when you click — never automatically</span></span>
1634
+ <span id="set-update-result" style="color:var(--faint);font-size:12px"></span>
1635
+ <button class="copy" id="set-update">check for updates</button>
1636
+ </div>`;
1637
+ const diag = (c.errors || []).length
1638
+ ? (c.errors || []).map((e) => `<div class="set-row"><span class="set-label mono" style="font-size:11.5px">${esc(e.message)}<span class="set-sub">${relTime(e.at)}</span></span></div>`).join('')
1639
+ : null;
1640
+ const projects = (lastState?.projects || [])
1641
+ .map((p) => `
1642
+ <div class="set-row">
1643
+ <span class="set-label">${esc(p.name)}<span class="set-sub">${esc(p.path)}</span></span>
1644
+ <input type="text" placeholder="rename…" value="${esc(nameFor(p.path))}" data-rename="${esc(p.path)}">
1645
+ <button class="copy" data-hide="${esc(p.path)}" title="Hide this project (and everything under it) from the dashboard">hide</button>
1646
+ </div>`)
1647
+ .join('');
1648
+ const hidden = (c.ignores || [])
1649
+ .map((p) => `
1650
+ <div class="set-row">
1651
+ <span class="set-label mono" style="font-size:11.5px">${esc(p)}</span>
1652
+ <button class="copy" data-unhide="${esc(p)}">unhide</button>
1653
+ </div>`)
1654
+ .join('');
1655
+ $('#d-body').innerHTML =
1656
+ dSection('General', general) +
1657
+ dSection(`Projects — ${lastState?.projects?.length || 0}`, projects, 'No projects yet.') +
1658
+ dSection(`Hidden — ${(c.ignores || []).length}`, hidden, 'Nothing hidden. The hide buttons above put a project (and its whole subtree) here.') +
1659
+ dSection(`Diagnostics — ${(c.errors || []).length} recent collection errors`, diag, 'No collection errors. Everything is reading cleanly.');
1660
+ $('#set-update').addEventListener('click', async (e) => {
1661
+ const out = $('#set-update-result');
1662
+ out.textContent = 'checking…';
1663
+ try {
1664
+ const r = await (await fetch('/api/update-check')).json();
1665
+ if (r.error) out.textContent = `couldn't check: ${r.error}`;
1666
+ else if (r.upToDate) out.textContent = `up to date (${r.current})`;
1667
+ else out.innerHTML = `${esc(r.latest)} available — <a href="${esc(r.url)}" target="_blank" rel="noopener" style="color:var(--accent)">release notes</a>, then git pull`;
1668
+ } catch {
1669
+ out.textContent = "couldn't reach GitHub";
1670
+ }
1671
+ });
1672
+ $('#set-notify').addEventListener('change', (e) =>
1673
+ saveSetting('/api/config', { notifications: e.target.checked }, e.target.checked ? 'Notifications on' : 'Notifications off'));
1674
+ $('#set-usage').addEventListener('change', (e) =>
1675
+ saveSetting('/api/config', { usageApi: e.target.checked }, e.target.checked ? 'Usage meters on' : 'Usage meters off — header falls back to the statusline cache'));
1676
+ $('#set-budget').addEventListener('change', (e) => {
1677
+ const n = Math.max(0, Math.round(Number(e.target.value) || 0));
1678
+ e.target.value = n;
1679
+ saveSetting('/api/config', { weeklyBudget: n }, n ? `Weekly budget set to $${n}` : 'Weekly budget off');
1680
+ });
1681
+ $('#set-terminal').addEventListener('change', async (e) => {
1682
+ await saveSetting('/api/config', { terminal: e.target.value }, `Sessions will open in ${e.target.selectedOptions[0].text}`);
1683
+ loadOpenPref(); // header label follows the choice
1684
+ });
1685
+ $('#d-body').addEventListener('change', (e) => {
1686
+ const input = e.target.closest('[data-rename]');
1687
+ if (input) saveSetting('/api/names', { path: input.dataset.rename, name: input.value }, input.value.trim() ? `Renamed to "${input.value.trim()}"` : 'Name reset to default');
1688
+ });
1689
+ $('#d-body').addEventListener('click', async (e) => {
1690
+ const hide = e.target.closest('[data-hide]');
1691
+ if (hide && (await saveSetting('/api/ignore', { path: hide.dataset.hide }, 'Hidden'))) { openSettings(); return; }
1692
+ const unhide = e.target.closest('[data-unhide]');
1693
+ if (unhide && (await saveSetting('/api/ignore', { path: unhide.dataset.unhide, remove: true }, 'Unhidden — back after the next scan'))) openSettings();
1694
+ });
1695
+ } catch {
1696
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load settings.</div>';
1697
+ }
1698
+ }
1699
+
1700
+ $('#gear').addEventListener('click', openSettings);
1701
+
1702
+ // '/' focuses search from anywhere
1703
+ document.addEventListener('keydown', (e) => {
1704
+ if (e.key === '/' && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '')) {
1705
+ e.preventDefault();
1706
+ $('#search').focus();
1707
+ }
1708
+ });
1709
+
1710
+ $('#search').addEventListener('keydown', (e) => {
1711
+ if (e.key === 'Enter' && e.target.value.trim().length >= 2) runSearch(e.target.value.trim());
1712
+ });
1713
+
1714
+ document.addEventListener('click', async (e) => {
1715
+ const pin = e.target.closest('[data-pin]');
1716
+ if (pin) {
1717
+ const r = await fetch('/api/config', {
1718
+ method: 'POST',
1719
+ headers: { 'Content-Type': 'application/json' },
1720
+ body: JSON.stringify({ pinSession: pin.dataset.pin }),
1721
+ }).then((x) => x.json()).catch(() => null);
1722
+ if (r && r.ok) {
1723
+ toast(r.pinned ? 'Pinned to the top of the dashboard' : 'Unpinned');
1724
+ if (pin.closest('#drawer')) pin.textContent = r.pinned ? '★ pinned' : '☆ pin';
1725
+ } else {
1726
+ toast("Couldn't update the pin");
1727
+ }
1728
+ return;
1729
+ }
1730
+ if (e.target.closest('[data-replay]')) {
1731
+ if (replay) stopReplay(true);
1732
+ else startReplay();
1733
+ return;
1734
+ }
1735
+ const mb = e.target.closest('[data-mute]');
1736
+ if (mb) {
1737
+ const muted = !mb.dataset.muted;
1738
+ const ok = await saveSetting('/api/config', { mutePath: mb.dataset.mute, muted },
1739
+ muted ? 'Notifications muted for this project' : 'Notifications unmuted');
1740
+ if (ok) {
1741
+ mb.dataset.muted = muted ? '1' : '';
1742
+ mb.textContent = muted ? '🔕 muted — click to unmute' : '🔔 notifying — click to mute';
1743
+ }
1744
+ return;
1745
+ }
1746
+ const t = e.target.closest('[data-session]');
1747
+ if (t) { openTranscript(t.dataset.session, t.dataset.hl); return; }
1748
+ const nb = e.target.closest('[data-new]');
1749
+ if (nb) {
1750
+ nb.disabled = true;
1751
+ try {
1752
+ const r = await fetch('/api/open', {
1753
+ method: 'POST',
1754
+ headers: { 'Content-Type': 'application/json' },
1755
+ body: JSON.stringify({ newSession: true, projectPath: nb.dataset.new, target: 'terminal' }),
1756
+ });
1757
+ const out = await r.json();
1758
+ toast(out.ok ? 'Opening a new session in Ghostty…' : `Couldn't open: ${out.error}`);
1759
+ } catch {
1760
+ toast("Couldn't reach the server");
1761
+ }
1762
+ nb.disabled = false;
1763
+ }
1764
+ });
1765
+
1766
+ document.addEventListener('click', (e) => {
1767
+ const el = e.target.closest('[data-detail]');
1768
+ if (el) openDetail(el.dataset.detail, el.textContent);
1769
+ });
1770
+ $('#d-close').addEventListener('click', closeDetail);
1771
+ $('#drawer-backdrop').addEventListener('click', closeDetail);
1772
+ document.addEventListener('keydown', (e) => {
1773
+ if (e.key === 'Escape') {
1774
+ const drawerOpen = !$('#drawer').hidden;
1775
+ closeDetail();
1776
+ if (typeof closePalette === 'function') closePalette();
1777
+ if (!drawerOpen && typeof closeMissionControl === 'function') closeMissionControl();
1778
+ }
1779
+ });
1780
+
1781
+ // ---------- stats view ----------
1782
+ async function openStats() {
1783
+ openDrawerShell('Stats', 'all projects, ignored ones excluded');
1784
+ try {
1785
+ const r = await fetch('/api/stats');
1786
+ if (!r.ok) throw new Error();
1787
+ const s = await r.json();
1788
+ const max = Math.max(1, ...s.days.map((d) => d.c));
1789
+ // pad so weeks align into columns (Sunday-first)
1790
+ const pad = new Date(s.days[0].t).getDay();
1791
+ const cells = Array(pad).fill('<span class="hc" style="visibility:hidden"></span>').concat(
1792
+ s.days.map((d) => {
1793
+ const date = new Date(d.t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
1794
+ const style = d.c ? `background:var(--accent);opacity:${(0.25 + 0.75 * (d.c / max)).toFixed(2)}` : '';
1795
+ return `<span class="hc" style="${style}" title="${esc(`${date}: ${d.c} prompt${d.c === 1 ? '' : 's'}`)}"></span>`;
1796
+ })
1797
+ );
1798
+ const hmax = Math.max(1, ...s.hours);
1799
+ const hourBars = s.hours.map((c, h) =>
1800
+ `<span class="hb" style="height:${Math.max(4, Math.round((c / hmax) * 100))}%${c ? '' : ';background:var(--surface2)'}"
1801
+ title="${esc(`${h}:00 — ${c} prompts`)}"></span>`).join('');
1802
+ const heat = s.heat || [];
1803
+ const wmax = Math.max(1, ...heat.flat());
1804
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1805
+ const whCells = heat.map((row, d) =>
1806
+ `<span class="wh-day">${dayNames[d]}</span>` + row.map((c, h) => {
1807
+ const style = c ? `background:var(--accent);opacity:${(0.25 + 0.75 * (c / wmax)).toFixed(2)}` : '';
1808
+ return `<span class="hc" style="${style}" title="${esc(`${dayNames[d]} ${h}:00 — ${c} prompt${c === 1 ? '' : 's'}`)}"></span>`;
1809
+ }).join('')
1810
+ ).join('');
1811
+ const costDays = s.costDays || [];
1812
+ const cmax = Math.max(0.01, ...costDays.map((d) => d.cost));
1813
+ const costBars = costDays.map((d) => {
1814
+ const date = new Date(d.t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
1815
+ return `<span class="hb" style="height:${Math.max(4, Math.round((d.cost / cmax) * 100))}%${d.cost ? '' : ';background:var(--surface2)'}"
1816
+ title="${esc(`${date} — ≈$${d.cost.toFixed(2)} · ${(d.tokens / 1e6).toFixed(1)}M tok`)}"></span>`;
1817
+ }).join('');
1818
+ const costStart = costDays.length
1819
+ ? new Date(costDays[0].t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
1820
+ const models = Object.entries(s.byModel).sort((a, b) => b[1].cost - a[1].cost);
1821
+ const modelRows = models.map(([m, e]) => `<tr>
1822
+ <td>${esc(m)}</td>
1823
+ <td>${(e.tokens / 1e6).toFixed(1)}M tok</td>
1824
+ <td>≈$${e.cost.toFixed(2)}</td>
1825
+ </tr>`).join('');
1826
+ const projRows = (s.perProject || []).map((p) => {
1827
+ const parts = Object.entries(p.models)
1828
+ .sort((a, b) => b[1] - a[1])
1829
+ .slice(0, 3)
1830
+ .map(([m, c]) => `${esc(modelShort(m))} ${Math.round((c / p.total) * 100)}%`)
1831
+ .join(' · ');
1832
+ return `<tr><td>${esc(p.name)}</td><td style="text-align:left;color:var(--muted)">${parts}</td><td>≈$${p.total.toFixed(2)}</td></tr>`;
1833
+ }).join('');
1834
+ $('#d-body').innerHTML =
1835
+ '<div style="margin-top:10px"><button class="copy" data-week>your week with Claude →</button></div>' +
1836
+ dSection('Activity — last 26 weeks', `<div class="heat">${cells.join('')}</div>`) +
1837
+ dSection('Busiest hours', `<div class="hours">${hourBars}</div>
1838
+ <div class="hours-labels"><span>12am</span><span>6am</span><span>12pm</span><span>6pm</span><span>11pm</span></div>`) +
1839
+ dSection('Weekly rhythm — prompts by day and hour', heat.length ? `<div class="wh">${whCells}</div>` : null, 'No activity yet.') +
1840
+ dSection('Spend — last 90 days', costDays.length ? `<div class="hours">${costBars}</div>
1841
+ <div class="hours-labels"><span>${esc(costStart)}</span><span>today</span></div>` : null, 'No usage recorded yet.') +
1842
+ dSection('By model — all time', models.length ? `<table class="stat-table">${modelRows}</table>` : null, 'No usage recorded yet.') +
1843
+ dSection('Models by project', projRows ? `<table class="stat-table">${projRows}</table>` : null, 'No project usage yet.') +
1844
+ `<div class="stat-totals"><strong>${s.totals.sessions}</strong> sessions · <strong>${s.totals.prompts}</strong> prompts · <strong>≈$${s.totals.cost}</strong> estimated list-price value, all time</div>`;
1845
+ } catch {
1846
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load stats.</div>';
1847
+ }
1848
+ }
1849
+
1850
+ document.addEventListener('click', (e) => {
1851
+ if (e.target.closest('#wk-chart')) openStats();
1852
+ const ds = e.target.closest('[data-deep-search]');
1853
+ if (ds) runSearch(ds.dataset.deepSearch, true);
1854
+ });
1855
+
1856
+ // ---------- weekly report ----------
1857
+ let weekReport = null;
1858
+ async function openWeekReport() {
1859
+ openDrawerShell('Your week with Claude', 'trailing 7 days · ignored projects excluded');
1860
+ try {
1861
+ const r = await fetch('/api/stats');
1862
+ if (!r.ok) throw new Error();
1863
+ const s = await r.json();
1864
+ const w = s.week || {};
1865
+ const b = lastState && lastState.budget;
1866
+ const waitTotal = (w.waits || []).reduce((a, x) => a + x, 0);
1867
+ const rows = [
1868
+ ['Sessions', String(w.sessions ?? 0)],
1869
+ ['Prompts', String(w.prompts ?? 0)],
1870
+ ['Estimated spend', `≈$${(w.cost || 0).toFixed(2)}${b ? ` of your $${b.limit} budget` : ''}`],
1871
+ ['Busiest day', w.busiestDay || '—'],
1872
+ ['Busiest hour', w.busiestHour != null ? `${w.busiestHour}:00` : '—'],
1873
+ ['Models', (w.models || []).map(modelShort).join(' · ') || '—'],
1874
+ ['Your typical reply time', w.typicalWait ? `${w.typicalWait} (${waitTotal} prompts measured)` : 'not enough data yet'],
1875
+ ];
1876
+ weekReport = { w, b, rows };
1877
+ const table = `<table class="stat-table">${rows.map(([k, v]) =>
1878
+ `<tr><td>${esc(k)}</td><td style="text-align:right">${esc(v)}</td></tr>`).join('')}</table>`;
1879
+ const projRows = (w.projects || []).map((p) =>
1880
+ `<tr><td>${esc(p.name)}</td><td style="text-align:right">≈$${p.cost.toFixed(2)}</td></tr>`).join('');
1881
+ $('#d-body').innerHTML =
1882
+ dSection('The week', table) +
1883
+ dSection('Where it went', projRows ? `<table class="stat-table">${projRows}</table>` : null, 'No spend recorded this week.') +
1884
+ '<div style="margin-top:16px"><button class="copy" data-week-export>download as markdown ⇩</button></div>';
1885
+ } catch {
1886
+ $('#d-body').innerHTML = '<div class="d-empty" style="padding:20px 0">Couldn\'t load the report.</div>';
1887
+ }
1888
+ }
1889
+
1890
+ document.addEventListener('click', (e) => {
1891
+ if (e.target.closest('[data-week]')) { openWeekReport(); return; }
1892
+ if (!e.target.closest('[data-week-export]') || !weekReport) return;
1893
+ const { w, rows } = weekReport;
1894
+ const lines = ['# Your week with Claude', '', `_${new Date().toLocaleDateString()} — trailing 7 days_`, ''];
1895
+ for (const [k, v] of rows) lines.push(`- **${k}:** ${v}`);
1896
+ if ((w.projects || []).length) {
1897
+ lines.push('', '## Where it went', '');
1898
+ for (const p of w.projects) lines.push(`- ${p.name}: ≈$${p.cost.toFixed(2)}`);
1899
+ }
1900
+ const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
1901
+ const a = document.createElement('a');
1902
+ a.href = URL.createObjectURL(blob);
1903
+ a.download = 'week-with-claude.md';
1904
+ a.click();
1905
+ URL.revokeObjectURL(a.href);
1906
+ toast('Report downloaded');
1907
+ });
1908
+
1909
+ // ---------- transcript export ----------
1910
+ document.addEventListener('click', async (e) => {
1911
+ if (!e.target.closest('[data-export]') || !exportableSession) return;
1912
+ try {
1913
+ const r = await fetch(`/api/session?id=${encodeURIComponent(exportableSession.id)}`);
1914
+ const t = await r.json();
1915
+ const lines = [`# ${t.title}`, '', `_${t.projectName} — exported from Claude Dashboard, ${new Date().toLocaleString()}_`, ''];
1916
+ for (const ev of t.events) {
1917
+ if (ev.kind === 'user') lines.push(`**You:** ${ev.text}`, '');
1918
+ else if (ev.kind === 'assistant') lines.push(ev.text, '');
1919
+ else if (ev.kind === 'tool') lines.push(`> ⚙ \`${ev.name}\` ${ev.input || ''}`, '');
1920
+ else if (ev.kind === 'note') lines.push(`> **Recap:** ${ev.text}`, '');
1921
+ }
1922
+ const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
1923
+ const a = document.createElement('a');
1924
+ a.href = URL.createObjectURL(blob);
1925
+ a.download = `${t.title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 60) || 'session'}.md`;
1926
+ a.click();
1927
+ URL.revokeObjectURL(a.href);
1928
+ toast('Transcript downloaded');
1929
+ } catch {
1930
+ toast("Couldn't export");
1931
+ }
1932
+ });
1933
+
1934
+ // ---------- command palette (⌘K) ----------
1935
+ const pal = { items: [], sel: 0 };
1936
+
1937
+ function paletteItems(q) {
1938
+ const items = [];
1939
+ const st = lastState || { projects: [], liveSessions: [] };
1940
+ items.push({ k: '⚙', label: 'Settings', hint: 'rename, hide, terminal, notifications', run: openSettings });
1941
+ items.push({ k: '▦', label: 'Mission Control', hint: 'every live session at once', run: openMissionControl });
1942
+ items.push({ k: '▲', label: 'Stats', hint: 'heatmap · models · hours', run: openStats });
1943
+ items.push({ k: '☰', label: 'Your week with Claude', hint: '7-day report · export', run: openWeekReport });
1944
+ for (const l of st.liveSessions || []) {
1945
+ items.push({ k: '●', label: `Watch live: ${l.projectName} — ${l.title || ''}`, hint: l.status, run: () => openTranscript(l.sessionId) });
1946
+ }
1947
+ for (const p of st.projects || []) {
1948
+ items.push({ k: '❯', label: p.name, hint: 'project details', run: () => openDetail(p.path, p.name) });
1949
+ items.push({ k: '⊕', label: `New session in ${p.name}`, hint: 'terminal', run: () =>
1950
+ fetch('/api/open', { method: 'POST', headers: { 'Content-Type': 'application/json' },
1951
+ body: JSON.stringify({ newSession: true, projectPath: p.path, target: 'terminal' }) })
1952
+ .then((r) => r.json()).then((o) => toast(o.ok ? 'Opening new session…' : `Couldn't open: ${o.error}`)) });
1953
+ for (const s of p.sessions) {
1954
+ items.push({ k: '·', label: `${p.name}: ${s.title}`, hint: relTime(s.lastActivityAt), run: () => openTranscript(s.sessionId) });
1955
+ }
1956
+ }
1957
+ if (!q) return items.filter((i) => ['⚙', '▲', '●', '❯'].includes(i.k)).slice(0, 12);
1958
+ const needle = q.toLowerCase();
1959
+ const scored = [];
1960
+ for (const it of items) {
1961
+ const l = it.label.toLowerCase();
1962
+ let score = -1;
1963
+ if (l.includes(needle)) score = 100 - l.indexOf(needle);
1964
+ else {
1965
+ // subsequence match
1966
+ let i = 0;
1967
+ for (const ch of l) if (ch === needle[i]) i++;
1968
+ if (i === needle.length) score = 10;
1969
+ }
1970
+ if (score >= 0) scored.push([score, it]);
1971
+ }
1972
+ scored.sort((a, b) => b[0] - a[0]);
1973
+ const out = scored.slice(0, 11).map(([, it]) => it);
1974
+ out.push({ k: '🔎', label: `Search all prompts for "${q}"`, hint: 'Enter', run: () => runSearch(q) });
1975
+ return out;
1976
+ }
1977
+
1978
+ function renderPalette() {
1979
+ const list = $('#palette-list');
1980
+ if (!pal.items.length) {
1981
+ list.innerHTML = '<div id="palette-empty">Nothing matches.</div>';
1982
+ return;
1983
+ }
1984
+ list.innerHTML = pal.items.map((it, i) => `
1985
+ <li class="${i === pal.sel ? 'sel' : ''}" data-pi="${i}">
1986
+ <span class="pk">${it.k}</span><span class="pl">${esc(it.label)}</span><span class="ph">${esc(it.hint || '')}</span>
1987
+ </li>`).join('');
1988
+ list.querySelector('.sel')?.scrollIntoView({ block: 'nearest' });
1989
+ }
1990
+
1991
+ function openPalette() {
1992
+ closeDetail();
1993
+ $('#palette').hidden = false;
1994
+ $('#palette-backdrop').hidden = false;
1995
+ const input = $('#palette-input');
1996
+ input.value = '';
1997
+ pal.sel = 0;
1998
+ pal.items = paletteItems('');
1999
+ renderPalette();
2000
+ input.focus();
2001
+ }
2002
+
2003
+ function closePalette() {
2004
+ $('#palette').hidden = true;
2005
+ $('#palette-backdrop').hidden = true;
2006
+ }
2007
+
2008
+ function runPaletteSelection(i) {
2009
+ const it = pal.items[i];
2010
+ if (!it) return;
2011
+ closePalette();
2012
+ it.run();
2013
+ }
2014
+
2015
+ $('#palette-input').addEventListener('input', (e) => {
2016
+ pal.items = paletteItems(e.target.value.trim());
2017
+ pal.sel = 0;
2018
+ renderPalette();
2019
+ });
2020
+ $('#palette-input').addEventListener('keydown', (e) => {
2021
+ if (e.key === 'ArrowDown') { e.preventDefault(); pal.sel = Math.min(pal.sel + 1, pal.items.length - 1); renderPalette(); }
2022
+ else if (e.key === 'ArrowUp') { e.preventDefault(); pal.sel = Math.max(pal.sel - 1, 0); renderPalette(); }
2023
+ else if (e.key === 'Enter') runPaletteSelection(pal.sel);
2024
+ else if (e.key === 'Escape') closePalette();
2025
+ });
2026
+ $('#palette-list').addEventListener('click', (e) => {
2027
+ const li = e.target.closest('[data-pi]');
2028
+ if (li) runPaletteSelection(Number(li.dataset.pi));
2029
+ });
2030
+ $('#palette-backdrop').addEventListener('click', closePalette);
2031
+ document.addEventListener('keydown', (e) => {
2032
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
2033
+ e.preventDefault();
2034
+ $('#palette').hidden ? openPalette() : closePalette();
2035
+ }
2036
+ });
2037
+
2038
+ poll();
2039
+ connect();
2040
+ setInterval(poll, 60_000);
2041
+ // keep relative times fresh even without state changes
2042
+ setInterval(() => { if (lastState) render(lastState); }, 30_000);
2043
+ </script>
2044
+ </body>
2045
+ </html>