runtime-inspector 0.1.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,1178 @@
1
+ // Embedded dashboard — returns full HTML with inline React app
2
+ // Mission-control list view with sorting, filtering, top offenders, detail drawer
3
+
4
+ export function dashboardHTML() {
5
+ return `<!DOCTYPE html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="UTF-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
+ <title>RuntimeInspector</title>
11
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
12
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
13
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
14
+ <style>
15
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
16
+
17
+ :root {
18
+ --bg: #08080d;
19
+ --bg-card: #0f0f18;
20
+ --bg-card-hover: #161625;
21
+ --bg-elevated: #111120;
22
+ --bg-purpose: #0c0c16;
23
+ --border: #1a1a2e;
24
+ --border-hover: #2a2a4a;
25
+ --text: #e0e0ef;
26
+ --text-dim: #6e6e8a;
27
+ --text-muted: #4a4a62;
28
+ --text-purpose: #9898b8;
29
+ --accent-blue: #5b8af5;
30
+ --accent-green: #34d399;
31
+ --accent-amber: #f59e0b;
32
+ --accent-red: #ef4444;
33
+ --accent-purple: #a78bfa;
34
+ --accent-cyan: #22d3ee;
35
+ }
36
+
37
+ body {
38
+ background: var(--bg);
39
+ color: var(--text);
40
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Inter', system-ui, sans-serif;
41
+ line-height: 1.5;
42
+ -webkit-font-smoothing: antialiased;
43
+ }
44
+
45
+ .container { max-width: 1280px; margin: 0 auto; padding: 20px 24px; }
46
+
47
+ /* ---- Header ---- */
48
+ .header { margin-bottom: 20px; }
49
+ .header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; }
50
+ .header-title { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
51
+ .header-title .blue { color: var(--accent-blue); }
52
+ .live-dot { display: inline-flex; align-items: center; gap: 6px; margin-left: 12px; }
53
+ .live-dot .dot {
54
+ width: 7px; height: 7px; border-radius: 50%; background: var(--accent-green);
55
+ animation: pulse-dot 2s ease-in-out infinite;
56
+ }
57
+ .live-dot span { font-size: 11px; color: var(--accent-green); font-weight: 500; }
58
+ @keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.3} }
59
+ @keyframes pulse-state { 0%,100%{opacity:1} 50%{opacity:0.5} }
60
+ .header-stats { font-size: 11px; color: var(--text-dim); text-align: right; }
61
+ .header-sub { font-size: 12px; color: var(--text-dim); }
62
+
63
+ /* ---- Top Offenders Strip ---- */
64
+ .offenders {
65
+ display: flex; gap: 10px; margin-bottom: 16px; overflow-x: auto;
66
+ scrollbar-width: none; -ms-overflow-style: none;
67
+ }
68
+ .offenders::-webkit-scrollbar { display: none; }
69
+ .offender {
70
+ flex: 0 0 auto; min-width: 200px; max-width: 260px;
71
+ background: var(--bg-card); border: 1px solid var(--border);
72
+ border-radius: 8px; padding: 10px 14px; cursor: pointer;
73
+ transition: border-color 0.15s, background 0.15s;
74
+ }
75
+ .offender:hover { border-color: var(--border-hover); background: var(--bg-card-hover); }
76
+ .offender-label {
77
+ font-size: 9px; text-transform: uppercase; letter-spacing: 1px;
78
+ color: var(--text-muted); font-weight: 600; margin-bottom: 4px;
79
+ }
80
+ .offender-value {
81
+ font-size: 20px; font-weight: 700; letter-spacing: -0.5px; line-height: 1.2;
82
+ }
83
+ .offender-name {
84
+ font-size: 11px; color: var(--text-dim); margin-top: 2px;
85
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
86
+ }
87
+ .offender .red { color: var(--accent-red); }
88
+ .offender .amber { color: var(--accent-amber); }
89
+ .offender .cyan { color: var(--accent-cyan); }
90
+ .offender .purple { color: var(--accent-purple); }
91
+
92
+ /* ---- Toolbar ---- */
93
+ .toolbar {
94
+ display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
95
+ flex-wrap: wrap;
96
+ }
97
+ .search-box {
98
+ flex: 0 0 220px; height: 32px; padding: 0 10px;
99
+ background: var(--bg-card); border: 1px solid var(--border);
100
+ border-radius: 6px; color: var(--text); font-size: 12px;
101
+ outline: none; transition: border-color 0.15s;
102
+ }
103
+ .search-box::placeholder { color: var(--text-muted); }
104
+ .search-box:focus { border-color: var(--accent-blue); }
105
+
106
+ .sort-select {
107
+ height: 32px; padding: 0 8px; background: var(--bg-card);
108
+ border: 1px solid var(--border); border-radius: 6px;
109
+ color: var(--text); font-size: 11px; font-weight: 500;
110
+ cursor: pointer; outline: none;
111
+ -webkit-appearance: none; appearance: none;
112
+ background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%236e6e8a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
113
+ background-repeat: no-repeat; background-position: right 8px center;
114
+ padding-right: 24px;
115
+ }
116
+ .sort-select option { background: var(--bg-card); color: var(--text); }
117
+
118
+ .filter-chips { display: flex; gap: 6px; flex-wrap: wrap; }
119
+ .chip {
120
+ font-size: 10px; font-weight: 600; padding: 4px 10px;
121
+ border-radius: 14px; border: 1px solid var(--border);
122
+ background: transparent; color: var(--text-dim); cursor: pointer;
123
+ transition: all 0.12s; white-space: nowrap; letter-spacing: 0.3px;
124
+ }
125
+ .chip:hover { border-color: var(--border-hover); color: var(--text); }
126
+ .chip.active {
127
+ background: rgba(91,138,245,0.12); border-color: rgba(91,138,245,0.35);
128
+ color: var(--accent-blue);
129
+ }
130
+
131
+ /* ---- List Table ---- */
132
+ .list-table { width: 100%; border-collapse: separate; border-spacing: 0; }
133
+ .list-table thead th {
134
+ font-size: 9px; text-transform: uppercase; letter-spacing: 1px;
135
+ font-weight: 600; color: var(--text-muted); padding: 6px 12px;
136
+ text-align: left; border-bottom: 1px solid var(--border);
137
+ position: sticky; top: 0; background: var(--bg); z-index: 2;
138
+ cursor: pointer; user-select: none; white-space: nowrap;
139
+ }
140
+ .list-table thead th:hover { color: var(--text-dim); }
141
+ .list-table thead th.sorted { color: var(--accent-blue); }
142
+ .th-right { text-align: right !important; }
143
+
144
+ .list-table tbody tr {
145
+ cursor: pointer; transition: background 0.1s;
146
+ }
147
+ .list-table tbody tr:hover { background: var(--bg-card-hover); }
148
+ .list-table tbody tr.selected { background: rgba(91,138,245,0.06); }
149
+ .list-table tbody td {
150
+ padding: 9px 12px; font-size: 12px; border-bottom: 1px solid rgba(255,255,255,0.025);
151
+ white-space: nowrap; vertical-align: middle;
152
+ }
153
+ .td-right { text-align: right; }
154
+
155
+ .row-title { display: flex; align-items: center; gap: 8px; }
156
+ .row-icon { font-size: 18px; line-height: 1; }
157
+ .row-name {
158
+ font-weight: 600; font-size: 13px; max-width: 200px;
159
+ overflow: hidden; text-overflow: ellipsis;
160
+ }
161
+ .row-repo {
162
+ color: var(--text-dim); font-size: 11px; max-width: 120px;
163
+ overflow: hidden; text-overflow: ellipsis;
164
+ }
165
+ .row-cpu { font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; }
166
+ .row-mem { font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; }
167
+ .row-dur { font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; color: var(--text-dim); }
168
+ .row-eta { font-size: 10px; color: var(--accent-cyan); }
169
+ .row-score {
170
+ font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace;
171
+ font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px;
172
+ }
173
+ .score-high { background: rgba(239,68,68,0.12); color: var(--accent-red); }
174
+ .score-med { background: rgba(245,158,11,0.12); color: var(--accent-amber); }
175
+ .score-low { background: rgba(110,110,138,0.08); color: var(--text-dim); }
176
+
177
+ /* State / badge pills in table */
178
+ .state-pill {
179
+ display: inline-block; font-size: 9px; font-weight: 600;
180
+ text-transform: uppercase; letter-spacing: 0.5px;
181
+ padding: 2px 7px; border-radius: 10px;
182
+ }
183
+ .state-pill.starting { background: rgba(91,138,245,0.15); color: var(--accent-blue); animation: pulse-state 1.5s infinite; }
184
+ .state-pill.progressing { background: rgba(52,211,153,0.12); color: var(--accent-green); }
185
+ .state-pill.working { background: rgba(245,158,11,0.12); color: var(--accent-amber); }
186
+ .state-pill.idle { background: rgba(110,110,138,0.12); color: var(--text-dim); }
187
+ .state-pill.stuck { background: rgba(239,68,68,0.15); color: var(--accent-red); animation: pulse-state 1.5s infinite; }
188
+ .state-pill.possibly-stuck { background: rgba(245,158,11,0.12); color: var(--accent-amber); animation: pulse-state 2s infinite; }
189
+ .state-pill.done { background: rgba(52,211,153,0.12); color: var(--accent-green); }
190
+ .state-pill.failed { background: rgba(239,68,68,0.15); color: var(--accent-red); }
191
+
192
+ .badge-mini {
193
+ display: inline-block; font-size: 9px; font-weight: 500;
194
+ padding: 1px 5px; border-radius: 4px; margin-left: 4px;
195
+ }
196
+ .badge-mini.long-running { background: rgba(245,158,11,0.1); color: var(--accent-amber); }
197
+ .badge-mini.high-cpu { background: rgba(239,68,68,0.1); color: var(--accent-red); }
198
+ .badge-mini.idle { background: rgba(110,110,138,0.08); color: var(--text-dim); }
199
+ .badge-mini.orphan { background: rgba(239,68,68,0.06); color: var(--accent-red); }
200
+ .badge-mini.wrapped { background: rgba(91,138,245,0.08); color: var(--accent-blue); }
201
+ .badge-mini.file-progress { background: rgba(52,211,153,0.1); color: var(--accent-green); }
202
+ .badge-mini.no-file-progress { background: rgba(245,158,11,0.1); color: var(--accent-amber); }
203
+ .badge-mini.alert { background: rgba(239,68,68,0.12); color: var(--accent-red); }
204
+ .badge-mini.warn { background: rgba(245,158,11,0.12); color: var(--accent-amber); }
205
+
206
+ /* Safe-to-leave banner */
207
+ .safe-banner {
208
+ display: flex; align-items: center; gap: 10px;
209
+ padding: 8px 16px; border-radius: 8px; margin-bottom: 14px;
210
+ font-size: 12px; font-weight: 500;
211
+ }
212
+ .safe-banner.safe {
213
+ background: rgba(52,211,153,0.06); border: 1px solid rgba(52,211,153,0.15);
214
+ color: var(--accent-green);
215
+ }
216
+ .safe-banner.unsafe {
217
+ background: rgba(239,68,68,0.06); border: 1px solid rgba(239,68,68,0.15);
218
+ color: var(--accent-red);
219
+ }
220
+ .safe-banner .safe-icon { font-size: 16px; }
221
+ .safe-banner .safe-count { margin-left: auto; font-size: 11px; opacity: 0.8; }
222
+
223
+ /* Attention reasons in drawer */
224
+ .attention-reason {
225
+ display: flex; align-items: center; gap: 8px;
226
+ font-size: 12px; padding: 5px 0;
227
+ }
228
+ .attention-dot {
229
+ width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
230
+ }
231
+ .attention-dot.alert { background: var(--accent-red); }
232
+ .attention-dot.warn { background: var(--accent-amber); }
233
+
234
+ /* Last command display */
235
+ .last-cmd {
236
+ font-family: 'SF Mono', monospace; font-size: 11px; color: var(--text-dim);
237
+ max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
238
+ }
239
+
240
+ /* tmux badge */
241
+ .badge-mini.tmux { background: rgba(34,211,238,0.1); color: var(--accent-cyan); }
242
+ .tmux-output-age {
243
+ font-family: 'SF Mono', monospace; font-size: 10px; color: var(--text-dim);
244
+ }
245
+ .tmux-output-age.fresh { color: var(--accent-green); }
246
+ .tmux-output-age.stale { color: var(--accent-amber); }
247
+ .tmux-info {
248
+ font-size: 12px; color: var(--text-dim);
249
+ display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
250
+ }
251
+ .tmux-info code {
252
+ font-family: 'SF Mono', monospace; font-size: 11px;
253
+ background: var(--bg-purpose); padding: 1px 5px; border-radius: 3px;
254
+ color: var(--accent-cyan);
255
+ }
256
+
257
+ /* File activity indicator in table */
258
+ .file-activity {
259
+ display: inline-flex; align-items: center; gap: 4px;
260
+ font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px;
261
+ }
262
+ .file-dot {
263
+ width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
264
+ }
265
+ .file-dot.active { background: var(--accent-green); animation: pulse-dot 1.5s infinite; }
266
+ .file-dot.stale { background: var(--text-muted); }
267
+
268
+ /* Repo activity panel in drawer */
269
+ .repo-activity-grid {
270
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
271
+ }
272
+ .repo-activity-val {
273
+ font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums;
274
+ letter-spacing: -0.3px;
275
+ }
276
+ .repo-activity-label { font-size: 10px; color: var(--text-dim); }
277
+ .repo-activity-bar {
278
+ margin-top: 10px; height: 4px; border-radius: 2px; background: var(--border);
279
+ overflow: hidden;
280
+ }
281
+ .repo-activity-fill {
282
+ height: 100%; border-radius: 2px; transition: width 0.5s ease-out;
283
+ }
284
+
285
+ /* ---- Detail Drawer ---- */
286
+ .drawer-overlay {
287
+ position: fixed; inset: 0; background: rgba(0,0,0,0.5);
288
+ z-index: 100; animation: fade-in 0.15s ease-out;
289
+ }
290
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
291
+ .drawer {
292
+ position: fixed; top: 0; right: 0; bottom: 0; width: 520px; max-width: 90vw;
293
+ background: var(--bg-elevated); border-left: 1px solid var(--border);
294
+ z-index: 101; overflow-y: auto; animation: slide-in 0.2s ease-out;
295
+ padding: 0;
296
+ }
297
+ @keyframes slide-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
298
+
299
+ .drawer-header {
300
+ display: flex; align-items: flex-start; justify-content: space-between;
301
+ padding: 20px 24px 16px; border-bottom: 1px solid var(--border);
302
+ position: sticky; top: 0; background: var(--bg-elevated); z-index: 1;
303
+ }
304
+ .drawer-id { display: flex; align-items: center; gap: 12px; }
305
+ .drawer-icon { font-size: 32px; line-height: 1; }
306
+ .drawer-name { font-size: 16px; font-weight: 700; }
307
+ .drawer-type {
308
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px;
309
+ font-weight: 500; margin-top: 2px;
310
+ }
311
+ .drawer-close {
312
+ width: 28px; height: 28px; border-radius: 6px;
313
+ border: 1px solid var(--border); background: transparent;
314
+ color: var(--text-dim); font-size: 14px; cursor: pointer;
315
+ display: flex; align-items: center; justify-content: center;
316
+ transition: all 0.1s;
317
+ }
318
+ .drawer-close:hover { background: var(--border); color: var(--text); }
319
+
320
+ .drawer-section { padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.03); }
321
+ .drawer-section:last-child { border-bottom: none; }
322
+ .drawer-label {
323
+ font-size: 9px; text-transform: uppercase; letter-spacing: 1px;
324
+ font-weight: 600; color: var(--text-muted); margin-bottom: 8px;
325
+ }
326
+
327
+ .drawer-stats {
328
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
329
+ }
330
+ .drawer-stat-val {
331
+ font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums;
332
+ letter-spacing: -0.5px;
333
+ }
334
+ .drawer-stat-label { font-size: 10px; color: var(--text-dim); }
335
+
336
+ .drawer-purpose {
337
+ font-size: 13px; color: var(--text-purpose); font-weight: 500;
338
+ padding: 8px 12px; background: var(--bg-purpose); border-radius: 6px;
339
+ border-left: 3px solid var(--accent-blue);
340
+ }
341
+ .drawer-explanation {
342
+ font-size: 12px; color: var(--text-dim); line-height: 1.6;
343
+ }
344
+ .drawer-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
345
+
346
+ /* Drawer actions */
347
+ .drawer-actions { display: flex; gap: 8px; }
348
+ .drawer-action-btn {
349
+ font-size: 11px; font-weight: 500; padding: 7px 14px;
350
+ border-radius: 6px; border: 1px solid var(--border);
351
+ background: transparent; color: var(--text-dim);
352
+ cursor: pointer; transition: all 0.12s;
353
+ display: flex; align-items: center; gap: 6px;
354
+ }
355
+ .drawer-action-btn:hover {
356
+ background: var(--border); color: var(--text); border-color: var(--border-hover);
357
+ }
358
+ .drawer-action-btn:disabled { opacity: 0.3; cursor: not-allowed; }
359
+ .drawer-action-btn.copied { color: var(--accent-green); border-color: rgba(52,211,153,0.3); }
360
+
361
+ /* Drawer process list */
362
+ .drawer-proc {
363
+ display: flex; align-items: center; gap: 10px;
364
+ font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
365
+ padding: 5px 8px; border-radius: 4px;
366
+ }
367
+ .drawer-proc:hover { background: rgba(255,255,255,0.02); }
368
+ .drawer-proc .pid { color: var(--text-muted); width: 48px; flex-shrink: 0; }
369
+ .drawer-proc .cmd { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
370
+ .drawer-proc .st { color: var(--text-dim); width: 60px; text-align: right; flex-shrink: 0; }
371
+
372
+ /* Drawer output */
373
+ .drawer-output {
374
+ max-height: 300px; overflow-y: auto;
375
+ background: #060610; border-radius: 6px; padding: 10px;
376
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
377
+ font-size: 11px; line-height: 1.6;
378
+ }
379
+ .output-line { display: flex; gap: 8px; }
380
+ .output-line .ts {
381
+ color: var(--text-dim); opacity: 0.4; flex-shrink: 0; font-size: 10px; min-width: 55px;
382
+ }
383
+ .output-line .text { white-space: pre-wrap; word-break: break-all; }
384
+ .output-line.stderr .text { color: var(--accent-red); }
385
+ .output-line.stdout .text { color: var(--text); opacity: 0.85; }
386
+
387
+ /* ---- Empty / Error ---- */
388
+ .empty { text-align: center; padding: 80px 20px; }
389
+ .empty-icon { font-size: 48px; margin-bottom: 16px; }
390
+ .empty-text { font-size: 15px; color: var(--text-dim); }
391
+ .empty-sub { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
392
+
393
+ .error-banner {
394
+ background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2);
395
+ border-radius: 8px; padding: 10px 16px; margin-bottom: 16px;
396
+ font-size: 12px; color: var(--accent-red);
397
+ }
398
+
399
+ /* Toast */
400
+ .toast {
401
+ position: fixed; bottom: 24px; right: 24px;
402
+ background: #1a1a2e; border: 1px solid var(--border-hover);
403
+ color: var(--accent-green); font-size: 12px; font-weight: 500;
404
+ padding: 8px 16px; border-radius: 8px;
405
+ animation: toast-in 0.2s ease-out; z-index: 200;
406
+ }
407
+ @keyframes toast-in { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
408
+
409
+ /* Scrollbar */
410
+ ::-webkit-scrollbar { width: 5px; }
411
+ ::-webkit-scrollbar-track { background: transparent; }
412
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
413
+
414
+ /* Responsive */
415
+ @media (max-width: 800px) {
416
+ .offenders { flex-direction: column; }
417
+ .offender { min-width: 0; max-width: none; }
418
+ .toolbar { flex-direction: column; align-items: stretch; }
419
+ .search-box { flex: 1 1 auto; }
420
+ .drawer { width: 100vw; max-width: 100vw; }
421
+ }
422
+ </style>
423
+ </head>
424
+ <body>
425
+ <div id="root"></div>
426
+ <script type="text/babel">
427
+ const { useState, useEffect, useCallback, useRef, useMemo } = React;
428
+
429
+ // ===== Constants =====
430
+
431
+ const TYPE_COLORS = {
432
+ 'ai-agent': 'var(--accent-purple)',
433
+ 'dev-server': 'var(--accent-blue)',
434
+ 'script': 'var(--accent-green)',
435
+ 'unknown': 'var(--text-dim)',
436
+ };
437
+
438
+ const STATE_LABELS = {
439
+ starting: 'Starting', progressing: 'Running', working: 'Working',
440
+ idle: 'Idle', stuck: 'Stuck', 'possibly-stuck': 'Possibly Stuck',
441
+ done: 'Done', failed: 'Failed',
442
+ };
443
+
444
+ const SORT_OPTIONS = [
445
+ { value: 'attention', label: 'Attention' },
446
+ { value: 'cpu', label: 'CPU' },
447
+ { value: 'memory', label: 'Memory' },
448
+ { value: 'duration', label: 'Duration' },
449
+ { value: 'files', label: 'File Activity' },
450
+ { value: 'state', label: 'State (stuck first)' },
451
+ ];
452
+
453
+ const FILTER_OPTIONS = [
454
+ { value: 'ai-agent', label: 'AI Agents' },
455
+ { value: 'dev-server', label: 'Dev Servers' },
456
+ { value: 'long-running', label: 'Long-running' },
457
+ { value: 'high-cpu', label: 'High CPU' },
458
+ { value: 'stuck', label: 'Stuck' },
459
+ { value: 'file-activity', label: 'File Activity' },
460
+ ];
461
+
462
+ // ===== Attention Score =====
463
+ //
464
+ // Computes a 0-100 score reflecting how much a session "needs your attention":
465
+ // cpuFactor = min(cpu / 100, 1) weight 30
466
+ // memFactor = min(memory / 50, 1) weight 15
467
+ // durFactor = min(durationSec / 7200, 1) weight 15 (caps at 2h)
468
+ // statusFactor = stuck +40, high-cpu +20, long-running +10, idle +5
469
+ //
470
+ // Final = clamp(0, 100, cpuFactor*30 + memFactor*15 + durFactor*15 + statusBonus)
471
+
472
+ function attentionScore(s) {
473
+ const cpuF = Math.min((s.cpu || 0) / 100, 1) * 30;
474
+ const memF = Math.min((s.memory || 0) / 50, 1) * 15;
475
+ const durF = Math.min((s.durationSeconds || 0) / 7200, 1) * 15;
476
+ let bonus = 0;
477
+ const st = s.status || [];
478
+ const rs = s.runtimeState || '';
479
+ if (rs === 'stuck' || st.includes('stuck')) bonus += 40;
480
+ if (st.includes('high-cpu')) bonus += 20;
481
+ if (st.includes('long-running')) bonus += 10;
482
+ if (rs === 'idle' || st.includes('idle')) bonus += 5;
483
+ if (rs === 'failed') bonus += 35;
484
+ if (rs === 'possibly-stuck' || st.includes('no-file-progress')) bonus += 25;
485
+ // Reduce attention if repo is actively producing files (healthy signal)
486
+ const fileCount = s.repoActivity?.filesChangedLast2m || 0;
487
+ if (fileCount > 0 && bonus > 0) bonus = Math.max(0, bonus - 10);
488
+ return Math.round(Math.min(100, Math.max(0, cpuF + memF + durF + bonus)));
489
+ }
490
+
491
+ function scoreClass(score) {
492
+ if (score >= 50) return 'score-high';
493
+ if (score >= 20) return 'score-med';
494
+ return 'score-low';
495
+ }
496
+
497
+ // ===== Helpers =====
498
+
499
+ function stripAnsi(str) {
500
+ return (str || '').replace(/\\x1B\\[[0-9;]*[a-zA-Z]/g, '').replace(/\\u001b\\[[0-9;]*[a-zA-Z]/g, '');
501
+ }
502
+
503
+ function fmtTime(iso) {
504
+ try { return new Date(iso).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit', second:'2-digit' }); }
505
+ catch { return ''; }
506
+ }
507
+
508
+ function fmtAge(ms) {
509
+ if (!ms) return null;
510
+ const sec = Math.round((Date.now() - ms) / 1000);
511
+ if (sec < 5) return 'just now';
512
+ if (sec < 60) return sec + 's ago';
513
+ if (sec < 3600) return Math.round(sec / 60) + 'm ago';
514
+ return Math.round(sec / 3600) + 'h ago';
515
+ }
516
+
517
+ async function doAction(action, sessionId) {
518
+ const res = await fetch('/api/actions/' + action, {
519
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
520
+ body: JSON.stringify({ sessionId }),
521
+ });
522
+ return res.json();
523
+ }
524
+
525
+ // ===== Sort & Filter Logic =====
526
+
527
+ const STATE_PRIORITY = { stuck: 0, 'possibly-stuck': 1, failed: 2, working: 3, progressing: 4, starting: 5, idle: 6, done: 7 };
528
+
529
+ function getFileCount(s) { return s.repoActivity?.filesChangedLast2m || 0; }
530
+
531
+ function sortSessions(sessions, sortBy) {
532
+ const arr = [...sessions];
533
+ switch (sortBy) {
534
+ case 'cpu': return arr.sort((a, b) => (b.cpu || 0) - (a.cpu || 0));
535
+ case 'memory': return arr.sort((a, b) => (b.memory || 0) - (a.memory || 0));
536
+ case 'duration': return arr.sort((a, b) => (b.durationSeconds || 0) - (a.durationSeconds || 0));
537
+ case 'files': return arr.sort((a, b) => getFileCount(b) - getFileCount(a));
538
+ case 'state':
539
+ return arr.sort((a, b) => {
540
+ const ap = STATE_PRIORITY[a.runtimeState] ?? 6;
541
+ const bp = STATE_PRIORITY[b.runtimeState] ?? 6;
542
+ return ap - bp || (b.cpu || 0) - (a.cpu || 0);
543
+ });
544
+ case 'attention':
545
+ default:
546
+ return arr.sort((a, b) => attentionScore(b) - attentionScore(a));
547
+ }
548
+ }
549
+
550
+ function filterSessions(sessions, filters, search) {
551
+ let result = sessions;
552
+ if (filters.length > 0) {
553
+ result = result.filter(s => {
554
+ for (const f of filters) {
555
+ if (f === 'ai-agent' && s.type === 'ai-agent') return true;
556
+ if (f === 'dev-server' && s.type === 'dev-server') return true;
557
+ if (f === 'long-running' && s.status?.includes('long-running')) return true;
558
+ if (f === 'high-cpu' && s.status?.includes('high-cpu')) return true;
559
+ if (f === 'stuck' && (s.runtimeState === 'stuck' || s.runtimeState === 'possibly-stuck' || s.status?.includes('stuck'))) return true;
560
+ if (f === 'file-activity' && s.repoActivity?.filesChangedLast2m > 0) return true;
561
+ }
562
+ return false;
563
+ });
564
+ }
565
+ if (search.trim()) {
566
+ const q = search.toLowerCase();
567
+ result = result.filter(s =>
568
+ (s.title || '').toLowerCase().includes(q) ||
569
+ (s.repo || '').toLowerCase().includes(q) ||
570
+ (s.wrappedCmd || '').toLowerCase().includes(q) ||
571
+ (s.explanation || '').toLowerCase().includes(q)
572
+ );
573
+ }
574
+ return result;
575
+ }
576
+
577
+ // ===== Components =====
578
+
579
+ function TopOffenders({ sessions, onSelect }) {
580
+ if (sessions.length === 0) return null;
581
+
582
+ const highCpu = [...sessions].sort((a, b) => (b.cpu || 0) - (a.cpu || 0))[0];
583
+ const highMem = [...sessions].sort((a, b) => (b.memory || 0) - (a.memory || 0))[0];
584
+ const longest = [...sessions].sort((a, b) => (b.durationSeconds || 0) - (a.durationSeconds || 0))[0];
585
+ const stuckCount = sessions.filter(s => s.runtimeState === 'stuck' || s.status?.includes('stuck')).length;
586
+
587
+ const cards = [];
588
+ if (highCpu && highCpu.cpu > 0) {
589
+ cards.push(
590
+ <div key="cpu" className="offender" onClick={() => onSelect(highCpu.id)}>
591
+ <div className="offender-label">Highest CPU</div>
592
+ <div className="offender-value red">{highCpu.cpu}%</div>
593
+ <div className="offender-name">{highCpu.title}</div>
594
+ </div>
595
+ );
596
+ }
597
+ if (highMem && highMem.memory > 0) {
598
+ cards.push(
599
+ <div key="mem" className="offender" onClick={() => onSelect(highMem.id)}>
600
+ <div className="offender-label">Highest Memory</div>
601
+ <div className="offender-value amber">{highMem.memory}%</div>
602
+ <div className="offender-name">{highMem.title}</div>
603
+ </div>
604
+ );
605
+ }
606
+ if (longest && longest.durationSeconds > 0) {
607
+ cards.push(
608
+ <div key="dur" className="offender" onClick={() => onSelect(longest.id)}>
609
+ <div className="offender-label">Longest Running</div>
610
+ <div className="offender-value cyan">{longest.duration}</div>
611
+ <div className="offender-name">{longest.title}</div>
612
+ </div>
613
+ );
614
+ }
615
+ const activeRepo = [...sessions]
616
+ .filter(s => s.repoActivity?.filesChangedLast2m > 0)
617
+ .sort((a, b) => b.repoActivity.filesChangedLast2m - a.repoActivity.filesChangedLast2m)[0];
618
+ if (activeRepo) {
619
+ cards.push(
620
+ <div key="files" className="offender" onClick={() => onSelect(activeRepo.id)}>
621
+ <div className="offender-label">Most Active Repo</div>
622
+ <div className="offender-value" style={{color:'var(--accent-green)'}}>{activeRepo.repoActivity.filesChangedLast2m} files</div>
623
+ <div className="offender-name">{activeRepo.repo || activeRepo.title}</div>
624
+ </div>
625
+ );
626
+ }
627
+ if (stuckCount > 0) {
628
+ cards.push(
629
+ <div key="stuck" className="offender" onClick={() => onSelect('__filter_stuck')}>
630
+ <div className="offender-label">Stuck Sessions</div>
631
+ <div className="offender-value red">{stuckCount}</div>
632
+ <div className="offender-name">need attention</div>
633
+ </div>
634
+ );
635
+ }
636
+
637
+ if (cards.length === 0) return null;
638
+ return <div className="offenders">{cards}</div>;
639
+ }
640
+
641
+ function Toolbar({ sortBy, onSortChange, filters, onToggleFilter, search, onSearchChange }) {
642
+ return (
643
+ <div className="toolbar">
644
+ <input
645
+ className="search-box"
646
+ type="text"
647
+ placeholder="Search sessions..."
648
+ value={search}
649
+ onChange={e => onSearchChange(e.target.value)}
650
+ />
651
+ <select className="sort-select" value={sortBy} onChange={e => onSortChange(e.target.value)}>
652
+ {SORT_OPTIONS.map(o => (
653
+ <option key={o.value} value={o.value}>Sort: {o.label}</option>
654
+ ))}
655
+ </select>
656
+ <div className="filter-chips">
657
+ {FILTER_OPTIONS.map(f => (
658
+ <button
659
+ key={f.value}
660
+ className={"chip" + (filters.includes(f.value) ? " active" : "")}
661
+ onClick={() => onToggleFilter(f.value)}
662
+ >{f.label}</button>
663
+ ))}
664
+ </div>
665
+ </div>
666
+ );
667
+ }
668
+
669
+ function SessionList({ sessions, selectedId, onSelect, sortBy, onColumnSort }) {
670
+ return (
671
+ <table className="list-table">
672
+ <thead>
673
+ <tr>
674
+ <th style={{width:'30%'}} className={sortBy==='attention'?'sorted':''} onClick={()=>onColumnSort('attention')}>Session</th>
675
+ <th style={{width:'10%'}}>Repo</th>
676
+ <th style={{width:'10%'}} className={sortBy==='state'?'sorted':''} onClick={()=>onColumnSort('state')}>State</th>
677
+ <th className={"th-right "+(sortBy==='cpu'?'sorted':'')} style={{width:'8%'}} onClick={()=>onColumnSort('cpu')}>CPU</th>
678
+ <th className={"th-right "+(sortBy==='memory'?'sorted':'')} style={{width:'8%'}} onClick={()=>onColumnSort('memory')}>Mem</th>
679
+ <th className={"th-right "+(sortBy==='duration'?'sorted':'')} style={{width:'9%'}} onClick={()=>onColumnSort('duration')}>Duration</th>
680
+ <th className={"th-right "+(sortBy==='files'?'sorted':'')} style={{width:'7%'}} onClick={()=>onColumnSort('files')}>Files</th>
681
+ <th style={{width:'10%'}}>Last Cmd</th>
682
+ <th style={{width:'10%'}}>Badges</th>
683
+ </tr>
684
+ </thead>
685
+ <tbody>
686
+ {sessions.map(s => {
687
+ const score = attentionScore(s);
688
+ return (
689
+ <tr
690
+ key={s.id}
691
+ className={s.id === selectedId ? 'selected' : ''}
692
+ onClick={() => onSelect(s.id)}
693
+ >
694
+ <td>
695
+ <div className="row-title">
696
+ <span className="row-icon">{s.icon}</span>
697
+ <span className="row-name" title={s.title}>{s.title}</span>
698
+ <span className={"row-score " + scoreClass(score)}>{score}</span>
699
+ </div>
700
+ </td>
701
+ <td><span className="row-repo" title={s.repo || ''}>{s.repo || '\\u2014'}</span></td>
702
+ <td>
703
+ {s.runtimeState ? (
704
+ <span className={"state-pill " + s.runtimeState}>
705
+ {STATE_LABELS[s.runtimeState] || s.runtimeState}
706
+ </span>
707
+ ) : (
708
+ <span style={{color:'var(--text-muted)'}}>\\u2014</span>
709
+ )}
710
+ </td>
711
+ <td className="td-right">
712
+ <span className={"row-cpu" + ((s.cpu||0)>50 ? ' red' : '')} style={(s.cpu||0)>50?{color:'var(--accent-red)'}:{}}>{(s.cpu||0).toFixed(1)}%</span>
713
+ </td>
714
+ <td className="td-right">
715
+ <span className="row-mem">{(s.memory||0).toFixed(1)}%</span>
716
+ </td>
717
+ <td className="td-right">
718
+ <span className="row-dur">{s.duration}</span>
719
+ </td>
720
+ <td className="td-right">
721
+ {s.repoActivity ? (
722
+ <span className="file-activity">
723
+ <span className={"file-dot " + (s.repoActivity.filesChangedLast2m > 0 ? 'active' : 'stale')} />
724
+ {s.repoActivity.filesChangedLast2m > 0 ? s.repoActivity.filesChangedLast2m : '\\u2014'}
725
+ </span>
726
+ ) : (
727
+ <span style={{color:'var(--text-muted)'}}>\\u2014</span>
728
+ )}
729
+ </td>
730
+ <td>
731
+ {s.lastCommand ? (
732
+ <span className="last-cmd" title={s.lastCommand}>{s.lastCommand}</span>
733
+ ) : (
734
+ <span style={{color:'var(--text-muted)'}}>\\u2014</span>
735
+ )}
736
+ </td>
737
+ <td>
738
+ {s.attention?.level === 'alert' && <span className="badge-mini alert">alert</span>}
739
+ {s.attention?.level === 'warn' && <span className="badge-mini warn">warn</span>}
740
+ {s.tmux && <span className="badge-mini tmux">tmux</span>}
741
+ {s.isWrapped && <span className="badge-mini wrapped">wrapped</span>}
742
+ {s.tmux?.lastPaneOutputAt && (
743
+ <span className={"tmux-output-age " + ((Date.now() - s.tmux.lastPaneOutputAt) < 60000 ? 'fresh' : 'stale')}>
744
+ {fmtAge(s.tmux.lastPaneOutputAt)}
745
+ </span>
746
+ )}
747
+ {(s.status||[]).filter(st => st !== 'file-progress' && st !== 'no-file-progress').slice(0,2).map(st => (
748
+ <span key={st} className={"badge-mini " + st}>{st}</span>
749
+ ))}
750
+ </td>
751
+ </tr>
752
+ );
753
+ })}
754
+ </tbody>
755
+ </table>
756
+ );
757
+ }
758
+
759
+ function DetailDrawer({ session, onClose, onToast }) {
760
+ const [copied, setCopied] = useState(false);
761
+ const outputRef = useRef(null);
762
+ const accent = TYPE_COLORS[session.type] || TYPE_COLORS.unknown;
763
+ const hasCwd = session.cwd && session.cwd !== '/';
764
+ const score = attentionScore(session);
765
+
766
+ useEffect(() => {
767
+ if (outputRef.current) {
768
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
769
+ }
770
+ }, [session.recentOutput]);
771
+
772
+ const handleAction = useCallback(async (action) => {
773
+ try {
774
+ if (action === 'copy-command') {
775
+ const result = await doAction(action, session.id);
776
+ if (result.command) {
777
+ await navigator.clipboard.writeText(result.command);
778
+ setCopied(true);
779
+ setTimeout(() => setCopied(false), 2000);
780
+ onToast('Command copied');
781
+ }
782
+ } else {
783
+ await doAction(action, session.id);
784
+ const msgs = { 'open-terminal': 'Terminal opened', 'open-folder': 'Folder opened', 'focus-terminal': 'Switching to terminal', 'tmux-focus': 'Focused tmux pane' };
785
+ onToast(msgs[action] || 'Done');
786
+ }
787
+ } catch (err) {
788
+ onToast('Action failed: ' + err.message);
789
+ }
790
+ }, [session.id, onToast]);
791
+
792
+ // Close on Escape
793
+ useEffect(() => {
794
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
795
+ window.addEventListener('keydown', handler);
796
+ return () => window.removeEventListener('keydown', handler);
797
+ }, [onClose]);
798
+
799
+ return (
800
+ <>
801
+ <div className="drawer-overlay" onClick={onClose} />
802
+ <div className="drawer">
803
+ <div className="drawer-header">
804
+ <div className="drawer-id">
805
+ <span className="drawer-icon">{session.icon}</span>
806
+ <div>
807
+ <div className="drawer-name">{session.title}</div>
808
+ <div className="drawer-type" style={{color: accent}}>
809
+ {session.type.replace('-', ' ')}
810
+ {session.isWrapped && <span className="badge-mini wrapped" style={{marginLeft:8}}>wrapped</span>}
811
+ </div>
812
+ </div>
813
+ </div>
814
+ <button className="drawer-close" onClick={onClose}>\\u2715</button>
815
+ </div>
816
+
817
+ {/* Stats */}
818
+ <div className="drawer-section">
819
+ <div className="drawer-stats">
820
+ <div>
821
+ <div className="drawer-stat-val" style={(session.cpu||0)>50?{color:'var(--accent-red)'}:{}}>{(session.cpu||0).toFixed(1)}%</div>
822
+ <div className="drawer-stat-label">CPU</div>
823
+ </div>
824
+ <div>
825
+ <div className="drawer-stat-val">{(session.memory||0).toFixed(1)}%</div>
826
+ <div className="drawer-stat-label">Memory</div>
827
+ </div>
828
+ <div>
829
+ <div className="drawer-stat-val">{session.duration}</div>
830
+ <div className="drawer-stat-label">Duration{session.eta ? ' (' + session.eta + ')' : ''}</div>
831
+ </div>
832
+ <div>
833
+ <div className="drawer-stat-val">
834
+ <span className={scoreClass(score)}
835
+ style={{fontSize:18,padding:'2px 8px',borderRadius:4}}>{score}</span>
836
+ </div>
837
+ <div className="drawer-stat-label">Attention</div>
838
+ </div>
839
+ </div>
840
+ </div>
841
+
842
+ {/* State + badges */}
843
+ {(session.runtimeState || (session.status && session.status.length > 0)) && (
844
+ <div className="drawer-section">
845
+ <div className="drawer-badges">
846
+ {session.runtimeState && (
847
+ <span className={"state-pill " + session.runtimeState}>
848
+ {STATE_LABELS[session.runtimeState] || session.runtimeState}
849
+ </span>
850
+ )}
851
+ {(session.status||[]).map(st => (
852
+ <span key={st} className={"badge-mini " + st}>{st}</span>
853
+ ))}
854
+ </div>
855
+ </div>
856
+ )}
857
+
858
+ {/* Purpose */}
859
+ {session.purpose && (
860
+ <div className="drawer-section">
861
+ <div className="drawer-label">Purpose</div>
862
+ <div className="drawer-purpose">{session.purpose}</div>
863
+ </div>
864
+ )}
865
+
866
+ {/* Shell Context */}
867
+ {session.lastCommand && (
868
+ <div className="drawer-section">
869
+ <div className="drawer-label">Last Shell Command</div>
870
+ <div style={{fontFamily:"'SF Mono',monospace",fontSize:12,color:'var(--text)',padding:'6px 10px',background:'var(--bg-purpose)',borderRadius:6}}>
871
+ $ {session.lastCommand}
872
+ {session.commandState === 'completed' && session.lastCommandExitCode != null && (
873
+ <span style={{marginLeft:10,fontSize:10,color:session.lastCommandExitCode===0?'var(--accent-green)':'var(--accent-red)'}}>
874
+ exit {session.lastCommandExitCode}
875
+ </span>
876
+ )}
877
+ {session.commandState === 'running' && (
878
+ <span style={{marginLeft:10,fontSize:10,color:'var(--accent-blue)'}}>running</span>
879
+ )}
880
+ </div>
881
+ {session.recentCommandCount > 0 && (
882
+ <div style={{fontSize:10,color:'var(--text-dim)',marginTop:4}}>{session.recentCommandCount} commands in last 5 min</div>
883
+ )}
884
+ </div>
885
+ )}
886
+
887
+ {/* Explanation */}
888
+ <div className="drawer-section">
889
+ <div className="drawer-label">Why this is running</div>
890
+ <div className="drawer-explanation">{session.explanation}</div>
891
+ </div>
892
+
893
+ {/* tmux Context */}
894
+ {session.tmux && (
895
+ <div className="drawer-section">
896
+ <div className="drawer-label">tmux Pane</div>
897
+ <div className="tmux-info">
898
+ <span>Session: <code>{session.tmux.sessionName}</code></span>
899
+ <span>Pane: <code>{session.tmux.paneId}</code></span>
900
+ <span>TTY: <code>{session.tmux.paneTty}</code></span>
901
+ {session.tmux.paneActive && <span className="badge-mini tmux">active</span>}
902
+ </div>
903
+ {session.tmux.lastPaneOutputAt && (
904
+ <div style={{marginTop:6,fontSize:11,color:'var(--text-dim)'}}>
905
+ Last terminal output:{' '}
906
+ <span className={(Date.now() - session.tmux.lastPaneOutputAt) < 60000 ? 'tmux-output-age fresh' : 'tmux-output-age stale'}>
907
+ {fmtAge(session.tmux.lastPaneOutputAt)}
908
+ </span>
909
+ </div>
910
+ )}
911
+ </div>
912
+ )}
913
+
914
+ {/* Attention Reasons */}
915
+ {session.attention && session.attention.reasons.length > 0 && (
916
+ <div className="drawer-section">
917
+ <div className="drawer-label">Attention ({session.attention.level})</div>
918
+ {session.attention.reasons.map((r, i) => (
919
+ <div key={i} className="attention-reason">
920
+ <span className={"attention-dot " + r.severity} />
921
+ <span>{r.message}</span>
922
+ </div>
923
+ ))}
924
+ </div>
925
+ )}
926
+
927
+ {/* Actions */}
928
+ <div className="drawer-section">
929
+ <div className="drawer-label">Actions</div>
930
+ <div className="drawer-actions">
931
+ <button className="drawer-action-btn" disabled={!hasCwd && !session.tmux}
932
+ onClick={() => handleAction(session.tmux ? 'tmux-focus' : 'focus-terminal')}
933
+ style={session.attention?.level === 'alert' ? {borderColor:'rgba(239,68,68,0.3)',color:'var(--accent-red)'} : {}}>
934
+ {session.tmux ? '\\u27A1 tmux focus' : '\\u27A1 Take me there'}
935
+ </button>
936
+ <button className="drawer-action-btn" disabled={!hasCwd}
937
+ onClick={() => handleAction('open-terminal')}>\\u2318 Terminal</button>
938
+ <button className="drawer-action-btn" disabled={!hasCwd}
939
+ onClick={() => handleAction('open-folder')}>\\uD83D\\uDCC2 Folder</button>
940
+ <button className={"drawer-action-btn" + (copied ? " copied" : "")}
941
+ onClick={() => handleAction('copy-command')}>
942
+ {copied ? '\\u2713 Copied' : '\\uD83D\\uDCCB Copy Cmd'}
943
+ </button>
944
+ </div>
945
+ </div>
946
+
947
+ {/* Repo Activity */}
948
+ {session.repoActivity && (
949
+ <div className="drawer-section">
950
+ <div className="drawer-label">Repo Activity</div>
951
+ <div className="repo-activity-grid">
952
+ <div>
953
+ <div className="repo-activity-val" style={session.repoActivity.filesChangedLast2m > 0 ? {color:'var(--accent-green)'} : {}}>
954
+ {session.repoActivity.filesChangedLast2m}
955
+ </div>
956
+ <div className="repo-activity-label">Files changed (2m)</div>
957
+ </div>
958
+ <div>
959
+ <div className="repo-activity-val">{session.repoActivity.repoWriteRate.toFixed(1)}</div>
960
+ <div className="repo-activity-label">Writes / min</div>
961
+ </div>
962
+ <div>
963
+ <div className="repo-activity-val" style={{fontSize:13}}>
964
+ {session.repoActivity.lastFileWriteAt
965
+ ? new Date(session.repoActivity.lastFileWriteAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})
966
+ : '\\u2014'}
967
+ </div>
968
+ <div className="repo-activity-label">Last write</div>
969
+ </div>
970
+ </div>
971
+ <div className="repo-activity-bar">
972
+ <div className="repo-activity-fill" style={{
973
+ width: Math.min(100, session.repoActivity.filesChangedLast2m * 5) + '%',
974
+ background: session.repoActivity.filesChangedLast2m > 0 ? 'var(--accent-green)' : 'var(--text-muted)',
975
+ }} />
976
+ </div>
977
+ </div>
978
+ )}
979
+
980
+ {/* Recent Output (wrapped) */}
981
+ {session.isWrapped && session.recentOutput && session.recentOutput.length > 0 && (
982
+ <div className="drawer-section">
983
+ <div className="drawer-label">Recent Output ({session.recentOutput.length} lines)</div>
984
+ <div className="drawer-output" ref={outputRef}>
985
+ {session.recentOutput.map((line, i) => (
986
+ <div key={i} className={"output-line " + line.stream}>
987
+ <span className="ts">{fmtTime(line.timestamp)}</span>
988
+ <span className="text">{stripAnsi(line.text)}</span>
989
+ </div>
990
+ ))}
991
+ </div>
992
+ </div>
993
+ )}
994
+
995
+ {/* Processes */}
996
+ <div className="drawer-section">
997
+ <div className="drawer-label">Processes ({session.processes.length})</div>
998
+ <div style={{maxHeight:220,overflowY:'auto'}}>
999
+ {session.processes.map(p => (
1000
+ <div key={p.pid} className="drawer-proc">
1001
+ <span className="pid">{p.pid}</span>
1002
+ <span className="cmd" title={p.cmd}>{p.cmd}</span>
1003
+ <span className="st">{p.cpu}%</span>
1004
+ <span className="st">{p.mem}%</span>
1005
+ </div>
1006
+ ))}
1007
+ </div>
1008
+ </div>
1009
+ </div>
1010
+ </>
1011
+ );
1012
+ }
1013
+
1014
+ function Toast({ message }) {
1015
+ if (!message) return null;
1016
+ return <div className="toast">{message}</div>;
1017
+ }
1018
+
1019
+ // ===== Safe-to-Leave Banner =====
1020
+
1021
+ function SafeBanner({ data }) {
1022
+ if (!data || data.safeToLeave === undefined) return null;
1023
+ const safe = data.safeToLeave;
1024
+ const count = data.needsAttentionCount || 0;
1025
+ return (
1026
+ <div className={"safe-banner " + (safe ? "safe" : "unsafe")}>
1027
+ <span className="safe-icon">{safe ? "\\u2705" : "\\u26A0\\uFE0F"}</span>
1028
+ <span>{safe ? "All clear — safe to step away" : count + " session" + (count !== 1 ? "s" : "") + " need" + (count === 1 ? "s" : "") + " attention"}</span>
1029
+ {count > 0 && <span className="safe-count">{data.needsAttention?.filter(s=>s.level==='alert').length || 0} alert, {data.needsAttention?.filter(s=>s.level==='warn').length || 0} warn</span>}
1030
+ </div>
1031
+ );
1032
+ }
1033
+
1034
+ // ===== App =====
1035
+
1036
+ function App() {
1037
+ const [data, setData] = useState(null);
1038
+ const [error, setError] = useState(null);
1039
+ const [toast, setToast] = useState(null);
1040
+ const [sortBy, setSortBy] = useState('attention');
1041
+ const [filters, setFilters] = useState([]);
1042
+ const [search, setSearch] = useState('');
1043
+ const [selectedId, setSelectedId] = useState(null);
1044
+
1045
+ const showToast = useCallback((msg) => {
1046
+ setToast(msg);
1047
+ setTimeout(() => setToast(null), 2500);
1048
+ }, []);
1049
+
1050
+ useEffect(() => {
1051
+ let mounted = true;
1052
+ async function fetchData() {
1053
+ try {
1054
+ const res = await fetch('/api/sessions');
1055
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1056
+ const json = await res.json();
1057
+ if (mounted) { setData(json); setError(null); }
1058
+ } catch (e) {
1059
+ if (mounted) setError(e.message);
1060
+ }
1061
+ }
1062
+ fetchData();
1063
+ const id = setInterval(fetchData, 5000);
1064
+ return () => { mounted = false; clearInterval(id); };
1065
+ }, []);
1066
+
1067
+ const sessions = data?.sessions || [];
1068
+
1069
+ const toggleFilter = useCallback((f) => {
1070
+ setFilters(prev => prev.includes(f) ? prev.filter(x => x !== f) : [...prev, f]);
1071
+ }, []);
1072
+
1073
+ // Handle offender click: either select a session or activate a filter
1074
+ const handleOffenderSelect = useCallback((idOrAction) => {
1075
+ if (idOrAction === '__filter_stuck') {
1076
+ setFilters(prev => prev.includes('stuck') ? prev : [...prev, 'stuck']);
1077
+ } else {
1078
+ setSelectedId(idOrAction);
1079
+ }
1080
+ }, []);
1081
+
1082
+ const processed = useMemo(() => {
1083
+ const filtered = filterSessions(sessions, filters, search);
1084
+ return sortSessions(filtered, sortBy);
1085
+ }, [sessions, filters, search, sortBy]);
1086
+
1087
+ const selectedSession = selectedId ? sessions.find(s => s.id === selectedId) : null;
1088
+
1089
+ return (
1090
+ <div className="container">
1091
+ <header className="header">
1092
+ <div className="header-top">
1093
+ <div style={{ display: 'flex', alignItems: 'center' }}>
1094
+ <h1 className="header-title">
1095
+ <span className="blue">Runtime</span>Inspector
1096
+ </h1>
1097
+ <div className="live-dot"><div className="dot" /><span>Live</span></div>
1098
+ </div>
1099
+ <div className="header-stats">
1100
+ {error ? (
1101
+ <span style={{ color: 'var(--accent-red)' }}>Connection error</span>
1102
+ ) : (
1103
+ <>
1104
+ {sessions.length} session{sessions.length !== 1 ? 's' : ''} &middot;{' '}
1105
+ {data?.processCount || 0} processes
1106
+ {data?.scannedAt && (
1107
+ <div>Last scan: {new Date(data.scannedAt).toLocaleTimeString()}</div>
1108
+ )}
1109
+ </>
1110
+ )}
1111
+ </div>
1112
+ </div>
1113
+ </header>
1114
+
1115
+ {error && (
1116
+ <div className="error-banner">
1117
+ Unable to reach the RuntimeInspector agent. Is it running?
1118
+ </div>
1119
+ )}
1120
+
1121
+ {!error && sessions.length > 0 && <SafeBanner data={data} />}
1122
+
1123
+ {sessions.length > 0 && (
1124
+ <TopOffenders sessions={sessions} onSelect={handleOffenderSelect} />
1125
+ )}
1126
+
1127
+ {sessions.length > 0 && (
1128
+ <Toolbar
1129
+ sortBy={sortBy} onSortChange={setSortBy}
1130
+ filters={filters} onToggleFilter={toggleFilter}
1131
+ search={search} onSearchChange={setSearch}
1132
+ />
1133
+ )}
1134
+
1135
+ {processed.length === 0 && sessions.length > 0 && (
1136
+ <div className="empty">
1137
+ <div className="empty-icon">\\uD83D\\uDD0D</div>
1138
+ <div className="empty-text">No sessions match your filters</div>
1139
+ <div className="empty-sub">Try adjusting your search or filters</div>
1140
+ </div>
1141
+ )}
1142
+
1143
+ {sessions.length === 0 && !error && (
1144
+ <div className="empty">
1145
+ <div className="empty-icon">\\uD83D\\uDD0D</div>
1146
+ <div className="empty-text">No active developer sessions detected</div>
1147
+ <div className="empty-sub">Start a dev server, AI agent, or script to see it here</div>
1148
+ </div>
1149
+ )}
1150
+
1151
+ {processed.length > 0 && (
1152
+ <SessionList
1153
+ sessions={processed}
1154
+ selectedId={selectedId}
1155
+ onSelect={setSelectedId}
1156
+ sortBy={sortBy}
1157
+ onColumnSort={setSortBy}
1158
+ />
1159
+ )}
1160
+
1161
+ {selectedSession && (
1162
+ <DetailDrawer
1163
+ session={selectedSession}
1164
+ onClose={() => setSelectedId(null)}
1165
+ onToast={showToast}
1166
+ />
1167
+ )}
1168
+
1169
+ <Toast message={toast} />
1170
+ </div>
1171
+ );
1172
+ }
1173
+
1174
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);
1175
+ </script>
1176
+ </body>
1177
+ </html>`;
1178
+ }