viberadar 0.3.45 → 0.3.46

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.
@@ -83,68 +83,6 @@
83
83
  #termBtn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
84
84
  #termBtn.term-active { color: var(--accent); border-color: var(--accent); }
85
85
 
86
- #agentBtn {
87
- padding: 5px 12px;
88
- background: var(--bg);
89
- border: 1px solid var(--border);
90
- border-radius: 6px;
91
- color: var(--muted);
92
- font-size: 12px;
93
- cursor: pointer;
94
- display: flex;
95
- align-items: center;
96
- gap: 5px;
97
- transition: background 0.1s, color 0.1s, border-color 0.1s;
98
- white-space: nowrap;
99
- }
100
- #agentBtn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
101
-
102
- .agent-menu {
103
- display: none;
104
- position: absolute;
105
- top: calc(100% + 6px);
106
- right: 0;
107
- min-width: 200px;
108
- background: var(--bg-card);
109
- border: 1px solid var(--border);
110
- border-radius: 8px;
111
- padding: 6px;
112
- z-index: 1000;
113
- box-shadow: 0 8px 24px rgba(0,0,0,0.4);
114
- }
115
- .agent-menu.open { display: block; }
116
- .agent-menu-label {
117
- font-size: 10px;
118
- text-transform: uppercase;
119
- letter-spacing: 0.5px;
120
- color: var(--dim);
121
- padding: 6px 10px 4px;
122
- }
123
- .agent-menu-item {
124
- display: flex;
125
- align-items: center;
126
- justify-content: space-between;
127
- gap: 8px;
128
- width: 100%;
129
- padding: 8px 10px;
130
- background: none;
131
- border: none;
132
- border-radius: 6px;
133
- color: var(--text);
134
- font-size: 13px;
135
- cursor: pointer;
136
- text-align: left;
137
- transition: background 0.1s;
138
- }
139
- .agent-menu-item:hover { background: var(--bg-hover); }
140
- .agent-menu-check { color: var(--green); font-weight: 700; display: none; }
141
- .agent-menu-check.active { display: inline; }
142
- .agent-menu-divider {
143
- height: 1px;
144
- background: var(--border);
145
- margin: 4px 0;
146
- }
147
-
148
86
  /* ── Stats bar ───────────────────────────────────────────────────────────── */
149
87
  .stats-bar {
150
88
  display: flex;
@@ -234,8 +172,7 @@
234
172
  .type-count { margin-left: auto; font-size: 11px; color: var(--dim); }
235
173
 
236
174
  /* ── Content area ────────────────────────────────────────────────────────── */
237
- .content { flex: 1; overflow-y: auto; padding: 18px 20px; transition: padding-bottom 0.25s ease; }
238
- .content.panel-open { padding-bottom: 300px; }
175
+ .content { flex: 1; overflow-y: auto; padding: 18px 20px; }
239
176
 
240
177
  /* ── Feature cards ───────────────────────────────────────────────────────── */
241
178
  .features-grid {
@@ -483,48 +420,56 @@
483
420
  /* ── Test-type cards inside feature detail ───────────────────────────────── */
484
421
  .test-type-grid {
485
422
  display: grid;
486
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
423
+ grid-template-columns: repeat(4, 1fr);
487
424
  gap: 10px;
488
- margin-bottom: 18px;
425
+ margin-bottom: 20px;
489
426
  }
490
427
  .test-type-card {
491
- display: flex;
492
- flex-direction: column;
493
428
  background: var(--bg-card);
494
429
  border: 1px solid var(--border);
495
- border-top: 3px solid transparent;
496
430
  border-radius: 8px;
497
- padding: 12px 14px 10px;
431
+ padding: 14px 16px;
498
432
  cursor: pointer;
499
433
  transition: background 0.15s, border-color 0.15s;
500
- min-width: 0;
434
+ position: relative;
435
+ overflow: hidden;
436
+ }
437
+ .test-type-card:hover { background: var(--bg-hover); border-color: var(--dim); }
438
+ .test-type-card .tt-accent {
439
+ position: absolute; top: 0; left: 0; right: 0; height: 3px;
440
+ }
441
+ .test-type-card .tt-label {
442
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
443
+ color: var(--muted); margin-bottom: 6px; margin-top: 2px;
444
+ }
445
+ .test-type-card .tt-count {
446
+ font-size: 26px; font-weight: 700; line-height: 1;
447
+ margin-bottom: 4px;
448
+ }
449
+ .test-type-card .tt-sub {
450
+ font-size: 11px; color: var(--dim);
451
+ }
452
+ .test-type-card.tt-empty .tt-count { color: var(--dim); }
453
+ .test-type-card.tt-empty { opacity: 0.7; }
454
+ .test-type-card.tt-active {
455
+ background: var(--bg-hover);
456
+ border-width: 2px;
501
457
  }
502
- .test-type-card:hover { background: var(--bg-hover); }
503
- .test-type-card.tt-active { background: var(--bg-hover); border-color: var(--border); }
504
- .test-type-card.tt-empty { opacity: 0.6; }
505
458
  .test-type-card.tt-failed {
506
459
  border-color: var(--red) !important;
507
- background: rgba(248, 81, 73, 0.05);
508
- }
509
- .tt-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
510
- .tt-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); }
511
- .tt-failed-badge { font-size: 10px; color: var(--red); font-weight: 700; }
512
- .tt-count { font-size: 28px; font-weight: 700; line-height: 1; margin-bottom: 2px; }
513
- .tt-sub { font-size: 11px; color: var(--dim); }
514
- .tt-card-actions { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
460
+ border-width: 2px;
461
+ background: rgba(248, 81, 73, 0.06);
462
+ }
515
463
  .tt-run-btn {
516
- width: 100%; padding: 5px 10px;
464
+ display: block; width: 100%; margin-top: 8px;
465
+ padding: 3px 0;
517
466
  background: transparent; border: 1px solid var(--border);
518
- border-radius: 5px; color: var(--muted); font-size: 11px;
467
+ border-radius: 4px; color: var(--muted); font-size: 10px;
519
468
  cursor: pointer; text-align: center;
520
469
  transition: background 0.1s, color 0.1s, border-color 0.1s;
521
470
  }
522
471
  .tt-run-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
523
472
  .tt-run-btn:disabled { opacity: 0.4; cursor: not-allowed; }
524
- .tt-fix-btn { border-color: var(--yellow); color: var(--yellow); font-weight: 600; }
525
- .tt-fix-btn:hover { background: rgba(255,200,0,0.1); color: var(--yellow); border-color: var(--yellow); }
526
- .tt-write-btn { border-color: var(--accent); color: var(--accent); }
527
- .tt-write-btn:hover { background: rgba(88,166,255,0.1); color: var(--accent); border-color: var(--accent); }
528
473
  .file-rows { display: flex; flex-direction: column; gap: 2px; }
529
474
  .file-row {
530
475
  display: flex;
@@ -576,10 +521,6 @@
576
521
  background: rgba(248, 81, 73, 0.07);
577
522
  }
578
523
  .file-row.has-errors:hover { background: rgba(248, 81, 73, 0.12); }
