viberadar 0.3.55 → 0.3.57

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.
@@ -42,18 +42,18 @@
42
42
  flex-shrink: 0;
43
43
  z-index: 10;
44
44
  }
45
- header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
46
- .header-project { margin-left: auto; font-size: 13px; color: var(--muted); }
47
- .header-time { font-size: 12px; color: var(--dim); }
48
- .header-agent-rights {
49
- font-size: 11px;
50
- color: var(--muted);
51
- padding: 2px 8px;
52
- border: 1px solid var(--border);
53
- border-radius: 999px;
54
- background: var(--bg);
55
- white-space: nowrap;
56
- }
45
+ header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
46
+ .header-project { margin-left: auto; font-size: 13px; color: var(--muted); }
47
+ .header-time { font-size: 12px; color: var(--dim); }
48
+ .header-agent-rights {
49
+ font-size: 11px;
50
+ color: var(--muted);
51
+ padding: 2px 8px;
52
+ border: 1px solid var(--border);
53
+ border-radius: 999px;
54
+ background: var(--bg);
55
+ white-space: nowrap;
56
+ }
57
57
 
58
58
  /* ── Run All Tests button ────────────────────────────────────────────────── */
59
59
  #runAllBtn {
@@ -168,6 +168,32 @@
168
168
  .stat-value { font-size: 20px; font-weight: 700; color: var(--blue); }
169
169
  .stat-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
170
170
 
171
+
172
+ .observability-panel {
173
+ background: var(--bg-card);
174
+ border: 1px solid var(--border);
175
+ border-radius: 8px;
176
+ padding: 14px;
177
+ margin-bottom: 12px;
178
+ }
179
+ .observability-title { font-size: 14px; font-weight: 700; margin-bottom: 8px; }
180
+ .obs-metrics { display:grid; grid-template-columns: repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin-bottom:10px; }
181
+ .obs-metric { background: var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px; }
182
+ .obs-metric-v { font-size:18px; font-weight:700; color: var(--blue); }
183
+ .obs-metric-l { font-size:10px; color: var(--muted); text-transform: uppercase; }
184
+ .obs-columns { display:grid; grid-template-columns: 1fr 1fr; gap:10px; }
185
+ .obs-list { background: var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px; }
186
+ .obs-list h4 { font-size:12px; margin-bottom:6px; color: var(--text); }
187
+ .obs-row { font-size:12px; color: var(--muted); padding:4px 0; border-bottom:1px dashed var(--border); }
188
+ .obs-row:last-child { border-bottom:none; }
189
+ .obs-priority-high { color: var(--red); }
190
+ .obs-priority-medium { color: var(--yellow); }
191
+ .obs-priority-low { color: var(--green); }
192
+ .obs-catalog { margin-top:10px; background: var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px; }
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); }
195
+ .obs-cat-row.head { color: var(--text); font-weight:600; text-transform:uppercase; font-size:10px; }
196
+
171
197
  /* ── Layout ──────────────────────────────────────────────────────────────── */
172
198
  .layout { display: flex; flex: 1; overflow: hidden; }
173
199
 
@@ -183,6 +209,30 @@
183
209
  gap: 14px;
184
210
  }
185
211
 
