claude-finops 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/finops/act_now.py +3 -0
- package/package.json +1 -1
- package/web/app.js +363 -63
- package/web/styles.css +141 -8
package/README.md
CHANGED
|
@@ -318,6 +318,11 @@ agents listed under Multi-agent.
|
|
|
318
318
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
319
319
|
generate as sensitive.
|
|
320
320
|
|
|
321
|
+
**Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
|
|
322
|
+
for when you share your screen. Session titles fall back to their short id. The
|
|
323
|
+
setting is remembered in that browser. It only changes what the page shows: the
|
|
324
|
+
database and exports still contain the full text.
|
|
325
|
+
|
|
321
326
|
The app makes exactly one outbound request of its own: once a day it asks
|
|
322
327
|
`registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
|
|
323
328
|
you when an upgrade is out (npm has no way to push one at you). It sends nothing
|
|
@@ -359,7 +364,8 @@ files on Windows.
|
|
|
359
364
|
|
|
360
365
|
## Actions (these change your machine, and only when you click)
|
|
361
366
|
|
|
362
|
-
-
|
|
367
|
+
- **Sync** (top bar, database icon) re-reads `~/.claude` and swaps in fresh data without restarting.
|
|
368
|
+
The circular-arrow **Reload** next to it only redraws the page from the data already loaded.
|
|
363
369
|
- **Free models** adds a launcher such as `claude-qwen` in `~/.local/bin` that runs Claude Code
|
|
364
370
|
on a free model (Ollama locally, or OpenRouter). It lists every step first, and asks before it
|
|
365
371
|
installs Ollama or downloads a model. Your normal `claude` is unchanged. Edit the list in
|
|
@@ -367,12 +373,17 @@ files on Windows.
|
|
|
367
373
|
- **Skills & MCP** suggests MCP servers and skills from work you repeat, with the evidence.
|
|
368
374
|
"Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
|
|
369
375
|
The strongest skill suggestions also appear in **⚡ Act now** on the overview.
|
|
370
|
-
- **Compact** (Running sessions, Act now, session rows) types `/compact` into that session's
|
|
376
|
+
- **Compact** (Running sessions, Recent, Act now, session rows) types `/compact` into that session's
|
|
371
377
|
terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
|
|
372
378
|
command for you to paste. It asks you to click twice.
|
|
373
379
|
- **Hand over / split** (Running sessions) writes a brief of the session to
|
|
374
380
|
`~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
|
|
375
381
|
you list them; it can close the old session (resumable).
|
|
382
|
+
- **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
|
|
383
|
+
with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
|
|
384
|
+
list its prompts, and click a prompt for its full details. Search matches your prompt text.
|
|
385
|
+
It covers all dates but keeps the agent and project filters. Close and Force kill stay on
|
|
386
|
+
Running sessions.
|
|
376
387
|
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal.
|
|
377
388
|
Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
378
389
|
interrupt one console process without hitting the others in its window.
|
package/finops/act_now.py
CHANGED
|
@@ -33,6 +33,8 @@ def build(live, skills, memory, statusline):
|
|
|
33
33
|
"kind": "session", "severity": x["severity"], "pid": x.get("pid"),
|
|
34
34
|
"session_id": x.get("session_id"), "hosts_dashboard": bool(x.get("hosts_dashboard")),
|
|
35
35
|
"title": f"{x.get('name') or 'A session'} is carrying {_k(x['context'])} tokens of context",
|
|
36
|
+
# the part of the title that comes from the user's prompts, for the privacy mask
|
|
37
|
+
"private": x.get("name"),
|
|
36
38
|
"detail": f"{x.get('project') or ''} · every message re-reads all of it. "
|
|
37
39
|
+ ("Hand it over to a fresh session." if x["severity"] == "high"
|
|
38
40
|
else "Compact it at the next break."),
|
|
@@ -52,6 +54,7 @@ def build(live, skills, memory, statusline):
|
|
|
52
54
|
items.append({
|
|
53
55
|
"kind": "memory",
|
|
54
56
|
"title": f"You keep re-typing instructions about: {m['text']}",
|
|
57
|
+
"private": m["text"],
|
|
55
58
|
"detail": f"Seen in {m['sessions']} sessions. Put it in {m['target']} once instead.",
|
|
56
59
|
})
|
|
57
60
|
if statusline is None:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/web/app.js
CHANGED
|
@@ -21,6 +21,15 @@ const h = (html) => { const t = document.createElement('template');
|
|
|
21
21
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>
|
|
22
22
|
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
23
23
|
|
|
24
|
+
/* ---------- privacy mask ----------
|
|
25
|
+
For screen-sharing: prompt text and session titles (written from your prompts)
|
|
26
|
+
render as dots. Only the page changes; the warehouse and exports are untouched. */
|
|
27
|
+
const MASK_KEY = 'finops-mask-prompts';
|
|
28
|
+
let MASKED = false;
|
|
29
|
+
try { MASKED = localStorage.getItem(MASK_KEY) === '1'; } catch (_) {}
|
|
30
|
+
const pt = t => MASKED && t ? String(t).replace(/\S/g, '•') : t;
|
|
31
|
+
const stitle = x => (!MASKED && x.title) || shortId(x.session_id);
|
|
32
|
+
|
|
24
33
|
const qs = () => {
|
|
25
34
|
const f = S.filter, p = new URLSearchParams();
|
|
26
35
|
if (f.start) p.set('start', f.start);
|
|
@@ -82,7 +91,7 @@ const vsYesterday = o => {
|
|
|
82
91
|
};
|
|
83
92
|
function kpi(label, value, detail, opts = {}) {
|
|
84
93
|
const na = value == null;
|
|
85
|
-
return `<div class="kpi${na ? ' na' : ''}">
|
|
94
|
+
return `<div class="kpi${na ? ' na' : ''}${opts.tone && !na ? ' tone-' + opts.tone : ''}"${opts.title ? ` title="${esc(opts.title)}"` : ''}>
|
|
86
95
|
<div class="l">${esc(label)}${opts.badge ? ' ' + opts.badge : ''}</div>
|
|
87
96
|
<div class="v${opts.small ? ' sm' : ''}">${na ? esc(naLabel()) : value}</div>
|
|
88
97
|
${detail ? `<div class="d">${detail}</div>` : ''}</div>`;
|
|
@@ -152,6 +161,31 @@ const NAV = [
|
|
|
152
161
|
]],
|
|
153
162
|
];
|
|
154
163
|
|
|
164
|
+
// Toolbar icons: 24px line icons drawn in currentColor, so they follow the theme.
|
|
165
|
+
const toolIcon = d => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
166
|
+
const ICON = {
|
|
167
|
+
tour: toolIcon('<circle cx="12" cy="12" r="10"/><path d="m16.2 7.8-2.1 6.3-6.3 2.1 2.1-6.3z"/>'),
|
|
168
|
+
recent: toolIcon('<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>'),
|
|
169
|
+
eye: toolIcon('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
|
170
|
+
eyeOff: toolIcon('<path d="M10.7 5.1A10.4 10.4 0 0 1 12 5c6.4 0 10 7 10 7a18 18 0 0 1-2.2 3.2"/><path d="M6.6 6.6A17.6 17.6 0 0 0 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="m2 2 20 20"/>'),
|
|
171
|
+
moon: toolIcon('<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>'),
|
|
172
|
+
sun: toolIcon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'),
|
|
173
|
+
reload: toolIcon('<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>'),
|
|
174
|
+
// Sync pulls transcripts into the warehouse: a database with an arrow going in.
|
|
175
|
+
sync: toolIcon('<ellipse cx="11" cy="5" rx="7" ry="3"/><path d="M4 5v6c0 1.66 3.13 3 7 3"/><path d="M4 11v6c0 1.66 3.13 3 7 3"/><path d="M18 5v5"/><path d="M19 13v8"/><path d="m16 18 3 3 3-3"/>'),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// The theme button shows the theme it switches to: a moon in light mode, a sun in dark.
|
|
179
|
+
function themeLabel() {
|
|
180
|
+
const b = $('#theme'); if (!b) return;
|
|
181
|
+
const cur = document.documentElement.getAttribute('data-theme')
|
|
182
|
+
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
183
|
+
const to = cur === 'dark' ? 'light' : 'dark';
|
|
184
|
+
b.innerHTML = to === 'dark' ? ICON.moon : ICON.sun;
|
|
185
|
+
b.title = `Switch to ${to} theme`;
|
|
186
|
+
b.setAttribute('aria-label', b.title);
|
|
187
|
+
}
|
|
188
|
+
|
|
155
189
|
function shell() {
|
|
156
190
|
document.body.innerHTML = `<div class="app">
|
|
157
191
|
<aside class="sidebar">
|
|
@@ -166,16 +200,21 @@ function shell() {
|
|
|
166
200
|
<div class="main">
|
|
167
201
|
<header class="topbar">
|
|
168
202
|
<div class="r1">
|
|
169
|
-
<h1 id="ttl">Executive overview</h1>
|
|
170
|
-
|
|
203
|
+
<div class="ttl-block"><h1 id="ttl">Executive overview</h1>
|
|
204
|
+
<div class="crumbs" id="crumbs"></div></div>
|
|
205
|
+
<div class="scope" id="scope" aria-label="Totals for the current filters"></div>
|
|
171
206
|
<span class="spacer"></span>
|
|
172
207
|
<div class="search"><span class="mag">⌕</span>
|
|
173
|
-
<input id="gsearch" placeholder="Search prompts, sessions, models
|
|
208
|
+
<input id="gsearch" placeholder="Search prompts, sessions, models…"></div>
|
|
174
209
|
<button class="iconbtn upd" id="upd" hidden></button>
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
210
|
+
<div class="tools">
|
|
211
|
+
<button class="iconbtn" id="tour-btn" title="Walk through this dashboard">${ICON.tour}Tour</button>
|
|
212
|
+
<button class="iconbtn sq" id="recent-btn" title="Recent sessions and prompts, from any page (R)" aria-label="Recent">${ICON.recent}</button>
|
|
213
|
+
<button class="iconbtn sq" id="mask"></button>
|
|
214
|
+
<button class="iconbtn sq" id="theme"></button>
|
|
215
|
+
<button class="iconbtn sq" id="refresh" title="Reload this page's numbers from the warehouse. To pick up new sessions, use Sync" aria-label="Reload view">${ICON.reload}</button>
|
|
216
|
+
</div>
|
|
217
|
+
<button class="act sync" id="sync" title="Re-read every agent's transcripts from disk into the warehouse">${ICON.sync}Sync</button>
|
|
179
218
|
</div>
|
|
180
219
|
<div class="filters" id="filters"></div>
|
|
181
220
|
</header>
|
|
@@ -191,9 +230,19 @@ function shell() {
|
|
|
191
230
|
: (matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark');
|
|
192
231
|
document.documentElement.setAttribute('data-theme', next);
|
|
193
232
|
localStorage.setItem('finops-theme', next);
|
|
233
|
+
themeLabel();
|
|
194
234
|
render();
|
|
195
235
|
};
|
|
236
|
+
themeLabel();
|
|
196
237
|
$('#refresh').onclick = () => { bust(); render(); };
|
|
238
|
+
maskLabel();
|
|
239
|
+
$('#mask').onclick = () => {
|
|
240
|
+
MASKED = !MASKED;
|
|
241
|
+
try { localStorage.setItem(MASK_KEY, MASKED ? '1' : '0'); } catch (_) {}
|
|
242
|
+
maskLabel(); closeDrawer(); render();
|
|
243
|
+
if (RECENT.open) { RECENT.offset = 0; renderRecent(); }
|
|
244
|
+
};
|
|
245
|
+
$('#recent-btn').onclick = () => toggleRecent();
|
|
197
246
|
updateChip();
|
|
198
247
|
$('#sync').onclick = runSync;
|
|
199
248
|
syncLabel();
|
|
@@ -203,6 +252,77 @@ function shell() {
|
|
|
203
252
|
else if (S.view === 'search') go('overview'); }, 260); };
|
|
204
253
|
}
|
|
205
254
|
|
|
255
|
+
// The page loader: a skeleton in the shape of a dashboard (KPI row, two charts, a table)
|
|
256
|
+
// so the layout doesn't jump when data lands, plus a status line that says what is
|
|
257
|
+
// happening and, on a slow query, how long it has taken.
|
|
258
|
+
const LOADER_STEPS = ['Reading the warehouse', 'Pricing every request', 'Adding up tokens and cache',
|
|
259
|
+
'Grouping by session and project', 'Drawing the charts'];
|
|
260
|
+
const LOADER_TIPS = ['Press R anywhere to open Recent sessions.',
|
|
261
|
+
'Click any row in a table to open its details.',
|
|
262
|
+
'The eye button hides prompt text before you share your screen.',
|
|
263
|
+
'"Actual" numbers come from transcripts; "Estimated" ones use list prices.',
|
|
264
|
+
'Tour walks you through whichever dashboard you are on.'];
|
|
265
|
+
function pageLoader() {
|
|
266
|
+
const bone = (w, h = 10) => `<i class="bone" style="width:${w};height:${h}px"></i>`;
|
|
267
|
+
const bars = [38, 62, 45, 80, 56, 90, 70, 48, 66, 84, 58, 74]
|
|
268
|
+
.map((v, i) => `<i style="height:${v}%;animation-delay:${i * 70}ms"></i>`).join('');
|
|
269
|
+
return `<div class="pl" role="status" aria-live="polite">
|
|
270
|
+
<div class="pl-status"><span class="pl-mark"><i></i><i></i><i></i></span>
|
|
271
|
+
<span class="pl-msg">${LOADER_STEPS[0]}…</span><span class="pl-time"></span></div>
|
|
272
|
+
<div class="grid g4">${Array.from({length: 4}, () =>
|
|
273
|
+
`<div class="kpi skel">${bone('55%', 8)}${bone('45%', 22)}${bone('70%', 8)}</div>`).join('')}</div>
|
|
274
|
+
<div class="grid g2">
|
|
275
|
+
<div class="card skel"><div class="body">${bone('35%', 10)}<div class="pl-bars">${bars}</div></div></div>
|
|
276
|
+
<div class="card skel"><div class="body">${bone('30%', 10)}<div class="pl-bars alt">${bars}</div></div></div>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="card skel"><div class="body">${bone('25%', 10)}
|
|
279
|
+
${Array.from({length: 4}, (_, i) => `<div class="pl-row">${bone(`${60 - i * 8}%`)}${bone('12%')}${bone('9%')}</div>`).join('')}
|
|
280
|
+
</div></div>
|
|
281
|
+
<div class="pl-tip">Tip: ${esc(LOADER_TIPS[Math.floor(Math.random() * LOADER_TIPS.length)])}</div>
|
|
282
|
+
</div>`;
|
|
283
|
+
}
|
|
284
|
+
function loaderTicker(page) {
|
|
285
|
+
const t0 = Date.now();
|
|
286
|
+
let i = 0;
|
|
287
|
+
const id = setInterval(() => {
|
|
288
|
+
const msg = page.querySelector('.pl-msg');
|
|
289
|
+
if (!msg || !page.isConnected) return clearInterval(id); // the view has rendered
|
|
290
|
+
i = Math.min(i + 1, LOADER_STEPS.length - 1);
|
|
291
|
+
msg.textContent = LOADER_STEPS[i] + '…';
|
|
292
|
+
const s = Math.round((Date.now() - t0) / 1000);
|
|
293
|
+
if (s >= 3) page.querySelector('.pl-time').textContent = `${s}s · large ranges take a little longer`;
|
|
294
|
+
}, 1100);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Headline totals for whatever the filters select, on every page, so a number
|
|
298
|
+
// on the page can always be read against the whole it came from.
|
|
299
|
+
async function scopeStrip() {
|
|
300
|
+
const el = $('#scope'); if (!el) return;
|
|
301
|
+
const key = qs();
|
|
302
|
+
el.dataset.key = key;
|
|
303
|
+
let o;
|
|
304
|
+
try { o = await api('overview'); } catch { el.innerHTML = ''; return; }
|
|
305
|
+
if (el.dataset.key !== key) return; // the filters moved on while this loaded
|
|
306
|
+
const cell = (l, v, t) => `<div class="sc"${t ? ` title="${esc(t)}"` : ''}><span>${l}</span><b>${v}</b></div>`;
|
|
307
|
+
el.innerHTML =
|
|
308
|
+
cell('Est. spend', fmtUSD(o.est_cost_usd), 'Estimated at list prices for the current filters') +
|
|
309
|
+
cell('Sessions', fmtInt(o.sessions)) +
|
|
310
|
+
cell('Prompts', fmtInt(o.prompts)) +
|
|
311
|
+
cell('Requests', fmtNum(o.requests), fmtInt(o.requests) + ' model requests') +
|
|
312
|
+
cell('Active days', fmtInt(o.active_days));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function maskLabel() {
|
|
316
|
+
const b = $('#mask');
|
|
317
|
+
// The icon shows the current state: an open eye while prompts are visible.
|
|
318
|
+
b.innerHTML = MASKED ? ICON.eyeOff : ICON.eye;
|
|
319
|
+
b.title = MASKED ? 'Prompt text is hidden. Click to show it'
|
|
320
|
+
: 'Hide prompt text and session titles, e.g. before sharing your screen';
|
|
321
|
+
b.setAttribute('aria-label', MASKED ? 'Show prompts' : 'Hide prompts');
|
|
322
|
+
b.setAttribute('aria-pressed', MASKED);
|
|
323
|
+
b.classList.toggle('on', MASKED);
|
|
324
|
+
}
|
|
325
|
+
|
|
206
326
|
/* ---------- update notice ----------
|
|
207
327
|
npm cannot push a new release at anyone, so the server asks the registry once
|
|
208
328
|
a day and we surface the answer here. Silent when you are current, when the
|
|
@@ -247,14 +367,13 @@ function filterBar() {
|
|
|
247
367
|
const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
|
|
248
368
|
const ag = o.agents || [];
|
|
249
369
|
$('#filters').innerHTML = `
|
|
250
|
-
${ag.length > 1 ? `<
|
|
251
|
-
${ag.map(a => `<button class="
|
|
252
|
-
title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
|
|
253
|
-
<button class="
|
|
254
|
-
|
|
255
|
-
${RANGES.map(([k, l]) => `<button class="
|
|
256
|
-
data-range="${k}">${l}</button>`).join('')}
|
|
257
|
-
<span class="divider"></span>
|
|
370
|
+
${ag.length > 1 ? `<div class="seg" role="group" aria-label="Agent"><span class="seg-l">Agent</span>
|
|
371
|
+
${ag.map(a => `<button class="${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
|
|
372
|
+
title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}. Cmd/Shift-click to combine">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
|
|
373
|
+
<button class="${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button></div>` : ''}
|
|
374
|
+
<div class="seg" role="group" aria-label="Date range"><span class="seg-l">Range</span>
|
|
375
|
+
${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
|
|
376
|
+
data-range="${k}">${l}</button>`).join('')}</div>
|
|
258
377
|
<div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
|
|
259
378
|
Model ${nSel(f.models)} ▾</button></div>
|
|
260
379
|
<div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
|
|
@@ -263,13 +382,14 @@ function filterBar() {
|
|
|
263
382
|
Category ${nSel(f.categories)} ▾</button></div>
|
|
264
383
|
<div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
|
|
265
384
|
data-pop="thresholds">Thresholds ▾</button></div>
|
|
266
|
-
<button class="chip ${f.include_sandbox ? '' : 'on'}" id="sbx"
|
|
267
|
-
${f.include_sandbox ? '
|
|
385
|
+
<button class="chip toggle ${f.include_sandbox ? '' : 'on'}" id="sbx" role="switch" aria-checked="${!f.include_sandbox}"
|
|
386
|
+
title="Sandbox agents are throwaway projects. ${f.include_sandbox ? 'They are included; click to leave them out' : 'They are left out; click to include them'}">
|
|
387
|
+
<i></i>Hide sandbox</button>
|
|
268
388
|
${(f.models.length || f.projects.length || f.categories.length || f.min_cost || f.min_tokens)
|
|
269
|
-
? '<button class="chip" id="clr">Clear ✕</button>' : ''}
|
|
389
|
+
? '<button class="chip clr" id="clr">Clear filters ✕</button>' : ''}
|
|
270
390
|
<span class="spacer"></span>
|
|
271
|
-
<span class="
|
|
272
|
-
${esc(o.meta.transcript_files)} transcripts ·
|
|
391
|
+
<span class="coverage" title="Data in the warehouse · last rebuilt ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}">
|
|
392
|
+
${esc(o.meta.transcript_files)} transcripts · since ${esc(o.date_range.first)}</span>`;
|
|
273
393
|
|
|
274
394
|
$('#filters').querySelectorAll('[data-range]').forEach(b =>
|
|
275
395
|
b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
|
|
@@ -367,7 +487,13 @@ function drawer(title, bodyHtml, sub) {
|
|
|
367
487
|
document.body.append(scrim, d);
|
|
368
488
|
return d;
|
|
369
489
|
}
|
|
370
|
-
document.addEventListener('keydown', e => {
|
|
490
|
+
document.addEventListener('keydown', e => {
|
|
491
|
+
// Esc closes the top-most layer: a detail drawer first, then the Recent panel.
|
|
492
|
+
if (e.key === 'Escape') { if ($('.drawer')) closeDrawer(); else if (RECENT.open) toggleRecent(false); return; }
|
|
493
|
+
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
|
|
494
|
+
if (!typing && !e.metaKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'r' && !$('.drawer'))
|
|
495
|
+
toggleRecent();
|
|
496
|
+
});
|
|
371
497
|
|
|
372
498
|
async function openPrompt(id) {
|
|
373
499
|
const d = drawer('Prompt detail', '<div class="loading">Loading…</div>');
|
|
@@ -379,11 +505,11 @@ async function openPrompt(id) {
|
|
|
379
505
|
${kpi('Output tokens', fmtInt(p.output_tokens))}
|
|
380
506
|
${kpi('Peak context', fmtInt(p.max_context_tokens))}
|
|
381
507
|
</div>
|
|
382
|
-
${card('Prompt', `<div class="prompt-text">${esc(p.text)}</div>
|
|
508
|
+
${card('Prompt', `<div class="prompt-text">${esc(pt(p.text))}</div>
|
|
383
509
|
<div class="mt" style="margin-top:8px;display:flex;gap:14px;flex-wrap:wrap;font-size:11px;color:var(--muted)">
|
|
384
510
|
<span>${esc(p.ts || '')}</span><span class="pill">${esc(p.category)}</span>
|
|
385
511
|
<span>confidence ${(100*(p.category_confidence||0)).toFixed(0)}%</span>
|
|
386
|
-
${(p.category_evidence||[]).length ? `<span>matched: ${esc(p.category_evidence.join(', '))}</span>` : ''}
|
|
512
|
+
${(p.category_evidence||[]).length ? `<span>matched: ${esc(pt(p.category_evidence.join(', ')))}</span>` : ''}
|
|
387
513
|
<span>${fmtInt(p.char_len)} chars · ${fmtInt(p.word_len)} words</span>
|
|
388
514
|
${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
|
|
389
515
|
</div>`, {badge: BADGE.actual})}
|
|
@@ -411,7 +537,7 @@ async function openPrompt(id) {
|
|
|
411
537
|
: '<div class="na">No files touched</div>', {badge: BADGE.actual})}
|
|
412
538
|
</div>
|
|
413
539
|
${card('Context', `<dl class="kv">
|
|
414
|
-
<dt>Session</dt><dd><a data-sess="${esc(p.session_id)}">${esc(p.session_title || shortId(p.session_id))}</a></dd>
|
|
540
|
+
<dt>Session</dt><dd><a data-sess="${esc(p.session_id)}">${esc((!MASKED && p.session_title) || shortId(p.session_id))}</a></dd>
|
|
415
541
|
<dt>Project</dt><dd>${esc(p.project)}</dd>
|
|
416
542
|
<dt>Git branch</dt><dd>${p.git_branch ? esc(p.git_branch) : NA()}</dd>
|
|
417
543
|
<dt>Models</dt><dd>${esc(p.models || '—')}</dd>
|
|
@@ -443,7 +569,7 @@ async function openSession(id) {
|
|
|
443
569
|
hint: 'context tokens actual, cost estimated'})}
|
|
444
570
|
${card('Prompts in this session', table([
|
|
445
571
|
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,16))}</span>`},
|
|
446
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
572
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
447
573
|
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
448
574
|
{h: 'Tools', num: 1, f: r => fmtInt(r.tool_calls)},
|
|
449
575
|
{h: 'Peak ctx', num: 1, f: r => fmtInt(r.max_context)},
|
|
@@ -462,7 +588,7 @@ async function openSession(id) {
|
|
|
462
588
|
${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
|
|
463
589
|
<button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
|
|
464
590
|
<dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
|
|
465
|
-
<dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
|
|
591
|
+
<dt>Title</dt><dd>${s.title ? esc(pt(s.title)) : NA()}</dd>
|
|
466
592
|
<dt>Project</dt><dd>${esc(s.project)}</dd>
|
|
467
593
|
<dt>Git branch</dt><dd>${s.git_branch ? esc(s.git_branch) : NA()}</dd>
|
|
468
594
|
<dt>CLI version</dt><dd>${s.cli_version ? esc(s.cli_version) : NA()}</dd>
|
|
@@ -650,7 +776,7 @@ VIEWS.overview = async (page) => {
|
|
|
650
776
|
const lp = leaderboards.most_expensive.slice(0, 10);
|
|
651
777
|
$('#topprompts', page).innerHTML = table([
|
|
652
778
|
{h: '#', num: 1, f: (r) => lp.indexOf(r) + 1},
|
|
653
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
779
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
654
780
|
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
655
781
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.ptokens)},
|
|
656
782
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
@@ -660,7 +786,7 @@ VIEWS.overview = async (page) => {
|
|
|
660
786
|
const ls = (await api('sessions', '&limit=10&order=cost')).rows;
|
|
661
787
|
$('#topsessions', page).innerHTML = table([
|
|
662
788
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
663
|
-
f: r => esc(
|
|
789
|
+
f: r => esc(stitle(r))},
|
|
664
790
|
{h: 'Project', f: r => esc(r.project)},
|
|
665
791
|
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
666
792
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
@@ -711,7 +837,7 @@ function actNowStrip(items) {
|
|
|
711
837
|
<span class="note">one click each · nothing changes until you click</span></div>
|
|
712
838
|
${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
|
|
713
839
|
<span class="ai">${ACT_ICON[it.kind] || '•'}</span>
|
|
714
|
-
<div class="ft"><b>${codeTicks(it.title)}</b><div class="note">${esc(it.detail)}</div></div>
|
|
840
|
+
<div class="ft"><b>${codeTicks(MASKED && it.private ? it.title.split(it.private).join(pt(it.private)) : it.title)}</b><div class="note">${esc(it.detail)}</div></div>
|
|
715
841
|
<div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
|
|
716
842
|
}
|
|
717
843
|
function wireActNow(root, items) {
|
|
@@ -1201,13 +1327,13 @@ VIEWS.sessions = async (page) => {
|
|
|
1201
1327
|
</div>`, {badge: BADGE.estimated, hint: 'click a row to open the session'})}
|
|
1202
1328
|
<div class="grid g2">
|
|
1203
1329
|
${card('Lowest output yield', table([
|
|
1204
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1330
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1205
1331
|
{h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
|
|
1206
1332
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1207
1333
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1208
1334
|
], eff.low_efficiency_sessions, {onRow: 1}), {badge: BADGE.estimated, hint: 'low efficiency'})}
|
|
1209
1335
|
${card('Highest output yield', table([
|
|
1210
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1336
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1211
1337
|
{h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
|
|
1212
1338
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1213
1339
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
@@ -1219,7 +1345,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1219
1345
|
if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
|
|
1220
1346
|
$('#st', page).innerHTML = table([
|
|
1221
1347
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1222
|
-
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(
|
|
1348
|
+
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(stitle(r))}
|
|
1223
1349
|
<div class="sub mono">${esc(shortId(r.session_id))}</div>`},
|
|
1224
1350
|
{h: '', f: r => rowActs(r, live)},
|
|
1225
1351
|
{h: 'Project', f: r => esc(r.project)},
|
|
@@ -1249,7 +1375,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1249
1375
|
wireTable(effCards[1], eff.high_efficiency_sessions, r => openSession(r.session_id));
|
|
1250
1376
|
addChart(page, 'Top sessions by estimated cost', el => C.barsH(el, {
|
|
1251
1377
|
rows: [...rows].sort((a, b) => b.cost - a.cost).slice(0, 12),
|
|
1252
|
-
label: r => clip(
|
|
1378
|
+
label: r => clip(stitle(r), 42), value: r => r.cost,
|
|
1253
1379
|
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
1254
1380
|
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.tokens)}</span></div>
|
|
1255
1381
|
<div class="row"><span class="k">Prompts</span><span class="v">${fmtInt(r.prompts)}</span></div>`,
|
|
@@ -1289,12 +1415,12 @@ VIEWS.prompts = async (page) => {
|
|
|
1289
1415
|
if (promptMore) promptMore.onclick = () => { pp.offset += pp.limit; bust(); render(); };
|
|
1290
1416
|
$('#pt', page).innerHTML = table([
|
|
1291
1417
|
{h: 'When', f: r => `<span class="mono">${esc((r.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1292
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1418
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
1293
1419
|
{h: 'Category', f: r => `<span class="pill" title="confidence ${(100*(r.category_confidence||0)).toFixed(0)}%${
|
|
1294
1420
|
r.category_evidence?.length ? ' · matched: ' + esc(r.category_evidence.join(', ')) : ''}">${esc(r.category)}</span>`},
|
|
1295
1421
|
{h: 'Project', f: r => esc(r.project)},
|
|
1296
1422
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1297
|
-
f: r => `<span class="mono sub">${esc(r.session_title || shortId(r.session_id))}</span>`},
|
|
1423
|
+
f: r => `<span class="mono sub">${esc((!MASKED && r.session_title) || shortId(r.session_id))}</span>`},
|
|
1298
1424
|
{h: 'Models', f: r => (r.models || '').split(',').map(m =>
|
|
1299
1425
|
`<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
|
|
1300
1426
|
{h: 'Chars', num: 1, f: r => fmtInt(r.char_len)},
|
|
@@ -1334,7 +1460,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1334
1460
|
S.rankTab = b.dataset.rt; render(); });
|
|
1335
1461
|
const cols = isSess ? [
|
|
1336
1462
|
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1337
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1463
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1338
1464
|
{h: '', f: r => rowActs(r, live)},
|
|
1339
1465
|
{h: 'Project', f: r => esc(r.project)},
|
|
1340
1466
|
{h: 'Duration', num: 1, f: r => dur(r.duration_s)},
|
|
@@ -1345,7 +1471,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1345
1471
|
{h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1346
1472
|
] : [
|
|
1347
1473
|
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1348
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1474
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
1349
1475
|
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
1350
1476
|
{h: 'Models', f: r => (r.models || '').split(',').map(m =>
|
|
1351
1477
|
`<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
|
|
@@ -1353,7 +1479,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1353
1479
|
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
1354
1480
|
{h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
|
|
1355
1481
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
1356
|
-
{h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
|
|
1482
|
+
{h: 'Session', trunc: 1, f: r => esc((!MASKED && r.session_title) || shortId(r.session_id))},
|
|
1357
1483
|
{h: '', f: r => rowActs(r, live)},
|
|
1358
1484
|
{h: 'Date', f: r => esc(r.day)},
|
|
1359
1485
|
];
|
|
@@ -1361,7 +1487,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1361
1487
|
wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
|
|
1362
1488
|
wireRowActs($('#rt', page));
|
|
1363
1489
|
addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
|
|
1364
|
-
rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
|
|
1490
|
+
rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(pt(r.preview), 46), value: r => r.pcost,
|
|
1365
1491
|
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
1366
1492
|
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.ptokens)}</span></div>`}),
|
|
1367
1493
|
{badge: BADGE.estimated, after: '.nothing'});
|
|
@@ -1416,6 +1542,12 @@ VIEWS.hygiene = async (page) => {
|
|
|
1416
1542
|
const T = hy.thresholds.map(String), kT = String(hy.rank_threshold);
|
|
1417
1543
|
const K = t => `${Math.round(+t / 1000)}K`;
|
|
1418
1544
|
const ab = t => hy.above[t];
|
|
1545
|
+
// Colour grade by how much of the spend went to re-sending a large prefix. The bands are
|
|
1546
|
+
// a reading aid, not a verdict: long agentic work legitimately runs at high context.
|
|
1547
|
+
const grade = (v, bands) => v == null ? null
|
|
1548
|
+
: v < bands[0] ? 'good' : v < bands[1] ? 'warning' : v < bands[2] ? 'serious' : 'critical';
|
|
1549
|
+
const SHARE = [25, 50, 75], FIT = [5, 15, 30];
|
|
1550
|
+
const bandTip = b => `Green under ${b[0]}%, amber ${b[0]}–${b[1]}%, orange ${b[1]}–${b[2]}%, red ${b[2]}% and up`;
|
|
1419
1551
|
page.innerHTML = `
|
|
1420
1552
|
<div class="note" style="margin:0 0 10px">Everything on this page is observed from your transcripts.
|
|
1421
1553
|
It shows where spend sat while a large prefix was being re-sent on every turn. It does
|
|
@@ -1425,16 +1557,18 @@ VIEWS.hygiene = async (page) => {
|
|
|
1425
1557
|
recorded and also counts.</div>
|
|
1426
1558
|
<div class="grid g4">
|
|
1427
1559
|
${T.map(t => kpi(`Spend in requests ≥ ${K(t)} context`, fmtPct(ab(t).share_pct),
|
|
1428
|
-
`${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`,
|
|
1560
|
+
`${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`,
|
|
1561
|
+
{badge: BADGE.actual, tone: grade(ab(t).share_pct, SHARE), title: bandTip(SHARE)})).join('')}
|
|
1429
1562
|
${T.map(t => kpi(`Spend after a session first crossed ${K(t)}`, fmtPct(ab(t).share_after_first_cross_pct),
|
|
1430
|
-
`${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`,
|
|
1563
|
+
`${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`,
|
|
1564
|
+
{badge: BADGE.actual, tone: grade(ab(t).share_after_first_cross_pct, SHARE), title: bandTip(SHARE)})).join('')}
|
|
1431
1565
|
</div>
|
|
1432
1566
|
<div class="grid g3">
|
|
1433
1567
|
${kpi('Spend near or over the context window', fmtPct(fit.near_or_over_cost_pct),
|
|
1434
1568
|
`≥ ${fit.threshold_pct}% of the window in use · ${fmtInt(fit.near_requests + fit.over_requests)} requests`,
|
|
1435
|
-
{badge: BADGE.actual})}
|
|
1436
|
-
${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual})}
|
|
1437
|
-
${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated})}
|
|
1569
|
+
{badge: BADGE.actual, tone: grade(fit.near_or_over_cost_pct, FIT), title: bandTip(FIT)})}
|
|
1570
|
+
${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual, tone: 'info'})}
|
|
1571
|
+
${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated, tone: 'info'})}
|
|
1438
1572
|
</div>
|
|
1439
1573
|
${card('Context per request, most expensive session after ' + K(kT), '<div class="chart" id="hy-traj"></div>',
|
|
1440
1574
|
{badge: BADGE.actual, hint: 'prompt-side tokens on each request, in order · click a row below to change session'})}
|
|
@@ -1448,13 +1582,13 @@ VIEWS.hygiene = async (page) => {
|
|
|
1448
1582
|
series: [{key: 'ctx', label: 'Context tokens', color: seriesVar(0)}],
|
|
1449
1583
|
xLabel: v => `#${v}`});
|
|
1450
1584
|
$('#hy-traj', page).insertAdjacentHTML('beforeend',
|
|
1451
|
-
`<div class="note" style="margin-top:6px">${esc(
|
|
1585
|
+
`<div class="note" style="margin-top:6px">${esc(stitle(s))} — ${fmtInt(s.requests)} requests,
|
|
1452
1586
|
${fmtUSD(s.cost_usd)}. Crossed ${K(kT)} at request #${s.ever_crossed[kT] ? s.first_cross_idx[kT] + 1 : '—'};
|
|
1453
1587
|
${fmtPct(s.cost_after_pct[kT])} of its spend came after that.</div>`);
|
|
1454
1588
|
};
|
|
1455
1589
|
const rows = hy.sessions_ranked;
|
|
1456
1590
|
$('#hy-sess', page).innerHTML = table([
|
|
1457
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1591
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1458
1592
|
{h: 'Project', trunc: 1, f: r => esc(r.project || '')},
|
|
1459
1593
|
{h: 'Context', f: r => `<span class="spk" data-i="${rows.indexOf(r)}" style="display:inline-block;width:110px"></span>`},
|
|
1460
1594
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
@@ -1599,7 +1733,7 @@ VIEWS.context = async (page) => {
|
|
|
1599
1733
|
{k: 'With caching (your actual usage)', v: ca.cost_with_cache}],
|
|
1600
1734
|
label: r => r.k, value: r => r.v, color: (r, i) => i ? seriesVar(2) : seriesVar(1), height: 60});
|
|
1601
1735
|
$('#hs', page).innerHTML = table([
|
|
1602
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1736
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1603
1737
|
{h: 'Project', f: r => esc(r.project)},
|
|
1604
1738
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1605
1739
|
{h: 'Avg context', num: 1, f: r => fmtNum(r.avg_context)},
|
|
@@ -1653,14 +1787,14 @@ VIEWS.waste = async (page) => {
|
|
|
1653
1787
|
const f = all.find(x => x.kind === host.dataset.kind);
|
|
1654
1788
|
const isSess = !!f.evidence[0]?.session_id && !f.evidence[0]?.prompt_id;
|
|
1655
1789
|
const cols = isSess ? [
|
|
1656
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1790
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1657
1791
|
{h: 'Project', f: r => esc(r.project || '—')},
|
|
1658
1792
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1659
1793
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens ?? r.reads)},
|
|
1660
1794
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1661
1795
|
{h: 'Est. excess', num: 1, f: r => fmtUSD(r.excess)},
|
|
1662
1796
|
] : [
|
|
1663
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1797
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
1664
1798
|
{h: 'Detail', f: r => r.n ? `repeated ${r.n}×` : r.char_len ? fmtInt(r.char_len) + ' chars'
|
|
1665
1799
|
: r.tools ? fmtInt(r.tools) + ' tool calls'
|
|
1666
1800
|
: r.out_tokens != null ? fmtInt(r.out_tokens) + ' output tokens' : '—'},
|
|
@@ -1719,7 +1853,7 @@ VIEWS.attribution = async (page) => {
|
|
|
1719
1853
|
${b.configured_unused_mcp.length ? `<div class="note">Configured but never called: <b>${esc(b.configured_unused_mcp.join(', '))}</b>. Their tool definitions still load into every session. Remove them with <code>claude mcp remove <name></code>.</div>` : ''}`;
|
|
1720
1854
|
const put = (id, cols, rows, onRow) => { const el = $(id, page); el.innerHTML = table(cols, rows, {onRow: !!onRow}); wireTable(el, rows, onRow); };
|
|
1721
1855
|
put('#at-sess', [
|
|
1722
|
-
{h: 'Session', trunc: 1, title: r => r.session_id, f: r => esc(
|
|
1856
|
+
{h: 'Session', trunc: 1, title: r => r.session_id, f: r => esc(stitle(r))},
|
|
1723
1857
|
{h: 'Project', f: r => esc(r.project)},
|
|
1724
1858
|
{h: 'Main agent', num: 1, f: r => fmtNum(r.main_tokens)},
|
|
1725
1859
|
{h: 'Subagents', num: 1, f: r => r.sub_tokens ? `${fmtNum(r.sub_tokens)} (${r.subagents})` : '—'},
|
|
@@ -1822,7 +1956,7 @@ VIEWS.live = async (page) => {
|
|
|
1822
1956
|
<div class="item sev-${sevOf(x)}" data-i="${i}">
|
|
1823
1957
|
<div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
|
|
1824
1958
|
<span class="pill">${esc(agentName(x.agent))}</span>
|
|
1825
|
-
${esc(x.name || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
|
|
1959
|
+
${esc((!MASKED && x.name) || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
|
|
1826
1960
|
${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
|
|
1827
1961
|
<span class="spacer"></span>
|
|
1828
1962
|
<span style="font-variant-numeric:tabular-nums">${x.context == null ? 'context not recorded' : fmtNum(x.context) + ' context'} · ${fmtInt(x.steps)} steps${x.est_cost_usd == null ? '' : ' · ' + fmtUSD(x.est_cost_usd)}</span></div>
|
|
@@ -1958,7 +2092,7 @@ VIEWS.diagnose = async (page) => {
|
|
|
1958
2092
|
<div id="dx-live"></div>${card('Running now', d.live_sessions.length ? `<div class="stack">${d.live_sessions.map(x => `
|
|
1959
2093
|
<div class="item sev-${x.severity === 'ok' ? 'low' : x.severity}">
|
|
1960
2094
|
<div class="hd">${x.severity === 'high' ? '🔴' : x.severity === 'medium' ? '🟡' : '🟢'}
|
|
1961
|
-
${esc(
|
|
2095
|
+
${esc(stitle(x))} <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
|
|
1962
2096
|
<span style="font-variant-numeric:tabular-nums">${fmtNum(x.context)} context · ${fmtInt(x.steps)} steps</span></div>
|
|
1963
2097
|
<div class="dt">${esc(x.advice)} <span class="note">Last write ${x.idle_min} min ago; context grew
|
|
1964
2098
|
${fmtNum(x.start_context)} → ${fmtNum(x.context)}.</span></div>${pb(x.playbook)}</div>`).join('')}</div>`
|
|
@@ -1966,7 +2100,7 @@ VIEWS.diagnose = async (page) => {
|
|
|
1966
2100
|
{badge: BADGE.actual, hint: 'Read live from transcripts on each load'})}
|
|
1967
2101
|
<div id="dx-past"></div>${card('Past sessions that carried too much context', `<div class="stack">${d.session_health.map(x => `
|
|
1968
2102
|
<div class="item sev-${x.peak >= 300000 ? 'high' : 'medium'}">
|
|
1969
|
-
<div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(
|
|
2103
|
+
<div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(stitle(x))}</a>
|
|
1970
2104
|
<span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
|
|
1971
2105
|
<span style="font-variant-numeric:tabular-nums">peak ${fmtNum(x.peak)} · ${fmtUSD(x.cost)} ·
|
|
1972
2106
|
~${fmtNum(x.tokens_above_100k)} tokens re-read above 100K (not a saving)</span></div>
|
|
@@ -1976,12 +2110,12 @@ VIEWS.diagnose = async (page) => {
|
|
|
1976
2110
|
<div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
|
|
1977
2111
|
<div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
|
|
1978
2112
|
<div class="hd">${x.kind === 'security' ? '🔐' : x.kind === 'template' ? '⚙' : x.kind === 'reference' ? '🔗' : '📝'}
|
|
1979
|
-
${esc(x.text.slice(0, 140))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
|
|
2113
|
+
${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
|
|
1980
2114
|
<span class="spacer"></span><span class="note">${fmtInt(x.sessions)} sessions</span></div>
|
|
1981
2115
|
<div class="dt">${esc(x.why)}</div>
|
|
1982
2116
|
<div class="dt"><b>Put it in:</b> ${esc(x.target)}</div>
|
|
1983
2117
|
${(x.examples || []).length ? `<details><summary style="cursor:pointer;font-size:11.5px">Examples</summary>
|
|
1984
|
-
${x.examples.map(e => `<div class="dt note">“${esc(e)}”</div>`).join('')}</details>` : ''}${pb(x.playbook)}</div>`).join('')
|
|
2118
|
+
${x.examples.map(e => `<div class="dt note">“${esc(pt(e))}”</div>`).join('')}</details>` : ''}${pb(x.playbook)}</div>`).join('')
|
|
1985
2119
|
|| '<div class="empty">No repeated instructions found</div>'}</div>`,
|
|
1986
2120
|
{badge: BADGE.recommendation, hint: 'Mined from non-sandbox prompts; secrets masked'})}
|
|
1987
2121
|
${card('Why consumption is high', `<div class="stack">${d.drivers.map(x => `
|
|
@@ -2325,7 +2459,7 @@ VIEWS.search = async (page) => {
|
|
|
2325
2459
|
if (r.prompts.length) {
|
|
2326
2460
|
$('#sp', page).innerHTML = table([
|
|
2327
2461
|
{h: 'When', f: x => `<span class="mono">${esc((x.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
2328
|
-
{h: 'Prompt', trunc: 1, title: x => x.preview, f: x => esc(x.preview)},
|
|
2462
|
+
{h: 'Prompt', trunc: 1, title: x => pt(x.preview), f: x => esc(pt(x.preview))},
|
|
2329
2463
|
{h: 'Category', f: x => `<span class="pill">${esc(x.category)}</span>`},
|
|
2330
2464
|
{h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
|
|
2331
2465
|
{h: 'Est. cost', num: 1, f: x => fmtUSD(x.est_cost_usd)},
|
|
@@ -2334,7 +2468,7 @@ VIEWS.search = async (page) => {
|
|
|
2334
2468
|
}
|
|
2335
2469
|
if (r.sessions.length) {
|
|
2336
2470
|
$('#ss', page).innerHTML = table([
|
|
2337
|
-
{h: 'Session', trunc: 1, f: x => esc(
|
|
2471
|
+
{h: 'Session', trunc: 1, f: x => esc(stitle(x))},
|
|
2338
2472
|
{h: 'Project', f: x => esc(x.project)},
|
|
2339
2473
|
{h: 'Branch', f: x => x.git_branch ? `<span class="mono">${esc(x.git_branch)}</span>` : '—'},
|
|
2340
2474
|
{h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
|
|
@@ -2574,7 +2708,9 @@ async function render() {
|
|
|
2574
2708
|
if (!f.include_sandbox) bits.push('excl. sandbox');
|
|
2575
2709
|
$('#crumbs').innerHTML = bits.join(' <span style="opacity:.4">·</span> ');
|
|
2576
2710
|
filterBar();
|
|
2577
|
-
|
|
2711
|
+
scopeStrip();
|
|
2712
|
+
page.innerHTML = pageLoader();
|
|
2713
|
+
loaderTicker(page);
|
|
2578
2714
|
updateNavBadges();
|
|
2579
2715
|
try {
|
|
2580
2716
|
await (VIEWS[S.view] || VIEWS.overview)(page);
|
|
@@ -2585,6 +2721,154 @@ async function render() {
|
|
|
2585
2721
|
}
|
|
2586
2722
|
}
|
|
2587
2723
|
|
|
2724
|
+
/* ---------- Recent panel ----------
|
|
2725
|
+
Past sessions and their prompts from any page, without leaving it. Resume and
|
|
2726
|
+
stop act on sessions, so the list is grouped by session; running ones sit on
|
|
2727
|
+
top. Close and Force kill stay on Running sessions, one click away from here. */
|
|
2728
|
+
const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set()};
|
|
2729
|
+
// Recent means all time: keep agent/project filters, drop the date range and thresholds.
|
|
2730
|
+
const recentQs = () => { const p = new URLSearchParams(qs());
|
|
2731
|
+
['start', 'end', 'min_cost', 'min_tokens'].forEach(k => p.delete(k)); return p.toString(); };
|
|
2732
|
+
const fmtWhen = iso => iso ? `${ago(iso)} · ${esc(iso.slice(0, 16).replace('T', ' '))}` : '';
|
|
2733
|
+
|
|
2734
|
+
function toggleRecent(force) {
|
|
2735
|
+
RECENT.open = force ?? !RECENT.open;
|
|
2736
|
+
document.querySelectorAll('.recent,.recent-scrim').forEach(e => e.remove());
|
|
2737
|
+
$('#recent-btn')?.classList.toggle('on', RECENT.open);
|
|
2738
|
+
if (!RECENT.open) return;
|
|
2739
|
+
const scrim = h('<div class="recent-scrim"></div>');
|
|
2740
|
+
const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
|
|
2741
|
+
<h2>Recent</h2><span class="note">all time · agent & project filters apply</span>
|
|
2742
|
+
<span class="spacer"></span><button class="iconbtn" data-x>Close ✕</button></header>
|
|
2743
|
+
<div class="recent-search"><span class="mag">⌕</span>
|
|
2744
|
+
<input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
|
|
2745
|
+
<div class="content"><div class="loading">Loading…</div></div></aside>`);
|
|
2746
|
+
scrim.onclick = () => toggleRecent(false);
|
|
2747
|
+
p.querySelector('[data-x]').onclick = () => toggleRecent(false);
|
|
2748
|
+
let t;
|
|
2749
|
+
p.querySelector('#recent-q').oninput = e => { clearTimeout(t);
|
|
2750
|
+
t = setTimeout(() => { RECENT.q = e.target.value.trim(); RECENT.offset = 0; renderRecent(); }, 260); };
|
|
2751
|
+
document.body.append(scrim, p);
|
|
2752
|
+
RECENT.offset = 0;
|
|
2753
|
+
renderRecent();
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
async function renderRecent() {
|
|
2757
|
+
const box = $('.recent .content');
|
|
2758
|
+
if (!box) return;
|
|
2759
|
+
const more = RECENT.offset > 0;
|
|
2760
|
+
const [live, res] = await Promise.all([
|
|
2761
|
+
more || RECENT.q ? null : fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(',')))
|
|
2762
|
+
.then(r => r.json()).catch(() => ({sessions: []})),
|
|
2763
|
+
RECENT.q
|
|
2764
|
+
? fetch(`/api/prompts?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}&q=${encodeURIComponent(RECENT.q)}`).then(r => r.json())
|
|
2765
|
+
: fetch(`/api/sessions?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
|
|
2766
|
+
if (!$('.recent')) return; // closed while loading
|
|
2767
|
+
const rows = res.rows || [];
|
|
2768
|
+
const liveIds = new Set(((live || {}).sessions || RECENT.live || []).map(x => x.session_id));
|
|
2769
|
+
if (live) RECENT.live = live.sessions || [];
|
|
2770
|
+
const html = RECENT.q ? recentPrompts(rows) : recentSessions(rows, liveIds);
|
|
2771
|
+
const shown = RECENT.offset + rows.length;
|
|
2772
|
+
const moreBtn = shown < (res.total || 0)
|
|
2773
|
+
? `<button class="btn pb-copy recent-more">Load more (${fmtInt(shown)} of ${fmtInt(res.total)})</button>` : '';
|
|
2774
|
+
if (more) { box.querySelector('.recent-more')?.remove(); box.insertAdjacentHTML('beforeend', html + moreBtn); }
|
|
2775
|
+
else box.innerHTML = (RECENT.q ? '' : recentLive(RECENT.live || [])) + html + moreBtn
|
|
2776
|
+
|| '<div class="empty">Nothing found</div>';
|
|
2777
|
+
if (!rows.length && !more && RECENT.q) box.innerHTML = '<div class="empty">No prompt matches</div>';
|
|
2778
|
+
wireRecent(box);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running now
|
|
2782
|
+
<span class="spacer"></span><a href="#" data-go-live>Close or kill ›</a></div>
|
|
2783
|
+
${list.map((x, i) => `<div class="item recent-it sev-${x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low'}" data-live="${i}">
|
|
2784
|
+
<div class="hd"><span class="live-dot ${x.status === 'busy' ? 'busy' : ''}" title="${esc(x.status)}"></span>
|
|
2785
|
+
<span class="recent-title">${esc((!MASKED && x.name) || shortId(x.session_id))}</span>
|
|
2786
|
+
<span class="spacer"></span><span class="note">${esc(x.status || '')}</span></div>
|
|
2787
|
+
<div class="mt"><span>${esc(x.project || '')}</span>
|
|
2788
|
+
${x.context ? `<span>${fmtNum(x.context)} context</span>` : ''}
|
|
2789
|
+
${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
|
|
2790
|
+
<div class="live-actions">
|
|
2791
|
+
${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">⧉ Resume</button>` : ''}
|
|
2792
|
+
${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
|
|
2793
|
+
${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>⏸ Stop</button>` : ''}
|
|
2794
|
+
${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
|
|
2795
|
+
<span class="live-msg"></span></div></div>`).join('')}
|
|
2796
|
+
<div class="recent-hd">Past sessions</div>`;
|
|
2797
|
+
|
|
2798
|
+
const recentSessions = (rows, liveIds) => rows.map(r => `
|
|
2799
|
+
<div class="item recent-it clickable${RECENT.open_ids.has(r.session_id) ? ' open' : ''}" data-sess="${esc(r.session_id)}">
|
|
2800
|
+
<div class="hd">${liveIds.has(r.session_id) ? '<span class="live-dot" title="Running now"></span>' : ''}
|
|
2801
|
+
<span class="recent-title">${esc(stitle(r))}</span><span class="spacer"></span>
|
|
2802
|
+
<span class="note">${fmtUSD(r.cost)}</span></div>
|
|
2803
|
+
<div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
|
|
2804
|
+
<span>${fmtWhen(r.started_at)}</span></div>
|
|
2805
|
+
<div class="live-actions">
|
|
2806
|
+
${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}
|
|
2807
|
+
<button class="act ghost" data-open-sess="${esc(r.session_id)}">Details</button>
|
|
2808
|
+
<span class="spacer"></span><span class="note recent-caret">${RECENT.open_ids.has(r.session_id) ? '▾' : '▸'} prompts</span></div>
|
|
2809
|
+
<div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
|
|
2810
|
+
|| '<div class="empty">No sessions for these filters</div>';
|
|
2811
|
+
|
|
2812
|
+
const promptLine = x => `<a href="#" class="recent-p" data-prompt="${x.prompt_id}" title="${esc(pt(x.preview))}">
|
|
2813
|
+
<span class="mono sub">${esc((x.ts || '').slice(5, 16).replace('T', ' '))}</span>
|
|
2814
|
+
<span class="recent-pv">${esc(pt(x.preview) || '—')}</span>
|
|
2815
|
+
<span class="note">${fmtUSD(x.pcost ?? x.est_cost_usd)}</span></a>`;
|
|
2816
|
+
|
|
2817
|
+
const recentPrompts = rows => rows.map(x => `<div class="item recent-it">
|
|
2818
|
+
${promptLine(x)}
|
|
2819
|
+
<div class="mt"><span>${esc((!MASKED && x.session_title) || shortId(x.session_id))}</span>
|
|
2820
|
+
<span>${esc(x.project || '')}</span>
|
|
2821
|
+
<a href="#" data-open-sess="${esc(x.session_id)}">Session ›</a></div></div>`).join('');
|
|
2822
|
+
|
|
2823
|
+
async function loadSessionPrompts(host, sid) {
|
|
2824
|
+
host.innerHTML = '<div class="note">Loading…</div>';
|
|
2825
|
+
try {
|
|
2826
|
+
const s = await fetch(`/api/session/${encodeURIComponent(sid)}`).then(r => r.json());
|
|
2827
|
+
const ps = s.prompts || [];
|
|
2828
|
+
host.innerHTML = ps.length ? ps.map(promptLine).join('') : '<div class="note">No prompts recorded</div>';
|
|
2829
|
+
wireRecent(host);
|
|
2830
|
+
} catch (e) { host.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; }
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
function wireRecent(root) {
|
|
2834
|
+
wireRowActs(root); // Resume: copy command
|
|
2835
|
+
root.querySelectorAll('[data-open-sess]').forEach(a => a.onclick = e => {
|
|
2836
|
+
e.preventDefault(); e.stopPropagation(); openSession(a.dataset.openSess); });
|
|
2837
|
+
root.querySelectorAll('[data-prompt]').forEach(a => a.onclick = e => {
|
|
2838
|
+
e.preventDefault(); e.stopPropagation(); openPrompt(+a.dataset.prompt); });
|
|
2839
|
+
root.querySelectorAll('[data-go-live]').forEach(a => a.onclick = e => {
|
|
2840
|
+
e.preventDefault(); toggleRecent(false); go('live'); });
|
|
2841
|
+
root.querySelector('.recent-more')?.addEventListener('click', () => {
|
|
2842
|
+
RECENT.offset += RECENT.limit; renderRecent(); });
|
|
2843
|
+
root.querySelectorAll('.item[data-sess]').forEach(it => {
|
|
2844
|
+
const host = it.querySelector('.recent-prompts'), sid = it.dataset.sess;
|
|
2845
|
+
if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
|
|
2846
|
+
it.onclick = e => {
|
|
2847
|
+
if (e.target.closest('button, a, .recent-prompts')) return;
|
|
2848
|
+
host.hidden = !host.hidden;
|
|
2849
|
+
it.classList.toggle('open', !host.hidden);
|
|
2850
|
+
it.querySelector('.recent-caret').textContent = (host.hidden ? '▸' : '▾') + ' prompts';
|
|
2851
|
+
host.hidden ? RECENT.open_ids.delete(sid) : RECENT.open_ids.add(sid);
|
|
2852
|
+
if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
|
|
2853
|
+
};
|
|
2854
|
+
});
|
|
2855
|
+
root.querySelectorAll('.item[data-live]').forEach(it => {
|
|
2856
|
+
const x = RECENT.live[+it.dataset.live], msg = it.querySelector('.live-msg');
|
|
2857
|
+
it.querySelectorAll('[data-la]').forEach(b => b.onclick = async e => {
|
|
2858
|
+
e.stopPropagation();
|
|
2859
|
+
if (!armed(b)) return; // two clicks, like Running sessions
|
|
2860
|
+
b.disabled = true;
|
|
2861
|
+
try {
|
|
2862
|
+
const r = await sessionAction(x.pid, b.dataset.la, {agent: x.agent});
|
|
2863
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
2864
|
+
msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
|
|
2865
|
+
msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
|
|
2866
|
+
} catch (err) { msg.textContent = 'Failed: ' + err.message; msg.className = 'live-msg err'; }
|
|
2867
|
+
setTimeout(() => { b.disabled = false; }, 2500);
|
|
2868
|
+
});
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2588
2872
|
/* ============================ boot ============================ */
|
|
2589
2873
|
(async function boot() {
|
|
2590
2874
|
const saved = localStorage.getItem('finops-theme');
|
|
@@ -2710,12 +2994,13 @@ async function syncLabel() {
|
|
|
2710
2994
|
const b = $('#sync'); if (!b) return;
|
|
2711
2995
|
const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
|
|
2712
2996
|
if (d.job && d.job.state === 'running') return runSync();
|
|
2713
|
-
b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's
|
|
2714
|
-
b.
|
|
2997
|
+
b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's transcripts from disk into the warehouse`;
|
|
2998
|
+
b.classList.remove('busy');
|
|
2999
|
+
b.innerHTML = `${ICON.sync}Sync <span class="ago">· ${ago(d.built_at)}</span>`;
|
|
2715
3000
|
}
|
|
2716
3001
|
async function runSync() {
|
|
2717
3002
|
const b = $('#sync');
|
|
2718
|
-
b.disabled = true; b.
|
|
3003
|
+
b.disabled = true; b.classList.add('busy'); b.innerHTML = `${ICON.sync}Syncing…`;
|
|
2719
3004
|
try {
|
|
2720
3005
|
const {job} = await doAction('sync');
|
|
2721
3006
|
const j = await followJob(job);
|
|
@@ -3123,7 +3408,15 @@ const TOURS = {
|
|
|
3123
3408
|
{el: 'card:MCP servers', t: 'MCP servers', see: 'Each configured server, whether it was ever called, and its context cost.', get: 'Tool definitions loaded on every request for nothing.', act: 'Remove servers that are configured but never called.'},
|
|
3124
3409
|
{el: 'card:Connectors', t: 'Connectors', see: 'claude.ai connectors and their share of context.', get: 'The same check for connectors as for MCP servers.', act: 'Disconnect what you don\'t use from this machine.'}],
|
|
3125
3410
|
hygiene: [
|
|
3126
|
-
{el: 'kpis', t: 'Where spend sat', see: '
|
|
3411
|
+
{el: 'kpis', t: 'Where spend sat', see: 'Headline shares of your spend, colour-graded: green is low, amber and orange are rising, red means most of the bill sat there. Hover a card for its bands.', get: 'How much of the bill was re-sending a large prefix.', act: 'Walk through each card with Next; the colour is a reading aid, not a verdict.'},
|
|
3412
|
+
{el: 'kpi:Spend in requests ≥ 100K', t: 'Spend in requests ≥ 100K context', see: 'The share of spend on requests whose prompt side (context re-sent to the model) was 100K tokens or more.', get: 'How much you paid while carrying a big conversation. Every turn re-sends it, so large context costs on every request.', act: 'Red here with a red "after crossing" card means sessions keep going long after they got big.'},
|
|
3413
|
+
{el: 'kpi:Spend in requests ≥ 150K', t: 'Spend in requests ≥ 150K context', see: 'The same share, but for requests at 150K tokens or more.', get: 'The heaviest tier: close to where auto-compaction kicks in.', act: 'Compare with the 100K card: a small gap means sessions that pass 100K usually keep growing to 150K.'},
|
|
3414
|
+
{el: 'kpi:Spend after a session first crossed 100K', t: 'Spend after first crossing 100K', see: 'The share of spend that came after a session first reached 100K, even if a later /compact brought it back down. The detail line counts how many sessions ever crossed it.', get: 'How much of your bill is the long tail of big sessions.', act: 'If this is much higher than the "requests ≥ 100K" card, compaction is working but sessions still run long afterwards.'},
|
|
3415
|
+
{el: 'kpi:Spend after a session first crossed 150K', t: 'Spend after first crossing 150K', see: 'The same, measured from the first time a session reached 150K.', get: 'Spend in sessions that went all the way to the heavy end.', act: 'Few sessions but a high share? Open them in the ranked list below.'},
|
|
3416
|
+
{el: 'kpi:Spend near or over the context window', t: 'Near or over the context window', see: 'The share of spend on requests using 90% or more of the model\'s context window, or going over it.', get: 'Turns that risk truncation or a forced compaction mid-task.', act: 'Anything above amber is worth a look: a /compact or fresh session before you hit the wall keeps the answer quality up.'},
|
|
3417
|
+
{el: 'kpi:Sessions in range', t: 'Sessions in range', see: 'How many sessions and main-thread requests the page is based on (blue: context, not graded).', get: 'The sample size behind the percentages.', act: 'Widen the date range if this is small; a handful of sessions makes the shares jumpy.'},
|
|
3418
|
+
{el: 'kpi:Spend in range', t: 'Spend in range', see: 'Total estimated main-thread spend the shares are taken from. Subagent turns are left out because they run on their own prefix.', get: 'The dollar base: multiply any share above by this to get dollars.', act: 'Marked Estimated because it uses list prices, not your invoice.'},
|
|
3419
|
+
{el: 'card:Context per request', t: 'Context per request', see: 'Prompt-side tokens on every request of one session, in order.', get: 'Where a session grew, and where compaction dropped it back.', act: 'Click a row in the ranked list below to switch session.'},
|
|
3127
3420
|
{el: 'card:Sessions ranked', t: 'Sessions', see: 'Each session\'s context trajectory and what it spent after crossing the threshold.', get: 'The sessions where a fresh start would have mattered most.', act: 'Click a row for its trajectory; alt-click to open the session.'}],
|
|
3128
3421
|
waste: [
|
|
3129
3422
|
{el: 'kpis', t: 'Waste headlines', see: 'Estimated excess, exposed spend, and how many prompts, sessions and rules are involved.', get: 'An honest estimate of avoidable spend.', act: '<b>Exposed</b> is what flagged work cost in total; <b>excess</b> is how much more than a fair baseline.'},
|
|
@@ -3181,6 +3474,13 @@ function tourTarget(el) {
|
|
|
3181
3474
|
const page = $('#page');
|
|
3182
3475
|
if (!el) return null;
|
|
3183
3476
|
if (el === 'kpis') return page?.querySelector('.kpi')?.closest('.grid') || null;
|
|
3477
|
+
// 'kpi:Label start' — one KPI card, matched by the start of its label (badge text ignored).
|
|
3478
|
+
if (el.startsWith('kpi:')) {
|
|
3479
|
+
const want = el.slice(4).trim().toLowerCase();
|
|
3480
|
+
for (const k of page?.querySelectorAll('.kpi') || [])
|
|
3481
|
+
if ((k.querySelector('.l')?.firstChild?.textContent || '').trim().toLowerCase().startsWith(want)) return k;
|
|
3482
|
+
return null;
|
|
3483
|
+
}
|
|
3184
3484
|
const m = /^card(\d+)$/.exec(el);
|
|
3185
3485
|
if (m) return page?.querySelectorAll('.card')[+m[1]] || null;
|
|
3186
3486
|
// 'card:Some title' — match a card by the start of its heading, so a step keeps
|
package/web/styles.css
CHANGED
|
@@ -101,14 +101,28 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
101
101
|
|
|
102
102
|
/* ---------- header ---------- */
|
|
103
103
|
.topbar { position:sticky; top:0; z-index:40; background: var(--surface);
|
|
104
|
-
border-bottom:1px solid var(--border); padding:10px 20px; }
|
|
105
|
-
.topbar .r1 { display:flex; align-items:center; gap:
|
|
106
|
-
.
|
|
107
|
-
.
|
|
108
|
-
.crumbs
|
|
104
|
+
border-bottom:1px solid var(--border); padding:10px 20px 9px; }
|
|
105
|
+
.topbar .r1 { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
|
106
|
+
.ttl-block { display:flex; flex-direction:column; gap:1px; min-width:0; }
|
|
107
|
+
.topbar h1 { font-size:16px; margin:0; font-weight:640; letter-spacing:-0.01em; line-height:1.2; }
|
|
108
|
+
.crumbs { color:var(--muted); font-size:11px; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
|
109
|
+
.crumbs b { color: var(--text-2); font-weight:560; font-variant-numeric: tabular-nums; }
|
|
109
110
|
.spacer { flex:1; }
|
|
111
|
+
|
|
112
|
+
/* Headline totals for the current filters. */
|
|
113
|
+
.scope { display:flex; align-items:stretch; margin-left:6px; padding-left:14px;
|
|
114
|
+
border-left:1px solid var(--border); min-height:30px; }
|
|
115
|
+
.scope:empty { display:none; }
|
|
116
|
+
.scope .sc { display:flex; flex-direction:column; justify-content:center; padding:0 12px; gap:1px;
|
|
117
|
+
border-right:1px solid var(--border); }
|
|
118
|
+
.scope .sc:first-child { padding-left:0; }
|
|
119
|
+
.scope .sc:last-child { border-right:0; }
|
|
120
|
+
.scope .sc span { font-size:9px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted);
|
|
121
|
+
font-weight:620; white-space:nowrap; }
|
|
122
|
+
.scope .sc b { font-size:13.5px; font-weight:650; font-variant-numeric: tabular-nums; white-space:nowrap; }
|
|
123
|
+
|
|
110
124
|
.search { position:relative; }
|
|
111
|
-
.search input { width:
|
|
125
|
+
.search input { width:230px; padding:6px 10px 6px 28px; border-radius:8px; font-size:12.5px;
|
|
112
126
|
border:1px solid var(--border); background:var(--surface-2); }
|
|
113
127
|
.search input:focus { outline:2px solid var(--s1); outline-offset:-1px; border-color:transparent; }
|
|
114
128
|
.search .mag { position:absolute; left:9px; top:50%; transform:translateY(-50%);
|
|
@@ -116,9 +130,48 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
116
130
|
.iconbtn { border:1px solid var(--border); background:var(--surface-2); border-radius:8px;
|
|
117
131
|
padding:6px 10px; cursor:pointer; font-size:12px; color:var(--text-2); }
|
|
118
132
|
.iconbtn:hover { background: var(--surface-3); color:var(--text); }
|
|
133
|
+
/* One joined toolbar instead of five loose buttons. */
|
|
134
|
+
.tools { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden;
|
|
135
|
+
background:var(--surface-2); }
|
|
136
|
+
.tools .iconbtn { border:0; border-radius:0; border-right:1px solid var(--border); background:transparent;
|
|
137
|
+
height:30px; line-height:1; }
|
|
138
|
+
.tools .iconbtn:last-child { border-right:0; }
|
|
139
|
+
.tools .iconbtn.sq { width:32px; padding:0; font-size:13px; }
|
|
140
|
+
.tools .iconbtn { display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
|
141
|
+
.tools svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.9;
|
|
142
|
+
stroke-linecap:round; stroke-linejoin:round; flex:none; }
|
|
143
|
+
.tools .iconbtn.on { background: var(--surface-3); color:var(--s1); box-shadow: inset 0 -2px 0 var(--s1); }
|
|
144
|
+
.act.sync { height:30px; white-space:nowrap; display:inline-flex; align-items:center; gap:6px; }
|
|
145
|
+
.act.sync svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.9;
|
|
146
|
+
stroke-linecap:round; stroke-linejoin:round; flex:none; }
|
|
147
|
+
.act.sync .ago { color:var(--muted); font-weight:450; }
|
|
148
|
+
/* While syncing, the arrow into the database pulses so the button reads as working. */
|
|
149
|
+
.act.sync.busy svg path:nth-last-child(-n+2) { animation: sync-in 1s ease-in-out infinite; }
|
|
150
|
+
@keyframes sync-in { 0%,100% { opacity:1; transform:translateY(0); } 50% { opacity:.35; transform:translateY(-2px); } }
|
|
151
|
+
@media (prefers-reduced-motion: reduce) { .act.sync.busy svg path { animation:none; } }
|
|
119
152
|
|
|
120
153
|
/* ---------- filter bar ---------- */
|
|
121
|
-
.filters { display:flex; gap:
|
|
154
|
+
.filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:9px; }
|
|
155
|
+
/* Segmented control: a labelled group of mutually exclusive choices. */
|
|
156
|
+
.seg { display:inline-flex; align-items:center; border:1px solid var(--border); border-radius:8px;
|
|
157
|
+
background:var(--surface-2); padding:2px; gap:1px; }
|
|
158
|
+
.seg .seg-l { font-size:9.5px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted);
|
|
159
|
+
font-weight:620; padding:0 7px 0 6px; }
|
|
160
|
+
.seg button { border:0; background:transparent; border-radius:6px; padding:3px 9px; font-size:11.5px;
|
|
161
|
+
color:var(--text-2); cursor:pointer; white-space:nowrap; }
|
|
162
|
+
.seg button:hover { background:var(--surface-3); color:var(--text); }
|
|
163
|
+
.seg button.on { background:var(--s1); color:#fff; font-weight:550; }
|
|
164
|
+
.chip.toggle i { width:22px; height:12px; border-radius:99px; background:var(--border-strong);
|
|
165
|
+
position:relative; display:inline-block; transition:background .15s; }
|
|
166
|
+
.chip.toggle i::after { content:''; position:absolute; top:2px; left:2px; width:8px; height:8px;
|
|
167
|
+
border-radius:50%; background:#fff; transition:left .15s; }
|
|
168
|
+
.chip.toggle.on { background:var(--surface-2); border-color:var(--border); color:var(--text); font-weight:500; }
|
|
169
|
+
.chip.toggle.on i { background:var(--s1); }
|
|
170
|
+
.chip.toggle.on i::after { left:12px; }
|
|
171
|
+
.chip.clr { color:var(--critical-ink); border-color:color-mix(in srgb, var(--critical) 35%, var(--border)); }
|
|
172
|
+
.coverage { font-size:10.5px; color:var(--muted); white-space:nowrap; font-variant-numeric: tabular-nums; }
|
|
173
|
+
@media (max-width: 1450px) { .scope .sc:nth-child(n+4) { display:none; } }
|
|
174
|
+
@media (max-width: 1150px) { .scope, .coverage { display:none; } }
|
|
122
175
|
.chipsel { position:relative; }
|
|
123
176
|
.chip { border:1px solid var(--border); background:var(--surface-2); border-radius:99px;
|
|
124
177
|
padding:4px 11px; font-size:11.5px; cursor:pointer; color:var(--text-2);
|
|
@@ -194,6 +247,17 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
194
247
|
.kpi .d { font-size:11px; color:var(--text-2); }
|
|
195
248
|
.kpi .d .up { color:var(--critical-ink); font-weight:600; }
|
|
196
249
|
.kpi .d .down { color:var(--good-ink); font-weight:600; }
|
|
250
|
+
/* Colour-graded KPI: a tinted surface, a left stripe and a tinted value. */
|
|
251
|
+
.kpi[class*="tone-"] { border-left-width:4px; }
|
|
252
|
+
.kpi.tone-good { border-left-color:var(--good); background:color-mix(in srgb, var(--good) 6%, var(--surface)); }
|
|
253
|
+
.kpi.tone-warning { border-left-color:var(--warning); background:color-mix(in srgb, var(--warning) 8%, var(--surface)); }
|
|
254
|
+
.kpi.tone-serious { border-left-color:var(--serious); background:color-mix(in srgb, var(--serious) 8%, var(--surface)); }
|
|
255
|
+
.kpi.tone-critical { border-left-color:var(--critical); background:color-mix(in srgb, var(--critical) 7%, var(--surface)); }
|
|
256
|
+
.kpi.tone-info { border-left-color:var(--s1); }
|
|
257
|
+
.kpi.tone-good .v { color:var(--good-ink); }
|
|
258
|
+
.kpi.tone-warning .v { color:var(--warning-ink); }
|
|
259
|
+
.kpi.tone-serious .v { color:var(--serious-ink); }
|
|
260
|
+
.kpi.tone-critical .v { color:var(--critical-ink); }
|
|
197
261
|
.kpi.na .v { font-size:12px; font-weight:500; color:var(--muted); font-style:italic; }
|
|
198
262
|
|
|
199
263
|
/* ---------- table ---------- */
|
|
@@ -284,7 +348,47 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
284
348
|
.pill { display:inline-block; font-size:10.5px; padding:1px 7px; border-radius:99px;
|
|
285
349
|
background:var(--surface-3); color:var(--text-2); border:1px solid var(--border); }
|
|
286
350
|
.empty { padding:26px 10px; text-align:center; color:var(--muted); font-size:12px; }
|
|
287
|
-
.loading { padding:40px; text-align:center; color:var(--muted); font-size:12px;
|
|
351
|
+
.loading { padding:40px; text-align:center; color:var(--muted); font-size:12px;
|
|
352
|
+
display:flex; align-items:center; justify-content:center; gap:9px; }
|
|
353
|
+
.loading::before { content:''; width:14px; height:14px; border-radius:50%; flex:none;
|
|
354
|
+
border:2px solid color-mix(in srgb, var(--s1) 22%, transparent); border-top-color:var(--s1);
|
|
355
|
+
animation: spin .8s linear infinite; }
|
|
356
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
357
|
+
|
|
358
|
+
/* ---------- page loader: dashboard-shaped skeleton + live status ---------- */
|
|
359
|
+
.pl { display:flex; flex-direction:column; gap:12px; }
|
|
360
|
+
.pl-status { display:flex; align-items:center; gap:10px; align-self:flex-start; padding:6px 12px 6px 8px;
|
|
361
|
+
border:1px solid color-mix(in srgb, var(--s1) 30%, var(--border)); border-radius:99px;
|
|
362
|
+
background:color-mix(in srgb, var(--s1) 7%, var(--surface)); font-size:12px; color:var(--text-2); }
|
|
363
|
+
.pl-mark { display:inline-flex; align-items:flex-end; gap:2px; height:14px; width:16px; }
|
|
364
|
+
.pl-mark i { width:4px; border-radius:1.5px; background:var(--s1); animation: pl-eq 0.9s ease-in-out infinite; }
|
|
365
|
+
.pl-mark i:nth-child(2) { background:var(--s2); animation-delay:.15s; }
|
|
366
|
+
.pl-mark i:nth-child(3) { background:var(--s3); animation-delay:.3s; }
|
|
367
|
+
@keyframes pl-eq { 0%,100% { height:30%; } 50% { height:100%; } }
|
|
368
|
+
.pl-msg { font-weight:560; color:var(--text); }
|
|
369
|
+
.pl-time { color:var(--muted); font-size:11px; font-variant-numeric: tabular-nums; }
|
|
370
|
+
.pl-time:empty { display:none; }
|
|
371
|
+
.pl-tip { align-self:center; font-size:11.5px; color:var(--muted); padding:4px 0 8px; }
|
|
372
|
+
.skel { box-shadow:none; gap:8px; }
|
|
373
|
+
.kpi.skel { gap:9px; }
|
|
374
|
+
.bone { display:block; border-radius:5px; background:var(--surface-3); position:relative; overflow:hidden; }
|
|
375
|
+
.bone::after, .pl-bars i::after { content:''; position:absolute; inset:0; transform:translateX(-100%);
|
|
376
|
+
background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 70%, transparent), transparent);
|
|
377
|
+
animation: shimmer 1.4s ease-in-out infinite; }
|
|
378
|
+
@keyframes shimmer { to { transform:translateX(100%); } }
|
|
379
|
+
.pl-bars { display:flex; align-items:flex-end; gap:6px; height:130px; margin-top:14px;
|
|
380
|
+
border-bottom:1px solid var(--grid); }
|
|
381
|
+
.pl-bars i { flex:1; border-radius:4px 4px 0 0; position:relative; overflow:hidden;
|
|
382
|
+
background:color-mix(in srgb, var(--s1) 16%, var(--surface-3));
|
|
383
|
+
animation: pl-grow 1.6s ease-in-out infinite alternate; transform-origin:bottom; }
|
|
384
|
+
.pl-bars.alt i { background:color-mix(in srgb, var(--s3) 16%, var(--surface-3)); }
|
|
385
|
+
@keyframes pl-grow { from { transform:scaleY(.55); } to { transform:scaleY(1); } }
|
|
386
|
+
.pl-row { display:flex; justify-content:space-between; gap:16px; padding:9px 0; border-bottom:1px solid var(--grid); }
|
|
387
|
+
.pl-row:last-child { border-bottom:0; }
|
|
388
|
+
@media (prefers-reduced-motion: reduce) {
|
|
389
|
+
.bone::after, .pl-bars i::after, .pl-bars i, .pl-mark i, .loading::before { animation:none; }
|
|
390
|
+
.pl-mark i { height:70%; }
|
|
391
|
+
}
|
|
288
392
|
.tabs { display:flex; gap:3px; border-bottom:1px solid var(--border); margin-bottom:2px;
|
|
289
393
|
overflow-x:auto; }
|
|
290
394
|
.tabs button { background:none; border:none; padding:7px 12px; font-size:12px; cursor:pointer;
|
|
@@ -457,3 +561,32 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
457
561
|
.brand .ver { font-size: 9.5px; font-weight: 600; color: var(--muted);
|
|
458
562
|
border: 1px solid var(--border); border-radius: 99px;
|
|
459
563
|
padding: 1px 5px; margin-left: 5px; letter-spacing: .01em; }
|
|
564
|
+
.iconbtn.on { background: var(--surface-3); color:var(--text); border-color: var(--s1); }
|
|
565
|
+
|
|
566
|
+
/* ---------- Recent panel: sits under the detail drawer so a click opens on top ---------- */
|
|
567
|
+
.recent-scrim { position:fixed; inset:0; background:rgba(11,11,11,.18); z-index:98; }
|
|
568
|
+
.recent { position:fixed; top:0; right:0; bottom:0; width:min(440px, 100vw); background:var(--plane);
|
|
569
|
+
z-index:99; box-shadow:-8px 0 40px rgba(0,0,0,.18); display:flex; flex-direction:column; }
|
|
570
|
+
.recent > header { padding:13px 16px; background:var(--surface); border-bottom:1px solid var(--border);
|
|
571
|
+
display:flex; align-items:center; gap:10px; }
|
|
572
|
+
.recent > header h2 { margin:0; font-size:14.5px; font-weight:640; }
|
|
573
|
+
.recent-search { position:relative; padding:10px 16px 0; }
|
|
574
|
+
.recent-search input { width:100%; box-sizing:border-box; padding:7px 10px 7px 28px; border-radius:8px;
|
|
575
|
+
font-size:12.5px; border:1px solid var(--border); background:var(--surface-2); color:var(--text); }
|
|
576
|
+
.recent-search .mag { position:absolute; left:26px; top:calc(50% + 5px); transform:translateY(-50%);
|
|
577
|
+
color:var(--muted); font-size:12px; pointer-events:none; }
|
|
578
|
+
.recent .content { overflow-y:auto; padding:10px 16px 40px; display:flex; flex-direction:column; gap:8px; }
|
|
579
|
+
.recent-hd { display:flex; align-items:center; font-size:11px; font-weight:640; text-transform:uppercase;
|
|
580
|
+
letter-spacing:.04em; color:var(--muted); margin-top:4px; }
|
|
581
|
+
.recent-hd a { text-transform:none; letter-spacing:0; font-weight:500; }
|
|
582
|
+
.recent-it { display:flex; flex-direction:column; gap:5px; }
|
|
583
|
+
.recent-it .live-actions { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
|
584
|
+
.recent-it .act { padding:2px 8px; font-size:11px; }
|
|
585
|
+
.recent-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
|
586
|
+
.recent-prompts { display:flex; flex-direction:column; border-top:1px solid var(--border); padding-top:4px; }
|
|
587
|
+
.recent-p { display:flex; gap:8px; align-items:baseline; padding:4px 2px; border-radius:5px;
|
|
588
|
+
color:var(--text); text-decoration:none; font-size:12px; }
|
|
589
|
+
.recent-p:hover { background:var(--surface-3); }
|
|
590
|
+
.recent-pv { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
591
|
+
.live-dot { width:8px; height:8px; border-radius:50%; background:var(--axis); flex:none; }
|
|
592
|
+
.live-dot.busy, .recent-it .hd > .live-dot[title="Running now"] { background:var(--s1); }
|