issue-flow 0.7.7 → 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-2RVGY5OK.js → analyze-VSWAEVN3.js} +6 -6
  3. package/dist/{chunk-WO6ERABE.js → chunk-57MH6RYJ.js} +106 -7
  4. package/dist/chunk-57MH6RYJ.js.map +1 -0
  5. package/dist/{chunk-6UTVPKNF.js → chunk-66ZGYXTH.js} +53 -9
  6. package/dist/chunk-66ZGYXTH.js.map +1 -0
  7. package/dist/{chunk-WGDLUPSF.js → chunk-DP43WJY4.js} +75 -11
  8. package/dist/chunk-DP43WJY4.js.map +1 -0
  9. package/dist/{chunk-ZYBYMEWG.js → chunk-DTNM3LTO.js} +33 -10
  10. package/dist/chunk-DTNM3LTO.js.map +1 -0
  11. package/dist/{chunk-KHTLN54U.js → chunk-JGKPSWOA.js} +2 -2
  12. package/dist/{chunk-EYVXLRRD.js → chunk-KXAG24DD.js} +219 -3
  13. package/dist/chunk-KXAG24DD.js.map +1 -0
  14. package/dist/{chunk-XPW46TI5.js → chunk-M37YZYDO.js} +3 -3
  15. package/dist/{chunk-G37JNQKB.js → chunk-M72ZEPWD.js} +2 -2
  16. package/dist/{chunk-RUTDK6ZF.js → chunk-MTJQOOMG.js} +105 -4
  17. package/dist/chunk-MTJQOOMG.js.map +1 -0
  18. package/dist/{chunk-OPIDR2DC.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-6IOPYFUM.js → chunk-TLSIK656.js} +6 -6
  22. package/dist/{chunk-JOK7VXRV.js → chunk-VNKSMWDN.js} +17 -3
  23. package/dist/chunk-VNKSMWDN.js.map +1 -0
  24. package/dist/{chunk-6L2BPOE3.js → chunk-WHEU55ZP.js} +7 -7
  25. package/dist/{chunk-EOTULFWV.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-OAZG7KIK.js → config-J5DW6HSY.js} +3 -3
  29. package/dist/execute-Z2DKGVLR.js +14 -0
  30. package/dist/{generate-BEJF3SVJ.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-3SBYL7QJ.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 +70 -8
  45. package/web/public/app.css +444 -0
  46. package/web/public/app.js +573 -17
  47. package/web/public/index.html +50 -0
  48. package/dist/chunk-3E6KASSE.js +0 -123
  49. package/dist/chunk-3E6KASSE.js.map +0 -1
  50. package/dist/chunk-6UTVPKNF.js.map +0 -1
  51. package/dist/chunk-EYVXLRRD.js.map +0 -1
  52. package/dist/chunk-JOK7VXRV.js.map +0 -1
  53. package/dist/chunk-RUTDK6ZF.js.map +0 -1
  54. package/dist/chunk-WGDLUPSF.js.map +0 -1
  55. package/dist/chunk-WO6ERABE.js.map +0 -1
  56. package/dist/chunk-ZYBYMEWG.js.map +0 -1
  57. package/dist/execute-AOEM52CD.js +0 -14
  58. package/dist/init-TBC3WI74.js +0 -9
  59. package/dist/plan-YDZYB2BO.js +0 -16
  60. package/dist/pr-SIXMJIZF.js +0 -16
  61. package/dist/pr-review-AY56ZPJR.js +0 -16
  62. package/dist/prd-MBSSBLGO.js +0 -16
  63. package/dist/review-2MDDHZ4X.js +0 -14
  64. package/dist/run-SEB6F3W4.js +0 -733
  65. package/dist/run-SEB6F3W4.js.map +0 -1
  66. /package/dist/{analyze-2RVGY5OK.js.map → analyze-VSWAEVN3.js.map} +0 -0
  67. /package/dist/{chunk-KHTLN54U.js.map → chunk-JGKPSWOA.js.map} +0 -0
  68. /package/dist/{chunk-XPW46TI5.js.map → chunk-M37YZYDO.js.map} +0 -0
  69. /package/dist/{chunk-G37JNQKB.js.map → chunk-M72ZEPWD.js.map} +0 -0
  70. /package/dist/{chunk-OPIDR2DC.js.map → chunk-NB6WKTBL.js.map} +0 -0
  71. /package/dist/{chunk-6IOPYFUM.js.map → chunk-TLSIK656.js.map} +0 -0
  72. /package/dist/{chunk-6L2BPOE3.js.map → chunk-WHEU55ZP.js.map} +0 -0
  73. /package/dist/{chunk-EOTULFWV.js.map → chunk-X7XBF5TS.js.map} +0 -0
  74. /package/dist/{config-OAZG7KIK.js.map → config-J5DW6HSY.js.map} +0 -0
  75. /package/dist/{execute-AOEM52CD.js.map → execute-Z2DKGVLR.js.map} +0 -0
  76. /package/dist/{generate-BEJF3SVJ.js.map → generate-ERDPOTKX.js.map} +0 -0
  77. /package/dist/{init-TBC3WI74.js.map → init-UY6ZCIFT.js.map} +0 -0
  78. /package/dist/{plan-YDZYB2BO.js.map → plan-REMAMCX4.js.map} +0 -0
  79. /package/dist/{pr-SIXMJIZF.js.map → pr-KERP52TI.js.map} +0 -0
  80. /package/dist/{pr-review-AY56ZPJR.js.map → pr-review-IU4N3TEK.js.map} +0 -0
  81. /package/dist/{prd-MBSSBLGO.js.map → prd-SHVQW6RM.js.map} +0 -0
  82. /package/dist/{review-2MDDHZ4X.js.map → review-L5F77QAC.js.map} +0 -0
  83. /package/dist/{web-3SBYL7QJ.js.map → web-WQISLO66.js.map} +0 -0
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,14 +34,43 @@
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
+
51
+ // Colunas do Kanban, na ordem em que a execução avança. Os títulos são os das
52
+ // colunas, não os rótulos dos badges (STORY_STATUS_LABELS), que seguem em minúsculas.
53
+ const KANBAN_COLUMNS = [
54
+ { status: 'backlog', title: 'Backlog' },
55
+ { status: 'in_progress', title: 'Em andamento' },
56
+ { status: 'in_review', title: 'Em revisão' },
57
+ { status: 'done', title: 'Concluído' },
58
+ ];
59
+
35
60
  const els = {
36
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'),
37
67
  issueLink: document.getElementById('issue-link'),
38
68
  branchLine: document.getElementById('branch-line'),
39
69
  statusBadge: document.getElementById('status-badge'),
40
70
  elapsed: document.getElementById('elapsed'),
41
71
  estimate: document.getElementById('estimate'),
42
72
  refreshSelect: document.getElementById('refresh-select'),
73
+ refreshSelectDashboard: document.getElementById('refresh-select-dashboard'),
43
74
  alerts: document.getElementById('alerts'),
44
75
  alertsBody: document.getElementById('alerts-body'),
45
76
  issueSummary: document.getElementById('issue-summary'),
@@ -56,6 +87,13 @@
56
87
  logFilter: document.getElementById('log-filter'),
57
88
  logs: document.getElementById('logs'),
58
89
  sessionMeta: document.getElementById('session-meta'),
90
+ tabs: Array.prototype.slice.call(document.querySelectorAll('[role="tab"]')),
91
+ kanban: document.getElementById('kanban'),
92
+ drawer: document.getElementById('drawer'),
93
+ drawerOverlay: document.getElementById('drawer-overlay'),
94
+ drawerClose: document.getElementById('drawer-close'),
95
+ drawerTitle: document.getElementById('drawer-title'),
96
+ drawerBody: document.getElementById('drawer-body'),
59
97
  };
60
98
 
61
99
  const state = {
@@ -66,6 +104,20 @@
66
104
  timer: null,
67
105
  polling: false,
68
106
  logFilter: 'all',
107
+ activeTab: 'tab-execution',
108
+ // Só o id: o card que abriu o drawer é destruído no próximo render, então
109
+ // guardar o nó levaria a uma referência morta.
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,
69
121
  };
70
122
 
71
123
  // ---- Utilitários ----------------------------------------------------------
@@ -189,6 +241,66 @@
189
241
  return match ? match[1] : null;
190
242
  }