579
- .file-agent-spinner { display: inline-block; width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
580
- .file-agent-spinner.running { border: 2px solid var(--yellow); border-top-color: transparent; animation: spin 0.7s linear infinite; }
581
- .file-agent-spinner.queued { border: 2px solid var(--dim); border-top-color: transparent; animation: spin 1.5s linear infinite; }
582
- @keyframes spin { to { transform: rotate(360deg); } }
583
524
  .file-row-errors {
584
525
  padding: 4px 10px 6px 32px;
585
526
  display: flex; flex-direction: column; gap: 3px;
@@ -661,74 +602,11 @@
661
602
  font-size: 14px; padding: 3px 6px; border-radius: 4px; line-height: 1;
662
603
  }
663
604
  .agent-panel-close:hover { background: var(--border); color: var(--text); }
664
- .agent-panel-copy {
665
- background: none; border: none; color: var(--dim); cursor: pointer;
666
- font-size: 13px; padding: 3px 7px; border-radius: 4px; line-height: 1;
667
- }
668
- .agent-panel-copy:hover { background: var(--border); color: var(--text); }
669
- .agent-panel-copy.copied { color: var(--green); }
670
605
  .agent-panel-cancel {
671
606
  background: none; border: 1px solid var(--yellow); color: var(--yellow);
672
607
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
673
608
  }
674
609
  .agent-panel-cancel:hover { background: var(--yellow); color: #000; }
675
- .agent-queue-badge {
676
- font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
677
- border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
678
- }
679
- /* ── Console Tabs ───────────────────────────────────────────────────────── */
680
- .agent-tabs-bar {
681
- display: flex; align-items: stretch; overflow-x: auto;
682
- background: #0a0e15; border-bottom: 1px solid var(--border);
683
- flex-shrink: 0; min-height: 30px;
684
- scrollbar-width: thin; scrollbar-color: var(--border) transparent;
685
- }
686
- .agent-tabs-bar::-webkit-scrollbar { height: 3px; }
687
- .agent-tabs-bar::-webkit-scrollbar-thumb { background: var(--border); }
688
- .agent-tab {
689
- display: flex; align-items: center; gap: 6px;
690
- padding: 0 8px 0 10px; cursor: pointer;
691
- white-space: nowrap; border-right: 1px solid var(--border);
692
- font-size: 11px; color: var(--muted); background: transparent;
693
- max-width: 200px; flex-shrink: 0; user-select: none;
694
- transition: background 0.1s, color 0.1s;
695
- border-bottom: 2px solid transparent;
696
- }
697
- .agent-tab:hover { background: var(--bg-hover); color: var(--text); }
698
- .agent-tab.active { background: var(--bg-card); color: var(--text); border-bottom-color: var(--accent); }
699
- .agent-tab-title { overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
700
- .agent-tab-close {
701
- background: none; border: none; cursor: pointer; color: var(--dim);
702
- font-size: 13px; padding: 0 2px; line-height: 1; flex-shrink: 0;
703
- border-radius: 3px; margin-left: 2px; opacity: 0;
704
- }
705
- .agent-tab:hover .agent-tab-close { opacity: 1; }
706
- .agent-tab-close:hover { background: var(--border); color: var(--text); }
707
- .tab-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
708
- .tab-dot-running { background: var(--yellow); animation: tab-pulse 1s ease-in-out infinite; }
709
- .tab-dot-ok { background: var(--green); }
710
- .tab-dot-error { background: var(--red); }
711
- .tab-dot-info { background: var(--muted); }
712
- @keyframes tab-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
713
- #termBtn.term-error { color: var(--red); border-color: var(--red); }
714
- .file-row-more-btn {
715
- background: none; border: none; cursor: pointer; font-size: 14px; color: var(--dim);
716
- padding: 0 4px; line-height: 1; opacity: 0; transition: opacity 0.1s;
717
- }
718
- .file-row:hover .file-row-more-btn { opacity: 1; }
719
- .file-row-more-wrap { position: relative; display: inline-block; }
720
- .file-row-more-dropdown {
721
- display: none; position: absolute; right: 0; top: 100%; z-index: 200;
722
- background: var(--card); border: 1px solid var(--border); border-radius: 6px;
723
- min-width: 220px; padding: 4px 0; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
724
- }
725
- .file-row-more-dropdown.open { display: block; }
726
- .file-row-more-item {
727
- display: block; width: 100%; text-align: left; background: none; border: none;
728
- cursor: pointer; padding: 7px 14px; font-size: 12px; color: var(--text);
729
- white-space: nowrap;
730
- }
731
- .file-row-more-item:hover { background: var(--border); }
732
610
  .agent-terminal {
733
611
  flex: 1;
734
612
  overflow-y: auto;
@@ -741,6 +619,93 @@
741
619
  .agent-line.err { color: var(--red); }
742
620
  .agent-line.dim { color: var(--dim); font-size: 10px; }
743
621
 
622
+ /* ── E2E Plan ──────────────────────────────────────────────────────────── */
623
+ .e2e-plan-container { display: flex; flex-direction: column; gap: 12px; }
624
+ .e2e-case {
625
+ background: var(--bg-card); border: 1px solid var(--border);
626
+ border-radius: 8px; padding: 14px 16px;
627
+ transition: border-color 0.15s, background 0.15s;
628
+ }
629
+ .e2e-case.approved { border-color: var(--green); }
630
+ .e2e-case.rejected { border-color: var(--red); opacity: 0.5; }
631
+ .e2e-case.written { border-color: var(--blue); }
632
+ .e2e-case.passed { border-color: var(--green); background: rgba(63,185,80,0.06); }
633
+ .e2e-case.failed { border-color: var(--red); background: rgba(248,81,73,0.06); }
634
+ .e2e-case-header {
635
+ display: flex; align-items: center; justify-content: space-between;
636
+ margin-bottom: 6px; gap: 8px;
637
+ }
638
+ .e2e-case-title { font-size: 13px; font-weight: 600; flex: 1; }
639
+ .e2e-case-id { font-size: 11px; color: var(--dim); font-family: monospace; }
640
+ .e2e-case-status {
641
+ font-size: 11px; font-weight: 600; padding: 2px 8px;
642
+ border-radius: 4px; text-transform: uppercase;
643
+ }
644
+ .e2e-case-desc { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.4; }
645
+ .e2e-case-steps {
646
+ font-size: 12px; color: var(--text); padding-left: 18px;
647
+ margin-bottom: 6px; line-height: 1.6;
648
+ }
649
+ .e2e-case-steps li { margin-bottom: 2px; }
650
+ .e2e-case-expected {
651
+ font-size: 12px; color: var(--muted); padding-left: 18px;
652
+ margin-bottom: 8px; line-height: 1.6;
653
+ }
654
+ .e2e-case-expected li { margin-bottom: 2px; list-style: disc; }
655
+ .e2e-case-actions { display: flex; gap: 6px; margin-top: 8px; }
656
+ .e2e-btn {
657
+ padding: 4px 12px; border-radius: 4px; font-size: 11px;
658
+ cursor: pointer; font-weight: 600; border: 1px solid; background: transparent;
659
+ transition: background 0.1s, color 0.1s;
660
+ }
661
+ .e2e-btn-approve { border-color: var(--green); color: var(--green); }
662
+ .e2e-btn-approve:hover { background: var(--green); color: #000; }
663
+ .e2e-btn-reject { border-color: var(--red); color: var(--red); }
664
+ .e2e-btn-reject:hover { background: var(--red); color: #fff; }
665
+ .e2e-btn-primary {
666
+ border: none; background: var(--blue); color: #000; font-weight: 700;
667
+ }
668
+ .e2e-btn-primary:hover { filter: brightness(1.1); }
669
+ .e2e-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
670
+ .e2e-btn-secondary {
671
+ border-color: var(--border); color: var(--muted);
672
+ }
673
+ .e2e-btn-secondary:hover { background: var(--bg-hover); color: var(--text); }
674
+ .e2e-error-msg {
675
+ font-size: 11px; color: var(--red); margin-top: 6px;
676
+ padding: 6px 10px; background: rgba(248,81,73,0.08); border-radius: 4px;
677
+ font-family: monospace; white-space: pre-wrap; word-break: break-all;
678
+ }
679
+ .e2e-screenshot-strip {
680
+ display: flex; gap: 8px; overflow-x: auto; padding: 8px 0;
681
+ scrollbar-width: thin;
682
+ }
683
+ .e2e-screenshot {
684
+ width: 180px; height: 120px; object-fit: cover;
685
+ border-radius: 6px; border: 1px solid var(--border);
686
+ cursor: pointer; transition: transform 0.15s, border-color 0.15s;
687
+ flex-shrink: 0;
688
+ }
689
+ .e2e-screenshot:hover { transform: scale(1.05); border-color: var(--blue); }
690
+ .e2e-screenshot-modal {
691
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
692
+ background: rgba(0,0,0,0.85); z-index: 300;
693
+ display: flex; align-items: center; justify-content: center;
694
+ cursor: pointer;
695
+ }
696
+ .e2e-screenshot-modal img {
697
+ max-width: 90vw; max-height: 90vh; border-radius: 8px;
698
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
699
+ }
700
+ .e2e-batch-bar {
701
+ display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;
702
+ align-items: center;
703
+ }
704
+ .e2e-stats {
705
+ display: flex; gap: 12px; font-size: 12px; color: var(--muted);
706
+ margin-left: auto;
707
+ }
708
+
744
709
  /* ── Misc ────────────────────────────────────────────────────────────────── */
745
710
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
746
711
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
@@ -755,31 +720,6 @@
755
720
  <span class="header-time" id="scannedAt"></span>
756
721
  <button id="covBtn" onclick="runCoverage()" title="Запустить тесты с coverage">🧪 Coverage</button>
757
722
  <button id="termBtn" onclick="toggleAgentPanel()" title="Показать/скрыть терминал агента">📟 Terminal</button>
758
- <div style="position:relative">
759
- <button id="agentBtn" onclick="toggleAgentMenu()" title="Настройки агента">🤖 —</button>
760
- <div id="agentMenu" class="agent-menu">
761
- <div class="agent-menu-label">AI Агент</div>
762
- <button class="agent-menu-item" id="amClaude" onclick="setAgent('claude');closeAgentMenu()">
763
- <span>⚡ Claude Code</span><span class="agent-menu-check" id="amClaudeCheck">✓</span>
764
- </button>
765
- <button class="agent-menu-item" id="amCodex" onclick="setAgent('codex');closeAgentMenu()">
766
- <span>🟢 Codex</span><span class="agent-menu-check" id="amCodexCheck">✓</span>
767
- </button>
768
- <div class="agent-menu-divider" id="amModelDivider"></div>
769
- <div class="agent-menu-label" id="amModelLabel">Модель Claude</div>
770
- <button class="agent-menu-item" id="amSonnet46" onclick="setModel('claude-sonnet-4-6');closeAgentMenu()">
771
- <span>sonnet-4-6 <span style="color:var(--dim);font-size:10px">быстрый</span></span><span class="agent-menu-check" id="amSonnet46Check">✓</span>
772
- </button>
773
- <button class="agent-menu-item" id="amOpus45" onclick="setModel('claude-opus-4-5');closeAgentMenu()">
774
- <span>opus-4-5 <span style="color:var(--dim);font-size:10px">умный</span></span><span class="agent-menu-check" id="amOpus45Check">✓</span>
775
- </button>
776
- <button class="agent-menu-item" id="amHaiku35" onclick="setModel('claude-haiku-3-5');closeAgentMenu()">
777
- <span>haiku-3-5 <span style="color:var(--dim);font-size:10px">дешёвый</span></span><span class="agent-menu-check" id="amHaiku35Check">✓</span>
778
- </button>
779
- <div class="agent-menu-divider"></div>
780
- <button class="agent-menu-item" onclick="reauthAgent();closeAgentMenu()">🔑 Перелогиниться</button>
781
- </div>
782
- </div>
783
723
  <span id="liveDot" title="Connecting…" style="
784
724
  width:8px; height:8px; border-radius:50%;
785
725
  background:var(--dim); display:inline-block;
@@ -814,13 +754,9 @@
814
754
  <div class="agent-panel-header">
815
755
  <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
816
756
  <span class="agent-panel-status" id="agentPanelStatus">running…</span>
817
- <span class="agent-queue-badge" id="agentQueueBadge" style="display:none">📋 <span id="agentQueueCount">0</span> в очереди</span>
818
- <button class="agent-panel-cancel" id="agentQueueClearBtn" onclick="clearAgentQueue()" title="Очистить очередь" style="display:none">🗑 очередь</button>
819
757
  <button class="agent-panel-cancel" id="agentCancelBtn" onclick="cancelAgent()" title="Сбросить состояние агента" style="display:none">⏹ сброс</button>
820
- <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
821
758
  <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
822
759
  </div>
823
- <div class="agent-tabs-bar" id="agentTabsBar"></div>
824
760
  <div class="agent-terminal" id="agentTerminal"></div>
825
761
  </div>
826
762
 
@@ -835,208 +771,229 @@ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string
835
771
  let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
836
772
  let coverageRunning = false;
837
773
  let coverageHasError = false;
774
+ let e2ePlan = null;
775
+ let e2ePlanLoading = false;
838
776
 
839
- // ─── Coverage button ───────────────────────────────────────────────────────────
840
- function updateCovBtn() {
841
- const btn = document.getElementById('covBtn');
842
- if (!btn) return;
843
- btn.className = '';
844
- btn.disabled = coverageRunning;
845
- if (coverageRunning) {
846
- btn.className = 'cov-running';
847
- btn.textContent = '⏳ Running...';
848
- } else if (coverageHasError) {
849
- btn.className = 'cov-error';
850
- btn.textContent = '❌ Coverage failed';
851
- } else {
852
- btn.textContent = '🧪 Coverage';
853
- }
777
+ // ─── E2E Plan functions ────────────────────────────────────────────────────────
778
+
779
+ async function generateE2ePlan(featureKey) {
780
+ if (agentRunning) { appendTerminalLine('Agent already running', true); return; }
781
+ e2ePlanLoading = true;
782
+ e2ePlan = null;
783
+ renderContent();
784
+ await fetch('/api/e2e/generate-plan', {
785
+ method: 'POST',
786
+ headers: { 'Content-Type': 'application/json' },
787
+ body: JSON.stringify({ featureKey }),
788
+ });
854
789
  }
855
790
 
856
- async function runCoverage() {
857
- if (coverageRunning) return;
858
- await fetch('/api/run-coverage', { method: 'POST' });
859
- // SSE events will update state
791
+ async function loadE2ePlan(featureKey) {
792
+ try {
793
+ const res = await fetch('/api/e2e/plan/' + encodeURIComponent(featureKey));
794
+ if (res.ok) { e2ePlan = await res.json(); }
795
+ else { e2ePlan = null; }
796
+ } catch { e2ePlan = null; }
860
797
  }
861
798
 
862
- // ─── Agent ────────────────────────────────────────────────────────────────────
863
- let agentRunning = false;
864
- const agentRunningPaths = new Set(); // paths of files currently being processed by agent
865
- const agentQueuedPaths = new Set(); // paths of files waiting in queue
866
-
867
- // ─── Console Sessions ─────────────────────────────────────────────────────────
868
- const consoleSessions = []; // { id, title, lines, status, startTime }
869
- let activeSessionId = null; // currently viewed tab
870
- let runningSessionId = null; // tab that is currently receiving output
871
- const SESSION_MAX = 25;
872
- const SESSIONS_KEY = 'viberadar_sessions';
873
-
874
- function _sessionId() {
875
- return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
799
+ async function reviewE2eCase(featureKey, testCaseId, status) {
800
+ const res = await fetch('/api/e2e/review', {
801
+ method: 'POST',
802
+ headers: { 'Content-Type': 'application/json' },
803
+ body: JSON.stringify({ featureKey, testCaseId, status }),
804
+ });
805
+ if (res.ok) { e2ePlan = (await res.json()).plan; renderContent(); }
876
806
  }
877
807
 
878
- function saveSessions() {
879
- try {
880
- const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
881
- ...s, lines: s.lines.slice(-500)
882
- }));
883
- localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
884
- } catch {}
808
+ async function reviewAllE2e(featureKey, status) {
809
+ const res = await fetch('/api/e2e/review-all', {
810
+ method: 'POST',
811
+ headers: { 'Content-Type': 'application/json' },
812
+ body: JSON.stringify({ featureKey, status }),
813
+ });
814
+ if (res.ok) { e2ePlan = (await res.json()).plan; renderContent(); }
885
815
  }
886
816
 
887
- function restoreSessions() {
888
- try {
889
- const raw = localStorage.getItem(SESSIONS_KEY);
890
- if (!raw) return;
891
- const saved = JSON.parse(raw);
892
- consoleSessions.push(...saved);
893
- for (const s of consoleSessions) {
894
- if (s.status === 'running') {
895
- s.status = 'error';
896
- s.lines.push({ text: '⚡ Прервано (перезагрузка страницы)', isError: true });
897
- }
898
- }
899
- if (consoleSessions.length > 0) {
900
- activeSessionId = consoleSessions[consoleSessions.length - 1].id;
901
- renderTabs();
902
- renderActiveSession();
903
- }
904
- } catch {}
817
+ async function writeE2eTests(featureKey) {
818
+ if (agentRunning) { appendTerminalLine('Agent already running', true); return; }
819
+ await fetch('/api/e2e/write-tests', {
820
+ method: 'POST',
821
+ headers: { 'Content-Type': 'application/json' },
822
+ body: JSON.stringify({ featureKey }),
823
+ });
905
824
  }
906
825
 
907
- function createSession(title, status = 'running') {
908
- if (consoleSessions.length >= SESSION_MAX) consoleSessions.shift();
909
- const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now() };
910
- consoleSessions.push(s);
911
- activeSessionId = s.id;
912
- document.getElementById('agentPanel').classList.add('open');
913
- document.getElementById('termBtn').classList.add('term-active');
914
- renderTabs();
915
- renderActiveSession();
916
- saveSessions();
917
- return s.id;
826
+ async function runE2eTests(featureKey) {
827
+ await fetch('/api/e2e/run-tests', {
828
+ method: 'POST',
829
+ headers: { 'Content-Type': 'application/json' },
830
+ body: JSON.stringify({ featureKey }),
831
+ });
918
832
  }
919
833
 
920
- function switchSession(id) {
921
- activeSessionId = id;
922
- const s = consoleSessions.find(s => s.id === id);
923
- if (s) {
924
- const statusText = s.status === 'running' ? 'работает…'
925
- : s.status === 'ok' ? '✅ готово'
926
- : s.status === 'error' ? ' ошибка'
927
- : '';
928
- document.getElementById('agentPanelTitle').textContent = s.title;
929
- document.getElementById('agentPanelStatus').textContent = statusText;
930
- }
931
- renderTabs();
932
- renderActiveSession();
834
+ function openScreenshot(src) {
835
+ const modal = document.createElement('div');
836
+ modal.className = 'e2e-screenshot-modal';
837
+ modal.innerHTML = '<img src="' + src + '" />';
838
+ modal.onclick = () => modal.remove();
839
+ document.addEventListener('keydown', function handler(e) {
840
+ if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', handler); }
841
+ });
842
+ document.body.appendChild(modal);
933
843
  }
934
844
 
935
- function closeSession(id) {
936
- const idx = consoleSessions.findIndex(s => s.id === id);
937
- if (idx === -1) return;
938
- consoleSessions.splice(idx, 1);
939
- if (activeSessionId === id) {
940
- activeSessionId = consoleSessions.length > 0
941
- ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
942
- : null;
943
- }
944
- renderTabs();
945
- renderActiveSession();
946
- saveSessions();
845
+ function statusBadge(status) {
846
+ const colors = {
847
+ pending: 'var(--muted)', approved: 'var(--green)', rejected: 'var(--red)',
848
+ written: 'var(--blue)', passed: 'var(--green)', failed: 'var(--red)',
849
+ };
850
+ const icons = {
851
+ pending: '⏳', approved: '✅', rejected: '❌',
852
+ written: '✍', passed: '✅', failed: '❌',
853
+ };
854
+ return '<span class="e2e-case-status" style="color:' + (colors[status] || 'var(--muted)') + '">'
855
+ + (icons[status] || '') + ' ' + status + '</span>';
947
856
  }
948
857
 
949
- function appendToSession(id, lineOrNode, isError = false, isDim = false) {
950
- const s = consoleSessions.find(s => s.id === id);
951
- if (!s) return;
952
- let stored;
953
- if (typeof lineOrNode === 'string') {
954
- stored = { text: lineOrNode, isError, isDim };
955
- } else {
956
- stored = { html: lineOrNode.outerHTML };
858
+ function renderE2eTestCase(tc, featureKey) {
859
+ const screenshotHtml = (tc.screenshotPaths && tc.screenshotPaths.length > 0)
860
+ ? '<div class="e2e-screenshot-strip">' +
861
+ tc.screenshotPaths.map(p =>
862
+ '<img class="e2e-screenshot" src="/api/e2e/screenshot/' + encodeURI(p) + '" onclick="event.stopPropagation();openScreenshot(this.src)" />'
863
+ ).join('') + '</div>'
864
+ : '';
865
+
866
+ const errorHtml = tc.lastError
867
+ ? '<div class="e2e-error-msg">' + tc.lastError.replace(/</g, '&lt;').slice(0, 500) + '</div>'
868
+ : '';
869
+
870
+ const canReview = tc.status === 'pending' || tc.status === 'approved' || tc.status === 'rejected';
871
+ const actionsHtml = canReview
872
+ ? '<div class="e2e-case-actions">' +
873
+ (tc.status !== 'approved'
874
+ ? '<button class="e2e-btn e2e-btn-approve" onclick="event.stopPropagation();reviewE2eCase(\'' + featureKey + '\',\'' + tc.id + '\',\'approved\')">✅ Approve</button>'
875
+ : '') +
876
+ (tc.status !== 'rejected'
877
+ ? '<button class="e2e-btn e2e-btn-reject" onclick="event.stopPropagation();reviewE2eCase(\'' + featureKey + '\',\'' + tc.id + '\',\'rejected\')">❌ Reject</button>'
878
+ : '') +
879
+ '</div>'
880
+ : '';
881
+
882
+ return '<div class="e2e-case ' + tc.status + '">' +
883
+ '<div class="e2e-case-header">' +
884
+ '<span class="e2e-case-id">' + tc.id + '</span>' +
885
+ '<span class="e2e-case-title">' + tc.name + '</span>' +
886
+ statusBadge(tc.status) +
887
+ '</div>' +
888
+ '<div class="e2e-case-desc">' + tc.description + '</div>' +
889
+ '<div style="font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:600">Steps:</div>' +
890
+ '<ol class="e2e-case-steps">' + tc.steps.map(s => '<li>' + s + '</li>').join('') + '</ol>' +
891
+ '<div style="font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:600">Expected:</div>' +
892
+ '<ul class="e2e-case-expected">' + tc.expectedResults.map(r => '<li>' + r + '</li>').join('') + '</ul>' +
893
+ screenshotHtml +
894
+ errorHtml +
895
+ actionsHtml +
896
+ '</div>';
897
+ }
898
+
899
+ function renderE2ePlanView(c, featureKey) {
900
+ const feat = (D.features || []).find(f => f.key === featureKey);
901
+ if (!feat) return;
902
+
903
+ if (e2ePlanLoading) {
904
+ c.innerHTML =
905
+ '<div class="drill-header">' +
906
+ '<button class="back-btn" onclick="drillTestType=null;renderContent()">← ' + feat.label + '</button>' +
907
+ '<div class="drill-title"><span>🎭</span><span>E2E План</span></div>' +
908
+ '</div>' +
909
+ '<div style="padding:40px;text-align:center;color:var(--muted)">' +
910
+ '<div style="font-size:28px;margin-bottom:12px">⏳</div>' +
911
+ 'AI-агент генерирует план E2E тестов…' +
912
+ '</div>';
913
+ return;
957
914
  }
958
- s.lines.push(stored);
959
- if (activeSessionId === id) {
960
- const term = document.getElementById('agentTerminal');
961
- const el = document.createElement('div');
962
- if (stored.html) {
963
- el.innerHTML = stored.html;
964
- } else {
965
- el.className = 'agent-line' + (isError ? ' err' : isDim ? ' dim' : '');
966
- el.textContent = lineOrNode;
967
- }
968
- term.appendChild(el);
969
- term.scrollTop = term.scrollHeight;
915
+
916
+ if (!e2ePlan) {
917
+ c.innerHTML =
918
+ '<div class="drill-header">' +
919
+ '<button class="back-btn" onclick="drillTestType=null;renderContent()">← ' + feat.label + '</button>' +
920
+ '<div class="drill-title"><span>🎭</span><span>E2E План</span></div>' +
921
+ '</div>' +
922
+ '<div style="padding:40px;text-align:center;border:1px dashed var(--border);border-radius:8px">' +
923
+ '<div style="font-size:32px;margin-bottom:12px">🎭</div>' +
924
+ '<div style="font-size:15px;color:var(--text);margin-bottom:6px">E2E план ещё не создан</div>' +
925
+ '<div style="font-size:12px;color:var(--dim);margin-bottom:16px">' +
926
+ 'AI-агент проанализирует фичу и создаст план тестирования' +
927
+ '</div>' +
928
+ (!D.hasPlaywright ? '<div style="font-size:12px;color:var(--yellow);margin-bottom:16px">⚠️ @playwright/test не найден в package.json — установи перед запуском тестов</div>' : '') +
929
+ '<button class="e2e-btn e2e-btn-primary" style="padding:10px 24px;font-size:13px;border-radius:8px" ' +
930
+ 'onclick="generateE2ePlan(\'' + featureKey + '\')"' +
931
+ (!D.agent ? ' disabled title="Сначала выбери AI агента"' : '') + '>' +
932
+ '▶ Сгенерировать план' +
933
+ '</button>' +
934
+ '</div>';
935
+ return;
970
936
  }
971
- }
972
937
 
973
- function updateSessionStatus(id, status) {
974
- const s = consoleSessions.find(s => s.id === id);
975
- if (!s) return;
976
- s.status = status;
977
- renderTabs();
978
- saveSessions();
938
+ const cases = e2ePlan.testCases || [];
939
+ const counts = { pending: 0, approved: 0, rejected: 0, written: 0, passed: 0, failed: 0 };
940
+ cases.forEach(tc => { counts[tc.status] = (counts[tc.status] || 0) + 1; });
941
+ const hasApproved = counts.approved > 0;
942
+ const hasWritten = counts.written + counts.passed + counts.failed > 0;
943
+
944
+ c.innerHTML =
945
+ '<div class="drill-header">' +
946
+ '<button class="back-btn" onclick="drillTestType=null;renderContent()">← ' + feat.label + '</button>' +
947
+ '<div class="drill-title"><span>🎭</span><span>E2E План</span></div>' +
948
+ '<div class="e2e-stats">' +
949
+ '<span>' + cases.length + ' кейсов</span>' +
950
+ (counts.approved > 0 ? '<span style="color:var(--green)">' + counts.approved + ' одобрено</span>' : '') +
951
+ (counts.written > 0 ? '<span style="color:var(--blue)">' + counts.written + ' написано</span>' : '') +
952
+ (counts.passed > 0 ? '<span style="color:var(--green)">' + counts.passed + ' passed</span>' : '') +
953
+ (counts.failed > 0 ? '<span style="color:var(--red)">' + counts.failed + ' failed</span>' : '') +
954
+ '</div>' +
955
+ '</div>' +
956
+ '<div class="e2e-batch-bar">' +
957
+ '<button class="e2e-btn e2e-btn-approve" onclick="reviewAllE2e(\'' + featureKey + '\',\'approved\')">✅ Approve All</button>' +
958
+ '<button class="e2e-btn e2e-btn-reject" onclick="reviewAllE2e(\'' + featureKey + '\',\'rejected\')">❌ Reject All</button>' +
959
+ (hasApproved && D.agent
960
+ ? '<button class="e2e-btn e2e-btn-primary" onclick="writeE2eTests(\'' + featureKey + '\')">✍ Написать тесты (' + counts.approved + ')</button>'
961
+ : '') +
962
+ (hasWritten
963
+ ? '<button class="e2e-btn e2e-btn-primary" style="background:var(--green)" onclick="runE2eTests(\'' + featureKey + '\')">▶ Запустить E2E</button>'
964
+ : '') +
965
+ '<button class="e2e-btn e2e-btn-secondary" onclick="generateE2ePlan(\'' + featureKey + '\')">🔄 Перегенерировать</button>' +
966
+ '</div>' +
967
+ '<div class="e2e-plan-container">' +
968
+ cases.map(tc => renderE2eTestCase(tc, featureKey)).join('') +
969
+ '</div>';
979
970
  }
980
971
 
981
- function renderTabs() {
982
- const bar = document.getElementById('agentTabsBar');
983
- if (!bar) return;
984
- bar.innerHTML = consoleSessions.map(s => {
985
- const isActive = s.id === activeSessionId;
986
- const safe = s.title.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
987
- return `<div class="agent-tab${isActive ? ' active' : ''}"
988
- onclick="switchSession('${s.id}')"
989
- onmousedown="if(event.button===1){event.preventDefault();event.stopPropagation();closeSession('${s.id}')}">
990
- <span class="tab-dot tab-dot-${s.status}"></span>
991
- <span class="agent-tab-title" title="${safe}">${safe}</span>
992
- <button class="agent-tab-close" onclick="event.stopPropagation();closeSession('${s.id}')" title="Закрыть вкладку">×</button>
993
- </div>`;
994
- }).join('');
995
- const active = bar.querySelector('.agent-tab.active');
996
- if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
972
+ // ─── Coverage button ───────────────────────────────────────────────────────────
973
+ function updateCovBtn() {
974
+ const btn = document.getElementById('covBtn');
975
+ if (!btn) return;
976
+ btn.className = '';
977
+ btn.disabled = coverageRunning;
978
+ if (coverageRunning) {
979
+ btn.className = 'cov-running';
980
+ btn.textContent = '⏳ Running...';
981
+ } else if (coverageHasError) {
982
+ btn.className = 'cov-error';
983
+ btn.textContent = ' Coverage failed';
984
+ } else {
985
+ btn.textContent = '🧪 Coverage';
986
+ }
997
987
  }
998
988
 
999
- function copyTerminalContent() {
1000
- const s = consoleSessions.find(s => s.id === activeSessionId);
1001
- if (!s || s.lines.length === 0) return;
1002
- // Extract plain text from each line (strip HTML for rich nodes)
1003
- const text = s.lines.map(l => {
1004
- if (l.text !== undefined) return l.text;
1005
- if (l.html) {
1006
- const tmp = document.createElement('div');
1007
- tmp.innerHTML = l.html;
1008
- return tmp.innerText;
1009
- }
1010
- return '';
1011
- }).filter(Boolean).join('\n');
1012
- navigator.clipboard.writeText(text).then(() => {
1013
- const btn = document.getElementById('agentCopyBtn');
1014
- if (!btn) return;
1015
- const prev = btn.textContent;
1016
- btn.textContent = '✓';
1017
- btn.classList.add('copied');
1018
- setTimeout(() => { btn.textContent = prev; btn.classList.remove('copied'); }, 1500);
1019
- });
989
+ async function runCoverage() {
990
+ if (coverageRunning) return;
991
+ await fetch('/api/run-coverage', { method: 'POST' });
992
+ // SSE events will update state
1020
993
  }
1021
994
 
1022
- function renderActiveSession() {
1023
- const term = document.getElementById('agentTerminal');
1024
- if (!term) return;
1025
- term.innerHTML = '';
1026
- const s = consoleSessions.find(s => s.id === activeSessionId);
1027
- if (!s) return;
1028
- for (const ln of s.lines) {
1029
- const el = document.createElement('div');
1030
- if (ln.html) {
1031
- el.innerHTML = ln.html;
1032
- } else {
1033
- el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1034
- el.textContent = ln.text;
1035
- }
1036
- term.appendChild(el);
1037
- }
1038
- term.scrollTop = term.scrollHeight;
1039
- }
995
+ // ─── Agent ────────────────────────────────────────────────────────────────────
996
+ let agentRunning = false;
1040
997
 
1041
998
  async function setAgent(agent) {
1042
999
  await fetch('/api/set-agent', {
@@ -1047,182 +1004,30 @@ async function setAgent(agent) {
1047
1004
  // scheduleRescan will fire → data-updated → D.agent updates
1048
1005
  }
1049
1006
 
1050
- async function setModel(model) {
1051
- await fetch('/api/set-model', {
1052
- method: 'POST',
1053
- headers: { 'Content-Type': 'application/json' },
1054
- body: JSON.stringify({ model }),
1055
- });
1056
- // scheduleRescan will fire → data-updated → D.model updates
1057
- }
1058
-
1059
1007
  function setAgentRunning(val) {
1060
1008
  agentRunning = val;
1061
1009
  document.getElementById('agentCancelBtn').style.display = val ? 'inline-block' : 'none';
1062
1010
  }
1063
1011
 
1064
- // Returns array of normalized relative paths affected by a given task
1065
- function getTaskFilePaths(task, featureKey, filePath) {
1066
- if ((task === 'write-tests-file' || task === 'fix-tests') && filePath) {
1067
- return [filePath.replace(/\\/g, '/')];
1068
- }
1069
- if ((task === 'write-tests' || task === 'fix-tests-all') && featureKey && D?.modules) {
1070
- return D.modules
1071
- .filter(m => m.featureKeys?.includes(featureKey) && m.type !== 'test' && (!m.hasTests || m.testStale))
1072
- .map(m => m.relativePath.replace(/\\/g, '/'));
1073
- }
1074
- return [];
1075
- }
1076
-
1077
- // Returns 'running', 'queued', or null for a given relative path
1078
- function isFileAgentActive(relPath) {
1079
- const n = relPath.replace(/\\/g, '/');
1080
- if (agentRunningPaths.has(n)) return 'running';
1081
- if (agentQueuedPaths.has(n)) return 'queued';
1082
- return null;
1083
- }
1084
-
1085
- function updateQueueBadge(n) {
1086
- document.getElementById('agentQueueCount').textContent = n;
1087
- document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1088
- document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1089
- }
1090
-
1091
1012
  async function cancelAgent() {
1092
1013
  await fetch('/api/cancel-agent', { method: 'POST' });
1093
1014
  setAgentRunning(false);
1094
- updateQueueBadge(0);
1095
1015
  document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1096
- if (runningSessionId) {
1097
- appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1098
- updateSessionStatus(runningSessionId, 'error');
1099
- runningSessionId = null;
1100
- } else {
1101
- appendTerminalLine('⏹ Состояние агента сброшено (очередь очищена)', false);
1102
- }
1103
- }
1104
-
1105
- async function clearAgentQueue() {
1106
- await fetch('/api/clear-queue', { method: 'POST' });
1107
- updateQueueBadge(0);
1108
- appendTerminalLine('🗑 Очередь очищена', false);
1109
- }
1110
-
1111
- // ─── File row more menu ──────────────────────────────────────────────────────
1112
- let _openFileMenu = null;
1113
-
1114
- function toggleFileMenu(btn, featureKey, relPath) {
1115
- const dropdown = btn.nextElementSibling;
1116
- const isOpen = dropdown.classList.contains('open');
1117
- // Close any open menu first
1118
- if (_openFileMenu && _openFileMenu !== dropdown) {
1119
- _openFileMenu.classList.remove('open');
1120
- }
1121
- dropdown.classList.toggle('open', !isOpen);
1122
- _openFileMenu = isOpen ? null : dropdown;
1123
- }
1124
-
1125
- // Close menu on outside click
1126
- document.addEventListener('click', () => {
1127
- if (_openFileMenu) { _openFileMenu.classList.remove('open'); _openFileMenu = null; }
1128
- });
1129
-
1130
- async function copyPromptForFile(featureKey, relPath, dropdown) {
1131
- if (dropdown) { dropdown.classList.remove('open'); _openFileMenu = null; }
1132
- try {
1133
- const res = await fetch('/api/get-prompt', {
1134
- method: 'POST',
1135
- headers: { 'Content-Type': 'application/json' },
1136
- body: JSON.stringify({ task: 'write-tests-file', featureKey, filePath: relPath }),
1137
- });
1138
- const { prompt, error } = await res.json();
1139
- if (error || !prompt) { alert('Не удалось получить промпт: ' + (error || 'пустой')); return; }
1140
- await navigator.clipboard.writeText(prompt);
1141
- // Brief visual feedback in terminal
1142
- document.getElementById('agentPanel').classList.add('open');
1143
- document.getElementById('termBtn').classList.add('term-active');
1144
- appendTerminalLine(`📋 Промпт для "${relPath.split('/').pop()}" скопирован в буфер обмена`, false);
1145
- appendTerminalLine(' Вставь его в другой агент (Claude, Codex, ChatGPT) и запусти вручную.', false);
1146
- } catch (e) {
1147
- alert('Ошибка копирования: ' + e.message);
1148
- }
1016
+ appendTerminalLine('⏹ Состояние агента сброшено', false);
1149
1017
  }
1150
1018
 
1151
- // ─── Agent menu ─────────────────────────────────────────────────────────────
1152
- const CLAUDE_MODELS = [
1153
- { id: 'claude-sonnet-4-6', checkId: 'amSonnet46Check' },
1154
- { id: 'claude-opus-4-5', checkId: 'amOpus45Check' },
1155
- { id: 'claude-haiku-3-5', checkId: 'amHaiku35Check' },
1156
- ];
1157
-
1158
- function updateAgentBtn() {
1159
- const btn = document.getElementById('agentBtn');
1160
- if (!D) return;
1161
- const a = D.agent;
1162
- const m = D.model;
1163
- // Button label: agent + model shortname
1164
- let label = a === 'claude' ? '🤖 Claude' : a === 'codex' ? '🤖 Codex' : '🤖 не выбран';
1165
- if (a === 'claude' && m) {
1166
- // Show short model name: "sonnet-4-6", "opus-4-5", "haiku-3-5"
1167
- const short = m.replace('claude-', '');
1168
- label += ` · ${short}`;
1169
- }
1170
- btn.textContent = label;
1171
- // Update agent checkmarks
1172
- document.getElementById('amClaudeCheck').classList.toggle('active', a === 'claude');
1173
- document.getElementById('amCodexCheck').classList.toggle('active', a === 'codex');
1174
- // Show/hide model section (only for claude)
1175
- const isClaude = a === 'claude';
1176
- document.getElementById('amModelDivider').style.display = isClaude ? '' : 'none';
1177
- document.getElementById('amModelLabel').style.display = isClaude ? '' : 'none';
1178
- CLAUDE_MODELS.forEach(({ id, checkId }) => {
1179
- const el = document.getElementById(id);
1180
- if (el) el.style.display = isClaude ? '' : 'none';
1181
- document.getElementById(checkId).classList.toggle('active', m === id);
1182
- });
1183
- }
1184
-
1185
- function toggleAgentMenu() {
1186
- const menu = document.getElementById('agentMenu');
1187
- const isOpen = menu.classList.contains('open');
1188
- menu.classList.toggle('open');
1189
- if (!isOpen) {
1190
- // Close on click outside
1191
- setTimeout(() => {
1192
- document.addEventListener('click', closeAgentMenuOnOutside, { once: true });
1193
- }, 0);
1194
- }
1195
- }
1196
- function closeAgentMenu() {
1197
- document.getElementById('agentMenu').classList.remove('open');
1198
- }
1199
- function closeAgentMenuOnOutside(e) {
1200
- if (!e.target.closest('#agentMenu') && !e.target.closest('#agentBtn')) {
1201
- closeAgentMenu();
1202
- }
1203
- }
1204
-
1205
- async function reauthAgent() {
1206
- const agent = D?.agent;
1207
- if (!agent) {
1208
- appendTerminalLine('⚠ Сначала выбери агента (Claude или Codex)', true);
1019
+ async function runAgentTask(task, featureKey, filePath) {
1020
+ if (agentRunning) {
1021
+ // Show panel so user sees cancel button
1209
1022
  document.getElementById('agentPanel').classList.add('open');
1210
1023
  document.getElementById('termBtn').classList.add('term-active');
1024
+ appendTerminalLine('⚠️ Агент уже запущен. Нажми ⏹ сброс чтобы отменить.', true);
1211
1025
  return;
1212
1026
  }
1213
- const id = createSession('🔑 Перелогинивание');
1214
- runningSessionId = id;
1215
- document.getElementById('agentPanelTitle').textContent = '🔑 Перелогинивание';
1216
- document.getElementById('agentPanelStatus').textContent = 'выполняю…';
1217
- await fetch('/api/agent-reauth', { method: 'POST' });
1218
- }
1219
-
1220
- async function runAgentTask(task, featureKey, filePath) {
1027
+ document.getElementById('agentTerminal').innerHTML = '';
1028
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1221
1029
  document.getElementById('agentPanel').classList.add('open');
1222
1030
  document.getElementById('termBtn').classList.add('term-active');
1223
- if (!agentRunning) {
1224
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1225
- }
1226
1031
  await fetch('/api/run-agent', {
1227
1032
  method: 'POST',
1228
1033
  headers: { 'Content-Type': 'application/json' },
@@ -1231,9 +1036,11 @@ async function runAgentTask(task, featureKey, filePath) {
1231
1036
  }
1232
1037
 
1233
1038
  async function runTests(featureKey, testType) {
1039
+ document.getElementById('agentTerminal').innerHTML = '';
1040
+ document.getElementById('agentPanelTitle').textContent = `🧪 ${testType} тесты`;
1041
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1234
1042
  document.getElementById('agentPanel').classList.add('open');
1235
1043
  document.getElementById('termBtn').classList.add('term-active');
1236
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1237
1044
  await fetch('/api/run-tests', {
1238
1045
  method: 'POST',
1239
1046
  headers: { 'Content-Type': 'application/json' },
@@ -1254,11 +1061,12 @@ function toggleAgentPanel() {
1254
1061
  }
1255
1062
 
1256
1063
  function appendTerminalLine(line, isError, isDim) {
1257
- if (!activeSessionId) {
1258
- const id = createSession('Консоль', 'info');
1259
- activeSessionId = id;
1260
- }
1261
- appendToSession(activeSessionId, line, !!isError, !!isDim);
1064
+ const term = document.getElementById('agentTerminal');
1065
+ const el = document.createElement('div');
1066
+ el.className = 'agent-line' + (isError ? ' err' : isDim ? ' dim' : '');
1067
+ el.textContent = line;
1068
+ term.appendChild(el);
1069
+ term.scrollTop = term.scrollHeight;
1262
1070
  }
1263
1071
 
1264
1072
  // ─── Color helpers ────────────────────────────────────────────────────────────
@@ -1305,7 +1113,6 @@ async function init() {
1305
1113
  coverageHasError = status.coverageError ?? false;
1306
1114
  }
1307
1115
  updateCovBtn();
1308
- updateAgentBtn();
1309
1116
 
1310
1117
  document.getElementById('projectName').textContent = D.projectName;
1311
1118
  document.getElementById('scannedAt').textContent =
@@ -1558,39 +1365,31 @@ function renderFeatureCards(c) {
1558
1365
  }
1559
1366
 
1560
1367
  function testTypeCard(type, label, icon, color, count, active, featureKey, failedCount = 0) {
1561
- const empty = count === 0 && type !== 'source';
1368
+ const empty = count === 0 && type !== 'source';
1562
1369
  const hasFailed = failedCount > 0 && type !== 'source';
1563
- const subLabel = type === 'source' ? 'файлов' : (empty ? 'нет тестов' : pluralFiles(count));
1564
- const accentColor = hasFailed ? 'var(--red)' : color;
1565
- const countColor = hasFailed ? 'var(--red)' : (active || !empty ? color : 'var(--dim)');
1566
-
1567
- const failedBadge = hasFailed
1568
- ? `<span class="tt-failed-badge">❌ ${failedCount}</span>`
1569
- : '';
1570
-
1370
+ const hasE2ePlan = type === 'e2e' && D.e2ePlansExist && D.e2ePlansExist.includes(featureKey);
1371
+ const subLabel = empty
1372
+ ? (type === 'e2e' && hasE2ePlan ? '📋 план создан' : 'нет тестов')
1373
+ : (type === 'source' ? 'код приложения' : pluralFiles(count));
1571
1374
  const runBtn = !empty && type !== 'source' && featureKey
1572
- ? `<button class="tt-run-btn" onclick="event.stopPropagation();runTests('${featureKey}','${type}')">▶ запустить тесты</button>`
1573
- : '';
1574
- const fixAllBtn = hasFailed && D.agent && featureKey
1575
- ? `<button class="tt-run-btn tt-fix-btn" onclick="event.stopPropagation();runAgentTask('fix-tests-all','${featureKey}','${type}')">🔧 починить все</button>`
1375
+ ? `<button class="tt-run-btn" onclick="event.stopPropagation();${type === 'e2e' ? "runE2eTests('" + featureKey + "')" : "runTests('" + featureKey + "','" + type + "')"}">▶ запустить</button>`
1576
1376
  : '';
1577
- const writeBtn = type !== 'source' && D.agent && featureKey
1578
- ? `<button class="tt-run-btn tt-write-btn" onclick="event.stopPropagation();runAgentTask('write-tests','${featureKey}')">✍ написать тесты</button>`
1579
- : '';
1580
- const actions = (runBtn || fixAllBtn || writeBtn)
1581
- ? `<div class="tt-card-actions">${runBtn}${fixAllBtn}${writeBtn}</div>`
1377
+ const accentColor = hasFailed && !active ? 'var(--red)' : color;
1378
+ const countColor = hasFailed ? 'var(--red)' : (active || !empty ? color : 'var(--dim)');
1379
+ const failedNote = hasFailed
1380
+ ? `<div style="font-size:11px;color:var(--red);margin-top:2px;font-weight:600">❌ ${failedCount} упало</div>`
1582
1381
  : '';
1583
-
1382
+ // E2E cards should always be clickable (to see/create plan)
1383
+ const forceClickable = type === 'e2e';
1584
1384
  return `
1585
- <div class="test-type-card${empty ? ' tt-empty' : ''}${active ? ' tt-active' : ''}${hasFailed ? ' tt-failed' : ''}"
1586
- data-testtype="${type}" style="border-top-color:${accentColor}">
1587
- <div class="tt-card-header">
1588
- <span class="tt-label">${icon} ${label}</span>
1589
- ${failedBadge}
1590
- </div>
1591
- <span class="tt-count" style="color:${countColor}">${count}</span>
1592
- <span class="tt-sub">${subLabel}</span>
1593
- ${actions}
1385
+ <div class="test-type-card${empty && !forceClickable ? ' tt-empty' : ''}${active ? ' tt-active' : ''}${hasFailed ? ' tt-failed' : ''}"
1386
+ data-testtype="${type}" style="${active ? 'border-color:' + color : ''}${forceClickable && empty ? ';cursor:pointer;opacity:1' : ''}">
1387
+ <div class="tt-accent" style="background:${accentColor}"></div>
1388
+ <div class="tt-label">${icon} ${label}</div>
1389
+ <div class="tt-count" style="color:${countColor}">${count}</div>
1390
+ <div class="tt-sub">${subLabel}</div>
1391
+ ${failedNote}
1392
+ ${runBtn}
1594
1393
  </div>`;
1595
1394
  }
1596
1395
 
@@ -1598,6 +1397,12 @@ function renderFeatureDetail(c) {
1598
1397
  const feat = D.features.find(f => f.key === drillFeatureKey);
1599
1398
  if (!feat) { backToFeatures(); return; }
1600
1399
 
1400
+ // E2E plan view override
1401
+ if (drillTestType === 'e2e') {
1402
+ renderE2ePlanView(c, drillFeatureKey);
1403
+ return;
1404
+ }
1405
+
1601
1406
  const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
1602
1407
  const src = mods.filter(m => m.type !== 'test');
1603
1408
  const tst = mods.filter(m => m.type === 'test');
@@ -1665,11 +1470,14 @@ function renderFeatureDetail(c) {
1665
1470
  </div>`;
1666
1471
 
1667
1472
  c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
1668
- card.onclick = () => {
1473
+ card.onclick = async () => {
1669
1474
  const type = card.dataset.testtype;
1670
1475
  drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
1671
1476
  activePanelKey = null;
1672
1477
  document.getElementById('panel').classList.remove('open');
1478
+ if (type === 'e2e') {
1479
+ await loadE2ePlan(drillFeatureKey);
1480
+ }
1673
1481
  renderContent();
1674
1482
  };
1675
1483
  });
@@ -1824,16 +1632,12 @@ function fileRow(m, isTest = false, featureKey = null) {
1824
1632
  const dir = parts.slice(0, -1).join('/');
1825
1633
 
1826
1634
  // Test errors from last run
1827
- const testErr = isTest && D.testErrors && D.testErrors[relPath];
1828
- const agentState = !isTest ? isFileAgentActive(relPath) : null;
1829
- const icon = agentState
1830
- ? `<span class="file-agent-spinner ${agentState}" title="${agentState === 'running' ? 'Агент работает…' : 'В очереди…'}"></span>`
1831
- : (testErr ? '❌' : isTest ? '🧪' : (m.testStale ? '⚠️' : m.hasTests ? '✅' : '⬜'));
1635
+ const testErr = isTest && D.testErrors && D.testErrors[relPath];
1636
+ const icon = testErr ? '❌' : isTest ? '🧪' : (m.testStale ? '⚠️' : m.hasTests ? '✅' : '⬜');
1832
1637
  const isActive = activePanelKey === m.id;
1833
1638
 
1834
1639
  // Write-test button for source files
1835
- // In feature drill-down (featureKey set), show button even for isInfra files user explicitly added them to the feature
1836
- const showAgentBtn = D.agent && !isTest && featureKey && (!m.hasTests || m.testStale);
1640
+ const showAgentBtn = D.agent && !isTest && !m.isInfra && featureKey && (!m.hasTests || m.testStale);
1837
1641
  const agentBtnLabel = m.testStale ? '↻ обновить тест' : '✍ написать тест';
1838
1642
  const agentBtnTitle = m.testStale ? 'Файл изменился — обновить тесты' : 'Написать тест для этого файла';
1839
1643
  const agentBtn = showAgentBtn
@@ -1864,20 +1668,6 @@ function fileRow(m, isTest = false, featureKey = null) {
1864
1668
  </div>`
1865
1669
  : '';
1866
1670
 
1867
- // "⋯" more menu — always show for source files in feature context
1868
- const moreBtn = !isTest && featureKey
1869
- ? `<div class="file-row-more-wrap">
1870
- <button class="file-row-more-btn" title="Ещё"
1871
- onclick="event.stopPropagation();toggleFileMenu(this,'${featureKey}','${relPath}')">⋯</button>
1872
- <div class="file-row-more-dropdown">
1873
- <button class="file-row-more-item"
1874
- onclick="event.stopPropagation();copyPromptForFile('${featureKey}','${relPath}',this.closest('.file-row-more-dropdown'))">
1875
- 📋 Скопировать промпт для агента
1876
- </button>
1877
- </div>
1878
- </div>`
1879
- : '';
1880
-
1881
1671
  return `
1882
1672
  <div class="file-row${isActive ? ' active' : ''}${testErr ? ' has-errors' : ''}" data-id="${m.id}">
1883
1673
  <span class="file-row-icon">${icon}</span>
@@ -1885,7 +1675,6 @@ function fileRow(m, isTest = false, featureKey = null) {
1885
1675
  ${errBadge}
1886
1676
  ${agentBtn}
1887
1677
  ${fixBtn}
1888
- ${moreBtn}
1889
1678
  <span class="file-row-dir">${dir}</span>
1890
1679
  </div>
1891
1680
  ${errHtml}`;
@@ -1981,11 +1770,7 @@ function fileItem(m, isTest = false) {
1981
1770
  const parts = m.relativePath.replace(/\\/g, '/').split('/');
1982
1771
  const name = parts[parts.length - 1];
1983
1772
  const dir = parts.slice(0, -1).join('/');
1984
- const relPathNorm = m.relativePath.replace(/\\/g, '/');
1985
- const agentStateItem = !isTest ? isFileAgentActive(relPathNorm) : null;
1986
- const icon = agentStateItem
1987
- ? `<span class="file-agent-spinner ${agentStateItem}" title="${agentStateItem === 'running' ? 'Агент работает…' : 'В очереди…'}"></span>`
1988
- : (isTest ? '🧪' : (m.hasTests ? '✅' : '⬜'));
1773
+ const icon = isTest ? '🧪' : (m.hasTests ? '' : '⬜');
1989
1774
  return `
1990
1775
  <div class="file-item">
1991
1776
  <span class="file-item-icon">${icon}</span>
@@ -2183,7 +1968,6 @@ async function refreshData() {
2183
1968
  renderStats();
2184
1969
  renderSidebar();
2185
1970
  renderContent();
2186
- updateAgentBtn();
2187
1971
 
2188
1972
  // Re-render drill-down or re-open panel
2189
1973
  const panelOpen = document.getElementById('panel').classList.contains('open');
@@ -2237,69 +2021,31 @@ function connectSSE() {
2237
2021
  setTimeout(() => { coverageHasError = false; updateCovBtn(); }, 8000);
2238
2022
  });
2239
2023
 
2240
- es.addEventListener('agent-queued', (e) => {
2241
- const { queueLength, title, task, featureKey, filePath } = JSON.parse(e.data);
2242
- updateQueueBadge(queueLength);
2243
- document.getElementById('agentPanel').classList.add('open');
2244
- document.getElementById('termBtn').classList.add('term-active');
2245
- // Track queued paths for spinner
2246
- getTaskFilePaths(task, featureKey, filePath).forEach(p => agentQueuedPaths.add(p));
2247
- renderContent();
2248
- // Append queue notification to the currently running session (or active)
2249
- const targetId = runningSessionId || activeSessionId;
2250
- if (targetId) {
2251
- appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
2252
- }
2253
- });
2254
-
2255
2024
  es.addEventListener('agent-started', (e) => {
2256
2025
  setAgentRunning(true);
2257
- const { title, queueLength = 0, task, featureKey, filePath } = JSON.parse(e.data);
2258
- updateQueueBadge(queueLength);
2259
- // Move paths from queued → running (current task)
2260
- const startedPaths = getTaskFilePaths(task, featureKey, filePath);
2261
- startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
2262
- renderContent();
2263
- // Close previous session (queue case: agent-done not fired between tasks)
2264
- if (runningSessionId) {
2265
- const prev = consoleSessions.find(s => s.id === runningSessionId);
2266
- if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
2267
- }
2268
- const id = createSession(title);
2269
- runningSessionId = id;
2026
+ const { title } = JSON.parse(e.data);
2270
2027
  document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
2271
2028
  document.getElementById('agentPanelStatus').textContent = 'запускаю…';
2029
+ document.getElementById('agentPanel').classList.add('open');
2030
+ document.getElementById('termBtn').classList.add('term-active');
2031
+ document.getElementById('agentTerminal').innerHTML = '';
2272
2032
  });
2273
2033
 
2274
2034
  es.addEventListener('agent-output', (e) => {
2275
2035
  const { line, isError, isDim } = JSON.parse(e.data);
2276
- if (runningSessionId) {
2277
- appendToSession(runningSessionId, line, !!isError, !!isDim);
2278
- if (activeSessionId === runningSessionId) {
2279
- document.getElementById('agentPanelStatus').textContent = 'работает…';
2280
- }
2281
- } else {
2282
- appendTerminalLine(line, !!isError, !!isDim);
2283
- }
2036
+ appendTerminalLine(line, !!isError, !!isDim);
2037
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
2284
2038
  });
2285
2039
 
2286
2040
  es.addEventListener('agent-done', () => {
2287
2041
  setAgentRunning(false);
2288
- updateQueueBadge(0);
2289
- agentRunningPaths.clear();
2290
- agentQueuedPaths.clear();
2291
- if (runningSessionId) {
2292
- updateSessionStatus(runningSessionId, 'ok');
2293
- if (activeSessionId === runningSessionId) {
2294
- document.getElementById('agentPanelStatus').textContent = '✅ готово';
2295
- }
2296
- runningSessionId = null;
2297
- }
2042
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
2298
2043
  renderContent();
2299
2044
  });
2300
2045
 
2301
2046
  es.addEventListener('agent-summary', (e) => {
2302
2047
  const { passed, failed, files } = JSON.parse(e.data);
2048
+ const term = document.getElementById('agentTerminal');
2303
2049
  const allOk = failed === 0;
2304
2050
  const box = document.createElement('div');
2305
2051
  box.style.cssText = `
@@ -2316,82 +2062,66 @@ function connectSSE() {
2316
2062
  </div>
2317
2063
  ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
2318
2064
  `;
2319
- const targetId = runningSessionId || activeSessionId;
2320
- if (targetId) {
2321
- appendToSession(targetId, box);
2322
- // Mark session status from test results immediately
2323
- if (runningSessionId === targetId) {
2324
- updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
2325
- }
2326
- }
2065
+ term.appendChild(box);
2066
+ term.scrollTop = term.scrollHeight;
2327
2067
  });
2328
2068
 
2329
2069
  es.addEventListener('agent-error', (e) => {
2330
2070
  setAgentRunning(false);
2331
- agentRunningPaths.clear();
2332
- agentQueuedPaths.clear();
2333
- renderContent();
2334
- const { message, authRequired, notInstalled } = JSON.parse(e.data);
2335
-
2336
- // Build error node (plain text or auth/install box)
2337
- let node = null;
2338
- if (authRequired || notInstalled) {
2339
- node = document.createElement('div');
2340
- node.style.cssText = 'margin:10px 0 4px;padding:10px 14px;border-radius:8px;border:1px solid var(--red);background:#2a0d0d;font-family:inherit;';
2341
- node.innerHTML = `
2342
- <div style="font-size:13px;font-weight:700;color:var(--red)">❌ ${message || 'Ошибка агента'}</div>
2343
- ${authRequired ? `<button onclick="reauthAgent()" style="margin-top:8px;padding:4px 12px;font-size:12px;background:none;border:1px solid var(--yellow);color:var(--yellow);border-radius:4px;cursor:pointer;">🔑 Перелогиниться</button>` : ''}
2344
- ${notInstalled ? `<div style="margin-top:6px;font-size:11px;color:var(--dim)">После установки перезапусти viberadar</div>` : ''}
2345
- `;
2346
- }
2347
-
2348
- // If no session exists yet (startup check fires before any run), create one
2349
- const targetId = runningSessionId || (() => {
2350
- if (authRequired || notInstalled) {
2351
- const id = createSession('⚠️ Проверка агента', 'error');
2352
- return id;
2353
- }
2354
- return activeSessionId;
2355
- })();
2356
-
2357
- if (targetId) {
2358
- if (node) {
2359
- appendToSession(targetId, node);
2360
- } else {
2361
- appendToSession(targetId, '❌ ' + (message || 'Ошибка агента'), true);
2362
- }
2363
- updateSessionStatus(targetId, 'error');
2364
- if (activeSessionId === targetId) {
2365
- document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
2366
- }
2367
- }
2368
- if (runningSessionId) runningSessionId = null;
2071
+ const { message } = JSON.parse(e.data);
2072
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
2073
+ appendTerminalLine('❌ ' + (message || 'Ошибка агента'), true);
2369
2074
  });
2370
2075
 
2371
2076
  es.addEventListener('tests-started', (e) => {
2372
2077
  const { testType, count } = JSON.parse(e.data);
2373
- const id = createSession(`🧪 ${testType}`);
2374
- runningSessionId = id;
2375
- appendToSession(id, `Запускаю ${testType} тесты (${count} файлов)…`);
2376
2078
  document.getElementById('agentPanelTitle').textContent = `🧪 ${testType} тесты`;
2377
2079
  document.getElementById('agentPanelStatus').textContent = `запускаю ${count} файлов…`;
2378
2080
  });
2379
2081
 
2380
2082
  es.addEventListener('tests-done', (e) => {
2381
2083
  const { passed, failed, testErrors } = JSON.parse(e.data);
2382
- const status = failed === 0 ? 'ok' : 'error';
2383
- if (runningSessionId) {
2384
- updateSessionStatus(runningSessionId, status);
2385
- if (activeSessionId === runningSessionId) {
2386
- document.getElementById('agentPanelStatus').textContent =
2387
- failed === 0 ? `✅ ${passed} passed` : `⚠️ ${passed} passed, ${failed} failed`;
2388
- }
2389
- runningSessionId = null;
2390
- }
2084
+ document.getElementById('agentPanelStatus').textContent =
2085
+ failed === 0 ? `✅ ${passed} passed` : `⚠️ ${passed} passed, ${failed} failed`;
2086
+ // Apply testErrors directly from event — no separate fetch needed
2391
2087
  D.testErrors = testErrors || {};
2392
2088
  renderContent();
2393
2089
  });
2394
2090
 
2091
+ // ── E2E plan events ──────────────────────────────────────────────────────
2092
+ es.addEventListener('e2e-plan-generating', () => {
2093
+ e2ePlanLoading = true;
2094
+ renderContent();
2095
+ });
2096
+
2097
+ es.addEventListener('e2e-plan-ready', (e) => {
2098
+ const data = JSON.parse(e.data);
2099
+ e2ePlanLoading = false;
2100
+ if (drillFeatureKey === data.featureKey) {
2101
+ e2ePlan = data.plan;
2102
+ renderContent();
2103
+ }
2104
+ });
2105
+
2106
+ es.addEventListener('e2e-plan-error', (e) => {
2107
+ e2ePlanLoading = false;
2108
+ const data = JSON.parse(e.data);
2109
+ if (drillFeatureKey === data.featureKey) {
2110
+ e2ePlan = null;
2111
+ renderContent();
2112
+ }
2113
+ });
2114
+
2115
+ es.addEventListener('e2e-tests-done', (e) => {
2116
+ const data = JSON.parse(e.data);
2117
+ if (drillFeatureKey === data.featureKey) {
2118
+ e2ePlan = data.plan;
2119
+ renderContent();
2120
+ }
2121
+ document.getElementById('agentPanelStatus').textContent =
2122
+ data.failed === 0 ? `✅ E2E: ${data.passed} passed` : `⚠️ E2E: ${data.passed}/${data.failed}`;
2123
+ });
2124
+
2395
2125
  es.onerror = () => {
2396
2126
  setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
2397
2127
  es.close();
@@ -2399,15 +2129,7 @@ function connectSSE() {
2399
2129
  };
2400
2130
  }
2401
2131
 
2402
- init().then(() => {
2403
- restoreSessions();
2404
- connectSSE();
2405
- // Sync content padding with terminal panel open state automatically
2406
- new MutationObserver(() => {
2407
- const isOpen = document.getElementById('agentPanel').classList.contains('open');
2408
- document.getElementById('content').classList.toggle('panel-open', isOpen);
2409
- }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
2410
- });
2132
+ init().then(() => connectSSE());
2411
2133
  </script>
2412
2134
  </body>
2413
2135
  </html>