mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
COD-9 add cross-session search frontend (history-panel search box) v1
Search box + grouped result cards + filters folded into the welcome/history panel, wired to GET /api/search. Debounced query (250ms), type-filter chips (session/event/file), client-side case/status/date filters, grouped cards (badge, name, timestamp, snippet) with jump-to (session->selectSession, run-summary->openRunSummary, file-preview->openFilePreview), empty-state + truncated notice. All result text via textContent (no XSS surface). Files: index.html (panel markup), terminal-ui.js (search mixin + initSearchPanel), styles.css (.search-* styles).
This commit is contained in:
@@ -311,7 +311,46 @@
|
||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||
</div>
|
||||
<div class="history-sessions" id="historySessions" style="display:none">
|
||||
<h3 class="history-title">Resume Conversation</h3>
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-input-row">
|
||||
<input
|
||||
type="search"
|
||||
id="searchInput"
|
||||
class="search-input"
|
||||
placeholder="Search sessions, events, files…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="200"
|
||||
aria-label="Search across sessions"
|
||||
/>
|
||||
<button type="button" id="searchClearBtn" class="search-clear-btn" aria-label="Clear search" hidden>×</button>
|
||||
</div>
|
||||
<div class="search-filters" id="searchFilters">
|
||||
<div class="search-filter-group" role="group" aria-label="Source type filter">
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="session">Sessions</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="event">Events</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="file">Files</button>
|
||||
</div>
|
||||
<div class="search-filter-group search-filter-secondary">
|
||||
<select id="searchCaseFilter" class="search-select" aria-label="Filter by case">
|
||||
<option value="">All cases</option>
|
||||
</select>
|
||||
<select id="searchStatusFilter" class="search-select" aria-label="Filter by session status">
|
||||
<option value="">Any status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="history">History</option>
|
||||
</select>
|
||||
<select id="searchDateFilter" class="search-select" aria-label="Filter by date range">
|
||||
<option value="">Any time</option>
|
||||
<option value="1">Past 24h</option>
|
||||
<option value="7">Past 7 days</option>
|
||||
<option value="30">Past 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results" id="searchResults" hidden></div>
|
||||
</div>
|
||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
||||
|
||||
@@ -2466,6 +2466,264 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* ── COD-9 cross-session search (folded into the welcome history panel) ── */
|
||||
.search-panel {
|
||||
width: 100%;
|
||||
margin-bottom: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search-input-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.55rem 2rem 0.55rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-smooth), background var(--transition-smooth);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-filter-group {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-filter-secondary {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-filter-chip {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.search-filter-chip:hover {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-filter-chip.active {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #cdddff;
|
||||
}
|
||||
|
||||
.search-select {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
padding: 0.28rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
max-width: 9rem;
|
||||
}
|
||||
|
||||
.search-select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.7rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-results[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.search-group-header:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.search-group-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-group-count {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.4rem;
|
||||
}
|
||||
|
||||
.search-result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.search-result-card:hover,
|
||||
.search-result-card:focus-visible {
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-result-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-result-badge {
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-badge-session {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: #9dc0ff;
|
||||
}
|
||||
|
||||
.search-badge-event {
|
||||
background: rgba(168, 85, 247, 0.18);
|
||||
color: #d4b3ff;
|
||||
}
|
||||
|
||||
.search-badge-file {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
color: #95e6b3;
|
||||
}
|
||||
|
||||
.search-result-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-result-time {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-result-snippet {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.search-empty,
|
||||
.search-truncated {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0.6rem 0.2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search-truncated {
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
margin-top: 0.3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-filter-secondary {
|
||||
margin-left: 0;
|
||||
}
|
||||
.search-select {
|
||||
max-width: 7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -983,6 +983,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
overlay.classList.add('visible');
|
||||
this.loadTunnelStatus();
|
||||
this.loadHistorySessions();
|
||||
this.initSearchPanel();
|
||||
}
|
||||
// Home screen has no input target — hide the CJK textarea (activeSessionId
|
||||
// is null by the time we get here). Guarded: defined on the app object.
|
||||
@@ -2253,3 +2254,338 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// COD-9 — Cross-session search (folded into the welcome history panel)
|
||||
// Consumes GET /api/search; renders grouped result cards with jump-to actions.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
(function (global) {
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
const SEARCH_LIMIT = 60;
|
||||
const SOURCE_LABELS = { session: 'Sessions', event: 'Events', file: 'Files' };
|
||||
|
||||
/** Human-friendly relative-ish timestamp matching the history panel's style. */
|
||||
function formatSearchTime(ts) {
|
||||
if (!Number.isFinite(ts)) return '';
|
||||
const d = new Date(ts);
|
||||
return (
|
||||
d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
);
|
||||
}
|
||||
|
||||
global.CodemanSearch = { SEARCH_DEBOUNCE_MS, SEARCH_LIMIT, SOURCE_LABELS, formatSearchTime };
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Wire up the search box, filter chips, and selects inside the welcome
|
||||
* history panel. Idempotent — safe to call every time the overlay opens.
|
||||
*/
|
||||
initSearchPanel() {
|
||||
const input = document.getElementById('searchInput');
|
||||
if (!input || this._searchPanelWired) {
|
||||
// Even when already wired, refresh the case dropdown (cases may have loaded since).
|
||||
if (this._searchPanelWired) this._populateSearchCaseFilter();
|
||||
return;
|
||||
}
|
||||
this._searchPanelWired = true;
|
||||
|
||||
// Active source-type filter set (mirrors the chip .active state → types= param).
|
||||
this._searchTypes = new Set(['session', 'event', 'file']);
|
||||
this._searchSecondary = { caseLabel: '', status: '', days: '' };
|
||||
this._searchDebounceTimer = null;
|
||||
this._searchSeq = 0;
|
||||
this._searchLastData = null;
|
||||
|
||||
const clearBtn = document.getElementById('searchClearBtn');
|
||||
const results = document.getElementById('searchResults');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
if (clearBtn) clearBtn.hidden = input.value.length === 0;
|
||||
this._scheduleSearch();
|
||||
});
|
||||
input.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape' && input.value) {
|
||||
ev.stopPropagation();
|
||||
this._clearSearch();
|
||||
}
|
||||
});
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this._clearSearch());
|
||||
}
|
||||
|
||||
document.querySelectorAll('#searchFilters .search-filter-chip').forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
const t = chip.dataset.typeFilter;
|
||||
// Keep at least one type selected.
|
||||
if (this._searchTypes.has(t) && this._searchTypes.size === 1) return;
|
||||
if (this._searchTypes.has(t)) {
|
||||
this._searchTypes.delete(t);
|
||||
chip.classList.remove('active');
|
||||
} else {
|
||||
this._searchTypes.add(t);
|
||||
chip.classList.add('active');
|
||||
}
|
||||
this._runSearch();
|
||||
});
|
||||
});
|
||||
|
||||
const caseSel = document.getElementById('searchCaseFilter');
|
||||
const statusSel = document.getElementById('searchStatusFilter');
|
||||
const dateSel = document.getElementById('searchDateFilter');
|
||||
if (caseSel) {
|
||||
caseSel.addEventListener('change', () => {
|
||||
this._searchSecondary.caseLabel = caseSel.value;
|
||||
this._renderSearch(this._searchLastData);
|
||||
});
|
||||
}
|
||||
if (statusSel) {
|
||||
statusSel.addEventListener('change', () => {
|
||||
this._searchSecondary.status = statusSel.value;
|
||||
this._renderSearch(this._searchLastData);
|
||||
});
|
||||
}
|
||||
if (dateSel) {
|
||||
dateSel.addEventListener('change', () => {
|
||||
this._searchSecondary.days = dateSel.value;
|
||||
this._renderSearch(this._searchLastData);
|
||||
});
|
||||
}
|
||||
|
||||
this._populateSearchCaseFilter();
|
||||
if (results) results.hidden = true;
|
||||
},
|
||||
|
||||
/** Fill the case <select> from loaded cases (#caseName values). */
|
||||
_populateSearchCaseFilter() {
|
||||
const sel = document.getElementById('searchCaseFilter');
|
||||
if (!sel) return;
|
||||
const cases = Array.isArray(this.cases) ? this.cases : [];
|
||||
const names = Array.from(new Set(cases.map((c) => c && c.name).filter(Boolean))).sort();
|
||||
const current = sel.value;
|
||||
// Rebuild options (keep the "All cases" placeholder).
|
||||
sel.innerHTML = '';
|
||||
const all = document.createElement('option');
|
||||
all.value = '';
|
||||
all.textContent = 'All cases';
|
||||
sel.appendChild(all);
|
||||
for (const name of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = '#' + name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (current && names.includes(current)) sel.value = current;
|
||||
},
|
||||
|
||||
/** Debounced trigger from the input event. */
|
||||
_scheduleSearch() {
|
||||
clearTimeout(this._searchDebounceTimer);
|
||||
this._searchDebounceTimer = setTimeout(() => this._runSearch(), window.CodemanSearch.SEARCH_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
_clearSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
const clearBtn = document.getElementById('searchClearBtn');
|
||||
if (input) input.value = '';
|
||||
if (clearBtn) clearBtn.hidden = true;
|
||||
this._searchLastData = null;
|
||||
this._renderSearch(null);
|
||||
},
|
||||
|
||||
/** Execute the federated search request and render the result. */
|
||||
async _runSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
if (!input) return;
|
||||
const q = input.value.trim();
|
||||
if (q.length === 0) {
|
||||
this._searchLastData = null;
|
||||
this._renderSearch(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const types = Array.from(this._searchTypes);
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', q.slice(0, 200));
|
||||
if (types.length > 0 && types.length < 3) params.set('types', types.join(','));
|
||||
params.set('limit', String(window.CodemanSearch.SEARCH_LIMIT));
|
||||
|
||||
const seq = ++this._searchSeq;
|
||||
const data = await this._apiJson('/api/search?' + params.toString());
|
||||
// Drop stale responses (a newer query already fired).
|
||||
if (seq !== this._searchSeq) return;
|
||||
|
||||
if (!data) {
|
||||
// null = request error or 400 (bad input). Show an empty/error state.
|
||||
this._searchLastData = { query: q, groups: [], totalResults: 0, truncated: false, _error: true };
|
||||
} else {
|
||||
this._searchLastData = data;
|
||||
}
|
||||
this._renderSearch(this._searchLastData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply client-side secondary filters (case / status / date) to a group's
|
||||
* results. Type filtering already happened server-side via types=.
|
||||
*/
|
||||
_applySecondaryFilters(results) {
|
||||
const { caseLabel, status, days } = this._searchSecondary;
|
||||
let out = results;
|
||||
if (caseLabel) {
|
||||
const want = '#' + caseLabel;
|
||||
out = out.filter((r) => (r.sessionName || '').includes(want) || r.sessionName === caseLabel);
|
||||
}
|
||||
if (status) {
|
||||
const activeIds = new Set((this.sessionOrder || []).concat(Object.keys(this.sessions || {})));
|
||||
out = out.filter((r) => {
|
||||
const isActive = activeIds.has(r.sessionId);
|
||||
return status === 'active' ? isActive : !isActive;
|
||||
});
|
||||
}
|
||||
if (days) {
|
||||
const cutoff = Date.now() - Number(days) * 24 * 60 * 60 * 1000;
|
||||
out = out.filter((r) => Number.isFinite(r.timestamp) && r.timestamp >= cutoff);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Render the grouped result cards (or empty/loading states). */
|
||||
_renderSearch(data) {
|
||||
const results = document.getElementById('searchResults');
|
||||
const historyTitle = document.getElementById('historyTitle');
|
||||
const historyList = document.getElementById('historyList');
|
||||
if (!results) return;
|
||||
|
||||
const searching = !!data;
|
||||
// Hide the plain "Resume Conversation" history list while a search is active.
|
||||
if (historyTitle) historyTitle.style.display = searching ? 'none' : '';
|
||||
if (historyList) historyList.style.display = searching ? 'none' : '';
|
||||
|
||||
results.innerHTML = '';
|
||||
if (!data) {
|
||||
results.hidden = true;
|
||||
return;
|
||||
}
|
||||
results.hidden = false;
|
||||
|
||||
if (data._error) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'search-empty';
|
||||
empty.textContent = 'Search unavailable — check the query and try again.';
|
||||
results.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply secondary (client-side) filters and recompute shown total.
|
||||
const groups = (data.groups || [])
|
||||
.map((g) => ({ type: g.type, results: this._applySecondaryFilters(g.results || []) }))
|
||||
.filter((g) => g.results.length > 0);
|
||||
|
||||
const shownTotal = groups.reduce((n, g) => n + g.results.length, 0);
|
||||
|
||||
if (shownTotal === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'search-empty';
|
||||
empty.textContent = 'No results for "' + (data.query || '') + '"';
|
||||
results.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'search-group-header';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'search-group-label';
|
||||
label.textContent = window.CodemanSearch.SOURCE_LABELS[group.type] || group.type;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'search-group-count';
|
||||
count.textContent = String(group.results.length);
|
||||
header.append(label, count);
|
||||
results.appendChild(header);
|
||||
|
||||
for (const r of group.results) {
|
||||
results.appendChild(this._buildSearchResultCard(r));
|
||||
}
|
||||
}
|
||||
|
||||
if (data.truncated) {
|
||||
const trunc = document.createElement('div');
|
||||
trunc.className = 'search-truncated';
|
||||
trunc.textContent = 'Showing the top matches — refine your search to narrow results.';
|
||||
results.appendChild(trunc);
|
||||
}
|
||||
},
|
||||
|
||||
/** Build a single result card DOM node wired to its jump-to action. */
|
||||
_buildSearchResultCard(r) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'search-result-card';
|
||||
card.dataset.type = r.type;
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('role', 'button');
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'search-result-top';
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'search-result-badge search-badge-' + r.type;
|
||||
badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'search-result-name';
|
||||
name.textContent = r.sessionName || r.sessionId || '(session)';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'search-result-time';
|
||||
time.textContent = window.CodemanSearch.formatSearchTime(r.timestamp);
|
||||
|
||||
topRow.append(badge, name, time);
|
||||
|
||||
const snippet = document.createElement('div');
|
||||
snippet.className = 'search-result-snippet';
|
||||
snippet.textContent = r.snippet || '';
|
||||
|
||||
card.append(topRow, snippet);
|
||||
|
||||
const jump = () => this._jumpToSearchResult(r);
|
||||
card.addEventListener('click', jump);
|
||||
card.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
jump();
|
||||
}
|
||||
});
|
||||
|
||||
return card;
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
|
||||
* session → selectSession(sessionId) (open/switch to the session)
|
||||
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||
* file-preview→ openFilePreview(path, sessionId, attachmentId)
|
||||
*/
|
||||
_jumpToSearchResult(r) {
|
||||
const jt = r && r.jumpTo;
|
||||
if (!jt) return;
|
||||
// Leaving the welcome overlay so the target surface is visible.
|
||||
if (typeof this.hideWelcome === 'function') this.hideWelcome();
|
||||
|
||||
try {
|
||||
if (jt.kind === 'run-summary') {
|
||||
this.openRunSummary(jt.sessionId);
|
||||
} else if (jt.kind === 'file-preview') {
|
||||
this.openFilePreview(jt.relativePath || '', jt.sessionId, jt.targetId || null);
|
||||
} else {
|
||||
// 'session' (default)
|
||||
this.selectSession(jt.sessionId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[search] jump failed', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user