claude-code-memory-explorer 1.2.0 → 1.3.1

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
@@ -5,6 +5,10 @@ let stackData = [];
5
5
  let summaryData = null;
6
6
  let selectedFileId = null;
7
7
  let healthViewOpen = false; // opt-in view; the Analyze button toggles it, selecting a file exits it
8
+ let homeData = null;
9
+ let homeOpen = false; // the user scope opens on the home cards; a card or the health view drills into the tree
10
+ let homeFocus = null; // scopes a home card drilled into; the tree shows only these until cleared
11
+ let previewPath = null; // a file outside the stack (settings, output style) shown read-only in the preview
8
12
 
9
13
  // #endregion STATE
10
14
 
@@ -202,10 +206,23 @@ async function fetchJSON(url) {
202
206
 
203
207
  // #region PROJECT
204
208
 
205
- async function loadProject() {
206
- projectData = await fetchJSON('/api/project');
209
+ // The user scope rides in the recents list as this entry, so the boot restore brings it back.
210
+ const USER_ROW = '::user::';
211
+
212
+ function projectKey(info) {
213
+ return info?.user ? USER_ROW : info?.path;
214
+ }
215
+
216
+ async function loadProject(info) {
217
+ const wasUser = !!projectData?.user;
218
+ projectData = info || (await fetchJSON('/api/project'));
207
219
  document.getElementById('projectName').textContent = projectData.name;
208
- document.getElementById('projectBtn').title = projectData.path;
220
+ document.getElementById('projectBtn').title = projectData.user
221
+ ? `User scope · ${projectData.configDir}`
222
+ : projectData.path;
223
+ document.body.classList.toggle('user-scope', !!projectData.user);
224
+ if (projectData.user !== wasUser) homeOpen = !!projectData.user;
225
+ homeFocus = null;
209
226
  }
210
227
 
211
228
  // Shared by the project picker, the boot restore, and the hub project shim. Throws with the
@@ -216,12 +233,13 @@ async function putProject(dirPath) {
216
233
  const res = await fetch('/api/project', {
217
234
  method: 'PUT',
218
235
  headers: { 'Content-Type': 'application/json' },
219
- body: JSON.stringify({ path: dirPath }),
236
+ body: JSON.stringify(dirPath === USER_ROW ? { user: true } : { path: dirPath }),
220
237
  });
221
238
  if (!res.ok) {
222
239
  const err = await res.json().catch(() => ({}));
223
240
  throw new Error(err.error || `${res.status}`);
224
241
  }
242
+ return res.json();
225
243
  }
226
244
 
227
245
  function changeProject() {
@@ -238,11 +256,15 @@ async function submitProjectPicker() {
238
256
  btn.disabled = true;
239
257
  btn.textContent = 'Switching...';
240
258
  try {
241
- await putProject(dirPath);
259
+ const info = await putProject(dirPath);
260
+ // A later hub push of the project this app left must still apply, not be deduped.
261
+ lastAppliedProject = dirPath;
242
262
  closeModal('projectPickerModal');
243
263
  addRecentProject(dirPath);
244
- await Promise.all([loadProject(), loadData()]);
245
- showToast('Project switched', 'success');
264
+ // loadData reads projectData to decide whether the home cards need /api/home.
265
+ await loadProject(info);
266
+ await loadData();
267
+ showToast(dirPath === USER_ROW ? 'Switched to user scope' : 'Project switched', 'success');
246
268
  } catch (err) {
247
269
  showToast(err.message, 'error');
248
270
  } finally {
@@ -291,6 +313,17 @@ function getRecentProjects() {
291
313
  }
292
314
  }
293
315
 
316
+ // A fresh app (no recents) starts on the user home, so the hub's replayed project does not apply either.
317
+ function userScopeIsHead() {
318
+ const head = getRecentProjects()[0];
319
+ return !head || head === USER_ROW;
320
+ }
321
+
322
+ async function isUserScopeRemembered() {
323
+ await projectInfoPromise;
324
+ return userScopeIsHead();
325
+ }
326
+
294
327
  function addRecentProject(p) {
295
328
  const recent = getRecentProjects().filter((r) => r !== p);
296
329
  recent.unshift(p);
@@ -305,7 +338,7 @@ function setPickerMode(add) {
305
338
  document.getElementById('projectPickerFilter').hidden = add;
306
339
  document.getElementById('projectPickerAddForm').hidden = !add;
307
340
  const input = document.getElementById(add ? 'projectPathInput' : 'projectPickerInput');
308
- if (add) input.value = document.getElementById('projectBtn').title;
341
+ if (add) input.value = projectData?.path || '';
309
342
  // The overlay fades in, so a focus() on a field that is still hidden is dropped.
310
343
  setTimeout(() => input.focus(), 100);
311
344
  }
@@ -319,19 +352,29 @@ function splitProjectPath(p) {
319
352
  function renderProjectPicker() {
320
353
  const list = document.getElementById('projectPickerList');
321
354
  const q = document.getElementById('projectPickerInput').value.trim().toLowerCase();
322
- const current = document.getElementById('projectBtn').title;
323
- const recent = getRecentProjects();
324
- pickerRows = q ? recent.filter((p) => p.toLowerCase().includes(q)) : recent;
355
+ const current = projectKey(projectData);
356
+ const recent = getRecentProjects().filter((p) => p !== USER_ROW);
357
+ const userMatches = !q || 'user (no project)'.includes(q);
358
+ pickerRows = [
359
+ ...(userMatches ? [USER_ROW] : []),
360
+ ...(q ? recent.filter((p) => p.toLowerCase().includes(q)) : recent),
361
+ ];
325
362
  if (!pickerRows.length) {
326
363
  pickerIdx = -1;
327
- const msg = recent.length ? 'No projects match' : 'No recent projects — add one below';
328
- list.innerHTML = `<div class="picker-empty">${msg}</div>`;
364
+ list.innerHTML = '<div class="picker-empty">No projects match</div>';
329
365
  return;
330
366
  }
331
367
  list.innerHTML = pickerRows
332
368
  .map((p, i) => {
333
- const { name, parent } = splitProjectPath(p);
334
369
  const cur = p === current;
370
+ if (p === USER_ROW) {
371
+ return `<div class="picker-row picker-user${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="Sources every session loads, whatever the project">
372
+ <span class="picker-name">User (no project)</span>
373
+ <span class="picker-parent">${esc(claudeConfigDir)}</span>
374
+ ${cur ? '<span class="picker-tag">current</span>' : ''}
375
+ </div>`;
376
+ }
377
+ const { name, parent } = splitProjectPath(p);
335
378
  return `<div class="picker-row${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="${esc(p)}">
336
379
  <span class="picker-name">${esc(name)}</span>
337
380
  <span class="picker-parent">${esc(parent)}</span>
@@ -363,7 +406,7 @@ function _pickProject(idx) {
363
406
  function _removeRecentProject(idx, e) {
364
407
  e.stopPropagation();
365
408
  const target = pickerRows[idx];
366
- if (!target) return;
409
+ if (!target || target === USER_ROW) return;
367
410
  localStorage.setItem(recentsKey(), JSON.stringify(getRecentProjects().filter((p) => p !== target)));
368
411
  renderProjectPicker();
369
412
  }
@@ -533,7 +576,8 @@ function toggleHelpModal() {
533
576
  }
534
577
 
535
578
  function openHealthView() {
536
- healthViewOpen = !healthViewOpen;
579
+ healthViewOpen = homeOpen || !healthViewOpen;
580
+ leaveHome();
537
581
  document.getElementById('analyzeBtn')?.classList.toggle('active', healthViewOpen);
538
582
  renderPreview();
539
583
  if (healthViewOpen) refreshAnalysis();
@@ -543,13 +587,14 @@ function openHealthView() {
543
587
 
544
588
  // #region RENDER_TREE
545
589
 
546
- const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
590
+ const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent', 'agent-memory'];
547
591
  const SCOPE_LABELS = {
548
592
  policy: 'Managed Policy',
549
593
  user: 'User',
550
594
  project: 'Project',
551
595
  rule: 'Rules',
552
596
  skill: 'Skills',
597
+ agent: 'Agents',
553
598
  memory: 'Auto Memory',
554
599
  'agent-memory': 'Agent Memory',
555
600
  };
@@ -646,9 +691,10 @@ function renderTree() {
646
691
  }
647
692
 
648
693
  let html = '';
694
+ if (projectData?.user) html += treeHomeLink();
649
695
  for (const scope of SCOPE_ORDER) {
650
696
  const items = groups[scope];
651
- if (!items) continue;
697
+ if (!items || !inFocus(scope)) continue;
652
698
  const label = SCOPE_LABELS[scope] || scope;
653
699
  const collapsible = scope === 'agent-memory';
654
700
  const collapsed = collapsible && isGroupCollapsed(scope);
@@ -737,13 +783,16 @@ function pushFileState(id) {
737
783
  }
738
784
 
739
785
  function selectFile(id, pushState = true) {
786
+ leaveHome();
740
787
  if (healthViewOpen) {
741
788
  healthViewOpen = false;
742
789
  document.getElementById('analyzeBtn')?.classList.remove('active');
743
790
  }
744
791
  selectedFileId = selectedFileId === id ? null : id;
792
+ previewPath = null;
745
793
  if (selectedFileId) {
746
794
  const item = stackData.find((s) => s.id === selectedFileId);
795
+ if (item && !inFocus(getItemScope(item))) homeFocus = null;
747
796
  if (item) {
748
797
  expandGroupForItem(item);
749
798
  expandItemAncestors(item);
@@ -783,7 +832,7 @@ function getVisibleOrder() {
783
832
  }
784
833
  };
785
834
  for (const scope of SCOPE_ORDER) {
786
- if (isGroupCollapsed(scope)) continue;
835
+ if (isGroupCollapsed(scope) || !inFocus(scope)) continue;
787
836
  const items = groups[scope];
788
837
  if (items) for (const item of items) walk(item);
789
838
  }
@@ -882,7 +931,7 @@ async function renderPreview() {
882
931
  if (lastAnalysisSt) rerenderAnalysis();
883
932
  return;
884
933
  }
885
- const source = stackData.find((s) => s.id === selectedFileId);
934
+ const source = stackData.find((s) => s.id === selectedFileId) || previewPath;
886
935
  if (!source) {
887
936
  panel.innerHTML =
888
937
  '<div class="preview-empty"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/><path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/></svg><span>Select a file to preview</span></div>';
@@ -893,9 +942,11 @@ async function renderPreview() {
893
942
  try {
894
943
  fileData = await fetchJSON(`/api/file?path=${encodeURIComponent(source.path)}`);
895
944
  } catch {
896
- panel.innerHTML = '<div class="preview-empty"><span>Failed to load file</span></div>';
945
+ const msg = source.outside ? `${source.path} does not exist` : 'Failed to load file';
946
+ panel.innerHTML = `<div class="preview-empty"><span>${esc(msg)}</span></div>`;
897
947
  return;
898
948
  }
949
+ if (source.outside) Object.assign(source, { lines: fileData.lines, bytes: fileData.bytes });
899
950
 
900
951
  let html = '<div class="preview-header">';
901
952
  html += '<div class="preview-title">';
@@ -909,7 +960,8 @@ async function renderPreview() {
909
960
  }
910
961
  }
911
962
  html += `<button class="action-btn small" onclick="openInEditor('${escAttrJs(source.path)}')" title="Open in VS Code"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg></button>`;
912
- html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escAttrJs(source.path)}','${escAttrJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
963
+ if (!source.outside)
964
+ html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escAttrJs(source.path)}','${escAttrJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
913
965
  html += '</div></div>';
914
966
 
915
967
  // Description gets its own full-width line; the rest stay as key:value chips
@@ -1114,6 +1166,7 @@ async function openInEditor(filePath) {
1114
1166
 
1115
1167
  // #region ANALYZER
1116
1168
 
1169
+ const SEV_ORDER = { high: 0, med: 1, low: 2, clean: 3 };
1117
1170
  let analysisPollTimer = null;
1118
1171
  let lastAnalysisSt = null;
1119
1172
  let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
@@ -1270,6 +1323,7 @@ async function refreshAnalysis() {
1270
1323
  renderTree();
1271
1324
  if (isNew && !healthViewOpen && selectedFileId) renderPreview();
1272
1325
  rerenderAnalysis(st);
1326
+ if (homeOpen) renderHome();
1273
1327
  }
1274
1328
  lastPollKey = pollKey;
1275
1329
  clearTimeout(analysisPollTimer);
@@ -1346,8 +1400,11 @@ function analysisScopeGroups() {
1346
1400
  function _toggleScopePanel() {
1347
1401
  scopePanelOpen = !scopePanelOpen;
1348
1402
  if (scopePanelOpen && !scopeChecked) {
1349
- // default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in)
1350
- scopeChecked = new Set(stackData.filter((s) => s.scope === 'memory').map((s) => s.id));
1403
+ // default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in);
1404
+ // the user scope has no auto memory, so it starts on the user CLAUDE.md
1405
+ const memory = stackData.filter((s) => s.scope === 'memory');
1406
+ const fallback = stackData.filter((s) => s.scope === 'user' && !s.parentId);
1407
+ scopeChecked = new Set((memory.length ? memory : fallback).map((s) => s.id));
1351
1408
  }
1352
1409
  rerenderAnalysis();
1353
1410
  }
@@ -1743,14 +1800,13 @@ function renderMemoryMap(shown) {
1743
1800
  }
1744
1801
  }
1745
1802
  }
1746
- const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
1747
1803
  const cellSev = (s) => {
1748
1804
  const c = perFile.get(s.id);
1749
1805
  return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
1750
1806
  };
1751
1807
  let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
1752
1808
  for (const s of [...audited].sort(
1753
- (a, b) => sevRank[cellSev(a)] - sevRank[cellSev(b)] || a.name.localeCompare(b.name),
1809
+ (a, b) => SEV_ORDER[cellSev(a)] - SEV_ORDER[cellSev(b)] || a.name.localeCompare(b.name),
1754
1810
  )) {
1755
1811
  const c = perFile.get(s.id);
1756
1812
  const sev = cellSev(s);
@@ -1815,10 +1871,9 @@ function _toggleFixSelect(idx) {
1815
1871
  }
1816
1872
 
1817
1873
  function _copyFixPlan() {
1818
- const order = { high: 0, med: 1, low: 2 };
1819
1874
  const picked = analysisFindings
1820
1875
  .filter((f) => fixSelected.has(findingKey(f)))
1821
- .sort((a, b) => order[a.severity] - order[b.severity]);
1876
+ .sort((a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity]);
1822
1877
  if (!picked.length) return;
1823
1878
  const plan = [
1824
1879
  `Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
@@ -2002,6 +2057,11 @@ function renderAnalysis(st) {
2002
2057
 
2003
2058
  // #region RENDER_BUDGET
2004
2059
 
2060
+ // Listed descriptions: [scope, summary key, label]. Each item's cost is its description, not its file.
2061
+ const DESC_SEGMENTS = [
2062
+ ['skill', 'skillDesc', 'Skill descriptions'],
2063
+ ['agent', 'agentDesc', 'Agent descriptions'],
2064
+ ];
2005
2065
  let footprintHighlight = false;
2006
2066
  let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
2007
2067
  let footprintIds = new Set(); // source ids the server counted into the footprint
@@ -2023,7 +2083,7 @@ function _toggleFootprint(scope = null, ev) {
2023
2083
  }
2024
2084
 
2025
2085
  function footprintHit(item) {
2026
- if (footprintScope === 'skill') return item.scope === 'skill' && !item.parentId;
2086
+ if (DESC_SEGMENTS.some(([scope]) => scope === footprintScope)) return item.scope === footprintScope && !item.parentId;
2027
2087
  if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
2028
2088
  return footprintIds.has(item.id);
2029
2089
  }
@@ -2053,7 +2113,6 @@ function renderBudget() {
2053
2113
  // the footprint filter, the client only renders its per-scope totals.
2054
2114
  const segContainer = document.getElementById('budgetSegments');
2055
2115
  const scopeTotals = summaryData.scopeChars || {};
2056
- const skillDescChars = summaryData.skillDesc?.chars || 0;
2057
2116
  const totalChars = summaryData.totalChars || 1;
2058
2117
  let html = '';
2059
2118
  for (const scope of SCOPE_ORDER) {
@@ -2062,9 +2121,11 @@ function renderBudget() {
2062
2121
  const pct = (chars / totalChars) * 100;
2063
2122
  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>`;
2064
2123
  }
2065
- if (skillDescChars) {
2066
- const pct = (skillDescChars / totalChars) * 100;
2067
- 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>`;
2124
+ for (const [scope, desc, label] of DESC_SEGMENTS) {
2125
+ const chars = summaryData[desc]?.chars || 0;
2126
+ if (!chars) continue;
2127
+ const pct = (chars / totalChars) * 100;
2128
+ html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${esc(label)} (${esc(summaryData[desc].count)}): ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
2068
2129
  }
2069
2130
  segContainer.innerHTML = html;
2070
2131
  applySegmentState();
@@ -2072,11 +2133,470 @@ function renderBudget() {
2072
2133
 
2073
2134
  // #endregion RENDER_BUDGET
2074
2135
 
2136
+ // #region HOME
2137
+
2138
+ const svgIcon = (body) => `<svg class="home-ic" viewBox="0 0 24 24">${body}</svg>`;
2139
+ const HOME_ICON = svgIcon('<path d="M3 10l9-7 9 7v10a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M9 22V12h6v10"/>');
2140
+ const LOCK_ICON = svgIcon('<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>');
2141
+ const SPARKLE_ICON = svgIcon(
2142
+ '<path d="M12 3l1.9 5.7L19.6 10l-5.7 1.9L12 17.6l-1.9-5.7L4.4 10l5.7-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
2143
+ );
2144
+
2145
+ function applyLayout() {
2146
+ const on = homeOpen && !!projectData?.user;
2147
+ document.body.classList.toggle('home-mode', on);
2148
+ if (on) renderHome();
2149
+ }
2150
+
2151
+ function leaveHome() {
2152
+ if (!homeOpen) return;
2153
+ homeOpen = false;
2154
+ applyLayout();
2155
+ }
2156
+
2157
+ function showHome() {
2158
+ homeOpen = true;
2159
+ homeFocus = null;
2160
+ previewPath = null;
2161
+ healthViewOpen = false;
2162
+ applyLayout();
2163
+ }
2164
+
2165
+ function inFocus(scope) {
2166
+ return !homeFocus || homeFocus.includes(scope);
2167
+ }
2168
+
2169
+ function treeHomeLink() {
2170
+ const home = `<span class="tree-home-crumb" onclick="showHome()">${HOME_ICON}Home</span>`;
2171
+ if (!homeFocus) return `<div class="tree-home-link">${home}</div>`;
2172
+ const { groups } = getTreeIndex();
2173
+ const label = homeFocus
2174
+ .filter((s) => groups[s])
2175
+ .map((s) => SCOPE_LABELS[s] || s)
2176
+ .join(' + ');
2177
+ return `<div class="tree-home-link">${home}<span class="tree-home-sep">›</span><span class="tree-home-focus">${esc(label)}</span><span class="tree-home-all" onclick="_clearHomeFocus()" title="Show every group">show all</span></div>`;
2178
+ }
2179
+
2180
+ function _clearHomeFocus() {
2181
+ homeFocus = null;
2182
+ renderTree();
2183
+ scrollToSelected();
2184
+ }
2185
+
2186
+ // scopes is a comma list; without it the clicked file's own group is the focus.
2187
+ function _openFromHome(id, scopes) {
2188
+ const item = stackData.find((s) => s.id === id);
2189
+ homeFocus = scopes ? scopes.split(',') : item ? [getItemScope(item)] : null;
2190
+ for (const s of homeFocus || []) setGroupCollapsed(s, false);
2191
+ selectedFileId = null;
2192
+ selectFile(id);
2193
+ }
2194
+
2195
+ function _openPath(path, scope) {
2196
+ leaveHome();
2197
+ healthViewOpen = false;
2198
+ selectedFileId = null;
2199
+ previewPath = { outside: true, id: null, scope, load: 'read-only', path, name: splitProjectPath(path).name };
2200
+ renderTree();
2201
+ renderPreview();
2202
+ }
2203
+
2204
+ function fmtChars(n) {
2205
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
2206
+ }
2207
+
2208
+ function rootOf(item, byId) {
2209
+ let cur = item;
2210
+ while (cur?.parentId && byId.has(cur.parentId)) cur = byId.get(cur.parentId);
2211
+ return cur;
2212
+ }
2213
+
2214
+ function hardImportsOf(rootId) {
2215
+ const kids = (getTreeIndex().childrenOf[rootId] || []).filter((s) => s.load === 'import');
2216
+ return kids.flatMap((k) => [k, ...hardImportsOf(k.id)]);
2217
+ }
2218
+
2219
+ function withImportsChars(item) {
2220
+ return [item, ...hardImportsOf(item.id)].reduce((n, s) => n + (s.chars || 0), 0);
2221
+ }
2222
+
2223
+ const HOME_NONE = '<li><span class="home-empty">None</span></li>';
2224
+
2225
+ function moreRow(n) {
2226
+ return `<li class="home-more"><span class="home-nm">+ ${n} more</span></li>`;
2227
+ }
2228
+
2229
+ function homeBudget() {
2230
+ const parts = { policy: 0, user: 0, rule: 0 };
2231
+ const byId = new Map(stackData.map((s) => [s.id, s]));
2232
+ for (const s of stackData) {
2233
+ if (!footprintIds.has(s.id)) continue;
2234
+ const root = rootOf(s, byId);
2235
+ if (root.scope === 'policy') parts.policy += s.chars || 0;
2236
+ else if (root.id === 'user-claude-md') parts.user += s.chars || 0;
2237
+ else if (root.scope === 'rule' && root.load === 'always') parts.rule += s.chars || 0;
2238
+ }
2239
+ parts.policy += homeData?.managed?.claudeMdChars || 0;
2240
+ const style = homeData?.outputStyle?.chars ?? null;
2241
+ return [
2242
+ { key: 'policy', label: 'Policy', chars: parts.policy },
2243
+ { key: 'user', label: 'CLAUDE.md + imports', chars: parts.user },
2244
+ { key: 'rule', label: 'Always-on rules', chars: parts.rule },
2245
+ { key: 'style', label: 'Output style', chars: style || 0, unknown: style == null },
2246
+ { key: 'skill', label: 'Skill descriptions', chars: summaryData?.skillDesc?.chars || 0 },
2247
+ { key: 'agent', label: 'Agent descriptions', chars: summaryData?.agentDesc?.chars || 0 },
2248
+ ];
2249
+ }
2250
+
2251
+ function homeCardClick({ open, focus, file, fileScope, health }) {
2252
+ if (open) return ` onclick="_openFromHome('${escAttrJs(open)}', '${escAttrJs(focus || '')}')"`;
2253
+ if (file) return ` onclick="_openPath('${escAttrJs(file)}', '${escAttrJs(fileScope)}')"`;
2254
+ if (health) return ' onclick="openHealthView()"';
2255
+ return '';
2256
+ }
2257
+
2258
+ function homeCard({ color, title, when, always, wide, full, body, ...click }) {
2259
+ const onclick = homeCardClick(click);
2260
+ return `<div class="home-card${full ? ' full' : wide ? ' wide' : ''}" style="--c:${color}"${onclick}${onclick ? ' tabindex="0" role="button"' : ''}>
2261
+ <div class="home-ch"><span class="home-title">${title}</span>${when ? `<span class="home-when${always ? ' always' : ''}">${esc(when)}</span>` : ''}</div>
2262
+ ${body}
2263
+ </div>`;
2264
+ }
2265
+
2266
+ function homeRow(s, right, extra = '', label = s.name) {
2267
+ return `<li onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-nm">${esc(label)}</span>${extra}<span class="home-r">${right}</span></li>`;
2268
+ }
2269
+
2270
+ function homeMiniRow(s, chars, max, color) {
2271
+ const pct = max ? Math.max(4, Math.round((chars / max) * 100)) : 0;
2272
+ return homeRow(
2273
+ s,
2274
+ esc(chars.toLocaleString()),
2275
+ `<span class="home-mini" style="--c:${color}"><span style="width:${pct}%"></span></span>`,
2276
+ );
2277
+ }
2278
+
2279
+ function homeTopRows(items, color, limit = 3) {
2280
+ if (!items.length) return HOME_NONE;
2281
+ const sorted = [...items].sort((a, b) => b.descChars - a.descChars);
2282
+ const max = sorted[0].descChars;
2283
+ let html = sorted
2284
+ .slice(0, limit)
2285
+ .map((s) => homeMiniRow(s, s.descChars, max, color))
2286
+ .join('');
2287
+ if (sorted.length > limit) html += moreRow(sorted.length - limit);
2288
+ return html;
2289
+ }
2290
+
2291
+ function homeStats(pairs) {
2292
+ return `<div class="home-stats">${pairs.map(([v, l]) => `<div><b>${esc(v)}</b><span>${esc(l)}</span></div>`).join('')}</div>`;
2293
+ }
2294
+
2295
+ function renderHomePolicy() {
2296
+ const policy = stackData.find((s) => s.scope === 'policy' && !s.parentId);
2297
+ const managed = homeData?.managed || {};
2298
+ const managedPath = homeData?.settingsPaths?.managed || '';
2299
+ const none = '<span class="home-pill mut">none</span>';
2300
+ const mdRow = policy
2301
+ ? homeRow(policy, esc(`${fmtChars(withImportsChars(policy))} chars`), '', 'CLAUDE.md')
2302
+ : `<li><span class="home-nm">CLAUDE.md</span><span class="home-r">${none}</span></li>`;
2303
+ const settingsName = splitProjectPath(managedPath).name || 'managed-settings.json';
2304
+ const settingsRow = managed.keys
2305
+ ? `<li onclick="event.stopPropagation();_openPath('${escAttrJs(managedPath)}', 'policy')" title="${esc(managedPath)}"><span class="home-nm">${esc(settingsName)}${managed.source === 'server' ? ' <span class="home-d">· from claude.ai</span>' : ''}</span><span class="home-r">${esc(managed.keys.join(', ') || 'empty')}</span></li>`
2306
+ : `<li title="${esc(managedPath)}"><span class="home-nm">managed settings</span><span class="home-r">${none}</span></li>`;
2307
+ const claudeMdRow =
2308
+ managed.claudeMdChars == null
2309
+ ? ''
2310
+ : `<li><span class="home-nm">claudeMd in settings</span><span class="home-r">${esc(`${fmtChars(managed.claudeMdChars)} chars`)}</span></li>`;
2311
+ return homeCard({
2312
+ color: 'var(--scope-policy)',
2313
+ title: `${LOCK_ICON}Managed policy`,
2314
+ when: 'always',
2315
+ always: true,
2316
+ open: policy?.id,
2317
+ file: managed.keys ? managedPath : null,
2318
+ fileScope: 'policy',
2319
+ body: `<ul class="home-rows">${mdRow}${settingsRow}${claudeMdRow}</ul>`,
2320
+ });
2321
+ }
2322
+
2323
+ function renderHomeUserMd() {
2324
+ const md = stackData.find((s) => s.id === 'user-claude-md');
2325
+ const card = { color: 'var(--scope-user)', title: 'User CLAUDE.md', when: 'always', always: true };
2326
+ if (!md) {
2327
+ return homeCard({
2328
+ ...card,
2329
+ body: `<div class="home-path">${esc(summaryData?.userClaudeMd || '')}</div><div class="home-empty">File does not exist</div>`,
2330
+ });
2331
+ }
2332
+ const imports = hardImportsOf(md.id);
2333
+ const tree = imports
2334
+ .map((s, i) => {
2335
+ const branch = i === imports.length - 1 ? '└─' : '├─';
2336
+ return `<div onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-d">${branch}</span> @${esc(s.name)} <span class="home-d">${esc(fmtChars(s.chars || 0))}</span></div>`;
2337
+ })
2338
+ .join('');
2339
+ return homeCard({
2340
+ ...card,
2341
+ open: md.id,
2342
+ body: `<div class="home-path" title="${esc(md.path)}">${esc(md.path)}</div>${homeStats([
2343
+ [md.lines, 'lines'],
2344
+ [fmtChars(withImportsChars(md)), 'chars'],
2345
+ [imports.length, 'imports'],
2346
+ ])}${imports.length ? `<div class="home-tree">${tree}</div>` : ''}`,
2347
+ });
2348
+ }
2349
+
2350
+ function renderHomeStyle() {
2351
+ const st = homeData?.outputStyle;
2352
+ if (!st) return '';
2353
+ const isDefault = st.from === 'default';
2354
+ const isManaged = st.from === 'managed';
2355
+ const fromLabel = isDefault ? 'not set · default' : `set in ${isManaged ? 'managed' : 'user'} settings`;
2356
+ const keep = st.keepCodingInstructions == null ? 'n/a' : String(st.keepCodingInstructions);
2357
+ const setIn = isManaged ? homeData.settingsPaths.managed : homeData.settingsPaths.user;
2358
+ const setInScope = isManaged ? 'policy' : 'user';
2359
+ const fromRow = isDefault
2360
+ ? `<li><span class="home-nm">${esc(fromLabel)}</span></li>`
2361
+ : `<li onclick="event.stopPropagation();_openPath('${escAttrJs(setIn)}', '${escAttrJs(setInScope)}')"><span class="home-nm" title="${esc(setIn)}">${esc(fromLabel)}</span></li>`;
2362
+ return homeCard({
2363
+ color: 'var(--scope-style)',
2364
+ title: 'Output style',
2365
+ when: 'always',
2366
+ always: true,
2367
+ file: st.path || (isDefault ? null : setIn),
2368
+ fileScope: st.path ? 'user' : setInScope,
2369
+ body: `${homeStats([[st.name, !st.path ? 'built-in · text not on disk' : `${fmtChars(st.chars || 0)} chars`]])}
2370
+ <ul class="home-rows">
2371
+ ${fromRow}
2372
+ <li><span class="home-nm">keep-coding-instructions</span><span class="home-r">${esc(keep)}</span></li>
2373
+ </ul>`,
2374
+ });
2375
+ }
2376
+
2377
+ function renderHomeRules() {
2378
+ const rules = stackData.filter((s) => s.scope === 'rule' && !s.parentId);
2379
+ const always = rules.filter((s) => s.load === 'always');
2380
+ const cond = rules.filter((s) => s.load !== 'always');
2381
+ const alwaysChars = always.reduce((n, s) => n + withImportsChars(s), 0);
2382
+ const globs = (s) => {
2383
+ const p = s.frontmatter?.paths;
2384
+ return Array.isArray(p) ? p.join(', ') : String(p || '');
2385
+ };
2386
+ const list = (items, right) => (items.length ? items.map((s) => homeRow(s, right(s))).join('') : HOME_NONE);
2387
+ return homeCard({
2388
+ color: 'var(--scope-rule)',
2389
+ title: 'User rules',
2390
+ when: `${claudeConfigDir}/rules`,
2391
+ wide: true,
2392
+ open: rules[0]?.id,
2393
+ body: `<div class="home-cols">
2394
+ <div><div class="home-colh">Always loaded · ${always.length}${always.length ? ` · ${esc(fmtChars(alwaysChars))}` : ''}</div>
2395
+ <ul class="home-rows">${list(always, (s) => esc(fmtChars(withImportsChars(s))))}</ul></div>
2396
+ <div><div class="home-colh">When a file matches · ${cond.length}</div>
2397
+ <ul class="home-rows">${list(cond, (s) => esc(globs(s)))}</ul></div>
2398
+ </div>`,
2399
+ });
2400
+ }
2401
+
2402
+ function renderHomeAgentMemory() {
2403
+ const idx = stackData.filter((s) => s.scope === 'agent-memory' && s.name === 'MEMORY.md' && !s.parentId);
2404
+ const rows = idx
2405
+ .map((s) => {
2406
+ const max = s.maxLines || 200;
2407
+ const pct = Math.min(100, Math.round((s.lines / max) * 100));
2408
+ const warn = pct >= 90 || s.bytes > (s.maxBytes || 25 * 1024);
2409
+ const bar = `<span class="home-mini" style="--c:var(--scope-agent-memory)"><span class="${warn ? 'warn' : ''}" style="width:${pct}%"></span></span>`;
2410
+ return homeRow(s, esc(`${s.lines}/${max}`), bar, s.agentName);
2411
+ })
2412
+ .join('');
2413
+ return homeCard({
2414
+ color: 'var(--scope-agent-memory)',
2415
+ title: 'Subagent memory',
2416
+ when: 'on agent start',
2417
+ open: idx[0]?.id,
2418
+ body: `<div class="home-path">${esc(claudeConfigDir)}/agent-memory/</div>${
2419
+ rows ? `<ul class="home-rows">${rows}</ul>` : '<div class="home-empty">No subagent keeps user memory</div>'
2420
+ }`,
2421
+ });
2422
+ }
2423
+
2424
+ function renderHomeListed() {
2425
+ const skills = stackData.filter((s) => s.scope === 'skill' && s.descChars != null);
2426
+ const agents = stackData.filter((s) => s.scope === 'agent' && s.descChars != null);
2427
+ const first = skills[0] || agents[0];
2428
+ return homeCard({
2429
+ color: 'var(--scope-skill)',
2430
+ title: 'User skills and agents',
2431
+ when: 'descriptions only',
2432
+ always: true,
2433
+ wide: true,
2434
+ open: first?.id,
2435
+ focus: 'skill,agent',
2436
+ body: `<div class="home-cols">
2437
+ <div><div class="home-colh">Skills · ${skills.length}${skills.length ? ` · ${esc(fmtChars(summaryData?.skillDesc?.chars || 0))}` : ''}</div>
2438
+ <ul class="home-rows">${homeTopRows(skills, 'var(--scope-skill)')}</ul></div>
2439
+ <div><div class="home-colh">Agents · ${agents.length}${agents.length ? ` · ${esc(fmtChars(summaryData?.agentDesc?.chars || 0))}` : ''}</div>
2440
+ <ul class="home-rows">${homeTopRows(agents, 'var(--scope-agent)')}</ul></div>
2441
+ </div>${
2442
+ hub.can('project.plugins')
2443
+ ? '<ul class="home-rows home-plugins"><li onclick="event.stopPropagation();_openPlugins()"><span class="home-nm">Plugins list their skills and agents too. Open Marketplace</span></li></ul>'
2444
+ : ''
2445
+ }`,
2446
+ });
2447
+ }
2448
+
2449
+ function _openPlugins() {
2450
+ hub.invoke('project.plugins');
2451
+ }
2452
+
2453
+ function renderHomeSettings() {
2454
+ const set = homeData?.settings;
2455
+ if (!set) return '';
2456
+ const show = (v) => {
2457
+ if (v == null) return '—';
2458
+ if (Array.isArray(v)) return v.length ? v.join(', ') : '[]';
2459
+ return String(v);
2460
+ };
2461
+ const rows = [
2462
+ ['autoMemoryEnabled', set.autoMemoryEnabled],
2463
+ ['autoMemoryDirectory', set.autoMemoryDirectory],
2464
+ ['claudeMdExcludes', set.claudeMdExcludes],
2465
+ ['cleanupPeriodDays', set.cleanupPeriodDays],
2466
+ ['DISABLE_AUTO_MEMORY', set.CLAUDE_CODE_DISABLE_AUTO_MEMORY],
2467
+ ]
2468
+ .map(
2469
+ ([k, s]) =>
2470
+ `<span class="home-k">${esc(k)}</span><span>${esc(show(s?.value))}<span class="home-from">${esc(s?.from || '')}</span></span>`,
2471
+ )
2472
+ .join('');
2473
+ return homeCard({
2474
+ color: 'var(--text-muted)',
2475
+ title: 'Memory settings',
2476
+ when: 'effective',
2477
+ file: homeData.settingsPaths.user,
2478
+ fileScope: 'user',
2479
+ body: `<div class="home-kv">${rows}</div>`,
2480
+ });
2481
+ }
2482
+
2483
+ const SEVERITY_PILL = { high: 'err', med: 'warn', low: 'mut' };
2484
+
2485
+ function renderHomeHealth() {
2486
+ const st = lastAnalysisSt;
2487
+ const run = st ? mergedRunView(st) : null;
2488
+ const latest = allAnalysisRuns(st)[0]?.run;
2489
+ const pending = (st?.pending || []).length;
2490
+ let rows;
2491
+ if (!run) {
2492
+ rows =
2493
+ '<li><span class="home-empty">No run yet. Analyze checks the user scope for conflicts and stale facts.</span></li>';
2494
+ } else {
2495
+ const findings = (run.result?.findings || [])
2496
+ .filter((f) => !isDismissed(f))
2497
+ .sort((a, b) => (SEV_ORDER[a.severity] ?? 3) - (SEV_ORDER[b.severity] ?? 3));
2498
+ rows = findings.length
2499
+ ? findings
2500
+ .slice(0, 4)
2501
+ .map(
2502
+ (f) =>
2503
+ `<li><span class="home-pill ${SEVERITY_PILL[f.severity] || 'mut'}">${esc(f.kind || f.severity)}</span><span class="home-nm">${esc(f.title)}</span></li>`,
2504
+ )
2505
+ .join('')
2506
+ : '<li><span class="home-pill ok">ok</span><span class="home-nm">No open findings</span></li>';
2507
+ if (findings.length > 4) rows += moreRow(findings.length - 4);
2508
+ }
2509
+ const when = pending ? 'running…' : latest ? `last run ${analysisTimeAgo(latest.ts)}` : 'never run';
2510
+ return homeCard({
2511
+ color: 'var(--accent)',
2512
+ title: `${SPARKLE_ICON}Health · user scope`,
2513
+ when,
2514
+ full: true,
2515
+ health: true,
2516
+ body: `<ul class="home-rows">${rows}</ul>`,
2517
+ });
2518
+ }
2519
+
2520
+ function renderHomeRecents() {
2521
+ const recent = getRecentProjects()
2522
+ .filter((p) => p !== USER_ROW)
2523
+ .slice(0, 6);
2524
+ const chips = recent
2525
+ .map((p) => {
2526
+ const { name } = splitProjectPath(p);
2527
+ return `<span class="home-chip" title="${esc(p)}" onclick="_homeSwitch('${escAttrJs(p)}')"><span class="home-dot"></span>${esc(name)}</span>`;
2528
+ })
2529
+ .join('');
2530
+ return `<div class="home-recents"><h2>Open a project</h2><div class="home-chips">${chips}<span class="home-chip home-chip-add" onclick="_homeAddPath()">+ Add path</span></div></div>`;
2531
+ }
2532
+
2533
+ function _homeSwitch(p) {
2534
+ document.getElementById('projectPathInput').value = p;
2535
+ submitProjectPicker();
2536
+ }
2537
+
2538
+ function _goUserHome() {
2539
+ if (projectData?.user) showHome();
2540
+ else _homeSwitch(USER_ROW);
2541
+ }
2542
+
2543
+ function _homeAddPath() {
2544
+ changeProject();
2545
+ setPickerMode(true);
2546
+ }
2547
+
2548
+ function renderHome() {
2549
+ const el = document.getElementById('homeView');
2550
+ if (!el || !summaryData) return;
2551
+ const parts = homeBudget();
2552
+ const known = parts.reduce((n, p) => n + p.chars, 0);
2553
+ const total = known || 1;
2554
+ const bar = parts
2555
+ .filter((p) => p.chars)
2556
+ .map(
2557
+ (p) =>
2558
+ `<span style="width:${(p.chars / total) * 100}%;background:var(--scope-${p.key})" title="${esc(p.label)}: ${p.chars.toLocaleString()} chars"></span>`,
2559
+ )
2560
+ .join('');
2561
+ const legend = parts
2562
+ .map((p) =>
2563
+ p.unknown
2564
+ ? `<span class="home-unk"><i style="background:var(--scope-${p.key})"></i>${esc(p.label)}: built-in, not counted</span>`
2565
+ : `<span${p.chars ? '' : ' class="home-zero"'}><i style="background:var(--scope-${p.key})"></i>${esc(p.label)} <b>${esc(fmtChars(p.chars))}</b></span>`,
2566
+ )
2567
+ .join('');
2568
+ el.innerHTML = `<div class="home">
2569
+ <h1>Your memory</h1>
2570
+ <div class="home-budget">
2571
+ <div class="home-budget-head"><span class="t">Loaded into every session</span><span class="n">${esc(known.toLocaleString())} <small>chars · from files on disk</small></span></div>
2572
+ <div class="home-bar">${bar}</div>
2573
+ <div class="home-legend">${legend}</div>
2574
+ </div>
2575
+ <div class="home-grid">
2576
+ ${renderHomePolicy()}
2577
+ ${renderHomeUserMd()}
2578
+ ${renderHomeStyle()}
2579
+ ${renderHomeListed()}
2580
+ ${renderHomeSettings()}
2581
+ ${renderHomeRules()}
2582
+ ${renderHomeAgentMemory()}
2583
+ ${renderHomeHealth()}
2584
+ </div>
2585
+ ${renderHomeRecents()}
2586
+ </div>`;
2587
+ }
2588
+
2589
+ // #endregion HOME
2590
+
2075
2591
  // #region DATA
2076
2592
 
2077
2593
  async function loadData() {
2078
2594
  try {
2079
- [stackData, summaryData] = await Promise.all([fetchJSON('/api/stack'), fetchJSON('/api/summary')]);
2595
+ [stackData, summaryData, homeData] = await Promise.all([
2596
+ fetchJSON('/api/stack'),
2597
+ fetchJSON('/api/summary'),
2598
+ projectData?.user ? fetchJSON('/api/home') : null,
2599
+ ]);
2080
2600
  invalidateTreeIndex();
2081
2601
  renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
2082
2602
  renderTree();
@@ -2089,6 +2609,7 @@ async function loadData() {
2089
2609
  }
2090
2610
  if (selectedFileId) renderTree();
2091
2611
  renderPreview();
2612
+ applyLayout();
2092
2613
  } catch (err) {
2093
2614
  showToast(`Failed to load: ${err.message}`, 'error');
2094
2615
  }
@@ -2136,6 +2657,11 @@ document.addEventListener('keydown', (e) => {
2136
2657
  openHealthView();
2137
2658
  return;
2138
2659
  }
2660
+ if (e.key === 'Escape' && projectData?.user && !homeOpen) {
2661
+ e.preventDefault();
2662
+ showHome();
2663
+ return;
2664
+ }
2139
2665
  if (e.key === 't') toggleTheme();
2140
2666
  if (e.key === 'r') refreshData();
2141
2667
  if (e.key === '?') {
@@ -2146,6 +2672,14 @@ document.addEventListener('keydown', (e) => {
2146
2672
  e.preventDefault();
2147
2673
  changeProject();
2148
2674
  }
2675
+ if (homeOpen) {
2676
+ const card = e.target.closest?.('.home-card[onclick]');
2677
+ if (card && (e.key === 'Enter' || e.key === ' ')) {
2678
+ e.preventDefault();
2679
+ card.click();
2680
+ }
2681
+ return;
2682
+ }
2149
2683
  // Skip when modifiers are held (e.g. hub's Ctrl+Alt+Arrow app switching).
2150
2684
  const plain = !e.ctrlKey && !e.altKey && !e.metaKey;
2151
2685
  if (plain && (e.key === 'j' || e.key === 'ArrowDown')) {
@@ -2165,8 +2699,8 @@ document.addEventListener('keydown', (e) => {
2165
2699
  expandOrDescend();
2166
2700
  }
2167
2701
  if (e.key === 'Enter' && selectedFileId) renderPreview();
2168
- if (e.key === 'e' && selectedFileId) {
2169
- const s = stackData.find((x) => x.id === selectedFileId);
2702
+ if (e.key === 'e' && (selectedFileId || previewPath)) {
2703
+ const s = stackData.find((x) => x.id === selectedFileId) || previewPath;
2170
2704
  if (s) openInEditor(s.path);
2171
2705
  }
2172
2706
  });
@@ -2207,6 +2741,7 @@ hub.onThemes((themes) => {
2207
2741
  // Set synchronously when the hub pushes a project, so the DOMContentLoaded restore below can't
2208
2742
  // let a stale localStorage recent win the race against the hub's choice.
2209
2743
  let hubProjectPath = null;
2744
+ let hubProjectReplayed = false;
2210
2745
  // Shared with the DOMContentLoaded restore so a boot restore and a hub push never apply the same project twice.
2211
2746
  let lastAppliedProject = null;
2212
2747
 
@@ -2217,22 +2752,35 @@ let lastAppliedProject = null;
2217
2752
  if (lastAppliedProject === dirPath) return;
2218
2753
  lastAppliedProject = dirPath;
2219
2754
  try {
2220
- await putProject(dirPath);
2755
+ const info = await putProject(dirPath);
2221
2756
  // The hub does not persist its own scope, so a hub-pushed project must become the recent
2222
2757
  // head here: after a hard refresh the boot path PUTs getRecentProjects()[0], which would
2223
2758
  // otherwise clobber the server's hub-scoped project with the previous one.
2224
2759
  addRecentProject(dirPath);
2225
- await Promise.all([loadProject(), loadData()]);
2760
+ await loadProject(info);
2761
+ await loadData();
2226
2762
  } catch (err) {
2227
2763
  lastAppliedProject = null;
2228
2764
  throw err;
2229
2765
  }
2230
2766
  }
2231
2767
 
2232
- hub.subscribe('project.changed', (p) => {
2768
+ // The hub replays project.changed after its welcome and before the first hub:active; a live
2769
+ // change comes after it. Only a live change takes the app off a remembered user scope.
2770
+ let hubActiveSeen = false;
2771
+ hub.onActive(() => {
2772
+ hubActiveSeen = true;
2773
+ });
2774
+
2775
+ hub.subscribe('project.changed', async (p) => {
2233
2776
  const dirPath = typeof p?.project === 'string' && p.project ? p.project : null;
2234
2777
  if (!dirPath) return;
2235
2778
  hubProjectPath = dirPath;
2779
+ hubProjectReplayed = !hubActiveSeen;
2780
+ if (hubProjectReplayed && (await isUserScopeRemembered())) {
2781
+ if (hubProjectPath === dirPath) hubProjectPath = null;
2782
+ return;
2783
+ }
2236
2784
  applyProject(dirPath).catch((err) => console.warn('project.changed failed:', err.message));
2237
2785
  });
2238
2786
 
@@ -2245,6 +2793,11 @@ let lastAppliedProject = null;
2245
2793
 
2246
2794
  // #endregion HUB_INTEGRATION
2247
2795
 
2796
+ // The home's Marketplace link depends on hub.can, which only knows the hub's actions after welcome.
2797
+ hub.onStatus(() => {
2798
+ if (homeOpen) renderHome();
2799
+ });
2800
+
2248
2801
  // #region RESIZE
2249
2802
 
2250
2803
  function initResize() {
@@ -2304,7 +2857,10 @@ document.addEventListener('DOMContentLoaded', async () => {
2304
2857
  // A hub-pushed project outranks the localStorage recent: ClaudeHub.connect() is fire-and-forget at script
2305
2858
  // eval, so project.changed can land before or during this block.
2306
2859
  await projectInfoPromise;
2307
- if (!desiredProject) desiredProject = hubProjectPath || getRecentProjects()[0] || null;
2860
+ if (!desiredProject) {
2861
+ const hubPick = hubProjectReplayed && userScopeIsHead() ? null : hubProjectPath;
2862
+ desiredProject = hubPick || getRecentProjects()[0] || USER_ROW;
2863
+ }
2308
2864
  if (desiredProject) {
2309
2865
  // Set before the PUT: the hub's push can land while it is in flight.
2310
2866
  lastAppliedProject = desiredProject;
@@ -2335,12 +2891,13 @@ document.addEventListener('DOMContentLoaded', async () => {
2335
2891
  else showToast('Failed to connect to server', 'error');
2336
2892
  }
2337
2893
  }
2338
- if (projectData) addRecentProject(projectData.path);
2894
+ if (projectData) addRecentProject(projectKey(projectData));
2339
2895
  await loadData();
2340
2896
  // Restore file selection from hash
2341
2897
  const hash = decodeURIComponent(location.hash.slice(1));
2342
2898
  const hashItem = hash && stackData.find((s) => s.id === hash);
2343
2899
  if (hashItem) {
2900
+ leaveHome();
2344
2901
  selectedFileId = hash;
2345
2902
  expandGroupForItem(hashItem);
2346
2903
  expandItemAncestors(hashItem);