viberadar 0.3.46 → 0.3.48

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,6 +83,68 @@
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
+
86
148
  /* ── Stats bar ───────────────────────────────────────────────────────────── */
87
149
  .stats-bar {
88
150
  display: flex;
@@ -172,7 +234,8 @@
172
234
  .type-count { margin-left: auto; font-size: 11px; color: var(--dim); }
173
235
 
174
236
  /* ── Content area ────────────────────────────────────────────────────────── */
175
- .content { flex: 1; overflow-y: auto; padding: 18px 20px; }
237
+ .content { flex: 1; overflow-y: auto; padding: 18px 20px; transition: padding-bottom 0.25s ease; }
238
+ .content.panel-open { padding-bottom: 300px; }
176
239
 
177
240
  /* ── Feature cards ───────────────────────────────────────────────────────── */
178
241
  .features-grid {
@@ -420,56 +483,48 @@
420
483
  /* ── Test-type cards inside feature detail ───────────────────────────────── */
421
484
  .test-type-grid {
422
485
  display: grid;
423
- grid-template-columns: repeat(4, 1fr);
486
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
424
487
  gap: 10px;
425
- margin-bottom: 20px;
488
+ margin-bottom: 18px;
426
489
  }
427
490
  .test-type-card {
491
+ display: flex;
492
+ flex-direction: column;
428
493
  background: var(--bg-card);
429
494
  border: 1px solid var(--border);
495
+ border-top: 3px solid transparent;
430
496
  border-radius: 8px;
431
- padding: 14px 16px;
497
+ padding: 12px 14px 10px;
432
498
  cursor: pointer;
433
499
  transition: background 0.15s, border-color 0.15s;
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;
500
+ min-width: 0;
457
501
  }
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; }
458
505
  .test-type-card.tt-failed {
459
506
  border-color: var(--red) !important;
460
- border-width: 2px;
461
- background: rgba(248, 81, 73, 0.06);
462
- }
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; }
463
515
  .tt-run-btn {
464
- display: block; width: 100%; margin-top: 8px;
465
- padding: 3px 0;
516
+ width: 100%; padding: 5px 10px;
466
517
  background: transparent; border: 1px solid var(--border);
467
- border-radius: 4px; color: var(--muted); font-size: 10px;
518
+ border-radius: 5px; color: var(--muted); font-size: 11px;
468
519
  cursor: pointer; text-align: center;
469
520
  transition: background 0.1s, color 0.1s, border-color 0.1s;
470
521
  }
471
522
  .tt-run-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
472
523
  .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); }
473
528
  .file-rows { display: flex; flex-direction: column; gap: 2px; }
474
529
  .file-row {
475
530
  display: flex;
@@ -521,6 +576,10 @@
521
576
  background: rgba(248, 81, 73, 0.07);
522
577
  }
523
578
  .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); } }
524
583
  .file-row-errors {
525
584
  padding: 4px 10px 6px 32px;
526
585
  display: flex; flex-direction: column; gap: 3px;
@@ -602,11 +661,74 @@
602
661
  font-size: 14px; padding: 3px 6px; border-radius: 4px; line-height: 1;
603
662
  }
604
663
  .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); }
605
670
  .agent-panel-cancel {
606
671
  background: none; border: 1px solid var(--yellow); color: var(--yellow);
607
672
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
608
673
  }
609
674
  .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); }
