viberadar 0.3.62 → 0.3.63

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.
@@ -611,19 +611,19 @@
611
611
  .tt-fix-btn:hover { background: rgba(255,200,0,0.1); color: var(--yellow); border-color: var(--yellow); }
612
612
  .tt-write-btn { border-color: var(--accent); color: var(--accent); }
613
613
  .tt-write-btn:hover { background: rgba(88,166,255,0.1); color: var(--accent); border-color: var(--accent); }
614
- .file-rows { display: flex; flex-direction: column; gap: 2px; contain: content; }
615
- .file-row {
616
- display: flex;
617
- align-items: center;
618
- gap: 8px;
619
- padding: 7px 10px;
614
+ .file-rows { display: flex; flex-direction: column; gap: 2px; contain: content; }
615
+ .file-row {
616
+ display: flex;
617
+ align-items: center;
618
+ gap: 8px;
619
+ padding: 7px 10px;
620
620
  border-radius: 6px;
621
621
  cursor: pointer;
622
- font-size: 13px;
623
- transition: background 0.1s;
624
- content-visibility: auto;
625
- contain-intrinsic-size: 34px;
626
- }
622
+ font-size: 13px;
623
+ transition: background 0.1s;
624
+ content-visibility: auto;
625
+ contain-intrinsic-size: 34px;
626
+ }
627
627
  .file-row:hover { background: var(--bg-card); }
628
628
  .file-row.active { background: var(--bg-hover); border-left: 2px solid var(--blue); padding-left: 8px; }
629
629
  .file-row-icon { font-size: 12px; flex-shrink: 0; }
@@ -675,12 +675,12 @@
675
675
  .file-agent-spinner.running { border: 2px solid var(--yellow); border-top-color: transparent; animation: spin 0.7s linear infinite; }
676
676
  .file-agent-spinner.queued { border: 2px solid var(--dim); border-top-color: transparent; animation: spin 1.5s linear infinite; }
677
677
  @keyframes spin { to { transform: rotate(360deg); } }
678
- .file-row-errors {
679
- padding: 4px 10px 6px 32px;
680
- display: flex; flex-direction: column; gap: 3px;
681
- content-visibility: auto;
682
- contain-intrinsic-size: 46px;
683
- }
678
+ .file-row-errors {
679
+ padding: 4px 10px 6px 32px;
680
+ display: flex; flex-direction: column; gap: 3px;
681
+ content-visibility: auto;
682
+ contain-intrinsic-size: 46px;
683
+ }
684
684
  .err-item { display: flex; flex-direction: column; gap: 1px; }
685
685
  .err-name { font-size: 11px; color: var(--muted); }
686
686
  .err-msg { font-size: 10px; color: var(--red); font-family: monospace; opacity: 0.85; }
@@ -769,117 +769,117 @@
769
769
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
770
770
  }
