issue-flow 0.8.0 → 0.9.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 (83) hide show
  1. package/README.md +16 -0
  2. package/dist/{analyze-RBNKXWL5.js → analyze-VSWAEVN3.js} +6 -6
  3. package/dist/{chunk-BH7DYADV.js → chunk-57MH6RYJ.js} +97 -7
  4. package/dist/chunk-57MH6RYJ.js.map +1 -0
  5. package/dist/{chunk-J4SICTAN.js → chunk-66ZGYXTH.js} +53 -9
  6. package/dist/chunk-66ZGYXTH.js.map +1 -0
  7. package/dist/{chunk-TGP6WK65.js → chunk-DP43WJY4.js} +75 -11
  8. package/dist/chunk-DP43WJY4.js.map +1 -0
  9. package/dist/{chunk-QO2MWMAZ.js → chunk-DTNM3LTO.js} +33 -10
  10. package/dist/chunk-DTNM3LTO.js.map +1 -0
  11. package/dist/{chunk-JWZKV3CL.js → chunk-JGKPSWOA.js} +2 -2
  12. package/dist/{chunk-HSGTZ3CT.js → chunk-KXAG24DD.js} +219 -3
  13. package/dist/chunk-KXAG24DD.js.map +1 -0
  14. package/dist/{chunk-J3PJBZXJ.js → chunk-M37YZYDO.js} +3 -3
  15. package/dist/{chunk-VPDWTZ3F.js → chunk-M72ZEPWD.js} +2 -2
  16. package/dist/{chunk-BASRRXAM.js → chunk-MTJQOOMG.js} +105 -4
  17. package/dist/chunk-MTJQOOMG.js.map +1 -0
  18. package/dist/{chunk-RQW5W4XC.js → chunk-NB6WKTBL.js} +2 -2
  19. package/dist/chunk-TJMZWRS6.js +265 -0
  20. package/dist/chunk-TJMZWRS6.js.map +1 -0
  21. package/dist/{chunk-VSF2CR3G.js → chunk-TLSIK656.js} +6 -6
  22. package/dist/{chunk-F52S6ZGF.js → chunk-VNKSMWDN.js} +17 -3
  23. package/dist/chunk-VNKSMWDN.js.map +1 -0
  24. package/dist/{chunk-36ECROTC.js → chunk-WHEU55ZP.js} +7 -7
  25. package/dist/{chunk-A4M6AKIK.js → chunk-X7XBF5TS.js} +6 -6
  26. package/dist/cli.js +91 -29
  27. package/dist/cli.js.map +1 -1
  28. package/dist/{config-R7O5YC65.js → config-J5DW6HSY.js} +3 -3
  29. package/dist/execute-Z2DKGVLR.js +14 -0
  30. package/dist/{generate-TGJQLGMV.js → generate-ERDPOTKX.js} +6 -6
  31. package/dist/init-UY6ZCIFT.js +9 -0
  32. package/dist/plan-REMAMCX4.js +16 -0
  33. package/dist/pr-KERP52TI.js +20 -0
  34. package/dist/pr-review-IU4N3TEK.js +16 -0
  35. package/dist/prd-SHVQW6RM.js +16 -0
  36. package/dist/review-L5F77QAC.js +14 -0
  37. package/dist/run-BRBFHVUO.js +1860 -0
  38. package/dist/run-BRBFHVUO.js.map +1 -0
  39. package/dist/{web-JVPK2PIF.js → web-WQISLO66.js} +4 -4
  40. package/package.json +1 -1
  41. package/prompts/execute.md +2 -2
  42. package/prompts/plan.md +10 -1
  43. package/prompts/pr.md +9 -4
  44. package/web/CLAUDE.md +22 -8
  45. package/web/public/app.css +178 -0
  46. package/web/public/app.js +336 -17
  47. package/web/public/index.html +24 -0
  48. package/dist/chunk-BASRRXAM.js.map +0 -1
  49. package/dist/chunk-BH7DYADV.js.map +0 -1
  50. package/dist/chunk-F52S6ZGF.js.map +0 -1
  51. package/dist/chunk-HSGTZ3CT.js.map +0 -1
  52. package/dist/chunk-J4SICTAN.js.map +0 -1
  53. package/dist/chunk-MK43N4X3.js +0 -123
  54. package/dist/chunk-MK43N4X3.js.map +0 -1
  55. package/dist/chunk-QO2MWMAZ.js.map +0 -1
  56. package/dist/chunk-TGP6WK65.js.map +0 -1
  57. package/dist/execute-LU3HWA54.js +0 -14
  58. package/dist/init-WP7Z2LFQ.js +0 -9
  59. package/dist/plan-2MWMCKRY.js +0 -16
  60. package/dist/pr-XVQDEQ7B.js +0 -16
  61. package/dist/pr-review-VO6ZXAFM.js +0 -16
  62. package/dist/prd-FPZAHQEO.js +0 -16
  63. package/dist/review-AHALLMXO.js +0 -14
  64. package/dist/run-2CXVR3VZ.js +0 -733
  65. package/dist/run-2CXVR3VZ.js.map +0 -1
  66. /package/dist/{analyze-RBNKXWL5.js.map → analyze-VSWAEVN3.js.map} +0 -0
  67. /package/dist/{chunk-JWZKV3CL.js.map → chunk-JGKPSWOA.js.map} +0 -0
  68. /package/dist/{chunk-J3PJBZXJ.js.map → chunk-M37YZYDO.js.map} +0 -0
  69. /package/dist/{chunk-VPDWTZ3F.js.map → chunk-M72ZEPWD.js.map} +0 -0
  70. /package/dist/{chunk-RQW5W4XC.js.map → chunk-NB6WKTBL.js.map} +0 -0
  71. /package/dist/{chunk-VSF2CR3G.js.map → chunk-TLSIK656.js.map} +0 -0
  72. /package/dist/{chunk-36ECROTC.js.map → chunk-WHEU55ZP.js.map} +0 -0
  73. /package/dist/{chunk-A4M6AKIK.js.map → chunk-X7XBF5TS.js.map} +0 -0
  74. /package/dist/{config-R7O5YC65.js.map → config-J5DW6HSY.js.map} +0 -0
  75. /package/dist/{execute-LU3HWA54.js.map → execute-Z2DKGVLR.js.map} +0 -0
  76. /package/dist/{generate-TGJQLGMV.js.map → generate-ERDPOTKX.js.map} +0 -0
  77. /package/dist/{init-WP7Z2LFQ.js.map → init-UY6ZCIFT.js.map} +0 -0
  78. /package/dist/{plan-2MWMCKRY.js.map → plan-REMAMCX4.js.map} +0 -0
  79. /package/dist/{pr-XVQDEQ7B.js.map → pr-KERP52TI.js.map} +0 -0
  80. /package/dist/{pr-review-VO6ZXAFM.js.map → pr-review-IU4N3TEK.js.map} +0 -0
  81. /package/dist/{prd-FPZAHQEO.js.map → prd-SHVQW6RM.js.map} +0 -0
  82. /package/dist/{review-AHALLMXO.js.map → review-L5F77QAC.js.map} +0 -0
  83. /package/dist/{web-JVPK2PIF.js.map → web-WQISLO66.js.map} +0 -0
