@grifhinz/logics-manager 2.5.1 → 2.6.0

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.
@@ -8,14 +8,21 @@
8
8
  const updateBanner = () => document.getElementById("viewer-update");
9
9
  const updateCopy = () => document.getElementById("viewer-update-copy");
10
10
  const updateCommand = () => document.getElementById("viewer-update-command");
11
+ const connectionBanner = () => document.getElementById("viewer-connection");
12
+ const connectionCopy = () => document.getElementById("viewer-connection-copy");
13
+ const connectionDetail = () => document.getElementById("viewer-connection-detail");
11
14
  const filterCount = () => document.getElementById("viewer-filter-count");
12
15
  const repoPill = () => document.getElementById("viewer-repo-pill");
16
+ const repoGithubLink = () => document.getElementById("viewer-repo-github");
17
+ const repoFolderButton = () => document.getElementById("viewer-repo-folder");
18
+ const ciButton = () => document.getElementById("viewer-ci");
13
19
  const autoRefreshControl = () => document.getElementById("viewer-auto-refresh");
14
20
  const refreshIntervalControl = () => document.getElementById("viewer-refresh-interval");
15
21
  const refreshMenuButton = () => document.getElementById("viewer-refresh-menu-button");
16
22
  const refreshMenuPanel = () => document.getElementById("viewer-refresh-menu");
17
23
  const activityClearControl = () => document.getElementById("activity-clear");
18
24
  const activityStorageLimit = 80;
25
+ const gitHistoryPageSize = 10;
19
26
  const minAutoRefreshIntervalSeconds = 5;
20
27
  const maxAutoRefreshIntervalSeconds = 60;
21
28
  const defaultAutoRefreshIntervalMs = 15 * 1000;
@@ -29,6 +36,7 @@
29
36
  let viewerFilterState = { ...defaultFilterState };
30
37
  let latestItems = [];
31
38
  let latestRepoRoot = "";
39
+ let latestRepository = { root: "", githubUrl: "" };
32
40
  let latestMetaText = "Read-only local viewer";
33
41
  let autoRefreshIntervalMs = defaultAutoRefreshIntervalMs;
34
42
  let nextAutoRefreshAt = 0;
@@ -42,6 +50,9 @@
42
50
  let mermaidInitialized = false;
43
51
  let focusApplied = false;
44
52
  let latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
53
+ let latestCiStatus = { visible: false, badgeState: "unknown", message: "" };
54
+ let connectionState = "connected";
55
+ let lastSuccessfulSyncAt = 0;
45
56
 