191
243
 
244
+ // ---- Camada de leitura das stories ----------------------------------------
245
+ // Único ponto de acesso a uma story individual: o Kanban e o drawer nunca
246
+ // varrem snapshot.stories por conta própria. Quando a escrita existir, é aqui
247
+ // que a leitura passa a conversar com ela, sem tocar na UI.
248
+
249
+ function text(value) {
250
+ return typeof value === 'string' ? value : '';
251
+ }
252
+
253
+ function list(value) {
254
+ return Array.isArray(value) ? value : [];
255
+ }
256
+
257
+ // Normaliza os campos que um session.json anterior pode não ter gravado, para
258
+ // que nenhum consumidor precise repetir a checagem de ausência.
259
+ function normalizeStory(story) {
260
+ const status = story.status !== null && story.status !== undefined ? story.status : 'backlog';
261
+ const stage = story.stage !== null && story.stage !== undefined ? story.stage : 'pending';
262
+ return {
263
+ ...story,
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,
269
+ dependencies: list(story.dependencies),
270
+ description: text(story.description),
271
+ acceptanceCriteria: list(story.acceptanceCriteria),
272
+ };
273
+ }
274
+
275
+ function getStoryById(snapshot, id) {
276
+ if (!snapshot) return null;
277
+ const stories = list(snapshot.stories);
278
+ for (const story of stories) {
279
+ if (story.id === id) return normalizeStory(story);
280
+ }
281
+ return null;
282
+ }
283
+
284
+ function getStories(snapshot) {
285
+ if (!snapshot) return [];
286
+ return list(snapshot.stories).map(normalizeStory);
287
+ }
288
+
289
+ // ---- Abas -----------------------------------------------------------------
290
+ // Troca apenas visibilidade e estado ARIA. Não toca no polling: render()
291
+ // segue atualizando os dois painéis, então a aba inativa nunca fica defasada.
292
+
293
+ function setActiveTab(tabId) {
294
+ state.activeTab = tabId;
295
+ for (const tab of els.tabs) {
296
+ const active = tab.id === tabId;
297
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
298
+ tab.classList.toggle('is-active', active);
299
+ const panel = document.getElementById(tab.getAttribute('aria-controls'));
300
+ if (panel) panel.hidden = !active;
301
+ }
302
+ }
303
+
192
304
  // ---- Polling: intervalo configurável, aba oculta e backoff ----------------
