viberadar 0.3.64 → 0.3.66

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.
@@ -191,7 +191,7 @@
191
191
  .obs-priority-low { color: var(--green); }
192
192
  .obs-catalog { margin-top:10px; background: var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px; }
193
193
  .obs-catalog h4 { font-size:12px; margin-bottom:6px; }
194
- .obs-cat-row { display:grid; grid-template-columns: 1.8fr .6fr .8fr .7fr .8fr 1fr; gap:8px; font-size:11px; color:var(--muted); padding:5px 0; border-bottom:1px dashed var(--border); }
194
+ .obs-cat-row { display:grid; grid-template-columns: 1.4fr .5fr .6fr .4fr 1.2fr .7fr auto; gap:6px; font-size:11px; color:var(--muted); padding:5px 0; border-bottom:1px dashed var(--border); }
195
195
  .obs-cat-row.head { color: var(--text); font-weight:600; text-transform:uppercase; font-size:10px; }
196
196
 
197
197
  /* ── Layout ──────────────────────────────────────────────────────────────── */
@@ -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; }
@@ -659,6 +659,16 @@
659
659
  color: #fff !important; font-weight: 600;
660
660
  }
661
661
  .file-row-fix-btn:hover { opacity: 0.85; }
662
+ .obs-action-btn {
663
+ display: inline-flex; align-items: center; gap: 3px;
664
+ padding: 2px 7px; font-size: 10px;
665
+ background: transparent; border: 1px solid var(--border); border-radius: 4px;
666
+ color: var(--dim); cursor: pointer; white-space: nowrap; flex-shrink: 0;
667
+ transition: background 0.1s, color 0.1s, border-color 0.1s;
668
+ }
669
+ .obs-action-btn:hover { background: var(--accent); color: #000; border-color: var(--accent); }
670
+ .obs-batch-btn { border-color: var(--yellow); color: var(--yellow); }
671
+ .obs-batch-btn:hover { background: rgba(255,200,0,0.15); color: #000; border-color: var(--yellow); }
662
672
  .file-row-err-badge {
663
673
  display: inline-flex; align-items: center;
664
674
  font-size: 11px; padding: 1px 6px; border-radius: 10px;
@@ -675,12 +685,12 @@
675
685
  .file-agent-spinner.running { border: 2px solid var(--yellow); border-top-color: transparent; animation: spin 0.7s linear infinite; }
676
686
  .file-agent-spinner.queued { border: 2px solid var(--dim); border-top-color: transparent; animation: spin 1.5s linear infinite; }
677
687
  @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
- }
688
+ .file-row-errors {
689
+ padding: 4px 10px 6px 32px;
690
+ display: flex; flex-direction: column; gap: 3px;
691
+ content-visibility: auto;
692
+ contain-intrinsic-size: 46px;
693
+ }
684
694
  .err-item { display: flex; flex-direction: column; gap: 1px; }
685
695
  .err-name { font-size: 11px; color: var(--muted); }
686
696
  .err-msg { font-size: 10px; color: var(--red); font-family: monospace; opacity: 0.85; }
@@ -769,92 +779,92 @@
769
779
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
770
780
  }
771
781
  .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); }
782
+ .agent-queue-badge {
783
+ font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
784
+ border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
785
+ }
786
+ .agent-toolbar {
787
+ display: flex;
788
+ align-items: center;
789
+ gap: 8px;
790
+ padding: 6px 12px;
791
+ background: #0b1018;
792
+ border-bottom: 1px solid var(--border);
793
+ flex-wrap: wrap;
794
+ }
795
+ .agent-toolbar input[type="text"] {
796
+ min-width: 220px;
797
+ padding: 4px 8px;
798
+ border-radius: 4px;
799
+ border: 1px solid var(--border);
800
+ background: #0b1220;
801
+ color: var(--text);
802
+ font-size: 11px;
803
+ }
804
+ .agent-toolbar label {
805
+ display: inline-flex;
806
+ align-items: center;
807
+ gap: 5px;
808
+ font-size: 11px;
809
+ color: var(--muted);
810
+ user-select: none;
811
+ }
812
+ .agent-toolbar-btn {
813
+ background: none;
814
+ border: 1px solid var(--border);
815
+ color: var(--muted);
816
+ cursor: pointer;
817
+ font-size: 11px;
818
+ padding: 3px 8px;
819
+ border-radius: 4px;
820
+ }
821
+ .agent-toolbar-btn:hover { color: var(--text); border-color: var(--blue); }
822
+ .agent-toolbar-btn:disabled {
823
+ opacity: 0.45;
824
+ cursor: not-allowed;
825
+ border-color: var(--border);
826
+ color: var(--dim);
827
+ }
828
+ .agent-toolbar-meta { font-size: 11px; color: var(--dim); margin-left: auto; }
829
+ .agent-queue-panel {
830
+ display: none;
831
+ border-bottom: 1px solid var(--border);
832
+ background: #090f18;
833
+ padding: 8px 12px;
834
+ max-height: 110px;
835
+ overflow-y: auto;
836
+ }
837
+ .agent-queue-title {
838
+ font-size: 11px;
839
+ color: var(--muted);
840
+ margin-bottom: 6px;
841
+ font-weight: 600;
842
+ }
843
+ .agent-queue-item {
844
+ display: flex;
845
+ align-items: center;
846
+ gap: 8px;
847
+ font-size: 11px;
848
+ color: var(--text);
849
+ margin-bottom: 4px;
850
+ }
851
+ .agent-queue-item:last-child { margin-bottom: 0; }
852
+ .agent-queue-pos {
853
+ color: var(--dim);
854
+ min-width: 24px;
855
+ }
856
+ .agent-queue-actions { margin-left: auto; display: inline-flex; gap: 4px; }
857
+ .agent-queue-action {
858
+ border: 1px solid var(--border);
859
+ background: transparent;
860
+ color: var(--muted);
861
+ border-radius: 4px;
862
+ padding: 1px 6px;
863
+ cursor: pointer;
864
+ font-size: 10px;
865
+ line-height: 1.4;
866
+ }
867
+ .agent-queue-action:hover { border-color: var(--blue); color: var(--text); }
858
868
  /* ── Console Tabs ───────────────────────────────────────────────────────── */
859
869
  .agent-tabs-bar {
860
870
  display: flex; align-items: stretch; overflow-x: auto;
@@ -934,50 +944,50 @@
934
944
  border-color: var(--accent);
935
945
  color: var(--accent);
936
946
  }