212
+ .mode-switch {
213
+ display: flex;
214
+ flex-direction: column;
215
+ gap: 6px;
216
+ }
217
+ .mode-switch-btn {
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: space-between;
221
+ gap: 8px;
222
+ width: 100%;
223
+ padding: 8px 10px;
224
+ border: 1px solid var(--border);
225
+ border-radius: 6px;
226
+ background: var(--bg);
227
+ color: var(--muted);
228
+ font-size: 12px;
229
+ cursor: pointer;
230
+ transition: border-color 0.1s, background 0.1s, color 0.1s;
231
+ }
232
+ .mode-switch-btn:hover { background: var(--bg-hover); color: var(--text); }
233
+ .mode-switch-btn.active { border-color: var(--blue); color: var(--text); }
234
+ .mode-switch-hint { font-size: 10px; color: var(--dim); }
235
+
186
236
  .view-tabs {
187
237
  display: flex;
188
238
  background: var(--bg);
@@ -246,6 +296,33 @@
246
296
  .content { flex: 1; overflow-y: auto; padding: 18px 20px; transition: padding-bottom 0.25s ease; }
247
297
  .content.panel-open { padding-bottom: 300px; }
248
298
 
299
+ .onboarding-block {
300
+ background: linear-gradient(135deg, rgba(88,166,255,0.12), rgba(63,185,80,0.08));
301
+ border: 1px solid var(--border);
302
+ border-radius: 10px;
303
+ padding: 14px 16px;
304
+ margin-bottom: 14px;
305
+ }
306
+ .onboarding-block h3 { font-size: 14px; margin-bottom: 6px; }
307
+ .onboarding-block p { font-size: 12px; color: var(--muted); line-height: 1.5; }
308
+
309
+ .obs-grid {
310
+ display: grid;
311
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
312
+ gap: 12px;
313
+ }
314
+ .obs-card {
315
+ background: var(--bg-card);
316
+ border: 1px solid var(--border);
317
+ border-radius: 8px;
318
+ padding: 14px;
319
+ }
320
+ .obs-title { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 10px; }
321
+ .obs-value { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
322
+ .obs-sub { font-size: 12px; color: var(--muted); line-height: 1.4; }
323
+ .obs-list { display: flex; flex-direction: column; gap: 6px; }
324
+ .obs-list-item { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; }
325
+
249
326
  /* ── Feature cards ───────────────────────────────────────────────────────── */
250
327
  .features-grid {
251
328
  display: grid;
@@ -548,6 +625,13 @@
548
625
  .file-row:hover { background: var(--bg-card); }
549
626
  .file-row.active { background: var(--bg-hover); border-left: 2px solid var(--blue); padding-left: 8px; }
550
627
  .file-row-icon { font-size: 12px; flex-shrink: 0; }
628
+ .file-row-select {
629
+ width: 14px;
630
+ height: 14px;
631
+ accent-color: var(--blue);
632
+ cursor: pointer;
633
+ flex-shrink: 0;
634
+ }
551
635
  .file-row-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
552
636
  .file-row-dir { font-size: 11px; color: var(--dim); margin-left: auto; white-space: nowrap; flex-shrink: 0; }
553
637
  .file-row-agent-btn {
@@ -738,11 +822,66 @@
738
822
  white-space: nowrap;
739
823
  }
740
824
  .file-row-more-item:hover { background: var(--border); }
741
- .agent-terminal {
742
- flex: 1;
743
- overflow-y: auto;
744
- padding: 10px 16px;
745
- font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
825
+
826
+ .bulk-actions {
827
+ display: flex;
828
+ align-items: center;
829
+ gap: 8px;
830
+ margin: 0 0 10px;
831
+ padding: 8px;
832
+ background: var(--bg-card);
833
+ border: 1px solid var(--border);
834
+ border-radius: 8px;
835
+ flex-wrap: wrap;
836
+ }
837
+ .bulk-actions-count { font-size: 12px; color: var(--muted); }
838
+ .bulk-actions-btn {
839
+ padding: 6px 10px;
840
+ border-radius: 6px;
841
+ border: 1px solid var(--border);
842
+ background: var(--bg);
843
+ color: var(--text);
844
+ font-size: 12px;
845
+ cursor: pointer;
846
+ }
847
+ .bulk-actions-btn.primary {
848
+ border-color: var(--accent);
849
+ color: var(--accent);
850
+ }
851
+ .bulk-actions-btn:hover:not(:disabled) { background: var(--bg-hover); }
852
+ .bulk-actions-btn:disabled { opacity: 0.4; cursor: not-allowed; }
853
+ .feature-file-filters {
854
+ display: flex;
855
+ align-items: center;
856
+ gap: 8px;
857
+ margin: 0 0 10px;
858
+ }
859
+ .feature-filter-toggle {
860
+ display: inline-flex;
861
+ align-items: center;
862
+ gap: 8px;
863
+ padding: 6px 10px;
864
+ border-radius: 6px;
865
+ border: 1px solid var(--border);
866
+ background: var(--bg-card);
867
+ color: var(--muted);
868
+ font-size: 12px;
869
+ cursor: pointer;
870
+ user-select: none;
871
+ }
872
+ .feature-filter-toggle:hover { background: var(--bg-hover); color: var(--text); }
873
+ .feature-filter-toggle input {
874
+ width: 14px;
875
+ height: 14px;
876
+ accent-color: var(--blue);
877
+ cursor: pointer;
878
+ }
879
+ .feature-filter-meta { color: var(--dim); font-size: 11px; }
880
+ .agent-terminal {
881
+ flex: 1;
882
+ overflow-y: auto;
883
+ padding: 10px 16px;
884
+ font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
746
885
  font-size: 12px;
747
886
  line-height: 1.5;
748
887
  }
@@ -801,10 +940,10 @@
801
940
  <header>
802
941
  <span style="font-size:20px">🔭</span>
803
942
  <h1>VibeRadar</h1>
804
- <span class="header-project" id="projectName">—</span>
805
- <span class="header-time" id="scannedAt"></span>
806
- <span class="header-agent-rights" id="headerAgentRights" title="Права/режим выполнения агента">🔐 —</span>
807
- <button id="runAllBtn" onclick="runAllTests()" title="Запустить все unit и integration тесты">▶ Все тесты</button>
943
+ <span class="header-project" id="projectName">—</span>
944
+ <span class="header-time" id="scannedAt"></span>
945
+ <span class="header-agent-rights" id="headerAgentRights" title="Права/режим выполнения агента">🔐 —</span>
946
+ <button id="runAllBtn" onclick="runAllTests()" title="Запустить все unit и integration тесты">▶ Все тесты</button>
808
947
  <button id="termBtn" onclick="toggleAgentPanel()" title="Показать/скрыть терминал агента">📟 Terminal</button>
809
948
  <div style="position:relative">
810
949
  <button id="agentBtn" onclick="toggleAgentMenu()" title="Настройки агента">🤖 —</button>
@@ -843,6 +982,7 @@
843
982
 
844
983
  <div class="layout">
845
984
  <aside class="sidebar">
985
+ <div class="mode-switch" id="modeSwitch"></div>
846
986
  <div class="view-tabs" id="viewTabs">
847
987
  <div class="view-tab" data-view="features">Features</div>
848
988
  <div class="view-tab" data-view="files">Files</div>
@@ -878,67 +1018,150 @@
878
1018
  <script>
879
1019
  // ─── State ────────────────────────────────────────────────────────────────────
880
1020
  let D = null;
1021
+ let contextMode = 'qa';
881
1022
  let view = 'features';
882
1023
  let searchQuery = '';
883
1024
  let activeTypes = new Set();
884
- let activePanelKey = null;
885
- let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
1025
+ let activePanelKey = null;
1026
+ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
886
1027
  let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
1028
+ let showOnlyUntestedInFeature = false; // source tab in feature detail
1029
+ const selectedSourceFiles = new Set(); // normalized relative paths for batch actions
887
1030
  let e2ePlan = null; // current E2E plan object
888
1031
  let e2ePlanLoading = false;
889
- // ─── Run All Tests button ──────────────────────────────────────────────────────
890
- let runAllRunning = false;
891
-
892
- function clearFeatureHash() {
893
- if (!window.location.hash) return;
894
- history.replaceState(null, '', window.location.pathname + window.location.search);
895
- }
896
-
897
- function buildFeatureTabUrl(featureKey) {
898
- const url = new URL(window.location.href);
899
- url.hash = `feature=${encodeURIComponent(featureKey)}`;
900
- return url.toString();
901
- }
902
-
903
- function openFeatureInNewTab(featureKey) {
904
- window.open(buildFeatureTabUrl(featureKey), '_blank', 'noopener');
905
- }
906
-
907
- function setFeatureDrill(featureKey, syncHash = true) {
908
- view = 'features';
909
- drillFeatureKey = featureKey;
910
- drillTestType = null;
911
- activePanelKey = null;
912
- document.getElementById('panel').classList.remove('open');
913
- if (syncHash) {
914
- if (featureKey) window.location.hash = `feature=${encodeURIComponent(featureKey)}`;
915
- else clearFeatureHash();
916
- }
917
- renderContent();
1032
+ const modeStore = {
1033
+ qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1034
+ observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1035
+ };
1036
+
1037
+ function getModeFromPath(pathname = window.location.pathname) {
1038
+ if (pathname.startsWith('/radar/observability')) return 'observability';
1039
+ return 'qa';
1040
+ }
1041
+
1042
+ function routePathForMode(mode) {
1043
+ return mode === 'observability' ? '/radar/observability' : '/radar/qa';
1044
+ }
1045
+
1046
+ function setModeRoute(mode, replace = false) {
1047
+ const target = routePathForMode(mode) + window.location.search + window.location.hash;
1048
+ if (replace) history.replaceState(null, '', target);
1049
+ else history.pushState(null, '', target);
1050
+ }
1051
+
1052
+ function saveModeState(mode) {
1053
+ modeStore[mode] = {
1054
+ view,
1055
+ searchQuery,
1056
+ activeTypes: new Set(activeTypes),
1057
+ drillFeatureKey,
1058
+ drillTestType,
1059
+ activePanelKey,
1060
+ showOnlyUntestedInFeature,
1061
+ };
918
1062
  }
919
1063
 
920
- function applyHashRoute() {
921
- const hash = (window.location.hash || '').replace(/^#/, '');
922
- if (!hash) {
923
- if (view === 'features' && drillFeatureKey) {
924
- drillFeatureKey = null;
925
- drillTestType = null;
926
- activePanelKey = null;
927
- }
928
- return;
929
- }
930
- const params = new URLSearchParams(hash);
931
- const featureKey = params.get('feature');
932
- if (!featureKey) return;
933
- const isKnownFeature = Array.isArray(D?.features) && D.features.some(f => f.key === featureKey);
934
- if (featureKey === '__unmapped__' || isKnownFeature) {
935
- view = 'features';
936
- drillFeatureKey = featureKey;
937
- drillTestType = null;
938
- activePanelKey = null;
939
- }
1064
+ function restoreModeState(mode) {
1065
+ const state = modeStore[mode];
1066
+ view = state.view;
1067
+ searchQuery = state.searchQuery;
1068
+ activeTypes = new Set(state.activeTypes);
1069
+ drillFeatureKey = state.drillFeatureKey;
1070
+ drillTestType = state.drillTestType;
1071
+ activePanelKey = state.activePanelKey;
1072
+ showOnlyUntestedInFeature = !!state.showOnlyUntestedInFeature;
940
1073
  }
941
1074
 
1075
+ function switchMode(nextMode) {
1076
+ if (nextMode === contextMode) return;
1077
+ saveModeState(contextMode);
1078
+ contextMode = nextMode;
1079
+ restoreModeState(contextMode);
1080
+ if (contextMode === 'observability') {
1081
+ view = 'files';
1082
+ drillFeatureKey = null;
1083
+ drillTestType = null;
1084
+ activePanelKey = null;
1085
+ clearFeatureHash();
1086
+ }
1087
+ setModeRoute(contextMode);
1088
+ document.getElementById('searchInput').value = searchQuery;
1089
+ document.getElementById('panel').classList.remove('open');
1090
+ renderStats();
1091
+ renderSidebar();
1092
+ renderContent();
1093
+ }
1094
+ // ─── Run All Tests button ──────────────────────────────────────────────────────
1095
+ let runAllRunning = false;
1096
+
1097
+ function escapeHtml(text) {
1098
+ return String(text || '')
1099
+ .replace(/&/g, '&amp;')
1100
+ .replace(/</g, '&lt;')
1101
+ .replace(/>/g, '&gt;');
1102
+ }
1103
+
1104
+
1105
+ function clearFeatureHash() {
1106
+ if (!window.location.hash) return;
1107
+ history.replaceState(null, '', window.location.pathname + window.location.search);
1108
+ }
1109
+
1110
+ function buildFeatureTabUrl(featureKey) {
1111
+ const url = new URL(window.location.href);
1112
+ url.hash = `feature=${encodeURIComponent(featureKey)}`;
1113
+ return url.toString();
1114
+ }
1115
+
1116
+ function openFeatureInNewTab(featureKey) {
1117
+ window.open(buildFeatureTabUrl(featureKey), '_blank', 'noopener');
1118
+ }
1119
+
1120
+ function setFeatureDrill(featureKey, syncHash = true) {
1121
+ view = 'features';
1122
+ drillFeatureKey = featureKey;
1123
+ drillTestType = null;
1124
+ activePanelKey = null;
1125
+ selectedSourceFiles.clear();
1126
+ document.getElementById('panel').classList.remove('open');
1127
+ if (syncHash) {
1128
+ if (featureKey) window.location.hash = `feature=${encodeURIComponent(featureKey)}`;
1129
+ else clearFeatureHash();
1130
+ }
1131
+ renderContent();
1132
+ }
1133
+
1134
+ function selectedFilesForFeature(featureKey, visibleSourceFiles = null) {
1135
+ const visibleSet = visibleSourceFiles ? new Set(visibleSourceFiles.map(p => p.replace(/\\/g, '/'))) : null;
1136
+ return Array.from(selectedSourceFiles).filter((relPath) => {
1137
+ if (visibleSet && !visibleSet.has(relPath)) return false;
1138
+ const mod = D?.modules?.find((m) => m.relativePath.replace(/\\/g, '/') === relPath);
1139
+ return !!mod && mod.type !== 'test' && mod.featureKeys?.includes(featureKey);
1140
+ });
1141
+ }
1142
+
1143
+ function applyHashRoute() {
1144
+ const hash = (window.location.hash || '').replace(/^#/, '');
1145
+ if (!hash) {
1146
+ if (view === 'features' && drillFeatureKey) {
1147
+ drillFeatureKey = null;
1148
+ drillTestType = null;
1149
+ activePanelKey = null;
1150
+ }
1151
+ return;
1152
+ }
1153
+ const params = new URLSearchParams(hash);
1154
+ const featureKey = params.get('feature');
1155
+ if (!featureKey) return;
1156
+ const isKnownFeature = Array.isArray(D?.features) && D.features.some(f => f.key === featureKey);
1157
+ if (featureKey === '__unmapped__' || isKnownFeature) {
1158
+ view = 'features';
1159
+ drillFeatureKey = featureKey;
1160
+ drillTestType = null;
1161
+ activePanelKey = null;
1162
+ }
1163
+ }
1164
+
942
1165
  function updateRunAllBtn(state) {
943
1166
  const btn = document.getElementById('runAllBtn');
944
1167
  if (!btn) return;
@@ -1167,10 +1390,13 @@ function setAgentRunning(val) {
1167
1390
  }
1168
1391
 
1169
1392
  // Returns array of normalized relative paths affected by a given task
1170
- function getTaskFilePaths(task, featureKey, filePath) {
1393
+ function getTaskFilePaths(task, featureKey, filePath, selectedFilePaths) {
1171
1394
  if ((task === 'write-tests-file' || task === 'fix-tests') && filePath) {
1172
1395
  return [filePath.replace(/\\/g, '/')];
1173
1396
  }
1397
+ if ((task === 'write-tests-selected' || task === 'refresh-tests-selected') && Array.isArray(selectedFilePaths)) {
1398
+ return selectedFilePaths.map(p => p.replace(/\\/g, '/'));
1399
+ }
1174
1400
  if ((task === 'write-tests' || task === 'fix-tests-all') && featureKey && D?.modules) {
1175
1401
  return D.modules
1176
1402
  .filter(m => m.featureKeys?.includes(featureKey) && m.type !== 'test' && (!m.hasTests || m.testStale))
@@ -1254,36 +1480,36 @@ async function copyPromptForFile(featureKey, relPath, dropdown) {
1254
1480
  }
1255
1481
 
1256
1482
  // ─── Agent menu ─────────────────────────────────────────────────────────────
1257
- const CLAUDE_MODELS = [
1258
- { id: 'claude-sonnet-4-6', checkId: 'amSonnet46Check' },
1259
- { id: 'claude-opus-4-5', checkId: 'amOpus45Check' },
1260
- { id: 'claude-haiku-3-5', checkId: 'amHaiku35Check' },
1261
- ];
1262
-
1263
- function updateAgentRightsInfo() {
1264
- const el = document.getElementById('headerAgentRights');
1265
- if (!el || !D) return;
1266
- const runtime = D.agentRuntime || {};
1267
- const sandbox = runtime.codexSandboxMode || 'read-only';
1268
- const approval = runtime.approvalPolicy || 'never';
1269
-
1270
- if (D.agent === 'codex') {
1271
- el.textContent = `🔐 Codex: ${sandbox}, approval: ${approval}`;
1272
- el.title = 'Права/режим выполнения Codex';
1273
- return;
1274
- }
1275
- if (D.agent === 'claude') {
1276
- el.textContent = '🔐 Claude: managed by Claude CLI';
1277
- el.title = 'Права определяются Claude CLI';
1278
- return;
1279
- }
1280
- el.textContent = '🔐 Агент не выбран';
1281
- el.title = 'Выбери агента в меню';
1282
- }
1283
-
1284
- function updateAgentBtn() {
1285
- const btn = document.getElementById('agentBtn');
1286
- if (!D) return;
1483
+ const CLAUDE_MODELS = [
1484
+ { id: 'claude-sonnet-4-6', checkId: 'amSonnet46Check' },
1485
+ { id: 'claude-opus-4-5', checkId: 'amOpus45Check' },
1486
+ { id: 'claude-haiku-3-5', checkId: 'amHaiku35Check' },
1487
+ ];
1488
+
1489
+ function updateAgentRightsInfo() {
1490
+ const el = document.getElementById('headerAgentRights');
1491
+ if (!el || !D) return;
1492
+ const runtime = D.agentRuntime || {};
1493
+ const sandbox = runtime.codexSandboxMode || 'read-only';
1494
+ const approval = runtime.approvalPolicy || 'never';
1495
+
1496
+ if (D.agent === 'codex') {
1497
+ el.textContent = `🔐 Codex: ${sandbox}, approval: ${approval}`;
1498
+ el.title = 'Права/режим выполнения Codex';
1499
+ return;
1500
+ }
1501
+ if (D.agent === 'claude') {
1502
+ el.textContent = '🔐 Claude: managed by Claude CLI';
1503
+ el.title = 'Права определяются Claude CLI';
1504
+ return;
1505
+ }
1506
+ el.textContent = '🔐 Агент не выбран';
1507
+ el.title = 'Выбери агента в меню';
1508
+ }
1509
+
1510
+ function updateAgentBtn() {
1511
+ const btn = document.getElementById('agentBtn');
1512
+ if (!D) return;
1287
1513
  const a = D.agent;
1288
1514
  const m = D.model;
1289
1515
  // Button label: agent + model shortname
@@ -1343,7 +1569,7 @@ async function reauthAgent() {
1343
1569
  await fetch('/api/agent-reauth', { method: 'POST' });
1344
1570
  }
1345
1571
 
1346
- async function runAgentTask(task, featureKey, filePath) {
1572
+ async function runAgentTask(task, featureKey, filePath, selectedFilePaths) {
1347
1573
  document.getElementById('agentPanel').classList.add('open');
1348
1574
  document.getElementById('termBtn').classList.add('term-active');
1349
1575
  if (!agentRunning) {
@@ -1352,7 +1578,12 @@ async function runAgentTask(task, featureKey, filePath) {
1352
1578
  await fetch('/api/run-agent', {
1353
1579
  method: 'POST',
1354
1580
  headers: { 'Content-Type': 'application/json' },
1355
- body: JSON.stringify({ task, featureKey, filePath: filePath || undefined }),
1581
+ body: JSON.stringify({
1582
+ task,
1583
+ featureKey,
1584
+ filePath: filePath || undefined,
1585
+ selectedFilePaths: Array.isArray(selectedFilePaths) ? selectedFilePaths : undefined,
1586
+ }),
1356
1587
  });
1357
1588
  }
1358
1589
 
@@ -1425,28 +1656,40 @@ async function init() {
1425
1656
  ]);
1426
1657
  D = await res.json();
1427
1658
 
1428
- if (statusRes) {
1429
- const status = await statusRes.json().catch(() => ({}));
1430
- D.agentRunning = status.agentRunning ?? false;
1431
- }
1432
- updateAgentBtn();
1433
- updateAgentRightsInfo();
1434
-
1435
- document.getElementById('projectName').textContent = D.projectName;
1436
- document.getElementById('scannedAt').textContent =
1437
- new Date(D.scannedAt).toLocaleTimeString();
1438
-
1439
- view = D.hasConfig ? 'features' : 'files';
1440
- applyHashRoute();
1659
+ if (statusRes) {
1660
+ const status = await statusRes.json().catch(() => ({}));
1661
+ D.agentRunning = status.agentRunning ?? false;
1662
+ }
1663
+ updateAgentBtn();
1664
+ updateAgentRightsInfo();
1665
+
1666
+ document.getElementById('projectName').textContent = D.projectName;
1667
+ document.getElementById('scannedAt').textContent =
1668
+ new Date(D.scannedAt).toLocaleTimeString();
1669
+
1670
+ contextMode = getModeFromPath();
1671
+ view = D.hasConfig ? 'features' : 'files';
1672
+ applyHashRoute();
1441
1673
 
1442
1674
  if (!D.hasConfig) {
1443
1675
  document.querySelector('[data-view="features"]').classList.add('disabled');
1676
+ modeStore.qa.view = 'files';
1677
+ }
1678
+ modeStore.qa.view = D.hasConfig ? modeStore.qa.view : 'files';
1679
+ restoreModeState(contextMode);
1680
+ if (contextMode === 'observability') {
1681
+ view = 'files';
1682
+ drillFeatureKey = null;
1683
+ drillTestType = null;
1684
+ activePanelKey = null;
1444
1685
  }
1686
+ setModeRoute(contextMode, true);
1687
+ document.getElementById('searchInput').value = searchQuery;
1445
1688
 
1446
1689
  document.getElementById('loading').style.display = 'none';
1447
- renderStats();
1448
- renderSidebar();
1449
- renderContent();
1690
+ renderStats();
1691
+ renderSidebar();
1692
+ renderContent();
1450
1693
  } catch (err) {
1451
1694
  document.getElementById('loading').textContent = '❌ Failed to load: ' + err.message;
1452
1695
  }
@@ -1460,7 +1703,21 @@ function renderStats() {
1460
1703
  const pct = src.length ? Math.round(tested / src.length * 100) : 0;
1461
1704
 
1462
1705
  let items;
1463
- if (D.hasConfig && D.features) {
1706
+ if (contextMode === 'observability') {
1707
+ const serviceSources = src.filter(m => m.type === 'service' || m.type === 'util' || m.type === 'other');
1708
+ const errorSignal = Object.values(D.testErrors || {}).reduce((acc, v) => acc + (v?.failed || 0), 0);
1709
+ const totalBytes = src.reduce((acc, m) => acc + (m.size || 0), 0);
1710
+ const avgKb = src.length ? Math.round(totalBytes / src.length / 1024) : 0;
1711
+ const noiseRatio = src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0;
1712
+ const missingStructured = serviceSources.filter(m => !m.hasTests).length;
1713
+ items = [
1714
+ { v: serviceSources.length, l: 'Log Sources' },
1715
+ { v: avgKb + 'kb', l: 'Avg Volume/File' },
1716
+ { v: noiseRatio + '%', l: 'Noise Ratio' },
1717
+ { v: errorSignal, l: 'Error Signal', c: errorSignal ? '#f85149' : undefined },
1718
+ { v: missingStructured, l: 'Missing Fields', c: missingStructured ? '#e3b341' : undefined },
1719
+ ];
1720
+ } else if (D.hasConfig && D.features) {
1464
1721
  const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
1465
1722
  items = [
1466
1723
  { v: D.features.length, l: 'Features' },
@@ -1486,13 +1743,44 @@ function renderStats() {
1486
1743
  ).join('');
1487
1744
  }
1488
1745
 
1746
+ function renderModeSwitch() {
1747
+ const root = document.getElementById('modeSwitch');
1748
+ const modes = [
1749
+ { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
1750
+ { key: 'observability', label: 'Observability', hint: 'Логи, шум, error signal' },
1751
+ ];
1752
+ root.innerHTML = modes.map(m => `
1753
+ <button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
1754
+ <span>${m.label}</span>
1755
+ <span class="mode-switch-hint">${m.hint}</span>
1756
+ </button>
1757
+ `).join('');
1758
+ root.querySelectorAll('.mode-switch-btn').forEach(btn => {
1759
+ btn.onclick = () => switchMode(btn.dataset.mode);
1760
+ });
1761
+ }
1762
+
1489
1763
  // ─── Sidebar ──────────────────────────────────────────────────────────────────
1490
1764
  function renderSidebar() {
1765
+ renderModeSwitch();
1766
+ const tabs = document.getElementById('viewTabs');
1767
+ const extra = document.getElementById('sidebarExtra');
1768
+
1769
+ if (contextMode === 'observability') {
1770
+ tabs.style.display = 'none';
1771
+ extra.innerHTML = `
1772
+ <div class="sidebar-label">Observability focus</div>
1773
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
1774
+ Источники логов и качество сигналов для triage инцидентов.
1775
+ </div>`;
1776
+ return;
1777
+ }
1778
+
1779
+ tabs.style.display = 'flex';
1491
1780
  document.querySelectorAll('.view-tab').forEach(t =>
1492
1781
  t.classList.toggle('active', t.dataset.view === view)
1493
1782
  );
1494
1783
 
1495
- const extra = document.getElementById('sidebarExtra');
1496
1784
  if (view !== 'files') { extra.innerHTML = ''; return; }
1497
1785
 
1498
1786
  const types = [...new Set(D.modules.map(m => m.type))].sort();
@@ -1528,6 +1816,11 @@ function renderSidebar() {
1528
1816
  // ─── Content ──────────────────────────────────────────────────────────────────
1529
1817
  function renderContent() {
1530
1818
  const c = document.getElementById('content');
1819
+ if (contextMode === 'observability') {
1820
+ renderObservability(c);
1821
+ return;
1822
+ }
1823
+
1531
1824
  if (view === 'features') {
1532
1825
  if (drillFeatureKey === '__unmapped__') renderUnmappedDetail(c);
1533
1826
  else if (drillFeatureKey) renderFeatureDetail(c);
@@ -1537,6 +1830,66 @@ function renderContent() {
1537
1830
  }
1538
1831
  }
1539
1832
 
1833
+ function renderQaOnboarding() {
1834
+ return `<div class="onboarding-block"><h3>QA Coverage: что это?</h3><p>Этот экран помогает найти пробелы в тестах: сначала проверь покрытие по фичам, затем открой критичные непокрытые файлы и запусти генерацию/фиксы тестов.</p></div>`;
1835
+ }
1836
+
1837
+ function renderObservability(c) {
1838
+ const src = D.modules.filter(m => m.type !== 'test');
1839
+ const sourceGroups = [
1840
+ { label: 'Service', count: src.filter(m => m.type === 'service').length },
1841
+ { label: 'Util', count: src.filter(m => m.type === 'util').length },
1842
+ { label: 'Other', count: src.filter(m => m.type === 'other').length },
1843
+ ].filter(x => x.count > 0);
1844
+ const errorSignal = Object.values(D.testErrors || {}).reduce((acc, v) => acc + (v?.failed || 0), 0);
1845
+ const missingStructured = src.filter(m => (m.type === 'service' || m.type === 'util') && !m.hasTests).slice(0, 8);
1846
+ const noisy = [...src]
1847
+ .sort((a, b) => (b.size || 0) - (a.size || 0))
1848
+ .slice(0, 5)
1849
+ .map(m => ({ name: m.name, rel: m.relativePath, kb: Math.round((m.size || 0) / 1024) }));
1850
+
1851
+ c.innerHTML = `
1852
+ <div class="onboarding-block">
1853
+ <h3>Observability: что это?</h3>
1854
+ <p>Экран для контроля signal-to-noise: откуда идут логи, где растёт объём, сколько шума и какие поля/сигналы нужно структурировать в первую очередь.</p>
1855
+ </div>
1856
+
1857
+ <div class="obs-grid">
1858
+ <div class="obs-card">
1859
+ <div class="obs-title">Источники логов</div>
1860
+ <div class="obs-list">
1861
+ ${sourceGroups.map(g => `<div class="obs-list-item"><span>${g.label}</span><strong>${g.count}</strong></div>`).join('') || '<div class="obs-sub">Нет данных</div>'}
1862
+ </div>
1863
+ </div>
1864
+
1865
+ <div class="obs-card">
1866
+ <div class="obs-title">Volume (top files)</div>
1867
+ <div class="obs-list">
1868
+ ${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>'}
1869
+ </div>
1870
+ </div>
1871
+
1872
+ <div class="obs-card">
1873
+ <div class="obs-title">Noise ratio</div>
1874
+ <div class="obs-value">${src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0}%</div>
1875
+ <div class="obs-sub">Доля источников без тестового контроля (proxy для noisy логирования).</div>
1876
+ </div>
1877
+
1878
+ <div class="obs-card">
1879
+ <div class="obs-title">Error signal</div>
1880
+ <div class="obs-value" style="color:${errorSignal ? 'var(--red)' : 'var(--green)'}">${errorSignal}</div>
1881
+ <div class="obs-sub">Сумма упавших тестов из последнего прогона как индикатор нестабильности сигналов.</div>
1882
+ </div>
1883
+
1884
+ <div class="obs-card" style="grid-column:1 / -1">
1885
+ <div class="obs-title">Missing structured fields (candidates)</div>
1886
+ <div class="obs-list">
1887
+ ${missingStructured.map(m => `<div class="obs-list-item"><span>${m.relativePath}</span><strong>needs schema</strong></div>`).join('') || '<div class="obs-sub">Явных кандидатов не найдено</div>'}
1888
+ </div>
1889
+ </div>
1890
+ </div>`;
1891
+ }
1892
+
1540
1893
  function backToFeatureDetail() {
1541
1894
  drillTestType = null;
1542
1895
  activePanelKey = null;
@@ -1544,9 +1897,62 @@ function backToFeatureDetail() {
1544
1897
  renderContent();
1545
1898
  }
1546
1899
 
1547
- function backToFeatures() {
1548
- setFeatureDrill(null);
1549
- }
1900
+ function backToFeatures() {
1901
+ setFeatureDrill(null);
1902
+ }
1903
+
1904
+
1905
+ function toPct(v) {
1906
+ return Math.round((v || 0) * 100) + '%';
1907
+ }
1908
+
1909
+ function renderObservabilityOverview(c) {
1910
+ const o = D.observability;
1911
+ if (!o) return '';
1912
+ const metrics = [
1913
+ ['noise_ratio', toPct(o.metrics.noise_ratio)],
1914
+ ['error_actionability', toPct(o.metrics.error_actionability)],
1915
+ ['structured_completeness', toPct(o.metrics.structured_completeness)],
1916
+ ['coverage_of_key_flows', toPct(o.metrics.coverage_of_key_flows)],
1917
+ ];
1918
+ const noisy = (o.topNoisyPatterns || []).slice(0, 5).map(i =>
1919
+ `<div class="obs-row"><span class="obs-priority-${i.priority}">[${i.priority}]</span> ${escapeHtml(i.pattern)} · x${i.count} → <b>${i.recommendation}</b></div>`
1920
+ ).join('') || '<div class="obs-row">Нет шумных паттернов</div>';
1921
+
1922
+ const missing = (o.missingCriticalLogs || []).slice(0, 5).map(i =>
1923
+ `<div class="obs-row"><span class="obs-priority-${i.priority}">[${i.priority}]</span> ${escapeHtml(i.pattern)} → <b>${i.recommendation}</b></div>`
1924
+ ).join('') || '<div class="obs-row">Критичные логи покрыты</div>';
1925
+
1926
+ const catalogRows = (o.catalog || []).slice(0, 10).map(i =>
1927
+ `<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>`
1928
+ ).join('');
1929
+
1930
+ return `
1931
+ <div class="observability-panel">
1932
+ <div class="observability-title">Observability</div>
1933
+ <div class="obs-metrics">
1934
+ ${metrics.map(([l,v]) => `<div class="obs-metric"><div class="obs-metric-v">${v}</div><div class="obs-metric-l">${l}</div></div>`).join('')}
1935
+ </div>
1936
+ <div class="obs-columns">
1937
+ <div class="obs-list">
1938
+ <h4>Top noisy patterns</h4>
1939
+ ${noisy}
1940
+ </div>
1941
+ <div class="obs-list">
1942
+ <h4>Missing critical logs</h4>
1943
+ ${missing}
1944
+ </div>
1945
+ </div>
1946
+ <div class="obs-catalog">
1947
+ <h4>Каталог источников логов (top 10)</h4>
1948
+ <div class="obs-cat-row head"><span>module</span><span>level</span><span>format</span><span>frequency</span><span>owner</span><span>action</span></div>
1949
+ ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
1950
+ </div>
1951
+ <div class="obs-row" style="margin-top:8px">
1952
+ Классификация: мусор ${o.classification.trash} · полезно ${o.classification.useful} · критично ${o.classification.critical}
1953
+ </div>
1954
+ </div>`;
1955
+ }
1550
1956
 
1551
1957
  function renderFeatureCards(c) {
1552
1958
  if (!D.hasConfig || !D.features) {
@@ -1579,7 +1985,7 @@ function renderFeatureCards(c) {
1579
1985
  </div>
1580
1986
  </div>` : '';
1581
1987
 
1582
- c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
1988
+ c.innerHTML = setupBanner + renderObservabilityOverview(c) + '<div class="features-grid" id="featGrid"></div>';
1583
1989
  const grid = document.getElementById('featGrid');
1584
1990
 
1585
1991
  list.forEach(f => {
@@ -1622,22 +2028,22 @@ function renderFeatureCards(c) {
1622
2028
  ▶ Написать тесты (${f.fileCount - f.testedCount} без тестов)
1623
2029
  </button>` : ''}
1624
2030
  </div>`;
1625
- card.onmousedown = (e) => {
1626
- if (e.button === 1 && !e.target.closest('.agent-card-btn')) e.preventDefault();
1627
- };
1628
- card.onauxclick = (e) => {
1629
- if (e.button !== 1 || e.target.closest('.agent-card-btn')) return;
1630
- e.preventDefault();
1631
- openFeatureInNewTab(f.key);
1632
- };
1633
- card.onclick = (e) => {
1634
- if (e.target.closest('.agent-card-btn')) return; // don't drill on agent btn click
1635
- if (e.metaKey || e.ctrlKey) {
1636
- openFeatureInNewTab(f.key);
1637
- return;
1638
- }
1639
- setFeatureDrill(f.key);
1640
- };
2031
+ card.onmousedown = (e) => {
2032
+ if (e.button === 1 && !e.target.closest('.agent-card-btn')) e.preventDefault();
2033
+ };
2034
+ card.onauxclick = (e) => {
2035
+ if (e.button !== 1 || e.target.closest('.agent-card-btn')) return;
2036
+ e.preventDefault();
2037
+ openFeatureInNewTab(f.key);
2038
+ };
2039
+ card.onclick = (e) => {
2040
+ if (e.target.closest('.agent-card-btn')) return; // don't drill on agent btn click
2041
+ if (e.metaKey || e.ctrlKey) {
2042
+ openFeatureInNewTab(f.key);
2043
+ return;
2044
+ }
2045
+ setFeatureDrill(f.key);
2046
+ };
1641
2047
  const agentBtn = card.querySelector('.agent-card-btn');
1642
2048
  if (agentBtn) {
1643
2049
  agentBtn.onclick = (e) => {
@@ -1678,21 +2084,21 @@ function renderFeatureCards(c) {
1678
2084
  <span class="feature-progress-label" style="color:var(--dim)">нет привязки</span>
1679
2085
  </div>
1680
2086
  </div>`;
1681
- card.onmousedown = (e) => {
1682
- if (e.button === 1) e.preventDefault();
1683
- };
1684
- card.onauxclick = (e) => {
1685
- if (e.button !== 1) return;
1686
- e.preventDefault();
1687
- openFeatureInNewTab('__unmapped__');
1688
- };
1689
- card.onclick = (e) => {
1690
- if (e.metaKey || e.ctrlKey) {
1691
- openFeatureInNewTab('__unmapped__');
1692
- return;
1693
- }
1694
- setFeatureDrill('__unmapped__');
1695
- };
2087
+ card.onmousedown = (e) => {
2088
+ if (e.button === 1) e.preventDefault();
2089
+ };
2090
+ card.onauxclick = (e) => {
2091
+ if (e.button !== 1) return;
2092
+ e.preventDefault();
2093
+ openFeatureInNewTab('__unmapped__');
2094
+ };
2095
+ card.onclick = (e) => {
2096
+ if (e.metaKey || e.ctrlKey) {
2097
+ openFeatureInNewTab('__unmapped__');
2098
+ return;
2099
+ }
2100
+ setFeatureDrill('__unmapped__');
2101
+ };
1696
2102
  grid.appendChild(card);
1697
2103
  }
1698
2104
  }
@@ -1884,11 +2290,12 @@ function renderFeatureDetail(c) {
1884
2290
  return;
1885
2291
  }
1886
2292
 
1887
- const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
1888
- const src = mods.filter(m => m.type !== 'test');
1889
- const tst = mods.filter(m => m.type === 'test');
1890
- const testedCount = src.filter(m => m.hasTests).length;
1891
- const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
2293
+ const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
2294
+ const src = mods.filter(m => m.type !== 'test');
2295
+ const untestedSrc = src.filter(m => !m.hasTests);
2296
+ const tst = mods.filter(m => m.type === 'test');
2297
+ const testedCount = src.filter(m => m.hasTests).length;
2298
+ const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
1892
2299
 
1893
2300
  const unitCount = feat.unitTestCount ?? tst.filter(m => m.testType === 'unit').length;
1894
2301
  const integrationCount = feat.integrationTestCount ?? tst.filter(m => m.testType === 'integration').length;
@@ -1900,20 +2307,28 @@ function renderFeatureDetail(c) {
1900
2307
  const integrationFailed = tst.filter(m => m.testType === 'integration' && te[m.relativePath.replace(/\\/g, '/')]).length;
1901
2308
  const e2eFailed = tst.filter(m => m.testType === 'e2e' && te[m.relativePath.replace(/\\/g, '/')]).length;
1902
2309
 
1903
- // Determine what list to show based on active tab
1904
- // null or 'source' → source files; test type → test files of that type
1905
- const activeTab = drillTestType || 'source';
1906
- const listFiles = activeTab === 'source' ? src : tst.filter(m => m.testType === activeTab);
1907
- const isTestList = activeTab !== 'source';
1908
- const meta = TEST_TYPE_META[activeTab];
1909
- const listLabel = meta
1910
- ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
1911
- : `📁 Файлы фичи (${listFiles.length})`;
2310
+ // Determine what list to show based on active tab
2311
+ // null or 'source' → source files; test type → test files of that type
2312
+ const activeTab = drillTestType || 'source';
2313
+ const sourceList = showOnlyUntestedInFeature ? untestedSrc : src;
2314
+ const listFiles = activeTab === 'source' ? sourceList : tst.filter(m => m.testType === activeTab);
2315
+ const isTestList = activeTab !== 'source';
2316
+ const showUntestedToggle = activeTab === 'source';
2317
+ const meta = TEST_TYPE_META[activeTab];
2318
+ const listLabel = meta
2319
+ ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
2320
+ : `📁 Файлы фичи (${listFiles.length})`;
1912
2321
 
1913
2322
  const q = searchQuery.toLowerCase();
1914
2323
  const filtered = q ? listFiles.filter(m =>
1915
2324
  m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
1916
2325
  ) : listFiles;
2326
+ const selectableSource = !isTestList && !!D.agent;
2327
+ const visibleSourcePaths = selectableSource
2328
+ ? filtered.map(m => m.relativePath.replace(/\\/g, '/'))
2329
+ : [];
2330
+ const selectedVisible = selectableSource ? selectedFilesForFeature(drillFeatureKey, filtered.map(m => m.relativePath)) : [];
2331
+ const selectedCount = selectedVisible.length;
1917
2332
 
1918
2333
  c.innerHTML = `
1919
2334
  <div class="drill-header">
@@ -1939,16 +2354,81 @@ function renderFeatureDetail(c) {
1939
2354
  ${testTypeCard('integration', 'Integration', '🔗', '#58a6ff', integrationCount, activeTab === 'integration', drillFeatureKey, integrationFailed)}
1940
2355
  ${testTypeCard('e2e', 'E2E', '🎭', '#d2a8ff', e2eCount, activeTab === 'e2e', drillFeatureKey, e2eFailed)}
1941
2356
  </div>
1942
-
1943
- <div class="drill-section-label">${listLabel}</div>
1944
- <div class="file-rows" id="fileRows">
1945
- ${filtered.length === 0
1946
- ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
1947
- ${isTestList ? 'Нет тестов этого типа для данной фичи' : 'Нет файлов — возможно паттерны в конфиге не совпадают'}
1948
- </div>`
1949
- : filtered.map(m => fileRow(m, isTestList, drillFeatureKey)).join('')
1950
- }
1951
- </div>`;
2357
+
2358
+ <div class="drill-section-label">${listLabel}</div>
2359
+ ${showUntestedToggle ? `
2360
+ <div class="feature-file-filters">
2361
+ <label class="feature-filter-toggle">
2362
+ <input type="checkbox" id="untestedOnlyToggle" ${showOnlyUntestedInFeature ? 'checked' : ''}>
2363
+ <span>Только без тестов</span>
2364
+ <span class="feature-filter-meta">(${untestedSrc.length})</span>
2365
+ </label>
2366
+ </div>` : ''}
2367
+ ${selectableSource ? `
2368
+ <div class="bulk-actions">
2369
+ <span class="bulk-actions-count">Выбрано файлов: <b>${selectedCount}</b></span>
2370
+ <button class="bulk-actions-btn" id="bulkSelectAllBtn" ${visibleSourcePaths.length === 0 ? 'disabled' : ''}>☑ Выбрать все (${visibleSourcePaths.length})</button>
2371
+ <button class="bulk-actions-btn" id="bulkClearBtn" ${selectedCount === 0 ? 'disabled' : ''}>Снять выбор</button>
2372
+ <button class="bulk-actions-btn primary" id="bulkWriteTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>✍ Написать тесты для выбранных</button>
2373
+ <button class="bulk-actions-btn" id="bulkRefreshTestsBtn" ${selectedCount === 0 ? 'disabled' : ''}>↻ Актуализировать тесты</button>
2374
+ </div>` : ''}
2375
+ <div class="file-rows" id="fileRows">
2376
+ ${filtered.length === 0
2377
+ ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
2378
+ ${isTestList
2379
+ ? 'Нет тестов этого типа для данной фичи'
2380
+ : (showOnlyUntestedInFeature
2381
+ ? 'Все файлы этой фичи уже покрыты тестами'
2382
+ : 'Нет файлов — возможно паттерны в конфиге не совпадают')}
2383
+ </div>`
2384
+ : filtered.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
2385
+ }
2386
+ </div>`;
2387
+
2388
+ const untestedOnlyToggle = document.getElementById('untestedOnlyToggle');
2389
+ if (untestedOnlyToggle) {
2390
+ untestedOnlyToggle.onchange = (e) => {
2391
+ showOnlyUntestedInFeature = !!e.target.checked;
2392
+ renderContent();
2393
+ };
2394
+ }
2395
+
2396
+ if (selectableSource) {
2397
+ const allVisibleSelected = visibleSourcePaths.length > 0 && visibleSourcePaths.every((p) => selectedSourceFiles.has(p));
2398
+ const bulkSelectAllBtn = document.getElementById('bulkSelectAllBtn');
2399
+ if (bulkSelectAllBtn) {
2400
+ bulkSelectAllBtn.textContent = allVisibleSelected
2401
+ ? `☑ Выбраны все (${visibleSourcePaths.length})`
2402
+ : `☑ Выбрать все (${visibleSourcePaths.length})`;
2403
+ bulkSelectAllBtn.onclick = () => {
2404
+ visibleSourcePaths.forEach((p) => selectedSourceFiles.add(p));
2405
+ renderContent();
2406
+ };
2407
+ }
2408
+ const bulkClearBtn = document.getElementById('bulkClearBtn');
2409
+ if (bulkClearBtn) {
2410
+ bulkClearBtn.onclick = () => {
2411
+ selectedVisible.forEach((p) => selectedSourceFiles.delete(p));
2412
+ renderContent();
2413
+ };
2414
+ }
2415
+ const bulkWriteTestsBtn = document.getElementById('bulkWriteTestsBtn');
2416
+ if (bulkWriteTestsBtn) {
2417
+ bulkWriteTestsBtn.onclick = () => {
2418
+ runAgentTask('write-tests-selected', drillFeatureKey, null, selectedVisible);
2419
+ selectedVisible.forEach((p) => selectedSourceFiles.delete(p));
2420
+ renderContent();
2421
+ };
2422
+ }
2423
+ const bulkRefreshTestsBtn = document.getElementById('bulkRefreshTestsBtn');
2424
+ if (bulkRefreshTestsBtn) {
2425
+ bulkRefreshTestsBtn.onclick = () => {
2426
+ runAgentTask('refresh-tests-selected', drillFeatureKey, null, selectedVisible);
2427
+ selectedVisible.forEach((p) => selectedSourceFiles.delete(p));
2428
+ renderContent();
2429
+ };
2430
+ }
2431
+ }
1952
2432
 
1953
2433
  c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
1954
2434
  card.onclick = async () => {
@@ -2106,7 +2586,7 @@ function renderUnmappedDetail(c) {
2106
2586
  });
2107
2587
  }
2108
2588
 
2109
- function fileRow(m, isTest = false, featureKey = null) {
2589
+ function fileRow(m, isTest = false, featureKey = null, selectable = false) {
2110
2590
  const relPath = m.relativePath.replace(/\\/g, '/');
2111
2591
  const parts = relPath.split('/');
2112
2592
  const name = parts[parts.length - 1];
@@ -2119,6 +2599,7 @@ function fileRow(m, isTest = false, featureKey = null) {
2119
2599
  ? `<span class="file-agent-spinner ${agentState}" title="${agentState === 'running' ? 'Агент работает…' : 'В очереди…'}"></span>`
2120
2600
  : (testErr ? '❌' : isTest ? '🧪' : (m.testStale ? '⚠️' : m.hasTests ? '✅' : '⬜'));
2121
2601
  const isActive = activePanelKey === m.id;
2602
+ const isSelected = selectedSourceFiles.has(relPath);
2122
2603
 
2123
2604
  // Write-test button for source files
2124
2605
  // In feature drill-down (featureKey set), show button even for isInfra files — user explicitly added them to the feature
@@ -2167,8 +2648,14 @@ function fileRow(m, isTest = false, featureKey = null) {
2167
2648
  </div>`
2168
2649
  : '';
2169
2650
 
2651
+ const selectBox = selectable
2652
+ ? `<input type="checkbox" class="file-row-select" ${isSelected ? 'checked' : ''}
2653
+ onclick="event.stopPropagation();toggleSourceSelection('${relPath}', this.checked)">`
2654
+ : '';
2655
+
2170
2656
  return `
2171
2657
  <div class="file-row${isActive ? ' active' : ''}${testErr ? ' has-errors' : ''}" data-id="${m.id}">
2658
+ ${selectBox}
2172
2659
  <span class="file-row-icon">${icon}</span>
2173
2660
  <span class="file-row-name">${name}</span>
2174
2661
  ${errBadge}
@@ -2180,6 +2667,13 @@ function fileRow(m, isTest = false, featureKey = null) {
2180
2667
  ${errHtml}`;
2181
2668
  }
2182
2669
 
2670
+ function toggleSourceSelection(relPath, checked) {
2671
+ const n = relPath.replace(/\\/g, '/');
2672
+ if (checked) selectedSourceFiles.add(n);
2673
+ else selectedSourceFiles.delete(n);
2674
+ renderContent();
2675
+ }
2676
+
2183
2677
  function renderModuleGrid(c) {
2184
2678
  const q = searchQuery.toLowerCase();
2185
2679
  const list = D.modules.filter(m => {
@@ -2429,36 +2923,54 @@ function closePanel() {
2429
2923
  }
2430
2924
 
2431
2925
  // ─── Events ───────────────────────────────────────────────────────────────────
2432
- document.querySelectorAll('.view-tab').forEach(tab => {
2433
- tab.onclick = () => {
2434
- if (tab.classList.contains('disabled')) return;
2435
- view = tab.dataset.view;
2436
- drillFeatureKey = null;
2926
+ document.querySelectorAll('.view-tab').forEach(tab => {
2927
+ tab.onclick = () => {
2928
+ if (contextMode !== 'qa') return;
2929
+ if (tab.classList.contains('disabled')) return;
2930
+ view = tab.dataset.view;
2931
+ drillFeatureKey = null;
2437
2932
  drillTestType = null;
2933
+ selectedSourceFiles.clear();
2438
2934
  activePanelKey = null;
2439
2935
  searchQuery = '';
2440
- activeTypes.clear();
2441
- document.getElementById('searchInput').value = '';
2442
- document.getElementById('panel').classList.remove('open');
2443
- if (view !== 'features') clearFeatureHash();
2444
- renderSidebar();
2445
- renderContent();
2446
- };
2447
- });
2936
+ activeTypes.clear();
2937
+ document.getElementById('searchInput').value = '';
2938
+ document.getElementById('panel').classList.remove('open');
2939
+ if (view !== 'features') clearFeatureHash();
2940
+ renderSidebar();
2941
+ renderContent();
2942
+ };
2943
+ });
2448
2944
 
2449
2945
  document.getElementById('searchInput').oninput = e => {
2450
2946
  searchQuery = e.target.value.toLowerCase();
2451
2947
  renderContent();
2452
2948
  };
2453
2949
 
2454
- document.getElementById('panelClose').onclick = closePanel;
2455
- document.addEventListener('keydown', e => { if (e.key === 'Escape') closePanel(); });
2456
- window.addEventListener('hashchange', () => {
2457
- if (!D) return;
2458
- applyHashRoute();
2459
- renderSidebar();
2460
- renderContent();
2461
- });
2950
+ document.getElementById('panelClose').onclick = closePanel;
2951
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closePanel(); });
2952
+ window.addEventListener('hashchange', () => {
2953
+ if (!D || contextMode !== 'qa') return;
2954
+ applyHashRoute();
2955
+ renderSidebar();
2956
+ renderContent();
2957
+ });
2958
+
2959
+
2960
+ window.addEventListener('popstate', () => {
2961
+ if (!D) return;
2962
+ const routeMode = getModeFromPath();
2963
+ if (routeMode !== contextMode) {
2964
+ saveModeState(contextMode);
2965
+ contextMode = routeMode;
2966
+ restoreModeState(contextMode);
2967
+ }
2968
+ if (contextMode === 'qa') applyHashRoute();
2969
+ document.getElementById('searchInput').value = searchQuery;
2970
+ renderStats();
2971
+ renderSidebar();
2972
+ renderContent();
2973
+ });
2462
2974
 
2463
2975
  // ─── Live reload ──────────────────────────────────────────────────────────────
2464
2976
  function setLiveDot(color, title) {
@@ -2476,11 +2988,11 @@ async function refreshData() {
2476
2988
  document.getElementById('scannedAt').textContent =
2477
2989
  new Date(D.scannedAt).toLocaleTimeString();
2478
2990
 
2479
- renderStats();
2480
- renderSidebar();
2481
- renderContent();
2482
- updateAgentBtn();
2483
- updateAgentRightsInfo();
2991
+ renderStats();
2992
+ renderSidebar();
2993
+ renderContent();
2994
+ updateAgentBtn();
2995
+ updateAgentRightsInfo();
2484
2996
 
2485
2997
  // Re-render drill-down or re-open panel
2486
2998
  const panelOpen = document.getElementById('panel').classList.contains('open');
@@ -2515,12 +3027,12 @@ function connectSSE() {
2515
3027
  });
2516
3028
 
2517
3029
  es.addEventListener('agent-queued', (e) => {
2518
- const { queueLength, title, task, featureKey, filePath } = JSON.parse(e.data);
3030
+ const { queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
2519
3031
  updateQueueBadge(queueLength);
2520
3032
  document.getElementById('agentPanel').classList.add('open');
2521
3033
  document.getElementById('termBtn').classList.add('term-active');
2522
3034
  // Track queued paths for spinner
2523
- getTaskFilePaths(task, featureKey, filePath).forEach(p => agentQueuedPaths.add(p));
3035
+ getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
2524
3036
  renderContent();
2525
3037
  // Append queue notification to the currently running session (or active)
2526
3038
  const targetId = runningSessionId || activeSessionId;
@@ -2531,10 +3043,10 @@ function connectSSE() {
2531
3043
 
2532
3044
  es.addEventListener('agent-started', (e) => {
2533
3045
  setAgentRunning(true);
2534
- const { title, queueLength = 0, task, featureKey, filePath } = JSON.parse(e.data);
3046
+ const { title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
2535
3047
  updateQueueBadge(queueLength);
2536
3048
  // Move paths from queued → running (current task)
2537
- const startedPaths = getTaskFilePaths(task, featureKey, filePath);
3049
+ const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
2538
3050
  startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
2539
3051
  renderContent();
2540
3052
  // Close previous session (queue case: agent-done not fired between tasks)
@@ -2575,37 +3087,37 @@ function connectSSE() {
2575
3087
  renderContent();
2576
3088
  });
2577
3089
 
2578
- es.addEventListener('agent-summary', (e) => {
2579
- const {
2580
- passed, failed, files = [],
2581
- testedFileCount = files.length,
2582
- passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
2583
- failedFileCount = failed > 0 ? 1 : 0,
2584
- autoFixQueued = false,
2585
- } = JSON.parse(e.data);
2586
- const allOk = failed === 0;
2587
- const box = document.createElement('div');
2588
- box.style.cssText = `
2589
- margin: 10px 0 4px;
2590
- padding: 10px 14px;
3090
+ es.addEventListener('agent-summary', (e) => {
3091
+ const {
3092
+ passed, failed, files = [],
3093
+ testedFileCount = files.length,
3094
+ passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
3095
+ failedFileCount = failed > 0 ? 1 : 0,
3096
+ autoFixQueued = false,
3097
+ } = JSON.parse(e.data);
3098
+ const allOk = failed === 0;
3099
+ const box = document.createElement('div');
3100
+ box.style.cssText = `
3101
+ margin: 10px 0 4px;
3102
+ padding: 10px 14px;
2591
3103
  border-radius: 8px;
2592
3104
  border: 1px solid ${allOk ? 'var(--green)' : 'var(--red)'};
2593
3105
  background: ${allOk ? '#0d2a1a' : '#2a0d0d'};
2594
3106
  font-family: inherit;
2595
- `;
2596
- box.innerHTML = `
2597
- <div style="font-size:13px;font-weight:700;color:${allOk ? 'var(--green)' : 'var(--red)'}">
2598
- ${allOk ? '✅' : '⚠️'} Тесты: ${allOk ? 'всё ок' : 'есть падения'}
2599
- </div>
2600
- <div style="font-size:11px;color:var(--muted);margin-top:4px">
2601
- Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}
2602
- </div>
2603
- <div style="font-size:11px;color:var(--muted);margin-top:2px">
2604
- Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
2605
- </div>
2606
- ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
2607
- ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
2608
- `;
3107
+ `;
3108
+ box.innerHTML = `
3109
+ <div style="font-size:13px;font-weight:700;color:${allOk ? 'var(--green)' : 'var(--red)'}">
3110
+ ${allOk ? '✅' : '⚠️'} Тесты: ${allOk ? 'всё ок' : 'есть падения'}
3111
+ </div>
3112
+ <div style="font-size:11px;color:var(--muted);margin-top:4px">
3113
+ Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}
3114
+ </div>
3115
+ <div style="font-size:11px;color:var(--muted);margin-top:2px">
3116
+ Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
3117
+ </div>
3118
+ ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
3119
+ ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
3120
+ `;
2609
3121
  const targetId = runningSessionId || activeSessionId;
2610
3122
  if (targetId) {
2611
3123
  appendToSession(targetId, box);