@luanpdd/kit-mcp 1.4.0 → 1.5.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,55 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.5.1] - 2026-05-05
10
+
11
+ Patch da UI sidecar: auto-reconnect quando o server reinicia + bordas com respiro.
12
+
13
+ ### Corrigido
14
+
15
+ - **UI fica presa em "desconectado" quando server volta.** O `EventSource` nativo às vezes estagna no estado `CONNECTING` mesmo depois do server voltar — usuário precisava recarregar a aba. Agora um poll do `/healthz` a cada 3s roda em paralelo: ao detectar 200, fecha o `EventSource` antigo, hidrata `/state`, e abre um novo. Funciona pra qualquer cenário (kill -9, `kit ui stop` + `kit ui start`, network blip, máquina suspended). Usuário **não precisa mais recarregar** — basta o server voltar.
16
+
17
+ - **Banner "Sidecar encerrou" persistia mesmo após reconnect.** Race entre o handler de shutdown e o poll de saúde podia deixar o banner visível mesmo com a conexão de volta. Agora `applyConnState("open")` sempre remove o banner — estado saudável significa que o aviso está stale.
18
+
19
+ - **Cropping nas bordas da timeline:** "há 22m" colado na borda esquerda e `runId`/tokens-chip cortados na direita. `.tl-row` ganhou `padding: var(--pad-tight) 12px`. `.tl-time` virou `padding-right: 8px`. `.tl-content` ganhou `padding-right: 4px` + `overflow: hidden`. Tokens-chip e tl-runid agora têm `flex-shrink: 0` explícito pra não encolher quando a mensagem ocupa muita largura.
20
+
21
+ ### Sem mudanças de API
22
+
23
+ Patch puro de UI. `src/ui/static/index.html` e `test/integration/ui-static.test.js` apenas. Stable API v1.0+ preservada.
24
+
25
+ ## [1.5.0] - 2026-05-05
26
+
27
+ UI sidecar — bug fixes visuais + tokens + histórico de sessão.
28
+
29
+ ### Adicionado
30
+
31
+ - **Tokens chip** em cada row da timeline e card de active run quando o evento traz `payload.tokens` (também aceita `payload.usage.total_tokens` e `payload.cost.tokens` para compatibilidade com diferentes wrappers). Formato `1.2k` / `5.3k` / `1.5M`.
32
+ - **Soma cumulativa de tokens da sessão** no footer (`6.2k tokens nesta sessão`). Aparece só quando algum evento veio com tokens — quem não usa LLM continua vendo o footer enxuto.
33
+ - **Histórico desta sessão** — drawer flutuante (botão de relógio na toolbar). Persiste em `sessionStorage` (não cross-tab, não cross-session); cada run terminada vira uma row com status (✓/✗/·), título, timestamp, duração, tokens, contagem de eventos e runId truncado. Click expande pra mostrar até os 100 últimos eventos da run com %, label e tokens. Cap em 50 runs (mais antigos são descartados).
34
+ - **Footer mostra runs concluídas** total da sessão (`3 runs concluídas`).
35
+
36
+ ### Corrigido
37
+
38
+ - **Mojibake (`�`) em payloads** — eventos publicados via shells com locale ruim podiam vazar U+FFFD. Helper `safeStr()` agora limpa esses bytes antes de qualquer renderização.
39
+ - **Rows vazias na timeline** — `milestone` event que vinha com `payload.label` mas sem `payload.name` rendia em branco. Cascata defensiva agora tenta `name → title → label → name → tipo humanizado` em todos os tipos. Garantia: nenhuma row sai sem texto.
40
+ - **Active card sem título** — antes mostrava `—` sozinho se `payload.tool` estava vazio. Helper `runTitle(run)` cascata `humanizeTool(tool) → lastTitle → lastLabel → lastName → "Processo"`.
41
+ - **Tool inline mostrava `—` em vez de fallback** — `escapeHtml(safeStr(run.tool) || "processo")` no rc-tool, `escapeHtml(safeStr(run.tool) || "")` no rc-foot.
42
+
43
+ ### Removido
44
+
45
+ - **Painel "Cenário (mock)" do Tweaks** — apenas cenários reais agora; sem botões de demo.
46
+ - **Botão "▸ replay"** — dependia de mock.
47
+ - **Funções `scenarioSync` / `scenarioMulti` / `scenarioError` / `scenarioIdle` / `runScenario` / `mockTimers` / `later` / `clearMock`** — toda infraestrutura de mock event generator (~80 LOC). `EventSource('/events')` é a única fonte de verdade.
48
+ - **Fallback `file://` boot** — sidecar não é mais aberto via `file://`; só via servidor.
49
+
50
+ ### Sem mudanças de API runtime
51
+
52
+ Mudanças concentradas em `src/ui/static/index.html`. `src/core/`, `src/cli/`, `src/mcp-server/`, `src/ui/server.js` intocados. Stable API v1.0+ preservada.
53
+
54
+ ### Migration
55
+
56
+ Wrappers que quiserem expor cost/tokens podem agora popular `payload.tokens` (number) em qualquer evento. Quem não popula continua funcionando idêntico — chips não aparecem, footer não mostra a linha de tokens. Histórico é per-tab via `sessionStorage` — fechar a aba apaga (intencional, não persistir é a feature).
57
+
9
58
  ## [1.4.0] - 2026-05-05
10
59
 
11
60
  Framework velocity — 7 melhorias para os comandos / agentes do kit, focadas em reduzir fricção, evitar conflitos com main, e auto-detectar configs que hoje exigem env var manual.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -483,9 +483,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
483
483
 
484
484
  .tl-row {
485
485
  display: grid;
486
- grid-template-columns: 64px 18px 1fr;
486
+ grid-template-columns: 64px 18px minmax(0, 1fr);
487
487
  gap: 0;
488
- padding: var(--pad-tight) 0;
488
+ padding: var(--pad-tight) 12px;
489
489
  position: relative;
490
490
  border-radius: 4px;
491
491
  transition: background .15s var(--ease);
@@ -514,8 +514,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
514
514
  font-size: 11px;
515
515
  color: var(--text-3);
516
516
  font-variant-numeric: tabular-nums;
517
- padding-left: 4px;
518
517
  padding-top: 1px;
518
+ padding-right: 8px;
519
+ text-align: left;
519
520
  }
520
521
 
521
522
  /* rail with node */
@@ -552,14 +553,17 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
552
553
  .tl-row[data-type="progress"] .tl-node { width: 5px; height: 5px; margin-top: 6px; }
553
554
 
554
555
  /* group runId connector — visually subtle indent */
555
- .tl-row[data-grouped="true"] { padding-left: 0; }
556
556
  .tl-row[data-grouped="true"] .tl-node { background: var(--text-3); }
557
557
 