46
57
  function readStoredState() {
47
58
  try {
@@ -157,6 +168,53 @@
157
168
  renderMeta();
158
169
  }
159
170
 
171
+ function formatConnectionTime(timestamp) {
172
+ if (!timestamp) {
173
+ return "No successful sync yet";
174
+ }
175
+ return `Last successful sync ${new Date(timestamp).toLocaleTimeString()}`;
176
+ }
177
+
178
+ function renderConnectionNotice() {
179
+ const banner = connectionBanner();
180
+ if (!(banner instanceof HTMLElement)) {
181
+ return;
182
+ }
183
+ if (connectionState !== "disconnected") {
184
+ banner.hidden = true;
185
+ return;
186
+ }
187
+ const copy = connectionCopy();
188
+ const detail = connectionDetail();
189
+ if (copy) {
190
+ copy.textContent = "Local viewer server disconnected. Displayed data may be stale; waiting for reconnection.";
191
+ }
192
+ if (detail) {
193
+ detail.textContent = formatConnectionTime(lastSuccessfulSyncAt);
194
+ }
195
+ banner.hidden = false;
196
+ }
197
+
198
+ function markConnectionHealthy(options = {}) {
199
+ const wasDisconnected = connectionState === "disconnected";
200
+ connectionState = "connected";
201
+ lastSuccessfulSyncAt = Date.now();
202
+ renderConnectionNotice();
203
+ if (wasDisconnected && !options.silent) {
204
+ setMeta(`Reconnected · refreshed ${new Date(lastSuccessfulSyncAt).toLocaleTimeString()}`);
205
+ }
206
+ }
207
+
208
+ function markConnectionDisconnected(error) {
209
+ connectionState = "disconnected";
210
+ renderConnectionNotice();
211
+ scheduleNextAutoRefresh();
212
+ const message = error instanceof Error && error.message
213
+ ? error.message
214
+ : "Unable to reach local viewer server.";
215
+ setMeta(`Disconnected · ${message}`);
216
+ }
217
+
160
218
  function renderMeta() {
161
219
  const node = meta();
162
220
  if (node) {
@@ -215,13 +273,52 @@
215
273
 
216
274
  function updateRepositoryIdentity(payload) {
217
275
  latestRepoRoot = String(payload.root || latestRepoRoot || "");
276
+ const repository = payload.repository && typeof payload.repository === "object" ? payload.repository : {};
277
+ latestRepository = {
278
+ root: String(repository.root || latestRepoRoot || ""),
279
+ githubUrl: String(repository.githubUrl || "")
280
+ };
218
281
  const pill = repoPill();
219
- if (!pill) {
282
+ if (pill) {
283
+ const repoName = String(payload.repoName || latestRepoRoot.split(/[\\/]/).filter(Boolean).pop() || "repository");
284
+ pill.textContent = repoName;
285
+ pill.title = latestRepoRoot || repoName;
286
+ }
287
+ updateRepositoryShortcuts();
288
+ }
289
+
290
+ function updateRepositoryShortcuts() {
291
+ const github = repoGithubLink();
292
+ const folder = repoFolderButton();
293
+ if (github instanceof HTMLAnchorElement) {
294
+ if (latestRepository.githubUrl) {
295
+ github.hidden = false;
296
+ github.href = latestRepository.githubUrl;
297
+ } else {
298
+ github.hidden = true;
299
+ github.removeAttribute("href");
300
+ }
301
+ }
302
+ if (folder instanceof HTMLButtonElement) {
303
+ folder.hidden = !latestRepository.root;
304
+ }
305
+ }
306
+
307
+ async function openRepositoryFolder() {
308
+ if (!latestRepository.root) {
309
+ setMeta("Repository folder is unavailable.");
220
310
  return;
221
311
  }
222
- const repoName = String(payload.repoName || latestRepoRoot.split(/[\\/]/).filter(Boolean).pop() || "repository");
223
- pill.textContent = repoName;
224
- pill.title = latestRepoRoot || repoName;
312
+ try {
313
+ const response = await fetch("/api/open-repo-folder", { method: "POST" });
314
+ const data = await response.json();
315
+ if (!response.ok || !data.ok) {
316
+ throw new Error(data.error || "Unable to open repository folder.");
317
+ }
318
+ setMeta("Repository folder opened.");
319
+ } catch (error) {
320
+ setMeta(error instanceof Error ? error.message : "Unable to open repository folder.");
321
+ }
225
322
  }
226
323
 
227
324
  function normalizeGitBadgeCounts(payload) {
@@ -269,6 +366,141 @@
269
366
  }
270
367
  }
271
368
 
369
+ function ciBadgeTone(value) {
370
+ const state = String(value || "").toLowerCase();
371
+ if (state === "passing") {
372
+ return "passing";
373
+ }
374
+ if (state === "failing") {
375
+ return "failing";
376
+ }
377
+ if (state === "running" || state === "queued") {
378
+ return "running";
379
+ }
380
+ if (state === "cancelled") {
381
+ return "cancelled";
382
+ }
383
+ if (state === "unavailable") {
384
+ return "unavailable";
385
+ }
386
+ return "unknown";
387
+ }
388
+
389
+ function ciBadgeLabel(value) {
390
+ const state = ciBadgeTone(value);
391
+ if (state === "passing") {
392
+ return "pass";
393
+ }
394
+ if (state === "failing") {
395
+ return "fail";
396
+ }
397
+ if (state === "running") {
398
+ return String(value || "").toLowerCase() === "queued" ? "queue" : "run";
399
+ }
400
+ if (state === "cancelled") {
401
+ return "cancel";
402
+ }
403
+ if (state === "unavailable") {
404
+ return "auth";
405
+ }
406
+ return "n/a";
407
+ }
408
+
409
+ function renderCiButtonBadge(payload) {
410
+ const state = payload?.badgeState || payload?.state || "unknown";
411
+ const label = ciBadgeLabel(state);
412
+ const tone = ciBadgeTone(state);
413
+ return `<span class="viewer-ci-badge viewer-ci-badge--${escapeHtml(tone)}" data-viewer-ci-badge title="${escapeHtml(payload?.message || `CI ${label}`)}">${escapeHtml(label)}</span>`;
414
+ }
415
+
416
+ function updateMainCiBadge(payload = latestCiStatus) {
417
+ latestCiStatus = payload && typeof payload === "object" ? payload : { visible: false, badgeState: "unknown", message: "" };
418
+ const button = ciButton();
419
+ if (!(button instanceof HTMLElement)) {
420
+ return;
421
+ }
422
+ button.querySelector("[data-viewer-ci-badge]")?.remove();
423
+ if (!latestCiStatus.visible) {
424
+ button.hidden = true;
425
+ return;
426
+ }
427
+ button.hidden = false;
428
+ button.title = latestCiStatus.message || "Show GitHub Actions CI status";
429
+ button.insertAdjacentHTML("beforeend", renderCiButtonBadge(latestCiStatus));
430
+ }
431
+
432
+ async function refreshCiBadgeCounters() {
433
+ try {
434
+ const response = await fetch("/api/ci-status");
435
+ if (response.status === 404) {
436
+ updateMainCiBadge({ visible: false, badgeState: "unknown", message: "CI status endpoint unavailable." });
437
+ return;
438
+ }
439
+ const data = await response.json();
440
+ if (response.ok && data.ok) {
441
+ updateMainCiBadge(data.payload);
442
+ }
443
+ } catch {
444
+ updateMainCiBadge({ visible: false, badgeState: "unknown", message: "CI status unavailable." });
445
+ }
446
+ }
447
+
448
+ function activeCdxAssistantCountFromPayload(payload) {
449
+ if (!payload || payload.state !== "ok") {
450
+ return 0;
451
+ }
452
+ const status = payload.status || {};
453
+ const sessions = cdxSessions(status);
454
+ const sessionActive = sessions.filter((session) => {
455
+ const state = String(session.state || session.status || session.availability || "").toLowerCase();
456
+ return session.active === true ||
457
+ state.includes("active") ||
458
+ state.includes("running") ||
459
+ state.includes("busy");
460
+ }).length;
461
+ if (sessionActive > 0) {
462
+ return sessionActive;
463
+ }
464
+ const rowsActive = cdxRows(status).filter((row) => row.active === true).length;
465
+ if (rowsActive > 0) {
466
+ return rowsActive;
467
+ }
468
+ return cdxProviders(status).reduce((total, provider) => total + Math.max(0, Number(provider.active || 0)), 0);
469
+ }
470
+
471
+ function updateMainCdxBadge(payload) {
472
+ const button = document.getElementById("viewer-cdx");
473
+ if (!(button instanceof HTMLElement)) {
474
+ return;
475
+ }
476
+ button.querySelector("[data-viewer-cdx-badge]")?.remove();
477
+ const activeCount = activeCdxAssistantCountFromPayload(payload);
478
+ if (activeCount <= 0) {
479
+ button.title = "Show CDX status";
480
+ return;
481
+ }
482
+ const label = activeCount > 9 ? "9+" : String(activeCount);
483
+ const title = activeCount === 1 ? "1 active assistant/session" : `${activeCount} active assistants/sessions`;
484
+ button.title = `Show CDX status · ${title}`;
485
+ button.insertAdjacentHTML("beforeend", `<span class="viewer-cdx-button-badge" data-viewer-cdx-badge title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}">${escapeHtml(label)}</span>`);
486
+ }
487
+
488
+ async function refreshCdxBadgeCounters() {
489
+ try {
490
+ const response = await fetch("/api/cdx-status");
491
+ if (response.status === 404) {
492
+ updateMainCdxBadge(null);
493
+ return;
494
+ }
495
+ const data = await response.json();
496
+ if (response.ok && data.ok) {
497
+ updateMainCdxBadge(data.payload);
498
+ }
499
+ } catch {
500
+ updateMainCdxBadge(null);
501
+ }
502
+ }
503
+
272
504
  function setGitBadgeCountsFromPayload(payload, options = {}) {
273
505
  latestGitBadgeCounts = normalizeGitBadgeCounts(payload);
274
506
  if (options.updateMain !== false) {
@@ -526,6 +758,7 @@
526
758
  }
527
759
 
528
760
  function postToApp(payload, options = {}) {
761
+ markConnectionHealthy({ silent: Boolean(options.silent) });
529
762
  latestItems = updateStoredActivity(Array.isArray(payload.items) ? payload.items : []);
530
763
  if (!autoRefreshIntervalTouched) {
531
764
  autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(payload.autoRefreshIntervalSeconds) * 1000;
@@ -541,6 +774,8 @@
541
774
  }
542
775
  scheduleNextAutoRefresh();
543
776
  renderUpdateNotice(payload.updateInfo);
777
+ refreshCiBadgeCounters();
778
+ refreshCdxBadgeCounters();
544
779
  updateFilterSummary();
545
780
  applyLocalViewerChrome();
546
781
  bindRefreshMenuControls();
@@ -585,6 +820,9 @@
585
820
  await refreshGitBadgeCounters();
586
821
  }
587
822
  return true;
823
+ } catch (error) {
824
+ markConnectionDisconnected(error);
825
+ throw error;
588
826
  } finally {
589
827
  itemsLoadInFlight = false;
590
828
  }
@@ -602,10 +840,18 @@
602
840
  return Boolean(panel && !panel.hidden && title && title.textContent === "CDX status");
603
841
  }
604
842
 
843
+ function isCiStatusOpen() {
844
+ const panel = documentPanel();
845
+ const title = documentTitle();
846
+ return Boolean(panel && !panel.hidden && title && title.textContent === "CI status");
847
+ }
848
+
605
849
  async function refreshViewer(method = "POST", options = {}) {
606
850
  await loadItems(method, options);
607
851
  if (isGitStatusOpen()) {
608
852
  await showGitStatus({ preserve: true, silent: Boolean(options.silent) });
853
+ } else if (isCiStatusOpen()) {
854
+ await showCiStatus({ silent: Boolean(options.silent) });
609
855
  } else if (isCdxStatusOpen()) {
610
856
  await showCdxStatus({ silent: Boolean(options.silent) });
611
857
  } else if (method === "POST") {
@@ -893,14 +1139,43 @@
893
1139
  }
894
1140
 
895
1141
  function renderMetricCards(entries) {
896
- return entries.map(([label, value]) => `
897
- <div class="viewer-insights__card">
1142
+ return entries.map(([label, value, tone]) => `
1143
+ <div class="viewer-insights__card${tone ? ` viewer-insights__card--${escapeHtml(tone)}` : ""}">
898
1144
  <div class="viewer-insights__label">${escapeHtml(label)}</div>
899
1145
  <div class="viewer-insights__value">${escapeHtml(value)}</div>
900
1146
  </div>
901
1147
  `).join("");
902
1148
  }
903
1149
 
1150
+ function renderInsightBars(entries, total) {
1151
+ const denominator = Math.max(1, Number(total) || 0);
1152
+ if (!entries.length) {
1153
+ return '<li class="viewer-insights__bar-row">No corpus shape available</li>';
1154
+ }
1155
+ return entries.map(([label, value]) => {
1156
+ const count = Number(value) || 0;
1157
+ const width = Math.max(count > 0 ? 4 : 0, Math.min(100, Math.round((count / denominator) * 100)));
1158
+ return `
1159
+ <li class="viewer-insights__bar-row">
1160
+ <div class="viewer-insights__bar-meta"><span>${escapeHtml(label)}</span><strong>${escapeHtml(count)}</strong></div>
1161
+ <div class="viewer-insights__bar-track" aria-hidden="true"><span style="width: ${width}%"></span></div>
1162
+ </li>
1163
+ `;
1164
+ }).join("");
1165
+ }
1166
+
1167
+ function renderSignalRows(items, emptyText = "No signals") {
1168
+ if (!items.length) {
1169
+ return `<li class="viewer-insights__signal viewer-insights__signal--empty">${escapeHtml(emptyText)}</li>`;
1170
+ }
1171
+ return items.map(([label, value, tone]) => `
1172
+ <li class="viewer-insights__signal${tone ? ` viewer-insights__signal--${escapeHtml(tone)}` : ""}">
1173
+ <span>${escapeHtml(label)}</span>
1174
+ <strong>${escapeHtml(value)}</strong>
1175
+ </li>
1176
+ `).join("");
1177
+ }
1178
+
904
1179
  function renderInsightRows(items, emptyText = "No signals") {
905
1180
  if (!items.length) {
906
1181
  return `<li class="viewer-insights__item">${escapeHtml(emptyText)}</li>`;
@@ -1046,63 +1321,80 @@
1046
1321
  const stageRows = Object.entries(countsByStage)
1047
1322
  .sort((left, right) => String(left[0]).localeCompare(String(right[0])))
1048
1323
  .map(([stage, count]) => [stage, count]);
1324
+ const qualityTotal = qualityFindings.length;
1325
+ const needsAttention = blocked.length + incompleteChains.length + brokenRefs.length + missingStatus.length + qualityTotal;
1326
+ const activeQuiet = Math.max(0, open.length - recentlyModified.length - staleActive.length);
1327
+ const primaryState = needsAttention > 0
1328
+ ? `${needsAttention} signals need attention`
1329
+ : "No immediate workflow risk detected";
1049
1330
  return `
1050
1331
  <div class="viewer-insights">
1051
- <div class="viewer-insights__summary">${renderMetricCards([
1052
- ["Docs", docs.length],
1053
- ["Open", open.length],
1054
- ["Closed", closed.length],
1055
- ["Blocked", blocked.length],
1056
- ["Missing status", missingStatus.length],
1057
- ["Modified 7d", recentlyModified.length]
1058
- ])}</div>
1059
- <section class="viewer-insights__section">
1060
- <h2>Overview</h2>
1061
- <ul class="viewer-insights__list">${renderInsightRows(stageRows, "No docs loaded")}</ul>
1062
- </section>
1063
- <section class="viewer-insights__section">
1064
- <h2>Flow health</h2>
1065
- <ul class="viewer-insights__list">${renderInsightRows([
1066
- ["Incomplete workflow chains", incompleteChains.length],
1067
- ["Promotion gaps", incompleteChains.filter((item) => item.stage === "request" || item.stage === "backlog").length],
1068
- ["Orphan or unlinked docs", unlinked.length],
1069
- ["Broken reference risks", brokenRefs.length]
1070
- ])}</ul>
1071
- <ul class="viewer-insights__rows">${renderDocRows(incompleteChains, "No incomplete chains")}</ul>
1072
- </section>
1073
- <section class="viewer-insights__section">
1074
- <h2>Activity</h2>
1075
- <ul class="viewer-insights__list">${renderInsightRows([
1076
- ["Latest changes", recentRows.map(itemLabel).join(", ") || "None"],
1077
- ["Stale active docs", staleActive.map(itemLabel).join(", ") || "None"],
1078
- ["Recently active docs", recentlyModified.slice(0, 8).map(itemLabel).join(", ") || "None"],
1079
- ["Activity classification", `recent ${recentlyModified.length}, stale ${open.filter(isStale).length}, quiet ${Math.max(0, open.length - recentlyModified.length)}`]
1080
- ])}</ul>
1081
- <ul class="viewer-insights__rows">${renderDocRows(recentRows, "No recent documents")}</ul>
1082
- </section>
1083
- <section class="viewer-insights__section">
1084
- <h2>Traceability</h2>
1085
- <ul class="viewer-insights__list">${renderInsightRows([
1086
- ["Most referenced docs", mostReferenced.map((item) => `${item.id} (${(item.usedBy || []).length})`).join(", ") || "None"],
1087
- ["Unlinked docs", unlinked.slice(0, 8).map((item) => item.id).join(", ") || "None"],
1088
- ["Broken references", brokenRefs.slice(0, 8).join(", ") || "None"],
1089
- ["Relationships by type", Object.entries(relationshipCounts).map(([stage, count]) => `${stage} ${count}`).join(", ") || "None"]
1090
- ])}</ul>
1091
- <ul class="viewer-insights__rows">${renderDocRows(unlinked, "No unlinked documents")}${renderPathRows(brokenRefs, "No broken references")}</ul>
1092
- </section>
1093
- <section class="viewer-insights__section">
1094
- <h2>Quality signals</h2>
1095
- <ul class="viewer-insights__list">${renderInsightRows([
1096
- ["Lint/audit categories", Object.entries(qualityBySource).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
1097
- ["Findings by document type", Object.entries(qualityByDocType).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
1098
- ["Concentrated issues", concentratedIssues.map(([key, count]) => `${key} ${count}`).join(", ") || "None"]
1099
- ])}</ul>
1100
- <ul class="viewer-insights__rows">${renderPathRows(concentratedIssues.map(([key, count]) => `${key} (${count})`), "No concentrated issues")}</ul>
1332
+ <section class="viewer-insights__hero">
1333
+ <div>
1334
+ <h2>Overview</h2>
1335
+ <p>${escapeHtml(primaryState)} across ${escapeHtml(docs.length)} workflow docs.</p>
1336
+ </div>
1337
+ <div class="viewer-insights__summary">${renderMetricCards([
1338
+ ["Docs", docs.length],
1339
+ ["Needs attention", needsAttention, needsAttention ? "warning" : "ok"],
1340
+ ["Recent 7d", recentlyModified.length],
1341
+ ["Quality findings", qualityTotal, qualityTotal ? "warning" : "ok"]
1342
+ ])}</div>
1101
1343
  </section>
1102
1344
  <section class="viewer-insights__section">
1103
1345
  <h2>Operator actions</h2>
1104
- <ul class="viewer-insights__rows">${renderActionRows(actions)}</ul>
1346
+ <ul class="viewer-insights__rows viewer-insights__rows--actions">${renderActionRows(actions)}</ul>
1105
1347
  </section>
1348
+ <div class="viewer-insights__workspace">
1349
+ <section class="viewer-insights__section">
1350
+ <h2>Corpus shape</h2>
1351
+ <ul class="viewer-insights__bars">${renderInsightBars(stageRows, docs.length)}</ul>
1352
+ <ul class="viewer-insights__list">${renderInsightRows([
1353
+ ["Open", open.length],
1354
+ ["Closed", closed.length],
1355
+ ["Blocked", blocked.length],
1356
+ ["Missing status", missingStatus.length]
1357
+ ])}</ul>
1358
+ </section>
1359
+ <section class="viewer-insights__section">
1360
+ <h2>Flow health</h2>
1361
+ <ul class="viewer-insights__signals">${renderSignalRows([
1362
+ ["Incomplete workflow chains", incompleteChains.length, incompleteChains.length ? "warning" : "ok"],
1363
+ ["Promotion gaps", incompleteChains.filter((item) => item.stage === "request" || item.stage === "backlog").length, incompleteChains.length ? "warning" : "ok"],
1364
+ ["Orphan or unlinked docs", unlinked.length, unlinked.length ? "muted" : "ok"],
1365
+ ["Broken reference risks", brokenRefs.length, brokenRefs.length ? "warning" : "ok"]
1366
+ ])}</ul>
1367
+ <ul class="viewer-insights__rows">${renderDocRows(incompleteChains, "No incomplete chains")}</ul>
1368
+ </section>
1369
+ <section class="viewer-insights__section">
1370
+ <h2>Activity</h2>
1371
+ <ul class="viewer-insights__signals">${renderSignalRows([
1372
+ ["Recently active docs", recentlyModified.length],
1373
+ ["Stale active docs", staleActive.length, staleActive.length ? "warning" : "ok"],
1374
+ ["Quiet active docs", activeQuiet]
1375
+ ])}</ul>
1376
+ <ul class="viewer-insights__rows">${renderDocRows(recentRows, "No recent documents")}</ul>
1377
+ </section>
1378
+ <section class="viewer-insights__section">
1379
+ <h2>Traceability</h2>
1380
+ <ul class="viewer-insights__signals">${renderSignalRows([
1381
+ ["Broken references", brokenRefs.length, brokenRefs.length ? "warning" : "ok"],
1382
+ ["Unlinked docs", unlinked.length, unlinked.length ? "muted" : "ok"],
1383
+ ["Most referenced docs", mostReferenced.map((item) => `${item.id} (${(item.usedBy || []).length})`).join(", ") || "None"],
1384
+ ["Relationships by type", Object.entries(relationshipCounts).map(([stage, count]) => `${stage} ${count}`).join(", ") || "None"]
1385
+ ])}</ul>
1386
+ <ul class="viewer-insights__rows">${renderPathRows(brokenRefs, "No broken references")}${renderDocRows(unlinked, "No unlinked documents")}</ul>
1387
+ </section>
1388
+ <section class="viewer-insights__section viewer-insights__section--wide">
1389
+ <h2>Quality signals</h2>
1390
+ <ul class="viewer-insights__signals">${renderSignalRows([
1391
+ ["Lint/audit categories", Object.entries(qualityBySource).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded", qualityTotal ? "warning" : "ok"],
1392
+ ["Findings by document type", Object.entries(qualityByDocType).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
1393
+ ["Concentrated issues", concentratedIssues.map(([key, count]) => `${key} ${count}`).join(", ") || "None"]
1394
+ ])}</ul>
1395
+ <ul class="viewer-insights__rows">${renderPathRows(concentratedIssues.map(([key, count]) => `${key} (${count})`), "No concentrated issues")}</ul>
1396
+ </section>
1397
+ </div>
1106
1398
  </div>
1107
1399
  `;
1108
1400
  }
@@ -1702,10 +1994,108 @@
1702
1994
  if (!response.ok || !data.ok) {
1703
1995
  throw new Error(data.error || "Unable to load CDX status.");
1704
1996
  }
1997
+ updateMainCdxBadge(data.payload);
1705
1998
  setDocument("CDX status", renderCdxStatus(data.payload));
1706
1999
  setMeta(options.silent ? "CDX status refreshed." : "CDX status loaded.");
1707
2000
  }
1708
2001
 
2002
+ function renderCiBadge(value) {
2003
+ const tone = ciBadgeTone(value);
2004
+ return `<span class="viewer-ci__badge viewer-ci__badge--${escapeHtml(tone)}">${escapeHtml(ciBadgeLabel(value))}</span>`;
2005
+ }
2006
+
2007
+ function formatCiDate(value) {
2008
+ const timestamp = Date.parse(String(value || ""));
2009
+ if (!Number.isFinite(timestamp)) {
2010
+ return "";
2011
+ }
2012
+ return new Date(timestamp).toLocaleString();
2013
+ }
2014
+
2015
+ function renderCiStatus(payload) {
2016
+ if (!payload || !payload.visible) {
2017
+ return `
2018
+ <div class="viewer-ci">
2019
+ <div class="viewer-ci__state">${escapeHtml(payload?.message || "GitHub Actions CI is not configured for this repository.")}</div>
2020
+ </div>
2021
+ `;
2022
+ }
2023
+ const run = payload.run && typeof payload.run === "object" ? payload.run : null;
2024
+ const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
2025
+ const state = payload.badgeState || run?.badgeState || payload.state || "unknown";
2026
+ const cards = renderMetricCards([
2027
+ ["State", ciBadgeLabel(state)],
2028
+ ["Branch", run?.branch || payload.branch || "Unknown"],
2029
+ ["Commit", (run?.headSha || payload.headSha || "").slice(0, 7) || "Unknown"],
2030
+ ["Match", run?.matchSource === "head" ? "Current HEAD" : "Latest branch run"]
2031
+ ]);
2032
+ const runUrl = run?.htmlUrl ? `<a class="viewer-ci__link" href="${escapeHtml(run.htmlUrl)}" target="_blank" rel="noreferrer">Open in GitHub</a>` : "";
2033
+ const runRows = run ? [
2034
+ ["Workflow", run.workflowName || run.name || "GitHub Actions"],
2035
+ ["Status", `${run.status || "unknown"}${run.conclusion ? ` / ${run.conclusion}` : ""}`],
2036
+ ["Event", run.event || "Unknown"],
2037
+ ["Commit", run.commitMessage || payload.subject || "Unknown"],
2038
+ ["Author", run.author || payload.author || "Unknown"],
2039
+ ["Started", formatCiDate(run.runStartedAt || run.createdAt) || "Unknown"],
2040
+ ["Updated", formatCiDate(run.updatedAt) || "Unknown"]
2041
+ ].map(([label, value]) => `
2042
+ <li class="viewer-ci__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></li>
2043
+ `).join("") : `<li class="viewer-ci__empty">${escapeHtml(payload.message || "No GitHub Actions run found for this branch.")}</li>`;
2044
+ const jobRows = jobs.length ? jobs.map((job) => {
2045
+ const jobState = ciBadgeTone(job.conclusion || job.status);
2046
+ const content = `
2047
+ <span>${escapeHtml(job.name || "Job")}</span>
2048
+ <strong>${escapeHtml([job.status, job.conclusion].filter(Boolean).join(" / ") || "unknown")}</strong>
2049
+ `;
2050
+ return `<li class="viewer-ci__job viewer-ci__job--${escapeHtml(jobState)}">${job.htmlUrl ? `<a href="${escapeHtml(job.htmlUrl)}" target="_blank" rel="noreferrer">${content}</a>` : content}</li>`;
2051
+ }).join("") : `<li class="viewer-ci__empty">No job details reported.</li>`;
2052
+ return `
2053
+ <div class="viewer-ci">
2054
+ <div class="viewer-ci__summary">${cards}</div>
2055
+ <div class="viewer-ci__workspace">
2056
+ <section class="viewer-ci__section">
2057
+ <div class="viewer-ci__heading"><h2>Latest run</h2>${renderCiBadge(state)}</div>
2058
+ <ul class="viewer-ci__list">${runRows}</ul>
2059
+ ${runUrl}
2060
+ </section>
2061
+ <section class="viewer-ci__section">
2062
+ <div class="viewer-ci__heading"><h2>Jobs</h2><span>${escapeHtml(jobs.length)} reported</span></div>
2063
+ <ul class="viewer-ci__jobs">${jobRows}</ul>
2064
+ </section>
2065
+ </div>
2066
+ </div>
2067
+ `;
2068
+ }
2069
+
2070
+ async function showCiStatus(options = {}) {
2071
+ if (!options.silent) {
2072
+ setMeta("Checking CI status...");
2073
+ }
2074
+ const response = await fetch("/api/ci-status");
2075
+ let data = {};
2076
+ try {
2077
+ data = await response.json();
2078
+ } catch {
2079
+ data = {};
2080
+ }
2081
+ if (response.status === 404) {
2082
+ setDocument("CI status", renderCiStatus({
2083
+ visible: true,
2084
+ state: "unavailable",
2085
+ badgeState: "unavailable",
2086
+ message: "CI status endpoint unavailable. Restart the local viewer so it loads the current logics-manager backend."
2087
+ }));
2088
+ setMeta("Restart the local viewer to enable CI status.");
2089
+ return;
2090
+ }
2091
+ if (!response.ok || !data.ok) {
2092
+ throw new Error(data.error || "Unable to load CI status.");
2093
+ }
2094
+ updateMainCiBadge(data.payload);
2095
+ setDocument("CI status", renderCiStatus(data.payload));
2096
+ setMeta(options.silent ? "CI status refreshed." : "CI status loaded.");
2097
+ }
2098
+
1709
2099
  function renderGitStatus(payload) {
1710
2100
  if (!payload || payload.state !== "ok") {
1711
2101
  return `
@@ -1751,6 +2141,14 @@
1751
2141
  <span class="viewer-git__domain-label">${escapeHtml(label)}${key === "changes" ? gitBadgeHtml("changes") : ""}${key === "history" ? gitBadgeHtml("history") : ""}</span><strong>${escapeHtml(count)}</strong>
1752
2142
  </button>
1753
2143
  `).join("");
2144
+ const renderChangeStats = (entry) => {
2145
+ const additions = Number(entry?.additions);
2146
+ const deletions = Number(entry?.deletions);
2147
+ if (!Number.isFinite(additions) || !Number.isFinite(deletions)) {
2148
+ return "";
2149
+ }
2150
+ return `<span class="viewer-git__file-changes" title="Line changes"><span class="viewer-git__file-additions">+${escapeHtml(additions)}</span><span class="viewer-git__file-deletions">-${escapeHtml(deletions)}</span></span>`;
2151
+ };
1754
2152
  const renderFileSections = (allowedKeys) => groupDefs.filter(([key]) => allowedKeys.includes(key)).map(([key, label]) => {
1755
2153
  const entries = Array.isArray(payload.groups?.[key]) ? payload.groups[key] : [];
1756
2154
  if (!entries.length) {
@@ -1763,6 +2161,7 @@
1763
2161
  <li>
1764
2162
  <button class="viewer-git__file" type="button" data-viewer-git-file="${escapeHtml(entry.path)}" data-viewer-git-cached="${key === "staged" ? "1" : "0"}">
1765
2163
  <span class="viewer-git__file-path">${escapeHtml(entry.from ? `${entry.from} -> ${entry.path}` : entry.path)}</span>
2164
+ ${renderChangeStats(entry)}
1766
2165
  ${entry.logicsType ? `<span class="viewer-git__file-kind">${escapeHtml(entry.logicsType)}</span>` : ""}
1767
2166
  </button>
1768
2167
  </li>
@@ -1776,9 +2175,16 @@
1776
2175
  const untrackedSections = renderFileSections(["untracked"]);
1777
2176
  const clean = payload.clean ? '<p class="viewer-git__state">Working tree clean.</p>' : "";
1778
2177
  const recentCommits = Array.isArray(payload.recentCommits) ? payload.recentCommits : [];
2178
+ const renderGitHistoryReveal = (hiddenCount) => {
2179
+ if (hiddenCount <= 0) {
2180
+ return "";
2181
+ }
2182
+ const nextCount = Math.min(gitHistoryPageSize, hiddenCount);
2183
+ return `<li class="viewer-git__commit-row viewer-git__commit-row--reveal"><button class="viewer-git__reveal" type="button" data-viewer-git-history-reveal>Show ${escapeHtml(nextCount)} more</button></li>`;
2184
+ };
1779
2185
  const historyRows = recentCommits.length
1780
- ? recentCommits.map((commit) => `
1781
- <li class="viewer-git__commit-row">
2186
+ ? recentCommits.map((commit, index) => `
2187
+ <li class="viewer-git__commit-row" ${index >= gitHistoryPageSize ? "hidden data-viewer-git-history-hidden" : ""}>
1782
2188
  <div class="viewer-git__commit-main">
1783
2189
  <code>${escapeHtml(commit.hash || "")}</code>
1784
2190
  <strong>${escapeHtml(commit.subject || "Untitled commit")}</strong>
@@ -1788,7 +2194,7 @@
1788
2194
  ${commit.refs ? `<span class="viewer-git__commit-refs">${escapeHtml(commit.refs)}</span>` : ""}
1789
2195
  </div>
1790
2196
  </li>
1791
- `).join("")
2197
+ `).join("") + renderGitHistoryReveal(Math.max(0, recentCommits.length - gitHistoryPageSize))
1792
2198
  : `<li class="viewer-git__commit-row">${escapeHtml(payload.latestCommit || "No commit history available.")}</li>`;
1793
2199
  const history = `
1794
2200
  <section class="viewer-git__section">
@@ -1852,6 +2258,29 @@
1852
2258
  });
1853
2259
  }
1854
2260
 
2261
+ function gitDiffLineKind(line) {
2262
+ if (line.startsWith("+") && !line.startsWith("+++")) {
2263
+ return "add";
2264
+ }
2265
+ if (line.startsWith("-") && !line.startsWith("---")) {
2266
+ return "delete";
2267
+ }
2268
+ if (line.startsWith("@@")) {
2269
+ return "hunk";
2270
+ }
2271
+ if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("+++") || line.startsWith("---")) {
2272
+ return "meta";
2273
+ }
2274
+ return "context";
2275
+ }
2276
+
2277
+ function renderGitDiffPreview(content) {
2278
+ return String(content)
2279
+ .split("\n")
2280
+ .map((line) => `<span class="viewer-git__diff-line viewer-git__diff-line--${gitDiffLineKind(line)}">${escapeHtml(line || " ")}</span>`)
2281
+ .join("");
2282
+ }
2283
+
1855
2284
  async function loadGitDiff(path, cached, button = null) {
1856
2285
  const diffPanel = document.querySelector("[data-viewer-git-diff]");
1857
2286
  if (!(diffPanel instanceof HTMLElement) || !path) {
@@ -1873,7 +2302,7 @@
1873
2302
  return;
1874
2303
  }
1875
2304
  const content = payload.diff || payload.message || "No diff is available for this file.";
1876
- diffPanel.innerHTML = `<div class="viewer-git__diff-meta">${escapeHtml(payload.path || path)} · ${escapeHtml(payload.mode || "worktree")}${payload.truncated ? " · truncated" : ""}</div><pre><code>${escapeHtml(content)}</code></pre>`;
2305
+ diffPanel.innerHTML = `<div class="viewer-git__diff-meta">${escapeHtml(payload.path || path)} · ${escapeHtml(payload.mode || "worktree")}${payload.truncated ? " · truncated" : ""}</div><pre><code>${renderGitDiffPreview(content)}</code></pre>`;
1877
2306
  }
1878
2307
 
1879
2308
  function applyGitDomain(domain) {
@@ -1989,6 +2418,7 @@
1989
2418
  applyLocalViewerChrome();
1990
2419
  [document.getElementById("viewer-insights")].forEach((button) => {
1991
2420
  button?.addEventListener("click", () => {
2421
+ setRefreshMenuOpen(false);
1992
2422
  showCorpusInsights().catch((error) => setMeta(error.message));
1993
2423
  });
1994
2424
  });
@@ -2038,14 +2468,21 @@
2038
2468
  });
2039
2469
  });
2040
2470
  document.getElementById("viewer-health")?.addEventListener("click", () => {
2471
+ setRefreshMenuOpen(false);
2041
2472
  showHealth().catch((error) => setMeta(error.message));
2042
2473
  });
2043
2474
  document.getElementById("viewer-git")?.addEventListener("click", () => {
2044
2475
  showGitStatus().catch((error) => setMeta(error.message));
2045
2476
  });
2477
+ ciButton()?.addEventListener("click", () => {
2478
+ showCiStatus().catch((error) => setMeta(error.message));
2479
+ });
2046
2480
  document.getElementById("viewer-cdx")?.addEventListener("click", () => {
2047
2481
  showCdxStatus().catch((error) => setMeta(error.message));
2048
2482
  });
2483
+ repoFolderButton()?.addEventListener("click", () => {
2484
+ openRepositoryFolder().catch((error) => setMeta(error.message));
2485
+ });
2049
2486
  activityClearControl()?.addEventListener("click", () => {
2050
2487
  clearActivityHistory();
2051
2488
  });
@@ -2078,8 +2515,34 @@
2078
2515
  const healthTarget = event.target instanceof Element ? event.target.closest("[data-viewer-open-health]") : null;
2079
2516
  const filterTarget = event.target instanceof Element ? event.target.closest("[data-viewer-filter-group][data-viewer-filter-value]") : null;
2080
2517
  const revealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-reveal]") : null;
2518
+ const gitHistoryRevealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-history-reveal]") : null;
2081
2519
  const gitDomainTarget = event.target instanceof Element ? event.target.closest(".viewer-git__domain[data-viewer-git-domain]") : null;