193
305
 
194
306
  function readStoredRefresh() {
@@ -211,22 +323,28 @@
211
323
  }
212
324
  }
213
325
 
214
- function buildRefreshSelect() {
326
+ function fillRefreshSelect(select) {
327
+ if (!select) return;
215
328
  const values = REFRESH_OPTIONS.slice();
216
329
  if (state.refreshSeconds !== PAUSED && values.indexOf(state.refreshSeconds) === -1) {
217
330
  values.push(state.refreshSeconds);
218
331
  values.sort((a, b) => a - b);
219
332
  }
220
- clear(els.refreshSelect);
333
+ clear(select);
221
334
  for (const value of values) {
222
335
  const option = el('option', null, value + 's');
223
336
  option.value = String(value);
224
- els.refreshSelect.appendChild(option);
337
+ select.appendChild(option);
225
338
  }
226
339
  const pause = el('option', null, 'pausar');
227
340
  pause.value = String(PAUSED);
228
- els.refreshSelect.appendChild(pause);
229
- 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);
230
348
  }
231
349
 
232
350
  function clearTimer() {
@@ -244,19 +362,161 @@
244
362
  state.timer = window.setTimeout(poll, Math.min(backoff, MAX_BACKOFF_MS));
245
363
  }
246
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
+
247
446
  async function poll() {
248
- if (state.polling) return;
447
+ if (state.polling) {
448
+ state.pollAgain = true;
449
+ return;
450
+ }
249
451
  state.polling = true;
452
+ state.pollAgain = false;
250
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
+
251
503
  const headers = {};
252
504
  if (state.etag) headers['If-None-Match'] = state.etag;
253
- 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
+ }
254
513
  if (res.status !== 304) {
255
514
  if (!res.ok) throw new Error('HTTP ' + res.status);
256
515
  state.etag = res.headers.get('ETag');
257
516
  state.snapshot = await res.json();
258
517
  render();
259
518
  }
519
+ setViewMode('detail');
260
520
  state.failures = 0;
261
521
  els.banner.hidden = true;
262
522
  } catch (err) {
@@ -264,7 +524,124 @@
264
524
  els.banner.hidden = false;
265
525
  } finally {
266
526
  state.polling = false;
267
- 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();
268
645
  }
269
646
  }
270
647
 
@@ -282,6 +659,9 @@
282
659
  renderPhases(snapshot);
283
660
  renderNextSteps(snapshot);
284
661
  renderStories(snapshot);
662
+ // Incondicional: a aba inativa não pode ficar defasada até ser aberta.
663
+ renderKanban(snapshot);
664
+ renderDrawer(snapshot);
285
665
  renderGit(snapshot);
286
666
  renderLogs(snapshot);
287
667
  renderMeta(snapshot);
@@ -510,7 +890,10 @@
510
890
  return;
511
891
  }