610
732
  .agent-terminal {
611
733
  flex: 1;
612
734
  overflow-y: auto;
@@ -619,96 +741,50 @@
619
741
  .agent-line.err { color: var(--red); }
620
742
  .agent-line.dim { color: var(--dim); font-size: 10px; }
621
743
 
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
-
709
744
  /* ── Misc ────────────────────────────────────────────────────────────────── */
710
745
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
711
746
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
747
+ /* ─── E2E Plan UI ─────────────────────────────────────────────────── */
748
+ .e2e-plan-container { padding: 16px 0; }
749
+ .e2e-batch-bar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px; align-items:center; }
750
+ .e2e-stats { font-size:12px; color:var(--dim); margin-left:auto; }
751
+ .e2e-btn { padding:5px 12px; border-radius:6px; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:12px; cursor:pointer; transition:background 0.15s; }
752
+ .e2e-btn:hover { background:var(--surface3); }
753
+ .e2e-btn-approve { border-color:#2ea043; color:#2ea043; }
754
+ .e2e-btn-approve:hover { background:rgba(46,160,67,0.15); }
755
+ .e2e-btn-reject { border-color:#f85149; color:#f85149; }
756
+ .e2e-btn-reject:hover { background:rgba(248,81,73,0.15); }
757
+ .e2e-btn-primary { background:#1f6feb; border-color:#1f6feb; color:#fff; }
758
+ .e2e-btn-primary:hover { background:#388bfd; }
759
+ .e2e-btn-secondary { background:var(--surface3); }
760
+ .e2e-case { border:1px solid var(--border); border-radius:8px; padding:14px; margin-bottom:10px; transition:border-color 0.2s; }
761
+ .e2e-case.approved { border-color:#2ea043; background:rgba(46,160,67,0.05); }
762
+ .e2e-case.rejected { border-color:#f85149; background:rgba(248,81,73,0.05); opacity:0.6; }
763
+ .e2e-case.written { border-color:#58a6ff; background:rgba(88,166,255,0.05); }
764
+ .e2e-case.passed { border-color:#2ea043; background:rgba(46,160,67,0.08); }
765
+ .e2e-case.failed { border-color:#f85149; background:rgba(248,81,73,0.08); }
766
+ .e2e-case-header { display:flex; align-items:flex-start; gap:10px; margin-bottom:8px; }
767
+ .e2e-case-name { font-weight:600; font-size:13px; flex:1; }
768
+ .e2e-status-badge { font-size:11px; padding:2px 8px; border-radius:10px; white-space:nowrap; }
769
+ .e2e-status-badge.pending { background:var(--surface3); color:var(--dim); }
770
+ .e2e-status-badge.approved { background:rgba(46,160,67,0.2); color:#2ea043; }
771
+ .e2e-status-badge.rejected { background:rgba(248,81,73,0.2); color:#f85149; }
772
+ .e2e-status-badge.written { background:rgba(88,166,255,0.2); color:#58a6ff; }
773
+ .e2e-status-badge.passed { background:rgba(46,160,67,0.2); color:#2ea043; }
774
+ .e2e-status-badge.failed { background:rgba(248,81,73,0.2); color:#f85149; }
775
+ .e2e-case-desc { font-size:12px; color:var(--dim); margin-bottom:8px; }
776
+ .e2e-steps { font-size:12px; color:var(--text); margin:4px 0; }
777
+ .e2e-steps ol { margin:4px 0 0 16px; padding:0; }
778
+ .e2e-steps li { margin-bottom:2px; }
779
+ .e2e-expected { font-size:12px; color:var(--dim); margin-top:4px; }
780
+ .e2e-case-actions { display:flex; gap:6px; margin-top:10px; }
781
+ .e2e-error { font-size:11px; color:#f85149; margin-top:6px; font-family:monospace; }
782
+ .e2e-screenshot-strip { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
783
+ .e2e-screenshot { width:80px; height:60px; object-fit:cover; border-radius:4px; border:1px solid var(--border); cursor:pointer; transition:opacity 0.15s; }
784
+ .e2e-screenshot:hover { opacity:0.8; }
785
+ .e2e-screenshot-modal { position:fixed; inset:0; background:rgba(0,0,0,0.8); z-index:9999; display:flex; align-items:center; justify-content:center; cursor:pointer; }
786
+ .e2e-screenshot-modal img { max-width:90vw; max-height:90vh; border-radius:8px; }
787
+ .e2e-plan-indicator { font-size:10px; color:#d2a8ff; margin-left:4px; }
712
788
  </style>
713
789
  </head>
714
790
  <body>
@@ -720,6 +796,31 @@
720
796
  <span class="header-time" id="scannedAt"></span>
721
797
  <button id="covBtn" onclick="runCoverage()" title="Запустить тесты с coverage">🧪 Coverage</button>
722
798
  <button id="termBtn" onclick="toggleAgentPanel()" title="Показать/скрыть терминал агента">📟 Terminal</button>
799
+ <div style="position:relative">
800
+ <button id="agentBtn" onclick="toggleAgentMenu()" title="Настройки агента">🤖 —</button>
801
+ <div id="agentMenu" class="agent-menu">
802
+ <div class="agent-menu-label">AI Агент</div>
803
+ <button class="agent-menu-item" id="amClaude" onclick="setAgent('claude');closeAgentMenu()">
804
+ <span>⚡ Claude Code</span><span class="agent-menu-check" id="amClaudeCheck">✓</span>
805
+ </button>
806
+ <button class="agent-menu-item" id="amCodex" onclick="setAgent('codex');closeAgentMenu()">
807
+ <span>🟢 Codex</span><span class="agent-menu-check" id="amCodexCheck">✓</span>
808
+ </button>
809
+ <div class="agent-menu-divider" id="amModelDivider"></div>
810
+ <div class="agent-menu-label" id="amModelLabel">Модель Claude</div>
811
+ <button class="agent-menu-item" id="amSonnet46" onclick="setModel('claude-sonnet-4-6');closeAgentMenu()">
812
+ <span>sonnet-4-6 <span style="color:var(--dim);font-size:10px">быстрый</span></span><span class="agent-menu-check" id="amSonnet46Check">✓</span>
813
+ </button>
814
+ <button class="agent-menu-item" id="amOpus45" onclick="setModel('claude-opus-4-5');closeAgentMenu()">
815
+ <span>opus-4-5 <span style="color:var(--dim);font-size:10px">умный</span></span><span class="agent-menu-check" id="amOpus45Check">✓</span>
816
+ </button>
817
+ <button class="agent-menu-item" id="amHaiku35" onclick="setModel('claude-haiku-3-5');closeAgentMenu()">
818
+ <span>haiku-3-5 <span style="color:var(--dim);font-size:10px">дешёвый</span></span><span class="agent-menu-check" id="amHaiku35Check">✓</span>
819
+ </button>
820
+ <div class="agent-menu-divider"></div>
821
+ <button class="agent-menu-item" onclick="reauthAgent();closeAgentMenu()">🔑 Перелогиниться</button>
822
+ </div>
823
+ </div>
723
824
  <span id="liveDot" title="Connecting…" style="
724
825
  width:8px; height:8px; border-radius:50%;
725
826
  background:var(--dim); display:inline-block;
@@ -754,9 +855,13 @@
754
855
  <div class="agent-panel-header">
755
856
  <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
756
857
  <span class="agent-panel-status" id="agentPanelStatus">running…</span>
858
+ <span class="agent-queue-badge" id="agentQueueBadge" style="display:none">📋 <span id="agentQueueCount">0</span> в очереди</span>
859
+ <button class="agent-panel-cancel" id="agentQueueClearBtn" onclick="clearAgentQueue()" title="Очистить очередь" style="display:none">🗑 очередь</button>
757
860
  <button class="agent-panel-cancel" id="agentCancelBtn" onclick="cancelAgent()" title="Сбросить состояние агента" style="display:none">⏹ сброс</button>
861
+ <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
758
862
  <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
759
863
  </div>
864
+ <div class="agent-tabs-bar" id="agentTabsBar"></div>
760
865
  <div class="agent-terminal" id="agentTerminal"></div>
761
866
  </div>
762
867
 
@@ -769,205 +874,10 @@ let activeTypes = new Set();
769
874
  let activePanelKey = null;
770
875
  let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
771
876
  let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
877
+ let e2ePlan = null; // current E2E plan object
878
+ let e2ePlanLoading = false;
772
879
  let coverageRunning = false;
773
880
  let coverageHasError = false;
774
- let e2ePlan = null;
775
- let e2ePlanLoading = false;
776
-
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
- });
789
- }
790
-
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; }
797
- }
798
-
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(); }
806
- }
807
-
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(); }
815
- }
816
-
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
- });
824
- }
825
-
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
- });
832
- }
833
-
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);
843
- }
844
-
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>';
856
- }
857
-
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;
914
- }
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;
936
- }
937
-
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>';
970
- }
971
881
 
972
882
  // ─── Coverage button ───────────────────────────────────────────────────────────
