@phren/cli 0.0.16 → 0.0.18

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.
@@ -86,55 +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>Help: How the Review Queue works</summary>
102
- <dl>
103
- <dt>What is the Review Queue?</dt>
104
- <dd>Fragments flagged by governance for human review. Items accumulate here when <code>phren maintain govern</code> is run.</dd>
105
- <dt>Can I approve, reject, or edit items here?</dt>
106
- <dd>Yes. Each review card has <strong>Approve</strong>, <strong>Reject</strong>, and <strong>Edit</strong> buttons. Approve accepts the fragment, Reject removes it, and Edit lets you revise the text before accepting. You can also use batch actions to approve or reject multiple items at once.</dd>
107
- <dt>How do I clear items?</dt>
108
- <dd>Approve or reject items directly in the UI, or use maintenance flows such as <code>phren maintain prune</code>.</dd>
109
- <dt>Is this automatic?</dt>
110
- <dd>No. Agents do not auto-accept review-queue items.</dd>
111
- <dt>How do items get here?</dt>
112
- <dd><code>phren maintain govern</code> flags stale or low-confidence fragments for review.</dd>
113
- <dt>How to reduce noise?</dt>
114
- <dd>Run <code>phren maintain prune</code> to auto-remove expired items without manual review.</dd>
115
- </dl>
116
- </details>
117
-
118
- <p style="font-size:var(--text-sm);color:var(--muted);margin-bottom:12px;letter-spacing:-0.01em">Fragments flagged for review. Approve, reject, or edit items directly from this tab.</p>
119
-
120
- <div id="review-summary-banner" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center"></div>
121
-
122
- <div class="review-filters" id="review-filters" style="display:none">
89
+ <div class="review-toolbar" id="review-filters" style="display:none">
123
90
  <select id="review-filter-project">
124
91
  <option value="">All projects</option>
125
92
  </select>
126
- <select id="review-filter-machine">
127
- <option value="">All machines</option>
128
- </select>
129
- <select id="review-filter-model">
130
- <option value="">All models</option>
131
- </select>
132
- <span id="review-filter-count" class="text-muted" style="font-size:var(--text-sm);margin-left:8px"></span>
133
- <button class="btn btn-sm" id="highlight-only-btn" style="margin-left:auto">Flagged only</button>
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" onchange="toggleSelectAll(this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:var(--accent)" />
100
+ Select all
101
+ </label>
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>
134
105
  </div>
135
106
 
136
- <div id="review-kbd-hints" style="font-size:var(--text-xs);color:var(--muted);margin-bottom:12px;display:none;gap:16px;flex-wrap:wrap">
137
- <span><kbd>j</kbd>/<kbd>k</kbd> navigate</span>
107
+ <div id="batch-bar" class="batch-bar">
108
+ <span id="batch-count" class="batch-bar-count"></span>
109
+ <button class="btn btn-sm btn-approve" onclick="batchAction('approve')">Approve selected</button>
110
+ <button class="btn btn-sm btn-reject" onclick="batchAction('reject')">Reject selected</button>
111
+ <button class="btn btn-sm" onclick="clearBatchSelection()">Clear</button>
138
112
  </div>
139
113
 
140
114
  <div class="review-cards" id="review-cards-list">
@@ -158,9 +132,10 @@ ${TASK_UI_STYLES}
158
132
  <div style="max-width:720px;margin:0 auto">
159
133
  <div style="display:flex;gap:8px;margin-bottom:16px">
160
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" />
161
- <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)">
162
- <option value="">All projects</option>
163
- </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>
164
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)">
165
140
  <option value="">All types</option>
166
141
  <option value="finding">Findings</option>
@@ -224,7 +199,7 @@ ${TASK_UI_STYLES}
224
199
  <div style="padding:20px;color:var(--muted)">Loading...</div>
225
200
  </div>
226
201
  <div class="split-reader" id="hooks-reader">
227
- <div class="reader-empty">Select a hook config to view its contents.</div>
202
+ <div class="reader-empty">Select a hook config to view its contents.<br/><span style="font-size:var(--text-sm);color:var(--muted);margin-top:8px;display:inline-block">Per-project hooks can also be configured in Settings &gt; [project name].</span></div>
228
203
  </div>
229
204
  </div>
230
205
  </div>
@@ -277,6 +252,12 @@ ${TASK_UI_STYLES}
277
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>
278
253
  </div>