@@ -465,6 +465,16 @@ progress {
465
465
  font-weight: 700;
466
466
  }
467
467
 
468
+ /* Story cuja stage é 'executing' (issue 38) — destaque visual na lista de
469
+ "User stories". A borda esquerda usa box-shadow (não border) para não
470
+ deslocar o conteúdo do item ao ganhar/perder a classe. */
471
+ .story-executing {
472
+ background: var(--run-bg);
473
+ border-radius: 6px;
474
+ box-shadow: inset 3px 0 0 0 var(--run);
475
+ padding-left: 9px;
476
+ }
477
+
468
478
  .icon-completed {
469
479
  color: var(--ok);
470
480
  }
@@ -836,4 +846,172 @@ progress {
836
846
  .header-side {
837
847
  align-items: flex-start;
838
848
  }
849
+
850
+ .dashboard-grid {
851
+ grid-template-columns: 1fr;
852
+ }
853
+ }
854
+
855
+ /* ---- Views: dashboard multi-sessão vs detalhe (#35) ---- */
856
+
857
+ .view {
858
+ display: grid;
859
+ gap: 16px;
860
+ min-width: 0;
861
+ }
862
+
863
+ .view[hidden] {
864
+ display: none;
865
+ }
866
+
867
+ .view > * {
868
+ min-width: 0;
869
+ }
870
+
871
+ .back-link {
872
+ appearance: none;
873
+ background: none;
874
+ border: none;
875
+ padding: 0;
876
+ margin: 0 0 6px;
877
+ font: inherit;
878
+ font-size: 0.85rem;
879
+ font-weight: 600;
880
+ color: var(--accent);
881
+ cursor: pointer;
882
+ display: inline-block;
883
+ }
884
+
885
+ .back-link:hover {
886
+ text-decoration: underline;
887
+ }
888
+
889
+ .dashboard-grid {
890
+ display: grid;
891
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
892
+ gap: 16px;
893
+ }
894
+
895
+ .dashboard-card {
896
+ appearance: none;
897
+ text-align: left;
898
+ background: var(--card);
899
+ border: 1px solid var(--border);
900
+ border-radius: 10px;
901
+ padding: 16px;
902
+ box-shadow: var(--shadow);
903
+ color: inherit;
904
+ font: inherit;
905
+ cursor: pointer;
906
+ display: grid;
907
+ gap: 10px;
908
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
909
+ }
910
+
911
+ .dashboard-card:hover,
912
+ .dashboard-card:focus-visible {
913
+ border-color: var(--accent);
914
+ outline: none;
915
+ }
916
+
917
+ .dashboard-card.is-live {
918
+ border-color: var(--run);
919
+ }
920
+
921
+ .dashboard-card-head {
922
+ display: flex;
923
+ justify-content: space-between;
924
+ align-items: center;
925
+ gap: 8px;
926
+ }
927
+
928
+ .dashboard-project {
929
+ font-size: 0.82rem;
930
+ font-weight: 600;
931
+ color: var(--muted);
932
+ overflow-wrap: anywhere;
933
+ }
934
+
935
+ .dashboard-title-row {
936
+ display: flex;
937
+ flex-wrap: wrap;
938
+ align-items: baseline;
939
+ gap: 8px;
940
+ }
941
+
942
+ .dashboard-issue {
943
+ font-weight: 700;
944
+ color: var(--accent);
945
+ font-variant-numeric: tabular-nums;
946
+ }
947
+
948
+ .dashboard-title {
949
+ font-weight: 600;
950
+ overflow-wrap: anywhere;
951
+ }
952
+
953
+ .dashboard-summary {
954
+ margin: 0;
955
+ font-size: 0.9rem;
956
+ overflow: hidden;
957
+ display: -webkit-box;
958
+ -webkit-box-orient: vertical;
959
+ -webkit-line-clamp: 3;
960
+ line-clamp: 3;
961
+ overflow-wrap: anywhere;
962
+ }
963
+
964
+ .dashboard-meta-row {
965
+ display: flex;
966
+ flex-wrap: wrap;
967
+ gap: 10px;
968
+ font-size: 0.82rem;
969
+ color: var(--muted);
970
+ font-variant-numeric: tabular-nums;
971
+ }
972
+
973
+ .dashboard-progress {
974
+ display: block;
975
+ height: 6px;
976
+ background: var(--idle-bg);
977
+ border-radius: 999px;
978
+ overflow: hidden;
979
+ }
980
+
981
+ .dashboard-progress-bar {
982
+ display: block;
983
+ height: 100%;
984
+ background: var(--run);
985
+ border-radius: 999px;
986
+ transition: width 0.3s ease;
987
+ }
988
+
989
+ .dashboard-live-dot {
990
+ font-size: 0.75rem;
991
+ font-weight: 600;
992
+ color: var(--run);
993
+ text-transform: uppercase;
994
+ letter-spacing: 0.04em;
995
+ }
996
+
997
+ .dashboard-live-dot::before {
998
+ content: '';
999
+ display: inline-block;
1000
+ width: 7px;
1001
+ height: 7px;
1002
+ margin-right: 6px;
1003
+ border-radius: 50%;
1004
+ background: var(--run);
1005
+ vertical-align: middle;
1006
+ animation: dashboard-pulse 1.4s ease-in-out infinite;
1007
+ }
1008
+
1009
+ @keyframes dashboard-pulse {
1010
+ 0%,
1011
+ 100% {
1012
+ opacity: 1;
1013
+ }
1014
+ 50% {
1015
+ opacity: 0.35;
1016
+ }
839
1017
  }