558
558
  .tl-content {
559
559
  display: flex; align-items: baseline; gap: 8px;
560
560
  padding-left: 8px;
561
+ padding-right: 4px;
561
562
  min-width: 0;
563
+ overflow: hidden;
562
564
  }
565
+ .tl-content > .tokens-chip,
566
+ .tl-content > .tl-runid { flex-shrink: 0; }
563
567
 
564
568
  .tl-badge {
565
569
  font-family: var(--mono);
@@ -802,6 +806,154 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
802
806
  }
803
807
  .tw-actions button:hover { color: var(--text); background: var(--surface-3); }
804
808
 
809
+ /* ───────────── tokens chips ───────────── */
810
+ .tokens-chip {
811
+ display: inline-flex; align-items: center; gap: 4px;
812
+ font-family: var(--mono);
813
+ font-size: 11px;
814
+ color: var(--text-2);
815
+ padding: 1px 6px;
816
+ border: 1px solid var(--line);
817
+ border-radius: 999px;
818
+ background: var(--surface-2);
819
+ font-variant-numeric: tabular-nums;
820
+ white-space: nowrap;
821
+ }
822
+ .tokens-chip::before {
823
+ content: "⌬";
824
+ color: var(--text-3);
825
+ font-size: 10px;
826
+ }
827
+ .rc-tokens {
828
+ margin-top: 6px;
829
+ font-family: var(--mono);
830
+ color: var(--text-3);
831
+ font-size: 11px;
832
+ display: flex; gap: 14px;
833
+ align-items: center;
834
+ }
835
+ .rc-tokens .num { color: var(--text-2); font-variant-numeric: tabular-nums; }
836
+
837
+ /* ───────────── history drawer ───────────── */
838
+ .history {
839
+ position: fixed;
840
+ right: 16px; bottom: 16px;
841
+ width: 360px;
842
+ max-height: 70vh;
843
+ background: var(--surface-2);
844
+ border: 1px solid var(--line-strong);
845
+ border-radius: 12px;
846
+ box-shadow: 0 24px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.02);
847
+ z-index: 50;
848
+ overflow: hidden;
849
+ display: none;
850
+ flex-direction: column;
851
+ animation: tweaks-in .25s var(--ease);
852
+ }
853
+ .history.open { display: flex; }
854
+ .history h3 {
855
+ margin: 0;
856
+ padding: 12px 14px;
857
+ font-family: var(--mono);
858
+ font-size: 10px;
859
+ text-transform: uppercase;
860
+ letter-spacing: .14em;
861
+ color: var(--text-3);
862
+ display: flex; align-items: center; gap: 8px;
863
+ border-bottom: 1px solid var(--line);
864
+ flex-shrink: 0;
865
+ }
866
+ .history h3 .hist-meta {
867
+ color: var(--text-2);
868
+ font-size: 10px;
869
+ font-weight: 400;
870
+ text-transform: none;
871
+ letter-spacing: .04em;
872
+ margin-left: auto;
873
+ }
874
+ .history h3 .close {
875
+ width: 18px; height: 18px;
876
+ display: grid; place-items: center;
877
+ border-radius: 4px;
878
+ color: var(--text-3);
879
+ }
880
+ .history h3 .close:hover { background: var(--surface-3); color: var(--text); }
881
+ .history-body { padding: 8px; overflow-y: auto; flex: 1; }
882
+ .hist-empty {
883
+ padding: 24px 12px;
884
+ text-align: center;
885
+ color: var(--text-3);
886
+ font-size: 12px;
887
+ }
888
+ .hist-row {
889
+ display: grid;
890
+ grid-template-columns: 22px 1fr auto;
891
+ gap: 4px 10px;
892
+ padding: 10px;
893
+ border: 1px solid var(--line);
894
+ border-radius: 8px;
895
+ margin-bottom: 6px;
896
+ background: var(--surface-1);
897
+ transition: border-color .15s var(--ease);
898
+ cursor: pointer;
899
+ }
900
+ .hist-row:hover { border-color: var(--line-strong); background: var(--surface-3); }
901
+ .hist-row[data-status="error"] { border-left: 3px solid var(--err); }
902
+ .hist-row[data-status="ok"] { border-left: 3px solid var(--accent); }
903
+ .hist-status {
904
+ width: 18px; height: 18px;
905
+ display: grid; place-items: center;
906
+ border-radius: 999px;
907
+ font-family: var(--mono);
908
+ font-size: 10px;
909
+ font-weight: 700;
910
+ grid-row: 1 / span 1;
911
+ }
912
+ .hist-row[data-status="ok"] .hist-status { color: var(--accent); background: var(--accent-soft); }
913
+ .hist-row[data-status="error"] .hist-status { color: var(--err); background: var(--err-soft); }
914
+ .hist-row[data-status="running"] .hist-status { color: var(--text-2); background: var(--surface-3); }
915
+ .hist-title {
916
+ font-size: 13px;
917
+ color: var(--text);
918
+ font-weight: 500;
919
+ min-width: 0;
920
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
921
+ }
922
+ .hist-when {
923
+ font-family: var(--mono);
924
+ font-size: 10px;
925
+ color: var(--text-3);
926
+ text-align: right;
927
+ grid-row: 1 / span 1;
928
+ }
929
+ .hist-meta-row {
930
+ grid-column: 2 / span 2;
931
+ display: flex; gap: 12px; flex-wrap: wrap;
932
+ font-family: var(--mono);
933
+ font-size: 10px;
934
+ color: var(--text-3);
935
+ }
936
+ .hist-meta-row .num { color: var(--text-2); font-variant-numeric: tabular-nums; }
937
+ .hist-detail {
938
+ grid-column: 1 / -1;
939
+ margin-top: 6px;
940
+ padding: 8px;
941
+ border: 1px solid var(--line);
942
+ border-radius: 6px;
943
+ background: var(--surface-2);
944
+ font-family: var(--mono);
945
+ font-size: 11px;
946
+ color: var(--text-2);
947
+ max-height: 240px;
948
+ overflow-y: auto;
949
+ display: none;
950
+ }
951
+ .hist-row.expanded .hist-detail { display: block; }
952
+ .hist-detail-row { padding: 2px 0; display: flex; gap: 6px; }
953
+ .hist-detail-row .pct { color: var(--accent); flex-shrink: 0; min-width: 36px; font-variant-numeric: tabular-nums; }
954
+ .hist-detail-row .lbl { color: var(--text-2); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
955
+ .hist-detail-row .tok { color: var(--text-3); flex-shrink: 0; }
956
+
805
957
  /* density-driven log padding handled via --pad-tight above */
806
958
 
807
959
  /* ───────────────────────── reduced motion ───────────────────────── */
@@ -868,6 +1020,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
868
1020
  <rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>
869
1021
  </svg>
870
1022
  </button>
1023
+ <button class="iconbtn" id="history-btn" aria-pressed="false" aria-label="Histórico desta sessão" title="Histórico desta sessão">
1024
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1025
+ <path d="M3 12a9 9 0 1 0 3-7"/><path d="M3 4v5h5"/><path d="M12 7v5l3 2"/>
1026
+ </svg>
1027
+ </button>
871
1028
  <button class="iconbtn" id="tweaks-btn" aria-pressed="false" aria-label="Tweaks" title="Tweaks">
872
1029
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
873
1030
  <circle cx="12" cy="12" r="3"/>
@@ -910,6 +1067,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
910
1067
  <span>fonte: <span class="live" id="src-label">ao vivo</span></span>
911
1068
  <span class="sep">·</span>
912
1069
  <span id="last-seen">aguardando…</span>
1070
+ <span class="sep" id="footer-tokens-sep" hidden>·</span>
1071
+ <span id="footer-tokens" hidden>0 tokens</span>
1072
+ <span class="sep">·</span>
1073
+ <span id="footer-runs">0 runs concluídas</span>
913
1074
  </footer>
914
1075
 
915
1076
  </div>
@@ -953,23 +1114,26 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
953
1114
  </div>
954
1115
  </div>
955
1116
 
956
- <div class="tw-group">
957
- <span class="tw-label">Cenário (mock)</span>
958
- <div class="seg" id="tw-scenario">
959
- <button data-v="sync" aria-pressed="true">Sync</button>
960
- <button data-v="multi">Multi</button>
961
- <button data-v="error">Erro</button>
962
- <button data-v="idle">Idle</button>
963
- </div>
964
- </div>
965
-
966
1117
  <div class="tw-actions">
967
- <button id="tw-replay">▸ replay</button>
968
- <button id="tw-clear">limpar</button>
1118
+ <button id="tw-clear">limpar tela</button>
969
1119
  </div>
970
1120
  </div>
971
1121
  </aside>
972
1122
 
1123
+ <!-- HISTORY DRAWER -->
1124
+ <aside class="history" id="history" role="dialog" aria-label="Histórico da sessão">
1125
+ <h3>
1126
+ Histórico desta sessão
1127
+ <span class="hist-meta" id="hist-meta">0 runs</span>
1128
+ <button class="close" id="history-close" aria-label="Fechar">
1129
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
1130
+ </button>
1131
+ </h3>
1132
+ <div class="history-body" id="history-body">
1133
+ <div class="hist-empty">Nada por aqui ainda. Quando uma run terminar, ela aparece aqui.</div>
1134
+ </div>
1135
+ </aside>
1136
+
973
1137
  <!-- SVG sprite (in-doc, no external) -->
974
1138
  <svg width="0" height="0" style="position:absolute" aria-hidden="true">
975
1139
  <defs>
@@ -1057,11 +1221,13 @@ function clockTime(ts) {
1057
1221
  /* ---------- state ---------- */
1058
1222
  const state = {
1059
1223
  events: [], // newest first
1060
- runs: new Map(), // runId → { startTs, tool, lastProgress, lastLabel, ended, ok, current, total }
1224
+ runs: new Map(), // runId → { startTs, tool, lastProgress, lastLabel, ended, ok, current, total, tokens, endTs }
1061
1225
  filterText: "",
1062
1226
  // tool_invocation + shutdown não têm chip de filtro, mas devem aparecer por default
1063
1227
  filters: new Set(["run.start","progress","milestone","run.end","error","tool_invocation","shutdown"]),
1064
1228
  paused: false,
1229
+ totalTokens: 0, // soma de payload.tokens em TODOS os eventos da sessão
1230
+ history: [], // runs já concluídas neste tab — persistido em sessionStorage
1065
1231
  };
1066
1232
 
1067
1233
  /* ---------- DOM refs ---------- */
@@ -1085,6 +1251,14 @@ const els = {
1085
1251
  tweaks: $("#tweaks"),
1086
1252
  tweaksClose: $("#tweaks-close"),
1087
1253
  srcLabel: $("#src-label"),
1254
+ historyBtn: $("#history-btn"),
1255
+ history: $("#history"),
1256
+ historyClose: $("#history-close"),
1257
+ historyBody: $("#history-body"),
1258
+ histMeta: $("#hist-meta"),
1259
+ footerTokens: $("#footer-tokens"),
1260
+ footerTokensSep: $("#footer-tokens-sep"),
1261
+ footerRuns: $("#footer-runs"),
1088
1262
  };
1089
1263
 
1090
1264
  /* ---------- ingest one event ---------- */
@@ -1093,16 +1267,24 @@ function ingest(evt) {
1093
1267
  state.events.unshift(evt);
1094
1268
  if (state.events.length > 200) state.events.length = 200;
1095
1269
 
1270
+ // tokens — soma global da sessão + por run
1271
+ const tk = readTokens(evt);
1272
+ if (tk > 0) state.totalTokens += tk;
1273
+
1096
1274
  // run tracking
1097
1275
  if (evt.runId) {
1098
1276
  let run = state.runs.get(evt.runId);
1099
1277
  if (!run) {
1100
- run = { runId: evt.runId, startTs: evt.ts, tool: null, lastProgress: 0, lastLabel: "", ended: false, ok: true, current: 0, total: 0, lastTs: evt.ts };
1278
+ run = { runId: evt.runId, startTs: evt.ts, tool: null, lastProgress: 0, lastLabel: "", ended: false, ok: true, current: 0, total: 0, tokens: 0, events: [], lastTs: evt.ts };
1101
1279
  state.runs.set(evt.runId, run);
1102
1280
  }
1103
1281
  run.lastTs = evt.ts;
1282
+ run.tokens = (run.tokens || 0) + tk;
1283
+ // store a slimmed-down record of every event in the run so the history
1284
+ // drawer can show what happened later
1285
+ run.events.push({ ts: evt.ts, type: evt.type, percent: evt.payload?.percent, label: evt.payload?.label, name: evt.payload?.name, message: evt.payload?.message, tokens: tk });
1104
1286
  if (evt.type === "run.start") {
1105
- run.tool = evt.payload?.tool;
1287
+ run.tool = evt.payload?.tool || run.tool;
1106
1288
  run.startTs = evt.ts;
1107
1289
  } else if (evt.type === "progress") {
1108
1290
  if (typeof evt.payload?.percent === "number") run.lastProgress = evt.payload.percent;
@@ -1112,9 +1294,13 @@ function ingest(evt) {
1112
1294
  } else if (evt.type === "run.end") {
1113
1295
  run.ended = true;
1114
1296
  run.ok = !!evt.payload?.ok;
1297
+ run.endTs = evt.ts;
1115
1298
  run.lastProgress = run.ok ? 100 : run.lastProgress;
1299
+ // arquive into history (client-only, sessionStorage)
1300
+ archiveRun(run);
1116
1301
  } else if (evt.type === "error") {
1117
- // don't end the run on error event alone; matches server semantics
1302
+ // não termina o run; registra
1303
+ run.lastError = evt.payload?.message || "erro";
1118
1304
  }
1119
1305
  }
1120
1306
 
@@ -1122,6 +1308,55 @@ function ingest(evt) {
1122
1308
  els.lastSeen.textContent = "último: " + clockTime(evt.ts);
1123
1309
  }
1124
1310
 
1311
+ /* ---------- helpers — tokens & history ---------- */
1312
+ function readTokens(evt) {
1313
+ // Aceita várias chaves comuns: payload.tokens (preferido), payload.usage.total_tokens, payload.cost.tokens
1314
+ const p = evt.payload || {};
1315
+ if (typeof p.tokens === "number") return p.tokens;
1316
+ if (p.usage && typeof p.usage.total_tokens === "number") return p.usage.total_tokens;
1317
+ if (p.cost && typeof p.cost.tokens === "number") return p.cost.tokens;
1318
+ return 0;
1319
+ }
1320
+
1321
+ function fmtTokens(n) {
1322
+ if (n < 1000) return String(n);
1323
+ if (n < 10_000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
1324
+ if (n < 1_000_000) return Math.round(n / 1000) + "k";
1325
+ return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
1326
+ }
1327
+
1328
+ function archiveRun(run) {
1329
+ // Snapshot leve da run completa — o que vai pra sessionStorage tem que ser serializável.
1330
+ const entry = {
1331
+ runId: run.runId,
1332
+ tool: run.tool,
1333
+ startTs: run.startTs,
1334
+ endTs: run.endTs,
1335
+ durationMs: (run.endTs || Date.now()) - run.startTs,
1336
+ ok: run.ok,
1337
+ tokens: run.tokens || 0,
1338
+ eventsCount: run.events.length,
1339
+ events: run.events.slice(-100), // últimos 100 eventos da run pra detalhe
1340
+ };
1341
+ state.history.unshift(entry);
1342
+ // cap em 50 runs pra não estourar sessionStorage
1343
+ if (state.history.length > 50) state.history.length = 50;
1344
+ saveHistory();
1345
+ }
1346
+
1347
+ function saveHistory() {
1348
+ try {
1349
+ sessionStorage.setItem("kit-mcp-history", JSON.stringify(state.history));
1350
+ } catch (_) { /* quota? sem espaço? sem stress. */ }
1351
+ }
1352
+
1353
+ function loadHistory() {
1354
+ try {
1355
+ const raw = sessionStorage.getItem("kit-mcp-history");
1356
+ if (raw) state.history = JSON.parse(raw) || [];
1357
+ } catch (_) { state.history = []; }
1358
+ }
1359
+
1125
1360
  /* ---------- render ---------- */
1126
1361
  function passesFilter(evt) {
1127
1362
  if (!state.filters.has(evt.type)) return false;
@@ -1137,6 +1372,64 @@ function render() {
1137
1372
  const total = state.events.length;
1138
1373
  els.evtCount.textContent = `${total} evento${total === 1 ? "" : "s"}`;
1139
1374
  els.logCount.textContent = `${total} evento${total === 1 ? "" : "s"}`;
1375
+ // Tokens (mostra só quando algum evento da sessão veio com payload.tokens)
1376
+ if (state.totalTokens > 0) {
1377
+ els.footerTokens.hidden = false;
1378
+ els.footerTokensSep.hidden = false;
1379
+ els.footerTokens.textContent = `${fmtTokens(state.totalTokens)} tokens nesta sessão`;
1380
+ } else {
1381
+ els.footerTokens.hidden = true;
1382
+ els.footerTokensSep.hidden = true;
1383
+ }
1384
+ const completed = state.history.length;
1385
+ els.footerRuns.textContent = `${completed} run${completed === 1 ? "" : "s"} concluída${completed === 1 ? "" : "s"}`;
1386
+ if (els.histMeta) els.histMeta.textContent = `${completed} run${completed === 1 ? "" : "s"}`;
1387
+ }
1388
+
1389
+ /* ---------- history drawer renderer ---------- */
1390
+ function renderHistory() {
1391
+ if (!els.historyBody) return;
1392
+ if (state.history.length === 0) {
1393
+ els.historyBody.innerHTML = `<div class="hist-empty">Nada por aqui ainda. Quando uma run terminar, ela aparece aqui.</div>`;
1394
+ return;
1395
+ }
1396
+ const rows = state.history.map((h) => historyRowHtml(h)).join("");
1397
+ els.historyBody.innerHTML = rows;
1398
+ els.historyBody.querySelectorAll(".hist-row").forEach((row) => {
1399
+ row.addEventListener("click", () => row.classList.toggle("expanded"));
1400
+ });
1401
+ }
1402
+
1403
+ function historyRowHtml(h) {
1404
+ const status = h.ok === true ? "ok" : h.ok === false ? "error" : "running";
1405
+ const statusGlyph = status === "ok" ? "✓" : status === "error" ? "✕" : "·";
1406
+ const title = humanizeTool(safeStr(h.tool || "")) || safeStr(h.tool) || "Processo";
1407
+ const dur = formatElapsed(h.durationMs || 0);
1408
+ const when = clockTime(h.startTs);
1409
+ const tokens = h.tokens > 0 ? `<span class="num">${fmtTokens(h.tokens)}</span> tokens` : "<span>sem tokens registrados</span>";
1410
+ const detailRows = (h.events || [])
1411
+ .filter((e) => e.type === "progress" || e.type === "milestone" || e.type === "error" || e.type === "run.end")
1412
+ .map((e) => {
1413
+ const pct = typeof e.percent === "number" ? `${Math.round(e.percent)}%` : "·";
1414
+ const lbl = safeStr(e.label) || safeStr(e.name) || safeStr(e.message) || humanizeEventType(e.type);
1415
+ const tk = e.tokens > 0 ? fmtTokens(e.tokens) : "";
1416
+ return `<div class="hist-detail-row"><span class="pct">${escapeHtml(pct)}</span><span class="lbl">${escapeHtml(lbl)}</span>${tk ? `<span class="tok">${tk}t</span>` : ""}</div>`;
1417
+ })
1418
+ .join("");
1419
+ return `
1420
+ <div class="hist-row" data-status="${status}" data-runid="${h.runId}">
1421
+ <div class="hist-status">${statusGlyph}</div>
1422
+ <div class="hist-title">${escapeHtml(title)}</div>
1423
+ <div class="hist-when">${when}</div>
1424
+ <div class="hist-meta-row">
1425
+ <span><span class="num">${dur}</span> dur</span>
1426
+ <span>${tokens}</span>
1427
+ <span><span class="num">${h.eventsCount || 0}</span> eventos</span>
1428
+ <span class="num">id ${h.runId.slice(0,8)}</span>
1429
+ </div>
1430
+ <div class="hist-detail">${detailRows || '<div class="hist-detail-row"><span class="lbl">(sem progresso registrado)</span></div>'}</div>
1431
+ </div>
1432
+ `;
1140
1433
  }
1141
1434
 
1142
1435
  function renderActive() {
@@ -1165,18 +1458,44 @@ function renderActive() {
1165
1458
  });
1166
1459
  }
1167
1460
 
1461
+ /**
1462
+ * Sanitização: remove o "U+FFFD replacement character" e variantes que
1463
+ * indicam mojibake — eventos vindos de publishers em locale ruim podem
1464
+ * vazar bytes corrompidos. Em vez de mostrar `�`, devolvemos string vazia
1465
+ * e deixamos o fallback escolher um nome decente.
1466
+ */
1467
+ function safeStr(s) {
1468
+ if (typeof s !== "string") return "";
1469
+ // remove FFFD (U+FFFD) e null bytes
1470
+ const cleaned = s.replace(/�+/g, "").replace(/+/g, "").trim();
1471
+ return cleaned;
1472
+ }
1473
+
1474
+ /**
1475
+ * Resolve o melhor título legível para uma run, NUNCA devolve "—" sozinho.
1476
+ * Cascata: humanizeTool(payload.tool) → payload.title → payload.label →
1477
+ * payload.name → "Processo".
1478
+ */
1479
+ function runTitle(run) {
1480
+ const t = humanizeTool(safeStr(run.tool || ""));
1481
+ if (t && t !== "—") return t;
1482
+ const fallback = safeStr(run.lastTitle) || safeStr(run.lastLabel) || safeStr(run.lastName);
1483
+ if (fallback) return fallback;
1484
+ return "Processo";
1485
+ }
1486
+
1168
1487
  function updateActiveCard(card, run) {
1169
1488
  const family = TOOL_FAMILIES[run.tool] || "sync";
1170
1489
  const percent = Math.max(0, Math.min(100, Math.round(run.lastProgress)));
1171
1490
  const elapsed = Date.now() - run.startTs;
1172
1491
  const longRunning = elapsed > 30_000;
1173
- const stepLabel = humanizePath(run.lastLabel) || "iniciando…";
1492
+ const stepLabel = humanizePath(safeStr(run.lastLabel)) || "iniciando…";
1174
1493
  const stepCount = run.total ? `${run.current}/${run.total}` : "";
1175
1494
 
1176
1495
  const set = (sel, fn) => { const n = card.querySelector(sel); if (n) fn(n); };
1177
1496
  set(".rc-icon", (n) => { n.dataset.tool = family; n.querySelector("use")?.setAttribute("href", `#i-${family}`); });
1178
- set(".rc-tool", (n) => { n.textContent = run.tool || ""; });
1179
- set(".rc-title", (n) => { n.textContent = humanizeTool(run.tool || "—"); });
1497
+ set(".rc-tool", (n) => { n.textContent = safeStr(run.tool) || "processo"; });
1498
+ set(".rc-title", (n) => { n.textContent = runTitle(run); });
1180
1499
  set(".rc-bar-fill", (n) => { n.style.width = percent + "%"; });
1181
1500
  set(".rc-pct", (n) => { n.textContent = percent + "%"; });
1182
1501
  set(".rc-step-text", (n) => { if (n.textContent !== stepLabel) n.textContent = stepLabel; });
@@ -1184,24 +1503,27 @@ function updateActiveCard(card, run) {
1184
1503
  set(".rc-elapsed-val", (n) => { n.textContent = formatElapsed(elapsed); });
1185
1504
  set(".rc-elapsed .em", (n) => n.classList.toggle("warn", longRunning));
1186
1505
  set(".rc-runid", (n) => { n.textContent = "id " + run.runId.slice(0, 8); });
1506
+ set(".rc-tokens-val", (n) => { n.textContent = run.tokens > 0 ? fmtTokens(run.tokens) : "—"; });
1507
+ set(".rc-tokens", (n) => { n.classList.toggle("hidden", !(run.tokens > 0)); });
1187
1508
  }
1188
1509
 
1189
1510
  function activeCardHtml(run) {
1190
1511
  const family = TOOL_FAMILIES[run.tool] || "sync";
1191
1512
  const iconHref = `#i-${family}`;
1192
- const title = humanizeTool(run.tool || "—");
1193
- const stepLabel = humanizePath(run.lastLabel) || "iniciando…";
1513
+ const title = runTitle(run);
1514
+ const stepLabel = humanizePath(safeStr(run.lastLabel)) || "iniciando…";
1194
1515
  const percent = Math.max(0, Math.min(100, Math.round(run.lastProgress)));
1195
1516
  const elapsed = Date.now() - run.startTs;
1196
1517
  const longRunning = elapsed > 30_000;
1197
1518
  const stepCount = run.total ? `${run.current}/${run.total}` : "";
1519
+ const showTokens = run.tokens > 0;
1198
1520
  return `
1199
1521
  <article class="run-card" data-runid="${run.runId}">
1200
1522
  <div class="rc-head">
1201
1523
  <div class="rc-icon" data-tool="${family}"><svg><use href="${iconHref}"/></svg></div>
1202
1524
  <div class="rc-title-block">
1203
- <div class="rc-tool">${run.tool || ""}</div>
1204
- <div class="rc-title">${title}</div>
1525
+ <div class="rc-tool">${escapeHtml(safeStr(run.tool) || "processo")}</div>
1526
+ <div class="rc-title">${escapeHtml(title)}</div>
1205
1527
  </div>
1206
1528
  <div class="rc-elapsed">
1207
1529
  <span class="em ${longRunning ? "warn" : ""}"><span class="rc-elapsed-val">${formatElapsed(elapsed)}</span></span>
@@ -1220,10 +1542,15 @@ function activeCardHtml(run) {
1220
1542
  ${stepCount ? `<span class="rc-step-count">${stepCount}</span>` : ""}
1221
1543
  </div>
1222
1544
 
1545
+ <div class="rc-tokens" ${showTokens ? "" : "style=\"display:none\""}>
1546
+ <span class="tokens-chip"><span class="rc-tokens-val">${showTokens ? fmtTokens(run.tokens) : "—"}</span></span>
1547
+ <span>tokens nesta run</span>
1548
+ </div>
1549
+
1223
1550
  <div class="rc-foot">
1224
1551
  <span class="rc-runid">id ${run.runId.slice(0, 8)}</span>
1225
1552
  <span class="sep">·</span>
1226
- <span>${run.tool || ""}</span>
1553
+ <span>${escapeHtml(safeStr(run.tool) || "")}</span>
1227
1554
  </div>
1228
1555
  </article>
1229
1556
  `;
@@ -1296,39 +1623,71 @@ function rowHtml(evt, idx, prev) {
1296
1623
  const time = clockTime(evt.ts);
1297
1624
  const rel = relTime(Date.now() - evt.ts);
1298
1625
  const badge = humanizeEventType(evt.type);
1299
- const isNew = (Date.now() - evt.ts) < 1500;
1626
+ const tk = readTokens(evt);
1627
+ const tokenChip = tk > 0 ? ` <span class="tokens-chip" title="tokens">${fmtTokens(tk)}</span>` : "";
1628
+ /**
1629
+ * Cascata defensiva pra NUNCA renderizar uma row sem texto:
1630
+ * 1) campo específico do tipo (name pra milestone, message pra error, …)
1631
+ * 2) payload.label
1632
+ * 3) payload.title
1633
+ * 4) humanizeTool(payload.tool)
1634
+ * 5) o tipo humanizado em si (último recurso — "Marco", "Erro", "Iniciado")
1635
+ */
1636
+ const fallback = (...candidates) => {
1637
+ for (const c of candidates) {
1638
+ const s = safeStr(typeof c === "string" ? c : (c == null ? "" : String(c)));
1639
+ if (s) return s;
1640
+ }
1641
+ return badge.toLowerCase();
1642
+ };
1643
+
1300
1644
  let msg = "";
1301
1645
  switch (evt.type) {
1302
1646
  case "run.start": {
1303
- msg = `<strong>${escapeHtml(humanizeTool(evt.payload?.tool))}</strong> <span class="ident">${escapeHtml(evt.payload?.tool || "")}</span>${evt.payload?.target ? ` <span class="arrow">→</span> <span class="ident">${escapeHtml(evt.payload.target)}</span>` : ""}`;
1647
+ const tool = safeStr(evt.payload?.tool || "");
1648
+ const title = humanizeTool(tool) || fallback(evt.payload?.title, evt.payload?.label, evt.payload?.name);
1649
+ const ident = tool ? ` <span class="ident">${escapeHtml(tool)}</span>` : "";
1650
+ const target = evt.payload?.target ? ` <span class="arrow">→</span> <span class="ident">${escapeHtml(safeStr(evt.payload.target))}</span>` : "";
1651
+ msg = `<strong>${escapeHtml(title)}</strong>${ident}${target}`;
1304
1652
  break;
1305
1653
  }
1306
1654
  case "run.end": {
1307
1655
  const dur = evt.payload?.duration_ms;
1308
- msg = `<strong>${escapeHtml(humanizeTool(evt.payload?.tool))}</strong> ${ok ? "concluído" : "falhou"}${dur ? ` <span class="ident">${(dur/1000).toFixed(2)}s</span>` : ""}`;
1656
+ const tool = safeStr(evt.payload?.tool || "");
1657
+ const title = humanizeTool(tool) || fallback(evt.payload?.title, evt.payload?.label, evt.payload?.name);
1658
+ msg = `<strong>${escapeHtml(title)}</strong> ${ok ? "concluído" : "falhou"}${dur ? ` <span class="ident">${(dur/1000).toFixed(2)}s</span>` : ""}`;
1309
1659
  break;
1310
1660
  }
1311
1661
  case "progress": {
1312
1662
  const pct = typeof evt.payload?.percent === "number" ? `${Math.round(evt.payload.percent)}%` : "";
1313
- const lbl = evt.payload?.label || "";
1663
+ const lbl = fallback(evt.payload?.label, evt.payload?.title, evt.payload?.name, "em andamento");
1314
1664
  const ct = evt.payload?.current && evt.payload?.total ? ` <span class="ident">${evt.payload.current}/${evt.payload.total}</span>` : "";
1315
1665
  msg = `${pct ? `<strong>${pct}</strong> ` : ""}<span class="path">${escapeHtml(humanizePath(lbl))}</span>${ct}`;
1316
1666
  break;
1317
1667
  }
1318
1668
  case "milestone": {
1319
- msg = `<strong>${escapeHtml(evt.payload?.name || "")}</strong>`;
1669
+ const lbl = fallback(evt.payload?.name, evt.payload?.title, evt.payload?.label, "marco");
1670
+ msg = `<strong>${escapeHtml(lbl)}</strong>`;
1320
1671
  break;
1321
1672
  }
1322
1673
  case "error": {
1323
- msg = `<strong>${escapeHtml(evt.payload?.message || "erro")}</strong>${evt.payload?.code ? ` <span class="ident">${evt.payload.code}</span>` : ""}`;
1674
+ const lbl = fallback(evt.payload?.message, evt.payload?.title, evt.payload?.label, evt.payload?.name, "erro");
1675
+ const code = evt.payload?.code ? ` <span class="ident">${escapeHtml(safeStr(evt.payload.code))}</span>` : "";
1676
+ msg = `<strong>${escapeHtml(lbl)}</strong>${code}`;
1324
1677
  break;
1325
1678
  }
1326
1679
  case "tool_invocation": {
1327
- msg = `<span class="ident">${escapeHtml(evt.payload?.tool || "")}</span>`;
1680
+ const tool = safeStr(evt.payload?.tool || "");
1681
+ const title = humanizeTool(tool) || fallback(evt.payload?.title, evt.payload?.label, "ferramenta invocada");
1682
+ msg = `<strong>${escapeHtml(title)}</strong>${tool ? ` <span class="ident">${escapeHtml(tool)}</span>` : ""}`;
1683
+ break;
1684
+ }
1685
+ case "shutdown": {
1686
+ msg = `<strong>${escapeHtml(fallback(evt.payload?.message, "sidecar encerrado"))}</strong>`;
1328
1687
  break;
1329
1688
  }
1330
1689
  default:
1331
- msg = "";
1690
+ msg = `<span class="ident">${escapeHtml(badge.toLowerCase())}</span>`;
1332
1691
  }
1333
1692
  return `
1334
1693
  <div class="tl-row" data-type="${evt.type}" data-ok="${ok}" data-grouped="${grouped}">
@@ -1337,6 +1696,7 @@ function rowHtml(evt, idx, prev) {
1337
1696
  <div class="tl-content">
1338
1697
  <span class="tl-badge">${badge}</span>
1339
1698
  <span class="tl-msg">${msg}</span>
1699
+ ${tokenChip}
1340
1700
  ${evt.runId ? `<span class="tl-runid">${evt.runId.slice(0,6)}</span>` : ""}
1341
1701
  </div>
1342
1702
  </div>
@@ -1365,104 +1725,14 @@ setInterval(() => {
1365
1725
  });
1366
1726
  }, 1000);
1367
1727
 
1368
- /* ───────────────────── mock event generator ───────────────────── */
1369
- let mockTimers = [];
1370
- function clearMock() {
1371
- mockTimers.forEach(clearTimeout);
1372
- mockTimers = [];
1373
- }
1374
- function later(ms, fn) { mockTimers.push(setTimeout(fn, ms)); }
1375
-
1376
- function genRunId() { return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 8); }
1377
-
1378
- function scenarioSync(delay = 0) {
1379
- const runId = genRunId();
1380
- const tool = "sync.install";
1381
- const start = Date.now() + delay;
1382
- const target = "claude-code";
1383
- later(delay, () => ingest({ type: "run.start", ts: start, runId, payload: { tool, target } }));
1384
-
1385
- const total = 19;
1386
- const labels = [
1387
- "lendo agente planner",
1388
- "analisando dependências",
1389
- "projetando framework",
1390
- "compilando templates",
1391
- "writing .claude/agents/planner.md",
1392
- "writing .claude/agents/builder.md",
1393
- "writing .claude/agents/reviewer.md",
1394
- "writing .claude/commands/sync.md",
1395
- "validando schema",
1396
- "merging com kit base",
1397
- "writing .claude/agents/coordinator.md",
1398
- "writing .claude/agents/specialist.md",
1399
- "configurando hooks",
1400
- "writing .claude/agents/architect.md",
1401
- "writing .claude/agents/qa.md",
1402
- "writing .claude/agents/docs.md",
1403
- "selando manifest",
1404
- "rodando linter final",
1405
- "gravando metadata",
1406
- ];
1407
- for (let i = 0; i < total; i++) {
1408
- const t = delay + 220 + i * 540;
1409
- later(t, () => ingest({
1410
- type: "progress", ts: Date.now(), runId,
1411
- payload: { percent: Math.round(((i + 1) / total) * 100), label: labels[i], current: i + 1, total, kind: "fs" }
1412
- }));
1413
- }
1414
- later(delay + 220 + total * 540 + 100, () => ingest({
1415
- type: "milestone", ts: Date.now(), runId, payload: { name: `✓ ${total} agentes projetados` }
1416
- }));
1417
- later(delay + 220 + total * 540 + 600, () => ingest({
1418
- type: "run.end", ts: Date.now(), runId, payload: { tool, ok: true, duration_ms: 220 + total * 540 + 400 }
1419
- }));
1420
- }
1421
-
1422
- function scenarioMulti() {
1423
- scenarioSync(0);
1424
- // gates run starting in parallel
1425
- const runId = genRunId();
1426
- later(800, () => ingest({ type: "run.start", ts: Date.now(), runId, payload: { tool: "gates.run", target: "claude-code" } }));
1427
- const stages = ["lint", "typecheck", "schema", "smoke"];
1428
- stages.forEach((s, i) => {
1429
- later(800 + (i + 1) * 1100, () => ingest({
1430
- type: "progress", ts: Date.now(), runId,
1431
- payload: { percent: Math.round(((i + 1) / stages.length) * 100), label: `gate: ${s}`, current: i + 1, total: stages.length, kind: "task" }
1432
- }));
1433
- });
1434
- later(800 + stages.length * 1100 + 300, () => ingest({
1435
- type: "run.end", ts: Date.now(), runId, payload: { tool: "gates.run", ok: true, duration_ms: stages.length * 1100 }
1436
- }));
1437
- }
1438
-
1439
- function scenarioError() {
1440
- const runId = genRunId();
1441
- later(0, () => ingest({ type: "run.start", ts: Date.now(), runId, payload: { tool: "reverse.scan", target: "cursor" } }));
1442
- later(400, () => ingest({ type: "progress", ts: Date.now(), runId, payload: { percent: 18, label: "lendo .cursor/rules/", current: 2, total: 11, kind: "fs" } }));
1443
- later(900, () => ingest({ type: "progress", ts: Date.now(), runId, payload: { percent: 38, label: "scanning .cursor/rules/architecture.mdc", current: 4, total: 11, kind: "fs" } }));
1444
- later(1500, () => ingest({ type: "error", ts: Date.now(), runId, payload: { message: "ENOENT: file not found", code: "ENOENT" } }));
1445
- later(1700, () => ingest({ type: "run.end", ts: Date.now(), runId, payload: { tool: "reverse.scan", ok: false, duration_ms: 1700 } }));
1446
- }
1447
-
1448
- function scenarioIdle() {
1449
- /* nothing */
1450
- }
1451
-
1452
- let currentScenario = "sync";
1453
- function runScenario(name) {
1454
- currentScenario = name;
1455
- if (name === "sync") scenarioSync();
1456
- else if (name === "multi") scenarioMulti();
1457
- else if (name === "error") scenarioError();
1458
- else if (name === "idle") scenarioIdle();
1459
- }
1460
-
1728
+ /* ───────────────────── view actions ───────────────────── */
1729
+ /* "limpar tela" zera o que está VISÍVEL (eventos correntes + cards ativos),
1730
+ mas NUNCA toca o histórico de runs concluídas — esse é o ponto do drawer. */
1461
1731
  function clearAll() {
1462
- clearMock();
1463
1732
  state.events = [];
1464
1733
  state.runs.clear();
1465
1734
  state._seenKeys = new Set();
1735
+ state.totalTokens = 0;
1466
1736
  els.active.innerHTML = "";
1467
1737
  render();
1468
1738
  els.lastSeen.textContent = "aguardando…";
@@ -1498,14 +1768,30 @@ function wireSeg(rootSel, attr, fn) {
1498
1768
  }
1499
1769
  wireSeg("#tw-density", "v", (v) => document.documentElement.dataset.density = v);
1500
1770
  wireSeg("#tw-motion", "v", (v) => document.documentElement.dataset.motion = v);
1501
- wireSeg("#tw-scenario","v", (v) => { clearAll(); runScenario(v); });
1502
1771
 
1503
1772
  document.documentElement.dataset.density = "normal";
1504
1773
  document.documentElement.dataset.motion = "medium";
1505
1774
 
1506
- document.getElementById("tw-replay").addEventListener("click", () => { clearAll(); runScenario(currentScenario); });
1507
1775
  document.getElementById("tw-clear").addEventListener("click", clearAll);
1508
1776
 
1777
+ /* ───────────────────── history wiring ───────────────────── */
1778
+ els.historyBtn.addEventListener("click", () => {
1779
+ const open = els.history.classList.toggle("open");
1780
+ els.historyBtn.setAttribute("aria-pressed", open);
1781
+ if (open) renderHistory();
1782
+ });
1783
+ els.historyClose.addEventListener("click", () => {
1784
+ els.history.classList.remove("open");
1785
+ els.historyBtn.setAttribute("aria-pressed", "false");
1786
+ });
1787
+ // Esc fecha o drawer se ele estiver aberto e nada mais focado
1788
+ document.addEventListener("keydown", (e) => {
1789
+ if (e.key === "Escape" && els.history.classList.contains("open") && document.activeElement !== els.q) {
1790
+ els.history.classList.remove("open");
1791
+ els.historyBtn.setAttribute("aria-pressed", "false");
1792
+ }
1793
+ });
1794
+
1509
1795
  /* ───────────────────── toolbar wiring ───────────────────── */
1510
1796
  els.q.addEventListener("input", (e) => { state.filterText = e.target.value.trim(); render(); });
1511
1797
  document.addEventListener("keydown", (e) => {
@@ -1564,6 +1850,12 @@ function applyConnState(s) {
1564
1850
  els.conn.dataset.state = "on";
1565
1851
  els.connLabel.textContent = "conectado";
1566
1852
  if (els.srcLabel) els.srcLabel.textContent = "ao vivo";
1853
+ // Healthy => banner stale. Independente de qual caminho colocou ele lá
1854
+ // (shutdown event antigo, race, hydrate trazendo shutdown histórico),
1855
+ // estar conectado significa que o aviso "Reabra com kit ui start" não
1856
+ // serve mais — remova sempre.
1857
+ const banner = document.getElementById("shutdown-banner");
1858
+ if (banner) banner.remove();
1567
1859
  } else if (s === "connecting") {
1568
1860
  els.conn.dataset.state = "off";
1569
1861
  els.connLabel.textContent = "conectando";
@@ -1611,11 +1903,16 @@ function connectRealSource() {
1611
1903
  evtSource.addEventListener("open", () => {
1612
1904
  lastConnectedAt = Date.now();
1613
1905
  applyConnState("open");
1906
+ stopHealthPoll(); // já tá conectado, não precisa pollar
1614
1907
  });
1615
1908
 
1616
1909
  evtSource.addEventListener("error", () => {
1617
- // EventSource auto-retries; reflect "closed" until next open fires.
1910
+ // EventSource auto-retries; nós atualizamos o estado visível e iniciamos
1911
+ // o poll-de-saúde paralelo, que cobre o caso "server reiniciou" — o native
1912
+ // retry às vezes gruda em CONNECTING sem nunca virar OPEN, e o poll detecta
1913
+ // quando o /healthz volta a responder e força um connectRealSource() limpo.
1618
1914
  applyConnState("closed");
1915
+ startHealthPoll();
1619
1916
  });
1620
1917
 
1621
1918
  // Listen for each typed event the server emits.
@@ -1625,6 +1922,9 @@ function connectRealSource() {
1625
1922
  if (data && data.type === "shutdown") {
1626
1923
  applyConnState("shutdown");
1627
1924
  showShutdownBanner();
1925
+ // Mesmo após shutdown, um novo `kit ui start` faz o server voltar.
1926
+ // Continue tentando reconectar — usuário não precisa recarregar a aba.
1927
+ startHealthPoll();
1628
1928
  }
1629
1929
  ingest(data);
1630
1930
  } catch (_) { /* swallow malformed */ }
@@ -1635,12 +1935,41 @@ function connectRealSource() {
1635
1935
  evtSource.onmessage = handler; // fallback for unnamed
1636
1936
  }
1637
1937
 
1638
- /* Background-tab recovery — Chrome throttles timers in inactive tabs and the
1639
- native EventSource retry can stall. When the tab becomes visible again and
1640
- we know we're closed, force a fresh hydrate + reconnect. */
1938
+ /* ───────────────────── health-poll auto-reconnect ─────────────────────
1939
+ Quando o EventSource cai e fica preso (server reiniciado, sidecar `kit ui
1940
+ stop` seguido de `kit ui start`, network blip), o native retry pode estagnar
1941
+ em CONNECTING. Esse poll roda em segundo plano em paralelo: quando o /healthz
1942
+ volta a responder 200, fechamos o EventSource antigo, hidratamos /state, e
1943
+ abrimos um novo. Para de pollar assim que conectamos com sucesso. */
1944
+ let healthPollTimer = null;
1945
+ function startHealthPoll() {
1946
+ if (healthPollTimer) return;
1947
+ healthPollTimer = setInterval(async () => {
1948
+ if (els.conn.dataset.state === "on") { stopHealthPoll(); return; }
1949
+ try {
1950
+ const res = await fetch("/healthz", { credentials: "omit", cache: "no-store" });
1951
+ if (res.ok) {
1952
+ stopHealthPoll();
1953
+ // Banner de shutdown é stale agora que o server voltou — remova.
1954
+ const banner = document.getElementById("shutdown-banner");
1955
+ if (banner) banner.remove();
1956
+ await hydrateFromState();
1957
+ connectRealSource();
1958
+ }
1959
+ } catch (_) { /* server ainda não voltou — keep tentando */ }
1960
+ }, 3000);
1961
+ }
1962
+ function stopHealthPoll() {
1963
+ if (healthPollTimer) { clearInterval(healthPollTimer); healthPollTimer = null; }
1964
+ }
1965
+
1966
+ /* Background-tab recovery — quando o tab volta a ficar visível e estamos fora
1967
+ do estado conectado, força um reconnect imediato em vez de esperar o próximo
1968
+ tick de health poll. */
1641
1969
  document.addEventListener("visibilitychange", () => {
1642
1970
  if (document.visibilityState !== "visible") return;
1643
- if (els.conn.dataset.state === "off") {
1971
+ if (els.conn.dataset.state !== "on") {
1972
+ stopHealthPoll();
1644
1973
  hydrateFromState().then(connectRealSource);
1645
1974
  }
1646
1975
  });
@@ -1671,22 +2000,14 @@ function showShutdownBanner() {
1671
2000
  }
1672
2001
 
1673
2002
  /* ───────────────────── boot ───────────────────── */
1674
- /* In production we DON'T auto-run the mock the real /events stream is the
1675
- source of truth. The mock scenarios are still wired and accessible through
1676
- the tweaks panel for demo/dev. */
2003
+ /* `/events` SSE é a única fonte de verdade. Nenhum mock no boot. */
1677
2004
 
2005
+ loadHistory(); // restaura o histórico desta sessão (sessionStorage)
1678
2006
  (async () => {
1679
2007
  await hydrateFromState();
1680
2008
  connectRealSource();
2009
+ render(); // pinta footer + drawer mesmo antes do primeiro evento real
1681
2010
  })();
1682
-
1683
- // Detect "we're being served via http(s)" — when not, we're probably the
1684
- // design preview opened via file://; in that case fall back to the mock.
1685
- if (location.protocol === "file:") {
1686
- setTimeout(() => {
1687
- if (state.events.length === 0) runScenario("sync");
1688
- }, 200);
1689
- }
1690
2011
  </script>
1691
2012
  </body>
1692
2013
  </html>