771
771
  .agent-panel-cancel:hover { background: var(--yellow); color: #000; }
772
- .agent-queue-badge {
773
- font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
774
- border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
775
- }
776
- .agent-toolbar {
777
- display: flex;
778
- align-items: center;
779
- gap: 8px;
780
- padding: 6px 12px;
781
- background: #0b1018;
782
- border-bottom: 1px solid var(--border);
783
- flex-wrap: wrap;
784
- }
785
- .agent-toolbar input[type="text"] {
786
- min-width: 220px;
787
- padding: 4px 8px;
788
- border-radius: 4px;
789
- border: 1px solid var(--border);
790
- background: #0b1220;
791
- color: var(--text);
792
- font-size: 11px;
793
- }
794
- .agent-toolbar label {
795
- display: inline-flex;
796
- align-items: center;
797
- gap: 5px;
798
- font-size: 11px;
799
- color: var(--muted);
800
- user-select: none;
801
- }
802
- .agent-toolbar-btn {
803
- background: none;
804
- border: 1px solid var(--border);
805
- color: var(--muted);
806
- cursor: pointer;
807
- font-size: 11px;
808
- padding: 3px 8px;
809
- border-radius: 4px;
810
- }
811
- .agent-toolbar-btn:hover { color: var(--text); border-color: var(--blue); }
812
- .agent-toolbar-btn:disabled {
813
- opacity: 0.45;
814
- cursor: not-allowed;
815
- border-color: var(--border);
816
- color: var(--dim);
817
- }
818
- .agent-toolbar-meta { font-size: 11px; color: var(--dim); margin-left: auto; }
819
- .agent-queue-panel {
820
- display: none;
821
- border-bottom: 1px solid var(--border);
822
- background: #090f18;
823
- padding: 8px 12px;
824
- max-height: 110px;
825
- overflow-y: auto;
826
- }
827
- .agent-queue-title {
828
- font-size: 11px;
829
- color: var(--muted);
830
- margin-bottom: 6px;
831
- font-weight: 600;
832
- }
833
- .agent-queue-item {
834
- display: flex;
835
- align-items: center;
836
- gap: 8px;
837
- font-size: 11px;
838
- color: var(--text);
839
- margin-bottom: 4px;
840
- }
841
- .agent-queue-item:last-child { margin-bottom: 0; }
842
- .agent-queue-pos {
843
- color: var(--dim);
844
- min-width: 24px;
845
- }
846
- .agent-queue-actions { margin-left: auto; display: inline-flex; gap: 4px; }
847
- .agent-queue-action {
848
- border: 1px solid var(--border);
849
- background: transparent;
850
- color: var(--muted);
851
- border-radius: 4px;
852
- padding: 1px 6px;
853
- cursor: pointer;
854
- font-size: 10px;
855
- line-height: 1.4;
856
- }
857
- .agent-queue-action:hover { border-color: var(--blue); color: var(--text); }
858
- .agent-summary-matrix {
859
- display: none;
860
- border-bottom: 1px solid var(--border);
861
- background: #090f17;
862
- padding: 8px 12px;
863
- max-height: 150px;
864
- overflow: auto;
865
- font-size: 11px;
866
- }
867
- .agent-summary-title {
868
- color: var(--muted);
869
- font-weight: 600;
870
- margin-bottom: 6px;
871
- }
872
- .agent-summary-row {
873
- display: grid;
874
- grid-template-columns: 60px 1fr auto;
875
- gap: 8px;
876
- margin-bottom: 4px;
877
- align-items: center;
878
- }
879
- .agent-summary-status-covered { color: var(--green); }
880
- .agent-summary-status-not-covered { color: var(--red); }
881
- .agent-summary-status-blocked { color: var(--yellow); }
882
- .agent-summary-status-infra { color: var(--dim); }
772
+ .agent-queue-badge {
773
+ font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
774
+ border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
775
+ }
776
+ .agent-toolbar {
777
+ display: flex;
778
+ align-items: center;
779
+ gap: 8px;
780
+ padding: 6px 12px;
781
+ background: #0b1018;
782
+ border-bottom: 1px solid var(--border);
783
+ flex-wrap: wrap;
784
+ }
785
+ .agent-toolbar input[type="text"] {
786
+ min-width: 220px;
787
+ padding: 4px 8px;
788
+ border-radius: 4px;
789
+ border: 1px solid var(--border);
790
+ background: #0b1220;
791
+ color: var(--text);
792
+ font-size: 11px;
793
+ }
794
+ .agent-toolbar label {
795
+ display: inline-flex;
796
+ align-items: center;
797
+ gap: 5px;
798
+ font-size: 11px;
799
+ color: var(--muted);
800
+ user-select: none;
801
+ }
802
+ .agent-toolbar-btn {
803
+ background: none;
804
+ border: 1px solid var(--border);
805
+ color: var(--muted);
806
+ cursor: pointer;
807
+ font-size: 11px;
808
+ padding: 3px 8px;
809
+ border-radius: 4px;
810
+ }
811
+ .agent-toolbar-btn:hover { color: var(--text); border-color: var(--blue); }
812
+ .agent-toolbar-btn:disabled {
813
+ opacity: 0.45;
814
+ cursor: not-allowed;
815
+ border-color: var(--border);
816
+ color: var(--dim);
817
+ }
818
+ .agent-toolbar-meta { font-size: 11px; color: var(--dim); margin-left: auto; }
819
+ .agent-queue-panel {
820
+ display: none;
821
+ border-bottom: 1px solid var(--border);
822
+ background: #090f18;
823
+ padding: 8px 12px;
824
+ max-height: 110px;
825
+ overflow-y: auto;
826
+ }
827
+ .agent-queue-title {
828
+ font-size: 11px;
829
+ color: var(--muted);
830
+ margin-bottom: 6px;
831
+ font-weight: 600;
832
+ }
833
+ .agent-queue-item {
834
+ display: flex;
835
+ align-items: center;
836
+ gap: 8px;
837
+ font-size: 11px;
838
+ color: var(--text);
839
+ margin-bottom: 4px;
840
+ }
841
+ .agent-queue-item:last-child { margin-bottom: 0; }
842
+ .agent-queue-pos {
843
+ color: var(--dim);
844
+ min-width: 24px;
845
+ }
846
+ .agent-queue-actions { margin-left: auto; display: inline-flex; gap: 4px; }
847
+ .agent-queue-action {
848
+ border: 1px solid var(--border);
849
+ background: transparent;
850
+ color: var(--muted);
851
+ border-radius: 4px;
852
+ padding: 1px 6px;
853
+ cursor: pointer;
854
+ font-size: 10px;
855
+ line-height: 1.4;
856
+ }
857
+ .agent-queue-action:hover { border-color: var(--blue); color: var(--text); }
858
+ .agent-summary-matrix {
859
+ display: none;
860
+ border-bottom: 1px solid var(--border);
861
+ background: #090f17;
862
+ padding: 8px 12px;
863
+ max-height: 150px;
864
+ overflow: auto;
865
+ font-size: 11px;
866
+ }
867
+ .agent-summary-title {
868
+ color: var(--muted);
869
+ font-weight: 600;
870
+ margin-bottom: 6px;
871
+ }
872
+ .agent-summary-row {
873
+ display: grid;
874
+ grid-template-columns: 60px 1fr auto;
875
+ gap: 8px;
876
+ margin-bottom: 4px;
877
+ align-items: center;
878
+ }
879
+ .agent-summary-status-covered { color: var(--green); }
880
+ .agent-summary-status-not-covered { color: var(--red); }
881
+ .agent-summary-status-blocked { color: var(--yellow); }
882
+ .agent-summary-status-infra { color: var(--dim); }
883
883
  /* ── Console Tabs ───────────────────────────────────────────────────────── */
884
884
  .agent-tabs-bar {
885
885
  display: flex; align-items: stretch; overflow-x: auto;
@@ -959,50 +959,50 @@
959
959
  border-color: var(--accent);
960
960
  color: var(--accent);
961
961
  }
962
- .bulk-actions-btn:hover:not(:disabled) { background: var(--bg-hover); }
963
- .bulk-actions-btn:disabled { opacity: 0.4; cursor: not-allowed; }
964
- .feature-file-filters {
965
- display: flex;
966
- align-items: center;
967
- gap: 8px;
968
- margin: 0 0 10px;
969
- }
970
- .feature-filter-toggle {
971
- display: inline-flex;
972
- align-items: center;
973
- gap: 8px;
974
- padding: 6px 10px;
975
- border-radius: 6px;
976
- border: 1px solid var(--border);
977
- background: var(--bg-card);
978
- color: var(--muted);
979
- font-size: 12px;
980
- cursor: pointer;
981
- user-select: none;
982
- }
983
- .feature-filter-toggle:hover { background: var(--bg-hover); color: var(--text); }
984
- .feature-filter-toggle input {
985
- width: 14px;
986
- height: 14px;
987
- accent-color: var(--blue);
988
- cursor: pointer;
989
- }
990
- .feature-filter-meta { color: var(--dim); font-size: 11px; }
991
- .agent-terminal {
992
- flex: 1;
993
- overflow-y: auto;
994
- padding: 10px 16px;
995
- font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
962
+ .bulk-actions-btn:hover:not(:disabled) { background: var(--bg-hover); }
963
+ .bulk-actions-btn:disabled { opacity: 0.4; cursor: not-allowed; }
964
+ .feature-file-filters {
965
+ display: flex;
966
+ align-items: center;
967
+ gap: 8px;
968
+ margin: 0 0 10px;
969
+ }
970
+ .feature-filter-toggle {
971
+ display: inline-flex;
972
+ align-items: center;
973
+ gap: 8px;
974
+ padding: 6px 10px;
975
+ border-radius: 6px;
976
+ border: 1px solid var(--border);
977
+ background: var(--bg-card);
978
+ color: var(--muted);
979
+ font-size: 12px;
980
+ cursor: pointer;
981
+ user-select: none;
982
+ }
983
+ .feature-filter-toggle:hover { background: var(--bg-hover); color: var(--text); }
984
+ .feature-filter-toggle input {
985
+ width: 14px;
986
+ height: 14px;
987
+ accent-color: var(--blue);
988
+ cursor: pointer;
989
+ }
990
+ .feature-filter-meta { color: var(--dim); font-size: 11px; }
991
+ .agent-terminal {
992
+ flex: 1;
993
+ overflow-y: auto;
994
+ padding: 10px 16px;
995
+ font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
996
996
  font-size: 12px;
997
997
  line-height: 1.5;
998
998
  }
999
- .agent-line { color: #c9d1d9; }
1000
- .agent-line.err { color: var(--red); }
1001
- .agent-line.dim { color: var(--dim); font-size: 10px; }
1002
- .agent-line.match { background: rgba(31, 111, 235, 0.22); }
1003
- .agent-line.command { color: #79c0ff; }
1004
-
1005
- /* ── Misc ────────────────────────────────────────────────────────────────── */
999
+ .agent-line { color: #c9d1d9; }
1000
+ .agent-line.err { color: var(--red); }
1001
+ .agent-line.dim { color: var(--dim); font-size: 10px; }
1002
+ .agent-line.match { background: rgba(31, 111, 235, 0.22); }
1003
+ .agent-line.command { color: #79c0ff; }
1004
+
1005
+ /* ── Misc ────────────────────────────────────────────────────────────────── */
1006
1006
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
1007
1007
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
1008
1008
  /* ─── E2E Plan UI ─────────────────────────────────────────────────── */
@@ -1114,35 +1114,35 @@
1114
1114
  <div id="panelContent"></div>
1115
1115
  </div>
1116
1116
 
1117
- <div class="agent-panel" id="agentPanel">
1118
- <div class="agent-panel-header">
1119
- <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
1120
- <span class="agent-panel-status" id="agentPanelStatus">running…</span>
1117
+ <div class="agent-panel" id="agentPanel">
1118
+ <div class="agent-panel-header">
1119
+ <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
1120
+ <span class="agent-panel-status" id="agentPanelStatus">running…</span>
1121
1121
  <span class="agent-queue-badge" id="agentQueueBadge" style="display:none">📋 <span id="agentQueueCount">0</span> в очереди</span>
1122
1122
  <button class="agent-panel-cancel" id="agentQueueClearBtn" onclick="clearAgentQueue()" title="Очистить очередь" style="display:none">🗑 очередь</button>
1123
1123
  <button class="agent-panel-cancel" id="agentCancelBtn" onclick="cancelAgent()" title="Сбросить состояние агента" style="display:none">⏹ сброс</button>
1124
- <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
1125
- <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
1126
- </div>
1127
- <div class="agent-toolbar">
1128
- <input id="agentSearchInput" type="text" placeholder="Поиск по терминалу..." />
1129
- <label><input id="agentSearchErrorsOnly" type="checkbox" /> only errors</label>
1130
- <label><input id="agentSearchCurrentRunOnly" type="checkbox" /> current run</label>
1131
- <label><input id="agentSearchRegex" type="checkbox" /> regex</label>
1132
- <button class="agent-toolbar-btn" id="btnMatchPrev" onclick="jumpTerminalMatch(-1)">↑ match</button>
1133
- <button class="agent-toolbar-btn" id="btnMatchNext" onclick="jumpTerminalMatch(1)">↓ match</button>
1134
- <button class="agent-toolbar-btn" id="btnCmdPrev" onclick="jumpCommand(-1)">↑ command</button>
1135
- <button class="agent-toolbar-btn" id="btnCmdNext" onclick="jumpCommand(1)">↓ command</button>
1136
- <button class="agent-toolbar-btn" id="btnErrPrev" onclick="jumpError(-1)">↑ error</button>
1137
- <button class="agent-toolbar-btn" id="btnErrNext" onclick="jumpError(1)">↓ error</button>
1138
- <button class="agent-toolbar-btn" id="btnExportRun" onclick="exportActiveRun()">Export run</button>
1139
- <span class="agent-toolbar-meta" id="agentSearchMeta">0 matches</span>
1140
- </div>
1141
- <div class="agent-queue-panel" id="agentQueuePanel"></div>
1142
- <div class="agent-summary-matrix" id="agentSummaryMatrix"></div>
1143
- <div class="agent-tabs-bar" id="agentTabsBar"></div>
1144
- <div class="agent-terminal" id="agentTerminal"></div>
1145
- </div>
1124
+ <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
1125
+ <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
1126
+ </div>
1127
+ <div class="agent-toolbar">
1128
+ <input id="agentSearchInput" type="text" placeholder="Поиск по терминалу..." />
1129
+ <label><input id="agentSearchErrorsOnly" type="checkbox" /> only errors</label>
1130
+ <label><input id="agentSearchCurrentRunOnly" type="checkbox" /> current run</label>
1131
+ <label><input id="agentSearchRegex" type="checkbox" /> regex</label>
1132
+ <button class="agent-toolbar-btn" id="btnMatchPrev" onclick="jumpTerminalMatch(-1)">↑ match</button>
1133
+ <button class="agent-toolbar-btn" id="btnMatchNext" onclick="jumpTerminalMatch(1)">↓ match</button>
1134
+ <button class="agent-toolbar-btn" id="btnCmdPrev" onclick="jumpCommand(-1)">↑ command</button>
1135
+ <button class="agent-toolbar-btn" id="btnCmdNext" onclick="jumpCommand(1)">↓ command</button>
1136
+ <button class="agent-toolbar-btn" id="btnErrPrev" onclick="jumpError(-1)">↑ error</button>
1137
+ <button class="agent-toolbar-btn" id="btnErrNext" onclick="jumpError(1)">↓ error</button>
1138
+ <button class="agent-toolbar-btn" id="btnExportRun" onclick="exportActiveRun()">Export run</button>
1139
+ <span class="agent-toolbar-meta" id="agentSearchMeta">0 matches</span>
1140
+ </div>
1141
+ <div class="agent-queue-panel" id="agentQueuePanel"></div>
1142
+ <div class="agent-summary-matrix" id="agentSummaryMatrix"></div>
1143
+ <div class="agent-tabs-bar" id="agentTabsBar"></div>
1144
+ <div class="agent-terminal" id="agentTerminal"></div>
1145
+ </div>
1146
1146
 
1147
1147
  <script>
1148
1148
  // ─── State ────────────────────────────────────────────────────────────────────
@@ -1151,21 +1151,21 @@ let contextMode = 'qa';
1151
1151
  let view = 'features';
1152
1152
  let searchQuery = '';
1153
1153
  let activeTypes = new Set();
1154
- let activePanelKey = null;
1155
- let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
1156
- let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
1157
- let showOnlyUntestedInFeature = false; // source tab in feature detail
1158
- const selectedSourceFiles = new Set(); // normalized relative paths for batch actions
1159
- const FILE_ROWS_INITIAL_LIMIT = 250;
1160
- const FILE_ROWS_LIMIT_STEP = 250;
1161
- let fileRowsRenderKey = '';
1162
- let fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1163
- let e2ePlan = null; // current E2E plan object
1164
- let e2ePlanLoading = false;
1165
- const modeStore = {
1166
- qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1167
- observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1168
- };
1154
+ let activePanelKey = null;
1155
+ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
1156
+ let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
1157
+ let showOnlyUntestedInFeature = false; // source tab in feature detail
1158
+ const selectedSourceFiles = new Set(); // normalized relative paths for batch actions
1159
+ const FILE_ROWS_INITIAL_LIMIT = 250;
1160
+ const FILE_ROWS_LIMIT_STEP = 250;
1161
+ let fileRowsRenderKey = '';
1162
+ let fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1163
+ let e2ePlan = null; // current E2E plan object
1164
+ let e2ePlanLoading = false;
1165
+ const modeStore = {
1166
+ qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1167
+ observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1168
+ };
1169
1169
 
1170
1170
  function getModeFromPath(pathname = window.location.pathname) {
1171
1171
  if (pathname.startsWith('/radar/observability')) return 'observability';
@@ -1186,31 +1186,31 @@ function saveModeState(mode) {
1186
1186
  modeStore[mode] = {
1187
1187
  view,
1188
1188
  searchQuery,
1189
- activeTypes: new Set(activeTypes),
1190
- drillFeatureKey,
1191
- drillTestType,
1192
- activePanelKey,
1193
- showOnlyUntestedInFeature,
1194
- };
1195
- }
1196
-
1197
- function restoreModeState(mode) {
1198
- const state = modeStore[mode];
1189
+ activeTypes: new Set(activeTypes),
1190
+ drillFeatureKey,
1191
+ drillTestType,
1192
+ activePanelKey,
1193
+ showOnlyUntestedInFeature,
1194
+ };
1195
+ }
1196
+
1197
+ function restoreModeState(mode) {
1198
+ const state = modeStore[mode];
1199
1199
  view = state.view;
1200
1200
  searchQuery = state.searchQuery;
1201
- activeTypes = new Set(state.activeTypes);
1202
- drillFeatureKey = state.drillFeatureKey;
1203
- drillTestType = state.drillTestType;
1204
- activePanelKey = state.activePanelKey;
1205
- showOnlyUntestedInFeature = !!state.showOnlyUntestedInFeature;
1206
- }
1201
+ activeTypes = new Set(state.activeTypes);
1202
+ drillFeatureKey = state.drillFeatureKey;
1203
+ drillTestType = state.drillTestType;
1204
+ activePanelKey = state.activePanelKey;
1205
+ showOnlyUntestedInFeature = !!state.showOnlyUntestedInFeature;
1206
+ }
1207
1207
 
1208
1208
  function switchMode(nextMode) {
1209
1209
  if (nextMode === contextMode) return;
1210
1210
  saveModeState(contextMode);
1211
1211
  contextMode = nextMode;
1212
1212
  restoreModeState(contextMode);
1213
- if (contextMode === 'observability') {
1213
+ if (contextMode === 'observability') {
1214
1214
  view = 'files';
1215
1215
  drillFeatureKey = null;
1216
1216
  drillTestType = null;
@@ -1224,11 +1224,11 @@ function switchMode(nextMode) {
1224
1224
  renderSidebar();
1225
1225
  renderContent();
1226
1226
  }
1227
- // ─── Run All Tests button ──────────────────────────────────────────────────────
1228
- let runAllRunning = false;
1229
- let refreshDataInFlight = false;
1230
- let refreshDataQueued = false;
1231
- let refreshDataTimer = null;
1227
+ // ─── Run All Tests button ──────────────────────────────────────────────────────
1228
+ let runAllRunning = false;
1229
+ let refreshDataInFlight = false;
1230
+ let refreshDataQueued = false;
1231
+ let refreshDataTimer = null;
1232
1232
 
1233
1233
  function escapeHtml(text) {
1234
1234
  return String(text || '')
@@ -1267,42 +1267,42 @@ function setFeatureDrill(featureKey, syncHash = true) {
1267
1267
  renderContent();
1268
1268
  }
1269
1269
 
1270
- function selectedFilesForFeature(featureKey, visibleSourceFiles = null) {
1271
- const visibleSet = visibleSourceFiles ? new Set(visibleSourceFiles.map(p => p.replace(/\\/g, '/'))) : null;
1272
- return Array.from(selectedSourceFiles).filter((relPath) => {
1270
+ function selectedFilesForFeature(featureKey, visibleSourceFiles = null) {
1271
+ const visibleSet = visibleSourceFiles ? new Set(visibleSourceFiles.map(p => p.replace(/\\/g, '/'))) : null;
1272
+ return Array.from(selectedSourceFiles).filter((relPath) => {
1273
1273
  if (visibleSet && !visibleSet.has(relPath)) return false;
1274
1274
  const mod = D?.modules?.find((m) => m.relativePath.replace(/\\/g, '/') === relPath);
1275
- return !!mod && mod.type !== 'test' && mod.featureKeys?.includes(featureKey);
1276
- });
1277
- }
1278
-
1279
- function getFileRowsWindow(items, renderKey) {
1280
- if (fileRowsRenderKey !== renderKey) {
1281
- fileRowsRenderKey = renderKey;
1282
- fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1283
- }
1284
- const visibleRows = items.slice(0, fileRowsRenderLimit);
1285
- const hiddenRows = Math.max(0, items.length - visibleRows.length);
1286
- return { visibleRows, hiddenRows, hasMoreRows: hiddenRows > 0 };
1287
- }
1288
-
1289
- function increaseFileRowsLimit() {
1290
- fileRowsRenderLimit += FILE_ROWS_LIMIT_STEP;
1291
- }
1292
-
1293
- function bindFileRowsClick(container) {
1294
- const fileRows = container.querySelector('#fileRows');
1295
- if (!fileRows) return;
1296
- fileRows.onclick = (event) => {
1297
- const row = event.target.closest('.file-row[data-id]');
1298
- if (!row) return;
1299
- const moduleId = row.dataset.id;
1300
- const mod = D.modules.find((m) => String(m.id) === moduleId);
1301
- if (mod) openModulePanel(mod);
1302
- };
1303
- }
1304
-
1305
- function applyHashRoute() {
1275
+ return !!mod && mod.type !== 'test' && mod.featureKeys?.includes(featureKey);
1276
+ });
1277
+ }
1278
+
1279
+ function getFileRowsWindow(items, renderKey) {
1280
+ if (fileRowsRenderKey !== renderKey) {
1281
+ fileRowsRenderKey = renderKey;
1282
+ fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1283
+ }
1284
+ const visibleRows = items.slice(0, fileRowsRenderLimit);
1285
+ const hiddenRows = Math.max(0, items.length - visibleRows.length);
1286
+ return { visibleRows, hiddenRows, hasMoreRows: hiddenRows > 0 };
1287
+ }
1288
+
1289
+ function increaseFileRowsLimit() {
1290
+ fileRowsRenderLimit += FILE_ROWS_LIMIT_STEP;
1291
+ }
1292
+
1293
+ function bindFileRowsClick(container) {
1294
+ const fileRows = container.querySelector('#fileRows');
1295
+ if (!fileRows) return;
1296
+ fileRows.onclick = (event) => {
1297
+ const row = event.target.closest('.file-row[data-id]');
1298
+ if (!row) return;
1299
+ const moduleId = row.dataset.id;
1300
+ const mod = D.modules.find((m) => String(m.id) === moduleId);
1301
+ if (mod) openModulePanel(mod);
1302
+ };
1303
+ }
1304
+
1305
+ function applyHashRoute() {
1306
1306
  const hash = (window.location.hash || '').replace(/^#/, '');
1307
1307
  if (!hash) {
1308
1308
  if (view === 'features' && drillFeatureKey) {
@@ -1350,148 +1350,148 @@ async function runAllTests() {
1350
1350
  }
1351
1351
 
1352
1352
  // ─── Agent ────────────────────────────────────────────────────────────────────
1353
- let agentRunning = false;
1354
- const agentRunningPaths = new Set(); // paths of files currently being processed by agent
1355
- const agentQueuedPaths = new Set(); // paths of files waiting in queue
1356
- let agentQueueState = [];
1357
- let agentRunsState = [];
1358
- let agentActiveRun = null;
1359
- let currentRunId = null;
1360
- let lastRunSummary = null;
1361
-
1362
- // ─── Console Sessions ─────────────────────────────────────────────────────────
1363
- const consoleSessions = []; // { id, title, lines, status, startTime }
1364
- let activeSessionId = null; // currently viewed tab
1365
- let runningSessionId = null; // tab that is currently receiving output
1366
- const runSessionMap = new Map(); // runId -> sessionId
1367
- const sessionRunMap = new Map(); // sessionId -> runId
1368
- const SESSION_MAX = 25;
1369
- const SESSION_LINE_LIMIT = 3000;
1370
- const SESSIONS_KEY = 'viberadar_sessions';
1371
- let terminalSearchQuery = '';
1372
- let terminalSearchErrorsOnly = false;
1373
- let terminalSearchCurrentRunOnly = false;
1374
- let terminalSearchRegex = false;
1375
- let terminalMatchRefs = [];
1376
- let terminalCommandRefs = [];
1377
- let terminalErrorRefs = [];
1378
- let terminalMatchCursor = -1;
1379
- let terminalCommandCursor = -1;
1380
- let terminalErrorCursor = -1;
1353
+ let agentRunning = false;
1354
+ const agentRunningPaths = new Set(); // paths of files currently being processed by agent
1355
+ const agentQueuedPaths = new Set(); // paths of files waiting in queue
1356
+ let agentQueueState = [];
1357
+ let agentRunsState = [];
1358
+ let agentActiveRun = null;
1359
+ let currentRunId = null;
1360
+ let lastRunSummary = null;
1361
+
1362
+ // ─── Console Sessions ─────────────────────────────────────────────────────────
1363
+ const consoleSessions = []; // { id, title, lines, status, startTime }
1364
+ let activeSessionId = null; // currently viewed tab
1365
+ let runningSessionId = null; // tab that is currently receiving output
1366
+ const runSessionMap = new Map(); // runId -> sessionId
1367
+ const sessionRunMap = new Map(); // sessionId -> runId
1368
+ const SESSION_MAX = 25;
1369
+ const SESSION_LINE_LIMIT = 3000;
1370
+ const SESSIONS_KEY = 'viberadar_sessions';
1371
+ let terminalSearchQuery = '';
1372
+ let terminalSearchErrorsOnly = false;
1373
+ let terminalSearchCurrentRunOnly = false;
1374
+ let terminalSearchRegex = false;
1375
+ let terminalMatchRefs = [];
1376
+ let terminalCommandRefs = [];
1377
+ let terminalErrorRefs = [];
1378
+ let terminalMatchCursor = -1;
1379
+ let terminalCommandCursor = -1;
1380
+ let terminalErrorCursor = -1;
1381
1381
 
1382
1382
  function _sessionId() {
1383
1383
  return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
1384
1384
  }
1385
1385
 
1386
- function saveSessions() {
1387
- try {
1388
- const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
1389
- ...s,
1390
- runId: sessionRunMap.get(s.id) || s.runId || null,
1391
- lines: s.lines.slice(-500)
1392
- }));
1393
- localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
1394
- } catch {}
1395
- }
1386
+ function saveSessions() {
1387
+ try {
1388
+ const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
1389
+ ...s,
1390
+ runId: sessionRunMap.get(s.id) || s.runId || null,
1391
+ lines: s.lines.slice(-500)
1392
+ }));
1393
+ localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
1394
+ } catch {}
1395
+ }
1396
1396
 
1397
1397
  function restoreSessions() {
1398
1398
  try {
1399
- const raw = localStorage.getItem(SESSIONS_KEY);
1400
- if (!raw) return;
1401
- const saved = JSON.parse(raw);
1402
- consoleSessions.push(...saved);
1403
- for (const s of consoleSessions) {
1404
- if (s.runId) {
1405
- runSessionMap.set(s.runId, s.id);
1406
- sessionRunMap.set(s.id, s.runId);
1407
- }
1408
- }
1409
- if (consoleSessions.length > 0) {
1410
- activeSessionId = consoleSessions[consoleSessions.length - 1].id;
1411
- renderTabs();
1412
- renderActiveSession();
1399
+ const raw = localStorage.getItem(SESSIONS_KEY);
1400
+ if (!raw) return;
1401
+ const saved = JSON.parse(raw);
1402
+ consoleSessions.push(...saved);
1403
+ for (const s of consoleSessions) {
1404
+ if (s.runId) {
1405
+ runSessionMap.set(s.runId, s.id);
1406
+ sessionRunMap.set(s.id, s.runId);
1407
+ }
1408
+ }
1409
+ if (consoleSessions.length > 0) {
1410
+ activeSessionId = consoleSessions[consoleSessions.length - 1].id;
1411
+ renderTabs();
1412
+ renderActiveSession();
1413
1413
  }
1414
1414
  } catch {}
1415
1415
  }
1416
1416
 
1417
- function createSession(title, status = 'running', runId = null) {
1418
- if (consoleSessions.length >= SESSION_MAX) {
1419
- const dropped = consoleSessions.shift();
1420
- if (dropped?.id) {
1421
- const droppedRunId = sessionRunMap.get(dropped.id);
1422
- if (droppedRunId) runSessionMap.delete(droppedRunId);
1423
- sessionRunMap.delete(dropped.id);
1424
- }
1425
- }
1426
- const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now(), runId };
1427
- consoleSessions.push(s);
1428
- activeSessionId = s.id;
1429
- if (runId) {
1430
- runSessionMap.set(runId, s.id);
1431
- sessionRunMap.set(s.id, runId);
1432
- }
1433
- document.getElementById('agentPanel').classList.add('open');
1434
- document.getElementById('termBtn').classList.add('term-active');
1435
- renderTabs();
1436
- renderActiveSession();
1437
- saveSessions();
1417
+ function createSession(title, status = 'running', runId = null) {
1418
+ if (consoleSessions.length >= SESSION_MAX) {
1419
+ const dropped = consoleSessions.shift();
1420
+ if (dropped?.id) {
1421
+ const droppedRunId = sessionRunMap.get(dropped.id);
1422
+ if (droppedRunId) runSessionMap.delete(droppedRunId);
1423
+ sessionRunMap.delete(dropped.id);
1424
+ }
1425
+ }
1426
+ const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now(), runId };
1427
+ consoleSessions.push(s);
1428
+ activeSessionId = s.id;
1429
+ if (runId) {
1430
+ runSessionMap.set(runId, s.id);
1431
+ sessionRunMap.set(s.id, runId);
1432
+ }
1433
+ document.getElementById('agentPanel').classList.add('open');
1434
+ document.getElementById('termBtn').classList.add('term-active');
1435
+ renderTabs();
1436
+ renderActiveSession();
1437
+ saveSessions();
1438
1438
  return s.id;
1439
1439
  }
1440
1440
 
1441
- function switchSession(id) {
1442
- activeSessionId = id;
1443
- currentRunId = sessionRunMap.get(id) || null;
1444
- const s = consoleSessions.find(s => s.id === id);
1445
- if (s) {
1446
- const statusText = s.status === 'running' ? 'работает…'
1447
- : s.status === 'ok' ? '✅ готово'
1448
- : s.status === 'error' ? '❌ ошибка'
1441
+ function switchSession(id) {
1442
+ activeSessionId = id;
1443
+ currentRunId = sessionRunMap.get(id) || null;
1444
+ const s = consoleSessions.find(s => s.id === id);
1445
+ if (s) {
1446
+ const statusText = s.status === 'running' ? 'работает…'
1447
+ : s.status === 'ok' ? '✅ готово'
1448
+ : s.status === 'error' ? '❌ ошибка'
1449
1449
  : '';
1450
1450
  document.getElementById('agentPanelTitle').textContent = s.title;
1451
1451
  document.getElementById('agentPanelStatus').textContent = statusText;
1452
- }
1453
- renderTabs();
1454
- renderActiveSession();
1455
- syncSummaryForActiveSession();
1456
- }
1457
-
1458
- function closeSession(id) {
1459
- const idx = consoleSessions.findIndex(s => s.id === id);
1460
- if (idx === -1) return;
1461
- const runId = sessionRunMap.get(id);
1462
- if (runId) {
1463
- sessionRunMap.delete(id);
1464
- runSessionMap.delete(runId);
1465
- }
1466
- consoleSessions.splice(idx, 1);
1467
- if (activeSessionId === id) {
1468
- activeSessionId = consoleSessions.length > 0
1469
- ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
1470
- : null;
1471
- }
1472
- renderTabs();
1473
- renderActiveSession();
1474
- syncSummaryForActiveSession();
1475
- saveSessions();
1476
- }
1477
-
1478
- function appendToSession(id, lineOrNode, isError = false, isDim = false) {
1479
- const s = consoleSessions.find(s => s.id === id);
1480
- if (!s) return;
1452
+ }
1453
+ renderTabs();
1454
+ renderActiveSession();
1455
+ syncSummaryForActiveSession();
1456
+ }
1457
+
1458
+ function closeSession(id) {
1459
+ const idx = consoleSessions.findIndex(s => s.id === id);
1460
+ if (idx === -1) return;
1461
+ const runId = sessionRunMap.get(id);
1462
+ if (runId) {
1463
+ sessionRunMap.delete(id);
1464
+ runSessionMap.delete(runId);
1465
+ }
1466
+ consoleSessions.splice(idx, 1);
1467
+ if (activeSessionId === id) {
1468
+ activeSessionId = consoleSessions.length > 0
1469
+ ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
1470
+ : null;
1471
+ }
1472
+ renderTabs();
1473
+ renderActiveSession();
1474
+ syncSummaryForActiveSession();
1475
+ saveSessions();
1476
+ }
1477
+
1478
+ function appendToSession(id, lineOrNode, isError = false, isDim = false) {
1479
+ const s = consoleSessions.find(s => s.id === id);
1480
+ if (!s) return;
1481
1481
  let stored;
1482
1482
  if (typeof lineOrNode === 'string') {
1483
1483
  stored = { text: lineOrNode, isError, isDim };
1484
1484
  } else {
1485
1485
  stored = { html: lineOrNode.outerHTML };
1486
- }
1487
- s.lines.push(stored);
1488
- if (s.lines.length > SESSION_LINE_LIMIT) {
1489
- s.lines.splice(0, s.lines.length - SESSION_LINE_LIMIT);
1490
- }
1491
- if (activeSessionId === id) {
1492
- renderActiveSession();
1493
- }
1494
- }
1486
+ }
1487
+ s.lines.push(stored);
1488
+ if (s.lines.length > SESSION_LINE_LIMIT) {
1489
+ s.lines.splice(0, s.lines.length - SESSION_LINE_LIMIT);
1490
+ }
1491
+ if (activeSessionId === id) {
1492
+ renderActiveSession();
1493
+ }
1494
+ }
1495
1495
 
1496
1496
  function updateSessionStatus(id, status) {
1497
1497
  const s = consoleSessions.find(s => s.id === id);
@@ -1519,9 +1519,9 @@ function renderTabs() {
1519
1519
  if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1520
1520
  }
1521
1521
 
1522
- function copyTerminalContent() {
1523
- const s = consoleSessions.find(s => s.id === activeSessionId);
1524
- if (!s || s.lines.length === 0) return;
1522
+ function copyTerminalContent() {
1523
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1524
+ if (!s || s.lines.length === 0) return;
1525
1525
  // Extract plain text from each line (strip HTML for rich nodes)
1526
1526
  const text = s.lines.map(l => {
1527
1527
  if (l.text !== undefined) return l.text;
@@ -1539,214 +1539,214 @@ function copyTerminalContent() {
1539
1539
  btn.textContent = '✓';
1540
1540
  btn.classList.add('copied');
1541
1541
  setTimeout(() => { btn.textContent = prev; btn.classList.remove('copied'); }, 1500);
1542
- });
1543
- }
1544
-
1545
- function downloadTextArtifact(filename, content) {
1546
- const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
1547
- const a = document.createElement('a');
1548
- a.href = URL.createObjectURL(blob);
1549
- a.download = filename;
1550
- document.body.appendChild(a);
1551
- a.click();
1552
- setTimeout(() => {
1553
- URL.revokeObjectURL(a.href);
1554
- a.remove();
1555
- }, 100);
1556
- }
1557
-
1558
- function getActiveRunForExport() {
1559
- const runId = sessionRunMap.get(activeSessionId) || currentRunId;
1560
- if (runId) {
1561
- const fromState = (agentRunsState || []).find((r) => r.runId === runId);
1562
- if (fromState) return fromState;
1563
- }
1564
- return null;
1565
- }
1566
-
1567
- function exportActiveRun() {
1568
- const run = getActiveRunForExport();
1569
- const session = consoleSessions.find((s) => s.id === activeSessionId);
1570
- if (!run && !session) {
1571
- flashToolbarHint('Нечего экспортировать');
1572
- return;
1573
- }
1574
- const mode = window.prompt('Формат экспорта: md или json', 'md');
1575
- const format = (mode || 'md').trim().toLowerCase() === 'json' ? 'json' : 'md';
1576
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
1577
- const runId = run?.runId || sessionRunMap.get(activeSessionId) || 'session';
1578
- if (format === 'json') {
1579
- const payload = {
1580
- run,
1581
- sessionTitle: session?.title || null,
1582
- lines: (session?.lines || []).map((line) => extractLineText(line)),
1583
- exportedAt: new Date().toISOString(),
1584
- };
1585
- downloadTextArtifact(`viberadar-run-${runId}-${ts}.json`, JSON.stringify(payload, null, 2));
1586
- return;
1587
- }
1588
- const lines = (session?.lines || []).map((line) => extractLineText(line)).filter(Boolean);
1589
- const outcomes = Array.isArray(run?.fileOutcomes) ? run.fileOutcomes : [];
1590
- const stats = run?.validationStats || {};
1591
- const md = [
1592
- `# VibeRadar Run Export`,
1593
- ``,
1594
- `- runId: ${run?.runId || runId}`,
1595
- `- title: ${run?.title || session?.title || '-'}`,
1596
- `- phase: ${run?.phase || '-'}`,
1597
- `- exportedAt: ${new Date().toISOString()}`,
1598
- ``,
1599
- `## Validation`,
1600
- ``,
1601
- `- covered: ${stats.covered ?? 0}`,
1602
- `- not-covered: ${stats.notCovered ?? 0}`,
1603
- `- blocked: ${stats.blocked ?? 0}`,
1604
- `- infra: ${stats.infra ?? 0}`,
1605
- ``,
1606
- `## File Outcomes`,
1607
- ``,
1608
- ...outcomes.map((o) => `- ${o.status}: ${o.sourcePath}${o.testFile ? ` -> ${o.testFile}` : ''}${o.reason ? ` (${o.reason})` : ''}`),
1609
- ``,
1610
- `## Logs`,
1611
- ``,
1612
- '```text',
1613
- ...lines,
1614
- '```',
1615
- ].join('\n');
1616
- downloadTextArtifact(`viberadar-run-${runId}-${ts}.md`, md);
1617
- }
1618
-
1619
- function renderActiveSession() {
1620
- const term = document.getElementById('agentTerminal');
1621
- if (!term) return;
1622
- term.innerHTML = '';
1623
- terminalMatchRefs = [];
1624
- terminalCommandRefs = [];
1625
- terminalErrorRefs = [];
1626
- terminalMatchCursor = -1;
1627
- terminalCommandCursor = -1;
1628
- terminalErrorCursor = -1;
1629
- const s = consoleSessions.find(s => s.id === activeSessionId);
1630
- if (!s) {
1631
- document.getElementById('agentSearchMeta').textContent = '0 matches';
1632
- updateToolbarButtonsState();
1633
- return;
1634
- }
1635
- const normalizedQuery = (terminalSearchQuery || '').trim();
1636
- let regex = null;
1637
- if (normalizedQuery && terminalSearchRegex) {
1638
- try { regex = new RegExp(normalizedQuery, 'i'); } catch { regex = null; }
1639
- }
1640
- for (let i = 0; i < s.lines.length; i++) {
1641
- const ln = s.lines[i];
1642
- const text = extractLineText(ln);
1643
- const isErrorLine = !!ln.isError || /(^|\s)❌/.test(text) || /\berror\b/i.test(text);
1644
- const isCommandLine = /^\s*⚡\s*\$/.test(text);
1645
- if (terminalSearchCurrentRunOnly && currentRunId && s.runId && s.runId !== currentRunId) continue;
1646
- if (terminalSearchErrorsOnly && !isErrorLine) continue;
1647
- let isMatch = false;
1648
- if (!normalizedQuery) {
1649
- isMatch = false;
1650
- } else if (regex) {
1651
- isMatch = regex.test(text);
1652
- } else {
1653
- isMatch = text.toLowerCase().includes(normalizedQuery.toLowerCase());
1654
- }
1655
- if (normalizedQuery && !isMatch) continue;
1656
-
1657
- const el = document.createElement('div');
1658
- if (ln.html) {
1659
- el.innerHTML = ln.html;
1660
- } else {
1661
- el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1662
- el.textContent = ln.text;
1663
- }
1664
- if (isCommandLine) el.classList.add('command');
1665
- if (isMatch) el.classList.add('match');
1666
- el.dataset.lineIndex = String(i);
1667
- term.appendChild(el);
1668
- if (isMatch) terminalMatchRefs.push(el);
1669
- if (isCommandLine) terminalCommandRefs.push(el);
1670
- if (isErrorLine) terminalErrorRefs.push(el);
1671
- }
1672
- term.scrollTop = term.scrollHeight;
1673
- document.getElementById('agentSearchMeta').textContent =
1674
- `${terminalMatchRefs.length} matches • ${terminalCommandRefs.length} commands • ${terminalErrorRefs.length} errors`;
1675
- updateToolbarButtonsState();
1676
- }
1677
-
1678
- function updateToolbarButtonsState() {
1679
- const setState = (id, enabled) => {
1680
- const el = document.getElementById(id);
1681
- if (!el) return;
1682
- el.disabled = !enabled;
1683
- };
1684
- setState('btnMatchPrev', terminalMatchRefs.length > 0);
1685
- setState('btnMatchNext', terminalMatchRefs.length > 0);
1686
- setState('btnCmdPrev', terminalCommandRefs.length > 0);
1687
- setState('btnCmdNext', terminalCommandRefs.length > 0);
1688
- setState('btnErrPrev', terminalErrorRefs.length > 0);
1689
- setState('btnErrNext', terminalErrorRefs.length > 0);
1690
- const hasExport = !!getActiveRunForExport() || !!consoleSessions.find((s) => s.id === activeSessionId);
1691
- setState('btnExportRun', hasExport);
1692
- }
1693
-
1694
- function flashToolbarHint(text) {
1695
- const meta = document.getElementById('agentSearchMeta');
1696
- if (!meta) return;
1697
- const previous = meta.textContent;
1698
- meta.textContent = text;
1699
- meta.style.color = 'var(--yellow)';
1700
- setTimeout(() => {
1701
- meta.style.color = '';
1702
- if (meta.textContent === text) {
1703
- meta.textContent = previous || '0 matches';
1704
- }
1705
- }, 1400);
1706
- }
1707
-
1708
- function extractLineText(line) {
1709
- if (!line) return '';
1710
- if (line.text !== undefined) return String(line.text || '');
1711
- if (line.html) {
1712
- const tmp = document.createElement('div');
1713
- tmp.innerHTML = line.html;
1714
- return tmp.innerText || '';
1715
- }
1716
- return '';
1717
- }
1718
-
1719
- function jumpByRefs(refs, dir, cursorName) {
1720
- if (!refs.length) {
1721
- const label = cursorName === 'match' ? 'совпадений' : cursorName === 'command' ? 'команд' : 'ошибок';
1722
- flashToolbarHint(`Нет ${label} для навигации`);
1723
- return;
1724
- }
1725
- if (cursorName === 'match') {
1726
- terminalMatchCursor = (terminalMatchCursor + dir + refs.length) % refs.length;
1727
- refs[terminalMatchCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1728
- return;
1729
- }
1730
- if (cursorName === 'command') {
1731
- terminalCommandCursor = (terminalCommandCursor + dir + refs.length) % refs.length;
1732
- refs[terminalCommandCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1733
- return;
1734
- }
1735
- terminalErrorCursor = (terminalErrorCursor + dir + refs.length) % refs.length;
1736
- refs[terminalErrorCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1737
- }
1738
-
1739
- function jumpTerminalMatch(dir) {
1740
- jumpByRefs(terminalMatchRefs, dir > 0 ? 1 : -1, 'match');
1741
- }
1742
-
1743
- function jumpCommand(dir) {
1744
- jumpByRefs(terminalCommandRefs, dir > 0 ? 1 : -1, 'command');
1745
- }
1746
-
1747
- function jumpError(dir) {
1748
- jumpByRefs(terminalErrorRefs, dir > 0 ? 1 : -1, 'error');
1749
- }
1542
+ });
1543
+ }
1544
+
1545
+ function downloadTextArtifact(filename, content) {
1546
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
1547
+ const a = document.createElement('a');
1548
+ a.href = URL.createObjectURL(blob);
1549
+ a.download = filename;
1550
+ document.body.appendChild(a);
1551
+ a.click();
1552
+ setTimeout(() => {
1553
+ URL.revokeObjectURL(a.href);
1554
+ a.remove();
1555
+ }, 100);
1556
+ }
1557
+
1558
+ function getActiveRunForExport() {
1559
+ const runId = sessionRunMap.get(activeSessionId) || currentRunId;
1560
+ if (runId) {
1561
+ const fromState = (agentRunsState || []).find((r) => r.runId === runId);
1562
+ if (fromState) return fromState;
1563
+ }
1564
+ return null;
1565
+ }
1566
+
1567
+ function exportActiveRun() {
1568
+ const run = getActiveRunForExport();
1569
+ const session = consoleSessions.find((s) => s.id === activeSessionId);
1570
+ if (!run && !session) {
1571
+ flashToolbarHint('Нечего экспортировать');
1572
+ return;
1573
+ }
1574
+ const mode = window.prompt('Формат экспорта: md или json', 'md');
1575
+ const format = (mode || 'md').trim().toLowerCase() === 'json' ? 'json' : 'md';
1576
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1577
+ const runId = run?.runId || sessionRunMap.get(activeSessionId) || 'session';
1578
+ if (format === 'json') {
1579
+ const payload = {
1580
+ run,
1581
+ sessionTitle: session?.title || null,
1582
+ lines: (session?.lines || []).map((line) => extractLineText(line)),
1583
+ exportedAt: new Date().toISOString(),
1584
+ };
1585
+ downloadTextArtifact(`viberadar-run-${runId}-${ts}.json`, JSON.stringify(payload, null, 2));
1586
+ return;
1587
+ }
1588
+ const lines = (session?.lines || []).map((line) => extractLineText(line)).filter(Boolean);
1589
+ const outcomes = Array.isArray(run?.fileOutcomes) ? run.fileOutcomes : [];
1590
+ const stats = run?.validationStats || {};
1591
+ const md = [
1592
+ `# VibeRadar Run Export`,
1593
+ ``,
1594
+ `- runId: ${run?.runId || runId}`,
1595
+ `- title: ${run?.title || session?.title || '-'}`,
1596
+ `- phase: ${run?.phase || '-'}`,
1597
+ `- exportedAt: ${new Date().toISOString()}`,
1598
+ ``,
1599
+ `## Validation`,
1600
+ ``,
1601
+ `- covered: ${stats.covered ?? 0}`,
1602
+ `- not-covered: ${stats.notCovered ?? 0}`,
1603
+ `- blocked: ${stats.blocked ?? 0}`,
1604
+ `- infra: ${stats.infra ?? 0}`,
1605
+ ``,
1606
+ `## File Outcomes`,
1607
+ ``,
1608
+ ...outcomes.map((o) => `- ${o.status}: ${o.sourcePath}${o.testFile ? ` -> ${o.testFile}` : ''}${o.reason ? ` (${o.reason})` : ''}`),
1609
+ ``,
1610
+ `## Logs`,
1611
+ ``,
1612
+ '```text',
1613
+ ...lines,
1614
+ '```',
1615
+ ].join('\n');
1616
+ downloadTextArtifact(`viberadar-run-${runId}-${ts}.md`, md);
1617
+ }
1618
+
1619
+ function renderActiveSession() {
1620
+ const term = document.getElementById('agentTerminal');
1621
+ if (!term) return;
1622
+ term.innerHTML = '';
1623
+ terminalMatchRefs = [];
1624
+ terminalCommandRefs = [];
1625
+ terminalErrorRefs = [];
1626
+ terminalMatchCursor = -1;
1627
+ terminalCommandCursor = -1;
1628
+ terminalErrorCursor = -1;
1629
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1630
+ if (!s) {
1631
+ document.getElementById('agentSearchMeta').textContent = '0 matches';
1632
+ updateToolbarButtonsState();
1633
+ return;
1634
+ }
1635
+ const normalizedQuery = (terminalSearchQuery || '').trim();
1636
+ let regex = null;
1637
+ if (normalizedQuery && terminalSearchRegex) {
1638
+ try { regex = new RegExp(normalizedQuery, 'i'); } catch { regex = null; }
1639
+ }
1640
+ for (let i = 0; i < s.lines.length; i++) {
1641
+ const ln = s.lines[i];
1642
+ const text = extractLineText(ln);
1643
+ const isErrorLine = !!ln.isError || /(^|\s)❌/.test(text) || /\berror\b/i.test(text);
1644
+ const isCommandLine = /^\s*⚡\s*\$/.test(text);
1645
+ if (terminalSearchCurrentRunOnly && currentRunId && s.runId && s.runId !== currentRunId) continue;
1646
+ if (terminalSearchErrorsOnly && !isErrorLine) continue;
1647
+ let isMatch = false;
1648
+ if (!normalizedQuery) {
1649
+ isMatch = false;
1650
+ } else if (regex) {
1651
+ isMatch = regex.test(text);
1652
+ } else {
1653
+ isMatch = text.toLowerCase().includes(normalizedQuery.toLowerCase());
1654
+ }
1655
+ if (normalizedQuery && !isMatch) continue;
1656
+
1657
+ const el = document.createElement('div');
1658
+ if (ln.html) {
1659
+ el.innerHTML = ln.html;
1660
+ } else {
1661
+ el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1662
+ el.textContent = ln.text;
1663
+ }
1664
+ if (isCommandLine) el.classList.add('command');
1665
+ if (isMatch) el.classList.add('match');
1666
+ el.dataset.lineIndex = String(i);
1667
+ term.appendChild(el);
1668
+ if (isMatch) terminalMatchRefs.push(el);
1669
+ if (isCommandLine) terminalCommandRefs.push(el);
1670
+ if (isErrorLine) terminalErrorRefs.push(el);
1671
+ }
1672
+ term.scrollTop = term.scrollHeight;
1673
+ document.getElementById('agentSearchMeta').textContent =
1674
+ `${terminalMatchRefs.length} matches • ${terminalCommandRefs.length} commands • ${terminalErrorRefs.length} errors`;
1675
+ updateToolbarButtonsState();
1676
+ }
1677
+
1678
+ function updateToolbarButtonsState() {
1679
+ const setState = (id, enabled) => {
1680
+ const el = document.getElementById(id);
1681
+ if (!el) return;
1682
+ el.disabled = !enabled;
1683
+ };
1684
+ setState('btnMatchPrev', terminalMatchRefs.length > 0);
1685
+ setState('btnMatchNext', terminalMatchRefs.length > 0);
1686
+ setState('btnCmdPrev', terminalCommandRefs.length > 0);
1687
+ setState('btnCmdNext', terminalCommandRefs.length > 0);
1688
+ setState('btnErrPrev', terminalErrorRefs.length > 0);
1689
+ setState('btnErrNext', terminalErrorRefs.length > 0);
1690
+ const hasExport = !!getActiveRunForExport() || !!consoleSessions.find((s) => s.id === activeSessionId);
1691
+ setState('btnExportRun', hasExport);
1692
+ }
1693
+
1694
+ function flashToolbarHint(text) {
1695
+ const meta = document.getElementById('agentSearchMeta');
1696
+ if (!meta) return;
1697
+ const previous = meta.textContent;
1698
+ meta.textContent = text;
1699
+ meta.style.color = 'var(--yellow)';
1700
+ setTimeout(() => {
1701
+ meta.style.color = '';
1702
+ if (meta.textContent === text) {
1703
+ meta.textContent = previous || '0 matches';
1704
+ }
1705
+ }, 1400);
1706
+ }
1707
+
1708
+ function extractLineText(line) {
1709
+ if (!line) return '';
1710
+ if (line.text !== undefined) return String(line.text || '');
1711
+ if (line.html) {
1712
+ const tmp = document.createElement('div');
1713
+ tmp.innerHTML = line.html;
1714
+ return tmp.innerText || '';
1715
+ }
1716
+ return '';
1717
+ }
1718
+
1719
+ function jumpByRefs(refs, dir, cursorName) {
1720
+ if (!refs.length) {
1721
+ const label = cursorName === 'match' ? 'совпадений' : cursorName === 'command' ? 'команд' : 'ошибок';
1722
+ flashToolbarHint(`Нет ${label} для навигации`);
1723
+ return;
1724
+ }
1725
+ if (cursorName === 'match') {
1726
+ terminalMatchCursor = (terminalMatchCursor + dir + refs.length) % refs.length;
1727
+ refs[terminalMatchCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1728
+ return;
1729
+ }
1730
+ if (cursorName === 'command') {
1731
+ terminalCommandCursor = (terminalCommandCursor + dir + refs.length) % refs.length;
1732
+ refs[terminalCommandCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1733
+ return;
1734
+ }
1735
+ terminalErrorCursor = (terminalErrorCursor + dir + refs.length) % refs.length;
1736
+ refs[terminalErrorCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1737
+ }
1738
+
1739
+ function jumpTerminalMatch(dir) {
1740
+ jumpByRefs(terminalMatchRefs, dir > 0 ? 1 : -1, 'match');
1741
+ }
1742
+
1743
+ function jumpCommand(dir) {
1744
+ jumpByRefs(terminalCommandRefs, dir > 0 ? 1 : -1, 'command');
1745
+ }
1746
+
1747
+ function jumpError(dir) {
1748
+ jumpByRefs(terminalErrorRefs, dir > 0 ? 1 : -1, 'error');
1749
+ }
1750
1750
 
1751
1751
  async function setAgent(agent) {
1752
1752
  await fetch('/api/set-agent', {
@@ -1795,199 +1795,199 @@ function isFileAgentActive(relPath) {
1795
1795
  return null;
1796
1796
  }
1797
1797
 
1798
- function updateQueueBadge(n) {
1799
- document.getElementById('agentQueueCount').textContent = n;
1800
- document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1801
- document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1802
- renderQueuePanel();
1803
- }
1804
-
1805
- function renderQueuePanel() {
1806
- const panel = document.getElementById('agentQueuePanel');
1807
- if (!panel) return;
1808
- if (!Array.isArray(agentQueueState) || agentQueueState.length === 0) {
1809
- panel.style.display = 'none';
1810
- panel.innerHTML = '';
1811
- return;
1812
- }
1813
- panel.style.display = 'block';
1814
- panel.innerHTML = `
1815
- <div class="agent-queue-title">Очередь задач (${agentQueueState.length})</div>
1816
- ${agentQueueState.map((item, idx) => `
1817
- <div class="agent-queue-item">
1818
- <span class="agent-queue-pos">#${item.position ?? idx + 1}</span>
1819
- <span title="${escapeHtml(item.title || '')}">${escapeHtml(item.title || item.task || item.runId)}</span>
1820
- <span style="color:var(--dim)">(${escapeHtml(item.task || 'task')})</span>
1821
- <div class="agent-queue-actions">
1822
- <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','up')" title="Сдвинуть вверх">↑</button>
1823
- <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','down')" title="Сдвинуть вниз">↓</button>
1824
- <button class="agent-queue-action" onclick="cancelQueueItem('${item.runId}')" title="Отменить задачу">✕</button>
1825
- <button class="agent-queue-action" onclick="retryRun('${item.runId}')" title="Retry">↻</button>
1826
- </div>
1827
- </div>
1828
- `).join('')}
1829
- `;
1830
- }
1831
-
1832
- function renderRunSummaryMatrix(summary = lastRunSummary) {
1833
- const box = document.getElementById('agentSummaryMatrix');
1834
- if (!box) return;
1835
- const outcomes = summary?.fileOutcomes || [];
1836
- if (!Array.isArray(outcomes) || outcomes.length === 0) {
1837
- box.style.display = 'none';
1838
- box.innerHTML = '';
1839
- return;
1840
- }
1841
- const stats = summary?.validationStats || {};
1842
- box.style.display = 'block';
1843
- box.innerHTML = `
1844
- <div class="agent-summary-title">
1845
- Матрица итогов (run: ${escapeHtml(summary?.runId || '—')}) • covered: ${stats.covered ?? 0} • not-covered: ${stats.notCovered ?? 0} • blocked: ${stats.blocked ?? 0} • infra: ${stats.infra ?? 0}
1846
- </div>
1847
- ${outcomes.map((entry) => `
1848
- <div class="agent-summary-row">
1849
- <span class="agent-summary-status-${entry.status}">${escapeHtml(entry.status)}</span>
1850
- <span title="${escapeHtml(entry.sourcePath || '')}">${escapeHtml(entry.sourcePath || '')}</span>
1851
- <span style="color:var(--dim)">${escapeHtml(entry.testFile || entry.reason || '')}</span>
1852
- </div>
1853
- `).join('')}
1854
- `;
1855
- }
1856
-
1857
- function findSummaryForRun(runId) {
1858
- if (!runId) return null;
1859
- return (agentRunsState || []).find((r) => r.runId === runId && Array.isArray(r.fileOutcomes) && r.fileOutcomes.length > 0) || null;
1860
- }
1861
-
1862
- function syncSummaryForActiveSession() {
1863
- const runId = sessionRunMap.get(activeSessionId) || currentRunId;
1864
- const summary = findSummaryForRun(runId) || null;
1865
- renderRunSummaryMatrix(summary);
1866
- }
1867
-
1868
- async function cancelQueueItem(runId) {
1869
- await fetch(`/api/queue/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
1870
- await loadAgentState();
1871
- }
1872
-
1873
- async function retryRun(runId) {
1874
- await fetch(`/api/queue/${encodeURIComponent(runId)}/retry`, { method: 'POST' });
1875
- await loadAgentState();
1876
- }
1877
-
1878
- async function reorderQueueItem(runId, direction) {
1879
- await fetch(`/api/queue/${encodeURIComponent(runId)}/reorder`, {
1880
- method: 'POST',
1881
- headers: { 'Content-Type': 'application/json' },
1882
- body: JSON.stringify({ direction }),
1883
- });
1884
- await loadAgentState();
1885
- }
1886
-
1887
- function ensureSessionForRun(run, makeActive = false) {
1888
- if (!run?.runId) return null;
1889
- let sessionId = runSessionMap.get(run.runId);
1890
- if (!sessionId) {
1891
- sessionId = createSession(run.title || `Run ${run.runId}`, run.phase === 'failed' ? 'error' : run.phase === 'completed' ? 'ok' : 'running', run.runId);
1892
- const meta = [`runId: ${run.runId}`, `phase: ${run.phase}`];
1893
- if (Array.isArray(run.targetSourcePaths) && run.targetSourcePaths.length > 0) {
1894
- meta.push(`targets: ${run.targetSourcePaths.length}`);
1895
- }
1896
- appendToSession(sessionId, `ℹ ${meta.join(' • ')}`, false, true);
1897
- }
1898
- if (makeActive) switchSession(sessionId);
1899
- return sessionId;
1900
- }
1901
-
1902
- function applyAgentStateSnapshot(state) {
1903
- if (!state) return;
1904
- agentQueueState = Array.isArray(state.queue) ? state.queue : [];
1905
- agentRunsState = Array.isArray(state.runs) ? state.runs : [];
1906
- agentActiveRun = state.activeRun || null;
1907
- updateQueueBadge(agentQueueState.length);
1908
- if (agentActiveRun?.runId) {
1909
- currentRunId = agentActiveRun.runId;
1910
- ensureSessionForRun(agentActiveRun);
1911
- setAgentRunning(['starting', 'running', 'validating'].includes(agentActiveRun.phase));
1912
- } else {
1913
- setAgentRunning(false);
1914
- }
1915
- const latestSummary = [...agentRunsState].reverse().find((r) => Array.isArray(r.fileOutcomes) && r.fileOutcomes.length > 0);
1916
- if (latestSummary) {
1917
- lastRunSummary = latestSummary;
1918
- }
1919
- syncSummaryForActiveSession();
1920
- }
1921
-
1922
- async function loadAgentState() {
1923
- try {
1924
- const res = await fetch('/api/agent/state');
1925
- if (!res.ok) return;
1926
- const state = await res.json();
1927
- applyAgentStateSnapshot(state);
1928
- } catch {}
1929
- }
1930
-
1931
- function upsertRunState(run) {
1932
- if (!run?.runId) return;
1933
- const idx = agentRunsState.findIndex((r) => r.runId === run.runId);
1934
- if (idx >= 0) agentRunsState[idx] = { ...agentRunsState[idx], ...run };
1935
- else agentRunsState.push(run);
1936
- if (agentRunsState.length > 80) {
1937
- agentRunsState = agentRunsState.slice(agentRunsState.length - 80);
1938
- }
1939
- }
1940
-
1941
- function updateSessionFromRun(run) {
1942
- if (!run?.runId) return;
1943
- upsertRunState(run);
1944
- if (['starting', 'running', 'validating'].includes(run.phase)) {
1945
- setAgentRunning(true);
1946
- currentRunId = run.runId;
1947
- const sid = ensureSessionForRun(run);
1948
- if (sid) {
1949
- runningSessionId = sid;
1950
- if (activeSessionId === sid) {
1951
- document.getElementById('agentPanelTitle').textContent = '🤖 ' + (run.title || 'Agent');
1952
- document.getElementById('agentPanelStatus').textContent = run.phase === 'validating' ? 'проверяю…' : 'работает…';
1953
- }
1954
- }
1955
- return;
1956
- }
1957
- const sid = runSessionMap.get(run.runId);
1958
- if (sid) {
1959
- const status = run.phase === 'completed' ? 'ok' : run.phase === 'canceled' ? 'error' : run.phase === 'failed' ? 'error' : 'info';
1960
- updateSessionStatus(sid, status);
1961
- if (runningSessionId === sid) runningSessionId = null;
1962
- if (activeSessionId === sid) {
1963
- document.getElementById('agentPanelStatus').textContent =
1964
- run.phase === 'completed' ? '✅ готово' : run.phase === 'canceled' ? '⏹ отменено' : run.phase === 'failed' ? '❌ ошибка' : run.phase;
1965
- }
1966
- }
1967
- if (run.phase === 'completed' || run.phase === 'failed' || run.phase === 'canceled') {
1968
- if (agentActiveRun?.runId === run.runId) agentActiveRun = null;
1969
- if (currentRunId === run.runId) currentRunId = null;
1970
- setAgentRunning(false);
1971
- }
1972
- syncSummaryForActiveSession();
1973
- }
1974
-
1975
- function refreshPathActivityFromState() {
1976
- agentQueuedPaths.clear();
1977
- (agentQueueState || []).forEach((q) => {
1978
- getTaskFilePaths(q.task, q.featureKey, q.filePath, q.selectedFilePaths).forEach((p) => agentQueuedPaths.add(p));
1979
- });
1980
- if (!agentActiveRun || !['starting', 'running', 'validating'].includes(agentActiveRun.phase)) {
1981
- agentRunningPaths.clear();
1982
- }
1983
- }
1984
-
1985
- async function cancelAgent() {
1986
- await fetch('/api/cancel-agent', { method: 'POST' });
1987
- await loadAgentState();
1988
- document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1989
- if (runningSessionId) {
1990
- appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1798
+ function updateQueueBadge(n) {
1799
+ document.getElementById('agentQueueCount').textContent = n;
1800
+ document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1801
+ document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1802
+ renderQueuePanel();
1803
+ }
1804
+
1805
+ function renderQueuePanel() {
1806
+ const panel = document.getElementById('agentQueuePanel');
1807
+ if (!panel) return;
1808
+ if (!Array.isArray(agentQueueState) || agentQueueState.length === 0) {
1809
+ panel.style.display = 'none';
1810
+ panel.innerHTML = '';
1811
+ return;
1812
+ }
1813
+ panel.style.display = 'block';
1814
+ panel.innerHTML = `
1815
+ <div class="agent-queue-title">Очередь задач (${agentQueueState.length})</div>
1816
+ ${agentQueueState.map((item, idx) => `
1817
+ <div class="agent-queue-item">
1818
+ <span class="agent-queue-pos">#${item.position ?? idx + 1}</span>
1819
+ <span title="${escapeHtml(item.title || '')}">${escapeHtml(item.title || item.task || item.runId)}</span>
1820
+ <span style="color:var(--dim)">(${escapeHtml(item.task || 'task')})</span>
1821
+ <div class="agent-queue-actions">
1822
+ <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','up')" title="Сдвинуть вверх">↑</button>
1823
+ <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','down')" title="Сдвинуть вниз">↓</button>
1824
+ <button class="agent-queue-action" onclick="cancelQueueItem('${item.runId}')" title="Отменить задачу">✕</button>
1825
+ <button class="agent-queue-action" onclick="retryRun('${item.runId}')" title="Retry">↻</button>
1826
+ </div>
1827
+ </div>
1828
+ `).join('')}
1829
+ `;
1830
+ }
1831
+
1832
+ function renderRunSummaryMatrix(summary = lastRunSummary) {
1833
+ const box = document.getElementById('agentSummaryMatrix');
1834
+ if (!box) return;
1835
+ const outcomes = summary?.fileOutcomes || [];
1836
+ if (!Array.isArray(outcomes) || outcomes.length === 0) {
1837
+ box.style.display = 'none';
1838
+ box.innerHTML = '';
1839
+ return;
1840
+ }
1841
+ const stats = summary?.validationStats || {};
1842
+ box.style.display = 'block';
1843
+ box.innerHTML = `
1844
+ <div class="agent-summary-title">
1845
+ Матрица итогов (run: ${escapeHtml(summary?.runId || '—')}) • covered: ${stats.covered ?? 0} • not-covered: ${stats.notCovered ?? 0} • blocked: ${stats.blocked ?? 0} • infra: ${stats.infra ?? 0}
1846
+ </div>
1847
+ ${outcomes.map((entry) => `
1848
+ <div class="agent-summary-row">
1849
+ <span class="agent-summary-status-${entry.status}">${escapeHtml(entry.status)}</span>
1850
+ <span title="${escapeHtml(entry.sourcePath || '')}">${escapeHtml(entry.sourcePath || '')}</span>
1851
+ <span style="color:var(--dim)">${escapeHtml(entry.testFile || entry.reason || '')}</span>
1852
+ </div>
1853
+ `).join('')}
1854
+ `;
1855
+ }
1856
+
1857
+ function findSummaryForRun(runId) {
1858
+ if (!runId) return null;
1859
+ return (agentRunsState || []).find((r) => r.runId === runId && Array.isArray(r.fileOutcomes) && r.fileOutcomes.length > 0) || null;
1860
+ }
1861
+
1862
+ function syncSummaryForActiveSession() {
1863
+ const runId = sessionRunMap.get(activeSessionId) || currentRunId;
1864
+ const summary = findSummaryForRun(runId) || null;
1865
+ renderRunSummaryMatrix(summary);
1866
+ }
1867
+
1868
+ async function cancelQueueItem(runId) {
1869
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
1870
+ await loadAgentState();
1871
+ }
1872
+
1873
+ async function retryRun(runId) {
1874
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/retry`, { method: 'POST' });
1875
+ await loadAgentState();
1876
+ }
1877
+
1878
+ async function reorderQueueItem(runId, direction) {
1879
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/reorder`, {
1880
+ method: 'POST',
1881
+ headers: { 'Content-Type': 'application/json' },
1882
+ body: JSON.stringify({ direction }),
1883
+ });
1884
+ await loadAgentState();
1885
+ }
1886
+
1887
+ function ensureSessionForRun(run, makeActive = false) {
1888
+ if (!run?.runId) return null;
1889
+ let sessionId = runSessionMap.get(run.runId);
1890
+ if (!sessionId) {
1891
+ sessionId = createSession(run.title || `Run ${run.runId}`, run.phase === 'failed' ? 'error' : run.phase === 'completed' ? 'ok' : 'running', run.runId);
1892
+ const meta = [`runId: ${run.runId}`, `phase: ${run.phase}`];
1893
+ if (Array.isArray(run.targetSourcePaths) && run.targetSourcePaths.length > 0) {
1894
+ meta.push(`targets: ${run.targetSourcePaths.length}`);
1895
+ }
1896
+ appendToSession(sessionId, `ℹ ${meta.join(' • ')}`, false, true);
1897
+ }
1898
+ if (makeActive) switchSession(sessionId);
1899
+ return sessionId;
1900
+ }
1901
+
1902
+ function applyAgentStateSnapshot(state) {
1903
+ if (!state) return;
1904
+ agentQueueState = Array.isArray(state.queue) ? state.queue : [];
1905
+ agentRunsState = Array.isArray(state.runs) ? state.runs : [];
1906
+ agentActiveRun = state.activeRun || null;
1907
+ updateQueueBadge(agentQueueState.length);
1908
+ if (agentActiveRun?.runId) {
1909
+ currentRunId = agentActiveRun.runId;
1910
+ ensureSessionForRun(agentActiveRun);
1911
+ setAgentRunning(['starting', 'running', 'validating'].includes(agentActiveRun.phase));
1912
+ } else {
1913
+ setAgentRunning(false);
1914
+ }
1915
+ const latestSummary = [...agentRunsState].reverse().find((r) => Array.isArray(r.fileOutcomes) && r.fileOutcomes.length > 0);
1916
+ if (latestSummary) {
1917
+ lastRunSummary = latestSummary;
1918
+ }
1919
+ syncSummaryForActiveSession();
1920
+ }
1921
+
1922
+ async function loadAgentState() {
1923
+ try {
1924
+ const res = await fetch('/api/agent/state');
1925
+ if (!res.ok) return;
1926
+ const state = await res.json();
1927
+ applyAgentStateSnapshot(state);
1928
+ } catch {}
1929
+ }
1930
+
1931
+ function upsertRunState(run) {
1932
+ if (!run?.runId) return;
1933
+ const idx = agentRunsState.findIndex((r) => r.runId === run.runId);
1934
+ if (idx >= 0) agentRunsState[idx] = { ...agentRunsState[idx], ...run };
1935
+ else agentRunsState.push(run);
1936
+ if (agentRunsState.length > 80) {
1937
+ agentRunsState = agentRunsState.slice(agentRunsState.length - 80);
1938
+ }
1939
+ }
1940
+
1941
+ function updateSessionFromRun(run) {
1942
+ if (!run?.runId) return;
1943
+ upsertRunState(run);
1944
+ if (['starting', 'running', 'validating'].includes(run.phase)) {
1945
+ setAgentRunning(true);
1946
+ currentRunId = run.runId;
1947
+ const sid = ensureSessionForRun(run);
1948
+ if (sid) {
1949
+ runningSessionId = sid;
1950
+ if (activeSessionId === sid) {
1951
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + (run.title || 'Agent');
1952
+ document.getElementById('agentPanelStatus').textContent = run.phase === 'validating' ? 'проверяю…' : 'работает…';
1953
+ }
1954
+ }
1955
+ return;
1956
+ }
1957
+ const sid = runSessionMap.get(run.runId);
1958
+ if (sid) {
1959
+ const status = run.phase === 'completed' ? 'ok' : run.phase === 'canceled' ? 'error' : run.phase === 'failed' ? 'error' : 'info';
1960
+ updateSessionStatus(sid, status);
1961
+ if (runningSessionId === sid) runningSessionId = null;
1962
+ if (activeSessionId === sid) {
1963
+ document.getElementById('agentPanelStatus').textContent =
1964
+ run.phase === 'completed' ? '✅ готово' : run.phase === 'canceled' ? '⏹ отменено' : run.phase === 'failed' ? '❌ ошибка' : run.phase;
1965
+ }
1966
+ }
1967
+ if (run.phase === 'completed' || run.phase === 'failed' || run.phase === 'canceled') {
1968
+ if (agentActiveRun?.runId === run.runId) agentActiveRun = null;
1969
+ if (currentRunId === run.runId) currentRunId = null;
1970
+ setAgentRunning(false);
1971
+ }
1972
+ syncSummaryForActiveSession();
1973
+ }
1974
+
1975
+ function refreshPathActivityFromState() {
1976
+ agentQueuedPaths.clear();
1977
+ (agentQueueState || []).forEach((q) => {
1978
+ getTaskFilePaths(q.task, q.featureKey, q.filePath, q.selectedFilePaths).forEach((p) => agentQueuedPaths.add(p));
1979
+ });
1980
+ if (!agentActiveRun || !['starting', 'running', 'validating'].includes(agentActiveRun.phase)) {
1981
+ agentRunningPaths.clear();
1982
+ }
1983
+ }
1984
+
1985
+ async function cancelAgent() {
1986
+ await fetch('/api/cancel-agent', { method: 'POST' });
1987
+ await loadAgentState();
1988
+ document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1989
+ if (runningSessionId) {
1990
+ appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1991
1991
  updateSessionStatus(runningSessionId, 'error');
1992
1992
  runningSessionId = null;
1993
1993
  } else {
@@ -1995,11 +1995,11 @@ async function cancelAgent() {
1995
1995
  }
1996
1996
  }
1997
1997
 
1998
- async function clearAgentQueue() {
1999
- await fetch('/api/clear-queue', { method: 'POST' });
2000
- await loadAgentState();
2001
- appendTerminalLine('🗑 Очередь очищена', false);
2002
- }
1998
+ async function clearAgentQueue() {
1999
+ await fetch('/api/clear-queue', { method: 'POST' });
2000
+ await loadAgentState();
2001
+ appendTerminalLine('🗑 Очередь очищена', false);
2002
+ }
2003
2003
 
2004
2004
  // ─── File row more menu ──────────────────────────────────────────────────────
2005
2005
  let _openFileMenu = null;
@@ -2126,9 +2126,12 @@ async function reauthAgent() {
2126
2126
  }
2127
2127
  const id = createSession('🔑 Перелогинивание');
2128
2128
  runningSessionId = id;
2129
+ document.getElementById('agentPanel').classList.add('open');
2130
+ document.getElementById('termBtn').classList.add('term-active');
2129
2131
  document.getElementById('agentPanelTitle').textContent = '🔑 Перелогинивание';
2130
2132
  document.getElementById('agentPanelStatus').textContent = 'выполняю…';
2131
2133
  await fetch('/api/agent-reauth', { method: 'POST' });
2134
+ document.getElementById('agentPanelStatus').textContent = 'жду завершения браузера…';
2132
2135
  }
2133
2136
 
2134
2137
  async function runAgentTask(task, featureKey, filePath, selectedFilePaths) {
@@ -2271,14 +2274,14 @@ function renderStats() {
2271
2274
  const totalBytes = src.reduce((acc, m) => acc + (m.size || 0), 0);
2272
2275
  const avgKb = src.length ? Math.round(totalBytes / src.length / 1024) : 0;
2273
2276
  const noiseRatio = src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0;
2274
- const missingStructured = serviceSources.filter(m => !m.hasTests).length;
2275
- items = [
2276
- { v: serviceSources.length, l: 'Источники логов' },
2277
- { v: avgKb + ' KB', l: 'Средний объём/файл' },
2278
- { v: noiseRatio + '%', l: 'Коэффициент шума' },
2279
- { v: errorSignal, l: 'Сигнал ошибок', c: errorSignal ? '#f85149' : undefined },
2280
- { v: missingStructured, l: 'Не хватает полей', c: missingStructured ? '#e3b341' : undefined },
2281
- ];
2277
+ const missingStructured = serviceSources.filter(m => !m.hasTests).length;
2278
+ items = [
2279
+ { v: serviceSources.length, l: 'Источники логов' },
2280
+ { v: avgKb + ' KB', l: 'Средний объём/файл' },
2281
+ { v: noiseRatio + '%', l: 'Коэффициент шума' },
2282
+ { v: errorSignal, l: 'Сигнал ошибок', c: errorSignal ? '#f85149' : undefined },
2283
+ { v: missingStructured, l: 'Не хватает полей', c: missingStructured ? '#e3b341' : undefined },
2284
+ ];
2282
2285
  } else if (D.hasConfig && D.features) {
2283
2286
  const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
2284
2287
  items = [
@@ -2307,10 +2310,10 @@ function renderStats() {
2307
2310
 
2308
2311
  function renderModeSwitch() {
2309
2312
  const root = document.getElementById('modeSwitch');
2310
- const modes = [
2311
- { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2312
- { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2313
- ];
2313
+ const modes = [
2314
+ { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2315
+ { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2316
+ ];
2314
2317
  root.innerHTML = modes.map(m => `
2315
2318
  <button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
2316
2319
  <span>${m.label}</span>
@@ -2328,15 +2331,15 @@ function renderSidebar() {
2328
2331
  const tabs = document.getElementById('viewTabs');
2329
2332
  const extra = document.getElementById('sidebarExtra');
2330
2333
 
2331
- if (contextMode === 'observability') {
2332
- tabs.style.display = 'none';
2333
- extra.innerHTML = `
2334
- <div class="sidebar-label">Фокус наблюдаемости</div>
2335
- <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
2336
- Источники логов и качество сигналов для триажа инцидентов.
2337
- </div>`;
2338
- return;
2339
- }
2334
+ if (contextMode === 'observability') {
2335
+ tabs.style.display = 'none';
2336
+ extra.innerHTML = `
2337
+ <div class="sidebar-label">Фокус наблюдаемости</div>
2338
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
2339
+ Источники логов и качество сигналов для триажа инцидентов.
2340
+ </div>`;
2341
+ return;
2342
+ }
2340
2343
 
2341
2344
  tabs.style.display = 'flex';
2342
2345
  document.querySelectorAll('.view-tab').forEach(t =>
@@ -2396,13 +2399,13 @@ function renderQaOnboarding() {
2396
2399
  return `<div class="onboarding-block"><h3>QA Coverage: что это?</h3><p>Этот экран помогает найти пробелы в тестах: сначала проверь покрытие по фичам, затем открой критичные непокрытые файлы и запусти генерацию/фиксы тестов.</p></div>`;
2397
2400
  }
2398
2401
 
2399
- function renderObservability(c) {
2400
- const src = D.modules.filter(m => m.type !== 'test');
2401
- const sourceGroups = [
2402
- { label: 'Сервисы', count: src.filter(m => m.type === 'service').length },
2403
- { label: 'Утилиты', count: src.filter(m => m.type === 'util').length },
2404
- { label: 'Прочее', count: src.filter(m => m.type === 'other').length },
2405
- ].filter(x => x.count > 0);
2402
+ function renderObservability(c) {
2403
+ const src = D.modules.filter(m => m.type !== 'test');
2404
+ const sourceGroups = [
2405
+ { label: 'Сервисы', count: src.filter(m => m.type === 'service').length },
2406
+ { label: 'Утилиты', count: src.filter(m => m.type === 'util').length },
2407
+ { label: 'Прочее', count: src.filter(m => m.type === 'other').length },
2408
+ ].filter(x => x.count > 0);
2406
2409
  const errorSignal = Object.values(D.testErrors || {}).reduce((acc, v) => acc + (v?.failed || 0), 0);
2407
2410
  const missingStructured = src.filter(m => (m.type === 'service' || m.type === 'util') && !m.hasTests).slice(0, 8);
2408
2411
  const noisy = [...src]
@@ -2410,11 +2413,11 @@ function renderObservability(c) {
2410
2413
  .slice(0, 5)
2411
2414
  .map(m => ({ name: m.name, rel: m.relativePath, kb: Math.round((m.size || 0) / 1024) }));
2412
2415
 
2413
- c.innerHTML = `
2414
- <div class="onboarding-block">
2415
- <h3>Наблюдаемость: что это?</h3>
2416
- <p>Экран для контроля отношения сигнал/шум: откуда идут логи, где растёт объём, сколько шума и какие поля/сигналы нужно структурировать в первую очередь.</p>
2417
- </div>
2416
+ c.innerHTML = `
2417
+ <div class="onboarding-block">
2418
+ <h3>Наблюдаемость: что это?</h3>
2419
+ <p>Экран для контроля отношения сигнал/шум: откуда идут логи, где растёт объём, сколько шума и какие поля/сигналы нужно структурировать в первую очередь.</p>
2420
+ </div>
2418
2421
 
2419
2422
  <div class="obs-grid">
2420
2423
  <div class="obs-card">
@@ -2424,33 +2427,33 @@ function renderObservability(c) {
2424
2427
  </div>
2425
2428
  </div>
2426
2429
 
2427
- <div class="obs-card">
2428
- <div class="obs-title">Объём логов (топ файлов)</div>
2429
- <div class="obs-list">
2430
- ${noisy.map(n => `<div class="obs-list-item"><span title="${n.rel}">${n.name}</span><strong>${n.kb} KB</strong></div>`).join('') || '<div class="obs-sub">Нет файлов</div>'}
2431
- </div>
2432
- </div>
2433
-
2434
- <div class="obs-card">
2435
- <div class="obs-title">Коэффициент шума</div>
2436
- <div class="obs-value">${src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0}%</div>
2437
- <div class="obs-sub">Доля источников без тестового контроля (прокси-метрика шумного логирования).</div>
2438
- </div>
2439
-
2440
- <div class="obs-card">
2441
- <div class="obs-title">Сигнал ошибок</div>
2442
- <div class="obs-value" style="color:${errorSignal ? 'var(--red)' : 'var(--green)'}">${errorSignal}</div>
2443
- <div class="obs-sub">Сумма упавших тестов из последнего прогона как индикатор нестабильности сигналов.</div>
2444
- </div>
2445
-
2446
- <div class="obs-card" style="grid-column:1 / -1">
2447
- <div class="obs-title">Недостающие структурированные поля (кандидаты)</div>
2448
- <div class="obs-list">
2449
- ${missingStructured.map(m => `<div class="obs-list-item"><span>${m.relativePath}</span><strong>нужна схема</strong></div>`).join('') || '<div class="obs-sub">Явных кандидатов не найдено</div>'}
2450
- </div>
2451
- </div>
2452
- </div>`;
2453
- }
2430
+ <div class="obs-card">
2431
+ <div class="obs-title">Объём логов (топ файлов)</div>
2432
+ <div class="obs-list">
2433
+ ${noisy.map(n => `<div class="obs-list-item"><span title="${n.rel}">${n.name}</span><strong>${n.kb} KB</strong></div>`).join('') || '<div class="obs-sub">Нет файлов</div>'}
2434
+ </div>
2435
+ </div>
2436
+
2437
+ <div class="obs-card">
2438
+ <div class="obs-title">Коэффициент шума</div>
2439
+ <div class="obs-value">${src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0}%</div>
2440
+ <div class="obs-sub">Доля источников без тестового контроля (прокси-метрика шумного логирования).</div>
2441
+ </div>
2442
+
2443
+ <div class="obs-card">
2444
+ <div class="obs-title">Сигнал ошибок</div>
2445
+ <div class="obs-value" style="color:${errorSignal ? 'var(--red)' : 'var(--green)'}">${errorSignal}</div>
2446
+ <div class="obs-sub">Сумма упавших тестов из последнего прогона как индикатор нестабильности сигналов.</div>
2447
+ </div>
2448
+
2449
+ <div class="obs-card" style="grid-column:1 / -1">
2450
+ <div class="obs-title">Недостающие структурированные поля (кандидаты)</div>
2451
+ <div class="obs-list">
2452
+ ${missingStructured.map(m => `<div class="obs-list-item"><span>${m.relativePath}</span><strong>нужна схема</strong></div>`).join('') || '<div class="obs-sub">Явных кандидатов не найдено</div>'}
2453
+ </div>
2454
+ </div>
2455
+ </div>`;
2456
+ }
2454
2457
 
2455
2458
  function backToFeatureDetail() {
2456
2459
  drillTestType = null;
@@ -2468,15 +2471,15 @@ function toPct(v) {
2468
2471
  return Math.round((v || 0) * 100) + '%';
2469
2472
  }
2470
2473
 
2471
- function renderObservabilityOverview(c) {
2472
- const o = D.observability;
2473
- if (!o) return '';
2474
- const metrics = [
2475
- ['Коэффициент шума', toPct(o.metrics.noise_ratio)],
2476
- ['Действия по ошибкам', toPct(o.metrics.error_actionability)],
2477
- ['Полнота структуры', toPct(o.metrics.structured_completeness)],
2478
- ['Покрытие ключевых сценариев', toPct(o.metrics.coverage_of_key_flows)],
2479
- ];
2474
+ function renderObservabilityOverview(c) {
2475
+ const o = D.observability;
2476
+ if (!o) return '';
2477
+ const metrics = [
2478
+ ['Коэффициент шума', toPct(o.metrics.noise_ratio)],
2479
+ ['Действия по ошибкам', toPct(o.metrics.error_actionability)],
2480
+ ['Полнота структуры', toPct(o.metrics.structured_completeness)],
2481
+ ['Покрытие ключевых сценариев', toPct(o.metrics.coverage_of_key_flows)],
2482
+ ];
2480
2483
  const noisy = (o.topNoisyPatterns || []).slice(0, 5).map(i =>
2481
2484
  `<div class="obs-row"><span class="obs-priority-${i.priority}">[${i.priority}]</span> ${escapeHtml(i.pattern)} · x${i.count} → <b>${i.recommendation}</b></div>`
2482
2485
  ).join('') || '<div class="obs-row">Нет шумных паттернов</div>';
@@ -2489,27 +2492,27 @@ function renderObservabilityOverview(c) {
2489
2492
  `<div class="obs-cat-row"><span>${escapeHtml(i.modulePath)}</span><span>${i.level}</span><span>${i.format}</span><span>${i.frequency}</span><span>${escapeHtml(i.owner)}</span><span>${i.recommendation}</span></div>`
2490
2493
  ).join('');
2491
2494
 
2492
- return `
2493
- <div class="observability-panel">
2494
- <div class="observability-title">Наблюдаемость</div>
2495
- <div class="obs-metrics">
2496
- ${metrics.map(([l,v]) => `<div class="obs-metric"><div class="obs-metric-v">${v}</div><div class="obs-metric-l">${l}</div></div>`).join('')}
2497
- </div>
2498
- <div class="obs-columns">
2499
- <div class="obs-list">
2500
- <h4>Топ шумных паттернов</h4>
2501
- ${noisy}
2502
- </div>
2503
- <div class="obs-list">
2504
- <h4>Отсутствующие критичные логи</h4>
2505
- ${missing}
2506
- </div>
2507
- </div>
2508
- <div class="obs-catalog">
2509
- <h4>Каталог источников логов (топ 10)</h4>
2510
- <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>частота</span><span>владелец</span><span>действие</span></div>
2511
- ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2512
- </div>
2495
+ return `
2496
+ <div class="observability-panel">
2497
+ <div class="observability-title">Наблюдаемость</div>
2498
+ <div class="obs-metrics">
2499
+ ${metrics.map(([l,v]) => `<div class="obs-metric"><div class="obs-metric-v">${v}</div><div class="obs-metric-l">${l}</div></div>`).join('')}
2500
+ </div>
2501
+ <div class="obs-columns">
2502
+ <div class="obs-list">
2503
+ <h4>Топ шумных паттернов</h4>
2504
+ ${noisy}
2505
+ </div>
2506
+ <div class="obs-list">
2507
+ <h4>Отсутствующие критичные логи</h4>
2508
+ ${missing}
2509
+ </div>
2510
+ </div>
2511
+ <div class="obs-catalog">
2512
+ <h4>Каталог источников логов (топ 10)</h4>
2513
+ <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>частота</span><span>владелец</span><span>действие</span></div>
2514
+ ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2515
+ </div>
2513
2516
  <div class="obs-row" style="margin-top:8px">
2514
2517
  Классификация: мусор ${o.classification.trash} · полезно ${o.classification.useful} · критично ${o.classification.critical}
2515
2518
  </div>
@@ -2547,7 +2550,7 @@ function renderFeatureCards(c) {
2547
2550
  </div>
2548
2551
  </div>` : '';
2549
2552
 
2550
- c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
2553
+ c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
2551
2554
  const grid = document.getElementById('featGrid');
2552
2555
 
2553
2556
  list.forEach(f => {
@@ -2852,12 +2855,12 @@ function renderFeatureDetail(c) {
2852
2855
  return;
2853
2856
  }
2854
2857
 
2855
- const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
2856
- const src = mods.filter(m => m.type !== 'test');
2857
- const untestedSrc = src.filter(m => !m.hasTests);
2858
- const tst = mods.filter(m => m.type === 'test');
2859
- const testedCount = src.filter(m => m.hasTests).length;
2860
- const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
2858
+ const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
2859
+ const src = mods.filter(m => m.type !== 'test');
2860
+ const untestedSrc = src.filter(m => !m.hasTests);
2861
+ const tst = mods.filter(m => m.type === 'test');
2862
+ const testedCount = src.filter(m => m.hasTests).length;
2863
+ const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
2861
2864
 
2862
2865
  const unitCount = feat.unitTestCount ?? tst.filter(m => m.testType === 'unit').length;
2863
2866
  const integrationCount = feat.integrationTestCount ?? tst.filter(m => m.testType === 'integration').length;
@@ -2869,32 +2872,32 @@ function renderFeatureDetail(c) {
2869
2872
  const integrationFailed = tst.filter(m => m.testType === 'integration' && te[m.relativePath.replace(/\\/g, '/')]).length;
2870
2873
  const e2eFailed = tst.filter(m => m.testType === 'e2e' && te[m.relativePath.replace(/\\/g, '/')]).length;
2871
2874
 
2872
- // Determine what list to show based on active tab
2873
- // null or 'source' → source files; test type → test files of that type
2874
- const activeTab = drillTestType || 'source';
2875
- const sourceList = showOnlyUntestedInFeature ? untestedSrc : src;
2876
- const listFiles = activeTab === 'source' ? sourceList : tst.filter(m => m.testType === activeTab);
2877
- const isTestList = activeTab !== 'source';
2878
- const showUntestedToggle = activeTab === 'source';
2879
- const meta = TEST_TYPE_META[activeTab];
2880
- const listLabel = meta
2881
- ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
2882
- : `📁 Файлы фичи (${listFiles.length})`;
2883
-
2884
- const q = searchQuery.toLowerCase();
2885
- const filtered = q ? listFiles.filter(m =>
2886
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2887
- ) : listFiles;
2888
- const rowsRenderKey = `feature:${drillFeatureKey}:${activeTab}:${showOnlyUntestedInFeature ? 1 : 0}:${q}`;
2889
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2890
- const selectableSource = !isTestList && !!D.agent;
2891
- const visibleSourcePaths = selectableSource
2892
- ? visibleRows.map(m => m.relativePath.replace(/\\/g, '/'))
2893
- : [];
2894
- const selectedVisible = selectableSource
2895
- ? selectedFilesForFeature(drillFeatureKey, visibleRows.map(m => m.relativePath))
2896
- : [];
2897
- const selectedCount = selectedVisible.length;
2875
+ // Determine what list to show based on active tab
2876
+ // null or 'source' → source files; test type → test files of that type
2877
+ const activeTab = drillTestType || 'source';
2878
+ const sourceList = showOnlyUntestedInFeature ? untestedSrc : src;
2879
+ const listFiles = activeTab === 'source' ? sourceList : tst.filter(m => m.testType === activeTab);
2880
+ const isTestList = activeTab !== 'source';
2881
+ const showUntestedToggle = activeTab === 'source';
2882
+ const meta = TEST_TYPE_META[activeTab];
2883
+ const listLabel = meta
2884
+ ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
2885
+ : `📁 Файлы фичи (${listFiles.length})`;
2886
+
2887
+ const q = searchQuery.toLowerCase();
2888
+ const filtered = q ? listFiles.filter(m =>
2889
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2890
+ ) : listFiles;
2891
+ const rowsRenderKey = `feature:${drillFeatureKey}:${activeTab}:${showOnlyUntestedInFeature ? 1 : 0}:${q}`;
2892
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2893
+ const selectableSource = !isTestList && !!D.agent;
2894
+ const visibleSourcePaths = selectableSource
2895
+ ? visibleRows.map(m => m.relativePath.replace(/\\/g, '/'))
2896
+ : [];
2897
+ const selectedVisible = selectableSource
2898
+ ? selectedFilesForFeature(drillFeatureKey, visibleRows.map(m => m.relativePath))
2899
+ : [];
2900
+ const selectedCount = selectedVisible.length;
2898
2901
 
2899
2902
  c.innerHTML = `
2900
2903
  <div class="drill-header">
@@ -2920,51 +2923,51 @@ function renderFeatureDetail(c) {
2920
2923
  ${testTypeCard('integration', 'Integration', '🔗', '#58a6ff', integrationCount, activeTab === 'integration', drillFeatureKey, integrationFailed)}
2921
2924
  ${testTypeCard('e2e', 'E2E', '🎭', '#d2a8ff', e2eCount, activeTab === 'e2e', drillFeatureKey, e2eFailed)}
2922
2925
  </div>
2923
-
2924
- <div class="drill-section-label">${listLabel}</div>
2925
- ${showUntestedToggle ? `
2926
- <div class="feature-file-filters">
2927
- <label class="feature-filter-toggle">
2928
- <input type="checkbox" id="untestedOnlyToggle" ${showOnlyUntestedInFeature ? 'checked' : ''}>
2929
- <span>Только без тестов</span>
2930
- <span class="feature-filter-meta">(${untestedSrc.length})</span>
2931
- </label>
2932
- </div>` : ''}
2933
- ${selectableSource ? `
2934
- <div class="bulk-actions">
2935
- <span class="bulk-actions-count">Выбрано файлов: <b>${selectedCount}</b></span>
2936
- <button class="bulk-actions-btn" id="bulkSelectAllBtn" ${visibleSourcePaths.length === 0 ? 'disabled' : ''}>☑ Выбрать все (${visibleSourcePaths.length})</button>
2926
+
2927
+ <div class="drill-section-label">${listLabel}</div>
2928
+ ${showUntestedToggle ? `
2929
+ <div class="feature-file-filters">
2930
+ <label class="feature-filter-toggle">
2931
+ <input type="checkbox" id="untestedOnlyToggle" ${showOnlyUntestedInFeature ? 'checked' : ''}>
2932
+ <span>Только без тестов</span>
2933
+ <span class="feature-filter-meta">(${untestedSrc.length})</span>
2934
+ </label>
2935
+ </div>` : ''}
2936
+ ${selectableSource ? `
2937
+ <div class="bulk-actions">
2938
+ <span class="bulk-actions-count">Выбрано файлов: <b>${selectedCount}</b></span>
2939
+ <button class="bulk-actions-btn" id="bulkSelectAllBtn" ${visibleSourcePaths.length === 0 ? 'disabled' : ''}>☑ Выбрать все (${visibleSourcePaths.length})</button>
2937
2940
  <button class="bulk-actions-btn" id="bulkClearBtn" ${selectedCount === 0 ? 'disabled' : ''}>Снять выбор</button>
2938
2941
  <button class="bulk-actions-btn primary" id="bulkWriteTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>✍ Написать тесты для выбранных</button>
2939
2942
  <button class="bulk-actions-btn" id="bulkRefreshTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>↻ Актуализировать тесты</button>
2940
2943
  </div>` : ''}
2941
- <div class="file-rows" id="fileRows">
2942
- ${filtered.length === 0
2943
- ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
2944
- ${isTestList
2945
- ? 'Нет тестов этого типа для данной фичи'
2946
- : (showOnlyUntestedInFeature
2947
- ? 'Все файлы этой фичи уже покрыты тестами'
2948
- : 'Нет файлов — возможно паттерны в конфиге не совпадают')}
2949
- </div>`
2950
- : visibleRows.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
2951
- }
2952
- </div>
2953
- ${hasMoreRows ? `
2954
- <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
2955
- <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
2956
- <button class="bulk-actions-btn" id="fileRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
2957
- </div>` : ''}`;
2958
-
2959
- const untestedOnlyToggle = document.getElementById('untestedOnlyToggle');
2960
- if (untestedOnlyToggle) {
2961
- untestedOnlyToggle.onchange = (e) => {
2962
- showOnlyUntestedInFeature = !!e.target.checked;
2963
- renderContent();
2964
- };
2965
- }
2966
-
2967
- if (selectableSource) {
2944
+ <div class="file-rows" id="fileRows">
2945
+ ${filtered.length === 0
2946
+ ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
2947
+ ${isTestList
2948
+ ? 'Нет тестов этого типа для данной фичи'
2949
+ : (showOnlyUntestedInFeature
2950
+ ? 'Все файлы этой фичи уже покрыты тестами'
2951
+ : 'Нет файлов — возможно паттерны в конфиге не совпадают')}
2952
+ </div>`
2953
+ : visibleRows.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
2954
+ }
2955
+ </div>
2956
+ ${hasMoreRows ? `
2957
+ <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
2958
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
2959
+ <button class="bulk-actions-btn" id="fileRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
2960
+ </div>` : ''}`;
2961
+
2962
+ const untestedOnlyToggle = document.getElementById('untestedOnlyToggle');
2963
+ if (untestedOnlyToggle) {
2964
+ untestedOnlyToggle.onchange = (e) => {
2965
+ showOnlyUntestedInFeature = !!e.target.checked;
2966
+ renderContent();
2967
+ };
2968
+ }
2969
+
2970
+ if (selectableSource) {
2968
2971
  const allVisibleSelected = visibleSourcePaths.length > 0 && visibleSourcePaths.every((p) => selectedSourceFiles.has(p));
2969
2972
  const bulkSelectAllBtn = document.getElementById('bulkSelectAllBtn');
2970
2973
  if (bulkSelectAllBtn) {
@@ -2999,17 +3002,17 @@ function renderFeatureDetail(c) {
2999
3002
  renderContent();
3000
3003
  };
3001
3004
  }
3002
- }
3003
-
3004
- const fileRowsLoadMoreBtn = document.getElementById('fileRowsLoadMoreBtn');
3005
- if (fileRowsLoadMoreBtn) {
3006
- fileRowsLoadMoreBtn.onclick = () => {
3007
- increaseFileRowsLimit();
3008
- renderContent();
3009
- };
3010
- }
3011
-
3012
- c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
3005
+ }
3006
+
3007
+ const fileRowsLoadMoreBtn = document.getElementById('fileRowsLoadMoreBtn');
3008
+ if (fileRowsLoadMoreBtn) {
3009
+ fileRowsLoadMoreBtn.onclick = () => {
3010
+ increaseFileRowsLimit();
3011
+ renderContent();
3012
+ };
3013
+ }
3014
+
3015
+ c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
3013
3016
  card.onclick = async () => {
3014
3017
  const type = card.dataset.testtype;
3015
3018
  drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
@@ -3018,11 +3021,11 @@ function renderFeatureDetail(c) {
3018
3021
  if (type === 'e2e') {
3019
3022
  await loadE2ePlan(drillFeatureKey);
3020
3023
  }
3021
- renderContent();
3022
- };
3023
- });
3024
- bindFileRowsClick(c);
3025
- }
3024
+ renderContent();
3025
+ };
3026
+ });
3027
+ bindFileRowsClick(c);
3028
+ }
3026
3029
 
3027
3030
  const TEST_TYPE_META = {
3028
3031
  unit: { label: 'Unit', icon: '🧪', color: '#e3b341', desc: 'Изолированные тесты функций и модулей' },
@@ -3041,12 +3044,12 @@ function renderTestTypeDetail(c) {
3041
3044
  m.featureKeys && m.featureKeys.includes(drillFeatureKey)
3042
3045
  );
3043
3046
 
3044
- const q = searchQuery.toLowerCase();
3045
- const filtered = q ? tests.filter(m =>
3046
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3047
- ) : tests;
3048
- const rowsRenderKey = `tests:${drillFeatureKey}:${drillTestType}:${q}`;
3049
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3047
+ const q = searchQuery.toLowerCase();
3048
+ const filtered = q ? tests.filter(m =>
3049
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3050
+ ) : tests;
3051
+ const rowsRenderKey = `tests:${drillFeatureKey}:${drillTestType}:${q}`;
3052
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3050
3053
 
3051
3054
  c.innerHTML = `
3052
3055
  <div class="drill-header">
@@ -3061,32 +3064,32 @@ function renderTestTypeDetail(c) {
3061
3064
  </div>
3062
3065
  <div style="font-size:12px;color:var(--dim);margin-bottom:16px">${meta.desc}</div>
3063
3066
 
3064
- <div class="file-rows" id="fileRows">
3065
- ${filtered.length === 0
3066
- ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
3067
- <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
3068
- <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
3069
- <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
3070
- </div>`
3071
- : visibleRows.map(m => fileRow(m, true)).join('')
3072
- }
3073
- </div>
3074
- ${hasMoreRows ? `
3075
- <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
3076
- <span>Показано ${visibleRows.length} из ${filtered.length} тест-файлов</span>
3077
- <button class="bulk-actions-btn" id="testRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3078
- </div>` : ''}`;
3079
-
3080
- const testRowsLoadMoreBtn = document.getElementById('testRowsLoadMoreBtn');
3081
- if (testRowsLoadMoreBtn) {
3082
- testRowsLoadMoreBtn.onclick = () => {
3083
- increaseFileRowsLimit();
3084
- renderContent();
3085
- };
3086
- }
3087
-
3088
- bindFileRowsClick(c);
3089
- }
3067
+ <div class="file-rows" id="fileRows">
3068
+ ${filtered.length === 0
3069
+ ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
3070
+ <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
3071
+ <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
3072
+ <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
3073
+ </div>`
3074
+ : visibleRows.map(m => fileRow(m, true)).join('')
3075
+ }
3076
+ </div>
3077
+ ${hasMoreRows ? `
3078
+ <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
3079
+ <span>Показано ${visibleRows.length} из ${filtered.length} тест-файлов</span>
3080
+ <button class="bulk-actions-btn" id="testRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3081
+ </div>` : ''}`;
3082
+
3083
+ const testRowsLoadMoreBtn = document.getElementById('testRowsLoadMoreBtn');
3084
+ if (testRowsLoadMoreBtn) {
3085
+ testRowsLoadMoreBtn.onclick = () => {
3086
+ increaseFileRowsLimit();
3087
+ renderContent();
3088
+ };
3089
+ }
3090
+
3091
+ bindFileRowsClick(c);
3092
+ }
3090
3093
 
3091
3094
  function renderUnmappedDetail(c) {
3092
3095
  const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
@@ -3094,12 +3097,12 @@ function renderUnmappedDetail(c) {
3094
3097
  m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
3095
3098
  );
3096
3099
 
3097
- const q = searchQuery.toLowerCase();
3098
- const filtered = q ? unmappedSrc.filter(m =>
3099
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3100
- ) : unmappedSrc;
3101
- const rowsRenderKey = `unmapped:${q}`;
3102
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3100
+ const q = searchQuery.toLowerCase();
3101
+ const filtered = q ? unmappedSrc.filter(m =>
3102
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3103
+ ) : unmappedSrc;
3104
+ const rowsRenderKey = `unmapped:${q}`;
3105
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3103
3106
 
3104
3107
  // Build prompt text
3105
3108
  const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
@@ -3140,44 +3143,44 @@ function renderUnmappedDetail(c) {
3140
3143
  border-radius:6px; color:var(--blue); font-size:12px; cursor:pointer;
3141
3144
  ">📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)</button>
3142
3145
  </div>
3143
- <div class="file-rows" id="fileRows">
3144
- ${filtered.length === 0
3145
- ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
3146
- : visibleRows.map(m => fileRow(m)).join('')
3147
- }
3148
- </div>
3149
- ${hasMoreRows ? `
3150
- <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
3151
- <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
3152
- <button class="bulk-actions-btn" id="unmappedRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3153
- </div>` : ''}`;
3146
+ <div class="file-rows" id="fileRows">
3147
+ ${filtered.length === 0
3148
+ ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
3149
+ : visibleRows.map(m => fileRow(m)).join('')
3150
+ }
3151
+ </div>
3152
+ ${hasMoreRows ? `
3153
+ <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
3154
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
3155
+ <button class="bulk-actions-btn" id="unmappedRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3156
+ </div>` : ''}`;
3154
3157
 
3155
3158
  const runAgentUnmappedBtn = document.getElementById('runAgentUnmapped');
3156
3159
  if (runAgentUnmappedBtn) {
3157
3160
  runAgentUnmappedBtn.onclick = () => runAgentTask('map-unmapped');
3158
3161
  }
3159
3162
 
3160
- document.getElementById('copyUnmappedDrill').onclick = function() {
3163
+ document.getElementById('copyUnmappedDrill').onclick = function() {
3161
3164
  navigator.clipboard.writeText(promptText).then(() => {
3162
3165
  this.textContent = '✅ Скопировано!';
3163
3166
  this.style.color = 'var(--green)';
3164
3167
  setTimeout(() => {
3165
3168
  this.textContent = `📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)`;
3166
3169
  this.style.color = 'var(--blue)';
3167
- }, 3000);
3168
- });
3169
- };
3170
-
3171
- const unmappedRowsLoadMoreBtn = document.getElementById('unmappedRowsLoadMoreBtn');
3172
- if (unmappedRowsLoadMoreBtn) {
3173
- unmappedRowsLoadMoreBtn.onclick = () => {
3174
- increaseFileRowsLimit();
3175
- renderContent();
3176
- };
3177
- }
3178
-
3179
- bindFileRowsClick(c);
3180
- }
3170
+ }, 3000);
3171
+ });
3172
+ };
3173
+
3174
+ const unmappedRowsLoadMoreBtn = document.getElementById('unmappedRowsLoadMoreBtn');
3175
+ if (unmappedRowsLoadMoreBtn) {
3176
+ unmappedRowsLoadMoreBtn.onclick = () => {
3177
+ increaseFileRowsLimit();
3178
+ renderContent();
3179
+ };
3180
+ }
3181
+
3182
+ bindFileRowsClick(c);
3183
+ }
3181
3184
 
3182
3185
  function fileRow(m, isTest = false, featureKey = null, selectable = false) {
3183
3186
  const relPath = m.relativePath.replace(/\\/g, '/');
@@ -3267,33 +3270,33 @@ function toggleSourceSelection(relPath, checked) {
3267
3270
  renderContent();
3268
3271
  }
3269
3272
 
3270
- function renderModuleGrid(c) {
3271
- const q = searchQuery.toLowerCase();
3272
- const list = D.modules.filter(m => {
3273
- if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
3274
- if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
3275
- return true;
3276
- });
3277
- const typeKey = [...activeTypes].sort().join(',');
3278
- const rowsRenderKey = `modules:${contextMode}:${view}:${typeKey}:${q}`;
3279
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(list, rowsRenderKey);
3280
-
3281
- if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
3282
-
3283
- c.innerHTML = `
3284
- <div class="module-grid" id="modGrid"></div>
3285
- ${hasMoreRows ? `
3286
- <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
3287
- <span>Показано ${visibleRows.length} из ${list.length} модулей</span>
3288
- <button class="bulk-actions-btn" id="moduleRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3289
- </div>` : ''}
3290
- `;
3291
- const grid = document.getElementById('modGrid');
3292
-
3293
- visibleRows.forEach(m => {
3294
- const cov = m.coverage?.lines;
3295
- const isActive = activePanelKey === m.id;
3296
- const card = document.createElement('div');
3273
+ function renderModuleGrid(c) {
3274
+ const q = searchQuery.toLowerCase();
3275
+ const list = D.modules.filter(m => {
3276
+ if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
3277
+ if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
3278
+ return true;
3279
+ });
3280
+ const typeKey = [...activeTypes].sort().join(',');
3281
+ const rowsRenderKey = `modules:${contextMode}:${view}:${typeKey}:${q}`;
3282
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(list, rowsRenderKey);
3283
+
3284
+ if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
3285
+
3286
+ c.innerHTML = `
3287
+ <div class="module-grid" id="modGrid"></div>
3288
+ ${hasMoreRows ? `
3289
+ <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);font-size:12px;color:var(--dim)">
3290
+ <span>Показано ${visibleRows.length} из ${list.length} модулей</span>
3291
+ <button class="bulk-actions-btn" id="moduleRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3292
+ </div>` : ''}
3293
+ `;
3294
+ const grid = document.getElementById('modGrid');
3295
+
3296
+ visibleRows.forEach(m => {
3297
+ const cov = m.coverage?.lines;
3298
+ const isActive = activePanelKey === m.id;
3299
+ const card = document.createElement('div');
3297
3300
  card.className = 'module-card' + (isActive ? ' active' : '');
3298
3301
  card.innerHTML = `
3299
3302
  <div class="module-name">${m.name}</div>
@@ -3305,18 +3308,18 @@ function renderModuleGrid(c) {
3305
3308
  <span class="badge ${m.hasTests ? 'badge-green' : 'badge-red'}">${m.hasTests ? '✓' : '✗'}</span>
3306
3309
  </div>
3307
3310
  ${cov != null ? `<div class="cov-bar"><div class="cov-fill" style="width:${cov}%;background:${covColor(cov)}"></div></div>` : ''}`;
3308
- card.onclick = () => openModulePanel(m);
3309
- grid.appendChild(card);
3310
- });
3311
-
3312
- const moduleRowsLoadMoreBtn = document.getElementById('moduleRowsLoadMoreBtn');
3313
- if (moduleRowsLoadMoreBtn) {
3314
- moduleRowsLoadMoreBtn.onclick = () => {
3315
- increaseFileRowsLimit();
3316
- renderContent();
3317
- };
3318
- }
3319
- }
3311
+ card.onclick = () => openModulePanel(m);
3312
+ grid.appendChild(card);
3313
+ });
3314
+
3315
+ const moduleRowsLoadMoreBtn = document.getElementById('moduleRowsLoadMoreBtn');
3316
+ if (moduleRowsLoadMoreBtn) {
3317
+ moduleRowsLoadMoreBtn.onclick = () => {
3318
+ increaseFileRowsLimit();
3319
+ renderContent();
3320
+ };
3321
+ }
3322
+ }
3320
3323
 
3321
3324
  // ─── Panels ───────────────────────────────────────────────────────────────────
3322
3325
  function openFeaturePanel(key) {
@@ -3527,29 +3530,29 @@ function openUnmappedPanel(files, infraFiles) {
3527
3530
  document.getElementById('panel').classList.add('open');
3528
3531
  }
3529
3532
 
3530
- function closePanel() {
3531
- activePanelKey = null;
3532
- document.getElementById('panel').classList.remove('open');
3533
- renderContent();
3534
- }
3535
-
3536
- function applyTerminalFiltersFromUi() {
3537
- terminalSearchQuery = document.getElementById('agentSearchInput')?.value || '';
3538
- terminalSearchErrorsOnly = !!document.getElementById('agentSearchErrorsOnly')?.checked;
3539
- terminalSearchCurrentRunOnly = !!document.getElementById('agentSearchCurrentRunOnly')?.checked;
3540
- terminalSearchRegex = !!document.getElementById('agentSearchRegex')?.checked;
3541
- renderActiveSession();
3542
- }
3543
-
3544
- ['agentSearchInput', 'agentSearchErrorsOnly', 'agentSearchCurrentRunOnly', 'agentSearchRegex'].forEach((id) => {
3545
- const el = document.getElementById(id);
3546
- if (!el) return;
3547
- const eventName = id === 'agentSearchInput' ? 'input' : 'change';
3548
- el.addEventListener(eventName, applyTerminalFiltersFromUi);
3549
- });
3550
-
3551
- // ─── Events ───────────────────────────────────────────────────────────────────
3552
- document.querySelectorAll('.view-tab').forEach(tab => {
3533
+ function closePanel() {
3534
+ activePanelKey = null;
3535
+ document.getElementById('panel').classList.remove('open');
3536
+ renderContent();
3537
+ }
3538
+
3539
+ function applyTerminalFiltersFromUi() {
3540
+ terminalSearchQuery = document.getElementById('agentSearchInput')?.value || '';
3541
+ terminalSearchErrorsOnly = !!document.getElementById('agentSearchErrorsOnly')?.checked;
3542
+ terminalSearchCurrentRunOnly = !!document.getElementById('agentSearchCurrentRunOnly')?.checked;
3543
+ terminalSearchRegex = !!document.getElementById('agentSearchRegex')?.checked;
3544
+ renderActiveSession();
3545
+ }
3546
+
3547
+ ['agentSearchInput', 'agentSearchErrorsOnly', 'agentSearchCurrentRunOnly', 'agentSearchRegex'].forEach((id) => {
3548
+ const el = document.getElementById(id);
3549
+ if (!el) return;
3550
+ const eventName = id === 'agentSearchInput' ? 'input' : 'change';
3551
+ el.addEventListener(eventName, applyTerminalFiltersFromUi);
3552
+ });
3553
+
3554
+ // ─── Events ───────────────────────────────────────────────────────────────────
3555
+ document.querySelectorAll('.view-tab').forEach(tab => {
3553
3556
  tab.onclick = () => {
3554
3557
  if (contextMode !== 'qa') return;
3555
3558
  if (tab.classList.contains('disabled')) return;
@@ -3599,204 +3602,204 @@ window.addEventListener('popstate', () => {
3599
3602
  });
3600
3603
 
3601
3604
  // ─── Live reload ──────────────────────────────────────────────────────────────
3602
- function setLiveDot(color, title) {
3603
- const dot = document.getElementById('liveDot');
3604
- dot.style.background = color;
3605
- dot.title = title;
3606
- }
3607
-
3608
- function scheduleRefreshData(delayMs = 120) {
3609
- if (refreshDataTimer) return;
3610
- refreshDataTimer = setTimeout(() => {
3611
- refreshDataTimer = null;
3612
- void refreshData();
3613
- }, delayMs);
3614
- }
3615
-
3616
- async function refreshData() {
3617
- if (refreshDataInFlight) {
3618
- refreshDataQueued = true;
3619
- return;
3620
- }
3621
- refreshDataInFlight = true;
3622
- try {
3623
- const res = await fetch('/api/data');
3624
- D = await res.json();
3605
+ function setLiveDot(color, title) {
3606
+ const dot = document.getElementById('liveDot');
3607
+ dot.style.background = color;
3608
+ dot.title = title;
3609
+ }
3610
+
3611
+ function scheduleRefreshData(delayMs = 120) {
3612
+ if (refreshDataTimer) return;
3613
+ refreshDataTimer = setTimeout(() => {
3614
+ refreshDataTimer = null;
3615
+ void refreshData();
3616
+ }, delayMs);
3617
+ }
3618
+
3619
+ async function refreshData() {
3620
+ if (refreshDataInFlight) {
3621
+ refreshDataQueued = true;
3622
+ return;
3623
+ }
3624
+ refreshDataInFlight = true;
3625
+ try {
3626
+ const res = await fetch('/api/data');
3627
+ D = await res.json();
3625
3628
 
3626
3629
  // Update header timestamp
3627
3630
  document.getElementById('scannedAt').textContent =
3628
3631
  new Date(D.scannedAt).toLocaleTimeString();
3629
-
3630
- renderStats();
3631
- renderSidebar();
3632
- updateAgentBtn();
3633
- updateAgentRightsInfo();
3634
-
3635
- // Re-render drill-down or re-open panel
3636
- const panelOpen = document.getElementById('panel').classList.contains('open');
3632
+
3633
+ renderStats();
3634
+ renderSidebar();
3635
+ updateAgentBtn();
3636
+ updateAgentRightsInfo();
3637
+
3638
+ // Re-render drill-down or re-open panel
3639
+ const panelOpen = document.getElementById('panel').classList.contains('open');
3637
3640
  if (panelOpen && activePanelKey) {
3638
3641
  if (drillFeatureKey === '__unmapped__') {
3639
3642
  renderContent(); // already routes to renderUnmappedDetail
3640
3643
  } else if (view === 'features' && D.features) {
3641
3644
  openFeaturePanel(activePanelKey);
3642
- } else {
3643
- const m = D.modules.find(m => m.id === activePanelKey);
3644
- if (m) openModulePanel(m);
3645
- else closePanel();
3646
- }
3647
- } else {
3648
- renderContent();
3649
- }
3650
-
3651
- // Brief green flash on the dot to signal fresh data
3652
- setLiveDot('#3fb950', 'Live — обновлено только что');
3653
- setTimeout(() => setLiveDot('#3fb950', 'Live — автообновление включено'), 1500);
3654
- void loadAgentState();
3655
- } catch (err) {
3656
- console.error('Refresh failed:', err);
3657
- } finally {
3658
- refreshDataInFlight = false;
3659
- if (refreshDataQueued) {
3660
- refreshDataQueued = false;
3661
- scheduleRefreshData(120);
3662
- }
3663
- }
3664
- }
3665
-
3666
- function connectSSE() {
3667
- const es = new EventSource('/api/events');
3668
-
3669
- es.onopen = () => {
3670
- setLiveDot('#3fb950', 'Live — автообновление включено');
3671
- void loadAgentState();
3672
- };
3673
-
3674
- es.addEventListener('data-updated', () => {
3675
- setLiveDot('#e3b341', 'Обновляю данные…');
3676
- scheduleRefreshData();
3677
- });
3678
-
3679
- es.addEventListener('agent-queue-updated', (e) => {
3680
- const payload = JSON.parse(e.data || '{}');
3681
- agentQueueState = Array.isArray(payload.queue) ? payload.queue : [];
3682
- refreshPathActivityFromState();
3683
- updateQueueBadge(agentQueueState.length);
3684
- renderContent();
3685
- });
3686
-
3687
- es.addEventListener('agent-run-created', (e) => {
3688
- const { run } = JSON.parse(e.data || '{}');
3689
- upsertRunState(run);
3690
- });
3691
-
3692
- es.addEventListener('agent-run-updated', (e) => {
3693
- const { run } = JSON.parse(e.data || '{}');
3694
- if (!run) return;
3695
- if (run.runId && agentQueueState.length > 0) {
3696
- agentQueueState = agentQueueState.filter((q) => q.runId !== run.runId || run.phase === 'queued');
3697
- updateQueueBadge(agentQueueState.length);
3698
- }
3699
- if (['starting', 'running', 'validating'].includes(run.phase)) {
3700
- agentActiveRun = run;
3701
- const startedPaths = getTaskFilePaths(run.task, run.featureKey, run.filePath, run.selectedFilePaths);
3702
- startedPaths.forEach((p) => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3703
- renderContent();
3704
- }
3705
- updateSessionFromRun(run);
3706
- });
3707
-
3708
- es.addEventListener('agent-run-finished', (e) => {
3709
- const { run } = JSON.parse(e.data || '{}');
3710
- if (!run) return;
3711
- updateSessionFromRun(run);
3712
- if (Array.isArray(run.fileOutcomes) && run.fileOutcomes.length > 0) {
3713
- lastRunSummary = run;
3714
- }
3715
- syncSummaryForActiveSession();
3716
- refreshPathActivityFromState();
3717
- renderContent();
3718
- });
3719
-
3720
- es.addEventListener('agent-queued', (e) => {
3721
- const { runId, queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3722
- updateQueueBadge(queueLength);
3723
- document.getElementById('agentPanel').classList.add('open');
3724
- document.getElementById('termBtn').classList.add('term-active');
3725
- // Track queued paths for spinner
3726
- getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
3727
- renderContent();
3728
- // Append queue notification to the currently running session (or active)
3729
- const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3730
- if (targetId) {
3731
- appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
3732
- }
3733
- void loadAgentState();
3734
- });
3735
-
3736
- es.addEventListener('agent-started', (e) => {
3737
- setAgentRunning(true);
3738
- const { runId, title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3739
- updateQueueBadge(queueLength);
3740
- currentRunId = runId || null;
3741
- // Move paths from queued → running (current task)
3742
- const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
3743
- startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3744
- renderContent();
3745
- // Close previous session (queue case: agent-done not fired between tasks)
3746
- if (runningSessionId) {
3747
- const prev = consoleSessions.find(s => s.id === runningSessionId);
3748
- if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
3749
- }
3750
- const id = runId ? ensureSessionForRun({ runId, title, phase: 'running' }, true) : createSession(title);
3751
- runningSessionId = id;
3752
- document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
3753
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
3754
- });
3755
-
3756
- es.addEventListener('agent-output', (e) => {
3757
- const { runId, line, isError, isDim } = JSON.parse(e.data);
3758
- let targetId = runningSessionId;
3759
- if (runId) {
3760
- targetId = runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'running' });
3761
- if (targetId) runningSessionId = targetId;
3762
- }
3763
- if (targetId) {
3764
- appendToSession(targetId, line, !!isError, !!isDim);
3765
- if (activeSessionId === targetId) {
3766
- document.getElementById('agentPanelStatus').textContent = 'работает…';
3767
- }
3768
- } else {
3769
- appendTerminalLine(line, !!isError, !!isDim);
3770
- }
3645
+ } else {
3646
+ const m = D.modules.find(m => m.id === activePanelKey);
3647
+ if (m) openModulePanel(m);
3648
+ else closePanel();
3649
+ }
3650
+ } else {
3651
+ renderContent();
3652
+ }
3653
+
3654
+ // Brief green flash on the dot to signal fresh data
3655
+ setLiveDot('#3fb950', 'Live — обновлено только что');
3656
+ setTimeout(() => setLiveDot('#3fb950', 'Live — автообновление включено'), 1500);
3657
+ void loadAgentState();
3658
+ } catch (err) {
3659
+ console.error('Refresh failed:', err);
3660
+ } finally {
3661
+ refreshDataInFlight = false;
3662
+ if (refreshDataQueued) {
3663
+ refreshDataQueued = false;
3664
+ scheduleRefreshData(120);
3665
+ }
3666
+ }
3667
+ }
3668
+
3669
+ function connectSSE() {
3670
+ const es = new EventSource('/api/events');
3671
+
3672
+ es.onopen = () => {
3673
+ setLiveDot('#3fb950', 'Live — автообновление включено');
3674
+ void loadAgentState();
3675
+ };
3676
+
3677
+ es.addEventListener('data-updated', () => {
3678
+ setLiveDot('#e3b341', 'Обновляю данные…');
3679
+ scheduleRefreshData();
3680
+ });
3681
+
3682
+ es.addEventListener('agent-queue-updated', (e) => {
3683
+ const payload = JSON.parse(e.data || '{}');
3684
+ agentQueueState = Array.isArray(payload.queue) ? payload.queue : [];
3685
+ refreshPathActivityFromState();
3686
+ updateQueueBadge(agentQueueState.length);
3687
+ renderContent();
3688
+ });
3689
+
3690
+ es.addEventListener('agent-run-created', (e) => {
3691
+ const { run } = JSON.parse(e.data || '{}');
3692
+ upsertRunState(run);
3693
+ });
3694
+
3695
+ es.addEventListener('agent-run-updated', (e) => {
3696
+ const { run } = JSON.parse(e.data || '{}');
3697
+ if (!run) return;
3698
+ if (run.runId && agentQueueState.length > 0) {
3699
+ agentQueueState = agentQueueState.filter((q) => q.runId !== run.runId || run.phase === 'queued');
3700
+ updateQueueBadge(agentQueueState.length);
3701
+ }
3702
+ if (['starting', 'running', 'validating'].includes(run.phase)) {
3703
+ agentActiveRun = run;
3704
+ const startedPaths = getTaskFilePaths(run.task, run.featureKey, run.filePath, run.selectedFilePaths);
3705
+ startedPaths.forEach((p) => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3706
+ renderContent();
3707
+ }
3708
+ updateSessionFromRun(run);
3709
+ });
3710
+
3711
+ es.addEventListener('agent-run-finished', (e) => {
3712
+ const { run } = JSON.parse(e.data || '{}');
3713
+ if (!run) return;
3714
+ updateSessionFromRun(run);
3715
+ if (Array.isArray(run.fileOutcomes) && run.fileOutcomes.length > 0) {
3716
+ lastRunSummary = run;
3717
+ }
3718
+ syncSummaryForActiveSession();
3719
+ refreshPathActivityFromState();
3720
+ renderContent();
3721
+ });
3722
+
3723
+ es.addEventListener('agent-queued', (e) => {
3724
+ const { runId, queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3725
+ updateQueueBadge(queueLength);
3726
+ document.getElementById('agentPanel').classList.add('open');
3727
+ document.getElementById('termBtn').classList.add('term-active');
3728
+ // Track queued paths for spinner
3729
+ getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
3730
+ renderContent();
3731
+ // Append queue notification to the currently running session (or active)
3732
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3733
+ if (targetId) {
3734
+ appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
3735
+ }
3736
+ void loadAgentState();
3737
+ });
3738
+
3739
+ es.addEventListener('agent-started', (e) => {
3740
+ setAgentRunning(true);
3741
+ const { runId, title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3742
+ updateQueueBadge(queueLength);
3743
+ currentRunId = runId || null;
3744
+ // Move paths from queued → running (current task)
3745
+ const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
3746
+ startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3747
+ renderContent();
3748
+ // Close previous session (queue case: agent-done not fired between tasks)
3749
+ if (runningSessionId) {
3750
+ const prev = consoleSessions.find(s => s.id === runningSessionId);
3751
+ if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
3752
+ }
3753
+ const id = runId ? ensureSessionForRun({ runId, title, phase: 'running' }, true) : createSession(title);
3754
+ runningSessionId = id;
3755
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
3756
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
3771
3757
  });
3772
3758
 
3773
- es.addEventListener('agent-done', () => {
3774
- setAgentRunning(false);
3775
- if (agentQueueState.length === 0) updateQueueBadge(0);
3776
- agentRunningPaths.clear();
3777
- if (runningSessionId) {
3778
- updateSessionStatus(runningSessionId, 'ok');
3779
- if (activeSessionId === runningSessionId) {
3780
- document.getElementById('agentPanelStatus').textContent = '✅ готово';
3781
- }
3759
+ es.addEventListener('agent-output', (e) => {
3760
+ const { runId, line, isError, isDim } = JSON.parse(e.data);
3761
+ let targetId = runningSessionId;
3762
+ if (runId) {
3763
+ targetId = runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'running' });
3764
+ if (targetId) runningSessionId = targetId;
3765
+ }
3766
+ if (targetId) {
3767
+ appendToSession(targetId, line, !!isError, !!isDim);
3768
+ if (activeSessionId === targetId) {
3769
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
3770
+ }
3771
+ } else {
3772
+ appendTerminalLine(line, !!isError, !!isDim);
3773
+ }
3774
+ });
3775
+
3776
+ es.addEventListener('agent-done', () => {
3777
+ setAgentRunning(false);
3778
+ if (agentQueueState.length === 0) updateQueueBadge(0);
3779
+ agentRunningPaths.clear();
3780
+ if (runningSessionId) {
3781
+ updateSessionStatus(runningSessionId, 'ok');
3782
+ if (activeSessionId === runningSessionId) {
3783
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
3784
+ }
3782
3785
  runningSessionId = null;
3783
3786
  }
3784
3787
  renderContent();
3785
3788
  });
3786
3789
 
3787
- es.addEventListener('agent-summary', (e) => {
3788
- const {
3789
- runId,
3790
- fileOutcomes = [],
3791
- validationStats = null,
3792
- passed = 0, failed = 0, files = [],
3793
- testedFileCount = files.length,
3794
- passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
3795
- failedFileCount = failed > 0 ? 1 : 0,
3796
- autoFixQueued = false,
3797
- } = JSON.parse(e.data);
3798
- const coverageOk = !validationStats || ((validationStats.notCovered || 0) === 0 && (validationStats.blocked || 0) === 0);
3799
- const allOk = failed === 0 && coverageOk;
3790
+ es.addEventListener('agent-summary', (e) => {
3791
+ const {
3792
+ runId,
3793
+ fileOutcomes = [],
3794
+ validationStats = null,
3795
+ passed = 0, failed = 0, files = [],
3796
+ testedFileCount = files.length,
3797
+ passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
3798
+ failedFileCount = failed > 0 ? 1 : 0,
3799
+ autoFixQueued = false,
3800
+ } = JSON.parse(e.data);
3801
+ const coverageOk = !validationStats || ((validationStats.notCovered || 0) === 0 && (validationStats.blocked || 0) === 0);
3802
+ const allOk = failed === 0 && coverageOk;
3800
3803
  const box = document.createElement('div');
3801
3804
  box.style.cssText = `
3802
3805
  margin: 10px 0 4px;
@@ -3813,37 +3816,37 @@ function connectSSE() {
3813
3816
  <div style="font-size:11px;color:var(--muted);margin-top:4px">
3814
3817
  Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}
3815
3818
  </div>
3816
- <div style="font-size:11px;color:var(--muted);margin-top:2px">
3817
- Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
3818
- </div>
3819
- ${validationStats ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">Coverage matrix: covered ${validationStats.covered || 0} • not-covered ${validationStats.notCovered || 0} • blocked ${validationStats.blocked || 0}</div>` : ''}
3820
- ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
3821
- ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
3822
- `;
3823
- if (fileOutcomes.length > 0) {
3824
- lastRunSummary = { runId, fileOutcomes, validationStats };
3825
- }
3826
- syncSummaryForActiveSession();
3827
- const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3828
- if (targetId) {
3829
- appendToSession(targetId, box);
3830
- // Mark session status from test results immediately
3831
- if (runningSessionId === targetId) {
3832
- updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
3819
+ <div style="font-size:11px;color:var(--muted);margin-top:2px">
3820
+ Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
3821
+ </div>
3822
+ ${validationStats ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">Coverage matrix: covered ${validationStats.covered || 0} • not-covered ${validationStats.notCovered || 0} • blocked ${validationStats.blocked || 0}</div>` : ''}
3823
+ ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
3824
+ ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
3825
+ `;
3826
+ if (fileOutcomes.length > 0) {
3827
+ lastRunSummary = { runId, fileOutcomes, validationStats };
3828
+ }
3829
+ syncSummaryForActiveSession();
3830
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3831
+ if (targetId) {
3832
+ appendToSession(targetId, box);
3833
+ // Mark session status from test results immediately
3834
+ if (runningSessionId === targetId) {
3835
+ updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
3833
3836
  }
3834
3837
  }
3835
3838
  });
3836
3839
 
3837
- es.addEventListener('agent-error', (e) => {
3838
- const { runId, message, authRequired, notInstalled } = JSON.parse(e.data);
3839
- if (!runId) setAgentRunning(false);
3840
- agentRunningPaths.clear();
3841
- agentQueuedPaths.clear();
3842
- renderContent();
3843
-
3844
- // Build error node (plain text or auth/install box)
3845
- let node = null;
3846
- if (authRequired || notInstalled) {
3840
+ es.addEventListener('agent-error', (e) => {
3841
+ const { runId, message, authRequired, notInstalled } = JSON.parse(e.data);
3842
+ if (!runId) setAgentRunning(false);
3843
+ agentRunningPaths.clear();
3844
+ agentQueuedPaths.clear();
3845
+ renderContent();
3846
+
3847
+ // Build error node (plain text or auth/install box)
3848
+ let node = null;
3849
+ if (authRequired || notInstalled) {
3847
3850
  node = document.createElement('div');
3848
3851
  node.style.cssText = 'margin:10px 0 4px;padding:10px 14px;border-radius:8px;border:1px solid var(--red);background:#2a0d0d;font-family:inherit;';
3849
3852
  node.innerHTML = `
@@ -3851,14 +3854,14 @@ function connectSSE() {
3851
3854
  ${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>` : ''}
3852
3855
  ${notInstalled ? `<div style="margin-top:6px;font-size:11px;color:var(--dim)">После установки перезапусти viberadar</div>` : ''}
3853
3856
  `;
3854
- }
3855
-
3856
- // If no session exists yet (startup check fires before any run), create one
3857
- const targetId = (runId ? (runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'failed' })) : runningSessionId) || (() => {
3858
- if (authRequired || notInstalled) {
3859
- const id = createSession('⚠️ Проверка агента', 'error');
3860
- return id;
3861
- }
3857
+ }
3858
+
3859
+ // If no session exists yet (startup check fires before any run), create one
3860
+ const targetId = (runId ? (runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'failed' })) : runningSessionId) || (() => {
3861
+ if (authRequired || notInstalled) {
3862
+ const id = createSession('⚠️ Проверка агента', 'error');
3863
+ return id;
3864
+ }
3862
3865
  return activeSessionId;
3863
3866
  })();
3864
3867
 
@@ -3869,12 +3872,12 @@ function connectSSE() {
3869
3872
  appendToSession(targetId, '❌ ' + (message || 'Ошибка агента'), true);
3870
3873
  }
3871
3874
  updateSessionStatus(targetId, 'error');
3872
- if (activeSessionId === targetId) {
3873
- document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3874
- }
3875
- }
3876
- if (!runId && runningSessionId) runningSessionId = null;
3877
- });
3875
+ if (activeSessionId === targetId) {
3876
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3877
+ }
3878
+ }
3879
+ if (!runId && runningSessionId) runningSessionId = null;
3880
+ });
3878
3881
 
3879
3882
  es.addEventListener('tests-started', (e) => {
3880
3883
  const { testType, count } = JSON.parse(e.data);
@@ -3942,24 +3945,24 @@ function connectSSE() {
3942
3945
  }
3943
3946
  });
3944
3947
 
3945
- es.onerror = () => {
3946
- setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
3947
- es.close();
3948
- setTimeout(connectSSE, 3000);
3949
- };
3950
- }
3951
-
3952
- init().then(async () => {
3953
- restoreSessions();
3954
- await loadAgentState();
3955
- connectSSE();
3956
- applyTerminalFiltersFromUi();
3957
- updateToolbarButtonsState();
3958
- // Sync content padding with terminal panel open state automatically
3959
- new MutationObserver(() => {
3960
- const isOpen = document.getElementById('agentPanel').classList.contains('open');
3961
- document.getElementById('content').classList.toggle('panel-open', isOpen);
3962
- }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
3948
+ es.onerror = () => {
3949
+ setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
3950
+ es.close();
3951
+ setTimeout(connectSSE, 3000);
3952
+ };
3953
+ }
3954
+
3955
+ init().then(async () => {
3956
+ restoreSessions();
3957
+ await loadAgentState();
3958
+ connectSSE();
3959
+ applyTerminalFiltersFromUi();
3960
+ updateToolbarButtonsState();
3961
+ // Sync content padding with terminal panel open state automatically
3962
+ new MutationObserver(() => {
3963
+ const isOpen = document.getElementById('agentPanel').classList.contains('open');
3964
+ document.getElementById('content').classList.toggle('panel-open', isOpen);
3965
+ }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
3963
3966
  });
3964
3967
  </script>
3965
3968
  </body>