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.
Files changed (47) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/dist/core/skills/discovery.d.ts +16 -0
  4. package/dist/core/skills/discovery.d.ts.map +1 -1
  5. package/dist/core/skills/discovery.js +48 -2
  6. package/dist/core/skills/discovery.js.map +1 -1
  7. package/dist/core/worker-pool.d.ts.map +1 -1
  8. package/dist/core/worker-pool.js +9 -2
  9. package/dist/core/worker-pool.js.map +1 -1
  10. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  11. package/dist/dashboard/web/i18n.js +60 -2
  12. package/dist/dashboard/web/i18n.js.map +1 -1
  13. package/dist/dashboard/web/insights.d.ts.map +1 -1
  14. package/dist/dashboard/web/insights.js +514 -68
  15. package/dist/dashboard/web/insights.js.map +1 -1
  16. package/dist/dashboard/web/skills.d.ts.map +1 -1
  17. package/dist/dashboard/web/skills.js +1 -1
  18. package/dist/dashboard/web/skills.js.map +1 -1
  19. package/dist/dashboard/web/whiteboards.js +3 -3
  20. package/dist/dashboard-web/app.js +883 -795
  21. package/dist/dashboard-web/style.css +105 -3
  22. package/dist/services/insight/classify.d.ts +2 -0
  23. package/dist/services/insight/classify.d.ts.map +1 -1
  24. package/dist/services/insight/classify.js +25 -0
  25. package/dist/services/insight/classify.js.map +1 -1
  26. package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
  27. package/dist/services/insight/claude-span-reader.js +7 -4
  28. package/dist/services/insight/claude-span-reader.js.map +1 -1
  29. package/dist/services/insight/report.d.ts.map +1 -1
  30. package/dist/services/insight/report.js +102 -23
  31. package/dist/services/insight/report.js.map +1 -1
  32. package/dist/services/insight/subagent-reader.d.ts +19 -0
  33. package/dist/services/insight/subagent-reader.d.ts.map +1 -0
  34. package/dist/services/insight/subagent-reader.js +111 -0
  35. package/dist/services/insight/subagent-reader.js.map +1 -0
  36. package/dist/services/insight/types.d.ts +43 -0
  37. package/dist/services/insight/types.d.ts.map +1 -1
  38. package/dist/services/insight/types.js.map +1 -1
  39. package/dist/skills/definitions.d.ts +8 -0
  40. package/dist/skills/definitions.d.ts.map +1 -1
  41. package/dist/skills/definitions.js +8 -2
  42. package/dist/skills/definitions.js.map +1 -1
  43. package/dist/skills/installer.d.ts +15 -0
  44. package/dist/skills/installer.d.ts.map +1 -1
  45. package/dist/skills/installer.js +39 -1
  46. package/dist/skills/installer.js.map +1 -1
  47. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { botDisplayName, escapeHtml, loadNameMaps, relTime, stripMentionPrefix, t } from './ui.js';
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
- return stripMentionPrefix(s.title ?? s.firstUserMessage ?? s.rootMessageId ?? s.sessionId);
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
- agg.readWriteRatio = rwN ? Number((rwSum / rwN).toFixed(2)) : null;
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 renderPhaseMix(report) {
283
- const entries = Object.entries(report.agg.phase ?? {}).filter(([, v]) => v.count > 0 || v.ms > 0);
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(([phase, v]) => {
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(phase)}" style="flex:${weight}" title="${escapeHtml(`${phaseLabel(phase)} · ${v.count} · ${fmtMs(v.ms)}`)}"></i>`;
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></div>
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
- </div>`;
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 = okReports(records);
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
- // Tool failure hotspots: total fails + how many sessions hit each tool.
401
- const toolFails = new Map();
402
- for (const r of reports) {
403
- for (const [tool, n] of Object.entries(r.agg.failByTool ?? {})) {
404
- const e = toolFails.get(tool) ?? { fails: 0, sessions: 0 };
405
- e.fails += n;
406
- e.sessions += 1;
407
- toolFails.set(tool, e);
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 tools = [...toolFails.entries()].map(([tool, v]) => ({ tool, ...v })).sort((a, b) => b.fails - a.fails).slice(0, 10);
411
- const toolMax = Math.max(1, ...tools.map(x => x.fails));
412
- // Slowest sessions.
413
- const slowSessions = [...records].filter(r => r.report?.status === 'ok' && r.report.agg.slowSpans > 0)
414
- .sort((a, b) => (b.report.agg.slowSpans) - (a.report.agg.slowSpans)).slice(0, 8);
415
- // Projects by session + fails.
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 records) {
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
- <section class="block">
433
- <h3>${escapeHtml(t('insights.hotToolFailures'))}</h3>
434
- <div class="hbars">${tools.length ? tools.map(x => {
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 `<div class="hbrow"><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></div>`;
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
- return `<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('');
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 = false;
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
- <button type="button" id="insight-refresh" class="primary">${escapeHtml(t('insights.refresh'))}</button>
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
- // Esc closes the full-text prompt modal.
1840
- const onKey = (e) => { if (e.key === 'Escape' && modalTurn !== null)
1841
- closeModal(); };
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(); });