viberadar 0.3.64 → 0.3.65

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.6fr .5fr .7fr .5fr 1.4fr .8fr; gap:8px; 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; }
@@ -675,12 +675,12 @@
675
675
  .file-agent-spinner.running { border: 2px solid var(--yellow); border-top-color: transparent; animation: spin 0.7s linear infinite; }
676
676
  .file-agent-spinner.queued { border: 2px solid var(--dim); border-top-color: transparent; animation: spin 1.5s linear infinite; }
677
677
  @keyframes spin { to { transform: rotate(360deg); } }
678
- .file-row-errors {
679
- padding: 4px 10px 6px 32px;
680
- display: flex; flex-direction: column; gap: 3px;
681
- content-visibility: auto;
682
- contain-intrinsic-size: 46px;
683
- }
678
+ .file-row-errors {
679
+ padding: 4px 10px 6px 32px;
680
+ display: flex; flex-direction: column; gap: 3px;
681
+ content-visibility: auto;
682
+ contain-intrinsic-size: 46px;
683
+ }
684
684
  .err-item { display: flex; flex-direction: column; gap: 1px; }
685
685
  .err-name { font-size: 11px; color: var(--muted); }
686
686
  .err-msg { font-size: 10px; color: var(--red); font-family: monospace; opacity: 0.85; }
@@ -769,92 +769,92 @@
769
769
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
770
770
  }
771
771
  .agent-panel-cancel:hover { background: var(--yellow); color: #000; }
772
- .agent-queue-badge {
773
- font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
774
- border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
775
- }
776
- .agent-toolbar {
777
- display: flex;
778
- align-items: center;
779
- gap: 8px;
780
- padding: 6px 12px;
781
- background: #0b1018;
782
- border-bottom: 1px solid var(--border);
783
- flex-wrap: wrap;
784
- }
785
- .agent-toolbar input[type="text"] {
786
- min-width: 220px;
787
- padding: 4px 8px;
788
- border-radius: 4px;
789
- border: 1px solid var(--border);
790
- background: #0b1220;
791
- color: var(--text);
792
- font-size: 11px;
793
- }
794
- .agent-toolbar label {
795
- display: inline-flex;
796
- align-items: center;
797
- gap: 5px;
798
- font-size: 11px;
799
- color: var(--muted);
800
- user-select: none;
801
- }
802
- .agent-toolbar-btn {
803
- background: none;
804
- border: 1px solid var(--border);
805
- color: var(--muted);
806
- cursor: pointer;
807
- font-size: 11px;
808
- padding: 3px 8px;
809
- border-radius: 4px;
810
- }
811
- .agent-toolbar-btn:hover { color: var(--text); border-color: var(--blue); }
812
- .agent-toolbar-btn:disabled {
813
- opacity: 0.45;
814
- cursor: not-allowed;
815
- border-color: var(--border);
816
- color: var(--dim);
817
- }
818
- .agent-toolbar-meta { font-size: 11px; color: var(--dim); margin-left: auto; }
819
- .agent-queue-panel {
820
- display: none;
821
- border-bottom: 1px solid var(--border);
822
- background: #090f18;
823
- padding: 8px 12px;
824
- max-height: 110px;
825
- overflow-y: auto;
826
- }
827
- .agent-queue-title {
828
- font-size: 11px;
829
- color: var(--muted);
830
- margin-bottom: 6px;
831
- font-weight: 600;
832
- }
833
- .agent-queue-item {
834
- display: flex;
835
- align-items: center;
836
- gap: 8px;
837
- font-size: 11px;
838
- color: var(--text);
839
- margin-bottom: 4px;
840
- }
841
- .agent-queue-item:last-child { margin-bottom: 0; }
842
- .agent-queue-pos {
843
- color: var(--dim);
844
- min-width: 24px;
845
- }
846
- .agent-queue-actions { margin-left: auto; display: inline-flex; gap: 4px; }
847
- .agent-queue-action {
848
- border: 1px solid var(--border);
849
- background: transparent;
850
- color: var(--muted);
851
- border-radius: 4px;
852
- padding: 1px 6px;
853
- cursor: pointer;
854
- font-size: 10px;
855
- line-height: 1.4;
856
- }
857
- .agent-queue-action:hover { border-color: var(--blue); color: var(--text); }
772
+ .agent-queue-badge {
773
+ font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
774
+ border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
775
+ }
776
+ .agent-toolbar {
777
+ display: flex;
778
+ align-items: center;
779
+ gap: 8px;
780
+ padding: 6px 12px;
781
+ background: #0b1018;
782
+ border-bottom: 1px solid var(--border);
783
+ flex-wrap: wrap;
784
+ }
785
+ .agent-toolbar input[type="text"] {
786
+ min-width: 220px;
787
+ padding: 4px 8px;
788
+ border-radius: 4px;
789
+ border: 1px solid var(--border);
790
+ background: #0b1220;
791
+ color: var(--text);
792
+ font-size: 11px;
793
+ }
794
+ .agent-toolbar label {
795
+ display: inline-flex;
796
+ align-items: center;
797
+ gap: 5px;
798
+ font-size: 11px;
799
+ color: var(--muted);
800
+ user-select: none;
801
+ }
802
+ .agent-toolbar-btn {
803
+ background: none;
804
+ border: 1px solid var(--border);
805
+ color: var(--muted);
806
+ cursor: pointer;
807
+ font-size: 11px;
808
+ padding: 3px 8px;
809
+ border-radius: 4px;
810
+ }
811
+ .agent-toolbar-btn:hover { color: var(--text); border-color: var(--blue); }
812
+ .agent-toolbar-btn:disabled {
813
+ opacity: 0.45;
814
+ cursor: not-allowed;
815
+ border-color: var(--border);
816
+ color: var(--dim);
817
+ }
818
+ .agent-toolbar-meta { font-size: 11px; color: var(--dim); margin-left: auto; }
819
+ .agent-queue-panel {
820
+ display: none;
821
+ border-bottom: 1px solid var(--border);
822
+ background: #090f18;
823
+ padding: 8px 12px;
824
+ max-height: 110px;
825
+ overflow-y: auto;
826
+ }
827
+ .agent-queue-title {
828
+ font-size: 11px;
829
+ color: var(--muted);
830
+ margin-bottom: 6px;
831
+ font-weight: 600;
832
+ }
833
+ .agent-queue-item {
834
+ display: flex;
835
+ align-items: center;
836
+ gap: 8px;
837
+ font-size: 11px;
838
+ color: var(--text);
839
+ margin-bottom: 4px;
840
+ }
841
+ .agent-queue-item:last-child { margin-bottom: 0; }
842
+ .agent-queue-pos {
843
+ color: var(--dim);
844
+ min-width: 24px;
845
+ }
846
+ .agent-queue-actions { margin-left: auto; display: inline-flex; gap: 4px; }
847
+ .agent-queue-action {
848
+ border: 1px solid var(--border);
849
+ background: transparent;
850
+ color: var(--muted);
851
+ border-radius: 4px;
852
+ padding: 1px 6px;
853
+ cursor: pointer;
854
+ font-size: 10px;
855
+ line-height: 1.4;
856
+ }
857
+ .agent-queue-action:hover { border-color: var(--blue); color: var(--text); }
858
858
  /* ── Console Tabs ───────────────────────────────────────────────────────── */
859
859
  .agent-tabs-bar {
860
860
  display: flex; align-items: stretch; overflow-x: auto;
@@ -934,50 +934,50 @@
934
934
  border-color: var(--accent);
935
935
  color: var(--accent);
936
936
  }
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;
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;
971
971
  font-size: 12px;
972
972
  line-height: 1.5;
973
973
  }
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 ────────────────────────────────────────────────────────────────── */
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 ────────────────────────────────────────────────────────────────── */
981
981
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
982
982
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
983
983
  /* ─── E2E Plan UI ─────────────────────────────────────────────────── */