973
883
  function updateCovBtn() {
@@ -994,6 +904,182 @@ async function runCoverage() {
994
904
 
995
905
  // ─── Agent ────────────────────────────────────────────────────────────────────
996
906
  let agentRunning = false;
907
+ const agentRunningPaths = new Set(); // paths of files currently being processed by agent
908
+ const agentQueuedPaths = new Set(); // paths of files waiting in queue
909
+
910
+ // ─── Console Sessions ─────────────────────────────────────────────────────────
911
+ const consoleSessions = []; // { id, title, lines, status, startTime }
912
+ let activeSessionId = null; // currently viewed tab
913
+ let runningSessionId = null; // tab that is currently receiving output
914
+ const SESSION_MAX = 25;
915
+ const SESSIONS_KEY = 'viberadar_sessions';
916
+
917
+ function _sessionId() {
918
+ return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
919
+ }
920
+
921
+ function saveSessions() {
922
+ try {
923
+ const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
924
+ ...s, lines: s.lines.slice(-500)
925
+ }));
926
+ localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
927
+ } catch {}
928
+ }
929
+
930
+ function restoreSessions() {
931
+ try {
932
+ const raw = localStorage.getItem(SESSIONS_KEY);
933
+ if (!raw) return;
934
+ const saved = JSON.parse(raw);
935
+ consoleSessions.push(...saved);
936
+ for (const s of consoleSessions) {
937
+ if (s.status === 'running') {
938
+ s.status = 'error';
939
+ s.lines.push({ text: '⚡ Прервано (перезагрузка страницы)', isError: true });
940
+ }
941
+ }
942
+ if (consoleSessions.length > 0) {
943
+ activeSessionId = consoleSessions[consoleSessions.length - 1].id;
944
+ renderTabs();
945
+ renderActiveSession();
946
+ }
947
+ } catch {}
948
+ }
949
+
950
+ function createSession(title, status = 'running') {
951
+ if (consoleSessions.length >= SESSION_MAX) consoleSessions.shift();
952
+ const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now() };
953
+ consoleSessions.push(s);
954
+ activeSessionId = s.id;
955
+ document.getElementById('agentPanel').classList.add('open');
956
+ document.getElementById('termBtn').classList.add('term-active');
957
+ renderTabs();
958
+ renderActiveSession();
959
+ saveSessions();
960
+ return s.id;
961
+ }
962
+
963
+ function switchSession(id) {
964
+ activeSessionId = id;
965
+ const s = consoleSessions.find(s => s.id === id);
966
+ if (s) {
967
+ const statusText = s.status === 'running' ? 'работает…'
968
+ : s.status === 'ok' ? '✅ готово'
969
+ : s.status === 'error' ? '❌ ошибка'
970
+ : '';
971
+ document.getElementById('agentPanelTitle').textContent = s.title;
972
+ document.getElementById('agentPanelStatus').textContent = statusText;
973
+ }
974
+ renderTabs();
975
+ renderActiveSession();
976
+ }
977
+
978
+ function closeSession(id) {
979
+ const idx = consoleSessions.findIndex(s => s.id === id);
980
+ if (idx === -1) return;
981
+ consoleSessions.splice(idx, 1);
982
+ if (activeSessionId === id) {
983
+ activeSessionId = consoleSessions.length > 0
984
+ ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
985
+ : null;
986
+ }
987
+ renderTabs();
988
+ renderActiveSession();
989
+ saveSessions();
990
+ }
991
+
992
+ function appendToSession(id, lineOrNode, isError = false, isDim = false) {
993
+ const s = consoleSessions.find(s => s.id === id);
994
+ if (!s) return;
995
+ let stored;
996
+ if (typeof lineOrNode === 'string') {
997
+ stored = { text: lineOrNode, isError, isDim };
998
+ } else {
999
+ stored = { html: lineOrNode.outerHTML };
1000
+ }
1001
+ s.lines.push(stored);
1002
+ if (activeSessionId === id) {
1003
+ const term = document.getElementById('agentTerminal');
1004
+ const el = document.createElement('div');
1005
+ if (stored.html) {
1006
+ el.innerHTML = stored.html;
1007
+ } else {
1008
+ el.className = 'agent-line' + (isError ? ' err' : isDim ? ' dim' : '');
1009
+ el.textContent = lineOrNode;
1010
+ }
1011
+ term.appendChild(el);
1012
+ term.scrollTop = term.scrollHeight;
1013
+ }
1014
+ }
1015
+
1016
+ function updateSessionStatus(id, status) {
1017
+ const s = consoleSessions.find(s => s.id === id);
1018
+ if (!s) return;
1019
+ s.status = status;
1020
+ renderTabs();
1021
+ saveSessions();
1022
+ }
1023
+
1024
+ function renderTabs() {
1025
+ const bar = document.getElementById('agentTabsBar');
1026
+ if (!bar) return;
1027
+ bar.innerHTML = consoleSessions.map(s => {
1028
+ const isActive = s.id === activeSessionId;
1029
+ const safe = s.title.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1030
+ return `<div class="agent-tab${isActive ? ' active' : ''}"
1031
+ onclick="switchSession('${s.id}')"
1032
+ onmousedown="if(event.button===1){event.preventDefault();event.stopPropagation();closeSession('${s.id}')}">
1033
+ <span class="tab-dot tab-dot-${s.status}"></span>
1034
+ <span class="agent-tab-title" title="${safe}">${safe}</span>
1035
+ <button class="agent-tab-close" onclick="event.stopPropagation();closeSession('${s.id}')" title="Закрыть вкладку">×</button>
1036
+ </div>`;
1037
+ }).join('');
1038
+ const active = bar.querySelector('.agent-tab.active');
1039
+ if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1040
+ }
1041
+
1042
+ function copyTerminalContent() {
1043
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1044
+ if (!s || s.lines.length === 0) return;
1045
+ // Extract plain text from each line (strip HTML for rich nodes)
1046
+ const text = s.lines.map(l => {
1047
+ if (l.text !== undefined) return l.text;
1048
+ if (l.html) {
1049
+ const tmp = document.createElement('div');
1050
+ tmp.innerHTML = l.html;
1051
+ return tmp.innerText;
1052
+ }
1053
+ return '';
1054
+ }).filter(Boolean).join('\n');
1055
+ navigator.clipboard.writeText(text).then(() => {
1056
+ const btn = document.getElementById('agentCopyBtn');
1057
+ if (!btn) return;
1058
+ const prev = btn.textContent;
1059
+ btn.textContent = '✓';
1060
+ btn.classList.add('copied');
1061
+ setTimeout(() => { btn.textContent = prev; btn.classList.remove('copied'); }, 1500);
1062
+ });
1063
+ }
1064
+
1065
+ function renderActiveSession() {
1066
+ const term = document.getElementById('agentTerminal');
1067
+ if (!term) return;
1068
+ term.innerHTML = '';
1069
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1070
+ if (!s) return;
1071
+ for (const ln of s.lines) {
1072
+ const el = document.createElement('div');
1073
+ if (ln.html) {
1074
+ el.innerHTML = ln.html;
1075
+ } else {
1076
+ el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1077
+ el.textContent = ln.text;
1078
+ }
1079
+ term.appendChild(el);
1080
+ }
1081
+ term.scrollTop = term.scrollHeight;
1082
+ }
997
1083
 