package/web/public/app.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // Interface do monitoramento web do issue-flow.
2
2
  // JS puro, sem framework e sem recursos externos — funciona offline.
3
- // Consome apenas GET api/status (ETag/304) e GET api/health; todo texto
4
- // dinâmico entra via textContent (nunca innerHTML com dados do snapshot).
3
+ // Consome GET api/sessions, GET api/status (ETag/304) e GET api/health;
4
+ // todo texto dinâmico entra via textContent (nunca innerHTML com dados
5
+ // do snapshot).
5
6
  (() => {
6
7
  'use strict';
7
8
 
@@ -10,6 +11,7 @@
10
11
  const STORAGE_KEY = 'issue-flow:refresh-seconds';
11
12
  const MAX_BACKOFF_MS = 60000;
12
13
  const ALERT_PREVIEW = 3;
14
+ const DESCRIPTION_PREVIEW = 140;
13
15
 
14
16
  const STATUS_LABELS = {
15
17
  idle: 'aguardando',
@@ -32,6 +34,20 @@
32
34
  done: 'concluída',
33
35
  };
34
36
 
37
+ // Rótulos do `stage` granular (issue 38) — mais fino que STORY_STATUS_LABELS,
38
+ // deriva de eventos reais do pipeline (iteration:start, stories:update,
39
+ // phase:start/phase:end da review, correction:cycle). Ver README, seção
40
+ // "Story stage".
41
+ const STORY_STAGE_LABELS = {
42
+ pending: 'aguardando',
43
+ executing: 'em execução',
44
+ awaiting_review: 'aguardando revisão',
45
+ in_review: 'em revisão',
46
+ in_correction: 'em correção',
47
+ done: 'concluída',
48
+ failed: 'falhou',
49
+ };
50
+
35
51
  // Colunas do Kanban, na ordem em que a execução avança. Os títulos são os das
36
52
  // colunas, não os rótulos dos badges (STORY_STATUS_LABELS), que seguem em minúsculas.
37
53
  const KANBAN_COLUMNS = [
@@ -43,12 +59,18 @@
43
59
 
44
60
  const els = {
45
61
  banner: document.getElementById('banner-disconnected'),
62
+ viewDashboard: document.getElementById('view-dashboard'),
63
+ viewDetail: document.getElementById('view-detail'),
64
+ dashboard: document.getElementById('dashboard'),
65
+ dashboardMeta: document.getElementById('dashboard-meta'),
66
+ backToDashboard: document.getElementById('back-to-dashboard'),
46
67
  issueLink: document.getElementById('issue-link'),
47
68
  branchLine: document.getElementById('branch-line'),
48
69
  statusBadge: document.getElementById('status-badge'),
49
70
  elapsed: document.getElementById('elapsed'),
50
71
  estimate: document.getElementById('estimate'),
51
72
  refreshSelect: document.getElementById('refresh-select'),
73
+ refreshSelectDashboard: document.getElementById('refresh-select-dashboard'),
52
74
  alerts: document.getElementById('alerts'),
53
75
  alertsBody: document.getElementById('alerts-body'),
54
76
  issueSummary: document.getElementById('issue-summary'),
@@ -86,6 +108,16 @@
86
108
  // Só o id: o card que abriu o drawer é destruído no próximo render, então
87
109
  // guardar o nó levaria a uma referência morta.
88
110
  selectedStoryId: null,
111
+ // Multi-sessão (#35): lista de /api/sessions e seleção explícita do usuário.
112
+ // selectedSessionId null = modo automático (1 sessão → detalhe; 2+ → dashboard).
113
+ sessions: [],
114
+ selectedSessionId: null,
115
+ // sessionId cujo snapshot/detalhe está na tela (evita flash ao trocar).
116
+ detailSessionId: null,
117
+ viewMode: 'detail', // 'detail' | 'dashboard'
118
+ statusUrl: 'api/status',
119
+ // Se selectSession/clearSessionSelection chega durante um poll, reexecuta.
120
+ pollAgain: false,
89
121
  };
90
122
 
91
123
  // ---- Utilitários ----------------------------------------------------------
@@ -226,9 +258,14 @@
226
258
  // que nenhum consumidor precise repetir a checagem de ausência.
227
259
  function normalizeStory(story) {
228
260
  const status = story.status !== null && story.status !== undefined ? story.status : 'backlog';
261
+ const stage = story.stage !== null && story.stage !== undefined ? story.stage : 'pending';
229
262
  return {
230
263
  ...story,
231
264
  status: STORY_STATUS_LABELS[status] !== undefined ? status : 'backlog',
265
+ stage: STORY_STAGE_LABELS[stage] !== undefined ? stage : 'pending',
266
+ stageSince: story.stageSince !== null && story.stageSince !== undefined ? story.stageSince : null,
267
+ stageDetail:
268
+ story.stageDetail !== null && story.stageDetail !== undefined ? story.stageDetail : null,
232
269
  dependencies: list(story.dependencies),
233
270
  description: text(story.description),
234
271
  acceptanceCriteria: list(story.acceptanceCriteria),
@@ -286,22 +323,28 @@
286
323
  }
287
324
  }
288
325
 
289
- function buildRefreshSelect() {
326
+ function fillRefreshSelect(select) {
327
+ if (!select) return;
290
328
  const values = REFRESH_OPTIONS.slice();
291
329
  if (state.refreshSeconds !== PAUSED && values.indexOf(state.refreshSeconds) === -1) {
292
330
  values.push(state.refreshSeconds);
293
331
  values.sort((a, b) => a - b);
294
332
  }
295
- clear(els.refreshSelect);
333
+ clear(select);
296
334
  for (const value of values) {
297
335
  const option = el('option', null, value + 's');
298
336
  option.value = String(value);
299
- els.refreshSelect.appendChild(option);
337
+ select.appendChild(option);
300
338
  }
301
339
  const pause = el('option', null, 'pausar');
302
340
  pause.value = String(PAUSED);
303
- els.refreshSelect.appendChild(pause);
304
- els.refreshSelect.value = String(state.refreshSeconds);
341
+ select.appendChild(pause);
342
+ select.value = String(state.refreshSeconds);
343
+ }
344
+
345
+ function buildRefreshSelect() {
346
+ fillRefreshSelect(els.refreshSelect);
347
+ fillRefreshSelect(els.refreshSelectDashboard);
305
348
  }
306
349
 
307
350
  function clearTimer() {
@@ -319,19 +362,161 @@
319
362
  state.timer = window.setTimeout(poll, Math.min(backoff, MAX_BACKOFF_MS));
320
363
  }
321
364
 
365
+ // Decide dashboard vs detalhe a partir de /api/sessions. selectedSessionId
366
+ // null = automático; string = escolha explícita do usuário (card).
367
+ function resolveView(sessions) {
368
+ state.sessions = sessions;
369
+
370
+ if (state.selectedSessionId !== null) {
371
+ const stillThere = sessions.some((s) => s.sessionId === state.selectedSessionId);
372
+ if (!stillThere) {
373
+ state.selectedSessionId = null;
374
+ state.etag = null;
375
+ } else if (sessions.length === 1) {
376
+ // Seleção explícita deixa de fazer sentido com uma única sessão.
377
+ state.selectedSessionId = null;
378
+ }
379
+ }
380
+
381
+ if (state.selectedSessionId !== null) {
382
+ const selected = sessions.find((s) => s.sessionId === state.selectedSessionId);
383
+ return { mode: 'detail', session: selected || null };
384
+ }
385
+
386
+ if (sessions.length === 0) {
387
+ return { mode: 'detail', session: null };
388
+ }
389
+
390
+ if (sessions.length === 1) {
391
+ return { mode: 'detail', session: sessions[0] };
392
+ }
393
+
394
+ return { mode: 'dashboard', session: null };
395
+ }
396
+
397
+ function setViewMode(mode) {
398
+ state.viewMode = mode;
399
+ els.viewDashboard.hidden = mode !== 'dashboard';
400
+ els.viewDetail.hidden = mode !== 'detail';
401
+ // Back só quando o usuário escolheu um card e ainda há dashboard para voltar.
402
+ const showBack =
403
+ mode === 'detail' && state.selectedSessionId !== null && state.sessions.length >= 2;
404
+ els.backToDashboard.hidden = !showBack;
405
+ if (mode === 'dashboard' && state.selectedStoryId !== null) {
406
+ closeDrawer();
407
+ }
408
+ }
409
+
410
+ function statusUrlFor(session) {
411
+ if (!session || !session.statusUrl) return 'api/status';
412
+ // statusUrl vem com barra inicial (/api/status?session=…); o fetch relativo
413
+ // ao path do painel precisa da forma sem a barra absoluta do host.
414
+ return session.statusUrl.replace(/^\//, '');
415
+ }
416
+
417
+ function clearDetailState() {
418
+ state.etag = null;
419
+ state.snapshot = null;
420
+ state.detailSessionId = null;
421
+ }
422
+
423
+ function requestPoll() {
424
+ clearTimer();
425
+ if (state.polling) {
426
+ state.pollAgain = true;
427
+ return;
428
+ }
429
+ poll();
430
+ }
431
+
432
+ function selectSession(sessionId) {
433
+ if (state.selectedSessionId !== sessionId) {
434
+ clearDetailState();
435
+ }
436
+ state.selectedSessionId = sessionId;
437
+ requestPoll();
438
+ }
439
+
440
+ function clearSessionSelection() {
441
+ state.selectedSessionId = null;
442
+ clearDetailState();
443
+ requestPoll();
444
+ }
445
+
322
446
  async function poll() {
323
- if (state.polling) return;
447
+ if (state.polling) {
448
+ state.pollAgain = true;
449
+ return;
450
+ }
324
451
  state.polling = true;
452
+ state.pollAgain = false;
325
453
  try {
454
+ const sessionsRes = await fetch('api/sessions', { cache: 'no-store' });
455
+ if (!sessionsRes.ok) throw new Error('HTTP ' + sessionsRes.status);
456
+ const sessions = await sessionsRes.json();
457
+ if (!Array.isArray(sessions)) throw new Error('sessions payload invalid');
458
+
459
+ const resolved = resolveView(sessions);
460
+
461
+ if (resolved.mode === 'dashboard') {
462
+ clearDetailState();
463
+ setViewMode('dashboard');
464
+ renderDashboard(sessions);
465
+ state.failures = 0;
466
+ els.banner.hidden = true;
467
+ return;
468
+ }
469
+
470
+ const nextId =
471
+ resolved.session && resolved.session.sessionId != null
472
+ ? resolved.session.sessionId
473
+ : null;
474
+ const sessionChanged = state.detailSessionId !== nextId;
475
+ if (sessionChanged) {
476
+ clearDetailState();
477
+ state.detailSessionId = nextId;
478
+ }
479
+
480
+ if (!resolved.session) {
481
+ setViewMode('detail');
482
+ // Zero sessões: mantém a última tela de detalhe se houver, sem explodir.
483
+ renderEmptyDetail();
484
+ state.failures = 0;
485
+ els.banner.hidden = true;
486
+ return;
487
+ }
488
+
489
+ const nextUrl = statusUrlFor(resolved.session);
490
+ if (nextUrl !== state.statusUrl) {
491
+ state.statusUrl = nextUrl;
492
+ state.etag = null;
493
+ }
494
+
495
+ // Evita flash da sessão anterior enquanto o status novo carrega.
496
+ if (sessionChanged || !state.snapshot) {
497
+ els.viewDashboard.hidden = true;
498
+ els.viewDetail.hidden = true;
499
+ } else {
500
+ setViewMode('detail');
501
+ }
502
+
326
503
  const headers = {};
327
504
  if (state.etag) headers['If-None-Match'] = state.etag;
328
- const res = await fetch('api/status', { headers, cache: 'no-store' });
505
+ const res = await fetch(state.statusUrl, { headers, cache: 'no-store' });
506
+ // Se selectSession/clear pediu outro poll no meio do fetch, descarta este
507
+ // status — o ciclo seguinte aplica a seleção atual.
508
+ if (state.pollAgain) {
509
+ state.failures = 0;
510
+ els.banner.hidden = true;
511
+ return;
512
+ }
329
513
  if (res.status !== 304) {
330
514
  if (!res.ok) throw new Error('HTTP ' + res.status);
331
515
  state.etag = res.headers.get('ETag');
332
516
  state.snapshot = await res.json();
333
517
  render();
334
518
  }
519
+ setViewMode('detail');
335
520
  state.failures = 0;
336
521
  els.banner.hidden = true;
337
522
  } catch (err) {
@@ -339,7 +524,124 @@
339
524
  els.banner.hidden = false;
340
525
  } finally {
341
526
  state.polling = false;
342
- schedule();
527
+ if (state.pollAgain) {
528
+ state.pollAgain = false;
529
+ poll();
530
+ } else {
531
+ schedule();
532
+ }
533
+ }
534
+ }
535
+
536
+ function renderEmptyDetail() {
537
+ if (state.snapshot) return;
538
+ document.title = 'issue-flow';
539
+ els.statusBadge.textContent = STATUS_LABELS.idle;
540
+ els.statusBadge.className = 'badge status-idle';
541
+ els.elapsed.textContent = '—';
542
+ els.estimate.hidden = true;
543
+ els.sessionMeta.textContent = 'nenhuma execução ativa';
544
+ }
545
+
546
+ function truncateText(text, max) {
547
+ if (!text) return '';
548
+ const normalized = String(text).replace(/\s+/g, ' ').trim();
549
+ if (normalized.length <= max) return normalized;
550
+ return normalized.slice(0, max - 1).trimEnd() + '…';
551
+ }
552
+
553
+ function renderDashboard(sessions) {
554
+ const active = document.activeElement;
555
+ const focusedId =
556
+ active &&
557
+ active.dataset &&
558
+ els.dashboard.contains(active) &&
559
+ active.dataset.sessionId
560
+ ? active.dataset.sessionId
561
+ : null;
562
+
563
+ clear(els.dashboard);
564
+
565
+ if (sessions.length === 0) {
566
+ els.dashboard.appendChild(el('p', 'empty', 'Nenhuma execução ativa.'));
567
+ els.dashboardMeta.textContent = '0 execuções';
568
+ document.title = 'issue-flow';
569
+ return;
570
+ }
571
+
572
+ for (const session of sessions) {
573
+ // <button> só aceita phrasing content — mesmos spans do Kanban.
574
+ const card = el('button', 'dashboard-card');
575
+ card.type = 'button';
576
+ if (session.sessionId) card.dataset.sessionId = session.sessionId;
577
+ if (session.status === 'running') card.classList.add('is-live');
578
+
579
+ const head = el('span', 'dashboard-card-head');
580
+ const project = el(
581
+ 'span',
582
+ 'dashboard-project',
583
+ session.repositoryName || 'Projeto desconhecido',
584
+ );
585
+ const badge = el('span', 'badge status-' + (session.status || 'idle'));
586
+ badge.textContent = STATUS_LABELS[session.status] || session.status || '—';
587
+ head.appendChild(project);
588
+ head.appendChild(badge);
589
+ card.appendChild(head);
590
+
591
+ const titleRow = el('span', 'dashboard-title-row');
592
+ if (session.issueNumber !== null && session.issueNumber !== undefined) {
593
+ titleRow.appendChild(el('span', 'dashboard-issue', '#' + session.issueNumber));
594
+ }
595
+ titleRow.appendChild(
596
+ el('span', 'dashboard-title', session.issueTitle || 'Sem título'),
597
+ );
598
+ card.appendChild(titleRow);
599
+
600
+ const summary = truncateText(session.issueDescription, DESCRIPTION_PREVIEW);
601
+ card.appendChild(el('span', 'dashboard-summary muted', summary || 'Sem descrição'));
602
+
603
+ const meta = el('span', 'dashboard-meta-row');
604
+ meta.appendChild(
605
+ el('span', null, 'Fase: ' + (session.currentPhase || '—')),
606
+ );
607
+ const percent =
608
+ typeof session.progressPercent === 'number' ? session.progressPercent : 0;
609
+ meta.appendChild(el('span', null, percent + '%'));
610
+ const elapsed =
611
+ typeof session.elapsedSeconds === 'number'
612
+ ? formatDuration(session.elapsedSeconds)
613
+ : session.startedAt
614
+ ? formatAgo(session.startedAt)
615
+ : '—';
616
+ meta.appendChild(el('span', null, elapsed));
617
+ card.appendChild(meta);
618
+
619
+ const progress = el('span', 'dashboard-progress');
620
+ const bar = el('span', 'dashboard-progress-bar');
621
+ bar.style.width = Math.max(0, Math.min(100, percent)) + '%';
622
+ progress.appendChild(bar);
623
+ card.appendChild(progress);
624
+
625
+ if (session.status === 'running') {
626
+ card.appendChild(el('span', 'dashboard-live-dot', 'ao vivo'));
627
+ }
628
+
629
+ card.addEventListener('click', () => {
630
+ if (session.sessionId) selectSession(session.sessionId);
631
+ });
632
+
633
+ els.dashboard.appendChild(card);
634
+ }
635
+
636
+ els.dashboardMeta.textContent =
637
+ sessions.length + (sessions.length === 1 ? ' execução' : ' execuções');
638
+ document.title = sessions.length + ' execuções · issue-flow';
639
+
640
+ if (focusedId) {
641
+ const card = els.dashboard.querySelector(
642
+ '[data-session-id="' + focusedId.replace(/"/g, '') + '"]',
643
+ );
644
+ if (card) card.focus();
343
645
  }
344
646
  }
345
647
 
@@ -588,7 +890,10 @@
588
890
  return;
589
891
  }
590
892
  for (const story of snapshot.stories) {
591
- const item = el('li');
893
+ // stage vem com default no schema ('pending'), mas session.json gravado
894
+ // antes da issue 38 pode chegar sem ele.
895
+ const storyStage = story.stage !== null && story.stage !== undefined ? story.stage : 'pending';
896
+ const item = el('li', storyStage === 'executing' ? 'story-executing' : null);
592
897
  const status = story.passes ? 'completed' : 'pending';
593
898
  item.appendChild(el('span', 'item-icon icon-' + status, story.passes ? '✓' : '○'));
594
899
  const main = el('div', 'item-main');
@@ -612,7 +917,12 @@
612
917
 
613
918
  item.appendChild(main);
614
919
  const duration = metric(story.durationSeconds);
920
+ const stageLabel = STORY_STAGE_LABELS[storyStage] || storyStage;
921
+ const stageText = story.stageSince
922
+ ? stageLabel + ' ' + formatAgo(story.stageSince)
923
+ : stageLabel;
615
924
  const side = itemSideText([
925
+ stageText,
616
926
  story.completedAt ? 'concluída ' + formatClock(story.completedAt) : '',
617
927
  duration !== null ? formatDuration(duration) : '',
618
928
  formatUsage(story),
@@ -862,6 +1172,14 @@
862
1172
 
863
1173
  // ---- Inicialização --------------------------------------------------------
864
1174
 
1175
+ function onRefreshChange(select) {
1176
+ state.refreshSeconds = Number(select.value);
1177
+ storeRefresh(state.refreshSeconds);
1178
+ buildRefreshSelect();
1179
+ clearTimer();
1180
+ if (state.refreshSeconds !== PAUSED) poll();
1181
+ }
1182
+
865
1183
  async function init() {
866
1184
  for (const tab of els.tabs) {
867
1185
  tab.addEventListener('click', () => setActiveTab(tab.id));
@@ -870,13 +1188,14 @@
870
1188
 
871
1189
  els.drawerClose.addEventListener('click', closeDrawer);
872
1190
  els.drawerOverlay.addEventListener('click', closeDrawer);
1191
+ els.backToDashboard.addEventListener('click', clearSessionSelection);
873
1192
 
874
- els.refreshSelect.addEventListener('change', () => {
875
- state.refreshSeconds = Number(els.refreshSelect.value);
876
- storeRefresh(state.refreshSeconds);
877
- clearTimer();
878
- if (state.refreshSeconds !== PAUSED) poll();
879
- });
1193
+ els.refreshSelect.addEventListener('change', () => onRefreshChange(els.refreshSelect));
1194
+ if (els.refreshSelectDashboard) {
1195
+ els.refreshSelectDashboard.addEventListener('change', () =>
1196
+ onRefreshChange(els.refreshSelectDashboard),
1197
+ );
1198
+ }
880
1199
 
881
1200
  els.logFilter.addEventListener('change', () => {
882
1201
  state.logFilter = els.logFilter.value;
@@ -15,8 +15,31 @@
15
15
  </div>
16
16
 
17
17
  <main>
18
+ <div id="view-dashboard" class="view" hidden>
19
+ <header class="card header">
20
+ <div class="header-main">
21
+ <h1><span class="brand">issue-flow</span></h1>
22
+ <p class="muted">Execuções ativas</p>
23
+ </div>
24
+ <div class="header-side">
25
+ <label class="refresh-control">
26
+ Atualizar
27
+ <select id="refresh-select-dashboard" aria-label="Intervalo de atualização"></select>
28
+ </label>
29
+ </div>
30
+ </header>
31
+ <div id="dashboard" class="dashboard-grid"></div>
32
+ <footer class="muted footer">
33
+ <span id="dashboard-meta" aria-live="polite"></span>
34
+ </footer>
35
+ </div>
36
+
37
+ <div id="view-detail" class="view">
18
38
  <header class="card header">
19
39
  <div class="header-main">
40
+ <button type="button" id="back-to-dashboard" class="back-link" hidden>
41
+ ← Todas as execuções
42
+ </button>
20
43
  <h1>
21
44
  <span class="brand">issue-flow</span>
22
45
  <a id="issue-link" class="issue-link" target="_blank" rel="noopener" hidden>#—</a>
@@ -128,6 +151,7 @@
128
151
  <footer class="muted footer">
129
152
  <span id="session-meta"></span>
130
153
  </footer>
154
+ </div>
131
155
  </main>
132
156
 
133
157
  <div id="drawer-overlay" class="drawer-overlay" hidden></div>