@@ -1089,34 +1089,34 @@
1089
1089
  <div id="panelContent"></div>
1090
1090
  </div>
1091
1091
 
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>
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>
1096
1096
  <span class="agent-queue-badge" id="agentQueueBadge" style="display:none">📋 <span id="agentQueueCount">0</span> в очереди</span>
1097
1097
  <button class="agent-panel-cancel" id="agentQueueClearBtn" onclick="clearAgentQueue()" title="Очистить очередь" style="display:none">🗑 очередь</button>
1098
1098
  <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>
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>
1120
1120
 
1121
1121
  <script>
1122
1122
  // ─── State ────────────────────────────────────────────────────────────────────
@@ -1125,21 +1125,21 @@ let contextMode = 'qa';
1125
1125
  let view = 'features';
1126
1126
  let searchQuery = '';
1127
1127
  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
- };
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
+ };
1143
1143
 
1144
1144
  function getModeFromPath(pathname = window.location.pathname) {
1145
1145
  if (pathname.startsWith('/radar/observability')) return 'observability';
@@ -1160,31 +1160,31 @@ function saveModeState(mode) {
1160
1160
  modeStore[mode] = {
1161
1161
  view,
1162
1162
  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];
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
1173
  view = state.view;
1174
1174
  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
- }
1175
+ activeTypes = new Set(state.activeTypes);
1176
+ drillFeatureKey = state.drillFeatureKey;
1177
+ drillTestType = state.drillTestType;
1178
+ activePanelKey = state.activePanelKey;
1179
+ showOnlyUntestedInFeature = !!state.showOnlyUntestedInFeature;
1180
+ }
1181
1181
 