2082
2520
  const gitFileTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-file]") : null;
2521
+ if (gitHistoryRevealTarget instanceof HTMLElement) {
2522
+ event.preventDefault();
2523
+ event.stopImmediatePropagation();
2524
+ if (gitHistoryRevealTarget.dataset.viewerGitHistoryBusy === "true") {
2525
+ return;
2526
+ }
2527
+ gitHistoryRevealTarget.dataset.viewerGitHistoryBusy = "true";
2528
+ const list = gitHistoryRevealTarget.closest("ul");
2529
+ const hiddenRows = Array.from(list?.querySelectorAll("[data-viewer-git-history-hidden]") || [])
2530
+ .filter((row) => row instanceof HTMLElement);
2531
+ hiddenRows.slice(0, gitHistoryPageSize).forEach((row) => {
2532
+ if (row instanceof HTMLElement) {
2533
+ row.hidden = false;
2534
+ row.removeAttribute("data-viewer-git-history-hidden");
2535
+ }
2536
+ });
2537
+ const remaining = Array.from(list?.querySelectorAll("[data-viewer-git-history-hidden]") || []).length;
2538
+ if (remaining > 0) {
2539
+ gitHistoryRevealTarget.textContent = `Show ${Math.min(gitHistoryPageSize, remaining)} more`;
2540
+ gitHistoryRevealTarget.dataset.viewerGitHistoryBusy = "false";
2541
+ } else {
2542
+ gitHistoryRevealTarget.closest("li")?.remove();
2543
+ }
2544
+ return;
2545
+ }
2083
2546
  if (revealTarget instanceof HTMLElement) {
2084
2547
  const list = revealTarget.closest("ul");
2085
2548
  list?.querySelectorAll("[data-viewer-hidden-row]").forEach((row) => {