998
1084
  async function setAgent(agent) {
999
1085
  await fetch('/api/set-agent', {
@@ -1004,30 +1090,182 @@ async function setAgent(agent) {
1004
1090
  // scheduleRescan will fire → data-updated → D.agent updates
1005
1091
  }
1006
1092
 
1093
+ async function setModel(model) {
1094
+ await fetch('/api/set-model', {
1095
+ method: 'POST',
1096
+ headers: { 'Content-Type': 'application/json' },
1097
+ body: JSON.stringify({ model }),
1098
+ });
1099
+ // scheduleRescan will fire → data-updated → D.model updates
1100
+ }
1101
+
1007
1102
  function setAgentRunning(val) {
1008
1103
  agentRunning = val;
1009
1104
  document.getElementById('agentCancelBtn').style.display = val ? 'inline-block' : 'none';
1010
1105
  }
1011
1106
 
1107
+ // Returns array of normalized relative paths affected by a given task
1108
+ function getTaskFilePaths(task, featureKey, filePath) {
1109
+ if ((task === 'write-tests-file' || task === 'fix-tests') && filePath) {
1110
+ return [filePath.replace(/\\/g, '/')];
1111
+ }
1112
+ if ((task === 'write-tests' || task === 'fix-tests-all') && featureKey && D?.modules) {
1113
+ return D.modules
1114
+ .filter(m => m.featureKeys?.includes(featureKey) && m.type !== 'test' && (!m.hasTests || m.testStale))
1115
+ .map(m => m.relativePath.replace(/\\/g, '/'));
1116
+ }
1117
+ return [];
1118
+ }
1119
+
1120
+ // Returns 'running', 'queued', or null for a given relative path
1121
+ function isFileAgentActive(relPath) {
1122
+ const n = relPath.replace(/\\/g, '/');
1123
+ if (agentRunningPaths.has(n)) return 'running';
1124
+ if (agentQueuedPaths.has(n)) return 'queued';
1125
+ return null;
1126
+ }
1127
+
1128
+ function updateQueueBadge(n) {
1129
+ document.getElementById('agentQueueCount').textContent = n;
1130
+ document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1131
+ document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1132
+ }
1133
+
1012
1134
  async function cancelAgent() {
1013
1135
  await fetch('/api/cancel-agent', { method: 'POST' });
1014
1136
  setAgentRunning(false);
1137
+ updateQueueBadge(0);
1015
1138
  document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1016
- appendTerminalLine('⏹ Состояние агента сброшено', false);
1139
+ if (runningSessionId) {
1140
+ appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1141
+ updateSessionStatus(runningSessionId, 'error');
1142
+ runningSessionId = null;
1143
+ } else {
1144
+ appendTerminalLine('⏹ Состояние агента сброшено (очередь очищена)', false);
1145
+ }
1017
1146
  }
1018
1147
 
1019
- async function runAgentTask(task, featureKey, filePath) {
1020
- if (agentRunning) {
1021
- // Show panel so user sees cancel button
1148
+ async function clearAgentQueue() {
1149
+ await fetch('/api/clear-queue', { method: 'POST' });
1150
+ updateQueueBadge(0);
1151
+ appendTerminalLine('🗑 Очередь очищена', false);
1152
+ }
1153
+
1154
+ // ─── File row more menu ──────────────────────────────────────────────────────
1155
+ let _openFileMenu = null;
1156
+
1157
+ function toggleFileMenu(btn, featureKey, relPath) {
1158
+ const dropdown = btn.nextElementSibling;
1159
+ const isOpen = dropdown.classList.contains('open');
1160
+ // Close any open menu first
1161
+ if (_openFileMenu && _openFileMenu !== dropdown) {
1162
+ _openFileMenu.classList.remove('open');
1163
+ }
1164
+ dropdown.classList.toggle('open', !isOpen);
1165
+ _openFileMenu = isOpen ? null : dropdown;
1166
+ }
1167
+
1168
+ // Close menu on outside click
1169
+ document.addEventListener('click', () => {
1170
+ if (_openFileMenu) { _openFileMenu.classList.remove('open'); _openFileMenu = null; }
1171
+ });
1172
+
1173
+ async function copyPromptForFile(featureKey, relPath, dropdown) {
1174
+ if (dropdown) { dropdown.classList.remove('open'); _openFileMenu = null; }
1175
+ try {
1176
+ const res = await fetch('/api/get-prompt', {
1177
+ method: 'POST',
1178
+ headers: { 'Content-Type': 'application/json' },
1179
+ body: JSON.stringify({ task: 'write-tests-file', featureKey, filePath: relPath }),
1180
+ });
1181
+ const { prompt, error } = await res.json();
1182
+ if (error || !prompt) { alert('Не удалось получить промпт: ' + (error || 'пустой')); return; }
1183
+ await navigator.clipboard.writeText(prompt);
1184
+ // Brief visual feedback in terminal
1185
+ document.getElementById('agentPanel').classList.add('open');
1186
+ document.getElementById('termBtn').classList.add('term-active');
1187
+ appendTerminalLine(`📋 Промпт для "${relPath.split('/').pop()}" скопирован в буфер обмена`, false);
1188
+ appendTerminalLine(' Вставь его в другой агент (Claude, Codex, ChatGPT) и запусти вручную.', false);
1189
+ } catch (e) {
1190
+ alert('Ошибка копирования: ' + e.message);
1191
+ }
1192
+ }
1193
+
1194
+ // ─── Agent menu ─────────────────────────────────────────────────────────────
1195
+ const CLAUDE_MODELS = [
1196
+ { id: 'claude-sonnet-4-6', checkId: 'amSonnet46Check' },
1197
+ { id: 'claude-opus-4-5', checkId: 'amOpus45Check' },
1198
+ { id: 'claude-haiku-3-5', checkId: 'amHaiku35Check' },
1199
+ ];
1200
+
1201
+ function updateAgentBtn() {
1202
+ const btn = document.getElementById('agentBtn');
1203
+ if (!D) return;
1204
+ const a = D.agent;
1205
+ const m = D.model;
1206
+ // Button label: agent + model shortname
1207
+ let label = a === 'claude' ? '🤖 Claude' : a === 'codex' ? '🤖 Codex' : '🤖 не выбран';
1208
+ if (a === 'claude' && m) {
1209
+ // Show short model name: "sonnet-4-6", "opus-4-5", "haiku-3-5"
1210
+ const short = m.replace('claude-', '');
1211
+ label += ` · ${short}`;
1212
+ }
1213
+ btn.textContent = label;
1214
+ // Update agent checkmarks
1215
+ document.getElementById('amClaudeCheck').classList.toggle('active', a === 'claude');
1216
+ document.getElementById('amCodexCheck').classList.toggle('active', a === 'codex');
1217
+ // Show/hide model section (only for claude)
1218
+ const isClaude = a === 'claude';
1219
+ document.getElementById('amModelDivider').style.display = isClaude ? '' : 'none';
1220
+ document.getElementById('amModelLabel').style.display = isClaude ? '' : 'none';
1221
+ CLAUDE_MODELS.forEach(({ id, checkId }) => {
1222
+ const el = document.getElementById(id);
1223
+ if (el) el.style.display = isClaude ? '' : 'none';
1224
+ document.getElementById(checkId).classList.toggle('active', m === id);
1225
+ });
1226
+ }
1227
+
1228
+ function toggleAgentMenu() {
1229
+ const menu = document.getElementById('agentMenu');
1230
+ const isOpen = menu.classList.contains('open');
1231
+ menu.classList.toggle('open');
1232
+ if (!isOpen) {
1233
+ // Close on click outside
1234
+ setTimeout(() => {
1235
+ document.addEventListener('click', closeAgentMenuOnOutside, { once: true });
1236
+ }, 0);
1237
+ }
1238
+ }
1239
+ function closeAgentMenu() {
1240
+ document.getElementById('agentMenu').classList.remove('open');
1241
+ }
1242
+ function closeAgentMenuOnOutside(e) {
1243
+ if (!e.target.closest('#agentMenu') && !e.target.closest('#agentBtn')) {
1244
+ closeAgentMenu();
1245
+ }
1246
+ }
1247
+
1248
+ async function reauthAgent() {
1249
+ const agent = D?.agent;
1250
+ if (!agent) {
1251
+ appendTerminalLine('⚠ Сначала выбери агента (Claude или Codex)', true);
1022
1252
  document.getElementById('agentPanel').classList.add('open');
1023
1253
  document.getElementById('termBtn').classList.add('term-active');
1024
- appendTerminalLine('⚠️ Агент уже запущен. Нажми ⏹ сброс чтобы отменить.', true);
1025
1254
  return;
1026
1255
  }
1027
- document.getElementById('agentTerminal').innerHTML = '';
1028
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1256
+ const id = createSession('🔑 Перелогинивание');
1257
+ runningSessionId = id;
1258
+ document.getElementById('agentPanelTitle').textContent = '🔑 Перелогинивание';
1259
+ document.getElementById('agentPanelStatus').textContent = 'выполняю…';
1260
+ await fetch('/api/agent-reauth', { method: 'POST' });
1261
+ }
1262
+
1263
+ async function runAgentTask(task, featureKey, filePath) {
1029
1264
  document.getElementById('agentPanel').classList.add('open');
1030
1265
  document.getElementById('termBtn').classList.add('term-active');
1266
+ if (!agentRunning) {
1267
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1268
+ }
1031
1269
  await fetch('/api/run-agent', {
1032
1270
  method: 'POST',
1033
1271
  headers: { 'Content-Type': 'application/json' },
@@ -1036,11 +1274,9 @@ async function runAgentTask(task, featureKey, filePath) {
1036
1274
  }
1037
1275
 
1038
1276
  async function runTests(featureKey, testType) {
1039
- document.getElementById('agentTerminal').innerHTML = '';
1040
- document.getElementById('agentPanelTitle').textContent = `🧪 ${testType} тесты`;
1041
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1042
1277
  document.getElementById('agentPanel').classList.add('open');
1043
1278
  document.getElementById('termBtn').classList.add('term-active');
1279
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1044
1280
  await fetch('/api/run-tests', {
1045
1281
  method: 'POST',
1046
1282
  headers: { 'Content-Type': 'application/json' },
@@ -1061,12 +1297,11 @@ function toggleAgentPanel() {
1061
1297
  }
1062
1298
 
1063
1299
  function appendTerminalLine(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;
1300
+ if (!activeSessionId) {
1301
+ const id = createSession('Консоль', 'info');
1302
+ activeSessionId = id;
1303
+ }
1304
+ appendToSession(activeSessionId, line, !!isError, !!isDim);
1070
1305
  }
1071
1306
 
1072
1307
  // ─── Color helpers ────────────────────────────────────────────────────────────
@@ -1113,6 +1348,7 @@ async function init() {
1113
1348
  coverageHasError = status.coverageError ?? false;
1114
1349
  }
1115
1350
  updateCovBtn();
1351
+ updateAgentBtn();
1116
1352
 
1117
1353
  document.getElementById('projectName').textContent = D.projectName;
1118
1354
  document.getElementById('scannedAt').textContent =
@@ -1364,32 +1600,179 @@ function renderFeatureCards(c) {
1364
1600
  }
1365
1601
  }
1366
1602
 
1603
+ // ─── E2E Plan functions ────────────────────────────────────────────────────────
1604
+
1605
+ async function generateE2ePlan(featureKey) {
1606
+ if (D.agentRunning) { alert('Агент уже запущен'); return; }
1607
+ await fetch('/api/e2e/generate-plan', {
1608
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1609
+ body: JSON.stringify({ featureKey })
1610
+ });
1611
+ }
1612
+
1613
+ async function loadE2ePlan(featureKey) {
1614
+ e2ePlanLoading = true;
1615
+ e2ePlan = null;
1616
+ try {
1617
+ const r = await fetch(`/api/e2e/plan/${encodeURIComponent(featureKey)}`);
1618
+ if (r.ok) e2ePlan = await r.json();
1619
+ } catch {}
1620
+ e2ePlanLoading = false;
1621
+ }
1622
+
1623
+ async function reviewE2eCase(featureKey, testCaseId, status) {
1624
+ const r = await fetch('/api/e2e/review', {
1625
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1626
+ body: JSON.stringify({ featureKey, testCaseId, status })
1627
+ });
1628
+ if (r.ok) { const data = await r.json(); e2ePlan = data.plan; renderContent(); }
1629
+ }
1630
+
1631
+ async function reviewAllE2e(featureKey, status) {
1632
+ const r = await fetch('/api/e2e/review-all', {
1633
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1634
+ body: JSON.stringify({ featureKey, status })
1635
+ });
1636
+ if (r.ok) { const data = await r.json(); e2ePlan = data.plan; renderContent(); }
1637
+ }
1638
+
1639
+ async function writeE2eTests(featureKey) {
1640
+ if (D.agentRunning) { alert('Агент уже запущен'); return; }
1641
+ await fetch('/api/e2e/write-tests', {
1642
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1643
+ body: JSON.stringify({ featureKey })
1644
+ });
1645
+ }
1646
+
1647
+ async function runE2eTests(featureKey) {
1648
+ await fetch('/api/e2e/run-tests', {
1649
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1650
+ body: JSON.stringify({ featureKey })
1651
+ });
1652
+ }
1653
+
1654
+ function openScreenshot(src) {
1655
+ const modal = document.createElement('div');
1656
+ modal.className = 'e2e-screenshot-modal';
1657
+ modal.innerHTML = `<img src="${src}" />`;
1658
+ modal.onclick = () => modal.remove();
1659
+ document.body.appendChild(modal);
1660
+ }
1661
+
1662
+ function statusBadge(status) {
1663
+ const labels = { pending:'⏳ ожидает', approved:'✅ одобрен', rejected:'❌ отклонён', written:'✍ написан', passed:'✅ прошёл', failed:'❌ упал' };
1664
+ return `<span class="e2e-status-badge ${status}">${labels[status] || status}</span>`;
1665
+ }
1666
+
1667
+ function renderE2eTestCase(tc, featureKey) {
1668
+ const screenshots = (tc.screenshotPaths || []).map(p =>
1669
+ `<img class="e2e-screenshot" src="/api/e2e/screenshot/${encodeURIComponent(p)}" onclick="openScreenshot(this.src)" />`
1670
+ ).join('');
1671
+ const screenshotStrip = screenshots ? `<div class="e2e-screenshot-strip">${screenshots}</div>` : '';
1672
+ const errorBlock = tc.lastError ? `<div class="e2e-error">❌ ${tc.lastError}</div>` : '';
1673
+ return `
1674
+ <div class="e2e-case ${tc.status}" id="e2e-case-${tc.id}">
1675
+ <div class="e2e-case-header">
1676
+ <div class="e2e-case-name">${tc.name}</div>
1677
+ ${statusBadge(tc.status)}
1678
+ </div>
1679
+ <div class="e2e-case-desc">${tc.description}</div>
1680
+ <div class="e2e-steps"><ol>${tc.steps.map(s => `<li>${s}</li>`).join('')}</ol></div>
1681
+ <div class="e2e-expected">Ожидается: ${tc.expectedResults.join('; ')}</div>
1682
+ ${errorBlock}
1683
+ ${screenshotStrip}
1684
+ <div class="e2e-case-actions">
1685
+ <button class="e2e-btn e2e-btn-approve" onclick="reviewE2eCase('${featureKey}','${tc.id}','approved')">✅ Одобрить</button>
1686
+ <button class="e2e-btn e2e-btn-reject" onclick="reviewE2eCase('${featureKey}','${tc.id}','rejected')">❌ Отклонить</button>
1687
+ </div>
1688
+ </div>`;
1689
+ }
1690
+
1691
+ function renderE2ePlanView(c) {
1692
+ const featureKey = drillFeatureKey;
1693
+ const feat = D.features.find(f => f.key === featureKey);
1694
+
1695
+ if (e2ePlanLoading) {
1696
+ c.innerHTML = `<div style="padding:40px;text-align:center;color:var(--dim)">Загружаю план…</div>`;
1697
+ return;
1698
+ }
1699
+
1700
+ if (!e2ePlan) {
1701
+ const hasPW = D.hasPlaywright;
1702
+ c.innerHTML = `
1703
+ <div class="drill-header">
1704
+ <button class="back-btn" onclick="drillTestType=null;renderContent()">← Назад</button>
1705
+ <div class="drill-title"><span>🎭 E2E план — ${feat?.label || featureKey}</span></div>
1706
+ </div>
1707
+ <div style="padding:40px;text-align:center;border:1px dashed var(--border);border-radius:8px;margin-top:16px">
1708
+ ${!hasPW ? '<div style="color:#f85149;margin-bottom:12px">⚠️ @playwright/test не найден в проекте</div>' : ''}
1709
+ <div style="color:var(--dim);margin-bottom:16px">E2E план не создан. AI проанализирует файлы фичи и предложит тест-кейсы.</div>
1710
+ <button class="e2e-btn e2e-btn-primary" onclick="generateE2ePlan('${featureKey}')">▶ Сгенерировать план</button>
1711
+ </div>`;
1712
+ return;
1713
+ }
1714
+
1715
+ const total = e2ePlan.testCases.length;
1716
+ const approved = e2ePlan.testCases.filter(t => t.status === 'approved').length;
1717
+ const rejected = e2ePlan.testCases.filter(t => t.status === 'rejected').length;
1718
+ const written = e2ePlan.testCases.filter(t => ['written','passed','failed'].includes(t.status)).length;
1719
+ const passed = e2ePlan.testCases.filter(t => t.status === 'passed').length;
1720
+ const failed = e2ePlan.testCases.filter(t => t.status === 'failed').length;
1721
+
1722
+ c.innerHTML = `
1723
+ <div class="drill-header">
1724
+ <button class="back-btn" onclick="drillTestType=null;renderContent()">← Назад</button>
1725
+ <div class="drill-title"><span>🎭 E2E план — ${feat?.label || featureKey}</span></div>
1726
+ </div>
1727
+ <div class="e2e-plan-container">
1728
+ <div class="e2e-batch-bar">
1729
+ <button class="e2e-btn e2e-btn-approve" onclick="reviewAllE2e('${featureKey}','approved')">✅ Одобрить все</button>
1730
+ <button class="e2e-btn e2e-btn-reject" onclick="reviewAllE2e('${featureKey}','rejected')">❌ Отклонить все</button>
1731
+ <button class="e2e-btn e2e-btn-primary" onclick="writeE2eTests('${featureKey}')" ${approved===0?'disabled':''}>✍ Написать тесты (${approved})</button>
1732
+ <button class="e2e-btn e2e-btn-secondary" onclick="runE2eTests('${featureKey}')" ${written===0?'disabled':''}>▶ Запустить E2E</button>
1733
+ <button class="e2e-btn e2e-btn-secondary" onclick="generateE2ePlan('${featureKey}')">🔄 Перегенерировать</button>
1734
+ <span class="e2e-stats">${total} кейсов · ✅ ${passed} · ❌ ${failed} · одобрено: ${approved} · отклонено: ${rejected}</span>
1735
+ </div>
1736
+ ${e2ePlan.testCases.map(tc => renderE2eTestCase(tc, featureKey)).join('')}
1737
+ </div>`;
1738
+ }
1739
+
1367
1740
  function testTypeCard(type, label, icon, color, count, active, featureKey, failedCount = 0) {
1368
- const empty = count === 0 && type !== 'source';
1741
+ const empty = count === 0 && type !== 'source' && type !== 'e2e';
1369
1742
  const hasFailed = failedCount > 0 && type !== 'source';
1370
- const hasE2ePlan = type === 'e2e' && D.e2ePlansExist && D.e2ePlansExist.includes(featureKey);
1371
- const subLabel = empty
1372
- ? (type === 'e2e' && hasE2ePlan ? '📋 план создан' : 'нет тестов')
1373
- : (type === 'source' ? 'код приложения' : pluralFiles(count));
1374
- const runBtn = !empty && type !== 'source' && featureKey
1375
- ? `<button class="tt-run-btn" onclick="event.stopPropagation();${type === 'e2e' ? "runE2eTests('" + featureKey + "')" : "runTests('" + featureKey + "','" + type + "')"}">▶ запустить</button>`
1743
+ const subLabel = type === 'source' ? 'файлов'
1744
+ : type === 'e2e' ? (count > 0 ? pluralFiles(count) : (D.e2ePlansExist?.[featureKey] ? '📋 план создан' : 'нет тестов'))
1745
+ : (empty ? 'нет тестов' : pluralFiles(count));
1746
+ const accentColor = hasFailed ? 'var(--red)' : color;
1747
+ const countColor = hasFailed ? 'var(--red)' : (active || !empty || type === 'e2e' ? color : 'var(--dim)');
1748
+
1749
+ const failedBadge = hasFailed
1750
+ ? `<span class="tt-failed-badge">❌ ${failedCount}</span>`
1376
1751
  : '';
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>`
1752
+
1753
+ const runBtn = !empty && type !== 'source' && type !== 'e2e' && featureKey
1754
+ ? `<button class="tt-run-btn" onclick="event.stopPropagation();runTests('${featureKey}','${type}')">▶ запустить тесты</button>`
1381
1755
  : '';
1382
- // E2E cards should always be clickable (to see/create plan)
1383
- const forceClickable = type === 'e2e';
1756
+ const fixAllBtn = hasFailed && D.agent && featureKey
1757
+ ? `<button class="tt-run-btn tt-fix-btn" onclick="event.stopPropagation();runAgentTask('fix-tests-all','${featureKey}','${type}')">🔧 починить все</button>`
1758
+ : '';
1759
+ const writeBtn = type !== 'source' && type !== 'e2e' && D.agent && featureKey
1760
+ ? `<button class="tt-run-btn tt-write-btn" onclick="event.stopPropagation();runAgentTask('write-tests','${featureKey}')">✍ написать тесты</button>`
1761
+ : '';
1762
+ const actions = (runBtn || fixAllBtn || writeBtn)
1763
+ ? `<div class="tt-card-actions">${runBtn}${fixAllBtn}${writeBtn}</div>`
1764
+ : '';
1765
+
1384
1766
  return `
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}
1767
+ <div class="test-type-card${empty ? ' tt-empty' : ''}${active ? ' tt-active' : ''}${hasFailed ? ' tt-failed' : ''}"
1768
+ data-testtype="${type}" style="border-top-color:${accentColor}">
1769
+ <div class="tt-card-header">
1770
+ <span class="tt-label">${icon} ${label}</span>
1771
+ ${failedBadge}
1772
+ </div>
1773
+ <span class="tt-count" style="color:${countColor}">${count}</span>
1774
+ <span class="tt-sub">${subLabel}</span>
1775
+ ${actions}
1393
1776
  </div>`;
1394
1777
  }
1395
1778
 
@@ -1397,9 +1780,9 @@ function renderFeatureDetail(c) {
1397
1780
  const feat = D.features.find(f => f.key === drillFeatureKey);
1398
1781
  if (!feat) { backToFeatures(); return; }
1399
1782
 
1400
- // E2E plan view override
1783
+ // E2E tab → show plan view instead of file list
1401
1784
  if (drillTestType === 'e2e') {
1402
- renderE2ePlanView(c, drillFeatureKey);
1785
+ renderE2ePlanView(c);
1403
1786
  return;
1404
1787
  }
1405
1788
 
@@ -1632,12 +2015,16 @@ function fileRow(m, isTest = false, featureKey = null) {
1632
2015
  const dir = parts.slice(0, -1).join('/');
1633
2016
 
1634
2017
  // Test errors from last run
1635
- const testErr = isTest && D.testErrors && D.testErrors[relPath];
1636
- const icon = testErr ? '❌' : isTest ? '🧪' : (m.testStale ? '⚠️' : m.hasTests ? '✅' : '⬜');
2018
+ const testErr = isTest && D.testErrors && D.testErrors[relPath];
2019
+ const agentState = !isTest ? isFileAgentActive(relPath) : null;
2020
+ const icon = agentState
2021
+ ? `<span class="file-agent-spinner ${agentState}" title="${agentState === 'running' ? 'Агент работает…' : 'В очереди…'}"></span>`
2022
+ : (testErr ? '❌' : isTest ? '🧪' : (m.testStale ? '⚠️' : m.hasTests ? '✅' : '⬜'));
1637
2023
  const isActive = activePanelKey === m.id;
1638
2024
 
1639
2025
  // Write-test button for source files
1640
- const showAgentBtn = D.agent && !isTest && !m.isInfra && featureKey && (!m.hasTests || m.testStale);
2026
+ // In feature drill-down (featureKey set), show button even for isInfra files user explicitly added them to the feature
2027
+ const showAgentBtn = D.agent && !isTest && featureKey && (!m.hasTests || m.testStale);
1641
2028
  const agentBtnLabel = m.testStale ? '↻ обновить тест' : '✍ написать тест';
1642
2029
  const agentBtnTitle = m.testStale ? 'Файл изменился — обновить тесты' : 'Написать тест для этого файла';
1643
2030
  const agentBtn = showAgentBtn
@@ -1668,6 +2055,20 @@ function fileRow(m, isTest = false, featureKey = null) {
1668
2055
  </div>`
1669
2056
  : '';
1670
2057
 
2058
+ // "⋯" more menu — always show for source files in feature context
2059
+ const moreBtn = !isTest && featureKey
2060
+ ? `<div class="file-row-more-wrap">
2061
+ <button class="file-row-more-btn" title="Ещё"
2062
+ onclick="event.stopPropagation();toggleFileMenu(this,'${featureKey}','${relPath}')">⋯</button>
2063
+ <div class="file-row-more-dropdown">
2064
+ <button class="file-row-more-item"
2065
+ onclick="event.stopPropagation();copyPromptForFile('${featureKey}','${relPath}',this.closest('.file-row-more-dropdown'))">
2066
+ 📋 Скопировать промпт для агента
2067
+ </button>
2068
+ </div>
2069
+ </div>`
2070
+ : '';
2071
+
1671
2072
  return `
1672
2073
  <div class="file-row${isActive ? ' active' : ''}${testErr ? ' has-errors' : ''}" data-id="${m.id}">
1673
2074
  <span class="file-row-icon">${icon}</span>
@@ -1675,6 +2076,7 @@ function fileRow(m, isTest = false, featureKey = null) {
1675
2076
  ${errBadge}
1676
2077
  ${agentBtn}
1677
2078
  ${fixBtn}
2079
+ ${moreBtn}
1678
2080
  <span class="file-row-dir">${dir}</span>
1679
2081
  </div>
1680
2082
  ${errHtml}`;
@@ -1770,7 +2172,11 @@ function fileItem(m, isTest = false) {
1770
2172
  const parts = m.relativePath.replace(/\\/g, '/').split('/');
1771
2173
  const name = parts[parts.length - 1];
1772
2174
  const dir = parts.slice(0, -1).join('/');
1773
- const icon = isTest ? '🧪' : (m.hasTests ? '' : '⬜');
2175
+ const relPathNorm = m.relativePath.replace(/\\/g, '/');
2176
+ const agentStateItem = !isTest ? isFileAgentActive(relPathNorm) : null;
2177
+ const icon = agentStateItem
2178
+ ? `<span class="file-agent-spinner ${agentStateItem}" title="${agentStateItem === 'running' ? 'Агент работает…' : 'В очереди…'}"></span>`
2179
+ : (isTest ? '🧪' : (m.hasTests ? '✅' : '⬜'));
1774
2180
  return `
1775
2181
  <div class="file-item">
1776
2182
  <span class="file-item-icon">${icon}</span>
@@ -1968,6 +2374,7 @@ async function refreshData() {
1968
2374
  renderStats();
1969
2375
  renderSidebar();
1970
2376
  renderContent();
2377
+ updateAgentBtn();
1971
2378
 
1972
2379
  // Re-render drill-down or re-open panel
1973
2380
  const panelOpen = document.getElementById('panel').classList.contains('open');
@@ -2021,31 +2428,69 @@ function connectSSE() {
2021
2428
  setTimeout(() => { coverageHasError = false; updateCovBtn(); }, 8000);
2022
2429
  });
2023
2430
 
2431
+ es.addEventListener('agent-queued', (e) => {
2432
+ const { queueLength, title, task, featureKey, filePath } = JSON.parse(e.data);
2433
+ updateQueueBadge(queueLength);
2434
+ document.getElementById('agentPanel').classList.add('open');
2435
+ document.getElementById('termBtn').classList.add('term-active');
2436
+ // Track queued paths for spinner
2437
+ getTaskFilePaths(task, featureKey, filePath).forEach(p => agentQueuedPaths.add(p));
2438
+ renderContent();
2439
+ // Append queue notification to the currently running session (or active)
2440
+ const targetId = runningSessionId || activeSessionId;
2441
+ if (targetId) {
2442
+ appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
2443
+ }
2444
+ });
2445
+
2024
2446
  es.addEventListener('agent-started', (e) => {
2025
2447
  setAgentRunning(true);
2026
- const { title } = JSON.parse(e.data);
2448
+ const { title, queueLength = 0, task, featureKey, filePath } = JSON.parse(e.data);
2449
+ updateQueueBadge(queueLength);
2450
+ // Move paths from queued → running (current task)
2451
+ const startedPaths = getTaskFilePaths(task, featureKey, filePath);
2452
+ startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
2453
+ renderContent();
2454
+ // Close previous session (queue case: agent-done not fired between tasks)
2455
+ if (runningSessionId) {
2456
+ const prev = consoleSessions.find(s => s.id === runningSessionId);
2457
+ if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
2458
+ }
2459
+ const id = createSession(title);
2460
+ runningSessionId = id;
2027
2461
  document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
2028
2462
  document.getElementById('agentPanelStatus').textContent = 'запускаю…';
2029
- document.getElementById('agentPanel').classList.add('open');
2030
- document.getElementById('termBtn').classList.add('term-active');
2031
- document.getElementById('agentTerminal').innerHTML = '';
2032
2463
  });
2033
2464
 
2034
2465
  es.addEventListener('agent-output', (e) => {
2035
2466
  const { line, isError, isDim } = JSON.parse(e.data);
2036
- appendTerminalLine(line, !!isError, !!isDim);
2037
- document.getElementById('agentPanelStatus').textContent = 'работает…';
2467
+ if (runningSessionId) {
2468
+ appendToSession(runningSessionId, line, !!isError, !!isDim);
2469
+ if (activeSessionId === runningSessionId) {
2470
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
2471
+ }
2472
+ } else {
2473
+ appendTerminalLine(line, !!isError, !!isDim);
2474
+ }
2038
2475
  });
2039
2476
 
2040
2477
  es.addEventListener('agent-done', () => {
2041
2478
  setAgentRunning(false);
2042
- document.getElementById('agentPanelStatus').textContent = '✅ готово';
2479
+ updateQueueBadge(0);
2480
+ agentRunningPaths.clear();
2481
+ agentQueuedPaths.clear();
2482
+ if (runningSessionId) {
2483
+ updateSessionStatus(runningSessionId, 'ok');
2484
+ if (activeSessionId === runningSessionId) {
2485
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
2486
+ }
2487
+ runningSessionId = null;
2488
+ }
2043
2489
  renderContent();
2044
2490
  });
2045
2491
 
2046
2492
  es.addEventListener('agent-summary', (e) => {
2047
2493
  const { passed, failed, files } = JSON.parse(e.data);
2048
- const term = document.getElementById('agentTerminal');
2049
2494
  const allOk = failed === 0;
2050
2495
  const box = document.createElement('div');
2051
2496
  box.style.cssText = `
@@ -2062,64 +2507,114 @@ function connectSSE() {
2062
2507
  </div>
2063
2508
  ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
2064
2509
  `;
2065
- term.appendChild(box);
2066
- term.scrollTop = term.scrollHeight;
2510
+ const targetId = runningSessionId || activeSessionId;
2511
+ if (targetId) {
2512
+ appendToSession(targetId, box);
2513
+ // Mark session status from test results immediately
2514
+ if (runningSessionId === targetId) {
2515
+ updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
2516
+ }
2517
+ }
2067
2518
  });
2068
2519
 
2069
2520
  es.addEventListener('agent-error', (e) => {
2070
2521
  setAgentRunning(false);
2071
- const { message } = JSON.parse(e.data);
2072
- document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
2073
- appendTerminalLine('❌ ' + (message || 'Ошибка агента'), true);
2522
+ agentRunningPaths.clear();
2523
+ agentQueuedPaths.clear();
2524
+ renderContent();
2525
+ const { message, authRequired, notInstalled } = JSON.parse(e.data);
2526
+
2527
+ // Build error node (plain text or auth/install box)
2528
+ let node = null;
2529
+ if (authRequired || notInstalled) {
2530
+ node = document.createElement('div');
2531
+ node.style.cssText = 'margin:10px 0 4px;padding:10px 14px;border-radius:8px;border:1px solid var(--red);background:#2a0d0d;font-family:inherit;';
2532
+ node.innerHTML = `
2533
+ <div style="font-size:13px;font-weight:700;color:var(--red)">❌ ${message || 'Ошибка агента'}</div>
2534
+ ${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>` : ''}
2535
+ ${notInstalled ? `<div style="margin-top:6px;font-size:11px;color:var(--dim)">После установки перезапусти viberadar</div>` : ''}
2536
+ `;
2537
+ }
2538
+
2539
+ // If no session exists yet (startup check fires before any run), create one
2540
+ const targetId = runningSessionId || (() => {
2541
+ if (authRequired || notInstalled) {
2542
+ const id = createSession('⚠️ Проверка агента', 'error');
2543
+ return id;
2544
+ }
2545
+ return activeSessionId;
2546
+ })();
2547
+
2548
+ if (targetId) {
2549
+ if (node) {
2550
+ appendToSession(targetId, node);
2551
+ } else {
2552
+ appendToSession(targetId, '❌ ' + (message || 'Ошибка агента'), true);
2553
+ }
2554
+ updateSessionStatus(targetId, 'error');
2555
+ if (activeSessionId === targetId) {
2556
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
2557
+ }
2558
+ }
2559
+ if (runningSessionId) runningSessionId = null;
2074
2560
  });
2075
2561
 
2076
2562
  es.addEventListener('tests-started', (e) => {
2077
2563
  const { testType, count } = JSON.parse(e.data);
2564
+ const id = createSession(`🧪 ${testType}`);
2565
+ runningSessionId = id;
2566
+ appendToSession(id, `Запускаю ${testType} тесты (${count} файлов)…`);
2078
2567
  document.getElementById('agentPanelTitle').textContent = `🧪 ${testType} тесты`;
2079
2568
  document.getElementById('agentPanelStatus').textContent = `запускаю ${count} файлов…`;
2080
2569
  });
2081
2570
 
2082
2571
  es.addEventListener('tests-done', (e) => {
2083
2572
  const { passed, failed, testErrors } = JSON.parse(e.data);
2084
- document.getElementById('agentPanelStatus').textContent =
2085
- failed === 0 ? `✅ ${passed} passed` : `⚠️ ${passed} passed, ${failed} failed`;
2086
- // Apply testErrors directly from event — no separate fetch needed
2573
+ const status = failed === 0 ? 'ok' : 'error';
2574
+ if (runningSessionId) {
2575
+ updateSessionStatus(runningSessionId, status);
2576
+ if (activeSessionId === runningSessionId) {
2577
+ document.getElementById('agentPanelStatus').textContent =
2578
+ failed === 0 ? `✅ ${passed} passed` : `⚠️ ${passed} passed, ${failed} failed`;
2579
+ }
2580
+ runningSessionId = null;
2581
+ }
2087
2582
  D.testErrors = testErrors || {};
2088
2583
  renderContent();
2089
2584
  });
2090
2585
 
2091
- // ── E2E plan events ──────────────────────────────────────────────────────
2092
- es.addEventListener('e2e-plan-generating', () => {
2093
- e2ePlanLoading = true;
2094
- renderContent();
2586
+ es.addEventListener('e2e-plan-generating', (e) => {
2587
+ const { featureKey } = JSON.parse(e.data);
2588
+ if (drillFeatureKey === featureKey && drillTestType === 'e2e') {
2589
+ e2ePlanLoading = true;
2590
+ renderContent();
2591
+ }
2095
2592
  });
2096
2593
 
2097
2594
  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();
2595
+ const { featureKey, plan } = JSON.parse(e.data);
2596
+ if (drillFeatureKey === featureKey) {
2597
+ e2ePlan = plan;
2598
+ e2ePlanLoading = false;
2599
+ if (drillTestType === 'e2e') renderContent();
2103
2600
  }
2104
2601
  });
2105
2602
 
2106
2603
  es.addEventListener('e2e-plan-error', (e) => {
2604
+ const { featureKey, message } = JSON.parse(e.data);
2107
2605
  e2ePlanLoading = false;
2108
- const data = JSON.parse(e.data);
2109
- if (drillFeatureKey === data.featureKey) {
2110
- e2ePlan = null;
2606
+ if (drillFeatureKey === featureKey && drillTestType === 'e2e') {
2111
2607
  renderContent();
2608
+ setTimeout(() => alert('Ошибка генерации плана: ' + message), 100);
2112
2609
  }
2113
2610
  });
2114
2611
 
2115
2612
  es.addEventListener('e2e-tests-done', (e) => {
2116
- const data = JSON.parse(e.data);
2117
- if (drillFeatureKey === data.featureKey) {
2118
- e2ePlan = data.plan;
2119
- renderContent();
2613
+ const { featureKey, plan } = JSON.parse(e.data);
2614
+ if (drillFeatureKey === featureKey) {
2615
+ e2ePlan = plan;
2616
+ if (drillTestType === 'e2e') renderContent();
2120
2617
  }
2121
- document.getElementById('agentPanelStatus').textContent =
2122
- data.failed === 0 ? `✅ E2E: ${data.passed} passed` : `⚠️ E2E: ${data.passed}/${data.failed}`;
2123
2618
  });
2124
2619
 
2125
2620
  es.onerror = () => {
@@ -2129,7 +2624,15 @@ function connectSSE() {
2129
2624
  };
2130
2625
  }
2131
2626
 
2132
- init().then(() => connectSSE());
2627
+ init().then(() => {
2628
+ restoreSessions();
2629
+ connectSSE();
2630
+ // Sync content padding with terminal panel open state automatically
2631
+ new MutationObserver(() => {
2632
+ const isOpen = document.getElementById('agentPanel').classList.contains('open');
2633
+ document.getElementById('content').classList.toggle('panel-open', isOpen);
2634
+ }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
2635
+ });
2133
2636
  </script>
2134
2637
  </body>
2135
2638
  </html>