1182
1182
  function switchMode(nextMode) {
1183
1183
  if (nextMode === contextMode) return;
1184
1184
  saveModeState(contextMode);
1185
1185
  contextMode = nextMode;
1186
1186
  restoreModeState(contextMode);
1187
- if (contextMode === 'observability') {
1187
+ if (contextMode === 'observability') {
1188
1188
  view = 'files';
1189
1189
  drillFeatureKey = null;
1190
1190
  drillTestType = null;
@@ -1198,11 +1198,11 @@ function switchMode(nextMode) {
1198
1198
  renderSidebar();
1199
1199
  renderContent();
1200
1200
  }
1201
- // ─── Run All Tests button ──────────────────────────────────────────────────────
1202
- let runAllRunning = false;
1203
- let refreshDataInFlight = false;
1204
- let refreshDataQueued = false;
1205
- let refreshDataTimer = null;
1201
+ // ─── Run All Tests button ──────────────────────────────────────────────────────
1202
+ let runAllRunning = false;
1203
+ let refreshDataInFlight = false;
1204
+ let refreshDataQueued = false;
1205
+ let refreshDataTimer = null;
1206
1206
 
1207
1207
  function escapeHtml(text) {
1208
1208
  return String(text || '')
@@ -1241,42 +1241,42 @@ function setFeatureDrill(featureKey, syncHash = true) {
1241
1241
  renderContent();
1242
1242
  }
1243
1243
 
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) => {
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) => {
1247
1247
  if (visibleSet && !visibleSet.has(relPath)) return false;
1248
1248
  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() {
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() {
1280
1280
  const hash = (window.location.hash || '').replace(/^#/, '');
1281
1281
  if (!hash) {
1282
1282
  if (view === 'features' && drillFeatureKey) {
@@ -1324,145 +1324,145 @@ async function runAllTests() {
1324
1324
  }
1325
1325
 
1326
1326
  // ─── 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;
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;
1354
1354
 
1355
1355
  function _sessionId() {
1356
1356
  return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
1357
1357
  }
1358
1358
 
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
- }
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
1369
 
1370
1370
  function restoreSessions() {
1371
1371
  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();
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();
1386
1386
  }
1387
1387
  } catch {}
1388
1388
  }
1389
1389
 
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();
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();
1411
1411
  return s.id;
1412
1412
  }
1413
1413
 
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' ? '❌ ошибка'
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' ? '❌ ошибка'
1422
1422
  : '';
1423
1423
  document.getElementById('agentPanelTitle').textContent = s.title;
1424
1424
  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;
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;
1452
1452
  let stored;
1453
1453
  if (typeof lineOrNode === 'string') {
1454
1454
  stored = { text: lineOrNode, isError, isDim };
1455
1455
  } else {
1456
1456
  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
- }
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
+ }
1466
1466
 
1467
1467
  function updateSessionStatus(id, status) {
1468
1468
  const s = consoleSessions.find(s => s.id === id);
@@ -1490,9 +1490,9 @@ function renderTabs() {
1490
1490
  if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1491
1491
  }
1492
1492
 
1493
- function copyTerminalContent() {
1494
- const s = consoleSessions.find(s => s.id === activeSessionId);
1495
- if (!s || s.lines.length === 0) return;
1493
+ function copyTerminalContent() {
1494
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1495
+ if (!s || s.lines.length === 0) return;
1496
1496
  // Extract plain text from each line (strip HTML for rich nodes)
1497
1497
  const text = s.lines.map(l => {
1498
1498
  if (l.text !== undefined) return l.text;
@@ -1510,214 +1510,214 @@ function copyTerminalContent() {
1510
1510
  btn.textContent = '✓';
1511
1511
  btn.classList.add('copied');
1512
1512
  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
- }
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
+ }
1721
1721
 
1722
1722
  async function setAgent(agent) {
1723
1723
  await fetch('/api/set-agent', {
@@ -1766,157 +1766,157 @@ function isFileAgentActive(relPath) {
1766
1766
  return null;
1767
1767
  }
1768
1768
 
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);
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);
1920
1920
  updateSessionStatus(runningSessionId, 'error');
1921
1921
  runningSessionId = null;
1922
1922
  } else {
@@ -1924,11 +1924,11 @@ async function cancelAgent() {
1924
1924
  }
1925
1925
  }
1926
1926
 
1927
- async function clearAgentQueue() {
1928
- await fetch('/api/clear-queue', { method: 'POST' });
1929
- await loadAgentState();
1930
- appendTerminalLine('🗑 Очередь очищена', false);
1931
- }
1927
+ async function clearAgentQueue() {
1928
+ await fetch('/api/clear-queue', { method: 'POST' });
1929
+ await loadAgentState();
1930
+ appendTerminalLine('🗑 Очередь очищена', false);
1931
+ }
1932
1932
 
1933
1933
  // ─── File row more menu ──────────────────────────────────────────────────────
1934
1934
  let _openFileMenu = null;
@@ -2195,19 +2195,29 @@ function renderStats() {
2195
2195
 
2196
2196
  let items;
2197
2197
  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
- ];
2198
+ const o = D.observability;
2199
+ if (o) {
2200
+ const noiseRatio = Math.round(o.metrics.noise_ratio * 100);
2201
+ const structPct = Math.round(o.metrics.structured_completeness * 100);
2202
+ const missingCoverage = o.missingCriticalLogs.length;
2203
+ const noisyCount = o.topNoisyPatterns.length;
2204
+ items = [
2205
+ { v: o.catalog.length, l: 'Источники логов' },
2206
+ { v: noiseRatio + '%', l: 'Коэффициент шума', c: noiseRatio > 30 ? '#f85149' : noiseRatio > 10 ? '#e3b341' : undefined },
2207
+ { v: structPct + '%', l: 'Структурированность', c: structPct < 50 ? '#f85149' : structPct < 80 ? '#e3b341' : undefined },
2208
+ { v: missingCoverage, l: 'Нет покрытия', c: missingCoverage ? '#e3b341' : undefined },
2209
+ { v: noisyCount, l: 'Шумных паттернов', c: noisyCount ? '#e3b341' : undefined },
2210
+ ];
2211
+ } else {
2212
+ const serviceSources = src.filter(m => m.type === 'service' || m.type === 'util' || m.type === 'other');
2213
+ items = [
2214
+ { v: serviceSources.length, l: 'Источники логов' },
2215
+ { v: '—', l: 'Коэффициент шума' },
2216
+ { v: '—', l: 'Структурированность' },
2217
+ { v: '—', l: 'Нет покрытия' },
2218
+ { v: '—', l: 'Шумных паттернов' },
2219
+ ];
2220
+ }
2211
2221
  } else if (D.hasConfig && D.features) {
2212
2222
  const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
2213
2223
  items = [
@@ -2236,10 +2246,10 @@ function renderStats() {
2236
2246
 
2237
2247
  function renderModeSwitch() {
2238
2248
  const root = document.getElementById('modeSwitch');
2239
- const modes = [
2240
- { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2241
- { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2242
- ];
2249
+ const modes = [
2250
+ { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2251
+ { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2252
+ ];
2243
2253
  root.innerHTML = modes.map(m => `
2244
2254
  <button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
2245
2255
  <span>${m.label}</span>
@@ -2257,15 +2267,15 @@ function renderSidebar() {
2257
2267
  const tabs = document.getElementById('viewTabs');
2258
2268
  const extra = document.getElementById('sidebarExtra');
2259
2269
 
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
- }
2270
+ if (contextMode === 'observability') {
2271
+ tabs.style.display = 'none';
2272
+ extra.innerHTML = `
2273
+ <div class="sidebar-label">Фокус наблюдаемости</div>
2274
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
2275
+ Источники логов и качество сигналов для триажа инцидентов.
2276
+ </div>`;
2277
+ return;
2278
+ }
2269
2279
 
2270
2280
  tabs.style.display = 'flex';
2271
2281
  document.querySelectorAll('.view-tab').forEach(t =>
@@ -2325,61 +2335,158 @@ function renderQaOnboarding() {
2325
2335
  return `<div class="onboarding-block"><h3>QA Coverage: что это?</h3><p>Этот экран помогает найти пробелы в тестах: сначала проверь покрытие по фичам, затем открой критичные непокрытые файлы и запусти генерацию/фиксы тестов.</p></div>`;
2326
2336
  }
2327
2337
 
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">
2338
+ function renderObservability(c) {
2339
+ const o = D.observability;
2340
+ if (!o) {
2341
+ c.innerHTML = `<div class="onboarding-block"><h3>Наблюдаемость</h3><p>Данные анализа логов недоступны. Пересканируй проект.</p></div>`;
2342
+ return;
2343
+ }
2344
+
2345
+ const noiseRatio = Math.round(o.metrics.noise_ratio * 100);
2346
+ const structPct = Math.round(o.metrics.structured_completeness * 100);
2347
+ const actionPct = Math.round(o.metrics.error_actionability * 100);
2348
+ const coveragePct = Math.round(o.metrics.coverage_of_key_flows * 100);
2349
+
2350
+ function metricColor(val, goodThreshold, warnThreshold, invert) {
2351
+ // invert=true: higher is worse (noise), invert=false: higher is better (coverage)
2352
+ if (invert) return val > warnThreshold ? 'var(--red)' : val > goodThreshold ? 'var(--yellow)' : 'var(--green)';
2353
+ return val < warnThreshold ? 'var(--red)' : val < goodThreshold ? 'var(--yellow)' : 'var(--green)';
2354
+ }
2355
+
2356
+ const formatLabels = { structured: 'structured', mixed: 'mixed', unstructured: 'unstructured' };
2357
+ const sourceByFormat = [
2358
+ { label: 'Structured', count: o.catalog.filter(c => c.format === 'structured').length, color: 'var(--green)' },
2359
+ { label: 'Mixed', count: o.catalog.filter(c => c.format === 'mixed').length, color: 'var(--yellow)' },
2360
+ { label: 'Unstructured', count: o.catalog.filter(c => c.format === 'unstructured').length, color: 'var(--red)' },
2361
+ ].filter(x => x.count > 0);
2362
+
2363
+ const recLabels = {
2364
+ 'suppress': 'убрать',
2365
+ 'downgrade level': 'понизить уровень',
2366
+ 'enrich fields': 'обогатить поля',
2367
+ 'add event': 'добавить событие',
2368
+ };
2369
+
2370
+ const noisyRows = o.topNoisyPatterns.slice(0, 8).map(i => `
2371
+ <div class="obs-list-item">
2372
+ <span class="obs-priority-${i.priority}" style="flex-shrink:0">[${i.priority}]</span>
2373
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(i.pattern)}">${escapeHtml(i.pattern)}</span>
2374
+ <strong style="flex-shrink:0;color:var(--red)">×${i.count} → ${recLabels[i.recommendation] || i.recommendation}</strong>
2375
+ </div>`).join('') || '<div class="obs-sub">Шумных паттернов не обнаружено</div>';
2376
+
2377
+ const missingRows = o.missingCriticalLogs.slice(0, 8).map(i => `
2378
+ <div class="obs-list-item">
2379
+ <span class="obs-priority-${i.priority}" style="flex-shrink:0">[${i.priority}]</span>
2380
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(i.pattern)}">${escapeHtml(i.pattern)}</span>
2381
+ <strong style="flex-shrink:0;color:var(--yellow)">${recLabels[i.recommendation] || i.recommendation}</strong>
2382
+ </div>`).join('') || '<div class="obs-sub" style="color:var(--green)">Критичные сценарии покрыты</div>';
2383
+
2384
+ const fieldGaps = o.fieldGaps || {};
2385
+ const fieldGapEntries = Object.entries(fieldGaps).filter(([,v]) => v > 0).sort((a,b) => b[1] - a[1]);
2386
+ const fieldGapRows = fieldGapEntries.map(([name, count]) => `
2387
+ <div class="obs-list-item">
2388
+ <span><code>${escapeHtml(name)}</code></span>
2389
+ <strong style="color:${count > 20 ? 'var(--red)' : count > 5 ? 'var(--yellow)' : 'var(--muted)'}">${count} пропусков</strong>
2390
+ </div>`).join('') || '<div class="obs-sub" style="color:var(--green)">Все обязательные поля на месте</div>';
2391
+
2392
+ const catalogRows = o.catalog.slice(0, 15).map(i => {
2393
+ const missing = (i.missingFields || []);
2394
+ const missingStr = missing.length ? missing.join(', ') : '—';
2395
+ return `
2396
+ <div class="obs-cat-row">
2397
+ <span title="${escapeHtml(i.modulePath)}">${escapeHtml(i.modulePath.split('/').slice(-2).join('/'))}</span>
2398
+ <span>${i.level}</span>
2399
+ <span style="color:${i.format==='structured'?'var(--green)':i.format==='mixed'?'var(--yellow)':'var(--red)'}">${i.format}</span>
2400
+ <span style="color:${missing.length > 4 ? 'var(--red)' : missing.length > 0 ? 'var(--yellow)' : 'var(--green)'}">${missing.length}/${missing.length > 0 ? '8' : '8'}</span>
2401
+ <span title="${escapeHtml(missingStr)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(missingStr)}</span>
2402
+ <span style="color:${i.recommendation==='suppress'?'var(--red)':i.recommendation==='add event'?'var(--yellow)':'var(--muted)'}">${recLabels[i.recommendation] || i.recommendation}</span>
2403
+ </div>`}).join('');
2404
+
2405
+ c.innerHTML = `
2406
+ <div class="onboarding-block">
2407
+ <h3>Наблюдаемость: что это?</h3>
2408
+ <p>Аудит покрытия логами: что добавить, что убрать, что обогатить — на основе статического анализа лог-вызовов.</p>
2409
+ </div>
2410
+
2411
+ <div class="obs-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
2349
2412
  <div class="obs-card">
2350
- <div class="obs-title">Источники логов</div>
2413
+ <div class="obs-title">Коэффициент шума</div>
2414
+ <div class="obs-value" style="color:${metricColor(noiseRatio,10,30,true)}">${noiseRatio}%</div>
2415
+ <div class="obs-sub">Доля шумных лог-вызовов из всех.</div>
2416
+ </div>
2417
+ <div class="obs-card">
2418
+ <div class="obs-title">Структурированность</div>
2419
+ <div class="obs-value" style="color:${metricColor(structPct,80,50,false)}">${structPct}%</div>
2420
+ <div class="obs-sub">Логи с обязательными полями (module, event, traceId).</div>
2421
+ </div>
2422
+ <div class="obs-card">
2423
+ <div class="obs-title">Actionable ошибки</div>
2424
+ <div class="obs-value" style="color:${metricColor(actionPct,80,50,false)}">${actionPct}%</div>
2425
+ <div class="obs-sub">ERROR-логи с контекстом для диагностики.</div>
2426
+ </div>
2427
+ <div class="obs-card">
2428
+ <div class="obs-title">Покрытие сценариев</div>
2429
+ <div class="obs-value" style="color:${metricColor(coveragePct,80,50,false)}">${coveragePct}%</div>
2430
+ <div class="obs-sub">Модули с хотя бы одним warn/error событием.</div>
2431
+ </div>
2432
+ </div>
2433
+
2434
+ <div class="obs-grid" style="grid-template-columns:1fr 1fr 1fr;margin-bottom:12px">
2435
+ <div class="obs-card">
2436
+ <div class="obs-title">Источники по формату</div>
2437
+ <div class="obs-list">
2438
+ ${sourceByFormat.map(g => `
2439
+ <div class="obs-list-item">
2440
+ <span style="color:${g.color}">${g.label}</span>
2441
+ <strong>${g.count}</strong>
2442
+ </div>`).join('') || '<div class="obs-sub">Нет данных</div>'}
2443
+ </div>
2444
+ </div>
2445
+ <div class="obs-card">
2446
+ <div class="obs-title">Классификация логов</div>
2351
2447
  <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>'}
2448
+ <div class="obs-list-item"><span style="color:var(--red)">Мусор (убрать)</span><strong>${o.classification.trash}</strong></div>
2449
+ <div class="obs-list-item"><span style="color:var(--green)">Полезные</span><strong>${o.classification.useful}</strong></div>
2450
+ <div class="obs-list-item"><span style="color:var(--blue)">Критичные</span><strong>${o.classification.critical}</strong></div>
2353
2451
  </div>
2354
2452
  </div>
2453
+ <div class="obs-card">
2454
+ <div class="obs-title">Рекомендации</div>
2455
+ <div class="obs-list">
2456
+ ${['suppress','enrich fields','add event','downgrade level'].map(rec => {
2457
+ const count = o.catalog.filter(c => c.recommendation === rec).length;
2458
+ if (!count) return '';
2459
+ return `<div class="obs-list-item"><span>${recLabels[rec]}</span><strong>${count}</strong></div>`;
2460
+ }).join('')}
2461
+ </div>
2462
+ </div>
2463
+ </div>
2464
+
2465
+ <div class="obs-grid" style="grid-template-columns:1fr 1fr;margin-bottom:12px">
2466
+ <div class="obs-card">
2467
+ <div class="obs-title">Что убрать — шумные паттерны</div>
2468
+ <div class="obs-list" style="gap:4px">${noisyRows}</div>
2469
+ </div>
2470
+ <div class="obs-card">
2471
+ <div class="obs-title">Что добавить — нет критичных логов</div>
2472
+ <div class="obs-list" style="gap:4px">${missingRows}</div>
2473
+ </div>
2474
+ </div>
2475
+
2476
+ <div class="obs-card" style="margin-bottom:12px">
2477
+ <div class="obs-title">Что обогатить — пробелы по полям</div>
2478
+ <div class="obs-sub" style="margin-bottom:8px">Обязательные поля по стандарту: service, env, trace_id, request_id, event_name, outcome, error_code (warn/error), user_id.</div>
2479
+ <div class="obs-list">${fieldGapRows}</div>
2480
+ </div>
2355
2481
 
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
- }
2482
+ <div class="obs-card" style="margin-bottom:12px">
2483
+ <div class="obs-title">Каталог источников логов (топ 15)</div>
2484
+ <div class="obs-catalog" style="margin-top:8px;border:none;padding:0">
2485
+ <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>пробелы</span><span>не хватает</span><span>действие</span></div>
2486
+ ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2487
+ </div>
2488
+ </div>`;
2489
+ }
2383
2490
 
2384
2491
  function backToFeatureDetail() {
2385
2492
  drillTestType = null;
@@ -2397,15 +2504,15 @@ function toPct(v) {
2397
2504
  return Math.round((v || 0) * 100) + '%';
2398
2505
  }
2399
2506
 
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
- ];
2507
+ function renderObservabilityOverview(c) {
2508
+ const o = D.observability;
2509
+ if (!o) return '';
2510
+ const metrics = [
2511
+ ['Коэффициент шума', toPct(o.metrics.noise_ratio)],
2512
+ ['Действия по ошибкам', toPct(o.metrics.error_actionability)],
2513
+ ['Полнота структуры', toPct(o.metrics.structured_completeness)],
2514
+ ['Покрытие ключевых сценариев', toPct(o.metrics.coverage_of_key_flows)],
2515
+ ];
2409
2516
  const noisy = (o.topNoisyPatterns || []).slice(0, 5).map(i =>
2410
2517
  `<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
2518
  ).join('') || '<div class="obs-row">Нет шумных паттернов</div>';
@@ -2418,27 +2525,27 @@ function renderObservabilityOverview(c) {
2418
2525
  `<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
2526
  ).join('');
2420
2527
 
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>
2528
+ return `
2529
+ <div class="observability-panel">
2530
+ <div class="observability-title">Наблюдаемость</div>
2531
+ <div class="obs-metrics">
2532
+ ${metrics.map(([l,v]) => `<div class="obs-metric"><div class="obs-metric-v">${v}</div><div class="obs-metric-l">${l}</div></div>`).join('')}
2533
+ </div>
2534
+ <div class="obs-columns">
2535
+ <div class="obs-list">
2536
+ <h4>Топ шумных паттернов</h4>
2537
+ ${noisy}
2538
+ </div>
2539
+ <div class="obs-list">
2540
+ <h4>Отсутствующие критичные логи</h4>
2541
+ ${missing}
2542
+ </div>
2543
+ </div>
2544
+ <div class="obs-catalog">
2545
+ <h4>Каталог источников логов (топ 10)</h4>
2546
+ <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>частота</span><span>владелец</span><span>действие</span></div>
2547
+ ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2548
+ </div>
2442
2549
  <div class="obs-row" style="margin-top:8px">
2443
2550
  Классификация: мусор ${o.classification.trash} · полезно ${o.classification.useful} · критично ${o.classification.critical}
2444
2551
  </div>
@@ -2476,7 +2583,7 @@ function renderFeatureCards(c) {
2476
2583
  </div>
2477
2584
  </div>` : '';
2478
2585
 
2479
- c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
2586
+ c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
2480
2587
  const grid = document.getElementById('featGrid');
2481
2588
 
2482
2589
  list.forEach(f => {
@@ -2781,12 +2888,12 @@ function renderFeatureDetail(c) {
2781
2888
  return;
2782
2889
  }
2783
2890
 
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;
2891
+ const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
2892
+ const src = mods.filter(m => m.type !== 'test');
2893
+ const untestedSrc = src.filter(m => !m.hasTests);
2894
+ const tst = mods.filter(m => m.type === 'test');
2895
+ const testedCount = src.filter(m => m.hasTests).length;
2896
+ const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
2790
2897
 
2791
2898
  const unitCount = feat.unitTestCount ?? tst.filter(m => m.testType === 'unit').length;
2792
2899
  const integrationCount = feat.integrationTestCount ?? tst.filter(m => m.testType === 'integration').length;
@@ -2798,32 +2905,32 @@ function renderFeatureDetail(c) {
2798
2905
  const integrationFailed = tst.filter(m => m.testType === 'integration' && te[m.relativePath.replace(/\\/g, '/')]).length;
2799
2906
  const e2eFailed = tst.filter(m => m.testType === 'e2e' && te[m.relativePath.replace(/\\/g, '/')]).length;
2800
2907
 
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;
2908
+ // Determine what list to show based on active tab
2909
+ // null or 'source' → source files; test type → test files of that type
2910
+ const activeTab = drillTestType || 'source';
2911
+ const sourceList = showOnlyUntestedInFeature ? untestedSrc : src;
2912
+ const listFiles = activeTab === 'source' ? sourceList : tst.filter(m => m.testType === activeTab);
2913
+ const isTestList = activeTab !== 'source';
2914
+ const showUntestedToggle = activeTab === 'source';
2915
+ const meta = TEST_TYPE_META[activeTab];
2916
+ const listLabel = meta
2917
+ ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
2918
+ : `📁 Файлы фичи (${listFiles.length})`;
2919
+
2920
+ const q = searchQuery.toLowerCase();
2921
+ const filtered = q ? listFiles.filter(m =>
2922
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2923
+ ) : listFiles;
2924
+ const rowsRenderKey = `feature:${drillFeatureKey}:${activeTab}:${showOnlyUntestedInFeature ? 1 : 0}:${q}`;
2925
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2926
+ const selectableSource = !isTestList && !!D.agent;
2927
+ const visibleSourcePaths = selectableSource
2928
+ ? visibleRows.map(m => m.relativePath.replace(/\\/g, '/'))
2929
+ : [];
2930
+ const selectedVisible = selectableSource
2931
+ ? selectedFilesForFeature(drillFeatureKey, visibleRows.map(m => m.relativePath))
2932
+ : [];
2933
+ const selectedCount = selectedVisible.length;
2827
2934
 
2828
2935
  c.innerHTML = `
2829
2936
  <div class="drill-header">
@@ -2849,51 +2956,51 @@ function renderFeatureDetail(c) {
2849
2956
  ${testTypeCard('integration', 'Integration', '🔗', '#58a6ff', integrationCount, activeTab === 'integration', drillFeatureKey, integrationFailed)}
2850
2957
  ${testTypeCard('e2e', 'E2E', '🎭', '#d2a8ff', e2eCount, activeTab === 'e2e', drillFeatureKey, e2eFailed)}
2851
2958
  </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>
2959
+
2960
+ <div class="drill-section-label">${listLabel}</div>
2961
+ ${showUntestedToggle ? `
2962
+ <div class="feature-file-filters">
2963
+ <label class="feature-filter-toggle">
2964
+ <input type="checkbox" id="untestedOnlyToggle" ${showOnlyUntestedInFeature ? 'checked' : ''}>
2965
+ <span>Только без тестов</span>
2966
+ <span class="feature-filter-meta">(${untestedSrc.length})</span>
2967
+ </label>
2968
+ </div>` : ''}
2969
+ ${selectableSource ? `
2970
+ <div class="bulk-actions">
2971
+ <span class="bulk-actions-count">Выбрано файлов: <b>${selectedCount}</b></span>
2972
+ <button class="bulk-actions-btn" id="bulkSelectAllBtn" ${visibleSourcePaths.length === 0 ? 'disabled' : ''}>☑ Выбрать все (${visibleSourcePaths.length})</button>
2866
2973
  <button class="bulk-actions-btn" id="bulkClearBtn" ${selectedCount === 0 ? 'disabled' : ''}>Снять выбор</button>
2867
2974
  <button class="bulk-actions-btn primary" id="bulkWriteTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>✍ Написать тесты для выбранных</button>
2868
2975
  <button class="bulk-actions-btn" id="bulkRefreshTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>↻ Актуализировать тесты</button>
2869
2976
  </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) {
2977
+ <div class="file-rows" id="fileRows">
2978
+ ${filtered.length === 0
2979
+ ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
2980
+ ${isTestList
2981
+ ? 'Нет тестов этого типа для данной фичи'
2982
+ : (showOnlyUntestedInFeature
2983
+ ? 'Все файлы этой фичи уже покрыты тестами'
2984
+ : 'Нет файлов — возможно паттерны в конфиге не совпадают')}
2985
+ </div>`
2986
+ : visibleRows.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
2987
+ }
2988
+ </div>
2989
+ ${hasMoreRows ? `
2990
+ <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)">
2991
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
2992
+ <button class="bulk-actions-btn" id="fileRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
2993
+ </div>` : ''}`;
2994
+
2995
+ const untestedOnlyToggle = document.getElementById('untestedOnlyToggle');
2996
+ if (untestedOnlyToggle) {
2997
+ untestedOnlyToggle.onchange = (e) => {
2998
+ showOnlyUntestedInFeature = !!e.target.checked;
2999
+ renderContent();
3000
+ };
3001
+ }
3002
+
3003
+ if (selectableSource) {
2897
3004
  const allVisibleSelected = visibleSourcePaths.length > 0 && visibleSourcePaths.every((p) => selectedSourceFiles.has(p));
2898
3005
  const bulkSelectAllBtn = document.getElementById('bulkSelectAllBtn');
2899
3006
  if (bulkSelectAllBtn) {
@@ -2928,17 +3035,17 @@ function renderFeatureDetail(c) {
2928
3035
  renderContent();
2929
3036
  };
2930
3037
  }
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 => {
3038
+ }
3039
+
3040
+ const fileRowsLoadMoreBtn = document.getElementById('fileRowsLoadMoreBtn');
3041
+ if (fileRowsLoadMoreBtn) {
3042
+ fileRowsLoadMoreBtn.onclick = () => {
3043
+ increaseFileRowsLimit();
3044
+ renderContent();
3045
+ };
3046
+ }
3047
+
3048
+ c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
2942
3049
  card.onclick = async () => {
2943
3050
  const type = card.dataset.testtype;
2944
3051
  drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
@@ -2947,11 +3054,11 @@ function renderFeatureDetail(c) {
2947
3054
  if (type === 'e2e') {
2948
3055
  await loadE2ePlan(drillFeatureKey);
2949
3056
  }
2950
- renderContent();
2951
- };
2952
- });
2953
- bindFileRowsClick(c);
2954
- }
3057
+ renderContent();
3058
+ };
3059
+ });
3060
+ bindFileRowsClick(c);
3061
+ }
2955
3062
 
2956
3063
  const TEST_TYPE_META = {
2957
3064
  unit: { label: 'Unit', icon: '🧪', color: '#e3b341', desc: 'Изолированные тесты функций и модулей' },
@@ -2970,12 +3077,12 @@ function renderTestTypeDetail(c) {
2970
3077
  m.featureKeys && m.featureKeys.includes(drillFeatureKey)
2971
3078
  );
2972
3079
 
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);
3080
+ const q = searchQuery.toLowerCase();
3081
+ const filtered = q ? tests.filter(m =>
3082
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3083
+ ) : tests;
3084
+ const rowsRenderKey = `tests:${drillFeatureKey}:${drillTestType}:${q}`;
3085
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2979
3086
 
2980
3087
  c.innerHTML = `
2981
3088
  <div class="drill-header">
@@ -2990,32 +3097,32 @@ function renderTestTypeDetail(c) {
2990
3097
  </div>
2991
3098
  <div style="font-size:12px;color:var(--dim);margin-bottom:16px">${meta.desc}</div>
2992
3099
 
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
- }
3100
+ <div class="file-rows" id="fileRows">
3101
+ ${filtered.length === 0
3102
+ ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
3103
+ <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
3104
+ <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
3105
+ <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
3106
+ </div>`
3107
+ : visibleRows.map(m => fileRow(m, true)).join('')
3108
+ }
3109
+ </div>
3110
+ ${hasMoreRows ? `
3111
+ <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)">
3112
+ <span>Показано ${visibleRows.length} из ${filtered.length} тест-файлов</span>
3113
+ <button class="bulk-actions-btn" id="testRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3114
+ </div>` : ''}`;
3115
+
3116
+ const testRowsLoadMoreBtn = document.getElementById('testRowsLoadMoreBtn');
3117
+ if (testRowsLoadMoreBtn) {
3118
+ testRowsLoadMoreBtn.onclick = () => {
3119
+ increaseFileRowsLimit();
3120
+ renderContent();
3121
+ };
3122
+ }
3123
+
3124
+ bindFileRowsClick(c);
3125
+ }
3019
3126
 
3020
3127
  function renderUnmappedDetail(c) {
3021
3128
  const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
@@ -3023,12 +3130,12 @@ function renderUnmappedDetail(c) {
3023
3130
  m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
3024
3131
  );
3025
3132
 
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);
3133
+ const q = searchQuery.toLowerCase();
3134
+ const filtered = q ? unmappedSrc.filter(m =>
3135
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3136
+ ) : unmappedSrc;
3137
+ const rowsRenderKey = `unmapped:${q}`;
3138
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
3032
3139
 
3033
3140
  // Build prompt text
3034
3141
  const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
@@ -3069,44 +3176,44 @@ function renderUnmappedDetail(c) {
3069
3176
  border-radius:6px; color:var(--blue); font-size:12px; cursor:pointer;
3070
3177
  ">📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)</button>
3071
3178
  </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>` : ''}`;
3179
+ <div class="file-rows" id="fileRows">
3180
+ ${filtered.length === 0
3181
+ ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
3182
+ : visibleRows.map(m => fileRow(m)).join('')
3183
+ }
3184
+ </div>
3185
+ ${hasMoreRows ? `
3186
+ <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)">
3187
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
3188
+ <button class="bulk-actions-btn" id="unmappedRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3189
+ </div>` : ''}`;
3083
3190
 
3084
3191
  const runAgentUnmappedBtn = document.getElementById('runAgentUnmapped');
3085
3192
  if (runAgentUnmappedBtn) {
3086
3193
  runAgentUnmappedBtn.onclick = () => runAgentTask('map-unmapped');
3087
3194
  }
3088
3195
 
3089
- document.getElementById('copyUnmappedDrill').onclick = function() {
3196
+ document.getElementById('copyUnmappedDrill').onclick = function() {
3090
3197
  navigator.clipboard.writeText(promptText).then(() => {
3091
3198
  this.textContent = '✅ Скопировано!';
3092
3199
  this.style.color = 'var(--green)';
3093
3200
  setTimeout(() => {
3094
3201
  this.textContent = `📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)`;
3095
3202
  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
- }
3203
+ }, 3000);
3204
+ });
3205
+ };
3206
+
3207
+ const unmappedRowsLoadMoreBtn = document.getElementById('unmappedRowsLoadMoreBtn');
3208
+ if (unmappedRowsLoadMoreBtn) {
3209
+ unmappedRowsLoadMoreBtn.onclick = () => {
3210
+ increaseFileRowsLimit();
3211
+ renderContent();
3212
+ };
3213
+ }
3214
+
3215
+ bindFileRowsClick(c);
3216
+ }
3110
3217
 
3111
3218
  function fileRow(m, isTest = false, featureKey = null, selectable = false) {
3112
3219
  const relPath = m.relativePath.replace(/\\/g, '/');
@@ -3196,33 +3303,33 @@ function toggleSourceSelection(relPath, checked) {
3196
3303
  renderContent();
3197
3304
  }
3198
3305
 
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');
3306
+ function renderModuleGrid(c) {
3307
+ const q = searchQuery.toLowerCase();
3308
+ const list = D.modules.filter(m => {
3309
+ if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
3310
+ if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
3311
+ return true;
3312
+ });
3313
+ const typeKey = [...activeTypes].sort().join(',');
3314
+ const rowsRenderKey = `modules:${contextMode}:${view}:${typeKey}:${q}`;
3315
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(list, rowsRenderKey);
3316
+
3317
+ if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
3318
+
3319
+ c.innerHTML = `
3320
+ <div class="module-grid" id="modGrid"></div>
3321
+ ${hasMoreRows ? `
3322
+ <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)">
3323
+ <span>Показано ${visibleRows.length} из ${list.length} модулей</span>
3324
+ <button class="bulk-actions-btn" id="moduleRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3325
+ </div>` : ''}
3326
+ `;
3327
+ const grid = document.getElementById('modGrid');
3328
+
3329
+ visibleRows.forEach(m => {
3330
+ const cov = m.coverage?.lines;
3331
+ const isActive = activePanelKey === m.id;
3332
+ const card = document.createElement('div');
3226
3333
  card.className = 'module-card' + (isActive ? ' active' : '');
3227
3334
  card.innerHTML = `
3228
3335
  <div class="module-name">${m.name}</div>
@@ -3234,18 +3341,18 @@ function renderModuleGrid(c) {
3234
3341
  <span class="badge ${m.hasTests ? 'badge-green' : 'badge-red'}">${m.hasTests ? '✓' : '✗'}</span>
3235
3342
  </div>
3236
3343
  ${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
- }
3344
+ card.onclick = () => openModulePanel(m);
3345
+ grid.appendChild(card);
3346
+ });
3347
+
3348
+ const moduleRowsLoadMoreBtn = document.getElementById('moduleRowsLoadMoreBtn');
3349
+ if (moduleRowsLoadMoreBtn) {
3350
+ moduleRowsLoadMoreBtn.onclick = () => {
3351
+ increaseFileRowsLimit();
3352
+ renderContent();
3353
+ };
3354
+ }
3355
+ }
3249
3356
 
3250
3357
  // ─── Panels ───────────────────────────────────────────────────────────────────
3251
3358
  function openFeaturePanel(key) {
@@ -3456,29 +3563,29 @@ function openUnmappedPanel(files, infraFiles) {
3456
3563
  document.getElementById('panel').classList.add('open');
3457
3564
  }
3458
3565
 
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 => {
3566
+ function closePanel() {
3567
+ activePanelKey = null;
3568
+ document.getElementById('panel').classList.remove('open');
3569
+ renderContent();
3570
+ }
3571
+
3572
+ function applyTerminalFiltersFromUi() {
3573
+ terminalSearchQuery = document.getElementById('agentSearchInput')?.value || '';
3574
+ terminalSearchErrorsOnly = !!document.getElementById('agentSearchErrorsOnly')?.checked;
3575
+ terminalSearchCurrentRunOnly = !!document.getElementById('agentSearchCurrentRunOnly')?.checked;
3576
+ terminalSearchRegex = !!document.getElementById('agentSearchRegex')?.checked;
3577
+ renderActiveSession();
3578
+ }
3579
+
3580
+ ['agentSearchInput', 'agentSearchErrorsOnly', 'agentSearchCurrentRunOnly', 'agentSearchRegex'].forEach((id) => {
3581
+ const el = document.getElementById(id);
3582
+ if (!el) return;
3583
+ const eventName = id === 'agentSearchInput' ? 'input' : 'change';
3584
+ el.addEventListener(eventName, applyTerminalFiltersFromUi);
3585
+ });
3586
+
3587
+ // ─── Events ───────────────────────────────────────────────────────────────────
3588
+ document.querySelectorAll('.view-tab').forEach(tab => {
3482
3589
  tab.onclick = () => {
3483
3590
  if (contextMode !== 'qa') return;
3484
3591
  if (tab.classList.contains('disabled')) return;
@@ -3528,199 +3635,199 @@ window.addEventListener('popstate', () => {
3528
3635
  });
3529
3636
 
3530
3637
  // ─── 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();
3638
+ function setLiveDot(color, title) {
3639
+ const dot = document.getElementById('liveDot');
3640
+ dot.style.background = color;
3641
+ dot.title = title;
3642
+ }
3643
+
3644
+ function scheduleRefreshData(delayMs = 120) {
3645
+ if (refreshDataTimer) return;
3646
+ refreshDataTimer = setTimeout(() => {
3647
+ refreshDataTimer = null;
3648
+ void refreshData();
3649
+ }, delayMs);
3650
+ }
3651
+
3652
+ async function refreshData() {
3653
+ if (refreshDataInFlight) {
3654
+ refreshDataQueued = true;
3655
+ return;
3656
+ }
3657
+ refreshDataInFlight = true;
3658
+ try {
3659
+ const res = await fetch('/api/data');
3660
+ D = await res.json();
3554
3661
 
3555
3662
  // Update header timestamp
3556
3663
  document.getElementById('scannedAt').textContent =
3557
3664
  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');
3665
+
3666
+ renderStats();
3667
+ renderSidebar();
3668
+ updateAgentBtn();
3669
+ updateAgentRightsInfo();
3670
+
3671
+ // Re-render drill-down or re-open panel
3672
+ const panelOpen = document.getElementById('panel').classList.contains('open');
3566
3673
  if (panelOpen && activePanelKey) {
3567
3674
  if (drillFeatureKey === '__unmapped__') {
3568
3675
  renderContent(); // already routes to renderUnmappedDetail
3569
3676
  } else if (view === 'features' && D.features) {
3570
3677
  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
- }
3678
+ } else {
3679
+ const m = D.modules.find(m => m.id === activePanelKey);
3680
+ if (m) openModulePanel(m);
3681
+ else closePanel();
3682
+ }
3683
+ } else {
3684
+ renderContent();
3685
+ }
3686
+
3687
+ // Brief green flash on the dot to signal fresh data
3688
+ setLiveDot('#3fb950', 'Live — обновлено только что');
3689
+ setTimeout(() => setLiveDot('#3fb950', 'Live — автообновление включено'), 1500);
3690
+ void loadAgentState();
3691
+ } catch (err) {
3692
+ console.error('Refresh failed:', err);
3693
+ } finally {
3694
+ refreshDataInFlight = false;
3695
+ if (refreshDataQueued) {
3696
+ refreshDataQueued = false;
3697
+ scheduleRefreshData(120);
3698
+ }
3699
+ }
3700
+ }
3701
+
3702
+ function connectSSE() {
3703
+ const es = new EventSource('/api/events');
3704
+
3705
+ es.onopen = () => {
3706
+ setLiveDot('#3fb950', 'Live — автообновление включено');
3707
+ void loadAgentState();
3708
+ };
3709
+
3710
+ es.addEventListener('data-updated', () => {
3711
+ setLiveDot('#e3b341', 'Обновляю данные…');
3712
+ scheduleRefreshData();
3713
+ });
3714
+
3715
+ es.addEventListener('agent-queue-updated', (e) => {
3716
+ const payload = JSON.parse(e.data || '{}');
3717
+ agentQueueState = Array.isArray(payload.queue) ? payload.queue : [];
3718
+ refreshPathActivityFromState();
3719
+ updateQueueBadge(agentQueueState.length);
3720
+ renderContent();
3721
+ });
3722
+
3723
+ es.addEventListener('agent-run-created', (e) => {
3724
+ const { run } = JSON.parse(e.data || '{}');
3725
+ upsertRunState(run);
3726
+ });
3727
+
3728
+ es.addEventListener('agent-run-updated', (e) => {
3729
+ const { run } = JSON.parse(e.data || '{}');
3730
+ if (!run) return;
3731
+ if (run.runId && agentQueueState.length > 0) {
3732
+ agentQueueState = agentQueueState.filter((q) => q.runId !== run.runId || run.phase === 'queued');
3733
+ updateQueueBadge(agentQueueState.length);
3734
+ }
3735
+ if (['starting', 'running', 'validating'].includes(run.phase)) {
3736
+ agentActiveRun = run;
3737
+ const startedPaths = getTaskFilePaths(run.task, run.featureKey, run.filePath, run.selectedFilePaths);
3738
+ startedPaths.forEach((p) => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3739
+ renderContent();
3740
+ }
3741
+ updateSessionFromRun(run);
3742
+ });
3743
+
3744
+ es.addEventListener('agent-run-finished', (e) => {
3745
+ const { run } = JSON.parse(e.data || '{}');
3746
+ if (!run) return;
3747
+ updateSessionFromRun(run);
3748
+ refreshPathActivityFromState();
3749
+ renderContent();
3696
3750
  });
3697
3751
 
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
- }
3752
+ es.addEventListener('agent-queued', (e) => {
3753
+ const { runId, queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3754
+ updateQueueBadge(queueLength);
3755
+ document.getElementById('agentPanel').classList.add('open');
3756
+ document.getElementById('termBtn').classList.add('term-active');
3757
+ // Track queued paths for spinner
3758
+ getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
3759
+ renderContent();
3760
+ // Append queue notification to the currently running session (or active)
3761
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3762
+ if (targetId) {
3763
+ appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
3764
+ }
3765
+ void loadAgentState();
3766
+ });
3767
+
3768
+ es.addEventListener('agent-started', (e) => {
3769
+ setAgentRunning(true);
3770
+ const { runId, title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3771
+ updateQueueBadge(queueLength);
3772
+ currentRunId = runId || null;
3773
+ // Move paths from queued → running (current task)
3774
+ const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
3775
+ startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3776
+ renderContent();
3777
+ // Close previous session (queue case: agent-done not fired between tasks)
3778
+ if (runningSessionId) {
3779
+ const prev = consoleSessions.find(s => s.id === runningSessionId);
3780
+ if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
3781
+ }
3782
+ const id = runId ? ensureSessionForRun({ runId, title, phase: 'running' }, true) : createSession(title);
3783
+ runningSessionId = id;
3784
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
3785
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
3786
+ });
3787
+
3788
+ es.addEventListener('agent-output', (e) => {
3789
+ const { runId, line, isError, isDim } = JSON.parse(e.data);
3790
+ let targetId = runningSessionId;
3791
+ if (runId) {
3792
+ targetId = runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'running' });
3793
+ if (targetId) runningSessionId = targetId;
3794
+ }
3795
+ if (targetId) {
3796
+ appendToSession(targetId, line, !!isError, !!isDim);
3797
+ if (activeSessionId === targetId) {
3798
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
3799
+ }
3800
+ } else {
3801
+ appendTerminalLine(line, !!isError, !!isDim);
3802
+ }
3803
+ });
3804
+
3805
+ es.addEventListener('agent-done', () => {
3806
+ setAgentRunning(false);
3807
+ if (agentQueueState.length === 0) updateQueueBadge(0);
3808
+ agentRunningPaths.clear();
3809
+ if (runningSessionId) {
3810
+ updateSessionStatus(runningSessionId, 'ok');
3811
+ if (activeSessionId === runningSessionId) {
3812
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
3813
+ }
3707
3814
  runningSessionId = null;
3708
3815
  }
3709
3816
  renderContent();
3710
3817
  });
3711
3818
 
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;
3819
+ es.addEventListener('agent-summary', (e) => {
3820
+ const {
3821
+ runId,
3822
+ validationStats = null,
3823
+ passed = 0, failed = 0, files = [],
3824
+ testedFileCount = files.length,
3825
+ passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
3826
+ failedFileCount = failed > 0 ? 1 : 0,
3827
+ autoFixQueued = false,
3828
+ } = JSON.parse(e.data);
3829
+ const coverageOk = !validationStats || ((validationStats.notCovered || 0) === 0 && (validationStats.blocked || 0) === 0);
3830
+ const allOk = failed === 0 && coverageOk;
3724
3831
  const box = document.createElement('div');
3725
3832
  box.style.cssText = `
3726
3833
  margin: 10px 0 4px;
@@ -3737,33 +3844,33 @@ function connectSSE() {
3737
3844
  <div style="font-size:11px;color:var(--muted);margin-top:4px">
3738
3845
  Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}
3739
3846
  </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');
3847
+ <div style="font-size:11px;color:var(--muted);margin-top:2px">
3848
+ Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
3849
+ </div>
3850
+ ${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>` : ''}
3851
+ ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
3852
+ ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
3853
+ `;
3854
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3855
+ if (targetId) {
3856
+ appendToSession(targetId, box);
3857
+ // Mark session status from test results immediately
3858
+ if (runningSessionId === targetId) {
3859
+ updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
3753
3860
  }
3754
3861
  }
3755
3862
  });
3756
3863
 
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) {
3864
+ es.addEventListener('agent-error', (e) => {
3865
+ const { runId, message, authRequired, notInstalled } = JSON.parse(e.data);
3866
+ if (!runId) setAgentRunning(false);
3867
+ agentRunningPaths.clear();
3868
+ agentQueuedPaths.clear();
3869
+ renderContent();
3870
+
3871
+ // Build error node (plain text or auth/install box)
3872
+ let node = null;
3873
+ if (authRequired || notInstalled) {
3767
3874
  node = document.createElement('div');
3768
3875
  node.style.cssText = 'margin:10px 0 4px;padding:10px 14px;border-radius:8px;border:1px solid var(--red);background:#2a0d0d;font-family:inherit;';
3769
3876
  node.innerHTML = `
@@ -3771,14 +3878,14 @@ function connectSSE() {
3771
3878
  ${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
3879
  ${notInstalled ? `<div style="margin-top:6px;font-size:11px;color:var(--dim)">После установки перезапусти viberadar</div>` : ''}
3773
3880
  `;
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
- }
3881
+ }
3882
+
3883
+ // If no session exists yet (startup check fires before any run), create one
3884
+ const targetId = (runId ? (runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'failed' })) : runningSessionId) || (() => {
3885
+ if (authRequired || notInstalled) {
3886
+ const id = createSession('⚠️ Проверка агента', 'error');
3887
+ return id;
3888
+ }
3782
3889
  return activeSessionId;
3783
3890
  })();
3784
3891
 
@@ -3789,12 +3896,12 @@ function connectSSE() {
3789
3896
  appendToSession(targetId, '❌ ' + (message || 'Ошибка агента'), true);
3790
3897
  }
3791
3898
  updateSessionStatus(targetId, 'error');
3792
- if (activeSessionId === targetId) {
3793
- document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3794
- }
3795
- }
3796
- if (!runId && runningSessionId) runningSessionId = null;
3797
- });
3899
+ if (activeSessionId === targetId) {
3900
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3901
+ }
3902
+ }
3903
+ if (!runId && runningSessionId) runningSessionId = null;
3904
+ });
3798
3905
 
3799
3906
  es.addEventListener('tests-started', (e) => {
3800
3907
  const { testType, count } = JSON.parse(e.data);
@@ -3862,24 +3969,24 @@ function connectSSE() {
3862
3969
  }
3863
3970
  });
3864
3971
 
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'] });
3972
+ es.onerror = () => {
3973
+ setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
3974
+ es.close();
3975
+ setTimeout(connectSSE, 3000);
3976
+ };
3977
+ }
3978
+
3979
+ init().then(async () => {
3980
+ restoreSessions();
3981
+ await loadAgentState();
3982
+ connectSSE();
3983
+ applyTerminalFiltersFromUi();
3984
+ updateToolbarButtonsState();
3985
+ // Sync content padding with terminal panel open state automatically
3986
+ new MutationObserver(() => {
3987
+ const isOpen = document.getElementById('agentPanel').classList.contains('open');
3988
+ document.getElementById('content').classList.toggle('panel-open', isOpen);
3989
+ }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
3883
3990
  });
3884
3991
  </script>
3885
3992
  </body>