diff --git a/src/web/public/index.html b/src/web/public/index.html index 4c4c7734..9a2a87b2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -311,7 +311,46 @@
Or press Ctrl+Enter to start
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index eeba9e9b..3506c3fe 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c131c4c4..d44e5dbf 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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