claude-code-memory-explorer 0.11.0 → 0.12.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.
package/public/app.js CHANGED
@@ -4,6 +4,7 @@ let projectData = null;
4
4
  let stackData = [];
5
5
  let summaryData = null;
6
6
  let selectedFileId = null;
7
+ let healthViewOpen = true; // health dashboard is the default view; selecting a file exits it
7
8
 
8
9
  // #endregion STATE
9
10
 
@@ -304,10 +305,11 @@ function toggleHelpModal() {
304
305
  document.getElementById('helpModal').classList.toggle('open');
305
306
  }
306
307
 
307
- // biome-ignore lint/correctness/noUnusedVariables: called from topbar markup
308
- function openAnalysisModal() {
309
- document.getElementById('analysisModal').classList.add('open');
310
- refreshAnalysis();
308
+ function openHealthView() {
309
+ healthViewOpen = !healthViewOpen;
310
+ document.getElementById('analyzeBtn')?.classList.toggle('active', healthViewOpen);
311
+ renderPreview();
312
+ if (healthViewOpen) refreshAnalysis();
311
313
  }
312
314
 
313
315
  function bindModalKeys(inputId, modalId, submitFn) {
@@ -342,6 +344,7 @@ const LOAD_ICONS = {
342
344
  startup: '\u25D2',
343
345
  conditional: '\u25CB',
344
346
  ondemand: '\u25CC',
347
+ tree: '\u25CE',
345
348
  import: '@',
346
349
  link: '\u2197',
347
350
  };
@@ -350,6 +353,7 @@ const LOAD_TITLES = {
350
353
  startup: 'Loaded at startup (partial)',
351
354
  conditional: 'Conditional (path-scoped)',
352
355
  ondemand: 'On-demand',
356
+ tree: 'Loaded progressively (when Claude works in its directory)',
353
357
  import: 'Imported by parent file',
354
358
  link: 'Referenced via markdown link (not auto-loaded)',
355
359
  };
@@ -397,7 +401,8 @@ function renderTree() {
397
401
  const loadIcon = LOAD_ICONS[item.load] || '';
398
402
  const loadTitle = LOAD_TITLES[item.load] || item.load;
399
403
  const meta = `${item.lines}L`;
400
- const isConditional = item.load === 'conditional' || item.load === 'ondemand' || item.load === 'link';
404
+ const isConditional =
405
+ item.load === 'conditional' || item.load === 'ondemand' || item.load === 'link' || item.load === 'tree';
401
406
  const pad = indent ? ` style="padding-left:${12 + indent * 16}px"` : '';
402
407
  const muted = item.scope === 'agent-memory' ? ' tree-agent-item' : '';
403
408
  const children = childrenOf[item.id];
@@ -405,10 +410,19 @@ function renderTree() {
405
410
  const chevron = children
406
411
  ? `<span class="tree-chevron" onclick="_toggleItem(event,'${escAttrJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
407
412
  : '<span class="tree-chevron-spacer"></span>';
408
- let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
413
+ const fp = footprintHighlight && footprintHit(item) ? ' fp-hit' : '';
414
+ let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}${fp}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
409
415
  h += chevron;
410
416
  h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
411
417
  h += `<span class="file-name">${esc(item.name)}</span>`;
418
+ const hs = treeHealth.get(item.id);
419
+ if (hs) {
420
+ const sev = hs.high ? 'high' : hs.med ? 'med' : 'low';
421
+ const n = hs.high || hs.med || hs.low;
422
+ h += `<span class="tree-health th-${esc(sev)}" title="${esc(hs.high)} high · ${esc(hs.med)} med · ${esc(hs.low)} low — open Health for details">${n}</span>`;
423
+ } else if (auditedIds.has(item.id)) {
424
+ h += '<span class="tree-health th-clean" title="Audited — no findings"></span>';
425
+ }
412
426
  h += `<span class="file-meta">${meta}</span>`;
413
427
  h += '</div>';
414
428
  if (expanded) {
@@ -509,6 +523,10 @@ function pushFileState(id) {
509
523
  }
510
524
 
511
525
  function selectFile(id, pushState = true) {
526
+ if (healthViewOpen) {
527
+ healthViewOpen = false;
528
+ document.getElementById('analyzeBtn')?.classList.remove('active');
529
+ }
512
530
  selectedFileId = selectedFileId === id ? null : id;
513
531
  if (selectedFileId) {
514
532
  const item = stackData.find((s) => s.id === selectedFileId);
@@ -644,6 +662,12 @@ function renderMemoryIndexTable(entries) {
644
662
 
645
663
  async function renderPreview() {
646
664
  const panel = document.getElementById('previewPanel');
665
+ if (healthViewOpen) {
666
+ document.getElementById('analyzeBtn')?.classList.add('active');
667
+ panel.innerHTML = '<div class="health-view"><div id="analysisSection"></div></div>';
668
+ if (lastAnalysisSt) rerenderAnalysis();
669
+ return;
670
+ }
647
671
  const source = stackData.find((s) => s.id === selectedFileId);
648
672
  if (!source) {
649
673
  panel.innerHTML =
@@ -723,6 +747,8 @@ async function renderPreview() {
723
747
  html += `<div class="preview-filepath" title="${esc(source.path)}">${esc(source.path)}</div>`;
724
748
  html += '</div>';
725
749
 
750
+ html += renderFileFindings(source);
751
+
726
752
  const hl = (text) => {
727
753
  const { text: processed, placeholders } = linkifyImports(text, source.id);
728
754
  // Skill names aren't filenames (e.g. "aspire") — derive the language from the real file
@@ -878,19 +904,78 @@ let analysisPollTimer = null;
878
904
  let lastAnalysisSt = null;
879
905
  let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
880
906
  let analysisFilter = 'all';
881
- let analysisDismissed = new Set(); // session-only; keys are `${kind}|${title}`
882
- let analysisTs = null; // reset dismissals + filter when a new result lands
883
- let analysisRunIdx = 0; // which run from the per-project history is shown (0 = latest)
884
-
885
- // Session-only identity of a finding, used by dismiss/copy/render alike
907
+ // Server persists dismissals per scope: project findings (cross included) in the
908
+ // project entry, user-scope findings in the shared user entry (visible from every
909
+ // project). Keys are `${kind}|${title}`.
910
+ const dismissedByScope = { project: new Set(), user: new Set() };
911
+ function dismissScope(f) {
912
+ return f.scope === 'user' ? 'user' : 'project';
913
+ }
914
+ function isDismissed(f) {
915
+ return dismissedByScope[dismissScope(f)].has(findingKey(f));
916
+ }
917
+ function dismissedCount() {
918
+ return dismissedByScope.project.size + dismissedByScope.user.size;
919
+ }
920
+ let dismissWrites = 0; // dismiss POSTs in flight — polls must not clobber the local set meanwhile
921
+ let lastPollKey = null; // fingerprint of the last rendered analysis state
922
+ let analysisTs = null; // reset filters when a new result lands
923
+ let analysisRunIdx = -1; // shown run: -1 = merged current state (default), 0+ = single run
924
+ let fixSelected = new Set(); // finding keys queued for the copyable fix plan
925
+ let analysisFileFilter = null; // {id, name} from a memory-map cell click; narrows the findings list
926
+ let treeHealth = new Map(); // source id -> {high, med, low} from the latest run
927
+ let auditedIds = new Set(); // ids covered by the latest run (clean files get a hollow dot)
928
+
929
+ // Identity of a finding, used by dismiss/copy/render alike
886
930
  function findingKey(f) {
887
931
  return `${f.kind}|${f.title}`;
888
932
  }
889
933
 
890
- function resetAnalysisView(runIdx = 0) {
934
+ function resetAnalysisView(runIdx = -1) {
891
935
  analysisRunIdx = runIdx;
892
936
  analysisFilter = 'all';
893
- analysisDismissed = new Set();
937
+ fixSelected = new Set();
938
+ analysisFileFilter = null;
939
+ }
940
+
941
+ // Findings name files by bare name while map cells carry display names (e.g.
942
+ // skills), so matching goes through ids — the same resolution as findingFileIds.
943
+ function _setFileFilter(id, name) {
944
+ analysisFileFilter = analysisFileFilter?.id === id ? null : { id, name };
945
+ rerenderAnalysis();
946
+ }
947
+
948
+ function findingMatchesFileFilter(f) {
949
+ if (!analysisFileFilter) return true;
950
+ return (f.files || []).some(
951
+ (n) => n === analysisFileFilter.name || findingFileIds(n, f.scope).includes(analysisFileFilter.id),
952
+ );
953
+ }
954
+
955
+ // Health annotations on the tree always reflect the merged current state,
956
+ // regardless of which historical run the health view is showing.
957
+ function computeTreeHealth(st) {
958
+ treeHealth = new Map();
959
+ auditedIds = new Set();
960
+ const run = mergedRunView(st);
961
+ if (!run) return;
962
+ for (const s of runAuditedSources(run)) auditedIds.add(s.id);
963
+ for (const f of run.result?.findings || []) {
964
+ if (isDismissed(f)) continue;
965
+ for (const name of f.files || []) {
966
+ for (const id of findingFileIds(name, f.scope)) {
967
+ const cur = treeHealth.get(id) || { high: 0, med: 0, low: 0 };
968
+ cur[f.severity] = (cur[f.severity] || 0) + 1;
969
+ treeHealth.set(id, cur);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // Dismissals change what the tree badges should show — recompute and repaint.
976
+ function syncTreeHealth() {
977
+ computeTreeHealth(lastAnalysisSt);
978
+ renderTree();
894
979
  }
895
980
  let scopePanelOpen = false;
896
981
  let scopeChecked = null; // Set of source ids; seeded with the default scope on first open
@@ -918,7 +1003,7 @@ async function _runAnalysis(force, ids) {
918
1003
  headers: { 'Content-Type': 'application/json' },
919
1004
  body: JSON.stringify(body),
920
1005
  });
921
- if (!res.ok && res.status !== 409) {
1006
+ if (!res.ok) {
922
1007
  const msg = await res
923
1008
  .json()
924
1009
  .then((e) => e.error)
@@ -936,32 +1021,63 @@ async function _runAnalysis(force, ids) {
936
1021
  }
937
1022
 
938
1023
  async function refreshAnalysis() {
939
- const el = document.getElementById('analysisSection');
940
- if (!el) return;
941
1024
  let st;
942
1025
  try {
943
1026
  st = await fetchJSON('/api/memory/analysis');
944
1027
  } catch {
945
1028
  return;
946
1029
  }
947
- if (st.ts !== analysisTs) {
948
- analysisTs = st.ts;
1030
+ const tsKey = `${st.ts}|${st.user?.ts || ''}`;
1031
+ const isNew = tsKey !== analysisTs;
1032
+ if (isNew) {
1033
+ analysisTs = tsKey;
949
1034
  resetAnalysisView();
950
1035
  }
1036
+ // A dismiss POST in flight owns the set — adopting the server's (older) copy here
1037
+ // would briefly resurrect the finding the user just dismissed.
1038
+ if (!dismissWrites) {
1039
+ dismissedByScope.project = new Set(st.dismissed || []);
1040
+ dismissedByScope.user = new Set(st.user?.dismissed || []);
1041
+ }
951
1042
  lastAnalysisSt = st;
952
- el.innerHTML = renderAnalysis(st);
953
- applyScopeIndeterminate(el);
1043
+ // Polls where only the banner clock changed must not rebuild the review DOM —
1044
+ // that would tear down the cards mid-interaction every 2.5s while a run is in flight.
1045
+ const pollKey = JSON.stringify([
1046
+ tsKey,
1047
+ st.error,
1048
+ [...dismissedByScope.project, ...dismissedByScope.user],
1049
+ (st.pending || []).map((p) => `${p.id}${p.stalled ? ':stalled' : ''}`),
1050
+ ]);
1051
+ const banner = document.getElementById('runningBanner');
1052
+ if (pollKey === lastPollKey && banner) {
1053
+ banner.innerHTML = renderRunningBanner(st);
1054
+ } else {
1055
+ computeTreeHealth(st);
1056
+ renderTree();
1057
+ if (isNew && !healthViewOpen && selectedFileId) renderPreview();
1058
+ rerenderAnalysis(st);
1059
+ }
1060
+ lastPollKey = pollKey;
954
1061
  clearTimeout(analysisPollTimer);
955
- if (st.running) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
1062
+ // Stalled-only pendings keep a slow poll alive — the banner promises a late
1063
+ // completion will surface, so it must actually be able to.
1064
+ const pend = st.pending || [];
1065
+ if (pend.some((p) => !p.stalled)) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
1066
+ else if (pend.length) analysisPollTimer = setTimeout(refreshAnalysis, 30000);
956
1067
  }
957
1068
 
958
1069
  // Re-render from the cached state — used by scope/filter/dismiss handlers so a
959
1070
  // checkbox click never triggers a server round-trip.
960
- function rerenderAnalysis() {
1071
+ function rerenderAnalysis(st = lastAnalysisSt) {
961
1072
  const el = document.getElementById('analysisSection');
962
- if (!el || !lastAnalysisSt) return;
963
- el.innerHTML = renderAnalysis(lastAnalysisSt);
1073
+ if (!el || !st) return;
1074
+ // The scope tree is the scroll container; innerHTML replaces it, which would
1075
+ // snap a group toggle deep in the list back to the top of the panel.
1076
+ const scroll = el.querySelector('.scope-tree')?.scrollTop || 0;
1077
+ el.innerHTML = renderAnalysis(st);
964
1078
  applyScopeIndeterminate(el);
1079
+ const tree = el.querySelector('.scope-tree');
1080
+ if (tree) tree.scrollTop = scroll;
965
1081
  }
966
1082
 
967
1083
  // `indeterminate` is a DOM property, not an attribute — set it after every innerHTML render.
@@ -1003,6 +1119,13 @@ function analysisScopeGroups() {
1003
1119
  tag: 'agent',
1004
1120
  items: stackData.filter((s) => s.scope === 'agent-memory'),
1005
1121
  },
1122
+ {
1123
+ key: 'user',
1124
+ label: 'User CLAUDE.md',
1125
+ cls: 't-user',
1126
+ tag: 'user',
1127
+ items: stackData.filter((s) => s.scope === 'user' && !s.parentId),
1128
+ },
1006
1129
  ].filter((g) => g.items.length);
1007
1130
  }
1008
1131
 
@@ -1094,23 +1217,54 @@ function memoryDirPath() {
1094
1217
  return idx ? idx.path.replace(/[\\/][^\\/]+$/, '') : null;
1095
1218
  }
1096
1219
 
1220
+ // Resolve a finding's bare file names to the live stack sources they name, so a
1221
+ // prompt can cite real paths instead of guessing a location from `scope`.
1222
+ function findingSources(f) {
1223
+ const ids = new Set();
1224
+ for (const name of f.files || []) {
1225
+ for (const id of findingFileIds(name, f.scope)) ids.add(id);
1226
+ }
1227
+ return [...ids].map((id) => stackData.find((s) => s.id === id)).filter((s) => s?.path);
1228
+ }
1229
+
1097
1230
  function findingPrompt(f) {
1098
- const dir = memoryDirPath();
1099
- const lines = [
1100
- dir
1231
+ const sources = findingSources(f);
1232
+ const paths = sources.map((s) => s.path);
1233
+ let header;
1234
+ if (paths.length === 1) {
1235
+ header = `In the Claude Code instruction file at ${paths[0]}, apply this fix:`;
1236
+ } else if (paths.length > 1) {
1237
+ header = 'In the Claude Code instruction files listed below, apply this fix:';
1238
+ } else if (f.scope === 'user') {
1239
+ const path = stackData.find((s) => s.id === 'user-claude-md')?.path || '~/.claude/CLAUDE.md';
1240
+ header = `In the user-level Claude Code instructions at ${path}, apply this fix:`;
1241
+ } else {
1242
+ const dir = memoryDirPath();
1243
+ header = dir
1101
1244
  ? `In the Claude Code auto-memory at ${dir}, apply this fix:`
1102
- : "In this project's Claude Code auto-memory, apply this fix:",
1103
- '',
1104
- `Finding (${f.kind}, ${f.severity}): ${f.title}`,
1105
- f.detail,
1106
- '',
1107
- `Action: ${f.suggestion}`,
1108
- ];
1109
- if ((f.files || []).length) lines.push(`Files: ${f.files.join(', ')}`);
1110
- lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
1245
+ : "In this project's Claude Code auto-memory, apply this fix:";
1246
+ }
1247
+ const lines = [header, '', `Finding (${f.kind}, ${f.severity}): ${f.title}`, f.detail, '', `Action: ${f.suggestion}`];
1248
+ const cited = paths.length ? paths : f.files || [];
1249
+ if (cited.length) lines.push(`Files: ${cited.join(', ')}`);
1250
+ // The index note only applies to auto-memory files; a CLAUDE.md or SKILL.md
1251
+ // fix has no MEMORY.md to keep in sync.
1252
+ const touchesMemory = paths.length ? sources.some((s) => s.scope === 'memory') : f.scope !== 'user';
1253
+ if (touchesMemory) lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
1111
1254
  return lines.join('\n');
1112
1255
  }
1113
1256
 
1257
+ // Badge shown next to the kind for non-project findings; project scope is the
1258
+ // default and carries no badge. Reuses the scope-tag palette from the picker.
1259
+ function scopeBadge(f) {
1260
+ if (!f.scope || f.scope === 'project') return '';
1261
+ const title =
1262
+ f.scope === 'user'
1263
+ ? 'User-scope finding — shared across projects'
1264
+ : 'Cross-scope finding — cites both user and project instructions';
1265
+ return `<span class="scope-tag t-${esc(f.scope)}" title="${esc(title)}">${esc(f.scope)}</span>`;
1266
+ }
1267
+
1114
1268
  function copyText(text, okMsg) {
1115
1269
  navigator.clipboard
1116
1270
  .writeText(text)
@@ -1124,25 +1278,55 @@ function _copyFindingPrompt(idx) {
1124
1278
  }
1125
1279
 
1126
1280
  function _copyAllFindings() {
1127
- const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
1281
+ const live = analysisFindings.filter((f) => !isDismissed(f));
1128
1282
  if (!live.length) return;
1129
1283
  copyText(live.map(findingPrompt).join('\n\n---\n\n'), `${live.length} prompts copied`);
1130
1284
  }
1131
1285
 
1132
- function _dismissFinding(btn, idx) {
1133
- const f = analysisFindings[idx];
1134
- if (!f) return;
1286
+ // The local set already changed, so the UI never waits; the response's authoritative
1287
+ // list is adopted once no other write is racing it.
1288
+ function persistDismissed(scope, body) {
1289
+ dismissWrites++;
1290
+ fetch('/api/memory/analysis/dismiss', {
1291
+ method: 'POST',
1292
+ headers: { 'Content-Type': 'application/json' },
1293
+ body: JSON.stringify({ ...body, scope }),
1294
+ })
1295
+ .then((r) => r.json())
1296
+ .then((d) => {
1297
+ if (dismissWrites === 1 && Array.isArray(d.dismissed)) dismissedByScope[scope] = new Set(d.dismissed);
1298
+ })
1299
+ .catch(() => {})
1300
+ .finally(() => dismissWrites--);
1301
+ }
1302
+
1303
+ // Shared by the health view and the file-preview cards — collapse the card,
1304
+ // persist the dismissal, then repaint whichever surface hosted it.
1305
+ function dismissFinding(btn, f, repaint) {
1135
1306
  const card = btn.closest('.analysis-card');
1136
1307
  card.style.maxHeight = `${card.offsetHeight}px`;
1137
1308
  requestAnimationFrame(() => card.classList.add('leaving'));
1138
1309
  setTimeout(() => {
1139
- analysisDismissed.add(findingKey(f));
1140
- rerenderAnalysis();
1310
+ // User-scope dismissals go to the shared user entry so they hold across projects.
1311
+ dismissedByScope[dismissScope(f)].add(findingKey(f));
1312
+ persistDismissed(dismissScope(f), { key: findingKey(f) });
1313
+ fixSelected.delete(findingKey(f)); // a dismissed finding can't stay queued in the fix plan
1314
+ syncTreeHealth();
1315
+ repaint();
1141
1316
  }, 280);
1142
1317
  }
1143
1318
 
1319
+ function _dismissFinding(btn, idx) {
1320
+ const f = analysisFindings[idx];
1321
+ if (f) dismissFinding(btn, f, rerenderAnalysis);
1322
+ }
1323
+
1144
1324
  function _restoreDismissed() {
1145
- analysisDismissed = new Set();
1325
+ for (const scope of Object.keys(dismissedByScope)) {
1326
+ if (dismissedByScope[scope].size) persistDismissed(scope, { all: true });
1327
+ dismissedByScope[scope] = new Set();
1328
+ }
1329
+ syncTreeHealth();
1146
1330
  rerenderAnalysis();
1147
1331
  }
1148
1332
 
@@ -1173,6 +1357,266 @@ function fileChipScope(source) {
1173
1357
  return source.scope === 'agent-memory' ? 'agent' : source.scope;
1174
1358
  }
1175
1359
 
1360
+ // Mirrors the server's analyzableSources: rules stay reference-only context for
1361
+ // the analyzer; the user CLAUDE.md is auditable (project-blind reviewer, results
1362
+ // shared across projects via the user-scope entry).
1363
+ function analyzableSource(s) {
1364
+ return (
1365
+ s.scope === 'memory' ||
1366
+ s.scope === 'agent-memory' ||
1367
+ ((s.scope === 'skill' || s.scope === 'user' || s.scope === 'project' || s.scope === 'local') && !s.parentId)
1368
+ );
1369
+ }
1370
+
1371
+ // Files covered by a run. Runs carry an `audited` snapshot taken at analysis
1372
+ // time — the authoritative list even after the stack changes. Older runs
1373
+ // predate the snapshot and fall back to re-deriving from the live stack.
1374
+ function runAuditedSources(run) {
1375
+ if (!run) return [];
1376
+ if (run.audited) return run.audited;
1377
+ // Soft-linked (@import) children never go into the analysis, so keep them out
1378
+ // of every health annotation even when their parent's id is in the run scope.
1379
+ const own = stackData.filter((s) => !s.parentId && analyzableSource(s));
1380
+ return run.ids ? own.filter((s) => run.ids.includes(s.id)) : own.filter((s) => s.scope === 'memory');
1381
+ }
1382
+
1383
+ // Findings name files by bare name, which collides across scopes (user vs
1384
+ // project CLAUDE.md) — the finding's `scope` field breaks the tie: user-scope
1385
+ // findings map only to user sources, project-scope ones exclude them ("cross"
1386
+ // keeps both sides). Attribute to audit-eligible sources first; fall back to
1387
+ // any source with that name only when none are eligible — the agent may report
1388
+ // out-of-scope files it read recursively, and those still deserve a badge.
1389
+ function findingFileIds(name, scope) {
1390
+ // The agent sometimes reports a relative path instead of the display name
1391
+ // (e.g. "dev-tools/bonya/.claude/skills/bonya/SKILL.md" for skill
1392
+ // "dev-tools/bonya:bonya") — match those by path suffix. Only slash-bearing
1393
+ // names qualify: a bare "SKILL.md" would suffix-match every skill.
1394
+ // The agent sometimes copies the prompt's scope marker into the name
1395
+ // ("CLAUDE.md (user)") — strip it, the scope field already carries it.
1396
+ const n = name.replace(/\s*\((user|project|local)\)$/, '').replace(/\\/g, '/');
1397
+ const byPath = (s) => {
1398
+ if (!n.includes('/')) return false;
1399
+ const p = (s.path || '').replace(/\\/g, '/');
1400
+ return p === n || p.endsWith(`/${n}`);
1401
+ };
1402
+ let matches = stackData.filter((s) => s.name === n || byPath(s));
1403
+ if (scope === 'user') matches = matches.filter((s) => s.scope === 'user');
1404
+ else if (scope === 'project') matches = matches.filter((s) => s.scope !== 'user');
1405
+ const eligible = matches.filter((s) => !s.parentId && analyzableSource(s));
1406
+ return (eligible.length ? eligible : matches).map((s) => s.id);
1407
+ }
1408
+
1409
+ // Project runs and shared user-scope runs interleave into one timeline —
1410
+ // run chips, the trend, and the merged view all address runs through this.
1411
+ function allAnalysisRuns(st) {
1412
+ const proj = (st?.runs || []).map((run) => ({ run, scope: 'project' }));
1413
+ const user = (st?.user?.runs || []).map((run) => ({ run, scope: 'user' }));
1414
+ return [...proj, ...user].sort((a, b) => b.run.ts - a.run.ts);
1415
+ }
1416
+
1417
+ // The default health view merges runs: each file carries the verdict of the
1418
+ // newest run that audited it, so a narrow re-run (say, one skill) overlays the
1419
+ // wider picture instead of replacing it. Run chips still show single runs.
1420
+ function mergedRunView(st) {
1421
+ const runs = allAnalysisRuns(st).map((e) => e.run);
1422
+ if (runs.length <= 1) return runs[0] || null;
1423
+ const owner = new Map(); // file id -> newest run that audited it
1424
+ const audited = [];
1425
+ for (const run of runs) {
1426
+ for (const s of runAuditedSources(run)) {
1427
+ if (owner.has(s.id)) continue;
1428
+ owner.set(s.id, run);
1429
+ audited.push(s);
1430
+ }
1431
+ }
1432
+ const findings = [];
1433
+ const seen = new Set();
1434
+ for (const run of runs) {
1435
+ for (const f of run.result?.findings || []) {
1436
+ if (seen.has(findingKey(f))) continue;
1437
+ const ids = (f.files || []).flatMap((n) => findingFileIds(n, f.scope));
1438
+ // A finding survives while its run still owns a file it touches — a newer
1439
+ // run that re-audited the file supersedes the old verdict. File-less
1440
+ // findings (suggested new memories) belong to the latest run only.
1441
+ const owns = ids.length ? ids.some((id) => owner.get(id) === run) : run === runs[0];
1442
+ if (!owns) continue;
1443
+ seen.add(findingKey(f));
1444
+ findings.push(f);
1445
+ }
1446
+ }
1447
+ const latest = runs[0];
1448
+ return {
1449
+ ...latest,
1450
+ audited,
1451
+ // The latest run's summary describes only its own scope — misleading when
1452
+ // merged, so it is dropped here.
1453
+ result: { ...latest.result, findings, summary: null, scopeDesc: `${runs.length} runs merged` },
1454
+ };
1455
+ }
1456
+
1457
+ const SEV_WEIGHT = { high: 10, med: 3, low: 1 };
1458
+
1459
+ // Start at 100, subtract per finding by severity. Blunt but explainable.
1460
+ function healthScore(findings) {
1461
+ const penalty = findings.reduce((s, f) => s + (SEV_WEIGHT[f.severity] || 1), 0);
1462
+ return Math.max(2, 100 - penalty);
1463
+ }
1464
+
1465
+ function sevCounts(findings) {
1466
+ const c = { high: 0, med: 0, low: 0 };
1467
+ for (const f of findings) c[f.severity] = (c[f.severity] || 0) + 1;
1468
+ return c;
1469
+ }
1470
+
1471
+ function renderHealthDash(st, shown) {
1472
+ // Score, tiles, and clean count track the live (non-dismissed) list they sit above.
1473
+ const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
1474
+ const c = sevCounts(findings);
1475
+ const audited = runAuditedSources(shown);
1476
+ const dirty = new Set(findings.flatMap((f) => (f.files || []).flatMap((n) => findingFileIds(n, f.scope))));
1477
+ const clean = audited.filter((s) => !dirty.has(s.id)).length;
1478
+ const score = healthScore(findings);
1479
+ const scoreColor = score >= 80 ? 'var(--success)' : score >= 50 ? 'var(--warning)' : 'var(--error)';
1480
+ const CIRC = 163.4; // 2πr for r=26
1481
+ let html = '<div class="hv-tiles">';
1482
+ html += '<div class="hv-tile hv-ring-tile">';
1483
+ html += `<div class="hv-ring"><svg width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="26" fill="none" stroke="var(--bg-hover)" stroke-width="6"/><circle cx="32" cy="32" r="26" fill="none" stroke="${esc(scoreColor)}" stroke-width="6" stroke-linecap="round" transform="rotate(-90 32 32)" stroke-dasharray="${CIRC}" stroke-dashoffset="${esc((CIRC * (100 - score)) / 100)}"/></svg><span class="hv-ring-val" style="color:${scoreColor}">${score}</span></div>`;
1484
+ html += `<div class="hv-tile-txt" title="Score = 100 − 10/high − 3/med − 1/low"><span class="hv-lbl">Health</span><span class="hv-sub">of 100</span></div></div>`;
1485
+ for (const [sev, label] of [
1486
+ ['high', 'High'],
1487
+ ['med', 'Medium'],
1488
+ ['low', 'Low'],
1489
+ ]) {
1490
+ html += `<div class="hv-tile"><span class="hv-lbl"><span class="dot sev-dot-${esc(sev)}"></span>${label}</span><span class="hv-num hv-num-${esc(sev)}">${c[sev]}</span></div>`;
1491
+ }
1492
+ html += `<div class="hv-tile"><span class="hv-lbl">Clean</span><span class="hv-num hv-num-clean">${clean}<i>/${audited.length}</i></span><span class="hv-sub">audited files</span></div>`;
1493
+ html += renderTrend(st);
1494
+ html += '</div>';
1495
+ return html;
1496
+ }
1497
+
1498
+ // Findings per run as stacked mini-columns, oldest to newest; click switches the shown run.
1499
+ function renderTrend(st) {
1500
+ const runs = allAnalysisRuns(st);
1501
+ if (runs.length < 2) return '';
1502
+ let html = '<div class="hv-tile hv-trend-tile"><span class="hv-lbl">Trend</span><div class="hv-trend">';
1503
+ for (let i = runs.length - 1; i >= 0; i--) {
1504
+ const c = sevCounts(runs[i].run.result?.findings || []);
1505
+ const px = (n) => (n ? Math.max(3, n * 4) : 0);
1506
+ html += `<div class="hv-trun${i === analysisRunIdx ? ' cur' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(analysisTimeAgo(runs[i].run.ts))}${runs[i].scope === 'user' ? ' · user' : ''} — ${c.high} high · ${c.med} med · ${c.low} low">`;
1507
+ if (c.low) html += `<i class="tl" style="height:${px(c.low)}px"></i>`;
1508
+ if (c.med) html += `<i class="tm" style="height:${px(c.med)}px"></i>`;
1509
+ if (c.high) html += `<i class="th" style="height:${px(c.high)}px"></i>`;
1510
+ if (!c.high && !c.med && !c.low) html += '<i class="tz"></i>';
1511
+ html += '</div>';
1512
+ }
1513
+ html += '</div></div>';
1514
+ return html;
1515
+ }
1516
+
1517
+ // Every audited file sized by line count, colored by its worst finding.
1518
+ function renderMemoryMap(shown) {
1519
+ const audited = runAuditedSources(shown);
1520
+ if (!audited.length) return '';
1521
+ const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
1522
+ const perFile = new Map();
1523
+ for (const f of findings) {
1524
+ for (const name of f.files || []) {
1525
+ for (const id of findingFileIds(name, f.scope)) {
1526
+ const cur = perFile.get(id) || { high: 0, med: 0, low: 0 };
1527
+ cur[f.severity]++;
1528
+ perFile.set(id, cur);
1529
+ }
1530
+ }
1531
+ }
1532
+ const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
1533
+ const cellSev = (s) => {
1534
+ const c = perFile.get(s.id);
1535
+ return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
1536
+ };
1537
+ let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
1538
+ for (const s of [...audited].sort(
1539
+ (a, b) => sevRank[cellSev(a)] - sevRank[cellSev(b)] || a.name.localeCompare(b.name),
1540
+ )) {
1541
+ const c = perFile.get(s.id);
1542
+ const sev = cellSev(s);
1543
+ const n = c ? c.high + c.med + c.low : 0;
1544
+ const title = `${s.name} — ${s.lines}L${n ? ` · ${n} finding${n > 1 ? 's' : ''} (worst: ${sev})` : ' · clean'}`;
1545
+ html += `<div class="hv-cell hv-${esc(sev)}${analysisFileFilter?.id === s.id ? ' active' : ''}" onclick="_setFileFilter('${escAttrJs(s.id)}','${escAttrJs(s.name)}')" title="${esc(title)} — click to filter findings">`;
1546
+ if (n) html += `<span class="hv-cell-n hv-cn-${esc(sev)}">${n}</span>`;
1547
+ html += `<span class="hv-cell-name">${esc(s.name)}</span><span class="hv-cell-meta">${s.lines}L</span></div>`;
1548
+ }
1549
+ html += '</div>';
1550
+ return html;
1551
+ }
1552
+
1553
+ // Findings for one file from the merged current state, shown inline in the file preview.
1554
+ function renderFileFindings(source) {
1555
+ const run = mergedRunView(lastAnalysisSt);
1556
+ if (!run) return '';
1557
+ const hits = (run.result?.findings || [])
1558
+ .map((f, i) => [f, i])
1559
+ .filter(([f]) => (f.files || []).some((n) => findingFileIds(n, f.scope).includes(source.id)) && !isDismissed(f));
1560
+ if (!hits.length) return '';
1561
+ let html = `<div class="preview-findings"><div class="pf-label">Claude analysis — ${hits.length} finding${hits.length > 1 ? 's' : ''} for this file</div>`;
1562
+ for (const [f, i] of hits) {
1563
+ html += `<div class="analysis-card pf-card ${esc(`sev-${f.severity}`)}">`;
1564
+ html += `<div class="analysis-card-head" onclick="this.parentElement.classList.toggle('expanded')">`;
1565
+ html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
1566
+ html += scopeBadge(f);
1567
+ html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
1568
+ html += '<span class="card-actions">';
1569
+ html += `<button class="card-btn" onclick="event.stopPropagation();_copyLatestFindingPrompt(${i})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
1570
+ html += `<button class="card-btn" onclick="event.stopPropagation();_dismissLatestFinding(this, ${i})" title="Drop this finding from the list">${DISMISS_ICON}Dismiss</button>`;
1571
+ html += '</span></div>';
1572
+ html += `<div class="analysis-card-detail">${esc(f.detail)}</div>`;
1573
+ html += `<div class="analysis-card-suggestion">${esc(f.suggestion)}</div>`;
1574
+ html += '</div>';
1575
+ }
1576
+ html += '</div>';
1577
+ return html;
1578
+ }
1579
+
1580
+ // Indexes into the merged current state — the same list renderFileFindings
1581
+ // draws from, unlike the health view, which can show a historical run.
1582
+ function _copyLatestFindingPrompt(i) {
1583
+ const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
1584
+ if (f) copyText(findingPrompt(f), 'Prompt copied — paste into any Claude Code session');
1585
+ }
1586
+
1587
+ // Dismissals share the health view's set, so a finding dropped here
1588
+ // disappears there too (and vice versa).
1589
+ function _dismissLatestFinding(btn, i) {
1590
+ const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
1591
+ if (f) dismissFinding(btn, f, renderPreview);
1592
+ }
1593
+
1594
+ function _toggleFixSelect(idx) {
1595
+ const f = analysisFindings[idx];
1596
+ if (!f) return;
1597
+ const key = findingKey(f);
1598
+ if (fixSelected.has(key)) fixSelected.delete(key);
1599
+ else fixSelected.add(key);
1600
+ rerenderAnalysis();
1601
+ }
1602
+
1603
+ function _copyFixPlan() {
1604
+ const order = { high: 0, med: 1, low: 2 };
1605
+ const picked = analysisFindings
1606
+ .filter((f) => fixSelected.has(findingKey(f)))
1607
+ .sort((a, b) => order[a.severity] - order[b.severity]);
1608
+ if (!picked.length) return;
1609
+ const plan = [
1610
+ `Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
1611
+ '',
1612
+ ...picked.map((f, i) => `## ${i + 1}. ${findingPrompt(f)}`),
1613
+ ].join('\n\n');
1614
+ copyText(
1615
+ plan,
1616
+ `Fix plan copied — ${picked.length} finding${picked.length > 1 ? 's' : ''}, paste into any Claude Code session`,
1617
+ );
1618
+ }
1619
+
1176
1620
  function renderAnalysisHead(st, r, ts) {
1177
1621
  const meta = [];
1178
1622
  if (ts) meta.push(analysisTimeAgo(ts));
@@ -1180,7 +1624,8 @@ function renderAnalysisHead(st, r, ts) {
1180
1624
  if (r?.durationMs != null) meta.push(`${Math.round(r.durationMs / 1000)}s`);
1181
1625
  if (r?.model) meta.push(r.model);
1182
1626
  if (r?.scopeDesc) meta.push(`scope: ${r.scopeDesc}`);
1183
- if (st.stale && analysisRunIdx === 0) meta.push('memory changed since');
1627
+ if (st.stale && analysisRunIdx <= 0) meta.push('memory changed since');
1628
+ if (st.user?.stale && analysisRunIdx < 0) meta.push('user CLAUDE.md changed since');
1184
1629
  let html = '<div class="analysis-head">';
1185
1630
  html += '<span class="analysis-title">Claude analysis</span>';
1186
1631
  html += `<span class="analysis-meta">${esc(meta.join(' · '))}</span>`;
@@ -1194,49 +1639,75 @@ function renderAnalysisHead(st, r, ts) {
1194
1639
  }
1195
1640
 
1196
1641
  function _setAnalysisRun(i) {
1197
- resetAnalysisView(i);
1642
+ resetAnalysisView(i); // clears the file filter, so the tree badges must be restored too
1643
+ syncTreeHealth();
1198
1644
  rerenderAnalysis();
1199
1645
  }
1200
1646
 
1201
- async function _deleteAnalysisRun(ev, ts) {
1647
+ async function _deleteAnalysisRun(ev, ts, scope) {
1202
1648
  ev.stopPropagation();
1203
1649
  await fetch('/api/memory/analysis/delete-run', {
1204
1650
  method: 'POST',
1205
1651
  headers: { 'Content-Type': 'application/json' },
1206
- body: JSON.stringify({ ts }),
1652
+ body: JSON.stringify({ ts, scope }),
1207
1653
  });
1208
1654
  resetAnalysisView();
1209
1655
  analysisTs = null; // force refreshAnalysis to re-adopt the new latest run
1210
1656
  refreshAnalysis();
1211
1657
  }
1212
1658
 
1213
- // Past runs for this project (newest first).
1659
+ // Past runs, newest first — project runs plus the shared user-scope runs.
1214
1660
  function renderRunsStrip(st) {
1215
- const runs = st.runs || [];
1661
+ const runs = allAnalysisRuns(st);
1216
1662
  if (!runs.length) return '';
1217
1663
  let html = '<div class="runs-strip"><span class="runs-label">runs</span>';
1218
- runs.forEach((run, i) => {
1664
+ if (runs.length > 1) {
1665
+ html += `<span class="run-chip${analysisRunIdx < 0 ? ' active' : ''}" onclick="_setAnalysisRun(-1)" title="Each file shows the newest run that audited it">current · ${runs.length} runs merged</span>`;
1666
+ }
1667
+ runs.forEach(({ run, scope }, i) => {
1219
1668
  const r = run.result || {};
1220
1669
  const bits = [analysisTimeAgo(run.ts)];
1670
+ if (scope === 'user') bits.push('user');
1221
1671
  if (r.model) bits.push(r.model);
1222
1672
  if (r.costUsd != null) bits.push(`$${r.costUsd.toFixed(2)}`);
1223
- html += `<span class="run-chip${i === analysisRunIdx ? ' active' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(r.scopeDesc || '')}">${esc(bits.join(' · '))}<button class="run-chip-x" onclick="_deleteAnalysisRun(event, ${escAttrJs(run.ts)})" title="Delete this run">×</button></span>`;
1673
+ html += `<span class="run-chip${scope === 'user' ? ' run-chip-user' : ''}${i === analysisRunIdx ? ' active' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(r.scopeDesc || '')}">${esc(bits.join(' · '))}<button class="run-chip-x" onclick="_deleteAnalysisRun(event, ${escAttrJs(run.ts)}, '${escAttrJs(scope)}')" title="Delete this run">×</button></span>`;
1224
1674
  });
1225
1675
  html += '</div>';
1226
1676
  return html;
1227
1677
  }
1228
1678
 
1229
- function renderAnalysis(st) {
1230
- if (st.running) {
1231
- return '<div class="analysis-box"><div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…</div></div>';
1679
+ // The previous review stays on screen while runs are in flight — running state is a
1680
+ // banner above it, not a replacement view.
1681
+ function renderRunningBanner(st) {
1682
+ const pending = st.pending || [];
1683
+ if (!pending.length) return '';
1684
+ let html = '';
1685
+ for (const p of pending) {
1686
+ const bits = [];
1687
+ if (p.model) bits.push(p.model);
1688
+ if (p.scopeDesc) bits.push(p.scopeDesc);
1689
+ if (p.startedAt) bits.push(`started ${analysisTimeAgo(p.startedAt)}`);
1690
+ const meta = bits.length ? ` <span class="analysis-meta">${esc(bits.join(' · '))}</span>` : '';
1691
+ if (p.stalled) {
1692
+ html += `<div class="analysis-status analysis-stalled">Run appears stalled (server restarted mid-run?) — its result will surface if it ever completes.${meta}</div>`;
1693
+ } else {
1694
+ html += `<div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…${meta}</div>`;
1695
+ }
1232
1696
  }
1233
- const runs = st.runs || [];
1234
- const shown = runs[analysisRunIdx];
1697
+ return html;
1698
+ }
1699
+
1700
+ function renderAnalysis(st) {
1701
+ const shown = analysisRunIdx < 0 ? mergedRunView(st) : allAnalysisRuns(st)[analysisRunIdx]?.run;
1702
+ // The wrapper stays in the DOM even when empty so quiet polls can update just
1703
+ // the banner in place instead of rebuilding the whole review (see refreshAnalysis).
1235
1704
  let html = '<div class="analysis-box">';
1705
+ html += `<div id="runningBanner">${renderRunningBanner(st)}</div>`;
1236
1706
  html += renderAnalysisHead(st, shown?.result, shown?.ts);
1707
+ // A failed run no longer wipes history — show the error above the surviving review.
1237
1708
  if (st.error) {
1238
- html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div></div>`;
1239
- return html;
1709
+ html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div>`;
1710
+ if (!shown) return `${html}</div>`;
1240
1711
  }
1241
1712
  if (!shown) {
1242
1713
  html += '<div class="analysis-empty">No analysis yet — pick a scope and run one.</div></div>';
@@ -1245,9 +1716,11 @@ function renderAnalysis(st) {
1245
1716
  const r = shown.result;
1246
1717
  analysisFindings = r.findings || [];
1247
1718
  const byName = Object.fromEntries(stackData.map((s) => [s.name, s]));
1719
+ html += renderHealthDash(st, shown);
1248
1720
  if (r.summary) html += `<div class="analysis-summary">${esc(r.summary)}</div>`;
1721
+ html += renderMemoryMap(shown);
1249
1722
 
1250
- const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
1723
+ const live = analysisFindings.filter((f) => !isDismissed(f));
1251
1724
  const count = (sev) => live.filter((f) => f.severity === sev).length;
1252
1725
  if (analysisFindings.length) {
1253
1726
  html += '<div class="sev-strip">';
@@ -1257,20 +1730,31 @@ function renderAnalysis(st) {
1257
1730
  if (sev !== 'all') html += '<span class="dot"></span>';
1258
1731
  html += `${sev} ${n}</button>`;
1259
1732
  }
1260
- if (analysisDismissed.size) {
1261
- html += `<span class="dismissed-note">${analysisDismissed.size} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
1733
+ if (analysisFileFilter) {
1734
+ html += `<button class="sev-pill active" onclick="_setFileFilter('${escAttrJs(analysisFileFilter.id)}','${escAttrJs(analysisFileFilter.name)}')" title="Clear the file filter">${esc(analysisFileFilter.name)} ×</button>`;
1735
+ }
1736
+ const nDismissed = dismissedCount();
1737
+ if (nDismissed) {
1738
+ html += `<span class="dismissed-note">${nDismissed} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
1739
+ }
1740
+ if (fixSelected.size) {
1741
+ html += `<button class="card-btn fix-plan-btn" onclick="_copyFixPlan()" title="Copy the selected findings as one ordered fix prompt">${COPY_ICON}Copy fix plan (${fixSelected.size})</button>`;
1262
1742
  }
1263
1743
  html += '</div>';
1264
1744
  }
1265
1745
 
1266
- const visible = live.filter((f) => analysisFilter === 'all' || f.severity === analysisFilter);
1746
+ const visible = live.filter(
1747
+ (f) => (analysisFilter === 'all' || f.severity === analysisFilter) && findingMatchesFileFilter(f),
1748
+ );
1267
1749
  if (!analysisFindings.length) html += '<div class="analysis-empty">No issues found.</div>';
1268
1750
  else if (!visible.length) html += '<div class="analysis-empty">Nothing here — filtered out or dismissed.</div>';
1269
1751
  for (const f of visible) {
1270
1752
  const idx = analysisFindings.indexOf(f);
1271
1753
  html += `<div class="analysis-card ${esc(`sev-${f.severity}`)}">`;
1272
1754
  html += '<div class="analysis-card-head">';
1755
+ html += `<input type="checkbox" class="fix-check" ${fixSelected.has(findingKey(f)) ? 'checked ' : ''}onclick="_toggleFixSelect(${idx})" title="Queue for the fix plan">`;
1273
1756
  html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
1757
+ html += scopeBadge(f);
1274
1758
  html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
1275
1759
  html += '<span class="card-actions">';
1276
1760
  html += `<button class="card-btn" onclick="_copyFindingPrompt(${idx})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
@@ -1280,10 +1764,13 @@ function renderAnalysis(st) {
1280
1764
  html += '<div class="analysis-card-files">';
1281
1765
  if (!files.length) html += '<span class="no-files">no file — new memory suggested</span>';
1282
1766
  for (const fn of files) {
1283
- const child = byName[fn];
1767
+ // Same name-or-path resolution as the map and tree badges — a chip must
1768
+ // stay clickable when the agent reported a path instead of the name.
1769
+ const ids = findingFileIds(fn, f.scope);
1770
+ const child = stackData.find((s) => ids.includes(s.id)) || byName[fn];
1284
1771
  const scopeTag = fileChipScope(child);
1285
1772
  if (child) {
1286
- html += `<a class="file-chip" href="#" onclick="closeModal('analysisModal');selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${FILE_ICON}${esc(fn)}<span class="fscope fscope-${esc(scopeTag)}">${esc(scopeTag)}</span></a>`;
1773
+ html += `<a class="file-chip" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${FILE_ICON}${esc(fn)}<span class="fscope fscope-${esc(scopeTag)}">${esc(scopeTag)}</span></a>`;
1287
1774
  } else {
1288
1775
  html += `<span class="file-chip">${FILE_ICON}${esc(fn)}</span>`;
1289
1776
  }
@@ -1301,8 +1788,42 @@ function renderAnalysis(st) {
1301
1788
 
1302
1789
  // #region RENDER_BUDGET
1303
1790
 
1791
+ let footprintHighlight = false;
1792
+ let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
1793
+ let footprintIds = new Set(); // source ids the server counted into the footprint
1794
+
1795
+ // Clicking the top bar highlights all footprint files; clicking a segment narrows
1796
+ // the highlight to that segment's scope (the skill segment = skill descriptions).
1797
+ function _toggleFootprint(scope = null, ev) {
1798
+ if (ev) ev.stopPropagation();
1799
+ if (footprintHighlight && footprintScope === scope) {
1800
+ footprintHighlight = false;
1801
+ footprintScope = null;
1802
+ } else {
1803
+ footprintHighlight = true;
1804
+ footprintScope = scope;
1805
+ }
1806
+ document.body.classList.toggle('fp-mode', footprintHighlight);
1807
+ applySegmentState();
1808
+ renderTree();
1809
+ }
1810
+
1811
+ function footprintHit(item) {
1812
+ if (footprintScope === 'skill') return item.scope === 'skill' && !item.parentId;
1813
+ if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
1814
+ return footprintIds.has(item.id);
1815
+ }
1816
+
1817
+ function applySegmentState() {
1818
+ const scoped = footprintHighlight && footprintScope !== null;
1819
+ for (const el of document.querySelectorAll('.budget-segment')) {
1820
+ el.classList.toggle('seg-dim', scoped && el.dataset.scope !== footprintScope);
1821
+ }
1822
+ }
1823
+
1304
1824
  function renderBudget() {
1305
1825
  if (!summaryData) return;
1826
+ footprintIds = new Set(summaryData.ids || []);
1306
1827
 
1307
1828
  // Summary stat cards
1308
1829
  document.getElementById('statFiles').textContent = summaryData.totalFiles;
@@ -1325,13 +1846,14 @@ function renderBudget() {
1325
1846
  const chars = scopeTotals[scope];
1326
1847
  if (!chars) continue;
1327
1848
  const pct = (chars / totalChars) * 100;
1328
- html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
1849
+ html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
1329
1850
  }
1330
1851
  if (skillDescChars) {
1331
1852
  const pct = (skillDescChars / totalChars) * 100;
1332
- html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
1853
+ html += `<div class="budget-segment" data-scope="skill" onclick="_toggleFootprint('skill', event)" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
1333
1854
  }
1334
1855
  segContainer.innerHTML = html;
1856
+ applySegmentState();
1335
1857
  }
1336
1858
 
1337
1859
  // #endregion RENDER_BUDGET
@@ -1342,18 +1864,17 @@ async function loadData() {
1342
1864
  try {
1343
1865
  [stackData, summaryData] = await Promise.all([fetchJSON('/api/stack'), fetchJSON('/api/summary')]);
1344
1866
  invalidateTreeIndex();
1867
+ renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
1345
1868
  renderTree();
1346
- renderBudget();
1347
- if (!selectedFileId) {
1869
+ refreshAnalysis(); // health dots on the tree come from the latest run
1870
+ if (!selectedFileId && !healthViewOpen) {
1348
1871
  const proj = stackData.find((s) => s.scope === 'project' && s.name === 'CLAUDE.md');
1349
1872
  const user = stackData.find((s) => s.scope === 'user' && s.name === 'CLAUDE.md');
1350
1873
  const auto = proj || user;
1351
1874
  if (auto) selectedFileId = auto.id;
1352
1875
  }
1353
- if (selectedFileId) {
1354
- renderTree();
1355
- renderPreview();
1356
- }
1876
+ if (selectedFileId) renderTree();
1877
+ renderPreview();
1357
1878
  } catch (err) {
1358
1879
  showToast(`Failed to load: ${err.message}`, 'error');
1359
1880
  }
@@ -1396,6 +1917,11 @@ document.addEventListener('keydown', (e) => {
1396
1917
  }
1397
1918
  return;
1398
1919
  }
1920
+ if (e.key === 'Escape' && healthViewOpen) {
1921
+ e.preventDefault();
1922
+ openHealthView();
1923
+ return;
1924
+ }
1399
1925
  if (e.key === 't') toggleTheme();
1400
1926
  if (e.key === 'r') refreshData();
1401
1927
  if (e.key === '?') {