viberadar 0.3.59 → 0.3.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -611,17 +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; }
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
- }
622
+ font-size: 13px;
623
+ transition: background 0.1s;
624
+ content-visibility: auto;
625
+ contain-intrinsic-size: 34px;
626
+ }
625
627
  .file-row:hover { background: var(--bg-card); }
626
628
  .file-row.active { background: var(--bg-hover); border-left: 2px solid var(--blue); padding-left: 8px; }
627
629
  .file-row-icon { font-size: 12px; flex-shrink: 0; }
@@ -673,10 +675,12 @@
673
675
  .file-agent-spinner.running { border: 2px solid var(--yellow); border-top-color: transparent; animation: spin 0.7s linear infinite; }
674
676
  .file-agent-spinner.queued { border: 2px solid var(--dim); border-top-color: transparent; animation: spin 1.5s linear infinite; }
675
677
  @keyframes spin { to { transform: rotate(360deg); } }
676
- .file-row-errors {
677
- padding: 4px 10px 6px 32px;
678
- display: flex; flex-direction: column; gap: 3px;
679
- }
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
+ }
680
684
  .err-item { display: flex; flex-direction: column; gap: 1px; }
681
685
  .err-name { font-size: 11px; color: var(--muted); }
682
686
  .err-msg { font-size: 10px; color: var(--red); font-family: monospace; opacity: 0.85; }
@@ -765,10 +769,111 @@
765
769
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
766
770
  }