937
- .bulk-actions-btn:hover:not(:disabled) { background: var(--bg-hover); }
938
- .bulk-actions-btn:disabled { opacity: 0.4; cursor: not-allowed; }
939
- .feature-file-filters {
940
- display: flex;
941
- align-items: center;
942
- gap: 8px;
943
- margin: 0 0 10px;
944
- }
945
- .feature-filter-toggle {
946
- display: inline-flex;
947
- align-items: center;
948
- gap: 8px;
949
- padding: 6px 10px;
950
- border-radius: 6px;
951
- border: 1px solid var(--border);
952
- background: var(--bg-card);
953
- color: var(--muted);
954
- font-size: 12px;
955
- cursor: pointer;
956
- user-select: none;
957
- }
958
- .feature-filter-toggle:hover { background: var(--bg-hover); color: var(--text); }
959
- .feature-filter-toggle input {
960
- width: 14px;
961
- height: 14px;
962
- accent-color: var(--blue);
963
- cursor: pointer;
964
- }
965
- .feature-filter-meta { color: var(--dim); font-size: 11px; }
966
- .agent-terminal {
967
- flex: 1;
968
- overflow-y: auto;
969
- padding: 10px 16px;
970
- font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
947
+ .bulk-actions-btn:hover:not(:disabled) { background: var(--bg-hover); }
948
+ .bulk-actions-btn:disabled { opacity: 0.4; cursor: not-allowed; }
949
+ .feature-file-filters {
950
+ display: flex;
951
+ align-items: center;
952
+ gap: 8px;
953
+ margin: 0 0 10px;
954
+ }
955
+ .feature-filter-toggle {
956
+ display: inline-flex;
957
+ align-items: center;
958
+ gap: 8px;
959
+ padding: 6px 10px;
960
+ border-radius: 6px;
961
+ border: 1px solid var(--border);
962
+ background: var(--bg-card);
963
+ color: var(--muted);
964
+ font-size: 12px;
965
+ cursor: pointer;
966
+ user-select: none;
967
+ }
968
+ .feature-filter-toggle:hover { background: var(--bg-hover); color: var(--text); }
969
+ .feature-filter-toggle input {
970
+ width: 14px;
971
+ height: 14px;
972
+ accent-color: var(--blue);
973
+ cursor: pointer;
974
+ }
975
+ .feature-filter-meta { color: var(--dim); font-size: 11px; }
976
+ .agent-terminal {
977
+ flex: 1;
978
+ overflow-y: auto;
979
+ padding: 10px 16px;
980
+ font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
971
981
  font-size: 12px;
972
982
  line-height: 1.5;
973
983
  }
974
- .agent-line { color: #c9d1d9; }
975
- .agent-line.err { color: var(--red); }
976
- .agent-line.dim { color: var(--dim); font-size: 10px; }
977
- .agent-line.match { background: rgba(31, 111, 235, 0.22); }
978
- .agent-line.command { color: #79c0ff; }
979
-
980
- /* ── Misc ────────────────────────────────────────────────────────────────── */
984
+ .agent-line { color: #c9d1d9; }
985
+ .agent-line.err { color: var(--red); }
986
+ .agent-line.dim { color: var(--dim); font-size: 10px; }
987
+ .agent-line.match { background: rgba(31, 111, 235, 0.22); }
988
+ .agent-line.command { color: #79c0ff; }
989
+
990
+ /* ── Misc ────────────────────────────────────────────────────────────────── */
981
991
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
982
992
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
983
993
  /* ─── E2E Plan UI ─────────────────────────────────────────────────── */
@@ -1089,34 +1099,34 @@
1089
1099
  <div id="panelContent"></div>
1090
1100
  </div>
1091
1101
 
1092
- <div class="agent-panel" id="agentPanel">
1093
- <div class="agent-panel-header">
1094
- <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
1095
- <span class="agent-panel-status" id="agentPanelStatus">running…</span>
1102
+ <div class="agent-panel" id="agentPanel">
1103
+ <div class="agent-panel-header">
1104
+ <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
1105
+ <span class="agent-panel-status" id="agentPanelStatus">running…</span>
1096
1106
  <span class="agent-queue-badge" id="agentQueueBadge" style="display:none">📋 <span id="agentQueueCount">0</span> в очереди</span>
1097
1107
  <button class="agent-panel-cancel" id="agentQueueClearBtn" onclick="clearAgentQueue()" title="Очистить очередь" style="display:none">🗑 очередь</button>
1098
1108
  <button class="agent-panel-cancel" id="agentCancelBtn" onclick="cancelAgent()" title="Сбросить состояние агента" style="display:none">⏹ сброс</button>
1099
- <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
1100
- <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
1101
- </div>
1102
- <div class="agent-toolbar">
1103
- <input id="agentSearchInput" type="text" placeholder="Поиск по терминалу..." />
1104
- <label><input id="agentSearchErrorsOnly" type="checkbox" /> only errors</label>
1105
- <label><input id="agentSearchCurrentRunOnly" type="checkbox" /> current run</label>
1106
- <label><input id="agentSearchRegex" type="checkbox" /> regex</label>
1107
- <button class="agent-toolbar-btn" id="btnMatchPrev" onclick="jumpTerminalMatch(-1)">↑ match</button>
1108
- <button class="agent-toolbar-btn" id="btnMatchNext" onclick="jumpTerminalMatch(1)">↓ match</button>
1109
- <button class="agent-toolbar-btn" id="btnCmdPrev" onclick="jumpCommand(-1)">↑ command</button>
1110
- <button class="agent-toolbar-btn" id="btnCmdNext" onclick="jumpCommand(1)">↓ command</button>
1111
- <button class="agent-toolbar-btn" id="btnErrPrev" onclick="jumpError(-1)">↑ error</button>
1112
- <button class="agent-toolbar-btn" id="btnErrNext" onclick="jumpError(1)">↓ error</button>
1113
- <button class="agent-toolbar-btn" id="btnExportRun" onclick="exportActiveRun()">Export run</button>
1114
- <span class="agent-toolbar-meta" id="agentSearchMeta">0 matches</span>
1115
- </div>
1116
- <div class="agent-queue-panel" id="agentQueuePanel"></div>
1117
- <div class="agent-tabs-bar" id="agentTabsBar"></div>
1118
- <div class="agent-terminal" id="agentTerminal"></div>
1119
- </div>
1109
+ <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
1110
+ <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
1111
+ </div>
1112
+ <div class="agent-toolbar">
1113
+ <input id="agentSearchInput" type="text" placeholder="Поиск по терминалу..." />
1114
+ <label><input id="agentSearchErrorsOnly" type="checkbox" /> only errors</label>
1115
+ <label><input id="agentSearchCurrentRunOnly" type="checkbox" /> current run</label>
1116
+ <label><input id="agentSearchRegex" type="checkbox" /> regex</label>
1117
+ <button class="agent-toolbar-btn" id="btnMatchPrev" onclick="jumpTerminalMatch(-1)">↑ match</button>
1118
+ <button class="agent-toolbar-btn" id="btnMatchNext" onclick="jumpTerminalMatch(1)">↓ match</button>
1119
+ <button class="agent-toolbar-btn" id="btnCmdPrev" onclick="jumpCommand(-1)">↑ command</button>
1120
+ <button class="agent-toolbar-btn" id="btnCmdNext" onclick="jumpCommand(1)">↓ command</button>
1121
+ <button class="agent-toolbar-btn" id="btnErrPrev" onclick="jumpError(-1)">↑ error</button>
1122
+ <button class="agent-toolbar-btn" id="btnErrNext" onclick="jumpError(1)">↓ error</button>
1123
+ <button class="agent-toolbar-btn" id="btnExportRun" onclick="exportActiveRun()">Export run</button>
1124
+ <span class="agent-toolbar-meta" id="agentSearchMeta">0 matches</span>
1125
+ </div>
1126
+ <div class="agent-queue-panel" id="agentQueuePanel"></div>
1127
+ <div class="agent-tabs-bar" id="agentTabsBar"></div>
1128
+ <div class="agent-terminal" id="agentTerminal"></div>
1129
+ </div>
1120
1130
 
1121
1131
  <script>
1122
1132
  // ─── State ────────────────────────────────────────────────────────────────────
@@ -1125,21 +1135,21 @@ let contextMode = 'qa';
1125
1135
  let view = 'features';
1126
1136
  let searchQuery = '';
1127
1137
  let activeTypes = new Set();
1128
- let activePanelKey = null;
1129
- let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
1130
- let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
1131
- let showOnlyUntestedInFeature = false; // source tab in feature detail
1132
- const selectedSourceFiles = new Set(); // normalized relative paths for batch actions
1133
- const FILE_ROWS_INITIAL_LIMIT = 250;
1134
- const FILE_ROWS_LIMIT_STEP = 250;
1135
- let fileRowsRenderKey = '';
1136
- let fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1137
- let e2ePlan = null; // current E2E plan object
1138
- let e2ePlanLoading = false;
1139
- const modeStore = {
1140
- qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1141
- observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1142
- };
1138
+ let activePanelKey = null;
1139
+ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
1140
+ let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
1141
+ let showOnlyUntestedInFeature = false; // source tab in feature detail
1142
+ const selectedSourceFiles = new Set(); // normalized relative paths for batch actions
1143
+ const FILE_ROWS_INITIAL_LIMIT = 250;
1144
+ const FILE_ROWS_LIMIT_STEP = 250;
1145
+ let fileRowsRenderKey = '';
1146
+ let fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1147
+ let e2ePlan = null; // current E2E plan object
1148
+ let e2ePlanLoading = false;
1149
+ const modeStore = {
1150
+ qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1151
+ observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1152
+ };
1143
1153
 
1144
1154
  function getModeFromPath(pathname = window.location.pathname) {
1145
1155
  if (pathname.startsWith('/radar/observability')) return 'observability';
@@ -1160,31 +1170,31 @@ function saveModeState(mode) {
1160
1170
  modeStore[mode] = {
1161
1171
  view,
1162
1172
  searchQuery,
1163
- activeTypes: new Set(activeTypes),
1164
- drillFeatureKey,
1165
- drillTestType,
1166
- activePanelKey,
1167
- showOnlyUntestedInFeature,
1168
- };
1169
- }
1170
-
1171
- function restoreModeState(mode) {
1172
- const state = modeStore[mode];
1173
+ activeTypes: new Set(activeTypes),
1174
+ drillFeatureKey,
1175
+ drillTestType,
1176
+ activePanelKey,
1177
+ showOnlyUntestedInFeature,
1178
+ };
1179
+ }
1180
+
1181
+ function restoreModeState(mode) {
1182
+ const state = modeStore[mode];
1173
1183
  view = state.view;
1174
1184
  searchQuery = state.searchQuery;
1175
- activeTypes = new Set(state.activeTypes);
1176
- drillFeatureKey = state.drillFeatureKey;
1177
- drillTestType = state.drillTestType;
1178
- activePanelKey = state.activePanelKey;
1179
- showOnlyUntestedInFeature = !!state.showOnlyUntestedInFeature;
1180
- }
1185
+ activeTypes = new Set(state.activeTypes);
1186
+ drillFeatureKey = state.drillFeatureKey;
1187
+ drillTestType = state.drillTestType;
1188
+ activePanelKey = state.activePanelKey;
1189
+ showOnlyUntestedInFeature = !!state.showOnlyUntestedInFeature;
1190
+ }
1181
1191
 
1182
1192
  function switchMode(nextMode) {
1183
1193
  if (nextMode === contextMode) return;
1184
1194
  saveModeState(contextMode);
1185
1195
  contextMode = nextMode;
1186
1196
  restoreModeState(contextMode);
1187
- if (contextMode === 'observability') {
1197
+ if (contextMode === 'observability') {
1188
1198
  view = 'files';
1189
1199
  drillFeatureKey = null;
1190
1200
  drillTestType = null;
@@ -1198,11 +1208,11 @@ function switchMode(nextMode) {
1198
1208
  renderSidebar();
1199
1209
  renderContent();
1200
1210
  }
1201
- // ─── Run All Tests button ──────────────────────────────────────────────────────
1202
- let runAllRunning = false;
1203
- let refreshDataInFlight = false;
1204
- let refreshDataQueued = false;
1205
- let refreshDataTimer = null;
1211
+ // ─── Run All Tests button ──────────────────────────────────────────────────────
1212
+ let runAllRunning = false;
1213
+ let refreshDataInFlight = false;
1214
+ let refreshDataQueued = false;
1215
+ let refreshDataTimer = null;
1206
1216
 
1207
1217
  function escapeHtml(text) {
1208
1218
  return String(text || '')
@@ -1241,42 +1251,42 @@ function setFeatureDrill(featureKey, syncHash = true) {
1241
1251
  renderContent();
1242
1252
  }
1243
1253
 
1244
- function selectedFilesForFeature(featureKey, visibleSourceFiles = null) {
1245
- const visibleSet = visibleSourceFiles ? new Set(visibleSourceFiles.map(p => p.replace(/\\/g, '/'))) : null;
1246
- return Array.from(selectedSourceFiles).filter((relPath) => {
1254
+ function selectedFilesForFeature(featureKey, visibleSourceFiles = null) {
1255
+ const visibleSet = visibleSourceFiles ? new Set(visibleSourceFiles.map(p => p.replace(/\\/g, '/'))) : null;
1256
+ return Array.from(selectedSourceFiles).filter((relPath) => {
1247
1257
  if (visibleSet && !visibleSet.has(relPath)) return false;
1248
1258
  const mod = D?.modules?.find((m) => m.relativePath.replace(/\\/g, '/') === relPath);
1249
- return !!mod && mod.type !== 'test' && mod.featureKeys?.includes(featureKey);
1250
- });
1251
- }
1252
-
1253
- function getFileRowsWindow(items, renderKey) {
1254
- if (fileRowsRenderKey !== renderKey) {
1255
- fileRowsRenderKey = renderKey;
1256
- fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1257
- }
1258
- const visibleRows = items.slice(0, fileRowsRenderLimit);
1259
- const hiddenRows = Math.max(0, items.length - visibleRows.length);
1260
- return { visibleRows, hiddenRows, hasMoreRows: hiddenRows > 0 };
1261
- }
1262
-
1263
- function increaseFileRowsLimit() {
1264
- fileRowsRenderLimit += FILE_ROWS_LIMIT_STEP;
1265
- }
1266
-
1267
- function bindFileRowsClick(container) {
1268
- const fileRows = container.querySelector('#fileRows');
1269
- if (!fileRows) return;
1270
- fileRows.onclick = (event) => {
1271
- const row = event.target.closest('.file-row[data-id]');
1272
- if (!row) return;
1273
- const moduleId = row.dataset.id;
1274
- const mod = D.modules.find((m) => String(m.id) === moduleId);
1275
- if (mod) openModulePanel(mod);
1276
- };
1277
- }
1278
-
1279
- function applyHashRoute() {
1259
+ return !!mod && mod.type !== 'test' && mod.featureKeys?.includes(featureKey);
1260
+ });
1261
+ }
1262
+
1263
+ function getFileRowsWindow(items, renderKey) {
1264
+ if (fileRowsRenderKey !== renderKey) {
1265
+ fileRowsRenderKey = renderKey;
1266
+ fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1267
+ }
1268
+ const visibleRows = items.slice(0, fileRowsRenderLimit);
1269
+ const hiddenRows = Math.max(0, items.length - visibleRows.length);
1270
+ return { visibleRows, hiddenRows, hasMoreRows: hiddenRows > 0 };
1271
+ }
1272
+
1273
+ function increaseFileRowsLimit() {
1274
+ fileRowsRenderLimit += FILE_ROWS_LIMIT_STEP;
1275
+ }
1276
+
1277
+ function bindFileRowsClick(container) {
1278
+ const fileRows = container.querySelector('#fileRows');
1279
+ if (!fileRows) return;
1280
+ fileRows.onclick = (event) => {
1281
+ const row = event.target.closest('.file-row[data-id]');
1282
+ if (!row) return;
1283
+ const moduleId = row.dataset.id;
1284
+ const mod = D.modules.find((m) => String(m.id) === moduleId);
1285
+ if (mod) openModulePanel(mod);
1286
+ };
1287
+ }
1288
+
1289
+ function applyHashRoute() {
1280
1290
  const hash = (window.location.hash || '').replace(/^#/, '');
1281
1291
  if (!hash) {
1282
1292
  if (view === 'features' && drillFeatureKey) {
@@ -1324,145 +1334,145 @@ async function runAllTests() {
1324
1334
  }
1325
1335
 
1326
1336
  // ─── Agent ────────────────────────────────────────────────────────────────────
1327
- let agentRunning = false;
1328
- const agentRunningPaths = new Set(); // paths of files currently being processed by agent
1329
- const agentQueuedPaths = new Set(); // paths of files waiting in queue
1330
- let agentQueueState = [];
1331
- let agentRunsState = [];
1332
- let agentActiveRun = null;
1333
- let currentRunId = null;
1334
-
1335
- // ─── Console Sessions ─────────────────────────────────────────────────────────
1336
- const consoleSessions = []; // { id, title, lines, status, startTime }
1337
- let activeSessionId = null; // currently viewed tab
1338
- let runningSessionId = null; // tab that is currently receiving output
1339
- const runSessionMap = new Map(); // runId -> sessionId
1340
- const sessionRunMap = new Map(); // sessionId -> runId
1341
- const SESSION_MAX = 25;
1342
- const SESSION_LINE_LIMIT = 3000;
1343
- const SESSIONS_KEY = 'viberadar_sessions';
1344
- let terminalSearchQuery = '';
1345
- let terminalSearchErrorsOnly = false;
1346
- let terminalSearchCurrentRunOnly = false;
1347
- let terminalSearchRegex = false;
1348
- let terminalMatchRefs = [];
1349
- let terminalCommandRefs = [];
1350
- let terminalErrorRefs = [];
1351
- let terminalMatchCursor = -1;
1352
- let terminalCommandCursor = -1;
1353
- let terminalErrorCursor = -1;
1337
+ let agentRunning = false;
1338
+ const agentRunningPaths = new Set(); // paths of files currently being processed by agent
1339
+ const agentQueuedPaths = new Set(); // paths of files waiting in queue
1340
+ let agentQueueState = [];
1341
+ let agentRunsState = [];
1342
+ let agentActiveRun = null;
1343
+ let currentRunId = null;
1344
+
1345
+ // ─── Console Sessions ─────────────────────────────────────────────────────────
1346
+ const consoleSessions = []; // { id, title, lines, status, startTime }
1347
+ let activeSessionId = null; // currently viewed tab
1348
+ let runningSessionId = null; // tab that is currently receiving output
1349
+ const runSessionMap = new Map(); // runId -> sessionId
1350
+ const sessionRunMap = new Map(); // sessionId -> runId
1351
+ const SESSION_MAX = 25;
1352
+ const SESSION_LINE_LIMIT = 3000;
1353
+ const SESSIONS_KEY = 'viberadar_sessions';
1354
+ let terminalSearchQuery = '';
1355
+ let terminalSearchErrorsOnly = false;
1356
+ let terminalSearchCurrentRunOnly = false;
1357
+ let terminalSearchRegex = false;
1358
+ let terminalMatchRefs = [];
1359
+ let terminalCommandRefs = [];
1360
+ let terminalErrorRefs = [];
1361
+ let terminalMatchCursor = -1;
1362
+ let terminalCommandCursor = -1;
1363
+ let terminalErrorCursor = -1;
1354
1364
 
1355
1365
  function _sessionId() {
1356
1366
  return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
1357
1367
  }
1358
1368
 
1359
- function saveSessions() {
1360
- try {
1361
- const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
1362
- ...s,
1363
- runId: sessionRunMap.get(s.id) || s.runId || null,
1364
- lines: s.lines.slice(-500)
1365
- }));
1366
- localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
1367
- } catch {}
1368
- }
1369
+ function saveSessions() {
1370
+ try {
1371
+ const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
1372
+ ...s,
1373
+ runId: sessionRunMap.get(s.id) || s.runId || null,
1374
+ lines: s.lines.slice(-500)
1375
+ }));
1376
+ localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
1377
+ } catch {}
1378
+ }
1369
1379
 
1370
1380
  function restoreSessions() {
1371
1381
  try {
1372
- const raw = localStorage.getItem(SESSIONS_KEY);
1373
- if (!raw) return;
1374
- const saved = JSON.parse(raw);
1375
- consoleSessions.push(...saved);
1376
- for (const s of consoleSessions) {
1377
- if (s.runId) {
1378
- runSessionMap.set(s.runId, s.id);
1379
- sessionRunMap.set(s.id, s.runId);
1380
- }
1381
- }
1382
- if (consoleSessions.length > 0) {
1383
- activeSessionId = consoleSessions[consoleSessions.length - 1].id;
1384
- renderTabs();
1385
- renderActiveSession();
1382
+ const raw = localStorage.getItem(SESSIONS_KEY);
1383
+ if (!raw) return;
1384
+ const saved = JSON.parse(raw);
1385
+ consoleSessions.push(...saved);
1386
+ for (const s of consoleSessions) {
1387
+ if (s.runId) {
1388
+ runSessionMap.set(s.runId, s.id);
1389
+ sessionRunMap.set(s.id, s.runId);
1390
+ }
1391
+ }
1392
+ if (consoleSessions.length > 0) {
1393
+ activeSessionId = consoleSessions[consoleSessions.length - 1].id;
1394
+ renderTabs();
1395
+ renderActiveSession();
1386
1396
  }
1387
1397
  } catch {}
1388
1398
  }
1389
1399
 
1390
- function createSession(title, status = 'running', runId = null) {
1391
- if (consoleSessions.length >= SESSION_MAX) {
1392
- const dropped = consoleSessions.shift();
1393
- if (dropped?.id) {
1394
- const droppedRunId = sessionRunMap.get(dropped.id);
1395
- if (droppedRunId) runSessionMap.delete(droppedRunId);
1396
- sessionRunMap.delete(dropped.id);
1397
- }
1398
- }
1399
- const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now(), runId };
1400
- consoleSessions.push(s);
1401
- activeSessionId = s.id;
1402
- if (runId) {
1403
- runSessionMap.set(runId, s.id);
1404
- sessionRunMap.set(s.id, runId);
1405
- }
1406
- document.getElementById('agentPanel').classList.add('open');
1407
- document.getElementById('termBtn').classList.add('term-active');
1408
- renderTabs();
1409
- renderActiveSession();
1410
- saveSessions();
1400
+ function createSession(title, status = 'running', runId = null) {
1401
+ if (consoleSessions.length >= SESSION_MAX) {
1402
+ const dropped = consoleSessions.shift();
1403
+ if (dropped?.id) {
1404
+ const droppedRunId = sessionRunMap.get(dropped.id);
1405
+ if (droppedRunId) runSessionMap.delete(droppedRunId);
1406
+ sessionRunMap.delete(dropped.id);
1407
+ }
1408
+ }
1409
+ const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now(), runId };
1410
+ consoleSessions.push(s);
1411
+ activeSessionId = s.id;
1412
+ if (runId) {
1413
+ runSessionMap.set(runId, s.id);
1414
+ sessionRunMap.set(s.id, runId);
1415
+ }
1416
+ document.getElementById('agentPanel').classList.add('open');
1417
+ document.getElementById('termBtn').classList.add('term-active');
1418
+ renderTabs();
1419
+ renderActiveSession();
1420
+ saveSessions();
1411
1421
  return s.id;
1412
1422
  }
1413
1423
 
1414
- function switchSession(id) {
1415
- activeSessionId = id;
1416
- currentRunId = sessionRunMap.get(id) || null;
1417
- const s = consoleSessions.find(s => s.id === id);
1418
- if (s) {
1419
- const statusText = s.status === 'running' ? 'работает…'
1420
- : s.status === 'ok' ? '✅ готово'
1421
- : s.status === 'error' ? '❌ ошибка'
1424
+ function switchSession(id) {
1425
+ activeSessionId = id;
1426
+ currentRunId = sessionRunMap.get(id) || null;
1427
+ const s = consoleSessions.find(s => s.id === id);
1428
+ if (s) {
1429
+ const statusText = s.status === 'running' ? 'работает…'
1430
+ : s.status === 'ok' ? '✅ готово'
1431
+ : s.status === 'error' ? '❌ ошибка'
1422
1432
  : '';
1423
1433
  document.getElementById('agentPanelTitle').textContent = s.title;
1424
1434
  document.getElementById('agentPanelStatus').textContent = statusText;
1425
- }
1426
- renderTabs();
1427
- renderActiveSession();
1428
- }
1429
-
1430
- function closeSession(id) {
1431
- const idx = consoleSessions.findIndex(s => s.id === id);
1432
- if (idx === -1) return;
1433
- const runId = sessionRunMap.get(id);
1434
- if (runId) {
1435
- sessionRunMap.delete(id);
1436
- runSessionMap.delete(runId);
1437
- }
1438
- consoleSessions.splice(idx, 1);
1439
- if (activeSessionId === id) {
1440
- activeSessionId = consoleSessions.length > 0
1441
- ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
1442
- : null;
1443
- }
1444
- renderTabs();
1445
- renderActiveSession();
1446
- saveSessions();
1447
- }
1448
-
1449
- function appendToSession(id, lineOrNode, isError = false, isDim = false) {
1450
- const s = consoleSessions.find(s => s.id === id);
1451
- if (!s) return;
1435
+ }
1436
+ renderTabs();
1437
+ renderActiveSession();
1438
+ }
1439
+
1440
+ function closeSession(id) {
1441
+ const idx = consoleSessions.findIndex(s => s.id === id);
1442
+ if (idx === -1) return;
1443
+ const runId = sessionRunMap.get(id);
1444
+ if (runId) {
1445
+ sessionRunMap.delete(id);
1446
+ runSessionMap.delete(runId);
1447
+ }
1448
+ consoleSessions.splice(idx, 1);
1449
+ if (activeSessionId === id) {
1450
+ activeSessionId = consoleSessions.length > 0
1451
+ ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
1452
+ : null;
1453
+ }
1454
+ renderTabs();
1455
+ renderActiveSession();
1456
+ saveSessions();
1457
+ }
1458
+
1459
+ function appendToSession(id, lineOrNode, isError = false, isDim = false) {
1460
+ const s = consoleSessions.find(s => s.id === id);
1461
+ if (!s) return;
1452
1462
  let stored;
1453
1463
  if (typeof lineOrNode === 'string') {
1454
1464
  stored = { text: lineOrNode, isError, isDim };
1455
1465
  } else {
1456
1466
  stored = { html: lineOrNode.outerHTML };
1457
- }
1458
- s.lines.push(stored);
1459
- if (s.lines.length > SESSION_LINE_LIMIT) {
1460
- s.lines.splice(0, s.lines.length - SESSION_LINE_LIMIT);
1461
- }
1462
- if (activeSessionId === id) {
1463
- renderActiveSession();
1464
- }
1465
- }
1467
+ }
1468
+ s.lines.push(stored);
1469
+ if (s.lines.length > SESSION_LINE_LIMIT) {
1470
+ s.lines.splice(0, s.lines.length - SESSION_LINE_LIMIT);
1471
+ }
1472
+ if (activeSessionId === id) {
1473
+ renderActiveSession();
1474
+ }
1475
+ }
1466
1476
 
1467
1477
  function updateSessionStatus(id, status) {
1468
1478
  const s = consoleSessions.find(s => s.id === id);
@@ -1490,9 +1500,9 @@ function renderTabs() {
1490
1500
  if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1491
1501
  }
1492
1502
 
1493
- function copyTerminalContent() {
1494
- const s = consoleSessions.find(s => s.id === activeSessionId);
1495
- if (!s || s.lines.length === 0) return;
1503
+ function copyTerminalContent() {
1504
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1505
+ if (!s || s.lines.length === 0) return;
1496
1506
  // Extract plain text from each line (strip HTML for rich nodes)
1497
1507
  const text = s.lines.map(l => {
1498
1508
  if (l.text !== undefined) return l.text;
@@ -1510,214 +1520,214 @@ function copyTerminalContent() {
1510
1520
  btn.textContent = '✓';
1511
1521
  btn.classList.add('copied');
1512
1522
  setTimeout(() => { btn.textContent = prev; btn.classList.remove('copied'); }, 1500);
1513
- });
1514
- }
1515
-
1516
- function downloadTextArtifact(filename, content) {
1517
- const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
1518
- const a = document.createElement('a');
1519
- a.href = URL.createObjectURL(blob);
1520
- a.download = filename;
1521
- document.body.appendChild(a);
1522
- a.click();
1523
- setTimeout(() => {
1524
- URL.revokeObjectURL(a.href);
1525
- a.remove();
1526
- }, 100);
1527
- }
1528
-
1529
- function getActiveRunForExport() {
1530
- const runId = sessionRunMap.get(activeSessionId) || currentRunId;
1531
- if (runId) {
1532
- const fromState = (agentRunsState || []).find((r) => r.runId === runId);
1533
- if (fromState) return fromState;
1534
- }
1535
- return null;
1536
- }
1537
-
1538
- function exportActiveRun() {
1539
- const run = getActiveRunForExport();
1540
- const session = consoleSessions.find((s) => s.id === activeSessionId);
1541
- if (!run && !session) {
1542
- flashToolbarHint('Нечего экспортировать');
1543
- return;
1544
- }
1545
- const mode = window.prompt('Формат экспорта: md или json', 'md');
1546
- const format = (mode || 'md').trim().toLowerCase() === 'json' ? 'json' : 'md';
1547
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
1548
- const runId = run?.runId || sessionRunMap.get(activeSessionId) || 'session';
1549
- if (format === 'json') {
1550
- const payload = {
1551
- run,
1552
- sessionTitle: session?.title || null,
1553
- lines: (session?.lines || []).map((line) => extractLineText(line)),
1554
- exportedAt: new Date().toISOString(),
1555
- };
1556
- downloadTextArtifact(`viberadar-run-${runId}-${ts}.json`, JSON.stringify(payload, null, 2));
1557
- return;
1558
- }
1559
- const lines = (session?.lines || []).map((line) => extractLineText(line)).filter(Boolean);
1560
- const outcomes = Array.isArray(run?.fileOutcomes) ? run.fileOutcomes : [];
1561
- const stats = run?.validationStats || {};
1562
- const md = [
1563
- `# VibeRadar Run Export`,
1564
- ``,
1565
- `- runId: ${run?.runId || runId}`,
1566
- `- title: ${run?.title || session?.title || '-'}`,
1567
- `- phase: ${run?.phase || '-'}`,
1568
- `- exportedAt: ${new Date().toISOString()}`,
1569
- ``,
1570
- `## Validation`,
1571
- ``,
1572
- `- covered: ${stats.covered ?? 0}`,
1573
- `- not-covered: ${stats.notCovered ?? 0}`,
1574
- `- blocked: ${stats.blocked ?? 0}`,
1575
- `- infra: ${stats.infra ?? 0}`,
1576
- ``,
1577
- `## File Outcomes`,
1578
- ``,
1579
- ...outcomes.map((o) => `- ${o.status}: ${o.sourcePath}${o.testFile ? ` -> ${o.testFile}` : ''}${o.reason ? ` (${o.reason})` : ''}`),
1580
- ``,
1581
- `## Logs`,
1582
- ``,
1583
- '```text',
1584
- ...lines,
1585
- '```',
1586
- ].join('\n');
1587
- downloadTextArtifact(`viberadar-run-${runId}-${ts}.md`, md);
1588
- }
1589
-
1590
- function renderActiveSession() {
1591
- const term = document.getElementById('agentTerminal');
1592
- if (!term) return;
1593
- term.innerHTML = '';
1594
- terminalMatchRefs = [];
1595
- terminalCommandRefs = [];
1596
- terminalErrorRefs = [];
1597
- terminalMatchCursor = -1;
1598
- terminalCommandCursor = -1;
1599
- terminalErrorCursor = -1;
1600
- const s = consoleSessions.find(s => s.id === activeSessionId);
1601
- if (!s) {
1602
- document.getElementById('agentSearchMeta').textContent = '0 matches';
1603
- updateToolbarButtonsState();
1604
- return;
1605
- }
1606
- const normalizedQuery = (terminalSearchQuery || '').trim();
1607
- let regex = null;
1608
- if (normalizedQuery && terminalSearchRegex) {
1609
- try { regex = new RegExp(normalizedQuery, 'i'); } catch { regex = null; }
1610
- }
1611
- for (let i = 0; i < s.lines.length; i++) {
1612
- const ln = s.lines[i];
1613
- const text = extractLineText(ln);
1614
- const isErrorLine = !!ln.isError || /(^|\s)❌/.test(text) || /\berror\b/i.test(text);
1615
- const isCommandLine = /^\s*⚡\s*\$/.test(text);
1616
- if (terminalSearchCurrentRunOnly && currentRunId && s.runId && s.runId !== currentRunId) continue;
1617
- if (terminalSearchErrorsOnly && !isErrorLine) continue;
1618
- let isMatch = false;
1619
- if (!normalizedQuery) {
1620
- isMatch = false;
1621
- } else if (regex) {
1622
- isMatch = regex.test(text);
1623
- } else {
1624
- isMatch = text.toLowerCase().includes(normalizedQuery.toLowerCase());
1625
- }
1626
- if (normalizedQuery && !isMatch) continue;
1627
-
1628
- const el = document.createElement('div');
1629
- if (ln.html) {
1630
- el.innerHTML = ln.html;
1631
- } else {
1632
- el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1633
- el.textContent = ln.text;
1634
- }
1635
- if (isCommandLine) el.classList.add('command');
1636
- if (isMatch) el.classList.add('match');
1637
- el.dataset.lineIndex = String(i);
1638
- term.appendChild(el);
1639
- if (isMatch) terminalMatchRefs.push(el);
1640
- if (isCommandLine) terminalCommandRefs.push(el);
1641
- if (isErrorLine) terminalErrorRefs.push(el);
1642
- }
1643
- term.scrollTop = term.scrollHeight;
1644
- document.getElementById('agentSearchMeta').textContent =
1645
- `${terminalMatchRefs.length} matches • ${terminalCommandRefs.length} commands • ${terminalErrorRefs.length} errors`;
1646
- updateToolbarButtonsState();
1647
- }
1648
-
1649
- function updateToolbarButtonsState() {
1650
- const setState = (id, enabled) => {
1651
- const el = document.getElementById(id);
1652
- if (!el) return;
1653
- el.disabled = !enabled;
1654
- };
1655
- setState('btnMatchPrev', terminalMatchRefs.length > 0);
1656
- setState('btnMatchNext', terminalMatchRefs.length > 0);
1657
- setState('btnCmdPrev', terminalCommandRefs.length > 0);
1658
- setState('btnCmdNext', terminalCommandRefs.length > 0);
1659
- setState('btnErrPrev', terminalErrorRefs.length > 0);
1660
- setState('btnErrNext', terminalErrorRefs.length > 0);
1661
- const hasExport = !!getActiveRunForExport() || !!consoleSessions.find((s) => s.id === activeSessionId);
1662
- setState('btnExportRun', hasExport);
1663
- }
1664
-
1665
- function flashToolbarHint(text) {
1666
- const meta = document.getElementById('agentSearchMeta');
1667
- if (!meta) return;
1668
- const previous = meta.textContent;
1669
- meta.textContent = text;
1670
- meta.style.color = 'var(--yellow)';
1671
- setTimeout(() => {
1672
- meta.style.color = '';
1673
- if (meta.textContent === text) {
1674
- meta.textContent = previous || '0 matches';
1675
- }
1676
- }, 1400);
1677
- }
1678
-
1679
- function extractLineText(line) {
1680
- if (!line) return '';
1681
- if (line.text !== undefined) return String(line.text || '');
1682
- if (line.html) {
1683
- const tmp = document.createElement('div');
1684
- tmp.innerHTML = line.html;
1685
- return tmp.innerText || '';
1686
- }
1687
- return '';
1688
- }
1689
-
1690
- function jumpByRefs(refs, dir, cursorName) {
1691
- if (!refs.length) {
1692
- const label = cursorName === 'match' ? 'совпадений' : cursorName === 'command' ? 'команд' : 'ошибок';
1693
- flashToolbarHint(`Нет ${label} для навигации`);
1694
- return;
1695
- }
1696
- if (cursorName === 'match') {
1697
- terminalMatchCursor = (terminalMatchCursor + dir + refs.length) % refs.length;
1698
- refs[terminalMatchCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1699
- return;
1700
- }
1701
- if (cursorName === 'command') {
1702
- terminalCommandCursor = (terminalCommandCursor + dir + refs.length) % refs.length;
1703
- refs[terminalCommandCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1704
- return;
1705
- }
1706
- terminalErrorCursor = (terminalErrorCursor + dir + refs.length) % refs.length;
1707
- refs[terminalErrorCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1708
- }
1709
-
1710
- function jumpTerminalMatch(dir) {
1711
- jumpByRefs(terminalMatchRefs, dir > 0 ? 1 : -1, 'match');
1712
- }
1713
-
1714
- function jumpCommand(dir) {
1715
- jumpByRefs(terminalCommandRefs, dir > 0 ? 1 : -1, 'command');
1716
- }
1717
-
1718
- function jumpError(dir) {
1719
- jumpByRefs(terminalErrorRefs, dir > 0 ? 1 : -1, 'error');
1720
- }
1523
+ });
1524
+ }
1525
+
1526
+ function downloadTextArtifact(filename, content) {
1527
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
1528
+ const a = document.createElement('a');
1529
+ a.href = URL.createObjectURL(blob);
1530
+ a.download = filename;
1531
+ document.body.appendChild(a);
1532
+ a.click();
1533
+ setTimeout(() => {
1534
+ URL.revokeObjectURL(a.href);
1535
+ a.remove();
1536
+ }, 100);
1537
+ }
1538
+
1539
+ function getActiveRunForExport() {
1540
+ const runId = sessionRunMap.get(activeSessionId) || currentRunId;
1541
+ if (runId) {
1542
+ const fromState = (agentRunsState || []).find((r) => r.runId === runId);
1543
+ if (fromState) return fromState;
1544
+ }
1545
+ return null;
1546
+ }
1547
+
1548
+ function exportActiveRun() {
1549
+ const run = getActiveRunForExport();
1550
+ const session = consoleSessions.find((s) => s.id === activeSessionId);
1551
+ if (!run && !session) {
1552
+ flashToolbarHint('Нечего экспортировать');
1553
+ return;
1554
+ }
1555
+ const mode = window.prompt('Формат экспорта: md или json', 'md');
1556
+ const format = (mode || 'md').trim().toLowerCase() === 'json' ? 'json' : 'md';
1557
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1558
+ const runId = run?.runId || sessionRunMap.get(activeSessionId) || 'session';
1559
+ if (format === 'json') {
1560
+ const payload = {
1561
+ run,
1562
+ sessionTitle: session?.title || null,
1563
+ lines: (session?.lines || []).map((line) => extractLineText(line)),
1564
+ exportedAt: new Date().toISOString(),
1565
+ };
1566
+ downloadTextArtifact(`viberadar-run-${runId}-${ts}.json`, JSON.stringify(payload, null, 2));
1567
+ return;
1568
+ }
1569
+ const lines = (session?.lines || []).map((line) => extractLineText(line)).filter(Boolean);
1570
+ const outcomes = Array.isArray(run?.fileOutcomes) ? run.fileOutcomes : [];
1571
+ const stats = run?.validationStats || {};
1572
+ const md = [
1573
+ `# VibeRadar Run Export`,
1574
+ ``,
1575
+ `- runId: ${run?.runId || runId}`,
1576
+ `- title: ${run?.title || session?.title || '-'}`,
1577
+ `- phase: ${run?.phase || '-'}`,
1578
+ `- exportedAt: ${new Date().toISOString()}`,
1579
+ ``,
1580
+ `## Validation`,
1581
+ ``,
1582
+ `- covered: ${stats.covered ?? 0}`,
1583
+ `- not-covered: ${stats.notCovered ?? 0}`,
1584
+ `- blocked: ${stats.blocked ?? 0}`,
1585
+ `- infra: ${stats.infra ?? 0}`,
1586
+ ``,
1587
+ `## File Outcomes`,
1588
+ ``,
1589
+ ...outcomes.map((o) => `- ${o.status}: ${o.sourcePath}${o.testFile ? ` -> ${o.testFile}` : ''}${o.reason ? ` (${o.reason})` : ''}`),
1590
+ ``,
1591
+ `## Logs`,
1592
+ ``,
1593
+ '```text',
1594
+ ...lines,
1595
+ '```',
1596
+ ].join('\n');
1597
+ downloadTextArtifact(`viberadar-run-${runId}-${ts}.md`, md);
1598
+ }
1599
+
1600
+ function renderActiveSession() {
1601
+ const term = document.getElementById('agentTerminal');
1602
+ if (!term) return;
1603
+ term.innerHTML = '';
1604
+ terminalMatchRefs = [];
1605
+ terminalCommandRefs = [];
1606
+ terminalErrorRefs = [];
1607
+ terminalMatchCursor = -1;
1608
+ terminalCommandCursor = -1;
1609
+ terminalErrorCursor = -1;
1610
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1611
+ if (!s) {
1612
+ document.getElementById('agentSearchMeta').textContent = '0 matches';
1613
+ updateToolbarButtonsState();
1614
+ return;
1615
+ }
1616
+ const normalizedQuery = (terminalSearchQuery || '').trim();
1617
+ let regex = null;
1618
+ if (normalizedQuery && terminalSearchRegex) {
1619
+ try { regex = new RegExp(normalizedQuery, 'i'); } catch { regex = null; }
1620
+ }
1621
+ for (let i = 0; i < s.lines.length; i++) {
1622
+ const ln = s.lines[i];
1623
+ const text = extractLineText(ln);
1624
+ const isErrorLine = !!ln.isError || /(^|\s)❌/.test(text) || /\berror\b/i.test(text);
1625
+ const isCommandLine = /^\s*⚡\s*\$/.test(text);
1626
+ if (terminalSearchCurrentRunOnly && currentRunId && s.runId && s.runId !== currentRunId) continue;
1627
+ if (terminalSearchErrorsOnly && !isErrorLine) continue;
1628
+ let isMatch = false;
1629
+ if (!normalizedQuery) {
1630
+ isMatch = false;
1631
+ } else if (regex) {
1632
+ isMatch = regex.test(text);
1633
+ } else {
1634
+ isMatch = text.toLowerCase().includes(normalizedQuery.toLowerCase());
1635
+ }
1636
+ if (normalizedQuery && !isMatch) continue;
1637
+
1638
+ const el = document.createElement('div');
1639
+ if (ln.html) {
1640
+ el.innerHTML = ln.html;
1641
+ } else {
1642
+ el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1643
+ el.textContent = ln.text;
1644
+ }
1645
+ if (isCommandLine) el.classList.add('command');
1646
+ if (isMatch) el.classList.add('match');
1647
+ el.dataset.lineIndex = String(i);
1648
+ term.appendChild(el);
1649
+ if (isMatch) terminalMatchRefs.push(el);
1650
+ if (isCommandLine) terminalCommandRefs.push(el);
1651
+ if (isErrorLine) terminalErrorRefs.push(el);
1652
+ }
1653
+ term.scrollTop = term.scrollHeight;
1654
+ document.getElementById('agentSearchMeta').textContent =
1655
+ `${terminalMatchRefs.length} matches • ${terminalCommandRefs.length} commands • ${terminalErrorRefs.length} errors`;
1656
+ updateToolbarButtonsState();
1657
+ }
1658
+
1659
+ function updateToolbarButtonsState() {
1660
+ const setState = (id, enabled) => {
1661
+ const el = document.getElementById(id);
1662
+ if (!el) return;
1663
+ el.disabled = !enabled;
1664
+ };
1665
+ setState('btnMatchPrev', terminalMatchRefs.length > 0);
1666
+ setState('btnMatchNext', terminalMatchRefs.length > 0);
1667
+ setState('btnCmdPrev', terminalCommandRefs.length > 0);
1668
+ setState('btnCmdNext', terminalCommandRefs.length > 0);
1669
+ setState('btnErrPrev', terminalErrorRefs.length > 0);
1670
+ setState('btnErrNext', terminalErrorRefs.length > 0);
1671
+ const hasExport = !!getActiveRunForExport() || !!consoleSessions.find((s) => s.id === activeSessionId);
1672
+ setState('btnExportRun', hasExport);
1673
+ }
1674
+
1675
+ function flashToolbarHint(text) {
1676
+ const meta = document.getElementById('agentSearchMeta');
1677
+ if (!meta) return;
1678
+ const previous = meta.textContent;
1679
+ meta.textContent = text;
1680
+ meta.style.color = 'var(--yellow)';
1681
+ setTimeout(() => {
1682
+ meta.style.color = '';
1683
+ if (meta.textContent === text) {
1684
+ meta.textContent = previous || '0 matches';
1685
+ }
1686
+ }, 1400);
1687
+ }
1688
+
1689
+ function extractLineText(line) {
1690
+ if (!line) return '';
1691
+ if (line.text !== undefined) return String(line.text || '');
1692
+ if (line.html) {
1693
+ const tmp = document.createElement('div');
1694
+ tmp.innerHTML = line.html;
1695
+ return tmp.innerText || '';
1696
+ }
1697
+ return '';
1698
+ }
1699
+
1700
+ function jumpByRefs(refs, dir, cursorName) {
1701
+ if (!refs.length) {
1702
+ const label = cursorName === 'match' ? 'совпадений' : cursorName === 'command' ? 'команд' : 'ошибок';
1703
+ flashToolbarHint(`Нет ${label} для навигации`);
1704
+ return;
1705
+ }
1706
+ if (cursorName === 'match') {
1707
+ terminalMatchCursor = (terminalMatchCursor + dir + refs.length) % refs.length;
1708
+ refs[terminalMatchCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1709
+ return;
1710
+ }
1711
+ if (cursorName === 'command') {
1712
+ terminalCommandCursor = (terminalCommandCursor + dir + refs.length) % refs.length;
1713
+ refs[terminalCommandCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1714
+ return;
1715
+ }
1716
+ terminalErrorCursor = (terminalErrorCursor + dir + refs.length) % refs.length;
1717
+ refs[terminalErrorCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1718
+ }
1719
+
1720
+ function jumpTerminalMatch(dir) {
1721
+ jumpByRefs(terminalMatchRefs, dir > 0 ? 1 : -1, 'match');
1722
+ }
1723
+
1724
+ function jumpCommand(dir) {
1725
+ jumpByRefs(terminalCommandRefs, dir > 0 ? 1 : -1, 'command');
1726
+ }
1727
+
1728
+ function jumpError(dir) {
1729
+ jumpByRefs(terminalErrorRefs, dir > 0 ? 1 : -1, 'error');
1730
+ }
1721
1731
 
1722
1732
  async function setAgent(agent) {
1723
1733
  await fetch('/api/set-agent', {
@@ -1766,157 +1776,157 @@ function isFileAgentActive(relPath) {
1766
1776
  return null;
1767
1777
  }
1768
1778
 
1769
- function updateQueueBadge(n) {
1770
- document.getElementById('agentQueueCount').textContent = n;
1771
- document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1772
- document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1773
- renderQueuePanel();
1774
- }
1775
-
1776
- function renderQueuePanel() {
1777
- const panel = document.getElementById('agentQueuePanel');
1778
- if (!panel) return;
1779
- if (!Array.isArray(agentQueueState) || agentQueueState.length === 0) {
1780
- panel.style.display = 'none';
1781
- panel.innerHTML = '';
1782
- return;
1783
- }
1784
- panel.style.display = 'block';
1785
- panel.innerHTML = `
1786
- <div class="agent-queue-title">Очередь задач (${agentQueueState.length})</div>
1787
- ${agentQueueState.map((item, idx) => `
1788
- <div class="agent-queue-item">
1789
- <span class="agent-queue-pos">#${item.position ?? idx + 1}</span>
1790
- <span title="${escapeHtml(item.title || '')}">${escapeHtml(item.title || item.task || item.runId)}</span>
1791
- <span style="color:var(--dim)">(${escapeHtml(item.task || 'task')})</span>
1792
- <div class="agent-queue-actions">
1793
- <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','up')" title="Сдвинуть вверх">↑</button>
1794
- <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','down')" title="Сдвинуть вниз">↓</button>
1795
- <button class="agent-queue-action" onclick="cancelQueueItem('${item.runId}')" title="Отменить задачу">✕</button>
1796
- <button class="agent-queue-action" onclick="retryRun('${item.runId}')" title="Retry">↻</button>
1797
- </div>
1798
- </div>
1799
- `).join('')}
1800
- `;
1801
- }
1802
-
1803
- async function cancelQueueItem(runId) {
1804
- await fetch(`/api/queue/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
1805
- await loadAgentState();
1806
- }
1807
-
1808
- async function retryRun(runId) {
1809
- await fetch(`/api/queue/${encodeURIComponent(runId)}/retry`, { method: 'POST' });
1810
- await loadAgentState();
1811
- }
1812
-
1813
- async function reorderQueueItem(runId, direction) {
1814
- await fetch(`/api/queue/${encodeURIComponent(runId)}/reorder`, {
1815
- method: 'POST',
1816
- headers: { 'Content-Type': 'application/json' },
1817
- body: JSON.stringify({ direction }),
1818
- });
1819
- await loadAgentState();
1820
- }
1821
-
1822
- function ensureSessionForRun(run, makeActive = false) {
1823
- if (!run?.runId) return null;
1824
- let sessionId = runSessionMap.get(run.runId);
1825
- if (!sessionId) {
1826
- sessionId = createSession(run.title || `Run ${run.runId}`, run.phase === 'failed' ? 'error' : run.phase === 'completed' ? 'ok' : 'running', run.runId);
1827
- const meta = [`runId: ${run.runId}`, `phase: ${run.phase}`];
1828
- if (Array.isArray(run.targetSourcePaths) && run.targetSourcePaths.length > 0) {
1829
- meta.push(`targets: ${run.targetSourcePaths.length}`);
1830
- }
1831
- appendToSession(sessionId, `ℹ ${meta.join(' • ')}`, false, true);
1832
- }
1833
- if (makeActive) switchSession(sessionId);
1834
- return sessionId;
1835
- }
1836
-
1837
- function applyAgentStateSnapshot(state) {
1838
- if (!state) return;
1839
- agentQueueState = Array.isArray(state.queue) ? state.queue : [];
1840
- agentRunsState = Array.isArray(state.runs) ? state.runs : [];
1841
- agentActiveRun = state.activeRun || null;
1842
- updateQueueBadge(agentQueueState.length);
1843
- if (agentActiveRun?.runId) {
1844
- currentRunId = agentActiveRun.runId;
1845
- ensureSessionForRun(agentActiveRun);
1846
- setAgentRunning(['starting', 'running', 'validating'].includes(agentActiveRun.phase));
1847
- } else {
1848
- setAgentRunning(false);
1849
- }
1850
- }
1851
-
1852
- async function loadAgentState() {
1853
- try {
1854
- const res = await fetch('/api/agent/state');
1855
- if (!res.ok) return;
1856
- const state = await res.json();
1857
- applyAgentStateSnapshot(state);
1858
- } catch {}
1859
- }
1860
-
1861
- function upsertRunState(run) {
1862
- if (!run?.runId) return;
1863
- const idx = agentRunsState.findIndex((r) => r.runId === run.runId);
1864
- if (idx >= 0) agentRunsState[idx] = { ...agentRunsState[idx], ...run };
1865
- else agentRunsState.push(run);
1866
- if (agentRunsState.length > 80) {
1867
- agentRunsState = agentRunsState.slice(agentRunsState.length - 80);
1868
- }
1869
- }
1870
-
1871
- function updateSessionFromRun(run) {
1872
- if (!run?.runId) return;
1873
- upsertRunState(run);
1874
- if (['starting', 'running', 'validating'].includes(run.phase)) {
1875
- setAgentRunning(true);
1876
- currentRunId = run.runId;
1877
- const sid = ensureSessionForRun(run);
1878
- if (sid) {
1879
- runningSessionId = sid;
1880
- if (activeSessionId === sid) {
1881
- document.getElementById('agentPanelTitle').textContent = '🤖 ' + (run.title || 'Agent');
1882
- document.getElementById('agentPanelStatus').textContent = run.phase === 'validating' ? 'проверяю…' : 'работает…';
1883
- }
1884
- }
1885
- return;
1886
- }
1887
- const sid = runSessionMap.get(run.runId);
1888
- if (sid) {
1889
- const status = run.phase === 'completed' ? 'ok' : run.phase === 'canceled' ? 'error' : run.phase === 'failed' ? 'error' : 'info';
1890
- updateSessionStatus(sid, status);
1891
- if (runningSessionId === sid) runningSessionId = null;
1892
- if (activeSessionId === sid) {
1893
- document.getElementById('agentPanelStatus').textContent =
1894
- run.phase === 'completed' ? '✅ готово' : run.phase === 'canceled' ? '⏹ отменено' : run.phase === 'failed' ? '❌ ошибка' : run.phase;
1895
- }
1896
- }
1897
- if (run.phase === 'completed' || run.phase === 'failed' || run.phase === 'canceled') {
1898
- if (agentActiveRun?.runId === run.runId) agentActiveRun = null;
1899
- if (currentRunId === run.runId) currentRunId = null;
1900
- setAgentRunning(false);
1901
- }
1902
- }
1903
-
1904
- function refreshPathActivityFromState() {
1905
- agentQueuedPaths.clear();
1906
- (agentQueueState || []).forEach((q) => {
1907
- getTaskFilePaths(q.task, q.featureKey, q.filePath, q.selectedFilePaths).forEach((p) => agentQueuedPaths.add(p));
1908
- });
1909
- if (!agentActiveRun || !['starting', 'running', 'validating'].includes(agentActiveRun.phase)) {
1910
- agentRunningPaths.clear();
1911
- }
1912
- }
1913
-
1914
- async function cancelAgent() {
1915
- await fetch('/api/cancel-agent', { method: 'POST' });
1916
- await loadAgentState();
1917
- document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1918
- if (runningSessionId) {
1919
- appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1779
+ function updateQueueBadge(n) {
1780
+ document.getElementById('agentQueueCount').textContent = n;
1781
+ document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1782
+ document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1783
+ renderQueuePanel();
1784
+ }
1785
+
1786
+ function renderQueuePanel() {
1787
+ const panel = document.getElementById('agentQueuePanel');
1788
+ if (!panel) return;
1789
+ if (!Array.isArray(agentQueueState) || agentQueueState.length === 0) {
1790
+ panel.style.display = 'none';
1791
+ panel.innerHTML = '';
1792
+ return;
1793
+ }
1794
+ panel.style.display = 'block';
1795
+ panel.innerHTML = `
1796
+ <div class="agent-queue-title">Очередь задач (${agentQueueState.length})</div>
1797
+ ${agentQueueState.map((item, idx) => `
1798
+ <div class="agent-queue-item">
1799
+ <span class="agent-queue-pos">#${item.position ?? idx + 1}</span>
1800
+ <span title="${escapeHtml(item.title || '')}">${escapeHtml(item.title || item.task || item.runId)}</span>
1801
+ <span style="color:var(--dim)">(${escapeHtml(item.task || 'task')})</span>
1802
+ <div class="agent-queue-actions">
1803
+ <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','up')" title="Сдвинуть вверх">↑</button>
1804
+ <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','down')" title="Сдвинуть вниз">↓</button>
1805
+ <button class="agent-queue-action" onclick="cancelQueueItem('${item.runId}')" title="Отменить задачу">✕</button>
1806
+ <button class="agent-queue-action" onclick="retryRun('${item.runId}')" title="Retry">↻</button>
1807
+ </div>
1808
+ </div>
1809
+ `).join('')}
1810
+ `;
1811
+ }
1812
+
1813
+ async function cancelQueueItem(runId) {
1814
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
1815
+ await loadAgentState();
1816
+ }
1817
+
1818
+ async function retryRun(runId) {
1819
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/retry`, { method: 'POST' });
1820
+ await loadAgentState();
1821
+ }
1822
+
1823
+ async function reorderQueueItem(runId, direction) {
1824
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/reorder`, {
1825
+ method: 'POST',
1826
+ headers: { 'Content-Type': 'application/json' },
1827
+ body: JSON.stringify({ direction }),
1828
+ });
1829
+ await loadAgentState();
1830
+ }
1831
+
1832
+ function ensureSessionForRun(run, makeActive = false) {
1833
+ if (!run?.runId) return null;
1834
+ let sessionId = runSessionMap.get(run.runId);
1835
+ if (!sessionId) {
1836
+ sessionId = createSession(run.title || `Run ${run.runId}`, run.phase === 'failed' ? 'error' : run.phase === 'completed' ? 'ok' : 'running', run.runId);
1837
+ const meta = [`runId: ${run.runId}`, `phase: ${run.phase}`];
1838
+ if (Array.isArray(run.targetSourcePaths) && run.targetSourcePaths.length > 0) {
1839
+ meta.push(`targets: ${run.targetSourcePaths.length}`);
1840
+ }
1841
+ appendToSession(sessionId, `ℹ ${meta.join(' • ')}`, false, true);
1842
+ }
1843
+ if (makeActive) switchSession(sessionId);
1844
+ return sessionId;
1845
+ }
1846
+
1847
+ function applyAgentStateSnapshot(state) {
1848
+ if (!state) return;
1849
+ agentQueueState = Array.isArray(state.queue) ? state.queue : [];
1850
+ agentRunsState = Array.isArray(state.runs) ? state.runs : [];
1851
+ agentActiveRun = state.activeRun || null;
1852
+ updateQueueBadge(agentQueueState.length);
1853
+ if (agentActiveRun?.runId) {
1854
+ currentRunId = agentActiveRun.runId;
1855
+ ensureSessionForRun(agentActiveRun);
1856
+ setAgentRunning(['starting', 'running', 'validating'].includes(agentActiveRun.phase));
1857
+ } else {
1858
+ setAgentRunning(false);
1859
+ }
1860
+ }
1861
+
1862
+ async function loadAgentState() {
1863
+ try {
1864
+ const res = await fetch('/api/agent/state');
1865
+ if (!res.ok) return;
1866
+ const state = await res.json();
1867
+ applyAgentStateSnapshot(state);
1868
+ } catch {}
1869
+ }
1870
+
1871
+ function upsertRunState(run) {
1872
+ if (!run?.runId) return;
1873
+ const idx = agentRunsState.findIndex((r) => r.runId === run.runId);
1874
+ if (idx >= 0) agentRunsState[idx] = { ...agentRunsState[idx], ...run };
1875
+ else agentRunsState.push(run);
1876
+ if (agentRunsState.length > 80) {
1877
+ agentRunsState = agentRunsState.slice(agentRunsState.length - 80);
1878
+ }
1879
+ }
1880
+
1881
+ function updateSessionFromRun(run) {
1882
+ if (!run?.runId) return;
1883
+ upsertRunState(run);
1884
+ if (['starting', 'running', 'validating'].includes(run.phase)) {
1885
+ setAgentRunning(true);
1886
+ currentRunId = run.runId;
1887
+ const sid = ensureSessionForRun(run);
1888
+ if (sid) {
1889
+ runningSessionId = sid;
1890
+ if (activeSessionId === sid) {
1891
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + (run.title || 'Agent');
1892
+ document.getElementById('agentPanelStatus').textContent = run.phase === 'validating' ? 'проверяю…' : 'работает…';
1893
+ }
1894
+ }
1895
+ return;
1896
+ }
1897
+ const sid = runSessionMap.get(run.runId);
1898
+ if (sid) {
1899
+ const status = run.phase === 'completed' ? 'ok' : run.phase === 'canceled' ? 'error' : run.phase === 'failed' ? 'error' : 'info';
1900
+ updateSessionStatus(sid, status);
1901
+ if (runningSessionId === sid) runningSessionId = null;
1902
+ if (activeSessionId === sid) {
1903
+ document.getElementById('agentPanelStatus').textContent =
1904
+ run.phase === 'completed' ? '✅ готово' : run.phase === 'canceled' ? '⏹ отменено' : run.phase === 'failed' ? '❌ ошибка' : run.phase;
1905
+ }
1906
+ }
1907
+ if (run.phase === 'completed' || run.phase === 'failed' || run.phase === 'canceled') {
1908
+ if (agentActiveRun?.runId === run.runId) agentActiveRun = null;
1909
+ if (currentRunId === run.runId) currentRunId = null;
1910
+ setAgentRunning(false);
1911
+ }
1912
+ }
1913
+
1914
+ function refreshPathActivityFromState() {
1915
+ agentQueuedPaths.clear();
1916
+ (agentQueueState || []).forEach((q) => {
1917
+ getTaskFilePaths(q.task, q.featureKey, q.filePath, q.selectedFilePaths).forEach((p) => agentQueuedPaths.add(p));
1918
+ });
1919
+ if (!agentActiveRun || !['starting', 'running', 'validating'].includes(agentActiveRun.phase)) {
1920
+ agentRunningPaths.clear();
1921
+ }
1922
+ }
1923
+
1924
+ async function cancelAgent() {
1925
+ await fetch('/api/cancel-agent', { method: 'POST' });
1926
+ await loadAgentState();
1927
+ document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1928
+ if (runningSessionId) {
1929
+ appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1920
1930
  updateSessionStatus(runningSessionId, 'error');
1921
1931
  runningSessionId = null;
1922
1932
  } else {
@@ -1924,11 +1934,11 @@ async function cancelAgent() {
1924
1934
  }
1925
1935
  }
1926
1936
 
1927
- async function clearAgentQueue() {
1928
- await fetch('/api/clear-queue', { method: 'POST' });
1929
- await loadAgentState();
1930
- appendTerminalLine('🗑 Очередь очищена', false);
1931
- }
1937
+ async function clearAgentQueue() {
1938
+ await fetch('/api/clear-queue', { method: 'POST' });
1939
+ await loadAgentState();
1940
+ appendTerminalLine('🗑 Очередь очищена', false);
1941
+ }
1932
1942
 
1933
1943
  // ─── File row more menu ──────────────────────────────────────────────────────
1934
1944
  let _openFileMenu = null;
@@ -2060,7 +2070,7 @@ async function reauthAgent() {
2060
2070
  await fetch('/api/agent-reauth', { method: 'POST' });
2061
2071
  }
2062
2072
 
2063
- async function runAgentTask(task, featureKey, filePath, selectedFilePaths) {
2073
+ async function runAgentTask(task, featureKey, filePath, selectedFilePaths, meta) {
2064
2074
  document.getElementById('agentPanel').classList.add('open');
2065
2075
  document.getElementById('termBtn').classList.add('term-active');
2066
2076
  if (!agentRunning) {
@@ -2074,6 +2084,7 @@ async function runAgentTask(task, featureKey, filePath, selectedFilePaths) {
2074
2084
  featureKey,
2075
2085
  filePath: filePath || undefined,
2076
2086
  selectedFilePaths: Array.isArray(selectedFilePaths) ? selectedFilePaths : undefined,
2087
+ meta: meta || undefined,
2077
2088
  }),
2078
2089
  });
2079
2090
  }
@@ -2195,19 +2206,29 @@ function renderStats() {
2195
2206
 
2196
2207
  let items;
2197
2208
  if (contextMode === 'observability') {
2198
- const serviceSources = src.filter(m => m.type === 'service' || m.type === 'util' || m.type === 'other');
2199
- const errorSignal = Object.values(D.testErrors || {}).reduce((acc, v) => acc + (v?.failed || 0), 0);
2200
- const totalBytes = src.reduce((acc, m) => acc + (m.size || 0), 0);
2201
- const avgKb = src.length ? Math.round(totalBytes / src.length / 1024) : 0;
2202
- const noiseRatio = src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0;
2203
- const missingStructured = serviceSources.filter(m => !m.hasTests).length;
2204
- items = [
2205
- { v: serviceSources.length, l: 'Источники логов' },
2206
- { v: avgKb + ' KB', l: 'Средний объём/файл' },
2207
- { v: noiseRatio + '%', l: 'Коэффициент шума' },
2208
- { v: errorSignal, l: 'Сигнал ошибок', c: errorSignal ? '#f85149' : undefined },
2209
- { v: missingStructured, l: 'Не хватает полей', c: missingStructured ? '#e3b341' : undefined },
2210
- ];
2209
+ const o = D.observability;
2210
+ if (o) {
2211
+ const noiseRatio = Math.round(o.metrics.noise_ratio * 100);
2212
+ const structPct = Math.round(o.metrics.structured_completeness * 100);
2213
+ const missingCoverage = o.missingCriticalLogs.length;
2214
+ const noisyCount = o.topNoisyPatterns.length;
2215
+ items = [
2216
+ { v: o.catalog.length, l: 'Источники логов' },
2217
+ { v: noiseRatio + '%', l: 'Коэффициент шума', c: noiseRatio > 30 ? '#f85149' : noiseRatio > 10 ? '#e3b341' : undefined },
2218
+ { v: structPct + '%', l: 'Структурированность', c: structPct < 50 ? '#f85149' : structPct < 80 ? '#e3b341' : undefined },
2219
+ { v: missingCoverage, l: 'Нет покрытия', c: missingCoverage ? '#e3b341' : undefined },
2220
+ { v: noisyCount, l: 'Шумных паттернов', c: noisyCount ? '#e3b341' : undefined },
2221
+ ];
2222
+ } else {
2223
+ const serviceSources = src.filter(m => m.type === 'service' || m.type === 'util' || m.type === 'other');
2224
+ items = [
2225
+ { v: serviceSources.length, l: 'Источники логов' },
2226
+ { v: '—', l: 'Коэффициент шума' },
2227
+ { v: '—', l: 'Структурированность' },
2228
+ { v: '—', l: 'Нет покрытия' },
2229
+ { v: '—', l: 'Шумных паттернов' },
2230
+ ];
2231
+ }
2211
2232
  } else if (D.hasConfig && D.features) {
2212
2233
  const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
2213
2234
  items = [
@@ -2236,10 +2257,10 @@ function renderStats() {
2236
2257
 
2237
2258
  function renderModeSwitch() {
2238
2259
  const root = document.getElementById('modeSwitch');
2239
- const modes = [
2240
- { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2241
- { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2242
- ];
2260
+ const modes = [
2261
+ { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2262
+ { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2263
+ ];
2243
2264
  root.innerHTML = modes.map(m => `
2244
2265
  <button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
2245
2266
  <span>${m.label}</span>
@@ -2257,15 +2278,15 @@ function renderSidebar() {
2257
2278
  const tabs = document.getElementById('viewTabs');
2258
2279
  const extra = document.getElementById('sidebarExtra');
2259
2280
 
2260
- if (contextMode === 'observability') {
2261
- tabs.style.display = 'none';
2262
- extra.innerHTML = `
2263
- <div class="sidebar-label">Фокус наблюдаемости</div>
2264
- <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
2265
- Источники логов и качество сигналов для триажа инцидентов.
2266
- </div>`;
2267
- return;
2268
- }
2281
+ if (contextMode === 'observability') {
2282
+ tabs.style.display = 'none';
2283
+ extra.innerHTML = `
2284
+ <div class="sidebar-label">Фокус наблюдаемости</div>
2285
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
2286
+ Источники логов и качество сигналов для триажа инцидентов.
2287
+ </div>`;
2288
+ return;
2289
+ }
2269
2290
 
2270
2291
  tabs.style.display = 'flex';
2271
2292
  document.querySelectorAll('.view-tab').forEach(t =>
@@ -2325,61 +2346,190 @@ function renderQaOnboarding() {
2325
2346
  return `<div class="onboarding-block"><h3>QA Coverage: что это?</h3><p>Этот экран помогает найти пробелы в тестах: сначала проверь покрытие по фичам, затем открой критичные непокрытые файлы и запусти генерацию/фиксы тестов.</p></div>`;
2326
2347
  }
2327
2348
 
2328
- function renderObservability(c) {
2329
- const src = D.modules.filter(m => m.type !== 'test');
2330
- const sourceGroups = [
2331
- { label: 'Сервисы', count: src.filter(m => m.type === 'service').length },
2332
- { label: 'Утилиты', count: src.filter(m => m.type === 'util').length },
2333
- { label: 'Прочее', count: src.filter(m => m.type === 'other').length },
2334
- ].filter(x => x.count > 0);
2335
- const errorSignal = Object.values(D.testErrors || {}).reduce((acc, v) => acc + (v?.failed || 0), 0);
2336
- const missingStructured = src.filter(m => (m.type === 'service' || m.type === 'util') && !m.hasTests).slice(0, 8);
2337
- const noisy = [...src]
2338
- .sort((a, b) => (b.size || 0) - (a.size || 0))
2339
- .slice(0, 5)
2340
- .map(m => ({ name: m.name, rel: m.relativePath, kb: Math.round((m.size || 0) / 1024) }));
2341
-
2342
- c.innerHTML = `
2343
- <div class="onboarding-block">
2344
- <h3>Наблюдаемость: что это?</h3>
2345
- <p>Экран для контроля отношения сигнал/шум: откуда идут логи, где растёт объём, сколько шума и какие поля/сигналы нужно структурировать в первую очередь.</p>
2346
- </div>
2347
-
2348
- <div class="obs-grid">
2349
+ function renderObservability(c) {
2350
+ const o = D.observability;
2351
+ if (!o) {
2352
+ c.innerHTML = `<div class="onboarding-block"><h3>Наблюдаемость</h3><p>Данные анализа логов недоступны. Пересканируй проект.</p></div>`;
2353
+ return;
2354
+ }
2355
+
2356
+ const noiseRatio = Math.round(o.metrics.noise_ratio * 100);
2357
+ const structPct = Math.round(o.metrics.structured_completeness * 100);
2358
+ const actionPct = Math.round(o.metrics.error_actionability * 100);
2359
+ const coveragePct = Math.round(o.metrics.coverage_of_key_flows * 100);
2360
+
2361
+ function metricColor(val, goodThreshold, warnThreshold, invert) {
2362
+ // invert=true: higher is worse (noise), invert=false: higher is better (coverage)
2363
+ if (invert) return val > warnThreshold ? 'var(--red)' : val > goodThreshold ? 'var(--yellow)' : 'var(--green)';
2364
+ return val < warnThreshold ? 'var(--red)' : val < goodThreshold ? 'var(--yellow)' : 'var(--green)';
2365
+ }
2366
+
2367
+ const formatLabels = { structured: 'structured', mixed: 'mixed', unstructured: 'unstructured' };
2368
+ const sourceByFormat = [
2369
+ { label: 'Structured', count: o.catalog.filter(c => c.format === 'structured').length, color: 'var(--green)' },
2370
+ { label: 'Mixed', count: o.catalog.filter(c => c.format === 'mixed').length, color: 'var(--yellow)' },
2371
+ { label: 'Unstructured', count: o.catalog.filter(c => c.format === 'unstructured').length, color: 'var(--red)' },
2372
+ ].filter(x => x.count > 0);
2373
+
2374
+ const recLabels = {
2375
+ 'suppress': 'убрать',
2376
+ 'downgrade level': 'понизить уровень',
2377
+ 'enrich fields': 'обогатить поля',
2378
+ 'add event': 'добавить событие',
2379
+ };
2380
+ const hasAgent = !!D.agent;
2381
+
2382
+ // Store catalog for buttons to reference by index (avoids inline JSON in onclick)
2383
+ window.__obsCatalog = o.catalog;
2384
+
2385
+ const noisyRows = o.topNoisyPatterns.slice(0, 8).map(i => {
2386
+ const safePattern = escapeHtml(i.pattern).replace(/'/g, '&#39;');
2387
+ const btn = hasAgent
2388
+ ? `<button class="obs-action-btn" onclick="event.stopPropagation();runAgentTask('obs-suppress-pattern',null,null,null,{pattern:'${safePattern}',recommendation:'${i.recommendation}'})">убрать</button>`
2389
+ : '';
2390
+ return `
2391
+ <div class="obs-list-item">
2392
+ <span class="obs-priority-${i.priority}" style="flex-shrink:0">[${i.priority}]</span>
2393
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(i.pattern)}">${escapeHtml(i.pattern)}</span>
2394
+ <strong style="flex-shrink:0;color:var(--red)">×${i.count} → ${recLabels[i.recommendation] || i.recommendation}</strong>
2395
+ ${btn}
2396
+ </div>`;
2397
+ }).join('') || '<div class="obs-sub">Шумных паттернов не обнаружено</div>';
2398
+
2399
+ const missingRows = o.missingCriticalLogs.slice(0, 8).map(i => {
2400
+ const modulePath = i.pattern.split(':')[0].trim();
2401
+ const safePath = escapeHtml(modulePath).replace(/'/g, '&#39;');
2402
+ const btn = hasAgent
2403
+ ? `<button class="obs-action-btn" onclick="event.stopPropagation();runAgentTask('obs-add-critical-logs',null,null,null,{modulePath:'${safePath}'})">добавить</button>`
2404
+ : '';
2405
+ return `
2406
+ <div class="obs-list-item">
2407
+ <span class="obs-priority-${i.priority}" style="flex-shrink:0">[${i.priority}]</span>
2408
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(i.pattern)}">${escapeHtml(i.pattern)}</span>
2409
+ <strong style="flex-shrink:0;color:var(--yellow)">${recLabels[i.recommendation] || i.recommendation}</strong>
2410
+ ${btn}
2411
+ </div>`;
2412
+ }).join('') || '<div class="obs-sub" style="color:var(--green)">Критичные сценарии покрыты</div>';
2413
+
2414
+ const fieldGaps = o.fieldGaps || {};
2415
+ const fieldGapEntries = Object.entries(fieldGaps).filter(([,v]) => v > 0).sort((a,b) => b[1] - a[1]);
2416
+ const fieldGapRows = fieldGapEntries.map(([name, count]) => {
2417
+ const btn = hasAgent
2418
+ ? `<button class="obs-action-btn" onclick="event.stopPropagation();runAgentTask('obs-enrich-field',null,null,null,{fieldName:'${escapeHtml(name)}'})">обогатить</button>`
2419
+ : '';
2420
+ return `
2421
+ <div class="obs-list-item">
2422
+ <span><code>${escapeHtml(name)}</code></span>
2423
+ <strong style="color:${count > 20 ? 'var(--red)' : count > 5 ? 'var(--yellow)' : 'var(--muted)'}">${count} пропусков</strong>
2424
+ ${btn}
2425
+ </div>`;
2426
+ }).join('') || '<div class="obs-sub" style="color:var(--green)">Все обязательные поля на месте</div>';
2427
+
2428
+ const catalogRows = o.catalog.slice(0, 15).map((i, idx) => {
2429
+ const missing = (i.missingFields || []);
2430
+ const missingStr = missing.length ? missing.join(', ') : '—';
2431
+ const btn = hasAgent
2432
+ ? `<button class="obs-action-btn" onclick="event.stopPropagation();runAgentTask('obs-fix-module',null,null,null,{catalogIndex:${idx}})">исправить</button>`
2433
+ : '';
2434
+ return `
2435
+ <div class="obs-cat-row">
2436
+ <span title="${escapeHtml(i.modulePath)}">${escapeHtml(i.modulePath.split('/').slice(-2).join('/'))}</span>
2437
+ <span>${i.level}</span>
2438
+ <span style="color:${i.format==='structured'?'var(--green)':i.format==='mixed'?'var(--yellow)':'var(--red)'}">${i.format}</span>
2439
+ <span style="color:${missing.length > 4 ? 'var(--red)' : missing.length > 0 ? 'var(--yellow)' : 'var(--green)'}">${missing.length}/8</span>
2440
+ <span title="${escapeHtml(missingStr)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(missingStr)}</span>
2441
+ <span style="color:${i.recommendation==='suppress'?'var(--red)':i.recommendation==='add event'?'var(--yellow)':'var(--muted)'}">${recLabels[i.recommendation] || i.recommendation}</span>
2442
+ ${btn}
2443
+ </div>`}).join('');
2444
+
2445
+ c.innerHTML = `
2446
+ <div class="onboarding-block">
2447
+ <h3>Наблюдаемость: что это?</h3>
2448
+ <p>Аудит покрытия логами: что добавить, что убрать, что обогатить — на основе статического анализа лог-вызовов.</p>
2449
+ </div>
2450
+
2451
+ <div class="obs-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
2452
+ <div class="obs-card">
2453
+ <div class="obs-title">Коэффициент шума</div>
2454
+ <div class="obs-value" style="color:${metricColor(noiseRatio,10,30,true)}">${noiseRatio}%</div>
2455
+ <div class="obs-sub">Доля шумных лог-вызовов из всех.</div>
2456
+ </div>
2349
2457
  <div class="obs-card">
2350
- <div class="obs-title">Источники логов</div>
2458
+ <div class="obs-title">Структурированность</div>
2459
+ <div class="obs-value" style="color:${metricColor(structPct,80,50,false)}">${structPct}%</div>
2460
+ <div class="obs-sub">Логи с обязательными полями (module, event, traceId).</div>
2461
+ </div>
2462
+ <div class="obs-card">
2463
+ <div class="obs-title">Actionable ошибки</div>
2464
+ <div class="obs-value" style="color:${metricColor(actionPct,80,50,false)}">${actionPct}%</div>
2465
+ <div class="obs-sub">ERROR-логи с контекстом для диагностики.</div>
2466
+ </div>
2467
+ <div class="obs-card">
2468
+ <div class="obs-title">Покрытие сценариев</div>
2469
+ <div class="obs-value" style="color:${metricColor(coveragePct,80,50,false)}">${coveragePct}%</div>
2470
+ <div class="obs-sub">Модули с хотя бы одним warn/error событием.</div>
2471
+ </div>
2472
+ </div>
2473
+
2474
+ <div class="obs-grid" style="grid-template-columns:1fr 1fr 1fr;margin-bottom:12px">
2475
+ <div class="obs-card">
2476
+ <div class="obs-title">Источники по формату</div>
2351
2477
  <div class="obs-list">
2352
- ${sourceGroups.map(g => `<div class="obs-list-item"><span>${g.label}</span><strong>${g.count}</strong></div>`).join('') || '<div class="obs-sub">Нет данных</div>'}
2478
+ ${sourceByFormat.map(g => `
2479
+ <div class="obs-list-item">
2480
+ <span style="color:${g.color}">${g.label}</span>
2481
+ <strong>${g.count}</strong>
2482
+ </div>`).join('') || '<div class="obs-sub">Нет данных</div>'}
2353
2483
  </div>
2354
2484
  </div>
2485
+ <div class="obs-card">
2486
+ <div class="obs-title">Классификация логов</div>
2487
+ <div class="obs-list">
2488
+ <div class="obs-list-item"><span style="color:var(--red)">Мусор (убрать)</span><strong>${o.classification.trash}</strong></div>
2489
+ <div class="obs-list-item"><span style="color:var(--green)">Полезные</span><strong>${o.classification.useful}</strong></div>
2490
+ <div class="obs-list-item"><span style="color:var(--blue)">Критичные</span><strong>${o.classification.critical}</strong></div>
2491
+ </div>
2492
+ </div>
2493
+ <div class="obs-card">
2494
+ <div class="obs-title">Рекомендации</div>
2495
+ <div class="obs-list">
2496
+ ${['suppress','enrich fields','add event','downgrade level'].map(rec => {
2497
+ const count = o.catalog.filter(c => c.recommendation === rec).length;
2498
+ if (!count) return '';
2499
+ const btn = hasAgent
2500
+ ? `<button class="obs-action-btn obs-batch-btn" onclick="event.stopPropagation();runAgentTask('obs-batch-recommendation',null,null,null,{recommendationType:'${rec}'})">исправить все</button>`
2501
+ : '';
2502
+ return `<div class="obs-list-item"><span>${recLabels[rec]}</span><strong>${count}</strong>${btn}</div>`;
2503
+ }).join('')}
2504
+ </div>
2505
+ </div>
2506
+ </div>
2355
2507
 
2356
- <div class="obs-card">
2357
- <div class="obs-title">Объём логов (топ файлов)</div>
2358
- <div class="obs-list">
2359
- ${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>'}
2360
- </div>
2361
- </div>
2362
-
2363
- <div class="obs-card">
2364
- <div class="obs-title">Коэффициент шума</div>
2365
- <div class="obs-value">${src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0}%</div>
2366
- <div class="obs-sub">Доля источников без тестового контроля (прокси-метрика шумного логирования).</div>
2367
- </div>
2368
-
2369
- <div class="obs-card">
2370
- <div class="obs-title">Сигнал ошибок</div>
2371
- <div class="obs-value" style="color:${errorSignal ? 'var(--red)' : 'var(--green)'}">${errorSignal}</div>
2372
- <div class="obs-sub">Сумма упавших тестов из последнего прогона как индикатор нестабильности сигналов.</div>
2373
- </div>
2374
-
2375
- <div class="obs-card" style="grid-column:1 / -1">
2376
- <div class="obs-title">Недостающие структурированные поля (кандидаты)</div>
2377
- <div class="obs-list">
2378
- ${missingStructured.map(m => `<div class="obs-list-item"><span>${m.relativePath}</span><strong>нужна схема</strong></div>`).join('') || '<div class="obs-sub">Явных кандидатов не найдено</div>'}
2379
- </div>
2380
- </div>
2381
- </div>`;
2382
- }
2508
+ <div class="obs-grid" style="grid-template-columns:1fr 1fr;margin-bottom:12px">
2509
+ <div class="obs-card">
2510
+ <div class="obs-title">Что убрать — шумные паттерны</div>
2511
+ <div class="obs-list" style="gap:4px">${noisyRows}</div>
2512
+ </div>
2513
+ <div class="obs-card">
2514
+ <div class="obs-title">Что добавить — нет критичных логов</div>
2515
+ <div class="obs-list" style="gap:4px">${missingRows}</div>
2516
+ </div>
2517
+ </div>
2518
+
2519
+ <div class="obs-card" style="margin-bottom:12px">
2520
+ <div class="obs-title">Что обогатить — пробелы по полям</div>
2521
+ <div class="obs-sub" style="margin-bottom:8px">Обязательные поля по стандарту: service, env, trace_id, request_id, event_name, outcome, error_code (warn/error), user_id.</div>
2522
+ <div class="obs-list">${fieldGapRows}</div>
2523
+ </div>
2524
+
2525
+ <div class="obs-card" style="margin-bottom:12px">
2526
+ <div class="obs-title">Каталог источников логов (топ 15)</div>
2527
+ <div class="obs-catalog" style="margin-top:8px;border:none;padding:0">
2528
+ <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>пробелы</span><span>не хватает</span><span>действие</span><span></span></div>
2529
+ ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2530
+ </div>
2531
+ </div>`;
2532
+ }
2383
2533
 
2384
2534
  function backToFeatureDetail() {
2385
2535
  drillTestType = null;
@@ -2397,15 +2547,15 @@ function toPct(v) {
2397
2547
  return Math.round((v || 0) * 100) + '%';
2398
2548
  }
2399
2549
 
2400
- function renderObservabilityOverview(c) {
2401
- const o = D.observability;
2402
- if (!o) return '';
2403
- const metrics = [
2404
- ['Коэффициент шума', toPct(o.metrics.noise_ratio)],
2405
- ['Действия по ошибкам', toPct(o.metrics.error_actionability)],
2406
- ['Полнота структуры', toPct(o.metrics.structured_completeness)],
2407
- ['Покрытие ключевых сценариев', toPct(o.metrics.coverage_of_key_flows)],
2408
- ];
2550
+ function renderObservabilityOverview(c) {
2551
+ const o = D.observability;
2552
+ if (!o) return '';
2553
+ const metrics = [
2554
+ ['Коэффициент шума', toPct(o.metrics.noise_ratio)],
2555
+ ['Действия по ошибкам', toPct(o.metrics.error_actionability)],
2556
+ ['Полнота структуры', toPct(o.metrics.structured_completeness)],
2557
+ ['Покрытие ключевых сценариев', toPct(o.metrics.coverage_of_key_flows)],
2558
+ ];
2409
2559
  const noisy = (o.topNoisyPatterns || []).slice(0, 5).map(i =>
2410
2560
  `<div class="obs-row"><span class="obs-priority-${i.priority}">[${i.priority}]</span> ${escapeHtml(i.pattern)} · x${i.count} → <b>${i.recommendation}</b></div>`
2411
2561
  ).join('') || '<div class="obs-row">Нет шумных паттернов</div>';
@@ -2418,27 +2568,27 @@ function renderObservabilityOverview(c) {
2418
2568
  `<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>`
2419
2569
  ).join('');
2420
2570
 
2421
- return `
2422
- <div class="observability-panel">
2423
- <div class="observability-title">Наблюдаемость</div>
2424
- <div class="obs-metrics">
2425
- ${metrics.map(([l,v]) => `<div class="obs-metric"><div class="obs-metric-v">${v}</div><div class="obs-metric-l">${l}</div></div>`).join('')}
2426
- </div>
2427
- <div class="obs-columns">
2428
- <div class="obs-list">
2429
- <h4>Топ шумных паттернов</h4>
2430
- ${noisy}
2431
- </div>
2432
- <div class="obs-list">
2433
- <h4>Отсутствующие критичные логи</h4>
2434
- ${missing}
2435
- </div>
2436
- </div>
2437
- <div class="obs-catalog">
2438
- <h4>Каталог источников логов (топ 10)</h4>
2439
- <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>частота</span><span>владелец</span><span>действие</span></div>
2440
- ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2441
- </div>
2571
+ return `
2572
+ <div class="observability-panel">
2573
+ <div class="observability-title">Наблюдаемость</div>
2574
+ <div class="obs-metrics">
2575
+ ${metrics.map(([l,v]) => `<div class="obs-metric"><div class="obs-metric-v">${v}</div><div class="obs-metric-l">${l}</div></div>`).join('')}
2576
+ </div>
2577
+ <div class="obs-columns">
2578
+ <div class="obs-list">
2579
+ <h4>Топ шумных паттернов</h4>
2580
+ ${noisy}
2581
+ </div>
2582
+ <div class="obs-list">
2583
+ <h4>Отсутствующие критичные логи</h4>
2584
+ ${missing}
2585
+ </div>
2586
+ </div>
2587
+ <div class="obs-catalog">
2588
+ <h4>Каталог источников логов (топ 10)</h4>
2589
+ <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>частота</span><span>владелец</span><span>действие</span></div>
2590
+ ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2591
+ </div>
2442
2592
  <div class="obs-row" style="margin-top:8px">
2443
2593
  Классификация: мусор ${o.classification.trash} · полезно ${o.classification.useful} · критично ${o.classification.critical}
2444
2594
  </div>
@@ -2476,7 +2626,7 @@ function renderFeatureCards(c) {
2476
2626
  </div>
2477
2627
  </div>` : '';
2478
2628
 
2479
- c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
2629
+ c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
2480
2630
  const grid = document.getElementById('featGrid');
2481
2631
 
2482
2632
  list.forEach(f => {
@@ -2781,12 +2931,12 @@ function renderFeatureDetail(c) {
2781
2931
  return;
2782
2932
  }
2783
2933
 
2784
- const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
2785
- const src = mods.filter(m => m.type !== 'test');
2786
- const untestedSrc = src.filter(m => !m.hasTests);
2787
- const tst = mods.filter(m => m.type === 'test');
2788
- const testedCount = src.filter(m => m.hasTests).length;
2789
- const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
2934
+ const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
2935
+ const src = mods.filter(m => m.type !== 'test');
2936
+ const untestedSrc = src.filter(m => !m.hasTests);
2937
+ const tst = mods.filter(m => m.type === 'test');
2938
+ const testedCount = src.filter(m => m.hasTests).length;
2939
+ const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
2790
2940
 
2791
2941
  const unitCount = feat.unitTestCount ?? tst.filter(m => m.testType === 'unit').length;
2792
2942
  const integrationCount = feat.integrationTestCount ?? tst.filter(m => m.testType === 'integration').length;
@@ -2798,32 +2948,32 @@ function renderFeatureDetail(c) {
2798
2948
  const integrationFailed = tst.filter(m => m.testType === 'integration' && te[m.relativePath.replace(/\\/g, '/')]).length;
2799
2949
  const e2eFailed = tst.filter(m => m.testType === 'e2e' && te[m.relativePath.replace(/\\/g, '/')]).length;
2800
2950
 
2801
- // Determine what list to show based on active tab
2802
- // null or 'source' → source files; test type → test files of that type
2803
- const activeTab = drillTestType || 'source';
2804
- const sourceList = showOnlyUntestedInFeature ? untestedSrc : src;
2805
- const listFiles = activeTab === 'source' ? sourceList : tst.filter(m => m.testType === activeTab);
2806
- const isTestList = activeTab !== 'source';
2807
- const showUntestedToggle = activeTab === 'source';
2808
- const meta = TEST_TYPE_META[activeTab];
2809
- const listLabel = meta
2810
- ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
2811
- : `📁 Файлы фичи (${listFiles.length})`;
2812
-
2813
- const q = searchQuery.toLowerCase();
2814
- const filtered = q ? listFiles.filter(m =>
2815
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2816
- ) : listFiles;
2817
- const rowsRenderKey = `feature:${drillFeatureKey}:${activeTab}:${showOnlyUntestedInFeature ? 1 : 0}:${q}`;
2818
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2819
- const selectableSource = !isTestList && !!D.agent;
2820
- const visibleSourcePaths = selectableSource
2821
- ? visibleRows.map(m => m.relativePath.replace(/\\/g, '/'))
2822
- : [];
2823
- const selectedVisible = selectableSource
2824
- ? selectedFilesForFeature(drillFeatureKey, visibleRows.map(m => m.relativePath))
2825
- : [];
2826
- const selectedCount = selectedVisible.length;
2951
+ // Determine what list to show based on active tab
2952
+ // null or 'source' → source files; test type → test files of that type
2953
+ const activeTab = drillTestType || 'source';
2954
+ const sourceList = showOnlyUntestedInFeature ? untestedSrc : src;
2955
+ const listFiles = activeTab === 'source' ? sourceList : tst.filter(m => m.testType === activeTab);
2956
+ const isTestList = activeTab !== 'source';
2957
+ const showUntestedToggle = activeTab === 'source';
2958
+ const meta = TEST_TYPE_META[activeTab];
2959
+ const listLabel = meta
2960
+ ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
2961
+ : `📁 Файлы фичи (${listFiles.length})`;
2962
+
2963
+ const q = searchQuery.toLowerCase();
2964
+ const filtered = q ? listFiles.filter(m =>
2965
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2966
+ ) : listFiles;
2967
+ const rowsRenderKey = `feature:${drillFeatureKey}:${activeTab}:${showOnlyUntestedInFeature ? 1 : 0}:${q}`;
2968
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2969
+ const selectableSource = !isTestList && !!D.agent;
2970
+ const visibleSourcePaths = selectableSource
2971
+ ? visibleRows.map(m => m.relativePath.replace(/\\/g, '/'))
2972
+ : [];
2973
+ const selectedVisible = selectableSource
2974
+ ? selectedFilesForFeature(drillFeatureKey, visibleRows.map(m => m.relativePath))
2975
+ : [];
2976
+ const selectedCount = selectedVisible.length;
2827
2977
 
2828
2978
  c.innerHTML = `
2829
2979
  <div class="drill-header">
@@ -2849,51 +2999,51 @@ function renderFeatureDetail(c) {
2849
2999
  ${testTypeCard('integration', 'Integration', '🔗', '#58a6ff', integrationCount, activeTab === 'integration', drillFeatureKey, integrationFailed)}
2850
3000
  ${testTypeCard('e2e', 'E2E', '🎭', '#d2a8ff', e2eCount, activeTab === 'e2e', drillFeatureKey, e2eFailed)}
2851
3001
  </div>
2852
-
2853
- <div class="drill-section-label">${listLabel}</div>
2854
- ${showUntestedToggle ? `
2855
- <div class="feature-file-filters">
2856
- <label class="feature-filter-toggle">
2857
- <input type="checkbox" id="untestedOnlyToggle" ${showOnlyUntestedInFeature ? 'checked' : ''}>
2858
- <span>Только без тестов</span>
2859
- <span class="feature-filter-meta">(${untestedSrc.length})</span>
2860
- </label>
2861
- </div>` : ''}
2862
- ${selectableSource ? `
2863
- <div class="bulk-actions">
2864
- <span class="bulk-actions-count">Выбрано файлов: <b>${selectedCount}</b></span>
2865
- <button class="bulk-actions-btn" id="bulkSelectAllBtn" ${visibleSourcePaths.length === 0 ? 'disabled' : ''}>☑ Выбрать все (${visibleSourcePaths.length})</button>
3002
+
3003
+ <div class="drill-section-label">${listLabel}</div>
3004
+ ${showUntestedToggle ? `
3005
+ <div class="feature-file-filters">
3006
+ <label class="feature-filter-toggle">
3007
+ <input type="checkbox" id="untestedOnlyToggle" ${showOnlyUntestedInFeature ? 'checked' : ''}>
3008
+ <span>Только без тестов</span>
3009
+ <span class="feature-filter-meta">(${untestedSrc.length})</span>
3010
+ </label>
3011
+ </div>` : ''}
3012
+ ${selectableSource ? `
3013
+ <div class="bulk-actions">
3014
+ <span class="bulk-actions-count">Выбрано файлов: <b>${selectedCount}</b></span>
3015
+ <button class="bulk-actions-btn" id="bulkSelectAllBtn" ${visibleSourcePaths.length === 0 ? 'disabled' : ''}>☑ Выбрать все (${visibleSourcePaths.length})</button>
2866
3016
  <button class="bulk-actions-btn" id="bulkClearBtn" ${selectedCount === 0 ? 'disabled' : ''}>Снять выбор</button>
2867
3017
  <button class="bulk-actions-btn primary" id="bulkWriteTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>✍ Написать тесты для выбранных</button>
2868
3018
  <button class="bulk-actions-btn" id="bulkRefreshTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>↻ Актуализировать тесты</button>
2869
3019
  </div>` : ''}
2870
- <div class="file-rows" id="fileRows">
2871
- ${filtered.length === 0
2872
- ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
2873
- ${isTestList
2874
- ? 'Нет тестов этого типа для данной фичи'
2875
- : (showOnlyUntestedInFeature
2876
- ? 'Все файлы этой фичи уже покрыты тестами'
2877
- : 'Нет файлов — возможно паттерны в конфиге не совпадают')}
2878
- </div>`
2879
- : visibleRows.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
2880
- }
2881
- </div>
2882
- ${hasMoreRows ? `
2883
- <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)">
2884
- <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
2885
- <button class="bulk-actions-btn" id="fileRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
2886
- </div>` : ''}`;
2887
-
2888
- const untestedOnlyToggle = document.getElementById('untestedOnlyToggle');
2889
- if (untestedOnlyToggle) {
2890
- untestedOnlyToggle.onchange = (e) => {
2891
- showOnlyUntestedInFeature = !!e.target.checked;
2892
- renderContent();
2893
- };
2894
- }
2895
-
2896
- if (selectableSource) {
3020
+ <div class="file-rows" id="fileRows">
3021
+ ${filtered.length === 0
3022
+ ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
3023
+ ${isTestList
3024
+ ? 'Нет тестов этого типа для данной фичи'
3025
+ : (showOnlyUntestedInFeature
3026
+ ? 'Все файлы этой фичи уже покрыты тестами'
3027
+ : 'Нет файлов — возможно паттерны в конфиге не совпадают')}
3028
+ </div>`
3029
+ : visibleRows.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
3030
+ }
3031
+ </div>
3032
+ ${hasMoreRows ? `
3033
+ <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)">
3034
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
3035
+ <button class="bulk-actions-btn" id="fileRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3036
+ </div>` : ''}`;
3037
+
3038
+ const untestedOnlyToggle = document.getElementById('untestedOnlyToggle');
3039
+ if (untestedOnlyToggle) {
3040
+ untestedOnlyToggle.onchange = (e) => {
3041
+ showOnlyUntestedInFeature = !!e.target.checked;
3042
+ renderContent();
3043
+ };
3044
+ }
3045
+
3046
+ if (selectableSource) {
2897
3047
  const allVisibleSelected = visibleSourcePaths.length > 0 && visibleSourcePaths.every((p) => selectedSourceFiles.has(p));
2898
3048
  const bulkSelectAllBtn = document.getElementById('bulkSelectAllBtn');
2899
3049
  if (bulkSelectAllBtn) {
@@ -2928,17 +3078,17 @@ function renderFeatureDetail(c) {
2928
3078
  renderContent();
2929
3079
  };
2930
3080
  }
2931
- }
2932
-
2933
- const fileRowsLoadMoreBtn = document.getElementById('fileRowsLoadMoreBtn');
2934
- if (fileRowsLoadMoreBtn) {
2935
- fileRowsLoadMoreBtn.onclick = () => {
2936
- increaseFileRowsLimit();
2937
- renderContent();
2938
- };
2939
- }
2940
-
2941
- c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
3081
+ }
3082
+
3083
+ const fileRowsLoadMoreBtn = document.getElementById('fileRowsLoadMoreBtn');
3084
+ if (fileRowsLoadMoreBtn) {
3085
+ fileRowsLoadMoreBtn.onclick = () => {
3086
+ increaseFileRowsLimit();
3087
+ renderContent();
3088
+ };
3089
+ }
3090
+
3091
+ c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
2942
3092
  card.onclick = async () => {
2943
3093
  const type = card.dataset.testtype;
2944
3094
  drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
@@ -2947,11 +3097,11 @@ function renderFeatureDetail(c) {
2947
3097
  if (type === 'e2e') {
2948
3098
  await loadE2ePlan(drillFeatureKey);
2949
3099
  }
2950
- renderContent();
2951
- };
2952
- });
2953
- bindFileRowsClick(c);
2954
- }
3100
+ renderContent();
3101
+ };
3102
+ });
3103
+ bindFileRowsClick(c);
3104
+ }
2955
3105
 
2956
3106
  const TEST_TYPE_META = {
2957
3107
  unit: { label: 'Unit', icon: '🧪', color: '#e3b341', desc: 'Изолированные тесты функций и модулей' },
@@ -2970,12 +3120,12 @@ function renderTestTypeDetail(c) {
2970
3120
  m.featureKeys && m.featureKeys.includes(drillFeatureKey)
2971
3121
  );
2972
3122
 
2973
- const q = searchQuery.toLowerCase();
2974
- const filtered = q ? tests.filter(m =>
2975
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2976
- ) : tests;
2977
- const rowsRenderKey = `tests:${drillFeatureKey}:${drillTestType}:${q}`;
2978
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3123
+ const q = searchQuery.toLowerCase();
3124
+ const filtered = q ? tests.filter(m =>
3125
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3126
+ ) : tests;
3127
+ const rowsRenderKey = `tests:${drillFeatureKey}:${drillTestType}:${q}`;
3128
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2979
3129
 
2980
3130
  c.innerHTML = `
2981
3131
  <div class="drill-header">
@@ -2990,32 +3140,32 @@ function renderTestTypeDetail(c) {
2990
3140
  </div>
2991
3141
  <div style="font-size:12px;color:var(--dim);margin-bottom:16px">${meta.desc}</div>
2992
3142
 
2993
- <div class="file-rows" id="fileRows">
2994
- ${filtered.length === 0
2995
- ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
2996
- <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
2997
- <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
2998
- <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
2999
- </div>`
3000
- : visibleRows.map(m => fileRow(m, true)).join('')
3001
- }
3002
- </div>
3003
- ${hasMoreRows ? `
3004
- <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)">
3005
- <span>Показано ${visibleRows.length} из ${filtered.length} тест-файлов</span>
3006
- <button class="bulk-actions-btn" id="testRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3007
- </div>` : ''}`;
3008
-
3009
- const testRowsLoadMoreBtn = document.getElementById('testRowsLoadMoreBtn');
3010
- if (testRowsLoadMoreBtn) {
3011
- testRowsLoadMoreBtn.onclick = () => {
3012
- increaseFileRowsLimit();
3013
- renderContent();
3014
- };
3015
- }
3016
-
3017
- bindFileRowsClick(c);
3018
- }
3143
+ <div class="file-rows" id="fileRows">
3144
+ ${filtered.length === 0
3145
+ ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
3146
+ <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
3147
+ <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
3148
+ <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
3149
+ </div>`
3150
+ : visibleRows.map(m => fileRow(m, true)).join('')
3151
+ }
3152
+ </div>
3153
+ ${hasMoreRows ? `
3154
+ <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)">
3155
+ <span>Показано ${visibleRows.length} из ${filtered.length} тест-файлов</span>
3156
+ <button class="bulk-actions-btn" id="testRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3157
+ </div>` : ''}`;
3158
+
3159
+ const testRowsLoadMoreBtn = document.getElementById('testRowsLoadMoreBtn');
3160
+ if (testRowsLoadMoreBtn) {
3161
+ testRowsLoadMoreBtn.onclick = () => {
3162
+ increaseFileRowsLimit();
3163
+ renderContent();
3164
+ };
3165
+ }
3166
+
3167
+ bindFileRowsClick(c);
3168
+ }
3019
3169
 
3020
3170
  function renderUnmappedDetail(c) {
3021
3171
  const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
@@ -3023,12 +3173,12 @@ function renderUnmappedDetail(c) {
3023
3173
  m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
3024
3174
  );
3025
3175
 
3026
- const q = searchQuery.toLowerCase();
3027
- const filtered = q ? unmappedSrc.filter(m =>
3028
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3029
- ) : unmappedSrc;
3030
- const rowsRenderKey = `unmapped:${q}`;
3031
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3176
+ const q = searchQuery.toLowerCase();
3177
+ const filtered = q ? unmappedSrc.filter(m =>
3178
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3179
+ ) : unmappedSrc;
3180
+ const rowsRenderKey = `unmapped:${q}`;
3181
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3032
3182
 
3033
3183
  // Build prompt text
3034
3184
  const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
@@ -3069,44 +3219,44 @@ function renderUnmappedDetail(c) {
3069
3219
  border-radius:6px; color:var(--blue); font-size:12px; cursor:pointer;
3070
3220
  ">📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)</button>
3071
3221
  </div>
3072
- <div class="file-rows" id="fileRows">
3073
- ${filtered.length === 0
3074
- ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
3075
- : visibleRows.map(m => fileRow(m)).join('')
3076
- }
3077
- </div>
3078
- ${hasMoreRows ? `
3079
- <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)">
3080
- <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
3081
- <button class="bulk-actions-btn" id="unmappedRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3082
- </div>` : ''}`;
3222
+ <div class="file-rows" id="fileRows">
3223
+ ${filtered.length === 0
3224
+ ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
3225
+ : visibleRows.map(m => fileRow(m)).join('')
3226
+ }
3227
+ </div>
3228
+ ${hasMoreRows ? `
3229
+ <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)">
3230
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
3231
+ <button class="bulk-actions-btn" id="unmappedRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3232
+ </div>` : ''}`;
3083
3233
 
3084
3234
  const runAgentUnmappedBtn = document.getElementById('runAgentUnmapped');
3085
3235
  if (runAgentUnmappedBtn) {
3086
3236
  runAgentUnmappedBtn.onclick = () => runAgentTask('map-unmapped');
3087
3237
  }
3088
3238
 
3089
- document.getElementById('copyUnmappedDrill').onclick = function() {
3239
+ document.getElementById('copyUnmappedDrill').onclick = function() {
3090
3240
  navigator.clipboard.writeText(promptText).then(() => {
3091
3241
  this.textContent = '✅ Скопировано!';
3092
3242
  this.style.color = 'var(--green)';
3093
3243
  setTimeout(() => {
3094
3244
  this.textContent = `📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)`;
3095
3245
  this.style.color = 'var(--blue)';
3096
- }, 3000);
3097
- });
3098
- };
3099
-
3100
- const unmappedRowsLoadMoreBtn = document.getElementById('unmappedRowsLoadMoreBtn');
3101
- if (unmappedRowsLoadMoreBtn) {
3102
- unmappedRowsLoadMoreBtn.onclick = () => {
3103
- increaseFileRowsLimit();
3104
- renderContent();
3105
- };
3106
- }
3107
-
3108
- bindFileRowsClick(c);
3109
- }
3246
+ }, 3000);
3247
+ });
3248
+ };
3249
+
3250
+ const unmappedRowsLoadMoreBtn = document.getElementById('unmappedRowsLoadMoreBtn');
3251
+ if (unmappedRowsLoadMoreBtn) {
3252
+ unmappedRowsLoadMoreBtn.onclick = () => {
3253
+ increaseFileRowsLimit();
3254
+ renderContent();
3255
+ };
3256
+ }
3257
+
3258
+ bindFileRowsClick(c);
3259
+ }
3110
3260
 
3111
3261
  function fileRow(m, isTest = false, featureKey = null, selectable = false) {
3112
3262
  const relPath = m.relativePath.replace(/\\/g, '/');
@@ -3196,33 +3346,33 @@ function toggleSourceSelection(relPath, checked) {
3196
3346
  renderContent();
3197
3347
  }
3198
3348
 
3199
- function renderModuleGrid(c) {
3200
- const q = searchQuery.toLowerCase();
3201
- const list = D.modules.filter(m => {
3202
- if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
3203
- if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
3204
- return true;
3205
- });
3206
- const typeKey = [...activeTypes].sort().join(',');
3207
- const rowsRenderKey = `modules:${contextMode}:${view}:${typeKey}:${q}`;
3208
- const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(list, rowsRenderKey);
3209
-
3210
- if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
3211
-
3212
- c.innerHTML = `
3213
- <div class="module-grid" id="modGrid"></div>
3214
- ${hasMoreRows ? `
3215
- <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)">
3216
- <span>Показано ${visibleRows.length} из ${list.length} модулей</span>
3217
- <button class="bulk-actions-btn" id="moduleRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3218
- </div>` : ''}
3219
- `;
3220
- const grid = document.getElementById('modGrid');
3221
-
3222
- visibleRows.forEach(m => {
3223
- const cov = m.coverage?.lines;
3224
- const isActive = activePanelKey === m.id;
3225
- const card = document.createElement('div');
3349
+ function renderModuleGrid(c) {
3350
+ const q = searchQuery.toLowerCase();
3351
+ const list = D.modules.filter(m => {
3352
+ if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
3353
+ if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
3354
+ return true;
3355
+ });
3356
+ const typeKey = [...activeTypes].sort().join(',');
3357
+ const rowsRenderKey = `modules:${contextMode}:${view}:${typeKey}:${q}`;
3358
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(list, rowsRenderKey);
3359
+
3360
+ if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
3361
+
3362
+ c.innerHTML = `
3363
+ <div class="module-grid" id="modGrid"></div>
3364
+ ${hasMoreRows ? `
3365
+ <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)">
3366
+ <span>Показано ${visibleRows.length} из ${list.length} модулей</span>
3367
+ <button class="bulk-actions-btn" id="moduleRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3368
+ </div>` : ''}
3369
+ `;
3370
+ const grid = document.getElementById('modGrid');
3371
+
3372
+ visibleRows.forEach(m => {
3373
+ const cov = m.coverage?.lines;
3374
+ const isActive = activePanelKey === m.id;
3375
+ const card = document.createElement('div');
3226
3376
  card.className = 'module-card' + (isActive ? ' active' : '');
3227
3377
  card.innerHTML = `
3228
3378
  <div class="module-name">${m.name}</div>
@@ -3234,18 +3384,18 @@ function renderModuleGrid(c) {
3234
3384
  <span class="badge ${m.hasTests ? 'badge-green' : 'badge-red'}">${m.hasTests ? '✓' : '✗'}</span>
3235
3385
  </div>
3236
3386
  ${cov != null ? `<div class="cov-bar"><div class="cov-fill" style="width:${cov}%;background:${covColor(cov)}"></div></div>` : ''}`;
3237
- card.onclick = () => openModulePanel(m);
3238
- grid.appendChild(card);
3239
- });
3240
-
3241
- const moduleRowsLoadMoreBtn = document.getElementById('moduleRowsLoadMoreBtn');
3242
- if (moduleRowsLoadMoreBtn) {
3243
- moduleRowsLoadMoreBtn.onclick = () => {
3244
- increaseFileRowsLimit();
3245
- renderContent();
3246
- };
3247
- }
3248
- }
3387
+ card.onclick = () => openModulePanel(m);
3388
+ grid.appendChild(card);
3389
+ });
3390
+
3391
+ const moduleRowsLoadMoreBtn = document.getElementById('moduleRowsLoadMoreBtn');
3392
+ if (moduleRowsLoadMoreBtn) {
3393
+ moduleRowsLoadMoreBtn.onclick = () => {
3394
+ increaseFileRowsLimit();
3395
+ renderContent();
3396
+ };
3397
+ }
3398
+ }
3249
3399
 
3250
3400
  // ─── Panels ───────────────────────────────────────────────────────────────────
3251
3401
  function openFeaturePanel(key) {
@@ -3456,29 +3606,29 @@ function openUnmappedPanel(files, infraFiles) {
3456
3606
  document.getElementById('panel').classList.add('open');
3457
3607
  }
3458
3608
 
3459
- function closePanel() {
3460
- activePanelKey = null;
3461
- document.getElementById('panel').classList.remove('open');
3462
- renderContent();
3463
- }
3464
-
3465
- function applyTerminalFiltersFromUi() {
3466
- terminalSearchQuery = document.getElementById('agentSearchInput')?.value || '';
3467
- terminalSearchErrorsOnly = !!document.getElementById('agentSearchErrorsOnly')?.checked;
3468
- terminalSearchCurrentRunOnly = !!document.getElementById('agentSearchCurrentRunOnly')?.checked;
3469
- terminalSearchRegex = !!document.getElementById('agentSearchRegex')?.checked;
3470
- renderActiveSession();
3471
- }
3472
-
3473
- ['agentSearchInput', 'agentSearchErrorsOnly', 'agentSearchCurrentRunOnly', 'agentSearchRegex'].forEach((id) => {
3474
- const el = document.getElementById(id);
3475
- if (!el) return;
3476
- const eventName = id === 'agentSearchInput' ? 'input' : 'change';
3477
- el.addEventListener(eventName, applyTerminalFiltersFromUi);
3478
- });
3479
-
3480
- // ─── Events ───────────────────────────────────────────────────────────────────
3481
- document.querySelectorAll('.view-tab').forEach(tab => {
3609
+ function closePanel() {
3610
+ activePanelKey = null;
3611
+ document.getElementById('panel').classList.remove('open');
3612
+ renderContent();
3613
+ }
3614
+
3615
+ function applyTerminalFiltersFromUi() {
3616
+ terminalSearchQuery = document.getElementById('agentSearchInput')?.value || '';
3617
+ terminalSearchErrorsOnly = !!document.getElementById('agentSearchErrorsOnly')?.checked;
3618
+ terminalSearchCurrentRunOnly = !!document.getElementById('agentSearchCurrentRunOnly')?.checked;
3619
+ terminalSearchRegex = !!document.getElementById('agentSearchRegex')?.checked;
3620
+ renderActiveSession();
3621
+ }
3622
+
3623
+ ['agentSearchInput', 'agentSearchErrorsOnly', 'agentSearchCurrentRunOnly', 'agentSearchRegex'].forEach((id) => {
3624
+ const el = document.getElementById(id);
3625
+ if (!el) return;
3626
+ const eventName = id === 'agentSearchInput' ? 'input' : 'change';
3627
+ el.addEventListener(eventName, applyTerminalFiltersFromUi);
3628
+ });
3629
+
3630
+ // ─── Events ───────────────────────────────────────────────────────────────────
3631
+ document.querySelectorAll('.view-tab').forEach(tab => {
3482
3632
  tab.onclick = () => {
3483
3633
  if (contextMode !== 'qa') return;
3484
3634
  if (tab.classList.contains('disabled')) return;
@@ -3528,199 +3678,199 @@ window.addEventListener('popstate', () => {
3528
3678
  });
3529
3679
 
3530
3680
  // ─── Live reload ──────────────────────────────────────────────────────────────
3531
- function setLiveDot(color, title) {
3532
- const dot = document.getElementById('liveDot');
3533
- dot.style.background = color;
3534
- dot.title = title;
3535
- }
3536
-
3537
- function scheduleRefreshData(delayMs = 120) {
3538
- if (refreshDataTimer) return;
3539
- refreshDataTimer = setTimeout(() => {
3540
- refreshDataTimer = null;
3541
- void refreshData();
3542
- }, delayMs);
3543
- }
3544
-
3545
- async function refreshData() {
3546
- if (refreshDataInFlight) {
3547
- refreshDataQueued = true;
3548
- return;
3549
- }
3550
- refreshDataInFlight = true;
3551
- try {
3552
- const res = await fetch('/api/data');
3553
- D = await res.json();
3681
+ function setLiveDot(color, title) {
3682
+ const dot = document.getElementById('liveDot');
3683
+ dot.style.background = color;
3684
+ dot.title = title;
3685
+ }
3686
+
3687
+ function scheduleRefreshData(delayMs = 120) {
3688
+ if (refreshDataTimer) return;
3689
+ refreshDataTimer = setTimeout(() => {
3690
+ refreshDataTimer = null;
3691
+ void refreshData();
3692
+ }, delayMs);
3693
+ }
3694
+
3695
+ async function refreshData() {
3696
+ if (refreshDataInFlight) {
3697
+ refreshDataQueued = true;
3698
+ return;
3699
+ }
3700
+ refreshDataInFlight = true;
3701
+ try {
3702
+ const res = await fetch('/api/data');
3703
+ D = await res.json();
3554
3704
 
3555
3705
  // Update header timestamp
3556
3706
  document.getElementById('scannedAt').textContent =
3557
3707
  new Date(D.scannedAt).toLocaleTimeString();
3558
-
3559
- renderStats();
3560
- renderSidebar();
3561
- updateAgentBtn();
3562
- updateAgentRightsInfo();
3563
-
3564
- // Re-render drill-down or re-open panel
3565
- const panelOpen = document.getElementById('panel').classList.contains('open');
3708
+
3709
+ renderStats();
3710
+ renderSidebar();
3711
+ updateAgentBtn();
3712
+ updateAgentRightsInfo();
3713
+
3714
+ // Re-render drill-down or re-open panel
3715
+ const panelOpen = document.getElementById('panel').classList.contains('open');
3566
3716
  if (panelOpen && activePanelKey) {
3567
3717
  if (drillFeatureKey === '__unmapped__') {
3568
3718
  renderContent(); // already routes to renderUnmappedDetail
3569
3719
  } else if (view === 'features' && D.features) {
3570
3720
  openFeaturePanel(activePanelKey);
3571
- } else {
3572
- const m = D.modules.find(m => m.id === activePanelKey);
3573
- if (m) openModulePanel(m);
3574
- else closePanel();
3575
- }
3576
- } else {
3577
- renderContent();
3578
- }
3579
-
3580
- // Brief green flash on the dot to signal fresh data
3581
- setLiveDot('#3fb950', 'Live — обновлено только что');
3582
- setTimeout(() => setLiveDot('#3fb950', 'Live — автообновление включено'), 1500);
3583
- void loadAgentState();
3584
- } catch (err) {
3585
- console.error('Refresh failed:', err);
3586
- } finally {
3587
- refreshDataInFlight = false;
3588
- if (refreshDataQueued) {
3589
- refreshDataQueued = false;
3590
- scheduleRefreshData(120);
3591
- }
3592
- }
3593
- }
3594
-
3595
- function connectSSE() {
3596
- const es = new EventSource('/api/events');
3597
-
3598
- es.onopen = () => {
3599
- setLiveDot('#3fb950', 'Live — автообновление включено');
3600
- void loadAgentState();
3601
- };
3602
-
3603
- es.addEventListener('data-updated', () => {
3604
- setLiveDot('#e3b341', 'Обновляю данные…');
3605
- scheduleRefreshData();
3606
- });
3607
-
3608
- es.addEventListener('agent-queue-updated', (e) => {
3609
- const payload = JSON.parse(e.data || '{}');
3610
- agentQueueState = Array.isArray(payload.queue) ? payload.queue : [];
3611
- refreshPathActivityFromState();
3612
- updateQueueBadge(agentQueueState.length);
3613
- renderContent();
3614
- });
3615
-
3616
- es.addEventListener('agent-run-created', (e) => {
3617
- const { run } = JSON.parse(e.data || '{}');
3618
- upsertRunState(run);
3619
- });
3620
-
3621
- es.addEventListener('agent-run-updated', (e) => {
3622
- const { run } = JSON.parse(e.data || '{}');
3623
- if (!run) return;
3624
- if (run.runId && agentQueueState.length > 0) {
3625
- agentQueueState = agentQueueState.filter((q) => q.runId !== run.runId || run.phase === 'queued');
3626
- updateQueueBadge(agentQueueState.length);
3627
- }
3628
- if (['starting', 'running', 'validating'].includes(run.phase)) {
3629
- agentActiveRun = run;
3630
- const startedPaths = getTaskFilePaths(run.task, run.featureKey, run.filePath, run.selectedFilePaths);
3631
- startedPaths.forEach((p) => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3632
- renderContent();
3633
- }
3634
- updateSessionFromRun(run);
3635
- });
3636
-
3637
- es.addEventListener('agent-run-finished', (e) => {
3638
- const { run } = JSON.parse(e.data || '{}');
3639
- if (!run) return;
3640
- updateSessionFromRun(run);
3641
- refreshPathActivityFromState();
3642
- renderContent();
3643
- });
3644
-
3645
- es.addEventListener('agent-queued', (e) => {
3646
- const { runId, queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3647
- updateQueueBadge(queueLength);
3648
- document.getElementById('agentPanel').classList.add('open');
3649
- document.getElementById('termBtn').classList.add('term-active');
3650
- // Track queued paths for spinner
3651
- getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
3652
- renderContent();
3653
- // Append queue notification to the currently running session (or active)
3654
- const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3655
- if (targetId) {
3656
- appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
3657
- }
3658
- void loadAgentState();
3659
- });
3660
-
3661
- es.addEventListener('agent-started', (e) => {
3662
- setAgentRunning(true);
3663
- const { runId, title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3664
- updateQueueBadge(queueLength);
3665
- currentRunId = runId || null;
3666
- // Move paths from queued → running (current task)
3667
- const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
3668
- startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3669
- renderContent();
3670
- // Close previous session (queue case: agent-done not fired between tasks)
3671
- if (runningSessionId) {
3672
- const prev = consoleSessions.find(s => s.id === runningSessionId);
3673
- if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
3674
- }
3675
- const id = runId ? ensureSessionForRun({ runId, title, phase: 'running' }, true) : createSession(title);
3676
- runningSessionId = id;
3677
- document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
3678
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
3679
- });
3680
-
3681
- es.addEventListener('agent-output', (e) => {
3682
- const { runId, line, isError, isDim } = JSON.parse(e.data);
3683
- let targetId = runningSessionId;
3684
- if (runId) {
3685
- targetId = runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'running' });
3686
- if (targetId) runningSessionId = targetId;
3687
- }
3688
- if (targetId) {
3689
- appendToSession(targetId, line, !!isError, !!isDim);
3690
- if (activeSessionId === targetId) {
3691
- document.getElementById('agentPanelStatus').textContent = 'работает…';
3692
- }
3693
- } else {
3694
- appendTerminalLine(line, !!isError, !!isDim);
3695
- }
3721
+ } else {
3722
+ const m = D.modules.find(m => m.id === activePanelKey);
3723
+ if (m) openModulePanel(m);
3724
+ else closePanel();
3725
+ }
3726
+ } else {
3727
+ renderContent();
3728
+ }
3729
+
3730
+ // Brief green flash on the dot to signal fresh data
3731
+ setLiveDot('#3fb950', 'Live — обновлено только что');
3732
+ setTimeout(() => setLiveDot('#3fb950', 'Live — автообновление включено'), 1500);
3733
+ void loadAgentState();
3734
+ } catch (err) {
3735
+ console.error('Refresh failed:', err);
3736
+ } finally {
3737
+ refreshDataInFlight = false;
3738
+ if (refreshDataQueued) {
3739
+ refreshDataQueued = false;
3740
+ scheduleRefreshData(120);
3741
+ }
3742
+ }
3743
+ }
3744
+
3745
+ function connectSSE() {
3746
+ const es = new EventSource('/api/events');
3747
+
3748
+ es.onopen = () => {
3749
+ setLiveDot('#3fb950', 'Live — автообновление включено');
3750
+ void loadAgentState();
3751
+ };
3752
+
3753
+ es.addEventListener('data-updated', () => {
3754
+ setLiveDot('#e3b341', 'Обновляю данные…');
3755
+ scheduleRefreshData();
3756
+ });
3757
+
3758
+ es.addEventListener('agent-queue-updated', (e) => {
3759
+ const payload = JSON.parse(e.data || '{}');
3760
+ agentQueueState = Array.isArray(payload.queue) ? payload.queue : [];
3761
+ refreshPathActivityFromState();
3762
+ updateQueueBadge(agentQueueState.length);
3763
+ renderContent();
3696
3764
  });
3697
3765
 
3698
- es.addEventListener('agent-done', () => {
3699
- setAgentRunning(false);
3700
- if (agentQueueState.length === 0) updateQueueBadge(0);
3701
- agentRunningPaths.clear();
3702
- if (runningSessionId) {
3703
- updateSessionStatus(runningSessionId, 'ok');
3704
- if (activeSessionId === runningSessionId) {
3705
- document.getElementById('agentPanelStatus').textContent = '✅ готово';
3706
- }
3766
+ es.addEventListener('agent-run-created', (e) => {
3767
+ const { run } = JSON.parse(e.data || '{}');
3768
+ upsertRunState(run);
3769
+ });
3770
+
3771
+ es.addEventListener('agent-run-updated', (e) => {
3772
+ const { run } = JSON.parse(e.data || '{}');
3773
+ if (!run) return;
3774
+ if (run.runId && agentQueueState.length > 0) {
3775
+ agentQueueState = agentQueueState.filter((q) => q.runId !== run.runId || run.phase === 'queued');
3776
+ updateQueueBadge(agentQueueState.length);
3777
+ }
3778
+ if (['starting', 'running', 'validating'].includes(run.phase)) {
3779
+ agentActiveRun = run;
3780
+ const startedPaths = getTaskFilePaths(run.task, run.featureKey, run.filePath, run.selectedFilePaths);
3781
+ startedPaths.forEach((p) => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3782
+ renderContent();
3783
+ }
3784
+ updateSessionFromRun(run);
3785
+ });
3786
+
3787
+ es.addEventListener('agent-run-finished', (e) => {
3788
+ const { run } = JSON.parse(e.data || '{}');
3789
+ if (!run) return;
3790
+ updateSessionFromRun(run);
3791
+ refreshPathActivityFromState();
3792
+ renderContent();
3793
+ });
3794
+
3795
+ es.addEventListener('agent-queued', (e) => {
3796
+ const { runId, queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3797
+ updateQueueBadge(queueLength);
3798
+ document.getElementById('agentPanel').classList.add('open');
3799
+ document.getElementById('termBtn').classList.add('term-active');
3800
+ // Track queued paths for spinner
3801
+ getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
3802
+ renderContent();
3803
+ // Append queue notification to the currently running session (or active)
3804
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3805
+ if (targetId) {
3806
+ appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
3807
+ }
3808
+ void loadAgentState();
3809
+ });
3810
+
3811
+ es.addEventListener('agent-started', (e) => {
3812
+ setAgentRunning(true);
3813
+ const { runId, title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3814
+ updateQueueBadge(queueLength);
3815
+ currentRunId = runId || null;
3816
+ // Move paths from queued → running (current task)
3817
+ const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
3818
+ startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3819
+ renderContent();
3820
+ // Close previous session (queue case: agent-done not fired between tasks)
3821
+ if (runningSessionId) {
3822
+ const prev = consoleSessions.find(s => s.id === runningSessionId);
3823
+ if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
3824
+ }
3825
+ const id = runId ? ensureSessionForRun({ runId, title, phase: 'running' }, true) : createSession(title);
3826
+ runningSessionId = id;
3827
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
3828
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
3829
+ });
3830
+
3831
+ es.addEventListener('agent-output', (e) => {
3832
+ const { runId, line, isError, isDim } = JSON.parse(e.data);
3833
+ let targetId = runningSessionId;
3834
+ if (runId) {
3835
+ targetId = runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'running' });
3836
+ if (targetId) runningSessionId = targetId;
3837
+ }
3838
+ if (targetId) {
3839
+ appendToSession(targetId, line, !!isError, !!isDim);
3840
+ if (activeSessionId === targetId) {
3841
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
3842
+ }
3843
+ } else {
3844
+ appendTerminalLine(line, !!isError, !!isDim);
3845
+ }
3846
+ });
3847
+
3848
+ es.addEventListener('agent-done', () => {
3849
+ setAgentRunning(false);
3850
+ if (agentQueueState.length === 0) updateQueueBadge(0);
3851
+ agentRunningPaths.clear();
3852
+ if (runningSessionId) {
3853
+ updateSessionStatus(runningSessionId, 'ok');
3854
+ if (activeSessionId === runningSessionId) {
3855
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
3856
+ }
3707
3857
  runningSessionId = null;
3708
3858
  }
3709
3859
  renderContent();
3710
3860
  });
3711
3861
 
3712
- es.addEventListener('agent-summary', (e) => {
3713
- const {
3714
- runId,
3715
- validationStats = null,
3716
- passed = 0, failed = 0, files = [],
3717
- testedFileCount = files.length,
3718
- passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
3719
- failedFileCount = failed > 0 ? 1 : 0,
3720
- autoFixQueued = false,
3721
- } = JSON.parse(e.data);
3722
- const coverageOk = !validationStats || ((validationStats.notCovered || 0) === 0 && (validationStats.blocked || 0) === 0);
3723
- const allOk = failed === 0 && coverageOk;
3862
+ es.addEventListener('agent-summary', (e) => {
3863
+ const {
3864
+ runId,
3865
+ validationStats = null,
3866
+ passed = 0, failed = 0, files = [],
3867
+ testedFileCount = files.length,
3868
+ passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
3869
+ failedFileCount = failed > 0 ? 1 : 0,
3870
+ autoFixQueued = false,
3871
+ } = JSON.parse(e.data);
3872
+ const coverageOk = !validationStats || ((validationStats.notCovered || 0) === 0 && (validationStats.blocked || 0) === 0);
3873
+ const allOk = failed === 0 && coverageOk;
3724
3874
  const box = document.createElement('div');
3725
3875
  box.style.cssText = `
3726
3876
  margin: 10px 0 4px;
@@ -3737,33 +3887,33 @@ function connectSSE() {
3737
3887
  <div style="font-size:11px;color:var(--muted);margin-top:4px">
3738
3888
  Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}
3739
3889
  </div>
3740
- <div style="font-size:11px;color:var(--muted);margin-top:2px">
3741
- Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
3742
- </div>
3743
- ${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>` : ''}
3744
- ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
3745
- ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
3746
- `;
3747
- const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3748
- if (targetId) {
3749
- appendToSession(targetId, box);
3750
- // Mark session status from test results immediately
3751
- if (runningSessionId === targetId) {
3752
- updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
3890
+ <div style="font-size:11px;color:var(--muted);margin-top:2px">
3891
+ Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
3892
+ </div>
3893
+ ${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>` : ''}
3894
+ ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
3895
+ ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
3896
+ `;
3897
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3898
+ if (targetId) {
3899
+ appendToSession(targetId, box);
3900
+ // Mark session status from test results immediately
3901
+ if (runningSessionId === targetId) {
3902
+ updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
3753
3903
  }
3754
3904
  }
3755
3905
  });
3756
3906
 
3757
- es.addEventListener('agent-error', (e) => {
3758
- const { runId, message, authRequired, notInstalled } = JSON.parse(e.data);
3759
- if (!runId) setAgentRunning(false);
3760
- agentRunningPaths.clear();
3761
- agentQueuedPaths.clear();
3762
- renderContent();
3763
-
3764
- // Build error node (plain text or auth/install box)
3765
- let node = null;
3766
- if (authRequired || notInstalled) {
3907
+ es.addEventListener('agent-error', (e) => {
3908
+ const { runId, message, authRequired, notInstalled } = JSON.parse(e.data);
3909
+ if (!runId) setAgentRunning(false);
3910
+ agentRunningPaths.clear();
3911
+ agentQueuedPaths.clear();
3912
+ renderContent();
3913
+
3914
+ // Build error node (plain text or auth/install box)
3915
+ let node = null;
3916
+ if (authRequired || notInstalled) {
3767
3917
  node = document.createElement('div');
3768
3918
  node.style.cssText = 'margin:10px 0 4px;padding:10px 14px;border-radius:8px;border:1px solid var(--red);background:#2a0d0d;font-family:inherit;';
3769
3919
  node.innerHTML = `
@@ -3771,14 +3921,14 @@ function connectSSE() {
3771
3921
  ${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>` : ''}
3772
3922
  ${notInstalled ? `<div style="margin-top:6px;font-size:11px;color:var(--dim)">После установки перезапусти viberadar</div>` : ''}
3773
3923
  `;
3774
- }
3775
-
3776
- // If no session exists yet (startup check fires before any run), create one
3777
- const targetId = (runId ? (runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'failed' })) : runningSessionId) || (() => {
3778
- if (authRequired || notInstalled) {
3779
- const id = createSession('⚠️ Проверка агента', 'error');
3780
- return id;
3781
- }
3924
+ }
3925
+
3926
+ // If no session exists yet (startup check fires before any run), create one
3927
+ const targetId = (runId ? (runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'failed' })) : runningSessionId) || (() => {
3928
+ if (authRequired || notInstalled) {
3929
+ const id = createSession('⚠️ Проверка агента', 'error');
3930
+ return id;
3931
+ }
3782
3932
  return activeSessionId;
3783
3933
  })();
3784
3934
 
@@ -3789,12 +3939,12 @@ function connectSSE() {
3789
3939
  appendToSession(targetId, '❌ ' + (message || 'Ошибка агента'), true);
3790
3940
  }
3791
3941
  updateSessionStatus(targetId, 'error');
3792
- if (activeSessionId === targetId) {
3793
- document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3794
- }
3795
- }
3796
- if (!runId && runningSessionId) runningSessionId = null;
3797
- });
3942
+ if (activeSessionId === targetId) {
3943
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3944
+ }
3945
+ }
3946
+ if (!runId && runningSessionId) runningSessionId = null;
3947
+ });
3798
3948
 
3799
3949
  es.addEventListener('tests-started', (e) => {
3800
3950
  const { testType, count } = JSON.parse(e.data);
@@ -3862,24 +4012,24 @@ function connectSSE() {
3862
4012
  }
3863
4013
  });
3864
4014
 
3865
- es.onerror = () => {
3866
- setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
3867
- es.close();
3868
- setTimeout(connectSSE, 3000);
3869
- };
3870
- }
3871
-
3872
- init().then(async () => {
3873
- restoreSessions();
3874
- await loadAgentState();
3875
- connectSSE();
3876
- applyTerminalFiltersFromUi();
3877
- updateToolbarButtonsState();
3878
- // Sync content padding with terminal panel open state automatically
3879
- new MutationObserver(() => {
3880
- const isOpen = document.getElementById('agentPanel').classList.contains('open');
3881
- document.getElementById('content').classList.toggle('panel-open', isOpen);
3882
- }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
4015
+ es.onerror = () => {
4016
+ setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
4017
+ es.close();
4018
+ setTimeout(connectSSE, 3000);
4019
+ };
4020
+ }
4021
+
4022
+ init().then(async () => {
4023
+ restoreSessions();
4024
+ await loadAgentState();
4025
+ connectSSE();
4026
+ applyTerminalFiltersFromUi();
4027
+ updateToolbarButtonsState();
4028
+ // Sync content padding with terminal panel open state automatically
4029
+ new MutationObserver(() => {
4030
+ const isOpen = document.getElementById('agentPanel').classList.contains('open');
4031
+ document.getElementById('content').classList.toggle('panel-open', isOpen);
4032
+ }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
3883
4033
  });
3884
4034
  </script>
3885
4035
  </body>