@phren/cli 0.0.17 → 0.0.19

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.
@@ -257,8 +257,8 @@ export function renderGraphScript() {
257
257
  /* ease-in-out via sine curve over the full trip distance */
258
258
  var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
259
259
  var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
260
- var baseSpeed = Math.max(3, phren.tripDist * 0.12);
261
- var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
260
+ var baseSpeed = Math.max(0.8, phren.tripDist * 0.03);
261
+ var speed = Math.max(0.4, baseSpeed * (0.15 + 0.85 * easeInOut));
262
262
  /* clamp speed to remaining distance — prevents overshoot oscillation */
263
263
  if (speed >= dist) {
264
264
  phren.x = wx;
@@ -338,10 +338,10 @@ export function renderGraphScript() {
338
338
  /* sprite rendering */
339
339
  if (phrenImgReady) {
340
340
  ctx.save();
341
- /* fixed 48px size in screen pixels, scale up to 56px on arrival flash */
342
- var spriteScreenSize = 48;
341
+ /* scale-aware sprite: 32px at default zoom, shrinks when zoomed out, caps at 40px */
342
+ var spriteScreenSize = Math.min(40, Math.max(16, 32 * Math.sqrt(scale)));
343
343
  if (phren.arriving && phren.arriveTimer < 0.4) {
344
- spriteScreenSize = 48 + 8 * (1 - phren.arriveTimer / 0.4);
344
+ spriteScreenSize += 6 * (1 - phren.arriveTimer / 0.4);
345
345
  }
346
346
  var spriteSize = spriteScreenSize * s; /* convert to graph coords */
347
347
  /* bob up/down when walking — sine wave synced to walk progress */
@@ -366,18 +366,7 @@ export function renderGraphScript() {
366
366
  ctx.translate(-px, -py);
367
367
  }
368
368
  ctx.drawImage(phrenImg, px - spriteSize / 2, py - spriteSize / 2 + totalYOffset, spriteSize, spriteSize);
369
- /* arrival flash: cyan glow ring */
370
- if (phren.arriving && phren.arriveTimer < 0.6) {
371
- var ringAlpha = 0.6 * (1 - phren.arriveTimer / 0.6);
372
- ctx.beginPath();
373
- ctx.arc(px, py + totalYOffset, spriteSize * 0.55, 0, Math.PI * 2);
374
- ctx.strokeStyle = 'rgba(0,229,255,' + ringAlpha + ')';
375
- ctx.lineWidth = 2 * s;
376
- ctx.shadowColor = 'rgba(0,229,255,' + (ringAlpha * 0.5) + ')';
377
- ctx.shadowBlur = 8;
378
- ctx.stroke();
379
- ctx.shadowBlur = 0;
380
- }
369
+ /* no glow ring — phren stands clean next to the node */
381
370
  ctx.restore();
382
371
  }
383
372
  }
@@ -1362,9 +1351,10 @@ export function renderGraphScript() {
1362
1351
  /* ── detail panel ───────────────────────────────────────────────────── */
1363
1352
  function renderGraphDetails(node) {
1364
1353
  selectedNode = node;
1365
- /* phren moves toward the selected node, following graph edges when possible */
1354
+ /* phren moves toward the selected node, stopping just beside it (not on top) */
1366
1355
  if (node && typeof node.x === 'number' && typeof node.y === 'number') {
1367
- phrenMoveTo(node.x, node.y, node);
1356
+ var nr = nodeRadius(node) + 12; /* offset by node radius + margin */
1357
+ phrenMoveTo(node.x + nr, node.y - nr * 0.5, node);
1368
1358
  }
1369
1359
  /* fire external callback so VS Code extension can react to selection */
1370
1360
  if (_nodeSelectCb && node) {
@@ -86,53 +86,29 @@ ${TASK_UI_STYLES}
86
86
 
87
87
  <!-- ── Review Tab ────────────────────────────────────────── -->
88
88
  <div id="tab-review" class="tab-content">
89
- <div class="card" style="margin-bottom:16px">
90
- <div class="card-header"><h2>Sync State</h2></div>
91
- <div class="card-body">
92
- <div id="sync-state-summary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;font-size:var(--text-base)">
93
- <div><strong>Auto-save</strong><div class="text-muted">${h(sync.autoSaveStatus || "n/a")}</div></div>
94
- <div><strong>Last pull</strong><div class="text-muted">${h(sync.lastPullStatus || "n/a")} ${h(sync.lastPullAt || "")}</div></div>
95
- <div><strong>Last push</strong><div class="text-muted">${h(sync.lastPushStatus || "n/a")} ${h(sync.lastPushAt || "")}</div></div>
96
- <div><strong>Unsynced commits</strong><div class="text-muted">${h(String(sync.unsyncedCommits || 0))}</div></div>
97
- </div>
98
- </div>
99
- </div>
100
- <details class="review-help" style="margin-bottom:16px">
101
- <summary>How review works</summary>
102
- <p style="margin-top:8px;font-size:var(--text-sm);color:var(--muted)">Items waiting for your review. Approve to keep, reject to remove. You can also edit the text before approving, or use the checkboxes to bulk approve or reject multiple items at once.</p>
103
- </details>
104
-
105
- <p style="font-size:var(--text-sm);color:var(--muted);margin-bottom:12px;letter-spacing:-0.01em">Items waiting for your review. Approve to keep, reject to remove.</p>
106
-
107
- <div id="review-summary-banner" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center"></div>
108
-
109
- <div class="review-filters" id="review-filters" style="display:none">
89
+ <div class="review-toolbar" id="review-filters" style="display:none">
110
90
  <select id="review-filter-project">
111
91
  <option value="">All projects</option>
112
92
  </select>
113
- <select id="review-filter-machine">
114
- <option value="">All machines</option>
115
- </select>
116
- <select id="review-filter-model">
117
- <option value="">All models</option>
118
- </select>
119
- <span id="review-filter-count" class="text-muted" style="font-size:var(--text-sm);margin-left:8px"></span>
120
- <label id="review-select-all" style="display:none;margin-left:auto;align-items:center;gap:6px;font-size:var(--text-sm);color:var(--muted);cursor:pointer;user-select:none">
121
- <input type="checkbox" onchange="toggleSelectAll(this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:var(--accent)" />
93
+ <label class="review-flagged-toggle" id="review-flagged-toggle">
94
+ <input type="checkbox" id="highlight-only-btn" />
95
+ <span>Flagged only</span>
96
+ </label>
97
+ <span id="review-filter-count" class="text-muted" style="font-size:var(--text-sm);margin-left:auto"></span>
98
+ <label id="review-select-all" style="display:none;align-items:center;gap:6px;font-size:var(--text-sm);color:var(--muted);cursor:pointer;user-select:none">
99
+ <input type="checkbox" id="review-select-all-cb" style="width:14px;height:14px;cursor:pointer;accent-color:var(--accent)" />
122
100
  Select all
123
101
  </label>
124
- <button class="btn btn-sm" id="highlight-only-btn">Flagged only</button>
102
+ <span id="review-sync-status" class="review-sync-dot" title="Sync status">
103
+ <span class="review-sync-indicator" id="review-sync-indicator"></span>
104
+ </span>
125
105
  </div>
126
106
 
127
107
  <div id="batch-bar" class="batch-bar">
128
108
  <span id="batch-count" class="batch-bar-count"></span>
129
- <button class="btn btn-sm btn-approve" onclick="batchAction('approve')">Approve selected</button>
130
- <button class="btn btn-sm btn-reject" onclick="batchAction('reject')">Reject selected</button>
131
- <button class="btn btn-sm" onclick="clearBatchSelection()">Clear</button>
132
- </div>
133
-
134
- <div id="review-kbd-hints" style="font-size:var(--text-xs);color:var(--muted);margin-bottom:12px;display:none;gap:16px;flex-wrap:wrap">
135
- <span><kbd>j</kbd>/<kbd>k</kbd> navigate</span>
109
+ <button class="btn btn-sm btn-approve" data-batch-action="approve">Approve selected</button>
110
+ <button class="btn btn-sm btn-reject" data-batch-action="reject">Reject selected</button>
111
+ <button class="btn btn-sm" data-batch-action="clear">Clear</button>
136
112
  </div>
137
113
 
138
114
  <div class="review-cards" id="review-cards-list">
@@ -156,9 +132,10 @@ ${TASK_UI_STYLES}
156
132
  <div style="max-width:720px;margin:0 auto">
157
133
  <div style="display:flex;gap:8px;margin-bottom:16px">
158
134
  <input type="text" id="search-query" placeholder="Search fragments, findings, tasks..." style="flex:1;border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 12px;background:var(--surface);color:var(--ink);font-size:var(--text-base);font-family:var(--font);outline:none" />
159
- <select id="search-project-filter" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm)">
160
- <option value="">All projects</option>
161
- </select>
135
+ <div id="search-project-wrap" style="position:relative">
136
+ <button id="search-project-btn" type="button" onclick="window._phrenToggleProjectDropdown()" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm);cursor:pointer;font-family:var(--font);min-width:120px;text-align:left;white-space:nowrap">All projects</button>
137
+ <div id="search-project-dropdown" style="display:none;position:absolute;top:100%;left:0;z-index:50;margin-top:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 4px 12px rgba(0,0,0,.15);max-height:240px;overflow-y:auto;min-width:160px"></div>
138
+ </div>
162
139
  <select id="search-type-filter" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm)">
163
140
  <option value="">All types</option>
164
141
  <option value="finding">Findings</option>
@@ -275,6 +252,12 @@ ${TASK_UI_STYLES}
275
252
  <div id="settings-scope-note" style="font-size:var(--text-sm);color:var(--muted)">Showing global settings. Select a project to view and edit per-project overrides.</div>
276
253
  </div>
277
254
  </section>
255
+ <section id="settings-project-info-section" class="settings-section" style="display:none;border-top:3px solid color-mix(in srgb, var(--accent) 45%, var(--border))">
256
+ <div class="settings-section-header">Project Info</div>
257
+ <div class="settings-section-body">
258
+ <div id="settings-project-info" style="color:var(--muted)"></div>
259
+ </div>
260
+ </section>
278
261
  <section class="settings-section settings-section-findings">
279
262
  <div class="settings-section-header">Findings</div>
280
263
  <div class="settings-section-body">
@@ -337,10 +320,10 @@ ${renderSkillUiEnhancementScript(h(authToken || ""))}
337
320
  ${renderProjectReferenceEnhancementScript(h(authToken || ""))}
338
321
  </script>
339
322
  <script${nonceAttr}>
340
- ${renderTasksAndSettingsScript(h(authToken || ""))}
323
+ ${renderTasksAndSettingsScript(authToken || "")}
341
324
  </script>
342
325
  <script${nonceAttr}>
343
- ${renderSearchScript(h(authToken || ""))}
326
+ ${renderSearchScript(authToken || "")}
344
327
  </script>
345
328
  <script${nonceAttr}>
346
329
  ${renderEventWiringScript()}
@@ -592,8 +592,9 @@ export function renderReviewQueueEditSyncScript() {
592
592
  return "";
593
593
  }
594
594
  export function renderTasksAndSettingsScript(authToken) {
595
+ const safeToken = JSON.stringify(authToken).slice(1, -1);
595
596
  return `(function() {
596
- var _tsAuthToken = '${authToken}';
597
+ var _tsAuthToken = '${safeToken}';
597
598
  var _allTasks = [];
598
599
  var esc = window._phrenEsc;
599
600
 
@@ -974,6 +975,35 @@ export function renderTasksAndSettingsScript(authToken) {
974
975
 
975
976
  var isProject = Boolean(selectedProject);
976
977
 
978
+ // Render project info section
979
+ var infoSection = document.getElementById('settings-project-info-section');
980
+ var infoEl = document.getElementById('settings-project-info');
981
+ if (infoSection && infoEl) {
982
+ if (isProject && data.projectInfo) {
983
+ var pi = data.projectInfo;
984
+ infoSection.style.display = '';
985
+ var infoHtml = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;font-size:var(--text-sm)">';
986
+ infoHtml += '<div><strong style="color:var(--ink)">Disk path</strong><div class="text-muted" style="font-family:var(--mono);font-size:var(--text-xs);word-break:break-all">' + esc(pi.diskPath) + '</div></div>';
987
+ infoHtml += '<div><strong style="color:var(--ink)">Ownership</strong><div class="text-muted">' + esc(pi.ownership) + '</div></div>';
988
+ infoHtml += '<div><strong style="color:var(--ink)">Config file</strong><div class="text-muted" style="font-family:var(--mono);font-size:var(--text-xs);word-break:break-all">' + esc(pi.configFile) + '</div>' + (pi.configExists ? '<span style="color:var(--green);font-size:var(--text-xs)">exists</span>' : '<span style="color:var(--muted);font-size:var(--text-xs)">not created</span>') + '</div>';
989
+ infoHtml += '<div><strong style="color:var(--ink)">Findings</strong><div class="text-muted">' + pi.findingCount + ' entries</div></div>';
990
+ infoHtml += '<div><strong style="color:var(--ink)">Tasks</strong><div class="text-muted">' + pi.taskCount + ' in queue</div></div>';
991
+ infoHtml += '</div>';
992
+ var files = [];
993
+ if (pi.hasFindings) files.push('FINDINGS.md');
994
+ if (pi.hasTasks) files.push('tasks.md');
995
+ if (pi.hasSummary) files.push('summary.md');
996
+ if (pi.hasClaudeMd) files.push('CLAUDE.md');
997
+ if (files.length) {
998
+ infoHtml += '<div style="margin-top:10px;font-size:var(--text-xs);color:var(--muted)">Files: ' + files.map(function(f) { return '<span class="badge" style="margin-right:4px">' + esc(f) + '</span>'; }).join('') + '</div>';
999
+ }
1000
+ infoEl.innerHTML = infoHtml;
1001
+ } else {
1002
+ infoSection.style.display = 'none';
1003
+ infoEl.innerHTML = '';
1004
+ }
1005
+ }
1006
+
977
1007
  function sourceBadge(isOverride) {
978
1008
  if (!isProject) return '';
979
1009
  return isOverride
@@ -1189,21 +1219,21 @@ export function renderTasksAndSettingsScript(authToken) {
1189
1219
  }
1190
1220
  list.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">' +
1191
1221
  '<thead><tr style="border-bottom:1px solid var(--border);text-align:left">' +
1192
- '<th style="padding:8px">Session</th><th style="padding:8px">Project</th><th style="padding:8px">Date</th>' +
1193
- '<th style="padding:8px">Duration</th><th style="padding:8px">Findings</th><th style="padding:8px">Status</th></tr></thead><tbody>' +
1222
+ '<th style="padding:8px">Session</th><th style="padding:8px">Project</th><th style="padding:8px">Started</th>' +
1223
+ '<th style="padding:8px">Ended</th><th style="padding:8px">Duration</th><th style="padding:8px">Findings</th></tr></thead><tbody>' +
1194
1224
  sessions.map(function(s) {
1195
1225
  var id = s.sessionId.slice(0, 8);
1196
- var date = (s.startedAt || '').slice(0, 16).replace('T', ' ');
1226
+ var startDate = (s.startedAt || '').slice(0, 16).replace('T', ' ');
1227
+ var endDate = s.endedAt ? (s.endedAt || '').slice(0, 16).replace('T', ' ') : '<span style="color:var(--green)">active</span>';
1197
1228
  var dur = s.durationMins != null ? s.durationMins + 'm' : '—';
1198
- var status = s.status === 'active' ? '<span style="color:var(--green)">● active</span>' : 'ended';
1199
1229
  var summarySnip = s.summary ? '<div class="text-muted" style="font-size:var(--text-xs);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(s.summary.slice(0, 80)) + '</div>' : '';
1200
1230
  return '<tr style="border-bottom:1px solid var(--border);cursor:pointer" data-ts-action="showSessionDetail" data-session-id="' + esc(s.sessionId) + '">' +
1201
1231
  '<td style="padding:8px;font-family:monospace">' + esc(id) + summarySnip + '</td>' +
1202
1232
  '<td style="padding:8px">' + esc(s.project || '—') + '</td>' +
1203
- '<td style="padding:8px">' + esc(date) + '</td>' +
1233
+ '<td style="padding:8px">' + esc(startDate) + '</td>' +
1234
+ '<td style="padding:8px">' + endDate + '</td>' +
1204
1235
  '<td style="padding:8px">' + esc(dur) + '</td>' +
1205
- '<td style="padding:8px">' + (s.findingsAdded || 0) + '</td>' +
1206
- '<td style="padding:8px">' + status + '</td></tr>';
1236
+ '<td style="padding:8px">' + (s.findingsAdded || 0) + '</td></tr>';
1207
1237
  }).join('') + '</tbody></table>';
1208
1238
  }
1209
1239
 
@@ -1359,94 +1389,161 @@ export function renderSearchScript(authToken) {
1359
1389
 
1360
1390
  var esc = window._phrenEsc;
1361
1391
 
1392
+ function relativeDate(iso) {
1393
+ if (!iso) return '';
1394
+ var d = new Date(iso);
1395
+ var now = new Date();
1396
+ var diff = now.getTime() - d.getTime();
1397
+ var days = Math.floor(diff / 86400000);
1398
+ if (days < 1) return 'today';
1399
+ if (days === 1) return '1d ago';
1400
+ if (days < 7) return days + 'd ago';
1401
+ if (days < 30) return Math.floor(days / 7) + 'w ago';
1402
+ var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1403
+ return months[d.getMonth()] + ' ' + d.getDate();
1404
+ }
1405
+
1406
+ // Multi-select project filter
1407
+ var _selectedProjects = [];
1408
+ function getSelectedProjects() { return _selectedProjects.slice(); }
1409
+ function toggleProjectFilter(name) {
1410
+ var idx = _selectedProjects.indexOf(name);
1411
+ if (idx === -1) _selectedProjects.push(name);
1412
+ else _selectedProjects.splice(idx, 1);
1413
+ renderProjectFilterLabel();
1414
+ renderProjectFilterChecks();
1415
+ }
1416
+ function renderProjectFilterLabel() {
1417
+ var btn = document.getElementById('search-project-btn');
1418
+ if (!btn) return;
1419
+ if (!_selectedProjects.length) btn.textContent = 'All projects';
1420
+ else if (_selectedProjects.length === 1) btn.textContent = _selectedProjects[0];
1421
+ else btn.textContent = _selectedProjects.length + ' projects';
1422
+ }
1423
+ function renderProjectFilterChecks() {
1424
+ var items = document.querySelectorAll('#search-project-dropdown input[type=checkbox]');
1425
+ for (var i = 0; i < items.length; i++) {
1426
+ items[i].checked = _selectedProjects.indexOf(items[i].value) !== -1;
1427
+ }
1428
+ }
1429
+ window._phrenToggleProjectDropdown = function() {
1430
+ var dd = document.getElementById('search-project-dropdown');
1431
+ if (dd) dd.style.display = dd.style.display === 'none' ? 'block' : 'none';
1432
+ };
1433
+ document.addEventListener('click', function(e) {
1434
+ var wrap = document.getElementById('search-project-wrap');
1435
+ var dd = document.getElementById('search-project-dropdown');
1436
+ if (wrap && dd && !wrap.contains(e.target)) dd.style.display = 'none';
1437
+ });
1438
+
1439
+ function parseResults(lines) {
1440
+ var cards = [];
1441
+ var current = null;
1442
+ for (var i = 0; i < lines.length; i++) {
1443
+ var line = lines[i];
1444
+ if (!line.trim()) continue;
1445
+ if (line.startsWith('[') && line.indexOf(']') > 0) {
1446
+ if (current) cards.push(current);
1447
+ var bracket = line.indexOf(']');
1448
+ var source = line.slice(1, bracket);
1449
+ var meta = line.slice(bracket + 1).trim();
1450
+ current = { source: source, meta: meta, snippets: [] };
1451
+ } else if (line === '(keyword fallback)') {
1452
+ // skip
1453
+ } else if (current) {
1454
+ current.snippets.push(line);
1455
+ } else {
1456
+ cards.push({ source: '', meta: '', snippets: [line] });
1457
+ }
1458
+ }
1459
+ if (current) cards.push(current);
1460
+ return cards;
1461
+ }
1462
+ function renderCards(cards) {
1463
+ var html = '';
1464
+ for (var c = 0; c < cards.length; c++) {
1465
+ var card = cards[c];
1466
+ html += '<div class="card" style="margin-bottom:8px">';
1467
+ html += '<div class="card-header" style="padding:10px 14px;display:flex;align-items:center">';
1468
+ if (card.source) {
1469
+ html += '<span style="font-weight:500;font-size:var(--text-sm)">' + esc(card.source) + '</span>';
1470
+ }
1471
+ if (card.meta) {
1472
+ html += '<span class="text-muted" style="font-size:var(--text-xs);margin-left:8px">' + esc(card.meta) + '</span>';
1473
+ }
1474
+ html += '</div>';
1475
+ if (card.snippets.length) {
1476
+ html += '<div class="card-body" style="padding:10px 14px;font-size:var(--text-sm);white-space:pre-wrap;color:var(--ink-secondary)">';
1477
+ html += esc(card.snippets.join('\\n'));
1478
+ html += '</div>';
1479
+ }
1480
+ html += '</div>';
1481
+ }
1482
+ return html;
1483
+ }
1484
+
1362
1485
  function doSearch() {
1363
1486
  var q = document.getElementById('search-query').value.trim();
1364
1487
  if (!q) return;
1365
- var project = document.getElementById('search-project-filter').value;
1488
+ var projects = getSelectedProjects();
1366
1489
  var type = document.getElementById('search-type-filter').value;
1367
1490
  var statusEl = document.getElementById('search-status');
1368
1491
  var resultsEl = document.getElementById('search-results');
1369
1492
  statusEl.textContent = 'Searching...';
1370
1493
  resultsEl.innerHTML = '';
1371
1494
 
1372
- var url = '/api/search?q=' + encodeURIComponent(q) + '&limit=20';
1373
- if (project) url += '&project=' + encodeURIComponent(project);
1374
- if (type) url += '&type=' + encodeURIComponent(type);
1375
-
1376
- fetch(searchAuthUrl(url)).then(function(r) { return r.json(); }).then(function(data) {
1377
- if (!data.ok) {
1378
- statusEl.textContent = '';
1379
- resultsEl.innerHTML = '<div style="padding:24px;color:var(--muted);text-align:center">' + esc(data.error || 'Search failed') + '</div>';
1380
- return;
1381
- }
1382
- var lines = data.results || [];
1383
- if (!lines.length) {
1384
- statusEl.textContent = 'No results.';
1385
- resultsEl.innerHTML = '<div style="padding:40px;color:var(--muted);text-align:center">No results for \\u201c' + esc(q) + '\\u201d</div>';
1386
- return;
1495
+ var fetches = [];
1496
+ if (projects.length <= 1) {
1497
+ var url = '/api/search?q=' + encodeURIComponent(q) + '&limit=20';
1498
+ if (projects.length === 1) url += '&project=' + encodeURIComponent(projects[0]);
1499
+ if (type) url += '&type=' + encodeURIComponent(type);
1500
+ fetches.push(fetch(searchAuthUrl(url)).then(function(r) { return r.json(); }));
1501
+ } else {
1502
+ for (var pi = 0; pi < projects.length; pi++) {
1503
+ (function(proj) {
1504
+ var purl = '/api/search?q=' + encodeURIComponent(q) + '&limit=20&project=' + encodeURIComponent(proj);
1505
+ if (type) purl += '&type=' + encodeURIComponent(type);
1506
+ fetches.push(fetch(searchAuthUrl(purl)).then(function(r) { return r.json(); }));
1507
+ })(projects[pi]);
1387
1508
  }
1509
+ }
1388
1510
 
1389
- // Parse lines into result cards: lines starting with [ are headers, others are content
1390
- var cards = [];
1391
- var current = null;
1392
- for (var i = 0; i < lines.length; i++) {
1393
- var line = lines[i];
1394
- if (!line.trim()) continue;
1395
- if (line.startsWith('[') && line.indexOf(']') > 0) {
1396
- if (current) cards.push(current);
1397
- var bracket = line.indexOf(']');
1398
- var source = line.slice(1, bracket);
1399
- var meta = line.slice(bracket + 1).trim();
1400
- current = { source: source, meta: meta, snippets: [] };
1401
- } else if (line === '(keyword fallback)') {
1402
- // skip
1403
- } else if (current) {
1404
- current.snippets.push(line);
1405
- } else {
1406
- // orphan line, show as standalone
1407
- cards.push({ source: '', meta: '', snippets: [line] });
1408
- }
1511
+ Promise.all(fetches).then(function(results) {
1512
+ var allCards = [];
1513
+ var hasError = false;
1514
+ for (var ri = 0; ri < results.length; ri++) {
1515
+ if (!results[ri].ok) { hasError = true; continue; }
1516
+ var parsed = parseResults(results[ri].results || []);
1517
+ allCards = allCards.concat(parsed);
1409
1518
  }
1410
- if (current) cards.push(current);
1411
-
1412
- statusEl.textContent = cards.length + ' result(s)';
1413
- var html = '';
1414
- for (var c = 0; c < cards.length; c++) {
1415
- var card = cards[c];
1416
- html += '<div class="card" style="margin-bottom:8px">';
1417
- html += '<div class="card-header" style="padding:10px 14px">';
1418
- if (card.source) {
1419
- html += '<span style="font-weight:500;font-size:var(--text-sm)">' + esc(card.source) + '</span>';
1420
- }
1421
- if (card.meta) {
1422
- html += '<span class="text-muted" style="font-size:var(--text-xs);margin-left:8px">' + esc(card.meta) + '</span>';
1423
- }
1424
- html += '</div>';
1425
- if (card.snippets.length) {
1426
- html += '<div class="card-body" style="padding:10px 14px;font-size:var(--text-sm);white-space:pre-wrap;color:var(--ink-secondary)">';
1427
- html += esc(card.snippets.join('\\n'));
1428
- html += '</div>';
1429
- }
1430
- html += '</div>';
1519
+ if (!allCards.length) {
1520
+ statusEl.textContent = hasError ? 'Search error.' : 'No results.';
1521
+ resultsEl.innerHTML = '<div style="padding:40px;color:var(--muted);text-align:center">' + (hasError ? 'Search failed' : 'No results for \\u201c' + esc(q) + '\\u201d') + '</div>';
1522
+ return;
1431
1523
  }
1432
- resultsEl.innerHTML = html;
1524
+ statusEl.textContent = allCards.length + ' result(s)';
1525
+ resultsEl.innerHTML = renderCards(allCards);
1433
1526
  }).catch(function(err) {
1434
1527
  statusEl.textContent = '';
1435
1528
  resultsEl.innerHTML = '<div style="padding:24px;color:var(--muted);text-align:center">Search error: ' + esc(String(err)) + '</div>';
1436
1529
  });
1437
1530
  }
1438
1531
 
1439
- // Populate project filter
1532
+ // Populate project filter dropdown
1440
1533
  function loadSearchProjects() {
1441
1534
  if (_searchProjectsLoaded) return;
1442
1535
  _searchProjectsLoaded = true;
1443
1536
  fetch(searchAuthUrl('/api/projects')).then(function(r) { return r.json(); }).then(function(data) {
1444
1537
  if (!data.ok) return;
1445
- var sel = document.getElementById('search-project-filter');
1538
+ var dd = document.getElementById('search-project-dropdown');
1539
+ if (!dd) return;
1540
+ var html = '';
1446
1541
  (data.projects || []).forEach(function(p) {
1447
- var opt = document.createElement('option');
1448
- opt.value = p.name; opt.textContent = p.name;
1449
- sel.appendChild(opt);
1542
+ html += '<label class="search-ms-item" style="display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:var(--text-sm);white-space:nowrap"><input type="checkbox" value="' + esc(p.name) + '" style="accent-color:var(--accent);cursor:pointer" /><span>' + esc(p.name) + '</span></label>';
1543
+ });
1544
+ dd.innerHTML = html;
1545
+ dd.querySelectorAll('input[type=checkbox]').forEach(function(cb) {
1546
+ cb.addEventListener('change', function() { toggleProjectFilter(cb.value); });
1450
1547
  });
1451
1548
  }).catch(function() {});
1452
1549
  }
@@ -1495,13 +1592,24 @@ export function renderEventWiringScript() {
1495
1592
  // --- Review filters ---
1496
1593
  var reviewFilterProject = document.getElementById('review-filter-project');
1497
1594
  if (reviewFilterProject) reviewFilterProject.addEventListener('change', function() { filterReviewCards(); });
1498
- var reviewFilterMachine = document.getElementById('review-filter-machine');
1499
- if (reviewFilterMachine) reviewFilterMachine.addEventListener('change', function() { filterReviewCards(); });
1500
- var reviewFilterModel = document.getElementById('review-filter-model');
1501
- if (reviewFilterModel) reviewFilterModel.addEventListener('change', function() { filterReviewCards(); });
1502
1595
 
1503
1596
  var highlightBtn = document.getElementById('highlight-only-btn');
1504
- if (highlightBtn) highlightBtn.addEventListener('click', function() { toggleHighlightOnly(this); });
1597
+ if (highlightBtn) highlightBtn.addEventListener('change', function() { filterReviewCards(); });
1598
+
1599
+ var selectAllCb = document.getElementById('review-select-all-cb');
1600
+ if (selectAllCb) selectAllCb.addEventListener('change', function() { toggleSelectAll(this.checked); });
1601
+
1602
+ // Batch bar buttons
1603
+ document.addEventListener('click', function(e) {
1604
+ var target = e.target;
1605
+ if (!target || typeof target.closest !== 'function') return;
1606
+ var actionEl = target.closest('[data-batch-action]');
1607
+ if (!actionEl) return;
1608
+ var action = actionEl.getAttribute('data-batch-action');
1609
+ if (action === 'approve') { batchAction('approve'); }
1610
+ else if (action === 'reject') { batchAction('reject'); }
1611
+ else if (action === 'clear') { clearBatchSelection(); }
1612
+ });
1505
1613
 
1506
1614
  // --- Graph controls ---
1507
1615
  var graphZoomIn = document.getElementById('graph-zoom-in');
@@ -1521,6 +1629,20 @@ export function renderEventWiringScript() {
1521
1629
  var sessionsFilterProject = document.getElementById('sessions-filter-project');
1522
1630
  if (sessionsFilterProject) sessionsFilterProject.addEventListener('change', function() { loadSessions(); });
1523
1631
 
1632
+ // --- Mascot click animation ---
1633
+ var mascotSvg = document.querySelector('.header-brand svg');
1634
+ if (mascotSvg) {
1635
+ mascotSvg.addEventListener('click', function() {
1636
+ mascotSvg.classList.remove('popped');
1637
+ void mascotSvg.offsetWidth;
1638
+ mascotSvg.classList.add('popped');
1639
+ mascotSvg.addEventListener('animationend', function handler() {
1640
+ mascotSvg.classList.remove('popped');
1641
+ mascotSvg.removeEventListener('animationend', handler);
1642
+ });
1643
+ });
1644
+ }
1645
+
1524
1646
  // --- Command palette ---
1525
1647
  var cmdpal = document.getElementById('cmdpal');
1526
1648
  if (cmdpal) cmdpal.addEventListener('click', function(e) { closeCmdPal(e); });
@@ -13,7 +13,7 @@ import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isA
13
13
  import { CONSOLIDATION_ENTRY_THRESHOLD } from "./content-validate.js";
14
14
  import { ensureTopicReferenceDoc, getProjectTopicsResponse, listProjectReferenceDocs, pinProjectTopicSuggestion, readReferenceContent, reclassifyLegacyTopicDocs, unpinProjectTopicSuggestion, writeProjectTopics, } from "./project-topics.js";
15
15
  import { getWorkflowPolicy, updateWorkflowPolicy, mergeConfig, getRetentionPolicy, getProjectConfigOverrides, VALID_TASK_MODES } from "./governance-policy.js";
16
- import { updateProjectConfigOverrides } from "./project-config.js";
16
+ import { readProjectConfig, updateProjectConfigOverrides } from "./project-config.js";
17
17
  import { findSkill } from "./skill-registry.js";
18
18
  import { setSkillEnabledAndSync } from "./skill-files.js";
19
19
  import { listAllSessions, getSessionArtifacts } from "./mcp-session.js";
@@ -803,8 +803,31 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
803
803
  try {
804
804
  const { runSearch } = await import("./cli-search.js");
805
805
  const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, phrenPath, profile || "");
806
+ // Build file date map from source headers like [project/filename]
807
+ const fileDates = {};
808
+ for (const line of result.lines) {
809
+ const srcMatch = line.match(/^\[([^\]]+)\]\s/);
810
+ if (srcMatch) {
811
+ const sourceKey = srcMatch[1];
812
+ if (fileDates[sourceKey])
813
+ continue;
814
+ const slashIdx = sourceKey.indexOf("/");
815
+ if (slashIdx > 0) {
816
+ const proj = sourceKey.slice(0, slashIdx);
817
+ const file = sourceKey.slice(slashIdx + 1);
818
+ try {
819
+ const filePath = path.join(phrenPath, proj, file);
820
+ if (fs.existsSync(filePath)) {
821
+ const stat = fs.statSync(filePath);
822
+ fileDates[sourceKey] = stat.mtime.toISOString();
823
+ }
824
+ }
825
+ catch { /* skip */ }
826
+ }
827
+ }
828
+ }
806
829
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
807
- res.end(JSON.stringify({ ok: true, query, results: result.lines }));
830
+ res.end(JSON.stringify({ ok: true, query, results: result.lines, fileDates }));
808
831
  }
809
832
  catch (err) {
810
833
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
@@ -977,6 +1000,37 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
977
1000
  const settingsProject = String(qs.project || "");
978
1001
  const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(phrenPath, settingsProject) : null;
979
1002
  const overrides = settingsProject && isValidProjectName(settingsProject) ? getProjectConfigOverrides(phrenPath, settingsProject) : null;
1003
+ // Build project info when a specific project is selected
1004
+ let projectInfo = null;
1005
+ if (settingsProject && isValidProjectName(settingsProject)) {
1006
+ const projectDir = path.join(phrenPath, settingsProject);
1007
+ const configFile = path.join(projectDir, "phren.project.yaml");
1008
+ const projConfig = readProjectConfig(phrenPath, settingsProject);
1009
+ const findingsPath = path.join(projectDir, "FINDINGS.md");
1010
+ const taskPath = path.join(projectDir, "tasks.md");
1011
+ let findingCount = 0;
1012
+ if (fs.existsSync(findingsPath)) {
1013
+ findingCount = (fs.readFileSync(findingsPath, "utf8").match(/^- /gm) || []).length;
1014
+ }
1015
+ let taskCount = 0;
1016
+ if (fs.existsSync(taskPath)) {
1017
+ const queueMatch = fs.readFileSync(taskPath, "utf8").match(/## Queue[\s\S]*?(?=## |$)/);
1018
+ if (queueMatch)
1019
+ taskCount = (queueMatch[0].match(/^- /gm) || []).length;
1020
+ }
1021
+ projectInfo = {
1022
+ diskPath: projConfig.sourcePath || projectDir,
1023
+ ownership: projConfig.ownership || "default",
1024
+ configFile,
1025
+ configExists: fs.existsSync(configFile),
1026
+ hasFindings: fs.existsSync(findingsPath),
1027
+ hasTasks: fs.existsSync(taskPath),
1028
+ hasSummary: fs.existsSync(path.join(projectDir, "summary.md")),
1029
+ hasClaudeMd: fs.existsSync(path.join(projectDir, "CLAUDE.md")),
1030
+ findingCount,
1031
+ taskCount,
1032
+ };
1033
+ }
980
1034
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
981
1035
  res.end(JSON.stringify({
982
1036
  ok: true,
@@ -994,6 +1048,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
994
1048
  workflowPolicy,
995
1049
  merged,
996
1050
  overrides,
1051
+ projectInfo,
997
1052
  }));
998
1053
  }
999
1054
  catch (err) {
@@ -25,7 +25,7 @@ export const EXTRA_ENTITY_PATTERNS = [
25
25
  { re: /\b\d{4}[-/]\d{2}[-/]\d{2}\b/g, label: "date" },
26
26
  ];
27
27
  /** Union of all directory names reserved by phren infrastructure — not valid project names. */
28
- export const RESERVED_PROJECT_DIR_NAMES = new Set(["global", ".runtime", ".sessions", ".governance", "profiles", "templates"]);
28
+ export const RESERVED_PROJECT_DIR_NAMES = new Set(["global", ".runtime", ".sessions", ".config", "profiles", "templates"]);
29
29
  // Default timeout for execFileSync calls (30s for most operations, 10s for quick probes like `which`)
30
30
  export const EXEC_TIMEOUT_MS = 30_000;
31
31
  export const EXEC_TIMEOUT_QUICK_MS = 10_000;