767
771
  .agent-panel-cancel:hover { background: var(--yellow); color: #000; }
768
- .agent-queue-badge {
769
- font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
770
- border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
771
- }
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-meta { font-size: 11px; color: var(--dim); margin-left: auto; }
813
+ .agent-queue-panel {
814
+ display: none;
815
+ border-bottom: 1px solid var(--border);
816
+ background: #090f18;
817
+ padding: 8px 12px;
818
+ max-height: 110px;
819
+ overflow-y: auto;
820
+ }
821
+ .agent-queue-title {
822
+ font-size: 11px;
823
+ color: var(--muted);
824
+ margin-bottom: 6px;
825
+ font-weight: 600;
826
+ }
827
+ .agent-queue-item {
828
+ display: flex;
829
+ align-items: center;
830
+ gap: 8px;
831
+ font-size: 11px;
832
+ color: var(--text);
833
+ margin-bottom: 4px;
834
+ }
835
+ .agent-queue-item:last-child { margin-bottom: 0; }
836
+ .agent-queue-pos {
837
+ color: var(--dim);
838
+ min-width: 24px;
839
+ }
840
+ .agent-queue-actions { margin-left: auto; display: inline-flex; gap: 4px; }
841
+ .agent-queue-action {
842
+ border: 1px solid var(--border);
843
+ background: transparent;
844
+ color: var(--muted);
845
+ border-radius: 4px;
846
+ padding: 1px 6px;
847
+ cursor: pointer;
848
+ font-size: 10px;
849
+ line-height: 1.4;
850
+ }
851
+ .agent-queue-action:hover { border-color: var(--blue); color: var(--text); }
852
+ .agent-summary-matrix {
853
+ display: none;
854
+ border-bottom: 1px solid var(--border);
855
+ background: #090f17;
856
+ padding: 8px 12px;
857
+ max-height: 150px;
858
+ overflow: auto;
859
+ font-size: 11px;
860
+ }
861
+ .agent-summary-title {
862
+ color: var(--muted);
863
+ font-weight: 600;
864
+ margin-bottom: 6px;
865
+ }
866
+ .agent-summary-row {
867
+ display: grid;
868
+ grid-template-columns: 60px 1fr auto;
869
+ gap: 8px;
870
+ margin-bottom: 4px;
871
+ align-items: center;
872
+ }
873
+ .agent-summary-status-covered { color: var(--green); }
874
+ .agent-summary-status-not-covered { color: var(--red); }
875
+ .agent-summary-status-blocked { color: var(--yellow); }
876
+ .agent-summary-status-infra { color: var(--dim); }
772
877
  /* ── Console Tabs ───────────────────────────────────────────────────────── */
773
878
  .agent-tabs-bar {
774
879
  display: flex; align-items: stretch; overflow-x: auto;
@@ -885,11 +990,13 @@
885
990
  font-size: 12px;
886
991
  line-height: 1.5;
887
992
  }
888
- .agent-line { color: #c9d1d9; }
889
- .agent-line.err { color: var(--red); }
890
- .agent-line.dim { color: var(--dim); font-size: 10px; }
891
-
892
- /* ── Misc ────────────────────────────────────────────────────────────────── */
993
+ .agent-line { color: #c9d1d9; }
994
+ .agent-line.err { color: var(--red); }
995
+ .agent-line.dim { color: var(--dim); font-size: 10px; }
996
+ .agent-line.match { background: rgba(31, 111, 235, 0.22); }
997
+ .agent-line.command { color: #79c0ff; }
998
+
999
+ /* ── Misc ────────────────────────────────────────────────────────────────── */
893
1000
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
894
1001
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
895
1002
  /* ─── E2E Plan UI ─────────────────────────────────────────────────── */
@@ -1001,19 +1108,35 @@
1001
1108
  <div id="panelContent"></div>
1002
1109
  </div>
1003
1110
 
1004
- <div class="agent-panel" id="agentPanel">
1005
- <div class="agent-panel-header">
1006
- <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
1007
- <span class="agent-panel-status" id="agentPanelStatus">running…</span>
1111
+ <div class="agent-panel" id="agentPanel">
1112
+ <div class="agent-panel-header">
1113
+ <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
1114
+ <span class="agent-panel-status" id="agentPanelStatus">running…</span>
1008
1115
  <span class="agent-queue-badge" id="agentQueueBadge" style="display:none">📋 <span id="agentQueueCount">0</span> в очереди</span>
1009
1116
  <button class="agent-panel-cancel" id="agentQueueClearBtn" onclick="clearAgentQueue()" title="Очистить очередь" style="display:none">🗑 очередь</button>
1010
1117
  <button class="agent-panel-cancel" id="agentCancelBtn" onclick="cancelAgent()" title="Сбросить состояние агента" style="display:none">⏹ сброс</button>
1011
- <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
1012
- <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
1013
- </div>
1014
- <div class="agent-tabs-bar" id="agentTabsBar"></div>
1015
- <div class="agent-terminal" id="agentTerminal"></div>
1016
- </div>
1118
+ <button class="agent-panel-copy" id="agentCopyBtn" onclick="copyTerminalContent()" title="Скопировать содержимое вкладки в буфер обмена">⎘</button>
1119
+ <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
1120
+ </div>
1121
+ <div class="agent-toolbar">
1122
+ <input id="agentSearchInput" type="text" placeholder="Поиск по терминалу..." />
1123
+ <label><input id="agentSearchErrorsOnly" type="checkbox" /> only errors</label>
1124
+ <label><input id="agentSearchCurrentRunOnly" type="checkbox" /> current run</label>
1125
+ <label><input id="agentSearchRegex" type="checkbox" /> regex</label>
1126
+ <button class="agent-toolbar-btn" onclick="jumpTerminalMatch(-1)">↑ match</button>
1127
+ <button class="agent-toolbar-btn" onclick="jumpTerminalMatch(1)">↓ match</button>
1128
+ <button class="agent-toolbar-btn" onclick="jumpCommand(-1)">↑ command</button>
1129
+ <button class="agent-toolbar-btn" onclick="jumpCommand(1)">↓ command</button>
1130
+ <button class="agent-toolbar-btn" onclick="jumpError(-1)">↑ error</button>
1131
+ <button class="agent-toolbar-btn" onclick="jumpError(1)">↓ error</button>
1132
+ <button class="agent-toolbar-btn" onclick="exportActiveRun()">Export run</button>
1133
+ <span class="agent-toolbar-meta" id="agentSearchMeta">0 matches</span>
1134
+ </div>
1135
+ <div class="agent-queue-panel" id="agentQueuePanel"></div>
1136
+ <div class="agent-summary-matrix" id="agentSummaryMatrix"></div>
1137
+ <div class="agent-tabs-bar" id="agentTabsBar"></div>
1138
+ <div class="agent-terminal" id="agentTerminal"></div>
1139
+ </div>
1017
1140
 
1018
1141
  <script>
1019
1142
  // ─── State ────────────────────────────────────────────────────────────────────
@@ -1027,6 +1150,10 @@ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string
1027
1150
  let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
1028
1151
  let showOnlyUntestedInFeature = false; // source tab in feature detail
1029
1152
  const selectedSourceFiles = new Set(); // normalized relative paths for batch actions
1153
+ const FILE_ROWS_INITIAL_LIMIT = 250;
1154
+ const FILE_ROWS_LIMIT_STEP = 250;
1155
+ let fileRowsRenderKey = '';
1156
+ let fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1030
1157
  let e2ePlan = null; // current E2E plan object
1031
1158
  let e2ePlanLoading = false;
1032
1159
  const modeStore = {
@@ -1091,8 +1218,11 @@ function switchMode(nextMode) {
1091
1218
  renderSidebar();
1092
1219
  renderContent();
1093
1220
  }
1094
- // ─── Run All Tests button ──────────────────────────────────────────────────────
1095
- let runAllRunning = false;
1221
+ // ─── Run All Tests button ──────────────────────────────────────────────────────
1222
+ let runAllRunning = false;
1223
+ let refreshDataInFlight = false;
1224
+ let refreshDataQueued = false;
1225
+ let refreshDataTimer = null;
1096
1226
 
1097
1227
  function escapeHtml(text) {
1098
1228
  return String(text || '')
@@ -1131,16 +1261,42 @@ function setFeatureDrill(featureKey, syncHash = true) {
1131
1261
  renderContent();
1132
1262
  }
1133
1263
 
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) => {
1264
+ function selectedFilesForFeature(featureKey, visibleSourceFiles = null) {
1265
+ const visibleSet = visibleSourceFiles ? new Set(visibleSourceFiles.map(p => p.replace(/\\/g, '/'))) : null;
1266
+ return Array.from(selectedSourceFiles).filter((relPath) => {
1137
1267
  if (visibleSet && !visibleSet.has(relPath)) return false;
1138
1268
  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() {
1269
+ return !!mod && mod.type !== 'test' && mod.featureKeys?.includes(featureKey);
1270
+ });
1271
+ }
1272
+
1273
+ function getFileRowsWindow(items, renderKey) {
1274
+ if (fileRowsRenderKey !== renderKey) {
1275
+ fileRowsRenderKey = renderKey;
1276
+ fileRowsRenderLimit = FILE_ROWS_INITIAL_LIMIT;
1277
+ }
1278
+ const visibleRows = items.slice(0, fileRowsRenderLimit);
1279
+ const hiddenRows = Math.max(0, items.length - visibleRows.length);
1280
+ return { visibleRows, hiddenRows, hasMoreRows: hiddenRows > 0 };
1281
+ }
1282
+
1283
+ function increaseFileRowsLimit() {
1284
+ fileRowsRenderLimit += FILE_ROWS_LIMIT_STEP;
1285
+ }
1286
+
1287
+ function bindFileRowsClick(container) {
1288
+ const fileRows = container.querySelector('#fileRows');
1289
+ if (!fileRows) return;
1290
+ fileRows.onclick = (event) => {
1291
+ const row = event.target.closest('.file-row[data-id]');
1292
+ if (!row) return;
1293
+ const moduleId = row.dataset.id;
1294
+ const mod = D.modules.find((m) => String(m.id) === moduleId);
1295
+ if (mod) openModulePanel(mod);
1296
+ };
1297
+ }
1298
+
1299
+ function applyHashRoute() {
1144
1300
  const hash = (window.location.hash || '').replace(/^#/, '');
1145
1301
  if (!hash) {
1146
1302
  if (view === 'features' && drillFeatureKey) {
@@ -1188,70 +1344,102 @@ async function runAllTests() {
1188
1344
  }
1189
1345
 
1190
1346
  // ─── Agent ────────────────────────────────────────────────────────────────────
1191
- let agentRunning = false;
1192
- const agentRunningPaths = new Set(); // paths of files currently being processed by agent
1193
- const agentQueuedPaths = new Set(); // paths of files waiting in queue
1194
-
1195
- // ─── Console Sessions ─────────────────────────────────────────────────────────
1196
- const consoleSessions = []; // { id, title, lines, status, startTime }
1197
- let activeSessionId = null; // currently viewed tab
1198
- let runningSessionId = null; // tab that is currently receiving output
1199
- const SESSION_MAX = 25;
1200
- const SESSIONS_KEY = 'viberadar_sessions';
1347
+ let agentRunning = false;
1348
+ const agentRunningPaths = new Set(); // paths of files currently being processed by agent
1349
+ const agentQueuedPaths = new Set(); // paths of files waiting in queue
1350
+ let agentQueueState = [];
1351
+ let agentRunsState = [];
1352
+ let agentActiveRun = null;
1353
+ let currentRunId = null;
1354
+ let lastRunSummary = null;
1355
+
1356
+ // ─── Console Sessions ─────────────────────────────────────────────────────────
1357
+ const consoleSessions = []; // { id, title, lines, status, startTime }
1358
+ let activeSessionId = null; // currently viewed tab
1359
+ let runningSessionId = null; // tab that is currently receiving output
1360
+ const runSessionMap = new Map(); // runId -> sessionId
1361
+ const sessionRunMap = new Map(); // sessionId -> runId
1362
+ const SESSION_MAX = 25;
1363
+ const SESSION_LINE_LIMIT = 3000;
1364
+ const SESSIONS_KEY = 'viberadar_sessions';
1365
+ let terminalSearchQuery = '';
1366
+ let terminalSearchErrorsOnly = false;
1367
+ let terminalSearchCurrentRunOnly = false;
1368
+ let terminalSearchRegex = false;
1369
+ let terminalMatchRefs = [];
1370
+ let terminalCommandRefs = [];
1371
+ let terminalErrorRefs = [];
1372
+ let terminalMatchCursor = -1;
1373
+ let terminalCommandCursor = -1;
1374
+ let terminalErrorCursor = -1;
1201
1375
 
1202
1376
  function _sessionId() {
1203
1377
  return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
1204
1378
  }
1205
1379
 
1206
- function saveSessions() {
1207
- try {
1208
- const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
1209
- ...s, lines: s.lines.slice(-500)
1210
- }));
1211
- localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
1212
- } catch {}
1213
- }
1380
+ function saveSessions() {
1381
+ try {
1382
+ const data = consoleSessions.slice(-SESSION_MAX).map(s => ({
1383
+ ...s,
1384
+ runId: sessionRunMap.get(s.id) || s.runId || null,
1385
+ lines: s.lines.slice(-500)
1386
+ }));
1387
+ localStorage.setItem(SESSIONS_KEY, JSON.stringify(data));
1388
+ } catch {}
1389
+ }
1214
1390
 
1215
1391
  function restoreSessions() {
1216
1392
  try {
1217
- const raw = localStorage.getItem(SESSIONS_KEY);
1218
- if (!raw) return;
1219
- const saved = JSON.parse(raw);
1220
- consoleSessions.push(...saved);
1221
- for (const s of consoleSessions) {
1222
- if (s.status === 'running') {
1223
- s.status = 'error';
1224
- s.lines.push({ text: '⚡ Прервано (перезагрузка страницы)', isError: true });
1225
- }
1226
- }
1227
- if (consoleSessions.length > 0) {
1228
- activeSessionId = consoleSessions[consoleSessions.length - 1].id;
1229
- renderTabs();
1230
- renderActiveSession();
1393
+ const raw = localStorage.getItem(SESSIONS_KEY);
1394
+ if (!raw) return;
1395
+ const saved = JSON.parse(raw);
1396
+ consoleSessions.push(...saved);
1397
+ for (const s of consoleSessions) {
1398
+ if (s.runId) {
1399
+ runSessionMap.set(s.runId, s.id);
1400
+ sessionRunMap.set(s.id, s.runId);
1401
+ }
1402
+ }
1403
+ if (consoleSessions.length > 0) {
1404
+ activeSessionId = consoleSessions[consoleSessions.length - 1].id;
1405
+ renderTabs();
1406
+ renderActiveSession();
1231
1407
  }
1232
1408
  } catch {}
1233
1409
  }
1234
1410
 
1235
- function createSession(title, status = 'running') {
1236
- if (consoleSessions.length >= SESSION_MAX) consoleSessions.shift();
1237
- const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now() };
1238
- consoleSessions.push(s);
1239
- activeSessionId = s.id;
1240
- document.getElementById('agentPanel').classList.add('open');
1241
- document.getElementById('termBtn').classList.add('term-active');
1242
- renderTabs();
1243
- renderActiveSession();
1244
- saveSessions();
1411
+ function createSession(title, status = 'running', runId = null) {
1412
+ if (consoleSessions.length >= SESSION_MAX) {
1413
+ const dropped = consoleSessions.shift();
1414
+ if (dropped?.id) {
1415
+ const droppedRunId = sessionRunMap.get(dropped.id);
1416
+ if (droppedRunId) runSessionMap.delete(droppedRunId);
1417
+ sessionRunMap.delete(dropped.id);
1418
+ }
1419
+ }
1420
+ const s = { id: _sessionId(), title, lines: [], status, startTime: Date.now(), runId };
1421
+ consoleSessions.push(s);
1422
+ activeSessionId = s.id;
1423
+ if (runId) {
1424
+ runSessionMap.set(runId, s.id);
1425
+ sessionRunMap.set(s.id, runId);
1426
+ }
1427
+ document.getElementById('agentPanel').classList.add('open');
1428
+ document.getElementById('termBtn').classList.add('term-active');
1429
+ renderTabs();
1430
+ renderActiveSession();
1431
+ saveSessions();
1245
1432
  return s.id;
1246
1433
  }
1247
1434
 
1248
- function switchSession(id) {
1249
- activeSessionId = id;
1250
- const s = consoleSessions.find(s => s.id === id);
1251
- if (s) {
1252
- const statusText = s.status === 'running' ? 'работает…'
1253
- : s.status === 'ok' ? '✅ готово'
1254
- : s.status === 'error' ? ' ошибка'
1435
+ function switchSession(id) {
1436
+ activeSessionId = id;
1437
+ currentRunId = sessionRunMap.get(id) || null;
1438
+ const s = consoleSessions.find(s => s.id === id);
1439
+ if (s) {
1440
+ const statusText = s.status === 'running' ? 'работает…'
1441
+ : s.status === 'ok' ? ' готово'
1442
+ : s.status === 'error' ? '❌ ошибка'
1255
1443
  : '';
1256
1444
  document.getElementById('agentPanelTitle').textContent = s.title;
1257
1445
  document.getElementById('agentPanelStatus').textContent = statusText;
@@ -1260,43 +1448,42 @@ function switchSession(id) {
1260
1448
  renderActiveSession();
1261
1449
  }
1262
1450
 
1263
- function closeSession(id) {
1264
- const idx = consoleSessions.findIndex(s => s.id === id);
1265
- if (idx === -1) return;
1266
- consoleSessions.splice(idx, 1);
1267
- if (activeSessionId === id) {
1268
- activeSessionId = consoleSessions.length > 0
1269
- ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
1270
- : null;
1451
+ function closeSession(id) {
1452
+ const idx = consoleSessions.findIndex(s => s.id === id);
1453
+ if (idx === -1) return;
1454
+ const runId = sessionRunMap.get(id);
1455
+ if (runId) {
1456
+ sessionRunMap.delete(id);
1457
+ runSessionMap.delete(runId);
1458
+ }
1459
+ consoleSessions.splice(idx, 1);
1460
+ if (activeSessionId === id) {
1461
+ activeSessionId = consoleSessions.length > 0
1462
+ ? consoleSessions[Math.min(idx, consoleSessions.length - 1)].id
1463
+ : null;
1271
1464
  }
1272
1465
  renderTabs();
1273
1466
  renderActiveSession();
1274
1467
  saveSessions();
1275
1468
  }
1276
1469
 
1277
- function appendToSession(id, lineOrNode, isError = false, isDim = false) {
1278
- const s = consoleSessions.find(s => s.id === id);
1279
- if (!s) return;
1470
+ function appendToSession(id, lineOrNode, isError = false, isDim = false) {
1471
+ const s = consoleSessions.find(s => s.id === id);
1472
+ if (!s) return;
1280
1473
  let stored;
1281
1474
  if (typeof lineOrNode === 'string') {
1282
1475
  stored = { text: lineOrNode, isError, isDim };
1283
1476
  } else {
1284
1477
  stored = { html: lineOrNode.outerHTML };
1285
- }
1286
- s.lines.push(stored);
1287
- if (activeSessionId === id) {
1288
- const term = document.getElementById('agentTerminal');
1289
- const el = document.createElement('div');
1290
- if (stored.html) {
1291
- el.innerHTML = stored.html;
1292
- } else {
1293
- el.className = 'agent-line' + (isError ? ' err' : isDim ? ' dim' : '');
1294
- el.textContent = lineOrNode;
1295
- }
1296
- term.appendChild(el);
1297
- term.scrollTop = term.scrollHeight;
1298
- }
1299
- }
1478
+ }
1479
+ s.lines.push(stored);
1480
+ if (s.lines.length > SESSION_LINE_LIMIT) {
1481
+ s.lines.splice(0, s.lines.length - SESSION_LINE_LIMIT);
1482
+ }
1483
+ if (activeSessionId === id) {
1484
+ renderActiveSession();
1485
+ }
1486
+ }
1300
1487
 
1301
1488
  function updateSessionStatus(id, status) {
1302
1489
  const s = consoleSessions.find(s => s.id === id);
@@ -1324,9 +1511,9 @@ function renderTabs() {
1324
1511
  if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1325
1512
  }
1326
1513
 
1327
- function copyTerminalContent() {
1328
- const s = consoleSessions.find(s => s.id === activeSessionId);
1329
- if (!s || s.lines.length === 0) return;
1514
+ function copyTerminalContent() {
1515
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1516
+ if (!s || s.lines.length === 0) return;
1330
1517
  // Extract plain text from each line (strip HTML for rich nodes)
1331
1518
  const text = s.lines.map(l => {
1332
1519
  if (l.text !== undefined) return l.text;
@@ -1344,27 +1531,175 @@ function copyTerminalContent() {
1344
1531
  btn.textContent = '✓';
1345
1532
  btn.classList.add('copied');
1346
1533
  setTimeout(() => { btn.textContent = prev; btn.classList.remove('copied'); }, 1500);
1347
- });
1348
- }
1534
+ });
1535
+ }
1536
+
1537
+ function downloadTextArtifact(filename, content) {
1538
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
1539
+ const a = document.createElement('a');
1540
+ a.href = URL.createObjectURL(blob);
1541
+ a.download = filename;
1542
+ document.body.appendChild(a);
1543
+ a.click();
1544
+ setTimeout(() => {
1545
+ URL.revokeObjectURL(a.href);
1546
+ a.remove();
1547
+ }, 100);
1548
+ }
1549
+
1550
+ function getActiveRunForExport() {
1551
+ const runId = sessionRunMap.get(activeSessionId) || currentRunId;
1552
+ if (runId) {
1553
+ const fromState = (agentRunsState || []).find((r) => r.runId === runId);
1554
+ if (fromState) return fromState;
1555
+ }
1556
+ return lastRunSummary || null;
1557
+ }
1558
+
1559
+ function exportActiveRun() {
1560
+ const run = getActiveRunForExport();
1561
+ const session = consoleSessions.find((s) => s.id === activeSessionId);
1562
+ if (!run && !session) return;
1563
+ const mode = window.prompt('Формат экспорта: md или json', 'md');
1564
+ const format = (mode || 'md').trim().toLowerCase() === 'json' ? 'json' : 'md';
1565
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1566
+ const runId = run?.runId || sessionRunMap.get(activeSessionId) || 'session';
1567
+ if (format === 'json') {
1568
+ const payload = {
1569
+ run,
1570
+ sessionTitle: session?.title || null,
1571
+ lines: (session?.lines || []).map((line) => extractLineText(line)),
1572
+ exportedAt: new Date().toISOString(),
1573
+ };
1574
+ downloadTextArtifact(`viberadar-run-${runId}-${ts}.json`, JSON.stringify(payload, null, 2));
1575
+ return;
1576
+ }
1577
+ const lines = (session?.lines || []).map((line) => extractLineText(line)).filter(Boolean);
1578
+ const outcomes = Array.isArray(run?.fileOutcomes) ? run.fileOutcomes : [];
1579
+ const stats = run?.validationStats || {};
1580
+ const md = [
1581
+ `# VibeRadar Run Export`,
1582
+ ``,
1583
+ `- runId: ${run?.runId || runId}`,
1584
+ `- title: ${run?.title || session?.title || '-'}`,
1585
+ `- phase: ${run?.phase || '-'}`,
1586
+ `- exportedAt: ${new Date().toISOString()}`,
1587
+ ``,
1588
+ `## Validation`,
1589
+ ``,
1590
+ `- covered: ${stats.covered ?? 0}`,
1591
+ `- not-covered: ${stats.notCovered ?? 0}`,
1592
+ `- blocked: ${stats.blocked ?? 0}`,
1593
+ `- infra: ${stats.infra ?? 0}`,
1594
+ ``,
1595
+ `## File Outcomes`,
1596
+ ``,
1597
+ ...outcomes.map((o) => `- ${o.status}: ${o.sourcePath}${o.testFile ? ` -> ${o.testFile}` : ''}${o.reason ? ` (${o.reason})` : ''}`),
1598
+ ``,
1599
+ `## Logs`,
1600
+ ``,
1601
+ '```text',
1602
+ ...lines,
1603
+ '```',
1604
+ ].join('\n');
1605
+ downloadTextArtifact(`viberadar-run-${runId}-${ts}.md`, md);
1606
+ }
1349
1607
 
1350
- function renderActiveSession() {
1351
- const term = document.getElementById('agentTerminal');
1352
- if (!term) return;
1353
- term.innerHTML = '';
1354
- const s = consoleSessions.find(s => s.id === activeSessionId);
1355
- if (!s) return;
1356
- for (const ln of s.lines) {
1357
- const el = document.createElement('div');
1358
- if (ln.html) {
1359
- el.innerHTML = ln.html;
1360
- } else {
1361
- el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1362
- el.textContent = ln.text;
1363
- }
1364
- term.appendChild(el);
1365
- }
1366
- term.scrollTop = term.scrollHeight;
1367
- }
1608
+ function renderActiveSession() {
1609
+ const term = document.getElementById('agentTerminal');
1610
+ if (!term) return;
1611
+ term.innerHTML = '';
1612
+ terminalMatchRefs = [];
1613
+ terminalCommandRefs = [];
1614
+ terminalErrorRefs = [];
1615
+ terminalMatchCursor = -1;
1616
+ terminalCommandCursor = -1;
1617
+ terminalErrorCursor = -1;
1618
+ const s = consoleSessions.find(s => s.id === activeSessionId);
1619
+ if (!s) {
1620
+ document.getElementById('agentSearchMeta').textContent = '0 matches';
1621
+ return;
1622
+ }
1623
+ const normalizedQuery = (terminalSearchQuery || '').trim();
1624
+ let regex = null;
1625
+ if (normalizedQuery && terminalSearchRegex) {
1626
+ try { regex = new RegExp(normalizedQuery, 'i'); } catch { regex = null; }
1627
+ }
1628
+ for (let i = 0; i < s.lines.length; i++) {
1629
+ const ln = s.lines[i];
1630
+ const text = extractLineText(ln);
1631
+ const isErrorLine = !!ln.isError || /(^|\s)❌/.test(text) || /\berror\b/i.test(text);
1632
+ const isCommandLine = /^\s*⚡\s*\$/.test(text);
1633
+ if (terminalSearchCurrentRunOnly && currentRunId && s.runId && s.runId !== currentRunId) continue;
1634
+ if (terminalSearchErrorsOnly && !isErrorLine) continue;
1635
+ let isMatch = false;
1636
+ if (!normalizedQuery) {
1637
+ isMatch = false;
1638
+ } else if (regex) {
1639
+ isMatch = regex.test(text);
1640
+ } else {
1641
+ isMatch = text.toLowerCase().includes(normalizedQuery.toLowerCase());
1642
+ }
1643
+ if (normalizedQuery && !isMatch) continue;
1644
+
1645
+ const el = document.createElement('div');
1646
+ if (ln.html) {
1647
+ el.innerHTML = ln.html;
1648
+ } else {
1649
+ el.className = 'agent-line' + (ln.isError ? ' err' : ln.isDim ? ' dim' : '');
1650
+ el.textContent = ln.text;
1651
+ }
1652
+ if (isCommandLine) el.classList.add('command');
1653
+ if (isMatch) el.classList.add('match');
1654
+ el.dataset.lineIndex = String(i);
1655
+ term.appendChild(el);
1656
+ if (isMatch) terminalMatchRefs.push(el);
1657
+ if (isCommandLine) terminalCommandRefs.push(el);
1658
+ if (isErrorLine) terminalErrorRefs.push(el);
1659
+ }
1660
+ term.scrollTop = term.scrollHeight;
1661
+ document.getElementById('agentSearchMeta').textContent =
1662
+ `${terminalMatchRefs.length} matches • ${terminalCommandRefs.length} commands • ${terminalErrorRefs.length} errors`;
1663
+ }
1664
+
1665
+ function extractLineText(line) {
1666
+ if (!line) return '';
1667
+ if (line.text !== undefined) return String(line.text || '');
1668
+ if (line.html) {
1669
+ const tmp = document.createElement('div');
1670
+ tmp.innerHTML = line.html;
1671
+ return tmp.innerText || '';
1672
+ }
1673
+ return '';
1674
+ }
1675
+
1676
+ function jumpByRefs(refs, dir, cursorName) {
1677
+ if (!refs.length) return;
1678
+ if (cursorName === 'match') {
1679
+ terminalMatchCursor = (terminalMatchCursor + dir + refs.length) % refs.length;
1680
+ refs[terminalMatchCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1681
+ return;
1682
+ }
1683
+ if (cursorName === 'command') {
1684
+ terminalCommandCursor = (terminalCommandCursor + dir + refs.length) % refs.length;
1685
+ refs[terminalCommandCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1686
+ return;
1687
+ }
1688
+ terminalErrorCursor = (terminalErrorCursor + dir + refs.length) % refs.length;
1689
+ refs[terminalErrorCursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
1690
+ }
1691
+
1692
+ function jumpTerminalMatch(dir) {
1693
+ jumpByRefs(terminalMatchRefs, dir > 0 ? 1 : -1, 'match');
1694
+ }
1695
+
1696
+ function jumpCommand(dir) {
1697
+ jumpByRefs(terminalCommandRefs, dir > 0 ? 1 : -1, 'command');
1698
+ }
1699
+
1700
+ function jumpError(dir) {
1701
+ jumpByRefs(terminalErrorRefs, dir > 0 ? 1 : -1, 'error');
1702
+ }
1368
1703
 
1369
1704
  async function setAgent(agent) {
1370
1705
  await fetch('/api/set-agent', {
@@ -1413,19 +1748,187 @@ function isFileAgentActive(relPath) {
1413
1748
  return null;
1414
1749
  }
1415
1750
 
1416
- function updateQueueBadge(n) {
1417
- document.getElementById('agentQueueCount').textContent = n;
1418
- document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1419
- document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1420
- }
1751
+ function updateQueueBadge(n) {
1752
+ document.getElementById('agentQueueCount').textContent = n;
1753
+ document.getElementById('agentQueueBadge').style.display = n > 0 ? 'inline' : 'none';
1754
+ document.getElementById('agentQueueClearBtn').style.display = n > 0 ? 'inline-block' : 'none';
1755
+ renderQueuePanel();
1756
+ }
1757
+
1758
+ function renderQueuePanel() {
1759
+ const panel = document.getElementById('agentQueuePanel');
1760
+ if (!panel) return;
1761
+ if (!Array.isArray(agentQueueState) || agentQueueState.length === 0) {
1762
+ panel.style.display = 'none';
1763
+ panel.innerHTML = '';
1764
+ return;
1765
+ }
1766
+ panel.style.display = 'block';
1767
+ panel.innerHTML = `
1768
+ <div class="agent-queue-title">Очередь задач (${agentQueueState.length})</div>
1769
+ ${agentQueueState.map((item, idx) => `
1770
+ <div class="agent-queue-item">
1771
+ <span class="agent-queue-pos">#${item.position ?? idx + 1}</span>
1772
+ <span title="${escapeHtml(item.title || '')}">${escapeHtml(item.title || item.task || item.runId)}</span>
1773
+ <span style="color:var(--dim)">(${escapeHtml(item.task || 'task')})</span>
1774
+ <div class="agent-queue-actions">
1775
+ <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','up')" title="Сдвинуть вверх">↑</button>
1776
+ <button class="agent-queue-action" onclick="reorderQueueItem('${item.runId}','down')" title="Сдвинуть вниз">↓</button>
1777
+ <button class="agent-queue-action" onclick="cancelQueueItem('${item.runId}')" title="Отменить задачу">✕</button>
1778
+ <button class="agent-queue-action" onclick="retryRun('${item.runId}')" title="Retry">↻</button>
1779
+ </div>
1780
+ </div>
1781
+ `).join('')}
1782
+ `;
1783
+ }
1784
+
1785
+ function renderRunSummaryMatrix(summary = lastRunSummary) {
1786
+ const box = document.getElementById('agentSummaryMatrix');
1787
+ if (!box) return;
1788
+ const outcomes = summary?.fileOutcomes || [];
1789
+ if (!Array.isArray(outcomes) || outcomes.length === 0) {
1790
+ box.style.display = 'none';
1791
+ box.innerHTML = '';
1792
+ return;
1793
+ }
1794
+ const stats = summary?.validationStats || {};
1795
+ box.style.display = 'block';
1796
+ box.innerHTML = `
1797
+ <div class="agent-summary-title">
1798
+ Матрица итогов (run: ${escapeHtml(summary?.runId || '—')}) • covered: ${stats.covered ?? 0} • not-covered: ${stats.notCovered ?? 0} • blocked: ${stats.blocked ?? 0} • infra: ${stats.infra ?? 0}
1799
+ </div>
1800
+ ${outcomes.map((entry) => `
1801
+ <div class="agent-summary-row">
1802
+ <span class="agent-summary-status-${entry.status}">${escapeHtml(entry.status)}</span>
1803
+ <span title="${escapeHtml(entry.sourcePath || '')}">${escapeHtml(entry.sourcePath || '')}</span>
1804
+ <span style="color:var(--dim)">${escapeHtml(entry.testFile || entry.reason || '')}</span>
1805
+ </div>
1806
+ `).join('')}
1807
+ `;
1808
+ }
1809
+
1810
+ async function cancelQueueItem(runId) {
1811
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
1812
+ await loadAgentState();
1813
+ }
1814
+
1815
+ async function retryRun(runId) {
1816
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/retry`, { method: 'POST' });
1817
+ await loadAgentState();
1818
+ }
1819
+
1820
+ async function reorderQueueItem(runId, direction) {
1821
+ await fetch(`/api/queue/${encodeURIComponent(runId)}/reorder`, {
1822
+ method: 'POST',
1823
+ headers: { 'Content-Type': 'application/json' },
1824
+ body: JSON.stringify({ direction }),
1825
+ });
1826
+ await loadAgentState();
1827
+ }
1828
+
1829
+ function ensureSessionForRun(run, makeActive = false) {
1830
+ if (!run?.runId) return null;
1831
+ let sessionId = runSessionMap.get(run.runId);
1832
+ if (!sessionId) {
1833
+ sessionId = createSession(run.title || `Run ${run.runId}`, run.phase === 'failed' ? 'error' : run.phase === 'completed' ? 'ok' : 'running', run.runId);
1834
+ const meta = [`runId: ${run.runId}`, `phase: ${run.phase}`];
1835
+ if (Array.isArray(run.targetSourcePaths) && run.targetSourcePaths.length > 0) {
1836
+ meta.push(`targets: ${run.targetSourcePaths.length}`);
1837
+ }
1838
+ appendToSession(sessionId, `ℹ ${meta.join(' • ')}`, false, true);
1839
+ }
1840
+ if (makeActive) switchSession(sessionId);
1841
+ return sessionId;
1842
+ }
1843
+
1844
+ function applyAgentStateSnapshot(state) {
1845
+ if (!state) return;
1846
+ agentQueueState = Array.isArray(state.queue) ? state.queue : [];
1847
+ agentRunsState = Array.isArray(state.runs) ? state.runs : [];
1848
+ agentActiveRun = state.activeRun || null;
1849
+ updateQueueBadge(agentQueueState.length);
1850
+ if (agentActiveRun?.runId) {
1851
+ currentRunId = agentActiveRun.runId;
1852
+ ensureSessionForRun(agentActiveRun);
1853
+ setAgentRunning(['starting', 'running', 'validating'].includes(agentActiveRun.phase));
1854
+ } else {
1855
+ setAgentRunning(false);
1856
+ }
1857
+ const latestSummary = [...agentRunsState].reverse().find((r) => Array.isArray(r.fileOutcomes) && r.fileOutcomes.length > 0);
1858
+ if (latestSummary) {
1859
+ lastRunSummary = latestSummary;
1860
+ renderRunSummaryMatrix(latestSummary);
1861
+ }
1862
+ }
1863
+
1864
+ async function loadAgentState() {
1865
+ try {
1866
+ const res = await fetch('/api/agent/state');
1867
+ if (!res.ok) return;
1868
+ const state = await res.json();
1869
+ applyAgentStateSnapshot(state);
1870
+ } catch {}
1871
+ }
1872
+
1873
+ function upsertRunState(run) {
1874
+ if (!run?.runId) return;
1875
+ const idx = agentRunsState.findIndex((r) => r.runId === run.runId);
1876
+ if (idx >= 0) agentRunsState[idx] = { ...agentRunsState[idx], ...run };
1877
+ else agentRunsState.push(run);
1878
+ if (agentRunsState.length > 80) {
1879
+ agentRunsState = agentRunsState.slice(agentRunsState.length - 80);
1880
+ }
1881
+ }
1882
+
1883
+ function updateSessionFromRun(run) {
1884
+ if (!run?.runId) return;
1885
+ upsertRunState(run);
1886
+ if (['starting', 'running', 'validating'].includes(run.phase)) {
1887
+ setAgentRunning(true);
1888
+ currentRunId = run.runId;
1889
+ const sid = ensureSessionForRun(run);
1890
+ if (sid) {
1891
+ runningSessionId = sid;
1892
+ if (activeSessionId === sid) {
1893
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + (run.title || 'Agent');
1894
+ document.getElementById('agentPanelStatus').textContent = run.phase === 'validating' ? 'проверяю…' : 'работает…';
1895
+ }
1896
+ }
1897
+ return;
1898
+ }
1899
+ const sid = runSessionMap.get(run.runId);
1900
+ if (sid) {
1901
+ const status = run.phase === 'completed' ? 'ok' : run.phase === 'canceled' ? 'error' : run.phase === 'failed' ? 'error' : 'info';
1902
+ updateSessionStatus(sid, status);
1903
+ if (runningSessionId === sid) runningSessionId = null;
1904
+ if (activeSessionId === sid) {
1905
+ document.getElementById('agentPanelStatus').textContent =
1906
+ run.phase === 'completed' ? '✅ готово' : run.phase === 'canceled' ? '⏹ отменено' : run.phase === 'failed' ? '❌ ошибка' : run.phase;
1907
+ }
1908
+ }
1909
+ if (run.phase === 'completed' || run.phase === 'failed' || run.phase === 'canceled') {
1910
+ if (agentActiveRun?.runId === run.runId) agentActiveRun = null;
1911
+ if (currentRunId === run.runId) currentRunId = null;
1912
+ setAgentRunning(false);
1913
+ }
1914
+ }
1915
+
1916
+ function refreshPathActivityFromState() {
1917
+ agentQueuedPaths.clear();
1918
+ (agentQueueState || []).forEach((q) => {
1919
+ getTaskFilePaths(q.task, q.featureKey, q.filePath, q.selectedFilePaths).forEach((p) => agentQueuedPaths.add(p));
1920
+ });
1921
+ if (!agentActiveRun || !['starting', 'running', 'validating'].includes(agentActiveRun.phase)) {
1922
+ agentRunningPaths.clear();
1923
+ }
1924
+ }
1421
1925
 
1422
- async function cancelAgent() {
1423
- await fetch('/api/cancel-agent', { method: 'POST' });
1424
- setAgentRunning(false);
1425
- updateQueueBadge(0);
1426
- document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1427
- if (runningSessionId) {
1428
- appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1926
+ async function cancelAgent() {
1927
+ await fetch('/api/cancel-agent', { method: 'POST' });
1928
+ await loadAgentState();
1929
+ document.getElementById('agentPanelStatus').textContent = '⏹ сброшен';
1930
+ if (runningSessionId) {
1931
+ appendToSession(runningSessionId, '⏹ Состояние агента сброшено (очередь очищена)', false);
1429
1932
  updateSessionStatus(runningSessionId, 'error');
1430
1933
  runningSessionId = null;
1431
1934
  } else {
@@ -1433,11 +1936,11 @@ async function cancelAgent() {
1433
1936
  }
1434
1937
  }
1435
1938
 
1436
- async function clearAgentQueue() {
1437
- await fetch('/api/clear-queue', { method: 'POST' });
1438
- updateQueueBadge(0);
1439
- appendTerminalLine('🗑 Очередь очищена', false);
1440
- }
1939
+ async function clearAgentQueue() {
1940
+ await fetch('/api/clear-queue', { method: 'POST' });
1941
+ await loadAgentState();
1942
+ appendTerminalLine('🗑 Очередь очищена', false);
1943
+ }
1441
1944
 
1442
1945
  // ─── File row more menu ──────────────────────────────────────────────────────
1443
1946
  let _openFileMenu = null;
@@ -2319,16 +2822,20 @@ function renderFeatureDetail(c) {
2319
2822
  ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
2320
2823
  : `📁 Файлы фичи (${listFiles.length})`;
2321
2824
 
2322
- const q = searchQuery.toLowerCase();
2323
- const filtered = q ? listFiles.filter(m =>
2324
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
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;
2825
+ const q = searchQuery.toLowerCase();
2826
+ const filtered = q ? listFiles.filter(m =>
2827
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2828
+ ) : listFiles;
2829
+ const rowsRenderKey = `feature:${drillFeatureKey}:${activeTab}:${showOnlyUntestedInFeature ? 1 : 0}:${q}`;
2830
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2831
+ const selectableSource = !isTestList && !!D.agent;
2832
+ const visibleSourcePaths = selectableSource
2833
+ ? visibleRows.map(m => m.relativePath.replace(/\\/g, '/'))
2834
+ : [];
2835
+ const selectedVisible = selectableSource
2836
+ ? selectedFilesForFeature(drillFeatureKey, visibleRows.map(m => m.relativePath))
2837
+ : [];
2838
+ const selectedCount = selectedVisible.length;
2332
2839
 
2333
2840
  c.innerHTML = `
2334
2841
  <div class="drill-header">
@@ -2381,9 +2888,14 @@ function renderFeatureDetail(c) {
2381
2888
  ? 'Все файлы этой фичи уже покрыты тестами'
2382
2889
  : 'Нет файлов — возможно паттерны в конфиге не совпадают')}
2383
2890
  </div>`
2384
- : filtered.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
2891
+ : visibleRows.map(m => fileRow(m, isTestList, drillFeatureKey, selectableSource)).join('')
2385
2892
  }
2386
- </div>`;
2893
+ </div>
2894
+ ${hasMoreRows ? `
2895
+ <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)">
2896
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
2897
+ <button class="bulk-actions-btn" id="fileRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
2898
+ </div>` : ''}`;
2387
2899
 
2388
2900
  const untestedOnlyToggle = document.getElementById('untestedOnlyToggle');
2389
2901
  if (untestedOnlyToggle) {
@@ -2428,9 +2940,17 @@ function renderFeatureDetail(c) {
2428
2940
  renderContent();
2429
2941
  };
2430
2942
  }
2431
- }
2432
-
2433
- c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
2943
+ }
2944
+
2945
+ const fileRowsLoadMoreBtn = document.getElementById('fileRowsLoadMoreBtn');
2946
+ if (fileRowsLoadMoreBtn) {
2947
+ fileRowsLoadMoreBtn.onclick = () => {
2948
+ increaseFileRowsLimit();
2949
+ renderContent();
2950
+ };
2951
+ }
2952
+
2953
+ c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
2434
2954
  card.onclick = async () => {
2435
2955
  const type = card.dataset.testtype;
2436
2956
  drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
@@ -2439,17 +2959,11 @@ function renderFeatureDetail(c) {
2439
2959
  if (type === 'e2e') {
2440
2960
  await loadE2ePlan(drillFeatureKey);
2441
2961
  }
2442
- renderContent();
2443
- };
2444
- });
2445
-
2446
- c.querySelectorAll('.file-row[data-id]').forEach(row => {
2447
- row.onclick = () => {
2448
- const m = D.modules.find(m => m.id === row.dataset.id);
2449
- if (m) openModulePanel(m);
2450
- };
2451
- });
2452
- }
2962
+ renderContent();
2963
+ };
2964
+ });
2965
+ bindFileRowsClick(c);
2966
+ }
2453
2967
 
2454
2968
  const TEST_TYPE_META = {
2455
2969
  unit: { label: 'Unit', icon: '🧪', color: '#e3b341', desc: 'Изолированные тесты функций и модулей' },
@@ -2468,10 +2982,12 @@ function renderTestTypeDetail(c) {
2468
2982
  m.featureKeys && m.featureKeys.includes(drillFeatureKey)
2469
2983
  );
2470
2984
 
2471
- const q = searchQuery.toLowerCase();
2472
- const filtered = q ? tests.filter(m =>
2473
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2474
- ) : tests;
2985
+ const q = searchQuery.toLowerCase();
2986
+ const filtered = q ? tests.filter(m =>
2987
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2988
+ ) : tests;
2989
+ const rowsRenderKey = `tests:${drillFeatureKey}:${drillTestType}:${q}`;
2990
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2475
2991
 
2476
2992
  c.innerHTML = `
2477
2993
  <div class="drill-header">
@@ -2486,24 +3002,32 @@ function renderTestTypeDetail(c) {
2486
3002
  </div>
2487
3003
  <div style="font-size:12px;color:var(--dim);margin-bottom:16px">${meta.desc}</div>
2488
3004
 
2489
- <div class="file-rows" id="fileRows">
2490
- ${filtered.length === 0
2491
- ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
2492
- <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
2493
- <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
2494
- <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
2495
- </div>`
2496
- : filtered.map(m => fileRow(m, true)).join('')
2497
- }
2498
- </div>`;
2499
-
2500
- c.querySelectorAll('.file-row[data-id]').forEach(row => {
2501
- row.onclick = () => {
2502
- const m = D.modules.find(m => m.id === row.dataset.id);
2503
- if (m) openModulePanel(m);
2504
- };
2505
- });
2506
- }
3005
+ <div class="file-rows" id="fileRows">
3006
+ ${filtered.length === 0
3007
+ ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
3008
+ <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
3009
+ <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
3010
+ <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
3011
+ </div>`
3012
+ : visibleRows.map(m => fileRow(m, true)).join('')
3013
+ }
3014
+ </div>
3015
+ ${hasMoreRows ? `
3016
+ <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)">
3017
+ <span>Показано ${visibleRows.length} из ${filtered.length} тест-файлов</span>
3018
+ <button class="bulk-actions-btn" id="testRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3019
+ </div>` : ''}`;
3020
+
3021
+ const testRowsLoadMoreBtn = document.getElementById('testRowsLoadMoreBtn');
3022
+ if (testRowsLoadMoreBtn) {
3023
+ testRowsLoadMoreBtn.onclick = () => {
3024
+ increaseFileRowsLimit();
3025
+ renderContent();
3026
+ };
3027
+ }
3028
+
3029
+ bindFileRowsClick(c);
3030
+ }
2507
3031
 
2508
3032
  function renderUnmappedDetail(c) {
2509
3033
  const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
@@ -2511,10 +3035,12 @@ function renderUnmappedDetail(c) {
2511
3035
  m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
2512
3036
  );
2513
3037
 
2514
- const q = searchQuery.toLowerCase();
2515
- const filtered = q ? unmappedSrc.filter(m =>
2516
- m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
2517
- ) : unmappedSrc;
3038
+ const q = searchQuery.toLowerCase();
3039
+ const filtered = q ? unmappedSrc.filter(m =>
3040
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
3041
+ ) : unmappedSrc;
3042
+ const rowsRenderKey = `unmapped:${q}`;
3043
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(filtered, rowsRenderKey);
2518
3044
 
2519
3045
  // Build prompt text
2520
3046
  const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
@@ -2555,36 +3081,44 @@ function renderUnmappedDetail(c) {
2555
3081
  border-radius:6px; color:var(--blue); font-size:12px; cursor:pointer;
2556
3082
  ">📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)</button>
2557
3083
  </div>
2558
- <div class="file-rows" id="fileRows">
2559
- ${filtered.length === 0
2560
- ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
2561
- : filtered.map(m => fileRow(m)).join('')
2562
- }
2563
- </div>`;
3084
+ <div class="file-rows" id="fileRows">
3085
+ ${filtered.length === 0
3086
+ ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
3087
+ : visibleRows.map(m => fileRow(m)).join('')
3088
+ }
3089
+ </div>
3090
+ ${hasMoreRows ? `
3091
+ <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)">
3092
+ <span>Показано ${visibleRows.length} из ${filtered.length} файлов</span>
3093
+ <button class="bulk-actions-btn" id="unmappedRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3094
+ </div>` : ''}`;
2564
3095
 
2565
3096
  const runAgentUnmappedBtn = document.getElementById('runAgentUnmapped');
2566
3097
  if (runAgentUnmappedBtn) {
2567
3098
  runAgentUnmappedBtn.onclick = () => runAgentTask('map-unmapped');
2568
3099
  }
2569
3100
 
2570
- document.getElementById('copyUnmappedDrill').onclick = function() {
3101
+ document.getElementById('copyUnmappedDrill').onclick = function() {
2571
3102
  navigator.clipboard.writeText(promptText).then(() => {
2572
3103
  this.textContent = '✅ Скопировано!';
2573
3104
  this.style.color = 'var(--green)';
2574
3105
  setTimeout(() => {
2575
3106
  this.textContent = `📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)`;
2576
3107
  this.style.color = 'var(--blue)';
2577
- }, 3000);
2578
- });
2579
- };
2580
-
2581
- c.querySelectorAll('.file-row[data-id]').forEach(row => {
2582
- row.onclick = () => {
2583
- const m = D.modules.find(m => m.id === row.dataset.id);
2584
- if (m) openModulePanel(m);
2585
- };
2586
- });
2587
- }
3108
+ }, 3000);
3109
+ });
3110
+ };
3111
+
3112
+ const unmappedRowsLoadMoreBtn = document.getElementById('unmappedRowsLoadMoreBtn');
3113
+ if (unmappedRowsLoadMoreBtn) {
3114
+ unmappedRowsLoadMoreBtn.onclick = () => {
3115
+ increaseFileRowsLimit();
3116
+ renderContent();
3117
+ };
3118
+ }
3119
+
3120
+ bindFileRowsClick(c);
3121
+ }
2588
3122
 
2589
3123
  function fileRow(m, isTest = false, featureKey = null, selectable = false) {
2590
3124
  const relPath = m.relativePath.replace(/\\/g, '/');
@@ -2674,23 +3208,33 @@ function toggleSourceSelection(relPath, checked) {
2674
3208
  renderContent();
2675
3209
  }
2676
3210
 
2677
- function renderModuleGrid(c) {
2678
- const q = searchQuery.toLowerCase();
2679
- const list = D.modules.filter(m => {
2680
- if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
2681
- if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
2682
- return true;
2683
- });
2684
-
2685
- if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
2686
-
2687
- c.innerHTML = '<div class="module-grid" id="modGrid"></div>';
2688
- const grid = document.getElementById('modGrid');
2689
-
2690
- list.forEach(m => {
2691
- const cov = m.coverage?.lines;
2692
- const isActive = activePanelKey === m.id;
2693
- const card = document.createElement('div');
3211
+ function renderModuleGrid(c) {
3212
+ const q = searchQuery.toLowerCase();
3213
+ const list = D.modules.filter(m => {
3214
+ if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
3215
+ if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
3216
+ return true;
3217
+ });
3218
+ const typeKey = [...activeTypes].sort().join(',');
3219
+ const rowsRenderKey = `modules:${contextMode}:${view}:${typeKey}:${q}`;
3220
+ const { visibleRows, hiddenRows, hasMoreRows } = getFileRowsWindow(list, rowsRenderKey);
3221
+
3222
+ if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
3223
+
3224
+ c.innerHTML = `
3225
+ <div class="module-grid" id="modGrid"></div>
3226
+ ${hasMoreRows ? `
3227
+ <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)">
3228
+ <span>Показано ${visibleRows.length} из ${list.length} модулей</span>
3229
+ <button class="bulk-actions-btn" id="moduleRowsLoadMoreBtn">Показать ещё ${Math.min(FILE_ROWS_LIMIT_STEP, hiddenRows)}</button>
3230
+ </div>` : ''}
3231
+ `;
3232
+ const grid = document.getElementById('modGrid');
3233
+
3234
+ visibleRows.forEach(m => {
3235
+ const cov = m.coverage?.lines;
3236
+ const isActive = activePanelKey === m.id;
3237
+ const card = document.createElement('div');
2694
3238
  card.className = 'module-card' + (isActive ? ' active' : '');
2695
3239
  card.innerHTML = `
2696
3240
  <div class="module-name">${m.name}</div>
@@ -2702,10 +3246,18 @@ function renderModuleGrid(c) {
2702
3246
  <span class="badge ${m.hasTests ? 'badge-green' : 'badge-red'}">${m.hasTests ? '✓' : '✗'}</span>
2703
3247
  </div>
2704
3248
  ${cov != null ? `<div class="cov-bar"><div class="cov-fill" style="width:${cov}%;background:${covColor(cov)}"></div></div>` : ''}`;
2705
- card.onclick = () => openModulePanel(m);
2706
- grid.appendChild(card);
2707
- });
2708
- }
3249
+ card.onclick = () => openModulePanel(m);
3250
+ grid.appendChild(card);
3251
+ });
3252
+
3253
+ const moduleRowsLoadMoreBtn = document.getElementById('moduleRowsLoadMoreBtn');
3254
+ if (moduleRowsLoadMoreBtn) {
3255
+ moduleRowsLoadMoreBtn.onclick = () => {
3256
+ increaseFileRowsLimit();
3257
+ renderContent();
3258
+ };
3259
+ }
3260
+ }
2709
3261
 
2710
3262
  // ─── Panels ───────────────────────────────────────────────────────────────────
2711
3263
  function openFeaturePanel(key) {
@@ -2916,14 +3468,29 @@ function openUnmappedPanel(files, infraFiles) {
2916
3468
  document.getElementById('panel').classList.add('open');
2917
3469
  }
2918
3470
 
2919
- function closePanel() {
2920
- activePanelKey = null;
2921
- document.getElementById('panel').classList.remove('open');
2922
- renderContent();
2923
- }
2924
-
2925
- // ─── Events ───────────────────────────────────────────────────────────────────
2926
- document.querySelectorAll('.view-tab').forEach(tab => {
3471
+ function closePanel() {
3472
+ activePanelKey = null;
3473
+ document.getElementById('panel').classList.remove('open');
3474
+ renderContent();
3475
+ }
3476
+
3477
+ function applyTerminalFiltersFromUi() {
3478
+ terminalSearchQuery = document.getElementById('agentSearchInput')?.value || '';
3479
+ terminalSearchErrorsOnly = !!document.getElementById('agentSearchErrorsOnly')?.checked;
3480
+ terminalSearchCurrentRunOnly = !!document.getElementById('agentSearchCurrentRunOnly')?.checked;
3481
+ terminalSearchRegex = !!document.getElementById('agentSearchRegex')?.checked;
3482
+ renderActiveSession();
3483
+ }
3484
+
3485
+ ['agentSearchInput', 'agentSearchErrorsOnly', 'agentSearchCurrentRunOnly', 'agentSearchRegex'].forEach((id) => {
3486
+ const el = document.getElementById(id);
3487
+ if (!el) return;
3488
+ const eventName = id === 'agentSearchInput' ? 'input' : 'change';
3489
+ el.addEventListener(eventName, applyTerminalFiltersFromUi);
3490
+ });
3491
+
3492
+ // ─── Events ───────────────────────────────────────────────────────────────────
3493
+ document.querySelectorAll('.view-tab').forEach(tab => {
2927
3494
  tab.onclick = () => {
2928
3495
  if (contextMode !== 'qa') return;
2929
3496
  if (tab.classList.contains('disabled')) return;
@@ -2973,129 +3540,204 @@ window.addEventListener('popstate', () => {
2973
3540
  });
2974
3541
 
2975
3542
  // ─── Live reload ──────────────────────────────────────────────────────────────
2976
- function setLiveDot(color, title) {
2977
- const dot = document.getElementById('liveDot');
2978
- dot.style.background = color;
2979
- dot.title = title;
2980
- }
2981
-
2982
- async function refreshData() {
2983
- try {
2984
- const res = await fetch('/api/data');
2985
- D = await res.json();
3543
+ function setLiveDot(color, title) {
3544
+ const dot = document.getElementById('liveDot');
3545
+ dot.style.background = color;
3546
+ dot.title = title;
3547
+ }
3548
+
3549
+ function scheduleRefreshData(delayMs = 120) {
3550
+ if (refreshDataTimer) return;
3551
+ refreshDataTimer = setTimeout(() => {
3552
+ refreshDataTimer = null;
3553
+ void refreshData();
3554
+ }, delayMs);
3555
+ }
3556
+
3557
+ async function refreshData() {
3558
+ if (refreshDataInFlight) {
3559
+ refreshDataQueued = true;
3560
+ return;
3561
+ }
3562
+ refreshDataInFlight = true;
3563
+ try {
3564
+ const res = await fetch('/api/data');
3565
+ D = await res.json();
2986
3566
 
2987
3567
  // Update header timestamp
2988
3568
  document.getElementById('scannedAt').textContent =
2989
3569
  new Date(D.scannedAt).toLocaleTimeString();
2990
-
2991
- renderStats();
2992
- renderSidebar();
2993
- renderContent();
2994
- updateAgentBtn();
2995
- updateAgentRightsInfo();
2996
-
2997
- // Re-render drill-down or re-open panel
2998
- const panelOpen = document.getElementById('panel').classList.contains('open');
3570
+
3571
+ renderStats();
3572
+ renderSidebar();
3573
+ updateAgentBtn();
3574
+ updateAgentRightsInfo();
3575
+
3576
+ // Re-render drill-down or re-open panel
3577
+ const panelOpen = document.getElementById('panel').classList.contains('open');
2999
3578
  if (panelOpen && activePanelKey) {
3000
3579
  if (drillFeatureKey === '__unmapped__') {
3001
3580
  renderContent(); // already routes to renderUnmappedDetail
3002
3581
  } else if (view === 'features' && D.features) {
3003
3582
  openFeaturePanel(activePanelKey);
3004
- } else {
3005
- const m = D.modules.find(m => m.id === activePanelKey);
3006
- if (m) openModulePanel(m);
3007
- else closePanel();
3008
- }
3009
- }
3010
-
3011
- // Brief green flash on the dot to signal fresh data
3012
- setLiveDot('#3fb950', 'Live — обновлено только что');
3013
- setTimeout(() => setLiveDot('#3fb950', 'Live автообновление включено'), 1500);
3014
- } catch (err) {
3015
- console.error('Refresh failed:', err);
3016
- }
3017
- }
3018
-
3019
- function connectSSE() {
3020
- const es = new EventSource('/api/events');
3021
-
3022
- es.onopen = () => setLiveDot('#3fb950', 'Live — автообновление включено');
3023
-
3024
- es.addEventListener('data-updated', () => {
3025
- setLiveDot('#e3b341', 'Обновляю данные…');
3026
- refreshData();
3027
- });
3028
-
3029
- es.addEventListener('agent-queued', (e) => {
3030
- const { queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3031
- updateQueueBadge(queueLength);
3032
- document.getElementById('agentPanel').classList.add('open');
3033
- document.getElementById('termBtn').classList.add('term-active');
3034
- // Track queued paths for spinner
3035
- getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
3036
- renderContent();
3037
- // Append queue notification to the currently running session (or active)
3038
- const targetId = runningSessionId || activeSessionId;
3039
- if (targetId) {
3040
- appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
3041
- }
3042
- });
3583
+ } else {
3584
+ const m = D.modules.find(m => m.id === activePanelKey);
3585
+ if (m) openModulePanel(m);
3586
+ else closePanel();
3587
+ }
3588
+ } else {
3589
+ renderContent();
3590
+ }
3591
+
3592
+ // Brief green flash on the dot to signal fresh data
3593
+ setLiveDot('#3fb950', 'Live — обновлено только что');
3594
+ setTimeout(() => setLiveDot('#3fb950', 'Live — автообновление включено'), 1500);
3595
+ void loadAgentState();
3596
+ } catch (err) {
3597
+ console.error('Refresh failed:', err);
3598
+ } finally {
3599
+ refreshDataInFlight = false;
3600
+ if (refreshDataQueued) {
3601
+ refreshDataQueued = false;
3602
+ scheduleRefreshData(120);
3603
+ }
3604
+ }
3605
+ }
3043
3606
 
3044
- es.addEventListener('agent-started', (e) => {
3045
- setAgentRunning(true);
3046
- const { title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3047
- updateQueueBadge(queueLength);
3048
- // Move paths from queued → running (current task)
3049
- const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
3050
- startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3051
- renderContent();
3052
- // Close previous session (queue case: agent-done not fired between tasks)
3053
- if (runningSessionId) {
3054
- const prev = consoleSessions.find(s => s.id === runningSessionId);
3055
- if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
3056
- }
3057
- const id = createSession(title);
3058
- runningSessionId = id;
3059
- document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
3060
- document.getElementById('agentPanelStatus').textContent = 'запускаю…';
3061
- });
3607
+ function connectSSE() {
3608
+ const es = new EventSource('/api/events');
3609
+
3610
+ es.onopen = () => {
3611
+ setLiveDot('#3fb950', 'Live автообновление включено');
3612
+ void loadAgentState();
3613
+ };
3062
3614
 
3063
- es.addEventListener('agent-output', (e) => {
3064
- const { line, isError, isDim } = JSON.parse(e.data);
3065
- if (runningSessionId) {
3066
- appendToSession(runningSessionId, line, !!isError, !!isDim);
3067
- if (activeSessionId === runningSessionId) {
3068
- document.getElementById('agentPanelStatus').textContent = 'работает…';
3069
- }
3070
- } else {
3071
- appendTerminalLine(line, !!isError, !!isDim);
3072
- }
3615
+ es.addEventListener('data-updated', () => {
3616
+ setLiveDot('#e3b341', 'Обновляю данные…');
3617
+ scheduleRefreshData();
3618
+ });
3619
+
3620
+ es.addEventListener('agent-queue-updated', (e) => {
3621
+ const payload = JSON.parse(e.data || '{}');
3622
+ agentQueueState = Array.isArray(payload.queue) ? payload.queue : [];
3623
+ refreshPathActivityFromState();
3624
+ updateQueueBadge(agentQueueState.length);
3625
+ renderContent();
3626
+ });
3627
+
3628
+ es.addEventListener('agent-run-created', (e) => {
3629
+ const { run } = JSON.parse(e.data || '{}');
3630
+ upsertRunState(run);
3631
+ });
3632
+
3633
+ es.addEventListener('agent-run-updated', (e) => {
3634
+ const { run } = JSON.parse(e.data || '{}');
3635
+ if (!run) return;
3636
+ if (run.runId && agentQueueState.length > 0) {
3637
+ agentQueueState = agentQueueState.filter((q) => q.runId !== run.runId || run.phase === 'queued');
3638
+ updateQueueBadge(agentQueueState.length);
3639
+ }
3640
+ if (['starting', 'running', 'validating'].includes(run.phase)) {
3641
+ agentActiveRun = run;
3642
+ const startedPaths = getTaskFilePaths(run.task, run.featureKey, run.filePath, run.selectedFilePaths);
3643
+ startedPaths.forEach((p) => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3644
+ renderContent();
3645
+ }
3646
+ updateSessionFromRun(run);
3647
+ });
3648
+
3649
+ es.addEventListener('agent-run-finished', (e) => {
3650
+ const { run } = JSON.parse(e.data || '{}');
3651
+ if (!run) return;
3652
+ updateSessionFromRun(run);
3653
+ if (Array.isArray(run.fileOutcomes) && run.fileOutcomes.length > 0) {
3654
+ lastRunSummary = run;
3655
+ renderRunSummaryMatrix(run);
3656
+ }
3657
+ refreshPathActivityFromState();
3658
+ renderContent();
3659
+ });
3660
+
3661
+ es.addEventListener('agent-queued', (e) => {
3662
+ const { runId, queueLength, title, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3663
+ updateQueueBadge(queueLength);
3664
+ document.getElementById('agentPanel').classList.add('open');
3665
+ document.getElementById('termBtn').classList.add('term-active');
3666
+ // Track queued paths for spinner
3667
+ getTaskFilePaths(task, featureKey, filePath, selectedFilePaths).forEach(p => agentQueuedPaths.add(p));
3668
+ renderContent();
3669
+ // Append queue notification to the currently running session (or active)
3670
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3671
+ if (targetId) {
3672
+ appendToSession(targetId, `📋 В очереди (${queueLength}): ${title}`, false);
3673
+ }
3674
+ void loadAgentState();
3675
+ });
3676
+
3677
+ es.addEventListener('agent-started', (e) => {
3678
+ setAgentRunning(true);
3679
+ const { runId, title, queueLength = 0, task, featureKey, filePath, selectedFilePaths } = JSON.parse(e.data);
3680
+ updateQueueBadge(queueLength);
3681
+ currentRunId = runId || null;
3682
+ // Move paths from queued → running (current task)
3683
+ const startedPaths = getTaskFilePaths(task, featureKey, filePath, selectedFilePaths);
3684
+ startedPaths.forEach(p => { agentQueuedPaths.delete(p); agentRunningPaths.add(p); });
3685
+ renderContent();
3686
+ // Close previous session (queue case: agent-done not fired between tasks)
3687
+ if (runningSessionId) {
3688
+ const prev = consoleSessions.find(s => s.id === runningSessionId);
3689
+ if (prev && prev.status === 'running') updateSessionStatus(runningSessionId, 'ok');
3690
+ }
3691
+ const id = runId ? ensureSessionForRun({ runId, title, phase: 'running' }, true) : createSession(title);
3692
+ runningSessionId = id;
3693
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
3694
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
3695
+ });
3696
+
3697
+ es.addEventListener('agent-output', (e) => {
3698
+ const { runId, line, isError, isDim } = JSON.parse(e.data);
3699
+ let targetId = runningSessionId;
3700
+ if (runId) {
3701
+ targetId = runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'running' });
3702
+ if (targetId) runningSessionId = targetId;
3703
+ }
3704
+ if (targetId) {
3705
+ appendToSession(targetId, line, !!isError, !!isDim);
3706
+ if (activeSessionId === targetId) {
3707
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
3708
+ }
3709
+ } else {
3710
+ appendTerminalLine(line, !!isError, !!isDim);
3711
+ }
3073
3712
  });
3074
3713
 
3075
- es.addEventListener('agent-done', () => {
3076
- setAgentRunning(false);
3077
- updateQueueBadge(0);
3078
- agentRunningPaths.clear();
3079
- agentQueuedPaths.clear();
3080
- if (runningSessionId) {
3081
- updateSessionStatus(runningSessionId, 'ok');
3082
- if (activeSessionId === runningSessionId) {
3083
- document.getElementById('agentPanelStatus').textContent = '✅ готово';
3084
- }
3714
+ es.addEventListener('agent-done', () => {
3715
+ setAgentRunning(false);
3716
+ if (agentQueueState.length === 0) updateQueueBadge(0);
3717
+ agentRunningPaths.clear();
3718
+ if (runningSessionId) {
3719
+ updateSessionStatus(runningSessionId, 'ok');
3720
+ if (activeSessionId === runningSessionId) {
3721
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
3722
+ }
3085
3723
  runningSessionId = null;
3086
3724
  }
3087
3725
  renderContent();
3088
3726
  });
3089
3727
 
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;
3728
+ es.addEventListener('agent-summary', (e) => {
3729
+ const {
3730
+ runId,
3731
+ fileOutcomes = [],
3732
+ validationStats = null,
3733
+ passed = 0, failed = 0, files = [],
3734
+ testedFileCount = files.length,
3735
+ passedFileCount = Math.max(0, testedFileCount - (failed > 0 ? 1 : 0)),
3736
+ failedFileCount = failed > 0 ? 1 : 0,
3737
+ autoFixQueued = false,
3738
+ } = JSON.parse(e.data);
3739
+ const coverageOk = !validationStats || ((validationStats.notCovered || 0) === 0 && (validationStats.blocked || 0) === 0);
3740
+ const allOk = failed === 0 && coverageOk;
3099
3741
  const box = document.createElement('div');
3100
3742
  box.style.cssText = `
3101
3743
  margin: 10px 0 4px;
@@ -3112,32 +3754,37 @@ function connectSSE() {
3112
3754
  <div style="font-size:11px;color:var(--muted);margin-top:4px">
3113
3755
  Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}
3114
3756
  </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
- `;
3121
- const targetId = runningSessionId || activeSessionId;
3122
- if (targetId) {
3123
- appendToSession(targetId, box);
3124
- // Mark session status from test results immediately
3125
- if (runningSessionId === targetId) {
3126
- updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
3757
+ <div style="font-size:11px;color:var(--muted);margin-top:2px">
3758
+ Тест-кейсы: ${passed} passed${failed > 0 ? ` • ${failed} failed` : ''}
3759
+ </div>
3760
+ ${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>` : ''}
3761
+ ${autoFixQueued ? `<div style="font-size:11px;color:var(--yellow);margin-top:4px">🛠️ Автоисправление поставлено в очередь</div>` : ''}
3762
+ ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
3763
+ `;
3764
+ if (fileOutcomes.length > 0) {
3765
+ lastRunSummary = { runId, fileOutcomes, validationStats };
3766
+ renderRunSummaryMatrix(lastRunSummary);
3767
+ }
3768
+ const targetId = runId ? (runSessionMap.get(runId) || runningSessionId || activeSessionId) : (runningSessionId || activeSessionId);
3769
+ if (targetId) {
3770
+ appendToSession(targetId, box);
3771
+ // Mark session status from test results immediately
3772
+ if (runningSessionId === targetId) {
3773
+ updateSessionStatus(targetId, failed > 0 ? 'error' : 'ok');
3127
3774
  }
3128
3775
  }
3129
3776
  });
3130
3777
 
3131
- es.addEventListener('agent-error', (e) => {
3132
- setAgentRunning(false);
3133
- agentRunningPaths.clear();
3134
- agentQueuedPaths.clear();
3135
- renderContent();
3136
- const { message, authRequired, notInstalled } = JSON.parse(e.data);
3137
-
3138
- // Build error node (plain text or auth/install box)
3139
- let node = null;
3140
- if (authRequired || notInstalled) {
3778
+ es.addEventListener('agent-error', (e) => {
3779
+ const { runId, message, authRequired, notInstalled } = JSON.parse(e.data);
3780
+ if (!runId) setAgentRunning(false);
3781
+ agentRunningPaths.clear();
3782
+ agentQueuedPaths.clear();
3783
+ renderContent();
3784
+
3785
+ // Build error node (plain text or auth/install box)
3786
+ let node = null;
3787
+ if (authRequired || notInstalled) {
3141
3788
  node = document.createElement('div');
3142
3789
  node.style.cssText = 'margin:10px 0 4px;padding:10px 14px;border-radius:8px;border:1px solid var(--red);background:#2a0d0d;font-family:inherit;';
3143
3790
  node.innerHTML = `
@@ -3145,14 +3792,14 @@ function connectSSE() {
3145
3792
  ${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>` : ''}
3146
3793
  ${notInstalled ? `<div style="margin-top:6px;font-size:11px;color:var(--dim)">После установки перезапусти viberadar</div>` : ''}
3147
3794
  `;
3148
- }
3149
-
3150
- // If no session exists yet (startup check fires before any run), create one
3151
- const targetId = runningSessionId || (() => {
3152
- if (authRequired || notInstalled) {
3153
- const id = createSession('⚠️ Проверка агента', 'error');
3154
- return id;
3155
- }
3795
+ }
3796
+
3797
+ // If no session exists yet (startup check fires before any run), create one
3798
+ const targetId = (runId ? (runSessionMap.get(runId) || ensureSessionForRun({ runId, title: `Run ${runId}`, phase: 'failed' })) : runningSessionId) || (() => {
3799
+ if (authRequired || notInstalled) {
3800
+ const id = createSession('⚠️ Проверка агента', 'error');
3801
+ return id;
3802
+ }
3156
3803
  return activeSessionId;
3157
3804
  })();
3158
3805
 
@@ -3163,12 +3810,12 @@ function connectSSE() {
3163
3810
  appendToSession(targetId, '❌ ' + (message || 'Ошибка агента'), true);
3164
3811
  }
3165
3812
  updateSessionStatus(targetId, 'error');
3166
- if (activeSessionId === targetId) {
3167
- document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3168
- }
3169
- }
3170
- if (runningSessionId) runningSessionId = null;
3171
- });
3813
+ if (activeSessionId === targetId) {
3814
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
3815
+ }
3816
+ }
3817
+ if (!runId && runningSessionId) runningSessionId = null;
3818
+ });
3172
3819
 
3173
3820
  es.addEventListener('tests-started', (e) => {
3174
3821
  const { testType, count } = JSON.parse(e.data);
@@ -3236,21 +3883,23 @@ function connectSSE() {
3236
3883
  }
3237
3884
  });
3238
3885
 
3239
- es.onerror = () => {
3240
- setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
3241
- es.close();
3242
- setTimeout(connectSSE, 3000);
3243
- };
3244
- }
3886
+ es.onerror = () => {
3887
+ setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
3888
+ es.close();
3889
+ setTimeout(connectSSE, 3000);
3890
+ };
3891
+ }
3245
3892
 
3246
- init().then(() => {
3247
- restoreSessions();
3248
- connectSSE();
3249
- // Sync content padding with terminal panel open state automatically
3250
- new MutationObserver(() => {
3251
- const isOpen = document.getElementById('agentPanel').classList.contains('open');
3252
- document.getElementById('content').classList.toggle('panel-open', isOpen);
3253
- }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
3893
+ init().then(async () => {
3894
+ restoreSessions();
3895
+ await loadAgentState();
3896
+ connectSSE();
3897
+ applyTerminalFiltersFromUi();
3898
+ // Sync content padding with terminal panel open state automatically
3899
+ new MutationObserver(() => {
3900
+ const isOpen = document.getElementById('agentPanel').classList.contains('open');
3901
+ document.getElementById('content').classList.toggle('panel-open', isOpen);
3902
+ }).observe(document.getElementById('agentPanel'), { attributes: true, attributeFilter: ['class'] });
3254
3903
  });
3255
3904
  </script>
3256
3905
  </body>