botmux 2.89.0 → 2.90.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.en.md +3 -3
- package/README.md +3 -3
- package/dist/core/skills/discovery.d.ts +16 -0
- package/dist/core/skills/discovery.d.ts.map +1 -1
- package/dist/core/skills/discovery.js +48 -2
- package/dist/core/skills/discovery.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +9 -2
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +60 -2
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/insights.d.ts.map +1 -1
- package/dist/dashboard/web/insights.js +514 -68
- package/dist/dashboard/web/insights.js.map +1 -1
- package/dist/dashboard/web/skills.d.ts.map +1 -1
- package/dist/dashboard/web/skills.js +1 -1
- package/dist/dashboard/web/skills.js.map +1 -1
- package/dist/dashboard/web/whiteboards.js +3 -3
- package/dist/dashboard-web/app.js +883 -795
- package/dist/dashboard-web/style.css +105 -3
- package/dist/services/insight/classify.d.ts +2 -0
- package/dist/services/insight/classify.d.ts.map +1 -1
- package/dist/services/insight/classify.js +25 -0
- package/dist/services/insight/classify.js.map +1 -1
- package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
- package/dist/services/insight/claude-span-reader.js +7 -4
- package/dist/services/insight/claude-span-reader.js.map +1 -1
- package/dist/services/insight/report.d.ts.map +1 -1
- package/dist/services/insight/report.js +102 -23
- package/dist/services/insight/report.js.map +1 -1
- package/dist/services/insight/subagent-reader.d.ts +19 -0
- package/dist/services/insight/subagent-reader.d.ts.map +1 -0
- package/dist/services/insight/subagent-reader.js +111 -0
- package/dist/services/insight/subagent-reader.js.map +1 -0
- package/dist/services/insight/types.d.ts +43 -0
- package/dist/services/insight/types.d.ts.map +1 -1
- package/dist/services/insight/types.js.map +1 -1
- package/dist/skills/definitions.d.ts +8 -0
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +8 -2
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/installer.d.ts +15 -0
- package/dist/skills/installer.d.ts.map +1 -1
- package/dist/skills/installer.js +39 -1
- package/dist/skills/installer.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { botDisplayName, escapeHtml, loadNameMaps, relTime,
|
|
1
|
+
import { botDisplayName, escapeHtml, loadNameMaps, relTime, t } from './ui.js';
|
|
2
2
|
import MarkdownIt from 'markdown-it';
|
|
3
3
|
const SEVERITY_RANK = { bad: 0, warn: 1, info: 2 };
|
|
4
4
|
function fmtInt(n) {
|
|
@@ -34,7 +34,12 @@ function safeStatus(report, error) {
|
|
|
34
34
|
return report.status;
|
|
35
35
|
}
|
|
36
36
|
function sessionTitle(s) {
|
|
37
|
-
|
|
37
|
+
// Strip the leading @mention(s), then fall back to the id when nothing
|
|
38
|
+
// readable remains — e.g. a title of just "@Gpt" or a blank/whitespace one.
|
|
39
|
+
// (stripMentionPrefix returns the raw string when stripping empties it, so we
|
|
40
|
+
// inline the strip here to detect the truly-empty case and fall back.)
|
|
41
|
+
const stripped = String(s.title ?? '').replace(/^(?:@\S+\s*)+/, '').trim();
|
|
42
|
+
return stripped || String(s.sessionId ?? '');
|
|
38
43
|
}
|
|
39
44
|
function severityLabel(sev) {
|
|
40
45
|
return sev === 'bad' ? t('insights.sevBad') : sev === 'warn' ? t('insights.sevWarn') : t('insights.sevInfo');
|
|
@@ -198,8 +203,6 @@ function aggregateRecords(records) {
|
|
|
198
203
|
subagentCostShare: null,
|
|
199
204
|
};
|
|
200
205
|
let analyzed = 0;
|
|
201
|
-
let rwSum = 0;
|
|
202
|
-
let rwN = 0;
|
|
203
206
|
const suggMap = new Map();
|
|
204
207
|
for (const rec of records) {
|
|
205
208
|
const r = rec.report;
|
|
@@ -221,10 +224,6 @@ function aggregateRecords(records) {
|
|
|
221
224
|
agg.phase[ph].ms += pv.ms;
|
|
222
225
|
}
|
|
223
226
|
}
|
|
224
|
-
if (a.readWriteRatio !== null && Number.isFinite(a.readWriteRatio)) {
|
|
225
|
-
rwSum += a.readWriteRatio;
|
|
226
|
-
rwN += 1;
|
|
227
|
-
}
|
|
228
227
|
for (const s of r.suggestions ?? []) {
|
|
229
228
|
const e = suggMap.get(s.id);
|
|
230
229
|
if (e)
|
|
@@ -233,7 +232,12 @@ function aggregateRecords(records) {
|
|
|
233
232
|
suggMap.set(s.id, { id: s.id, title: s.title, severity: s.severity, count: 1, evidence: s.evidence ?? [], action: s.action });
|
|
234
233
|
}
|
|
235
234
|
}
|
|
236
|
-
|
|
235
|
+
// Pooled read:write (Σreads / Σwrites), consistent with the pooled totals on the
|
|
236
|
+
// same metric row (spans/failed/slow). A mean of per-session ratios would let a
|
|
237
|
+
// tiny 2-read/1-write session skew the headline as much as a 300/100 one.
|
|
238
|
+
agg.readWriteRatio = agg.phase.edit.count > 0
|
|
239
|
+
? Number((agg.phase.research.count / agg.phase.edit.count).toFixed(2))
|
|
240
|
+
: null;
|
|
237
241
|
const topFailedTools = Object.entries(agg.failByTool)
|
|
238
242
|
.map(([tool, count]) => ({ tool, count }))
|
|
239
243
|
.sort((x, y) => y.count - x.count)
|
|
@@ -242,6 +246,11 @@ function aggregateRecords(records) {
|
|
|
242
246
|
}
|
|
243
247
|
// Overview metrics + recommendations for the CURRENTLY VISIBLE (filtered) records,
|
|
244
248
|
// so the summary tracks the active filter rather than staying global.
|
|
249
|
+
// Cross-tool measurement caveat (Codex reads via shell aren't counted as 'read', etc.) —
|
|
250
|
+
// shown under the overview & distribution so ratios/distributions aren't misread.
|
|
251
|
+
function disclosureNote() {
|
|
252
|
+
return `<p class="insight-disclosure">⚖︎ ${escapeHtml(t('insights.disclosure'))}</p>`;
|
|
253
|
+
}
|
|
245
254
|
function renderOverview(d) {
|
|
246
255
|
const a = d.agg;
|
|
247
256
|
const rw = a.readWriteRatio === null ? '-' : a.readWriteRatio.toFixed(1);
|
|
@@ -277,20 +286,50 @@ function renderOverview(d) {
|
|
|
277
286
|
}).join('') : `<p class="mut">${escapeHtml(t('insights.noFailures'))}</p>`}
|
|
278
287
|
</div>
|
|
279
288
|
</section>
|
|
280
|
-
</div
|
|
289
|
+
</div>
|
|
290
|
+
${disclosureNote()}`;
|
|
281
291
|
}
|
|
282
|
-
function
|
|
283
|
-
const entries = Object.entries(
|
|
292
|
+
function phaseMixBar(phase) {
|
|
293
|
+
const entries = Object.entries(phase ?? {}).filter(([, v]) => v.count > 0 || v.ms > 0);
|
|
284
294
|
if (!entries.length)
|
|
285
295
|
return '';
|
|
286
|
-
return `<div class="mph">${entries.map(([
|
|
296
|
+
return `<div class="mph">${entries.map(([ph, v]) => {
|
|
287
297
|
const weight = Math.max(1, v.ms || v.count);
|
|
288
|
-
return `<i class="${phaseClass(
|
|
298
|
+
return `<i class="${phaseClass(ph)}" style="flex:${weight}" title="${escapeHtml(`${phaseLabel(ph)} · ${v.count} · ${fmtMs(v.ms)}`)}"></i>`;
|
|
289
299
|
}).join('')}</div>`;
|
|
290
300
|
}
|
|
301
|
+
function renderPhaseMix(report) {
|
|
302
|
+
return phaseMixBar(report.agg.phase);
|
|
303
|
+
}
|
|
304
|
+
// Delegated sub-agents (Claude Task/Agent) as swim-lanes: each shows its type,
|
|
305
|
+
// task, what it did (phase mix) and how long it ran. Renders nothing when none.
|
|
306
|
+
function renderSubagents(report) {
|
|
307
|
+
const lanes = report.subagents ?? [];
|
|
308
|
+
if (!lanes.length)
|
|
309
|
+
return '';
|
|
310
|
+
const totMs = lanes.reduce((s, l) => s + l.durationMs, 0);
|
|
311
|
+
const rows = lanes.map(l => `<div class="sublane${l.failures ? ' bad' : ''}">
|
|
312
|
+
<div class="sublane-head">
|
|
313
|
+
<span class="sublane-type">${escapeHtml(l.agentType)}</span>
|
|
314
|
+
<span class="sublane-task">${escapeHtml(l.task?.text ?? '')}${l.task?.truncated ? '…' : ''}</span>
|
|
315
|
+
</div>
|
|
316
|
+
${phaseMixBar(l.phase)}
|
|
317
|
+
<div class="sublane-stats">
|
|
318
|
+
<span>${fmtInt(l.spans)} ${escapeHtml(t('insights.spansShort'))}</span>
|
|
319
|
+
<span>${escapeHtml(fmtMs(l.durationMs))}</span>
|
|
320
|
+
${l.failures ? `<span class="bad">${fmtInt(l.failures)} ${escapeHtml(t('insights.failedShort'))}</span>` : ''}
|
|
321
|
+
</div>
|
|
322
|
+
</div>`).join('');
|
|
323
|
+
return `<section class="block subagent-block">
|
|
324
|
+
<h3>${escapeHtml(t('insights.subagents'))} <span class="mut">· ${lanes.length} · ${escapeHtml(fmtMs(totMs))}</span></h3>
|
|
325
|
+
<p class="mut ins-hint">${escapeHtml(t('insights.subagentsHint'))}</p>
|
|
326
|
+
<div class="sublanes">${rows}</div>
|
|
327
|
+
</section>`;
|
|
328
|
+
}
|
|
291
329
|
const INSIGHT_TABS = [
|
|
292
330
|
{ key: 'overview', label: 'insights.tabOverview' },
|
|
293
331
|
{ key: 'sessions', label: 'insights.tabSessions' },
|
|
332
|
+
{ key: 'flow', label: 'insights.tabFlow' },
|
|
294
333
|
{ key: 'dist', label: 'insights.tabDist' },
|
|
295
334
|
{ key: 'hot', label: 'insights.tabHot' },
|
|
296
335
|
];
|
|
@@ -333,7 +372,7 @@ function median(values) {
|
|
|
333
372
|
}
|
|
334
373
|
// One histogram card. bins is an ordered list of {label, test}; each session value
|
|
335
374
|
// falls into the first matching bin. Reuses the .hbars bar styling.
|
|
336
|
-
function renderHistogram(title, values, bins, fmtMedian = fmtInt) {
|
|
375
|
+
function renderHistogram(title, values, bins, fmtMedian = fmtInt, sortKey) {
|
|
337
376
|
const counts = bins.map(b => ({ label: b.label, count: values.filter(b.test).length }));
|
|
338
377
|
const max = Math.max(1, ...counts.map(c => c.count));
|
|
339
378
|
const total = Math.max(1, values.length);
|
|
@@ -342,15 +381,48 @@ function renderHistogram(title, values, bins, fmtMedian = fmtInt) {
|
|
|
342
381
|
const share = Math.round((c.count / total) * 100);
|
|
343
382
|
return `<div class="hbrow"><div class="hblabel">${escapeHtml(c.label)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(c.count)}<small>${share}%</small></div></div>`;
|
|
344
383
|
}).join('');
|
|
384
|
+
const jump = sortKey ? `<button type="button" class="ihist-jump" data-distsort="${sortKey}">${escapeHtml(t('insights.viewSessions'))} ›</button>` : '';
|
|
345
385
|
return `<section class="block ihist">
|
|
346
|
-
<div class="ihist-head"><h3>${escapeHtml(title)}</h3><span class="mut">${escapeHtml(t('insights.distMedian', { v: fmtMedian(median(values)) }))}</span
|
|
386
|
+
<div class="ihist-head"><h3>${escapeHtml(title)}</h3><span class="mut">${escapeHtml(t('insights.distMedian', { v: fmtMedian(median(values)) }))}</span>${jump}</div>
|
|
347
387
|
<div class="hbars">${rows}</div>
|
|
348
388
|
</section>`;
|
|
349
389
|
}
|
|
390
|
+
// Sessions-per-day over the last 4 weeks (from each record's lastMessageAt) — a quick
|
|
391
|
+
// "how busy lately" trend. Pure client-side, no new engine data.
|
|
392
|
+
function renderTrend(records) {
|
|
393
|
+
const DAYS = 28;
|
|
394
|
+
const dayMs = 86400000;
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
const counts = new Array(DAYS).fill(0);
|
|
397
|
+
for (const rec of records) {
|
|
398
|
+
const ts = Number(rec.session.lastMessageAt ?? 0);
|
|
399
|
+
if (!ts)
|
|
400
|
+
continue;
|
|
401
|
+
const age = Math.floor((now - ts) / dayMs);
|
|
402
|
+
if (age >= 0 && age < DAYS)
|
|
403
|
+
counts[DAYS - 1 - age]++;
|
|
404
|
+
}
|
|
405
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
406
|
+
if (!total)
|
|
407
|
+
return '';
|
|
408
|
+
const max = Math.max(1, ...counts);
|
|
409
|
+
// Native title tooltip (date · count). The old data-tip was never wired to a
|
|
410
|
+
// tooltip host outside the detail body, so it surfaced nothing on hover.
|
|
411
|
+
const bars = counts.map((c, j) => {
|
|
412
|
+
const d = new Date(now - (DAYS - 1 - j) * dayMs);
|
|
413
|
+
const label = `${d.getMonth() + 1}-${d.getDate()} · ${c}`;
|
|
414
|
+
return `<i class="trendbar" style="height:${c ? Math.max(8, Math.round((c / max) * 100)) : 2}%" title="${escapeHtml(label)}"></i>`;
|
|
415
|
+
}).join('');
|
|
416
|
+
return `<section class="block trend-block">
|
|
417
|
+
<div class="ihist-head"><h3>${escapeHtml(t('insights.distTrend'))}</h3><span class="mut">${escapeHtml(t('insights.distTrendSub'))} · ${total}</span></div>
|
|
418
|
+
<div class="trend">${bars}</div>
|
|
419
|
+
</section>`;
|
|
420
|
+
}
|
|
350
421
|
function renderDistribution(records) {
|
|
351
422
|
const reports = okReports(records);
|
|
352
423
|
if (!reports.length)
|
|
353
424
|
return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
|
|
425
|
+
const churn = reports.map(r => (r.hot?.files ?? []).reduce((s, f) => s + (f.added ?? 0) + (f.removed ?? 0), 0));
|
|
354
426
|
const spans = reports.map(r => r.agg.totalSpans);
|
|
355
427
|
const failed = reports.map(r => r.agg.failedSpans);
|
|
356
428
|
const slow = reports.map(r => r.agg.slowSpans);
|
|
@@ -358,65 +430,179 @@ function renderDistribution(records) {
|
|
|
358
430
|
const rw = reports.map(r => r.agg.readWriteRatio).filter((v) => v !== null && Number.isFinite(v));
|
|
359
431
|
return `<p class="mut ins-hint">${escapeHtml(t('insights.distHint'))}</p>
|
|
360
432
|
<div class="ihist-grid">
|
|
433
|
+
${renderTrend(records)}
|
|
361
434
|
${renderHistogram(t('insights.distSpans'), spans, [
|
|
362
435
|
{ label: '0–10', test: v => v <= 10 },
|
|
363
436
|
{ label: '11–50', test: v => v > 10 && v <= 50 },
|
|
364
437
|
{ label: '51–200', test: v => v > 50 && v <= 200 },
|
|
365
438
|
{ label: '201–500', test: v => v > 200 && v <= 500 },
|
|
366
439
|
{ label: '500+', test: v => v > 500 },
|
|
367
|
-
])}
|
|
440
|
+
], fmtInt, 'spans')}
|
|
368
441
|
${renderHistogram(t('insights.distFailed'), failed, [
|
|
369
442
|
{ label: '0', test: v => v === 0 },
|
|
370
443
|
{ label: '1–2', test: v => v >= 1 && v <= 2 },
|
|
371
444
|
{ label: '3–5', test: v => v >= 3 && v <= 5 },
|
|
372
445
|
{ label: '6–10', test: v => v >= 6 && v <= 10 },
|
|
373
446
|
{ label: '10+', test: v => v > 10 },
|
|
374
|
-
])}
|
|
447
|
+
], fmtInt, 'fails')}
|
|
375
448
|
${renderHistogram(t('insights.distSlow'), slow, [
|
|
376
449
|
{ label: '0', test: v => v === 0 },
|
|
377
450
|
{ label: '1–2', test: v => v >= 1 && v <= 2 },
|
|
378
451
|
{ label: '3–5', test: v => v >= 3 && v <= 5 },
|
|
379
452
|
{ label: '5+', test: v => v > 5 },
|
|
380
|
-
])}
|
|
453
|
+
], fmtInt, 'slow')}
|
|
381
454
|
${renderHistogram(t('insights.distAgentTime'), agentMin, [
|
|
382
455
|
{ label: '<1m', test: v => v < 1 },
|
|
383
456
|
{ label: '1–5m', test: v => v >= 1 && v < 5 },
|
|
384
457
|
{ label: '5–30m', test: v => v >= 5 && v < 30 },
|
|
385
458
|
{ label: '30m–2h', test: v => v >= 30 && v < 120 },
|
|
386
459
|
{ label: '2h+', test: v => v >= 120 },
|
|
387
|
-
], n => `${Math.round(n)}m
|
|
460
|
+
], n => `${Math.round(n)}m`, 'agent')}
|
|
388
461
|
${rw.length ? renderHistogram(t('insights.distRw'), rw, [
|
|
389
462
|
{ label: '0', test: v => v === 0 },
|
|
390
463
|
{ label: '0–1', test: v => v > 0 && v < 1 },
|
|
391
464
|
{ label: '1–3', test: v => v >= 1 && v < 3 },
|
|
392
465
|
{ label: '3+', test: v => v >= 3 },
|
|
393
466
|
], n => n.toFixed(1)) : ''}
|
|
394
|
-
|
|
467
|
+
${renderHistogram(t('insights.distChurn'), churn, [
|
|
468
|
+
{ label: '0', test: v => v === 0 },
|
|
469
|
+
{ label: '1–100', test: v => v > 0 && v <= 100 },
|
|
470
|
+
{ label: '100–1k', test: v => v > 100 && v <= 1000 },
|
|
471
|
+
{ label: '1k–10k', test: v => v > 1000 && v <= 10000 },
|
|
472
|
+
{ label: '10k+', test: v => v > 10000 },
|
|
473
|
+
])}
|
|
474
|
+
</div>
|
|
475
|
+
${disclosureNote()}`;
|
|
476
|
+
}
|
|
477
|
+
const FLOW_PHASES = ['research', 'edit', 'run', 'delegate', 'discuss'];
|
|
478
|
+
// "行为流": where the agent's actions & wall-time land across activity phases,
|
|
479
|
+
// plus each session's phase rhythm. Built from agg.phase (present in summary mode),
|
|
480
|
+
// so it works at the overview level without the (detail-only) turn timeline.
|
|
481
|
+
function renderFlow(records) {
|
|
482
|
+
const ok = records.filter(r => r.report && r.report.status === 'ok' && r.report.agg);
|
|
483
|
+
if (!ok.length)
|
|
484
|
+
return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
|
|
485
|
+
const tot = {};
|
|
486
|
+
for (const p of FLOW_PHASES)
|
|
487
|
+
tot[p] = { count: 0, ms: 0 };
|
|
488
|
+
for (const rec of ok)
|
|
489
|
+
for (const p of FLOW_PHASES) {
|
|
490
|
+
const v = rec.report.agg.phase?.[p];
|
|
491
|
+
if (v) {
|
|
492
|
+
tot[p].count += v.count;
|
|
493
|
+
tot[p].ms += v.ms;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const totCount = FLOW_PHASES.reduce((s, p) => s + tot[p].count, 0) || 1;
|
|
497
|
+
const totMs = FLOW_PHASES.reduce((s, p) => s + tot[p].ms, 0) || 1;
|
|
498
|
+
const nodes = FLOW_PHASES.map((p, i) => {
|
|
499
|
+
const v = tot[p];
|
|
500
|
+
const cPct = Math.round((v.count / totCount) * 100);
|
|
501
|
+
const tPct = Math.round((v.ms / totMs) * 100);
|
|
502
|
+
const arrow = i < FLOW_PHASES.length - 1 ? '<span class="flow-arrow">→</span>' : '';
|
|
503
|
+
return `<div class="flow-node"><i class="flow-dot ${phaseClass(p)}"></i>
|
|
504
|
+
<strong>${escapeHtml(phaseLabel(p))}</strong>
|
|
505
|
+
<span class="flow-n">${fmtInt(v.count)}<em> · ${cPct}%</em></span>
|
|
506
|
+
<span class="flow-t">${escapeHtml(fmtMs(v.ms))}<em> · ${tPct}%</em></span></div>${arrow}`;
|
|
507
|
+
}).join('');
|
|
508
|
+
const bars = FLOW_PHASES.map(p => {
|
|
509
|
+
const v = tot[p];
|
|
510
|
+
const cPct = (v.count / totCount) * 100;
|
|
511
|
+
const tPct = (v.ms / totMs) * 100;
|
|
512
|
+
return `<div class="flow-brow">
|
|
513
|
+
<span class="flow-blabel"><i class="${phaseClass(p)}"></i>${escapeHtml(phaseLabel(p))}</span>
|
|
514
|
+
<span class="flow-btrack"><span class="flow-bfill ${phaseClass(p)}" style="width:${Math.max(2, cPct).toFixed(1)}%"></span><em>${Math.round(cPct)}%</em></span>
|
|
515
|
+
<span class="flow-btrack"><span class="flow-bfill ${phaseClass(p)}" style="width:${Math.max(2, tPct).toFixed(1)}%"></span><em>${Math.round(tPct)}%</em></span>
|
|
516
|
+
</div>`;
|
|
517
|
+
}).join('');
|
|
518
|
+
const rhythm = [...ok]
|
|
519
|
+
.sort((a, b) => agentMsOf(b.report) - agentMsOf(a.report))
|
|
520
|
+
.slice(0, 40)
|
|
521
|
+
.map(rec => `<button type="button" class="flow-sess" data-session-id="${escapeHtml(String(rec.session.sessionId))}">
|
|
522
|
+
<span class="flow-stitle">${escapeHtml(sessionTitle(rec.session))}</span>
|
|
523
|
+
${renderPhaseMix(rec.report)}
|
|
524
|
+
<span class="flow-stime">${escapeHtml(fmtMs(agentMsOf(rec.report)))}</span></button>`)
|
|
525
|
+
.join('');
|
|
526
|
+
return `<p class="mut ins-hint">${escapeHtml(t('insights.flowHint'))}</p>
|
|
527
|
+
<section class="block flow-pipe-block">
|
|
528
|
+
<h3>${escapeHtml(t('insights.flowPipeline'))}</h3>
|
|
529
|
+
<p class="mut flow-sub">${escapeHtml(t('insights.flowPipeSub'))}</p>
|
|
530
|
+
<div class="flow-pipe">${nodes}</div>
|
|
531
|
+
</section>
|
|
532
|
+
<div class="insights-overview-grid">
|
|
533
|
+
<section class="block">
|
|
534
|
+
<h3>${escapeHtml(t('insights.flowShares'))}</h3>
|
|
535
|
+
<div class="flow-bhead"><span></span><span>${escapeHtml(t('insights.flowActShare'))}</span><span>${escapeHtml(t('insights.flowTimeShare'))}</span></div>
|
|
536
|
+
<div class="flow-bars">${bars}</div>
|
|
537
|
+
</section>
|
|
538
|
+
<section class="block">
|
|
539
|
+
<h3>${escapeHtml(t('insights.flowRhythm'))}</h3>
|
|
540
|
+
<div class="flow-rhythm">${rhythm}</div>
|
|
541
|
+
<div class="rl-legend">${FLOW_PHASES.map(p => `<span class="rl-item"><i class="${phaseClass(p)}"></i>${escapeHtml(phaseLabel(p))}</span>`).join('')}</div>
|
|
542
|
+
</section>
|
|
543
|
+
</div>
|
|
544
|
+
${disclosureNote()}`;
|
|
395
545
|
}
|
|
396
|
-
function renderHotspots(records) {
|
|
397
|
-
const reports =
|
|
546
|
+
function renderHotspots(records, openHot) {
|
|
547
|
+
const reports = records.filter(r => !!r.report && r.report.status === 'ok');
|
|
398
548
|
if (!reports.length)
|
|
399
549
|
return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
550
|
+
// Cross-session recurrence from each session's compact report.hot, tracking which
|
|
551
|
+
// sessions contributed so each row can drill down to them.
|
|
552
|
+
const fileAgg = new Map();
|
|
553
|
+
const cmdAgg = new Map();
|
|
554
|
+
const errAgg = new Map();
|
|
555
|
+
const bump = (m, key, label, sid, title) => {
|
|
556
|
+
let h = m.get(key);
|
|
557
|
+
if (!h) {
|
|
558
|
+
h = { key, label, sessions: [], reads: 0, edits: 0, runs: 0, fails: 0, count: 0 };
|
|
559
|
+
m.set(key, h);
|
|
560
|
+
}
|
|
561
|
+
if (!h.sessions.some(s => s.id === sid))
|
|
562
|
+
h.sessions.push({ id: sid, title });
|
|
563
|
+
return h;
|
|
564
|
+
};
|
|
565
|
+
for (const rec of reports) {
|
|
566
|
+
const sid = String(rec.session.sessionId);
|
|
567
|
+
const title = sessionTitle(rec.session);
|
|
568
|
+
const hot = rec.report.hot;
|
|
569
|
+
for (const f of hot?.files ?? []) {
|
|
570
|
+
const h = bump(fileAgg, `file:${f.path}`, f.path, sid, title);
|
|
571
|
+
h.reads += f.reads;
|
|
572
|
+
h.edits += f.edits;
|
|
573
|
+
}
|
|
574
|
+
for (const c of hot?.cmds ?? []) {
|
|
575
|
+
const h = bump(cmdAgg, `cmd:${c.cmd}`, c.cmd, sid, title);
|
|
576
|
+
h.runs += c.runs;
|
|
577
|
+
h.fails += c.fails;
|
|
578
|
+
}
|
|
579
|
+
for (const e of hot?.errs ?? []) {
|
|
580
|
+
const h = bump(errAgg, `err:${e.tool} ${e.result}`, `${e.tool} · ${resultLabel(e.result)}`, sid, title);
|
|
581
|
+
h.count += e.count;
|
|
408
582
|
}
|
|
409
583
|
}
|
|
410
|
-
const
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
584
|
+
const recur = (a, b) => b.sessions.length - a.sessions.length;
|
|
585
|
+
const files = [...fileAgg.values()].sort((a, b) => recur(a, b) || (b.edits + b.reads) - (a.edits + a.reads)).slice(0, 12);
|
|
586
|
+
const cmds = [...cmdAgg.values()].sort((a, b) => recur(a, b) || b.fails - a.fails || b.runs - a.runs).slice(0, 12);
|
|
587
|
+
const errs = [...errAgg.values()].sort((a, b) => recur(a, b) || b.count - a.count).slice(0, 10);
|
|
588
|
+
const block = (title, rows, meta) => `
|
|
589
|
+
<section class="block">
|
|
590
|
+
<h3>${escapeHtml(title)}</h3>
|
|
591
|
+
<div class="hotlist">${rows.length ? rows.map(h => {
|
|
592
|
+
const open = openHot.has(h.key);
|
|
593
|
+
const sess = open ? `<div class="hot-sess">${h.sessions.map(s => `<button type="button" class="hot-sesslink" data-session-id="${escapeHtml(s.id)}">${escapeHtml(s.title || s.id)}</button>`).join('')}</div>` : '';
|
|
594
|
+
return `<div class="hotitem${open ? ' open' : ''}">
|
|
595
|
+
<button type="button" class="hotrow" data-hotkey="${escapeHtml(h.key)}" aria-expanded="${open}">
|
|
596
|
+
<span class="hotlabel" title="${escapeHtml(h.label)}">${escapeHtml(h.label)}</span>
|
|
597
|
+
<span class="hotmeta">${meta(h)}</span>
|
|
598
|
+
<span class="hotses">${h.sessions.length} ${escapeHtml(t('insights.hotSessionsCol'))}</span>
|
|
599
|
+
</button>${sess}
|
|
600
|
+
</div>`;
|
|
601
|
+
}).join('') : `<p class="mut">-</p>`}</div>
|
|
602
|
+
</section>`;
|
|
603
|
+
// Projects (kept, with drill-down) + slowest sessions.
|
|
416
604
|
const projMap = new Map();
|
|
417
|
-
for (const rec of
|
|
418
|
-
if (rec.report?.status !== 'ok')
|
|
419
|
-
continue;
|
|
605
|
+
for (const rec of reports) {
|
|
420
606
|
const p = projectOf(rec);
|
|
421
607
|
if (!p)
|
|
422
608
|
continue;
|
|
@@ -427,34 +613,30 @@ function renderHotspots(records) {
|
|
|
427
613
|
}
|
|
428
614
|
const projects = [...projMap.entries()].map(([id, v]) => ({ id, ...v })).sort((a, b) => b.fails - a.fails || b.sessions - a.sessions).slice(0, 10);
|
|
429
615
|
const projMax = Math.max(1, ...projects.map(x => x.sessions));
|
|
616
|
+
const slowSessions = [...reports].filter(r => r.report.agg.slowSpans > 0).sort((a, b) => b.report.agg.slowSpans - a.report.agg.slowSpans).slice(0, 8);
|
|
430
617
|
return `<p class="mut ins-hint">${escapeHtml(t('insights.hotHint'))}</p>
|
|
431
618
|
<div class="hot-grid">
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const pct = Math.max(4, Math.round((x.fails / toolMax) * 100));
|
|
436
|
-
return `<div class="hbrow"><div class="hblabel">${escapeHtml(x.tool)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(x.fails)}<small>${x.sessions} ${escapeHtml(t('insights.hotSessionsCol'))}</small></div></div>`;
|
|
437
|
-
}).join('') : `<p class="mut">${escapeHtml(t('insights.noFailures'))}</p>`}</div>
|
|
438
|
-
</section>
|
|
619
|
+
${block(t('insights.hotFiles'), files, h => `${escapeHtml(t('insights.readsShort'))}${h.reads} · ${escapeHtml(t('insights.editsShort'))}${h.edits}`)}
|
|
620
|
+
${block(t('insights.hotCommands'), cmds, h => `${h.runs}×${h.fails ? ` · <span class="bad">${h.fails} ${escapeHtml(t('insights.hotFailsCol'))}</span>` : ''}`)}
|
|
621
|
+
${block(t('insights.hotErrors'), errs, h => `${h.count}×`)}
|
|
439
622
|
<section class="block">
|
|
440
623
|
<h3>${escapeHtml(t('insights.hotProjects'))}</h3>
|
|
441
624
|
<div class="hbars">${projects.length ? projects.map(x => {
|
|
442
625
|
const pct = Math.max(4, Math.round((x.sessions / projMax) * 100));
|
|
443
|
-
return `<
|
|
626
|
+
return `<button type="button" class="hbrow hbrow-click" data-hotproject="${escapeHtml(x.id)}"><div class="hblabel">${escapeHtml(x.id)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(x.sessions)}<small>${x.fails} ${escapeHtml(t('insights.hotFailsCol'))}</small></div></button>`;
|
|
444
627
|
}).join('') : `<p class="mut">-</p>`}</div>
|
|
445
628
|
</section>
|
|
446
629
|
<section class="block hot-sessions">
|
|
447
630
|
<h3>${escapeHtml(t('insights.hotSlowSessions'))}</h3>
|
|
448
631
|
<div class="slist">${slowSessions.length ? slowSessions.map(rec => {
|
|
449
632
|
const a = rec.report.agg;
|
|
450
|
-
return `<button type="button" class="srow" data-session-id="${escapeHtml(rec.session.sessionId)}">
|
|
633
|
+
return `<button type="button" class="srow" data-session-id="${escapeHtml(String(rec.session.sessionId))}">
|
|
451
634
|
<div class="srmain"><strong>${escapeHtml(sessionTitle(rec.session))}</strong>
|
|
452
635
|
<small>${escapeHtml(botDisplayName(rec.session))} · ${escapeHtml(String(rec.session.cliId ?? '-'))}</small></div>
|
|
453
636
|
<div class="srstats"><b>${escapeHtml(t('insights.hotSlowCol'))}<em>${fmtInt(a.slowSpans)}</em></b><b>span<em>${fmtInt(a.totalSpans)}</em></b></div>
|
|
454
637
|
</button>`;
|
|
455
638
|
}).join('') : `<p class="mut">-</p>`}</div>
|
|
456
639
|
</section>
|
|
457
|
-
<p class="hot-note mut">${escapeHtml(t('insights.hotComingSoon'))}</p>
|
|
458
640
|
</div>`;
|
|
459
641
|
}
|
|
460
642
|
const SESS_SORTS = [
|
|
@@ -465,8 +647,10 @@ const SESS_SORTS = [
|
|
|
465
647
|
{ key: 'slow', label: 'insights.sortSlow' },
|
|
466
648
|
{ key: 'agent', label: 'insights.sortAgent' },
|
|
467
649
|
];
|
|
468
|
-
function renderSortBar(active) {
|
|
469
|
-
|
|
650
|
+
function renderSortBar(active, layout) {
|
|
651
|
+
const sorts = `<span class="sesssort-label mut">${escapeHtml(t('insights.sortLabel'))}</span>` + SESS_SORTS.map(s => `<button type="button" class="spanchip${s.key === active ? ' on' : ''}" data-sesssort="${s.key}">${escapeHtml(t(s.label))}</button>`).join('');
|
|
652
|
+
const layouts = `<span class="sesssort-sep"></span>` + [['card', 'insights.layoutCard'], ['table', 'insights.layoutTable']].map(([k, lbl]) => `<button type="button" class="spanchip${layout === k ? ' on' : ''}" data-sesslayout="${k}">${escapeHtml(t(lbl))}</button>`).join('');
|
|
653
|
+
return sorts + layouts;
|
|
470
654
|
}
|
|
471
655
|
function reviewScore(r) {
|
|
472
656
|
return r?.status === 'ok' ? r.agg.failedSpans * 6 + r.agg.slowSpans * 3 + r.suggestions.filter(s => s.severity === 'bad').length * 5 : 0;
|
|
@@ -500,7 +684,7 @@ function renderSessionRows(records, selectedId, wide = false) {
|
|
|
500
684
|
const review = reportNeedsReview(r) ? ' review' : '';
|
|
501
685
|
const reads = agg?.phase?.research?.count ?? 0;
|
|
502
686
|
const edits = agg?.phase?.edit?.count ?? 0;
|
|
503
|
-
return `<button type="button" class="srow${on}${review}" data-session-id="${escapeHtml(s.sessionId)}">
|
|
687
|
+
return `<button type="button" class="srow${on}${review}" data-session-id="${escapeHtml(String(s.sessionId))}">
|
|
504
688
|
<div class="srmain">
|
|
505
689
|
<strong>${escapeHtml(sessionTitle(s))}</strong>
|
|
506
690
|
<small>${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))}${s.workingDir ? ` · ${escapeHtml(projectOf(rec))}` : ''} · ${escapeHtml(relTime(s.lastMessageAt ?? s.spawnedAt ?? 0))}</small>
|
|
@@ -516,6 +700,44 @@ function renderSessionRows(records, selectedId, wide = false) {
|
|
|
516
700
|
</button>`;
|
|
517
701
|
}).join('')}</div>`;
|
|
518
702
|
}
|
|
703
|
+
// Dense table layout for the session list (ASI 表格视图) — sticky header, tabular
|
|
704
|
+
// columns; row click drills into the same full-width detail.
|
|
705
|
+
function renderSessionTable(records, selectedId) {
|
|
706
|
+
if (!records.length)
|
|
707
|
+
return `<div class="insight-empty">${escapeHtml(t('insights.empty'))}</div>`;
|
|
708
|
+
const head = `<div class="strow sthead">
|
|
709
|
+
<span class="stc-title">${escapeHtml(t('insights.colTitle'))}</span>
|
|
710
|
+
<span class="stc-proj">${escapeHtml(t('insights.colProject'))}</span>
|
|
711
|
+
<span class="stc-num">${escapeHtml(t('insights.spansShort'))}</span>
|
|
712
|
+
<span class="stc-num">${escapeHtml(t('insights.failedShort'))}</span>
|
|
713
|
+
<span class="stc-num">${escapeHtml(t('insights.slowShort'))}</span>
|
|
714
|
+
<span class="stc-num">${escapeHtml(t('insights.rwShort'))}</span>
|
|
715
|
+
<span class="stc-num">${escapeHtml(t('insights.durShort'))}</span>
|
|
716
|
+
<span class="stc-num">${escapeHtml(t('insights.colTime'))}</span>
|
|
717
|
+
</div>`;
|
|
718
|
+
const rows = records.map(rec => {
|
|
719
|
+
const s = rec.session;
|
|
720
|
+
const r = rec.report;
|
|
721
|
+
const ok = r?.status === 'ok';
|
|
722
|
+
const agg = r?.agg;
|
|
723
|
+
const on = s.sessionId === selectedId ? ' on' : '';
|
|
724
|
+
const review = reportNeedsReview(r) ? ' review' : '';
|
|
725
|
+
const mid = ok
|
|
726
|
+
? `<span class="stc-num">${fmtInt(agg.totalSpans)}</span>
|
|
727
|
+
<span class="stc-num${agg.failedSpans ? ' bad' : ''}">${fmtInt(agg.failedSpans)}</span>
|
|
728
|
+
<span class="stc-num">${fmtInt(agg.slowSpans)}</span>
|
|
729
|
+
<span class="stc-num">${agg.readWriteRatio !== null ? agg.readWriteRatio.toFixed(1) : '-'}</span>
|
|
730
|
+
<span class="stc-num">${escapeHtml(fmtMs(agentMsOf(r)))}</span>`
|
|
731
|
+
: `<span class="stc-msg">${escapeHtml(safeStatus(r, rec.error))}</span>`;
|
|
732
|
+
return `<button type="button" class="strow${on}${review}${ok ? '' : ' nostat'}" data-session-id="${escapeHtml(String(s.sessionId))}">
|
|
733
|
+
<span class="stc-title"><strong>${escapeHtml(sessionTitle(s))}</strong><small>${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))}</small></span>
|
|
734
|
+
<span class="stc-proj">${escapeHtml(s.workingDir ? projectOf(rec) : '-')}</span>
|
|
735
|
+
${mid}
|
|
736
|
+
<span class="stc-num stc-time">${escapeHtml(relTime(s.lastMessageAt ?? s.spawnedAt ?? 0))}</span>
|
|
737
|
+
</button>`;
|
|
738
|
+
}).join('');
|
|
739
|
+
return `<div class="stable">${head}${rows}</div>`;
|
|
740
|
+
}
|
|
519
741
|
// ── Diagnosis-driven detail view ────────────────────────────────────────────
|
|
520
742
|
// codex's detail=spans report carries everything rendered here as fail-closed safe
|
|
521
743
|
// projections (enums / tool names / numbers / basenames — never raw text):
|
|
@@ -1260,6 +1482,7 @@ function renderDetailBody(report, view) {
|
|
|
1260
1482
|
</section>
|
|
1261
1483
|
${renderTurnRail(report, focus, recByTurn)}
|
|
1262
1484
|
${renderWorkGantt(report)}
|
|
1485
|
+
${renderSubagents(report)}
|
|
1263
1486
|
${renderContextCurve(report)}
|
|
1264
1487
|
<div class="detailtabs">
|
|
1265
1488
|
<div class="detailtabbar" role="tablist" aria-label="${escapeHtml(t('insights.detailTabs'))}">
|
|
@@ -1281,13 +1504,39 @@ async function fetchDetail(sessionId) {
|
|
|
1281
1504
|
throw new Error(String(d?.error ?? r.status));
|
|
1282
1505
|
return d.report;
|
|
1283
1506
|
}
|
|
1507
|
+
// URL state (deep-link + refresh-stable): the insights view state lives in the hash
|
|
1508
|
+
// query after #/insights (e.g. #/insights?tab=dist&project=botmux&sess=<id>). Written
|
|
1509
|
+
// via history.replaceState (no router re-run), read back on (re)mount.
|
|
1510
|
+
function parseInsightsHash() {
|
|
1511
|
+
const h = typeof location !== 'undefined' ? (location.hash || '') : '';
|
|
1512
|
+
const qi = h.indexOf('?');
|
|
1513
|
+
if (qi < 0)
|
|
1514
|
+
return {};
|
|
1515
|
+
const out = {};
|
|
1516
|
+
for (const [k, v] of new URLSearchParams(h.slice(qi + 1)))
|
|
1517
|
+
out[k] = v;
|
|
1518
|
+
return out;
|
|
1519
|
+
}
|
|
1520
|
+
function buildInsightsHash(p) {
|
|
1521
|
+
const sp = new URLSearchParams();
|
|
1522
|
+
for (const [k, v] of Object.entries(p))
|
|
1523
|
+
if (v)
|
|
1524
|
+
sp.set(k, v);
|
|
1525
|
+
const q = sp.toString();
|
|
1526
|
+
return '#/insights' + (q ? `?${q}` : '');
|
|
1527
|
+
}
|
|
1528
|
+
const INSIGHT_FILTERS = ['all', 'review', 'failed', 'slow'];
|
|
1529
|
+
const INSIGHT_TAB_KEYS = ['overview', 'sessions', 'flow', 'dist', 'hot'];
|
|
1530
|
+
const SESS_SORT_KEYS = ['recent', 'review', 'spans', 'fails', 'slow', 'agent'];
|
|
1284
1531
|
export function renderInsightsPage(root) {
|
|
1532
|
+
const hp = parseInsightsHash();
|
|
1285
1533
|
let overviewData = null;
|
|
1286
1534
|
let records = [];
|
|
1287
|
-
let filter = 'all';
|
|
1288
|
-
const cliFilter = new Set();
|
|
1289
|
-
let q = '';
|
|
1535
|
+
let filter = INSIGHT_FILTERS.includes(hp.filter) ? hp.filter : 'all';
|
|
1536
|
+
const cliFilter = new Set((hp.cli ?? '').split(',').filter(Boolean));
|
|
1537
|
+
let q = hp.q ?? '';
|
|
1290
1538
|
let selectedId = null;
|
|
1539
|
+
let initialSess = hp.sess ?? null;
|
|
1291
1540
|
let activeRec = null;
|
|
1292
1541
|
let detailReport = null;
|
|
1293
1542
|
let detailTab = 'spans';
|
|
@@ -1305,11 +1554,16 @@ export function renderInsightsPage(root) {
|
|
|
1305
1554
|
let modalPrompt = null;
|
|
1306
1555
|
let modalReq = 0;
|
|
1307
1556
|
let disposed = false;
|
|
1308
|
-
let tab = 'overview';
|
|
1309
|
-
let project = '';
|
|
1310
|
-
let timeWin = 'all';
|
|
1311
|
-
let showNoise =
|
|
1312
|
-
let sessSort = 'recent';
|
|
1557
|
+
let tab = INSIGHT_TAB_KEYS.includes(hp.tab) ? hp.tab : 'overview';
|
|
1558
|
+
let project = hp.project ?? '';
|
|
1559
|
+
let timeWin = hp.time ?? 'all';
|
|
1560
|
+
let showNoise = hp.noise === '1';
|
|
1561
|
+
let sessSort = SESS_SORT_KEYS.includes(hp.sort) ? hp.sort : 'recent';
|
|
1562
|
+
let sessLayout = hp.layout === 'table' ? 'table' : 'card';
|
|
1563
|
+
const openHot = new Set();
|
|
1564
|
+
let paletteOpen = false;
|
|
1565
|
+
let paletteQ = '';
|
|
1566
|
+
let paletteIdx = 0;
|
|
1313
1567
|
root.innerHTML = `
|
|
1314
1568
|
<section class="page insights-page">
|
|
1315
1569
|
<div class="page-heading">
|
|
@@ -1318,7 +1572,10 @@ export function renderInsightsPage(root) {
|
|
|
1318
1572
|
<h1>${escapeHtml(t('insights.title'))}</h1>
|
|
1319
1573
|
<p>${escapeHtml(t('insights.subtitle'))}</p>
|
|
1320
1574
|
</div>
|
|
1321
|
-
<
|
|
1575
|
+
<div class="insight-head-acts">
|
|
1576
|
+
<button type="button" id="insight-palette-open" class="ins-clear">${escapeHtml(t('insights.paletteOpen'))}</button>
|
|
1577
|
+
<button type="button" id="insight-refresh" class="primary">${escapeHtml(t('insights.refresh'))}</button>
|
|
1578
|
+
</div>
|
|
1322
1579
|
</div>
|
|
1323
1580
|
<form id="insight-filters" class="filters insights-filters">
|
|
1324
1581
|
<input type="search" name="q" placeholder="${escapeHtml(t('insights.search'))}">
|
|
@@ -1352,9 +1609,11 @@ export function renderInsightsPage(root) {
|
|
|
1352
1609
|
<div id="insight-detail">${renderDetailShell(undefined)}</div>
|
|
1353
1610
|
</div>
|
|
1354
1611
|
</div>
|
|
1612
|
+
<div class="insight-panel" role="tabpanel" data-tabpanel="flow" hidden><div id="insight-flow"></div></div>
|
|
1355
1613
|
<div class="insight-panel" role="tabpanel" data-tabpanel="dist" hidden><div id="insight-dist"></div></div>
|
|
1356
1614
|
<div class="insight-panel" role="tabpanel" data-tabpanel="hot" hidden><div id="insight-hot"></div></div>
|
|
1357
1615
|
<div id="insight-modal" class="insight-modal" hidden></div>
|
|
1616
|
+
<div id="insight-palette" class="insight-palette" hidden></div>
|
|
1358
1617
|
<div id="insight-tip" class="ins-tip" role="tooltip" hidden></div>
|
|
1359
1618
|
</section>`;
|
|
1360
1619
|
const status = root.querySelector('#insight-status');
|
|
@@ -1363,9 +1622,11 @@ export function renderInsightsPage(root) {
|
|
|
1363
1622
|
const listSubtitle = root.querySelector('#insight-list-subtitle');
|
|
1364
1623
|
const detail = root.querySelector('#insight-detail');
|
|
1365
1624
|
const search = root.querySelector('input[name=q]');
|
|
1625
|
+
search.value = q;
|
|
1366
1626
|
const refreshBtn = root.querySelector('#insight-refresh');
|
|
1367
1627
|
const filterButtons = [...root.querySelectorAll('[data-filter]')];
|
|
1368
1628
|
const cliFilterEl = root.querySelector('#insight-cli-filter');
|
|
1629
|
+
const flowEl = root.querySelector('#insight-flow');
|
|
1369
1630
|
const distEl = root.querySelector('#insight-dist');
|
|
1370
1631
|
const hotEl = root.querySelector('#insight-hot');
|
|
1371
1632
|
const projectSel = root.querySelector('#insight-project');
|
|
@@ -1374,6 +1635,8 @@ export function renderInsightsPage(root) {
|
|
|
1374
1635
|
const clearBtn = root.querySelector('#insight-clear');
|
|
1375
1636
|
const tabbar = root.querySelector('#insight-tabbar');
|
|
1376
1637
|
const panels = [...root.querySelectorAll('.insight-panel')];
|
|
1638
|
+
const paletteEl = root.querySelector('#insight-palette');
|
|
1639
|
+
const paletteOpenBtn = root.querySelector('#insight-palette-open');
|
|
1377
1640
|
const listView = root.querySelector('#insight-list-view');
|
|
1378
1641
|
const detailView = root.querySelector('#insight-detail-view');
|
|
1379
1642
|
const backBtn = root.querySelector('#insight-back');
|
|
@@ -1390,6 +1653,101 @@ export function renderInsightsPage(root) {
|
|
|
1390
1653
|
const sinceMs = w && w.days > 0 ? Date.now() - w.days * 86400000 : undefined;
|
|
1391
1654
|
return { project: project || undefined, sinceMs, analyzableOnly: !showNoise };
|
|
1392
1655
|
}
|
|
1656
|
+
// Mirror current view state into the URL hash (deep-link + refresh-stable) without
|
|
1657
|
+
// triggering the router (replaceState fires no hashchange).
|
|
1658
|
+
function syncHash() {
|
|
1659
|
+
const p = {};
|
|
1660
|
+
if (tab !== 'overview')
|
|
1661
|
+
p.tab = tab;
|
|
1662
|
+
if (filter !== 'all')
|
|
1663
|
+
p.filter = filter;
|
|
1664
|
+
if (q.trim())
|
|
1665
|
+
p.q = q.trim();
|
|
1666
|
+
if (project)
|
|
1667
|
+
p.project = project;
|
|
1668
|
+
if (timeWin !== 'all')
|
|
1669
|
+
p.time = timeWin;
|
|
1670
|
+
if (cliFilter.size)
|
|
1671
|
+
p.cli = [...cliFilter].join(',');
|
|
1672
|
+
if (sessSort !== 'recent')
|
|
1673
|
+
p.sort = sessSort;
|
|
1674
|
+
if (sessLayout !== 'card')
|
|
1675
|
+
p.layout = sessLayout;
|
|
1676
|
+
if (showNoise)
|
|
1677
|
+
p.noise = '1';
|
|
1678
|
+
if (selectedId)
|
|
1679
|
+
p.sess = selectedId;
|
|
1680
|
+
try {
|
|
1681
|
+
history.replaceState(null, '', buildInsightsHash(p));
|
|
1682
|
+
}
|
|
1683
|
+
catch { /* ignore */ }
|
|
1684
|
+
}
|
|
1685
|
+
function paletteItems() {
|
|
1686
|
+
const ql = paletteQ.trim().toLowerCase();
|
|
1687
|
+
const tabs = INSIGHT_TABS
|
|
1688
|
+
.map(tb => ({ type: 'tab', key: tb.key, label: t(tb.label), sub: t('insights.paletteTabs') }))
|
|
1689
|
+
.filter(it => !ql || it.label.toLowerCase().includes(ql));
|
|
1690
|
+
const sess = records
|
|
1691
|
+
.filter(r => { const s = r.session; return !ql || `${sessionTitle(s)} ${botDisplayName(s)} ${s.cliId ?? ''}`.toLowerCase().includes(ql); })
|
|
1692
|
+
.slice(0, 20)
|
|
1693
|
+
.map(r => ({ type: 'session', key: String(r.session.sessionId), label: sessionTitle(r.session), sub: `${botDisplayName(r.session)} · ${r.session.cliId ?? '-'}` }));
|
|
1694
|
+
return [...tabs, ...sess];
|
|
1695
|
+
}
|
|
1696
|
+
function choosePalette(type, key) {
|
|
1697
|
+
closePalette();
|
|
1698
|
+
if (type === 'tab') {
|
|
1699
|
+
tab = key;
|
|
1700
|
+
selectedId = null;
|
|
1701
|
+
paint();
|
|
1702
|
+
}
|
|
1703
|
+
else {
|
|
1704
|
+
tab = 'sessions';
|
|
1705
|
+
showTab();
|
|
1706
|
+
void selectSession(key);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
// Re-render ONLY the results list (not the <input>), so typing never rebuilds
|
|
1710
|
+
// or re-focuses the input — that would reset the caret and break IME (CJK)
|
|
1711
|
+
// composition on every keystroke.
|
|
1712
|
+
function paintPaletteList() {
|
|
1713
|
+
const list = paletteEl.querySelector('.palette-list');
|
|
1714
|
+
if (!list)
|
|
1715
|
+
return;
|
|
1716
|
+
const items = paletteItems();
|
|
1717
|
+
if (paletteIdx >= items.length)
|
|
1718
|
+
paletteIdx = Math.max(0, items.length - 1);
|
|
1719
|
+
list.innerHTML = items.length ? items.map((it, i) => `<button type="button" class="palette-item${i === paletteIdx ? ' on' : ''}" data-pal-type="${it.type}" data-pal-key="${escapeHtml(it.key)}" data-pal-i="${i}">
|
|
1720
|
+
<span class="pal-label">${escapeHtml(it.label)}</span><span class="pal-sub">${escapeHtml(it.sub)}</span>
|
|
1721
|
+
</button>`).join('') : `<p class="mut palette-empty">${escapeHtml(t('insights.paletteEmpty'))}</p>`;
|
|
1722
|
+
list.querySelectorAll('.palette-item').forEach(btn => btn.addEventListener('click', () => choosePalette(btn.dataset.palType ?? 'tab', btn.dataset.palKey ?? '')));
|
|
1723
|
+
list.querySelector('.palette-item.on')?.scrollIntoView({ block: 'nearest' });
|
|
1724
|
+
}
|
|
1725
|
+
function paintPalette() {
|
|
1726
|
+
if (!paletteOpen) {
|
|
1727
|
+
paletteEl.hidden = true;
|
|
1728
|
+
paletteEl.innerHTML = '';
|
|
1729
|
+
document.body.classList.remove('insight-modal-open');
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
paletteEl.hidden = false;
|
|
1733
|
+
document.body.classList.add('insight-modal-open');
|
|
1734
|
+
// Build the shell once per open. The <input> stays stable across keystrokes;
|
|
1735
|
+
// only paintPaletteList() touches the DOM on input/arrow navigation.
|
|
1736
|
+
paletteEl.innerHTML = `<div class="modal-backdrop" data-pal-close></div>
|
|
1737
|
+
<div class="palette-panel" role="dialog" aria-modal="true">
|
|
1738
|
+
<input type="search" class="palette-input" placeholder="${escapeHtml(t('insights.palettePlaceholder'))}" value="${escapeHtml(paletteQ)}">
|
|
1739
|
+
<div class="palette-list"></div>
|
|
1740
|
+
</div>`;
|
|
1741
|
+
const input = paletteEl.querySelector('.palette-input');
|
|
1742
|
+
if (input) {
|
|
1743
|
+
input.focus();
|
|
1744
|
+
input.oninput = () => { paletteQ = input.value; paletteIdx = 0; paintPaletteList(); };
|
|
1745
|
+
}
|
|
1746
|
+
paletteEl.querySelectorAll('[data-pal-close]').forEach(el => el.addEventListener('click', closePalette));
|
|
1747
|
+
paintPaletteList();
|
|
1748
|
+
}
|
|
1749
|
+
function openPalette() { paletteOpen = true; paletteQ = ''; paletteIdx = 0; paintPalette(); }
|
|
1750
|
+
function closePalette() { paletteOpen = false; paintPalette(); }
|
|
1393
1751
|
// Project <select> options reflect the severity+search+time scoped set (project
|
|
1394
1752
|
// pick itself NOT applied), so the dropdown always shows reachable projects.
|
|
1395
1753
|
function paintProjectOptions() {
|
|
@@ -1446,21 +1804,24 @@ export function renderInsightsPage(root) {
|
|
|
1446
1804
|
filterButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.filter === filter));
|
|
1447
1805
|
overviewEl.innerHTML = overviewData ? renderOverview(aggregateRecords(rows)) : '';
|
|
1448
1806
|
listSubtitle.textContent = t('insights.listCount', { shown: rows.length, total: records.length });
|
|
1449
|
-
sortBar.innerHTML = renderSortBar(sessSort);
|
|
1807
|
+
sortBar.innerHTML = renderSortBar(sessSort, sessLayout);
|
|
1450
1808
|
// When the analyzable-only default empties the list, point at the toggle so
|
|
1451
1809
|
// the user isn't stranded on a blank page wondering where their sessions went.
|
|
1452
1810
|
list.innerHTML = (!rows.length && !showNoise)
|
|
1453
1811
|
? `<div class="insight-empty">${escapeHtml(t('insights.empty'))}<br><span class="mut">${escapeHtml(t('insights.emptyAnalyzableHint'))}</span></div>`
|
|
1454
|
-
: renderSessionRows(rows, selectedId, true);
|
|
1812
|
+
: sessLayout === 'table' ? renderSessionTable(rows, selectedId) : renderSessionRows(rows, selectedId, true);
|
|
1455
1813
|
const selected = rows.find(r => r.session.sessionId === selectedId) ?? records.find(r => r.session.sessionId === selectedId);
|
|
1456
1814
|
if (!selectedId || !selected)
|
|
1457
1815
|
detail.innerHTML = renderDetailShell(undefined);
|
|
1458
1816
|
wireSessionButtons(list);
|
|
1459
1817
|
showSessionsView();
|
|
1818
|
+
flowEl.innerHTML = overviewData ? renderFlow(rows) : '';
|
|
1819
|
+
wireSessionButtons(flowEl, true);
|
|
1460
1820
|
distEl.innerHTML = overviewData ? renderDistribution(rows) : '';
|
|
1461
|
-
hotEl.innerHTML = overviewData ? renderHotspots(rows) : '';
|
|
1821
|
+
hotEl.innerHTML = overviewData ? renderHotspots(rows, openHot) : '';
|
|
1462
1822
|
wireSessionButtons(hotEl, true);
|
|
1463
1823
|
showTab();
|
|
1824
|
+
syncHash();
|
|
1464
1825
|
}
|
|
1465
1826
|
// Re-render the detail body in place (no refetch) after a focus/filter change.
|
|
1466
1827
|
function paintDetailBody() {
|
|
@@ -1723,6 +2084,7 @@ export function renderInsightsPage(root) {
|
|
|
1723
2084
|
async function selectSession(sessionId) {
|
|
1724
2085
|
selectedId = sessionId;
|
|
1725
2086
|
showSessionsView();
|
|
2087
|
+
syncHash();
|
|
1726
2088
|
activeRec = null;
|
|
1727
2089
|
detailTab = 'spans';
|
|
1728
2090
|
spanFilter = 'all';
|
|
@@ -1786,6 +2148,11 @@ export function renderInsightsPage(root) {
|
|
|
1786
2148
|
selectedId = null;
|
|
1787
2149
|
paint();
|
|
1788
2150
|
refreshBtn.disabled = false;
|
|
2151
|
+
// Restore a deep-linked session (load its detail) once records are in.
|
|
2152
|
+
const want = initialSess;
|
|
2153
|
+
initialSess = null;
|
|
2154
|
+
if (want && !selectedId && records.some(r => r.session.sessionId === want))
|
|
2155
|
+
void selectSession(want);
|
|
1789
2156
|
}
|
|
1790
2157
|
search.oninput = () => { q = search.value; paint(); };
|
|
1791
2158
|
refreshBtn.onclick = () => void refresh();
|
|
@@ -1798,12 +2165,51 @@ export function renderInsightsPage(root) {
|
|
|
1798
2165
|
});
|
|
1799
2166
|
backBtn.addEventListener('click', () => { selectedId = null; showSessionsView(); paint(); });
|
|
1800
2167
|
sortBar.addEventListener('click', e => {
|
|
2168
|
+
const lay = e.target.closest('[data-sesslayout]');
|
|
2169
|
+
if (lay) {
|
|
2170
|
+
sessLayout = lay.dataset.sesslayout === 'table' ? 'table' : 'card';
|
|
2171
|
+
paint();
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
1801
2174
|
const btn = e.target.closest('[data-sesssort]');
|
|
1802
2175
|
if (!btn)
|
|
1803
2176
|
return;
|
|
1804
2177
|
sessSort = btn.dataset.sesssort || 'recent';
|
|
1805
2178
|
paint();
|
|
1806
2179
|
});
|
|
2180
|
+
// 分布直方图 → 跳到会话 tab 按该指标排序。
|
|
2181
|
+
distEl.addEventListener('click', e => {
|
|
2182
|
+
const btn = e.target.closest('[data-distsort]');
|
|
2183
|
+
if (!btn)
|
|
2184
|
+
return;
|
|
2185
|
+
sessSort = btn.dataset.distsort || 'recent';
|
|
2186
|
+
tab = 'sessions';
|
|
2187
|
+
selectedId = null;
|
|
2188
|
+
paint();
|
|
2189
|
+
});
|
|
2190
|
+
// 项目热点 → 设项目筛选并跳到会话 tab(最慢会话行的 data-session-id 由 wireSessionButtons 处理)。
|
|
2191
|
+
hotEl.addEventListener('click', e => {
|
|
2192
|
+
const target = e.target;
|
|
2193
|
+
// Expand/collapse a recurrence row → reveal its contributing sessions in place.
|
|
2194
|
+
const hk = target.closest('[data-hotkey]');
|
|
2195
|
+
if (hk) {
|
|
2196
|
+
const k = hk.dataset.hotkey || '';
|
|
2197
|
+
if (openHot.has(k))
|
|
2198
|
+
openHot.delete(k);
|
|
2199
|
+
else
|
|
2200
|
+
openHot.add(k);
|
|
2201
|
+
hotEl.innerHTML = renderHotspots(currentRows(), openHot);
|
|
2202
|
+
wireSessionButtons(hotEl, true);
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
const proj = target.closest('[data-hotproject]');
|
|
2206
|
+
if (!proj)
|
|
2207
|
+
return;
|
|
2208
|
+
project = proj.dataset.hotproject || '';
|
|
2209
|
+
tab = 'sessions';
|
|
2210
|
+
selectedId = null;
|
|
2211
|
+
paint();
|
|
2212
|
+
});
|
|
1807
2213
|
projectSel.addEventListener('change', () => { project = projectSel.value; paint(); });
|
|
1808
2214
|
timeSel.addEventListener('change', () => { timeWin = timeSel.value; paint(); });
|
|
1809
2215
|
noiseToggle.addEventListener('change', () => { showNoise = noiseToggle.checked; paint(); });
|
|
@@ -1836,9 +2242,49 @@ export function renderInsightsPage(root) {
|
|
|
1836
2242
|
cliFilter.add(key);
|
|
1837
2243
|
paint();
|
|
1838
2244
|
});
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
2245
|
+
paletteOpenBtn.addEventListener('click', openPalette);
|
|
2246
|
+
// ⌘K opens the palette; Esc closes palette/modal; arrows + Enter drive the palette.
|
|
2247
|
+
const onKey = (e) => {
|
|
2248
|
+
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
|
|
2249
|
+
e.preventDefault();
|
|
2250
|
+
if (paletteOpen)
|
|
2251
|
+
closePalette();
|
|
2252
|
+
else
|
|
2253
|
+
openPalette();
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (paletteOpen) {
|
|
2257
|
+
// While an IME (CJK) composition is active, Enter/Arrow confirm or move the
|
|
2258
|
+
// candidate list — don't hijack them to select/close the palette. keyCode 229
|
|
2259
|
+
// is the legacy "composition in progress" signal for browsers without isComposing.
|
|
2260
|
+
if (e.isComposing || e.keyCode === 229)
|
|
2261
|
+
return;
|
|
2262
|
+
const items = paletteItems();
|
|
2263
|
+
if (e.key === 'Escape') {
|
|
2264
|
+
e.preventDefault();
|
|
2265
|
+
closePalette();
|
|
2266
|
+
}
|
|
2267
|
+
else if (e.key === 'ArrowDown') {
|
|
2268
|
+
e.preventDefault();
|
|
2269
|
+
paletteIdx = Math.min(items.length - 1, paletteIdx + 1);
|
|
2270
|
+
paintPaletteList();
|
|
2271
|
+
}
|
|
2272
|
+
else if (e.key === 'ArrowUp') {
|
|
2273
|
+
e.preventDefault();
|
|
2274
|
+
paletteIdx = Math.max(0, paletteIdx - 1);
|
|
2275
|
+
paintPaletteList();
|
|
2276
|
+
}
|
|
2277
|
+
else if (e.key === 'Enter') {
|
|
2278
|
+
e.preventDefault();
|
|
2279
|
+
const it = items[paletteIdx];
|
|
2280
|
+
if (it)
|
|
2281
|
+
choosePalette(it.type, it.key);
|
|
2282
|
+
}
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
if (e.key === 'Escape' && modalTurn !== null)
|
|
2286
|
+
closeModal();
|
|
2287
|
+
};
|
|
1842
2288
|
document.addEventListener('keydown', onKey);
|
|
1843
2289
|
void loadNameMaps().then(() => { if (!disposed)
|
|
1844
2290
|
paint(); });
|