279
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>
280
261
  <section class="settings-section settings-section-findings">
281
262
  <div class="settings-section-header">Findings</div>
282
263
  <div class="settings-section-body">
@@ -605,6 +605,8 @@ export function renderTasksAndSettingsScript(authToken) {
605
605
  return fetch(url).then(function(r) { return r.json(); });
606
606
  }
607
607
 
608
+ var _taskViewMode = 'list';
609
+
608
610
  function priorityBadge(p) {
609
611
  if (!p) return '';
610
612
  var colors = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' };
@@ -612,6 +614,12 @@ export function renderTasksAndSettingsScript(authToken) {
612
614
  return '<span class="task-priority-badge task-priority-' + esc(p) + '">' + esc(p) + '</span>';
613
615
  }
614
616
 
617
+ function sessionBadge(sessionId) {
618
+ if (!sessionId) return '';
619
+ var short = sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
620
+ return '<span class="task-session-badge" title="Session ' + esc(sessionId) + '">' + esc(short) + '</span>';
621
+ }
622
+
615
623
  function statusChip(section, checked) {
616
624
  if (checked || section === 'Done') return '<span class="task-status-chip task-status-done" title="Done"><svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3.5 3.5 6.5-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Done</span>';
617
625
  if (section === 'Active') return '<span class="task-status-chip task-status-active" title="In Progress">In Progress</span>';
@@ -731,10 +739,16 @@ export function renderTasksAndSettingsScript(authToken) {
731
739
  // Group by priority within sections
732
740
  var priorityOrder = { high: 0, medium: 1, low: 2 };
733
741
  function sortByPriority(a, b) {
734
- var pa = priorityOrder[a.priority] !== undefined ? priorityOrder[a.priority] : 1;
735
- var pb = priorityOrder[b.priority] !== undefined ? priorityOrder[b.priority] : 1;
742
+ // Unchecked before checked
743
+ var aChecked = a.checked || a.section === 'Done' ? 1 : 0;
744
+ var bChecked = b.checked || b.section === 'Done' ? 1 : 0;
745
+ if (aChecked !== bChecked) return aChecked - bChecked;
746
+ // Pinned first
736
747
  if (a.pinned && !b.pinned) return -1;
737
748
  if (!a.pinned && b.pinned) return 1;
749
+ // Then by priority
750
+ var pa = priorityOrder[a.priority] !== undefined ? priorityOrder[a.priority] : 1;
751
+ var pb = priorityOrder[b.priority] !== undefined ? priorityOrder[b.priority] : 1;
738
752
  return pa - pb;
739
753
  }
740
754
 
@@ -756,6 +770,7 @@ export function renderTasksAndSettingsScript(authToken) {
756
770
  html += pinIndicator(t.pinned);
757
771
  html += githubBadge(t.githubIssue, t.githubUrl);
758
772
  html += priorityBadge(t.priority);
773
+ html += sessionBadge(t.sessionId);
759
774
  html += '</div>';
760
775
  html += '<div class="task-card-body">';
761
776
  html += '<span class="task-card-text">' + esc(t.line) + '</span>';
@@ -773,6 +788,30 @@ export function renderTasksAndSettingsScript(authToken) {
773
788
 
774
789
  var html = '';
775
790
 
791
+ // Summary bar
792
+ var allActive = _allTasks.filter(function(t) { return t.section !== 'Done' && !t.checked; });
793
+ var highCount = allActive.filter(function(t) { return t.priority === 'high'; }).length;
794
+ var medCount = allActive.filter(function(t) { return t.priority === 'medium'; }).length;
795
+ var lowCount = allActive.filter(function(t) { return t.priority === 'low'; }).length;
796
+ var projectCounts = {};
797
+ allActive.forEach(function(t) { projectCounts[t.project] = (projectCounts[t.project] || 0) + 1; });
798
+ var topProjects = Object.keys(projectCounts).sort(function(a, b) { return projectCounts[b] - projectCounts[a]; }).slice(0, 3);
799
+ html += '<div class="task-summary-bar">';
800
+ html += '<span class="task-summary-total">' + allActive.length + ' active</span>';
801
+ if (highCount) html += '<span class="task-summary-pill task-summary-high">' + highCount + ' high</span>';
802
+ if (medCount) html += '<span class="task-summary-pill task-summary-medium">' + medCount + ' medium</span>';
803
+ if (lowCount) html += '<span class="task-summary-pill task-summary-low">' + lowCount + ' low</span>';
804
+ if (topProjects.length) {
805
+ html += '<span class="task-summary-projects">';
806
+ topProjects.forEach(function(p) { html += '<span class="task-summary-project">' + esc(p) + ' (' + projectCounts[p] + ')</span>'; });
807
+ html += '</span>';
808
+ }
809
+ html += '<span class="task-view-toggle" style="margin-left:auto">';
810
+ html += '<button class="task-view-btn' + (_taskViewMode === 'list' ? ' active' : '') + '" data-ts-action="setTaskView" data-mode="list" title="List view"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>';
811
+ html += '<button class="task-view-btn' + (_taskViewMode === 'compact' ? ' active' : '') + '" data-ts-action="setTaskView" data-mode="compact" title="Compact view"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="9" y="2" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="1" y="9" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="9" y="9" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/></svg></button>';
812
+ html += '</span>';
813
+ html += '</div>';
814
+
776
815
  // Add task input at top (only when a specific project is selected)
777
816
  var projects = projectFilter ? [projectFilter] : [];
778
817
  projects.forEach(function(proj) {
@@ -785,9 +824,10 @@ export function renderTasksAndSettingsScript(authToken) {
785
824
  // Priority sections
786
825
  function renderSection(title, items, icon) {
787
826
  if (!items.length) return '';
827
+ var gridClass = _taskViewMode === 'compact' ? 'task-card-grid task-card-grid-compact' : 'task-card-grid';
788
828
  var shtml = '<div class="task-priority-section">';
789
829
  shtml += '<div class="task-section-header"><span class="task-section-icon">' + icon + '</span> ' + title + ' <span class="task-section-count">' + items.length + '</span></div>';
790
- shtml += '<div class="task-card-grid">';
830
+ shtml += '<div class="' + gridClass + '">';
791
831
  items.forEach(function(t) { shtml += renderTaskCard(t); });
792
832
  shtml += '</div></div>';
793
833
  return shtml;
@@ -934,6 +974,35 @@ export function renderTasksAndSettingsScript(authToken) {
934
974
 
935
975
  var isProject = Boolean(selectedProject);
936
976
 
977
+ // Render project info section
978
+ var infoSection = document.getElementById('settings-project-info-section');
979
+ var infoEl = document.getElementById('settings-project-info');
980
+ if (infoSection && infoEl) {
981
+ if (isProject && data.projectInfo) {
982
+ var pi = data.projectInfo;
983
+ infoSection.style.display = '';
984
+ var infoHtml = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;font-size:var(--text-sm)">';
985
+ 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>';
986
+ infoHtml += '<div><strong style="color:var(--ink)">Ownership</strong><div class="text-muted">' + esc(pi.ownership) + '</div></div>';
987
+ 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>';
988
+ infoHtml += '<div><strong style="color:var(--ink)">Findings</strong><div class="text-muted">' + pi.findingCount + ' entries</div></div>';
989
+ infoHtml += '<div><strong style="color:var(--ink)">Tasks</strong><div class="text-muted">' + pi.taskCount + ' in queue</div></div>';
990
+ infoHtml += '</div>';
991
+ var files = [];
992
+ if (pi.hasFindings) files.push('FINDINGS.md');
993
+ if (pi.hasTasks) files.push('tasks.md');
994
+ if (pi.hasSummary) files.push('summary.md');
995
+ if (pi.hasClaudeMd) files.push('CLAUDE.md');
996
+ if (files.length) {
997
+ 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>';
998
+ }
999
+ infoEl.innerHTML = infoHtml;
1000
+ } else {
1001
+ infoSection.style.display = 'none';
1002
+ infoEl.innerHTML = '';
1003
+ }
1004
+ }
1005
+
937
1006
  function sourceBadge(isOverride) {
938
1007
  if (!isProject) return '';
939
1008
  return isOverride
@@ -1149,21 +1218,21 @@ export function renderTasksAndSettingsScript(authToken) {
1149
1218
  }
1150
1219
  list.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">' +
1151
1220
  '<thead><tr style="border-bottom:1px solid var(--border);text-align:left">' +
1152
- '<th style="padding:8px">Session</th><th style="padding:8px">Project</th><th style="padding:8px">Date</th>' +
1153
- '<th style="padding:8px">Duration</th><th style="padding:8px">Findings</th><th style="padding:8px">Status</th></tr></thead><tbody>' +
1221
+ '<th style="padding:8px">Session</th><th style="padding:8px">Project</th><th style="padding:8px">Started</th>' +
1222
+ '<th style="padding:8px">Ended</th><th style="padding:8px">Duration</th><th style="padding:8px">Findings</th></tr></thead><tbody>' +
1154
1223
  sessions.map(function(s) {
1155
1224
  var id = s.sessionId.slice(0, 8);
1156
- var date = (s.startedAt || '').slice(0, 16).replace('T', ' ');
1225
+ var startDate = (s.startedAt || '').slice(0, 16).replace('T', ' ');
1226
+ var endDate = s.endedAt ? (s.endedAt || '').slice(0, 16).replace('T', ' ') : '<span style="color:var(--green)">active</span>';
1157
1227
  var dur = s.durationMins != null ? s.durationMins + 'm' : '—';
1158
- var status = s.status === 'active' ? '<span style="color:var(--green)">● active</span>' : 'ended';
1159
1228
  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>' : '';
1160
1229
  return '<tr style="border-bottom:1px solid var(--border);cursor:pointer" data-ts-action="showSessionDetail" data-session-id="' + esc(s.sessionId) + '">' +
1161
1230
  '<td style="padding:8px;font-family:monospace">' + esc(id) + summarySnip + '</td>' +
1162
1231
  '<td style="padding:8px">' + esc(s.project || '—') + '</td>' +
1163
- '<td style="padding:8px">' + esc(date) + '</td>' +
1232
+ '<td style="padding:8px">' + esc(startDate) + '</td>' +
1233
+ '<td style="padding:8px">' + endDate + '</td>' +
1164
1234
  '<td style="padding:8px">' + esc(dur) + '</td>' +
1165
- '<td style="padding:8px">' + (s.findingsAdded || 0) + '</td>' +
1166
- '<td style="padding:8px">' + status + '</td></tr>';
1235
+ '<td style="padding:8px">' + (s.findingsAdded || 0) + '</td></tr>';
1167
1236
  }).join('') + '</tbody></table>';
1168
1237
  }
1169
1238
 
@@ -1228,6 +1297,7 @@ export function renderTasksAndSettingsScript(authToken) {
1228
1297
  else if (action === 'completeTask') { completeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
1229
1298
  else if (action === 'removeTask') { removeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
1230
1299
  else if (action === 'addTask') { addTaskFromUi(actionEl.getAttribute('data-project')); }
1300
+ else if (action === 'setTaskView') { _taskViewMode = actionEl.getAttribute('data-mode') || 'list'; filterTasks(); }
1231
1301
  else if (action === 'setFindingSensitivity') { setFindingSensitivity(actionEl.getAttribute('data-level')); }
1232
1302
  else if (action === 'toggleAutoCapture') { setAutoCapture(actionEl.getAttribute('data-enabled') !== 'true'); }
1233
1303
  else if (action === 'setTaskMode') { setTaskMode(actionEl.getAttribute('data-mode')); }
@@ -1318,94 +1388,161 @@ export function renderSearchScript(authToken) {
1318
1388
 
1319
1389
  var esc = window._phrenEsc;
1320
1390
 
1391
+ function relativeDate(iso) {
1392
+ if (!iso) return '';
1393
+ var d = new Date(iso);
1394
+ var now = new Date();
1395
+ var diff = now.getTime() - d.getTime();
1396
+ var days = Math.floor(diff / 86400000);
1397
+ if (days < 1) return 'today';
1398
+ if (days === 1) return '1d ago';
1399
+ if (days < 7) return days + 'd ago';
1400
+ if (days < 30) return Math.floor(days / 7) + 'w ago';
1401
+ var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1402
+ return months[d.getMonth()] + ' ' + d.getDate();
1403
+ }
1404
+
1405
+ // Multi-select project filter
1406
+ var _selectedProjects = [];
1407
+ function getSelectedProjects() { return _selectedProjects.slice(); }
1408
+ function toggleProjectFilter(name) {
1409
+ var idx = _selectedProjects.indexOf(name);
1410
+ if (idx === -1) _selectedProjects.push(name);
1411
+ else _selectedProjects.splice(idx, 1);
1412
+ renderProjectFilterLabel();
1413
+ renderProjectFilterChecks();
1414
+ }
1415
+ function renderProjectFilterLabel() {
1416
+ var btn = document.getElementById('search-project-btn');
1417
+ if (!btn) return;
1418
+ if (!_selectedProjects.length) btn.textContent = 'All projects';
1419
+ else if (_selectedProjects.length === 1) btn.textContent = _selectedProjects[0];
1420
+ else btn.textContent = _selectedProjects.length + ' projects';
1421
+ }
1422
+ function renderProjectFilterChecks() {
1423
+ var items = document.querySelectorAll('#search-project-dropdown input[type=checkbox]');
1424
+ for (var i = 0; i < items.length; i++) {
1425
+ items[i].checked = _selectedProjects.indexOf(items[i].value) !== -1;
1426
+ }
1427
+ }
1428
+ window._phrenToggleProjectDropdown = function() {
1429
+ var dd = document.getElementById('search-project-dropdown');
1430
+ if (dd) dd.style.display = dd.style.display === 'none' ? 'block' : 'none';
1431
+ };
1432
+ document.addEventListener('click', function(e) {
1433
+ var wrap = document.getElementById('search-project-wrap');
1434
+ var dd = document.getElementById('search-project-dropdown');
1435
+ if (wrap && dd && !wrap.contains(e.target)) dd.style.display = 'none';
1436
+ });
1437
+
1438
+ function parseResults(lines) {
1439
+ var cards = [];
1440
+ var current = null;
1441
+ for (var i = 0; i < lines.length; i++) {
1442
+ var line = lines[i];
1443
+ if (!line.trim()) continue;
1444
+ if (line.startsWith('[') && line.indexOf(']') > 0) {
1445
+ if (current) cards.push(current);
1446
+ var bracket = line.indexOf(']');
1447
+ var source = line.slice(1, bracket);
1448
+ var meta = line.slice(bracket + 1).trim();
1449
+ current = { source: source, meta: meta, snippets: [] };
1450
+ } else if (line === '(keyword fallback)') {
1451
+ // skip
1452
+ } else if (current) {
1453
+ current.snippets.push(line);
1454
+ } else {
1455
+ cards.push({ source: '', meta: '', snippets: [line] });
1456
+ }
1457
+ }
1458
+ if (current) cards.push(current);
1459
+ return cards;
1460
+ }
1461
+ function renderCards(cards) {
1462
+ var html = '';
1463
+ for (var c = 0; c < cards.length; c++) {
1464
+ var card = cards[c];
1465
+ html += '<div class="card" style="margin-bottom:8px">';
1466
+ html += '<div class="card-header" style="padding:10px 14px;display:flex;align-items:center">';
1467
+ if (card.source) {
1468
+ html += '<span style="font-weight:500;font-size:var(--text-sm)">' + esc(card.source) + '</span>';
1469
+ }
1470
+ if (card.meta) {
1471
+ html += '<span class="text-muted" style="font-size:var(--text-xs);margin-left:8px">' + esc(card.meta) + '</span>';
1472
+ }
1473
+ html += '</div>';
1474
+ if (card.snippets.length) {
1475
+ html += '<div class="card-body" style="padding:10px 14px;font-size:var(--text-sm);white-space:pre-wrap;color:var(--ink-secondary)">';
1476
+ html += esc(card.snippets.join('\\n'));
1477
+ html += '</div>';
1478
+ }
1479
+ html += '</div>';
1480
+ }
1481
+ return html;
1482
+ }
1483
+
1321
1484
  function doSearch() {
1322
1485
  var q = document.getElementById('search-query').value.trim();
1323
1486
  if (!q) return;
1324
- var project = document.getElementById('search-project-filter').value;
1487
+ var projects = getSelectedProjects();
1325
1488
  var type = document.getElementById('search-type-filter').value;
1326
1489
  var statusEl = document.getElementById('search-status');
1327
1490
  var resultsEl = document.getElementById('search-results');
1328
1491
  statusEl.textContent = 'Searching...';
1329
1492
  resultsEl.innerHTML = '';
1330
1493
 
1331
- var url = '/api/search?q=' + encodeURIComponent(q) + '&limit=20';
1332
- if (project) url += '&project=' + encodeURIComponent(project);
1333
- if (type) url += '&type=' + encodeURIComponent(type);
1334
-
1335
- fetch(searchAuthUrl(url)).then(function(r) { return r.json(); }).then(function(data) {
1336
- if (!data.ok) {
1337
- statusEl.textContent = '';
1338
- resultsEl.innerHTML = '<div style="padding:24px;color:var(--muted);text-align:center">' + esc(data.error || 'Search failed') + '</div>';
1339
- return;
1340
- }
1341
- var lines = data.results || [];
1342
- if (!lines.length) {
1343
- statusEl.textContent = 'No results.';
1344
- resultsEl.innerHTML = '<div style="padding:40px;color:var(--muted);text-align:center">No results for \\u201c' + esc(q) + '\\u201d</div>';
1345
- return;
1494
+ var fetches = [];
1495
+ if (projects.length <= 1) {
1496
+ var url = '/api/search?q=' + encodeURIComponent(q) + '&limit=20';
1497
+ if (projects.length === 1) url += '&project=' + encodeURIComponent(projects[0]);
1498
+ if (type) url += '&type=' + encodeURIComponent(type);
1499
+ fetches.push(fetch(searchAuthUrl(url)).then(function(r) { return r.json(); }));
1500
+ } else {
1501
+ for (var pi = 0; pi < projects.length; pi++) {
1502
+ (function(proj) {
1503
+ var purl = '/api/search?q=' + encodeURIComponent(q) + '&limit=20&project=' + encodeURIComponent(proj);
1504
+ if (type) purl += '&type=' + encodeURIComponent(type);
1505
+ fetches.push(fetch(searchAuthUrl(purl)).then(function(r) { return r.json(); }));
1506
+ })(projects[pi]);
1346
1507
  }
1508
+ }
1347
1509
 
1348
- // Parse lines into result cards: lines starting with [ are headers, others are content
1349
- var cards = [];
1350
- var current = null;
1351
- for (var i = 0; i < lines.length; i++) {
1352
- var line = lines[i];
1353
- if (!line.trim()) continue;
1354
- if (line.startsWith('[') && line.indexOf(']') > 0) {
1355
- if (current) cards.push(current);
1356
- var bracket = line.indexOf(']');
1357
- var source = line.slice(1, bracket);
1358
- var meta = line.slice(bracket + 1).trim();
1359
- current = { source: source, meta: meta, snippets: [] };
1360
- } else if (line === '(keyword fallback)') {
1361
- // skip
1362
- } else if (current) {
1363
- current.snippets.push(line);
1364
- } else {
1365
- // orphan line, show as standalone
1366
- cards.push({ source: '', meta: '', snippets: [line] });
1367
- }
1510
+ Promise.all(fetches).then(function(results) {
1511
+ var allCards = [];
1512
+ var hasError = false;
1513
+ for (var ri = 0; ri < results.length; ri++) {
1514
+ if (!results[ri].ok) { hasError = true; continue; }
1515
+ var parsed = parseResults(results[ri].results || []);
1516
+ allCards = allCards.concat(parsed);
1368
1517
  }
1369
- if (current) cards.push(current);
1370
-
1371
- statusEl.textContent = cards.length + ' result(s)';
1372
- var html = '';
1373
- for (var c = 0; c < cards.length; c++) {
1374
- var card = cards[c];
1375
- html += '<div class="card" style="margin-bottom:8px">';
1376
- html += '<div class="card-header" style="padding:10px 14px">';
1377
- if (card.source) {
1378
- html += '<span style="font-weight:500;font-size:var(--text-sm)">' + esc(card.source) + '</span>';
1379
- }
1380
- if (card.meta) {
1381
- html += '<span class="text-muted" style="font-size:var(--text-xs);margin-left:8px">' + esc(card.meta) + '</span>';
1382
- }
1383
- html += '</div>';
1384
- if (card.snippets.length) {
1385
- html += '<div class="card-body" style="padding:10px 14px;font-size:var(--text-sm);white-space:pre-wrap;color:var(--ink-secondary)">';
1386
- html += esc(card.snippets.join('\\n'));
1387
- html += '</div>';
1388
- }
1389
- html += '</div>';
1518
+ if (!allCards.length) {
1519
+ statusEl.textContent = hasError ? 'Search error.' : 'No results.';
1520
+ resultsEl.innerHTML = '<div style="padding:40px;color:var(--muted);text-align:center">' + (hasError ? 'Search failed' : 'No results for \\u201c' + esc(q) + '\\u201d') + '</div>';
1521
+ return;
1390
1522
  }
1391
- resultsEl.innerHTML = html;
1523
+ statusEl.textContent = allCards.length + ' result(s)';
1524
+ resultsEl.innerHTML = renderCards(allCards);
1392
1525
  }).catch(function(err) {
1393
1526
  statusEl.textContent = '';
1394
1527
  resultsEl.innerHTML = '<div style="padding:24px;color:var(--muted);text-align:center">Search error: ' + esc(String(err)) + '</div>';
1395
1528
  });
1396
1529
  }
1397
1530
 
1398
- // Populate project filter
1531
+ // Populate project filter dropdown
1399
1532
  function loadSearchProjects() {
1400
1533
  if (_searchProjectsLoaded) return;
1401
1534
  _searchProjectsLoaded = true;
1402
1535
  fetch(searchAuthUrl('/api/projects')).then(function(r) { return r.json(); }).then(function(data) {
1403
1536
  if (!data.ok) return;
1404
- var sel = document.getElementById('search-project-filter');
1537
+ var dd = document.getElementById('search-project-dropdown');
1538
+ if (!dd) return;
1539
+ var html = '';
1405
1540
  (data.projects || []).forEach(function(p) {
1406
- var opt = document.createElement('option');
1407
- opt.value = p.name; opt.textContent = p.name;
1408
- sel.appendChild(opt);
1541
+ 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>';
1542
+ });
1543
+ dd.innerHTML = html;
1544
+ dd.querySelectorAll('input[type=checkbox]').forEach(function(cb) {
1545
+ cb.addEventListener('change', function() { toggleProjectFilter(cb.value); });
1409
1546
  });
1410
1547
  }).catch(function() {});
1411
1548
  }
@@ -1454,13 +1591,9 @@ export function renderEventWiringScript() {
1454
1591
  // --- Review filters ---
1455
1592
  var reviewFilterProject = document.getElementById('review-filter-project');
1456
1593
  if (reviewFilterProject) reviewFilterProject.addEventListener('change', function() { filterReviewCards(); });
1457
- var reviewFilterMachine = document.getElementById('review-filter-machine');
1458
- if (reviewFilterMachine) reviewFilterMachine.addEventListener('change', function() { filterReviewCards(); });
1459
- var reviewFilterModel = document.getElementById('review-filter-model');
1460
- if (reviewFilterModel) reviewFilterModel.addEventListener('change', function() { filterReviewCards(); });
1461
1594
 
1462
1595
  var highlightBtn = document.getElementById('highlight-only-btn');
1463
- if (highlightBtn) highlightBtn.addEventListener('click', function() { toggleHighlightOnly(this); });
1596
+ if (highlightBtn) highlightBtn.addEventListener('change', function() { filterReviewCards(); });
1464
1597
 
1465
1598
  // --- Graph controls ---
1466
1599
  var graphZoomIn = document.getElementById('graph-zoom-in');
@@ -1480,6 +1613,20 @@ export function renderEventWiringScript() {
1480
1613
  var sessionsFilterProject = document.getElementById('sessions-filter-project');
1481
1614
  if (sessionsFilterProject) sessionsFilterProject.addEventListener('change', function() { loadSessions(); });
1482
1615
 
1616
+ // --- Mascot click animation ---
1617
+ var mascotSvg = document.querySelector('.header-brand svg');
1618
+ if (mascotSvg) {
1619
+ mascotSvg.addEventListener('click', function() {
1620
+ mascotSvg.classList.remove('popped');
1621
+ void mascotSvg.offsetWidth;
1622
+ mascotSvg.classList.add('popped');
1623
+ mascotSvg.addEventListener('animationend', function handler() {
1624
+ mascotSvg.classList.remove('popped');
1625
+ mascotSvg.removeEventListener('animationend', handler);
1626
+ });
1627
+ });
1628
+ }
1629
+
1483
1630
  // --- Command palette ---
1484
1631
  var cmdpal = document.getElementById('cmdpal');
1485
1632
  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" });
@@ -854,6 +877,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
854
877
  githubUrl: item.githubUrl,
855
878
  context: item.context,
856
879
  checked: item.checked,
880
+ sessionId: item.sessionId,
857
881
  });
858
882
  }
859
883
  }
@@ -976,6 +1000,37 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
976
1000
  const settingsProject = String(qs.project || "");
977
1001
  const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(phrenPath, settingsProject) : null;
978
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
+ }
979
1034
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
980
1035
  res.end(JSON.stringify({
981
1036
  ok: true,
@@ -993,6 +1048,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
993
1048
  workflowPolicy,
994
1049
  merged,
995
1050
  overrides,
1051
+ projectInfo,
996
1052
  }));
997
1053
  }
998
1054
  catch (err) {