512
892
  for (const story of snapshot.stories) {
513
- 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);
514
897
  const status = story.passes ? 'completed' : 'pending';
515
898
  item.appendChild(el('span', 'item-icon icon-' + status, story.passes ? '✓' : '○'));
516
899
  const main = el('div', 'item-main');
@@ -534,7 +917,12 @@
534
917
 
535
918
  item.appendChild(main);
536
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;
537
924
  const side = itemSideText([
925
+ stageText,
538
926
  story.completedAt ? 'concluída ' + formatClock(story.completedAt) : '',
539
927
  duration !== null ? formatDuration(duration) : '',
540
928
  formatUsage(story),
@@ -544,6 +932,157 @@
544
932
  }
545
933
  }
546
934
 
935
+ // As stories chegam já normalizadas por getStories(), então aqui nenhum campo
936
+ // precisa de checagem de ausência.
937
+ // <button> em vez de <div role="button">: acionamento por Enter/Espaço e foco
938
+ // saem de graça. Por isso todo o conteúdo é <span> — <p>/<div> não são
939
+ // conteúdo válido dentro de um botão.
940
+ function storyCard(story) {
941
+ const card = el('button', 'kanban-card');
942
+ card.type = 'button';
943
+ card.dataset.storyId = story.id;
944
+
945
+ const head = el('span', 'kanban-card-head');
946
+ head.appendChild(
947
+ el('span', 'item-icon icon-' + (story.passes ? 'completed' : 'pending'), story.passes ? '✓' : '○'),
948
+ );
949
+ head.appendChild(el('span', 'story-id', story.id));
950
+ card.appendChild(head);
951
+
952
+ card.appendChild(el('span', 'kanban-card-title', story.title));
953
+ if (story.description) card.appendChild(el('span', 'kanban-card-desc', story.description));
954
+ card.appendChild(
955
+ el('span', 'badge story-status-' + story.status, STORY_STATUS_LABELS[story.status]),
956
+ );
957
+ card.addEventListener('click', () => openDrawer(story.id));
958
+ return card;
959
+ }
960
+
961
+ function renderKanban(snapshot) {
962
+ clear(els.kanban);
963
+ const stories = getStories(snapshot);
964
+ for (const column of KANBAN_COLUMNS) {
965
+ const entries = stories.filter((story) => story.status === column.status);
966
+
967
+ const node = el('section', 'kanban-column');
968
+ const head = el('div', 'kanban-column-head');
969
+ head.appendChild(el('h3', 'kanban-column-title', column.title));
970
+ head.appendChild(el('span', 'kanban-column-count', String(entries.length)));
971
+ node.appendChild(head);
972
+
973
+ if (entries.length === 0) {
974
+ node.appendChild(el('p', 'empty kanban-empty', 'Nenhuma story.'));
975
+ } else {
976
+ for (const story of entries) node.appendChild(storyCard(story));
977
+ }
978
+ els.kanban.appendChild(node);
979
+ }
980
+ }
981
+
982
+ // ---- Drawer de detalhes da story ------------------------------------------
983
+
984
+ function onDrawerKeydown(event) {
985
+ if (event.key === 'Escape') closeDrawer();
986
+ }
987
+
988
+ // Reidrata a partir do id a cada render: o drawer aberto sobrevive ao poll.
989
+ function renderDrawer(snapshot) {
990
+ if (state.selectedStoryId === null) return;
991
+ const story = getStoryById(snapshot, state.selectedStoryId);
992
+ // A story saiu do plano: manter o drawer aberto exibiria dados obsoletos.
993
+ if (story === null) {
994
+ closeDrawer();
995
+ return;
996
+ }
997
+ els.drawerTitle.textContent = story.id + ' · ' + story.title;
998
+
999
+ clear(els.drawerBody);
1000
+ els.drawerBody.appendChild(
1001
+ el('span', 'badge story-status-' + story.status, STORY_STATUS_LABELS[story.status]),
1002
+ );
1003
+
1004
+ const timing = itemSideText([
1005
+ story.completedAt ? 'concluída ' + formatClock(story.completedAt) : '',
1006
+ metric(story.durationSeconds) !== null ? formatDuration(story.durationSeconds) : '',
1007
+ ]);
1008
+ if (timing) els.drawerBody.appendChild(el('p', 'muted drawer-timing', timing));
1009
+
1010
+ drawerSection('Descrição', (body) => {
1011
+ body.appendChild(
1012
+ story.description
1013
+ ? el('p', 'drawer-text', story.description)
1014
+ : el('p', 'empty', 'Sem descrição.'),
1015
+ );
1016
+ });
1017
+
1018
+ drawerSection('Critérios de aceite', (body) => {
1019
+ if (story.acceptanceCriteria.length === 0) {
1020
+ body.appendChild(el('p', 'empty', 'Nenhum critério declarado.'));
1021
+ return;
1022
+ }
1023
+ const items = el('ul', 'drawer-list');
1024
+ for (const criterion of story.acceptanceCriteria) items.appendChild(el('li', null, criterion));
1025
+ body.appendChild(items);
1026
+ });
1027
+
1028
+ drawerSection('Dependências', (body) => {
1029
+ if (story.dependencies.length === 0) {
1030
+ body.appendChild(el('p', 'empty', 'Nenhuma dependência.'));
1031
+ return;
1032
+ }
1033
+ const row = el('div', 'badge-row');
1034
+ for (const dependency of story.dependencies) {
1035
+ row.appendChild(el('span', 'badge label-badge', dependency));
1036
+ }
1037
+ body.appendChild(row);
1038
+ });
1039
+
1040
+ // Nenhum campo de histórico existe no snapshot hoje; a seção só aparece se
1041
+ // uma versão futura publicar um.
1042
+ const history = list(story.history);
1043
+ if (history.length > 0) {
1044
+ drawerSection('Histórico', (body) => {
1045
+ const entries = el('ol', 'drawer-list');
1046
+ for (const entry of history) {
1047
+ const item = el('li');
1048
+ if (entry.at) item.appendChild(el('span', 'log-time', formatClock(entry.at)));
1049
+ item.appendChild(document.createTextNode(text(entry.message)));
1050
+ entries.appendChild(item);
1051
+ }
1052
+ body.appendChild(entries);
1053
+ });
1054
+ }
1055
+ }
1056
+
1057
+ function drawerSection(title, fill) {
1058
+ const section = el('section', 'drawer-section');
1059
+ section.appendChild(el('h3', 'drawer-section-title', title));
1060
+ fill(section);
1061
+ els.drawerBody.appendChild(section);
1062
+ }
1063
+
1064
+ function openDrawer(id) {
1065
+ state.selectedStoryId = id;
1066
+ els.drawer.hidden = false;
1067
+ els.drawerOverlay.hidden = false;
1068
+ document.addEventListener('keydown', onDrawerKeydown);
1069
+ renderDrawer(state.snapshot);
1070
+ els.drawer.focus();
1071
+ }
1072
+
1073
+ function closeDrawer() {
1074
+ const id = state.selectedStoryId;
1075
+ state.selectedStoryId = null;
1076
+ els.drawer.hidden = true;
1077
+ els.drawerOverlay.hidden = true;
1078
+ document.removeEventListener('keydown', onDrawerKeydown);
1079
+ clear(els.drawerBody);
1080
+ // O card é recriado a cada render, então o foco volta pelo id, não por uma
1081
+ // referência guardada na abertura.
1082
+ const card = id ? els.kanban.querySelector('[data-story-id="' + id + '"]') : null;
1083
+ if (card) card.focus();
1084
+ }
1085
+
547
1086
  function renderGit(snapshot) {
548
1087
  const repo = repoUrl(snapshot);
549
1088
 
@@ -633,13 +1172,30 @@
633
1172
 
634
1173
  // ---- Inicialização --------------------------------------------------------
635
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
+
636
1183
  async function init() {
637
- els.refreshSelect.addEventListener('change', () => {
638
- state.refreshSeconds = Number(els.refreshSelect.value);
639
- storeRefresh(state.refreshSeconds);
640
- clearTimer();
641
- if (state.refreshSeconds !== PAUSED) poll();
642
- });
1184
+ for (const tab of els.tabs) {
1185
+ tab.addEventListener('click', () => setActiveTab(tab.id));
1186
+ }
1187
+ setActiveTab(state.activeTab);
1188
+
1189
+ els.drawerClose.addEventListener('click', closeDrawer);
1190
+ els.drawerOverlay.addEventListener('click', closeDrawer);
1191
+ els.backToDashboard.addEventListener('click', clearSessionSelection);
1192
+
1193
+ els.refreshSelect.addEventListener('change', () => onRefreshChange(els.refreshSelect));
1194
+ if (els.refreshSelectDashboard) {
1195
+ els.refreshSelectDashboard.addEventListener('change', () =>
1196
+ onRefreshChange(els.refreshSelectDashboard),
1197
+ );
1198
+ }
643
1199
 
644
1200
  els.logFilter.addEventListener('change', () => {
645
1201
  state.logFilter = els.logFilter.value;