diff --git a/src/search-service.ts b/src/search-service.ts new file mode 100644 index 00000000..620fad9c --- /dev/null +++ b/src/search-service.ts @@ -0,0 +1,207 @@ +/** + * @fileoverview Pure cross-session federated search core (COD-9). + * + * `searchSources()` is the testable heart of `GET /api/search`: it takes a + * normalized query plus already-collected, in-memory source data and returns + * grouped, ranked, and capped results. It performs NO I/O — the route wrapper + * (`src/web/routes/search-routes.ts`) is responsible for harvesting the source + * arrays from the live server stores (sessions, run-summary trackers, attachment + * histories) in a bounded way before calling this. + * + * v1 scope (do not expand here): three sources — sessions/cases, run-summary + * events, file paths. Terminal-buffer scanning and any persisted index are + * explicitly deferred. + * + * Ranking: results are grouped by source type in the fixed order + * sessions → events → files. Within each group, exact (case-insensitive) + * name/path matches come first, then recency (newest timestamp first) as the + * tiebreak. There is no relevance-scoring pass in v1. + * + * Safety: file results only ever expose a workspace-relative path — server- + * private absolute paths are never placed in a result. Per-group and total caps + * bound the output so a broad query cannot return an unbounded payload. + * + * Key exports: + * - searchSources() — the pure core. + * - SEARCH_TOTAL_CAP / SEARCH_PER_GROUP_CAP — the output bounds. + * - SearchSources and the *Input row types — the source-data contract. + */ + +import type { SearchResult, SearchResultGroup, SearchResponseData, SearchSourceType } from './types/search.js'; + +/** Maximum results returned across all groups combined. */ +export const SEARCH_TOTAL_CAP = 60; +/** Maximum results returned within any single source group. */ +export const SEARCH_PER_GROUP_CAP = 25; +/** Maximum characters in a result snippet. */ +export const SEARCH_SNIPPET_MAX = 200; + +/** A live-session row harvested for the session/case source. */ +export interface SessionSearchInput { + sessionId: string; + sessionName: string; + workingDir: string; + /** Recency timestamp (e.g. lastActivityAt or createdAt). */ + timestamp: number; +} + +/** A run-summary timeline event harvested for the event source. */ +export interface EventSearchInput { + sessionId: string; + sessionName: string; + eventId: string; + title: string; + details: string; + timestamp: number; +} + +/** A per-session attachment harvested for the file source. */ +export interface FileSearchInput { + sessionId: string; + sessionName: string; + fileName: string; + /** Workspace-relative path, if known. Absolute/external paths are never passed in. */ + relativePath: string | undefined; + timestamp: number; + /** Attachment history item id, used as the jump-to target. */ + itemId: string; +} + +/** The full set of in-memory source data the pure core searches over. */ +export interface SearchSources { + sessions: SessionSearchInput[]; + events: EventSearchInput[]; + files: FileSearchInput[]; +} + +/** Fixed group/render order. */ +const GROUP_ORDER: SearchSourceType[] = ['session', 'event', 'file']; + +function truncate(text: string, max = SEARCH_SNIPPET_MAX): string { + const trimmed = text.trim().replace(/\s+/g, ' '); + return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed; +} + +/** + * Sort a group's results: exact matches first, then newest timestamp first. + * Stable for equal keys. + */ +function sortGroup(rows: SearchResult[]): SearchResult[] { + return rows + .map((result, index) => ({ result, index })) + .sort((a, b) => { + if (a.result.exactMatch !== b.result.exactMatch) { + return a.result.exactMatch ? -1 : 1; + } + if (a.result.timestamp !== b.result.timestamp) { + return b.result.timestamp - a.result.timestamp; + } + return a.index - b.index; + }) + .map((r) => r.result); +} + +/** + * Search the provided in-memory sources for `query`. + * + * @param query Raw query string (already length-validated by the route). Blank + * queries return an empty result set. + * @param sources Harvested, bounded source arrays. + */ +export function searchSources(query: string, sources: SearchSources): SearchResponseData { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) { + return { query: query.trim(), groups: [], totalResults: 0, truncated: false }; + } + + const contains = (s: string | undefined): boolean => !!s && s.toLowerCase().includes(needle); + const isExact = (s: string | undefined): boolean => !!s && s.toLowerCase() === needle; + + // -- Source: sessions/cases -- + const sessionRows: SearchResult[] = []; + for (const s of sources.sessions) { + if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) { + sessionRows.push({ + type: 'session', + sessionId: s.sessionId, + sessionName: s.sessionName, + timestamp: s.timestamp, + snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName), + exactMatch: isExact(s.sessionName), + jumpTo: { kind: 'session', sessionId: s.sessionId }, + }); + } + } + + // -- Source: run-summary events -- + const eventRows: SearchResult[] = []; + for (const e of sources.events) { + if (contains(e.title) || contains(e.details)) { + const snippetBase = e.details && contains(e.details) ? `${e.title}: ${e.details}` : e.title; + eventRows.push({ + type: 'event', + sessionId: e.sessionId, + sessionName: e.sessionName, + timestamp: e.timestamp, + snippet: truncate(snippetBase), + exactMatch: isExact(e.title), + jumpTo: { kind: 'run-summary', sessionId: e.sessionId, targetId: e.eventId }, + }); + } + } + + // -- Source: file paths -- + const fileRows: SearchResult[] = []; + for (const f of sources.files) { + if (contains(f.fileName) || contains(f.relativePath)) { + fileRows.push({ + type: 'file', + sessionId: f.sessionId, + sessionName: f.sessionName, + timestamp: f.timestamp, + snippet: truncate(f.relativePath ?? f.fileName), + // Exact match keys off the safe path (or filename) — never an absolute path. + exactMatch: isExact(f.relativePath) || isExact(f.fileName), + jumpTo: { + kind: 'file-preview', + sessionId: f.sessionId, + targetId: f.itemId, + // Only ever expose a relative path; absolute/external paths are not passed in. + relativePath: f.relativePath, + }, + }); + } + } + + const byType: Record = { + session: sortGroup(sessionRows), + event: sortGroup(eventRows), + file: sortGroup(fileRows), + }; + + const groups: SearchResultGroup[] = []; + let total = 0; + let truncated = false; + + for (const type of GROUP_ORDER) { + const all = byType[type]; + if (all.length === 0) continue; + + // Per-group cap. + let capped = all.slice(0, SEARCH_PER_GROUP_CAP); + if (all.length > capped.length) truncated = true; + + // Total cap (never exceed the global budget). + const remaining = SEARCH_TOTAL_CAP - total; + if (capped.length > remaining) { + capped = capped.slice(0, Math.max(0, remaining)); + truncated = true; + } + if (capped.length === 0) continue; + + groups.push({ type, results: capped }); + total += capped.length; + } + + return { query: query.trim(), groups, totalResults: total, truncated }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 7624f89b..5a5b0911 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,3 +68,4 @@ export * from './plan.js'; export * from './orchestrator.js'; export * from './update.js'; export * from './workflow-run.js'; +export * from './search.js'; diff --git a/src/types/search.ts b/src/types/search.ts new file mode 100644 index 00000000..418aa236 --- /dev/null +++ b/src/types/search.ts @@ -0,0 +1,77 @@ +/** + * @fileoverview Cross-session federated search types (COD-9). + * + * Defines the typed shapes for `GET /api/search` — a bounded, in-memory + * federated search across three v1 sources: live sessions/cases, run-summary + * timeline events, and per-session attachment file paths. Terminal-buffer scans + * and any persisted index are explicitly out of scope for v1. + * + * Key exports: + * - SearchSourceType — the federated source kinds, also the group order key. + * - SearchResult — a single typed result card (source, session id/name, + * timestamp, snippet, jump-to action target). + * - SearchJumpTarget — where the frontend should navigate when a card is opened. + * - SearchResponseData — grouped result payload returned in the ApiResponse envelope. + * + * No I/O, no dependencies on other domain modules. The pure search core lives + * in `src/search-service.ts`; the route wrapper in `src/web/routes/search-routes.ts`. + */ + +/** Federated source kinds. Group/render order is sessions → events → files. */ +export type SearchSourceType = 'session' | 'event' | 'file'; + +/** Where the frontend should jump when a result card is activated. */ +export interface SearchJumpTarget { + /** Kind of navigation target. */ + kind: 'session' | 'run-summary' | 'file-preview'; + /** Owning Codeman session id (always present — every result is session-scoped). */ + sessionId: string; + /** + * Secondary identifier for the target: + * - kind 'run-summary': the run-summary event id + * - kind 'file-preview': the attachment history item id + * - kind 'session': undefined (the sessionId is sufficient) + */ + targetId?: string; + /** + * Workspace-relative path for file-preview targets. Never an absolute path — + * server-private external paths are intentionally omitted to avoid leakage. + */ + relativePath?: string; +} + +/** A single typed search result card. */ +export interface SearchResult { + /** Which federated source produced this result. */ + type: SearchSourceType; + /** Owning Codeman session id. */ + sessionId: string; + /** Display name of the owning session / case. */ + sessionName: string; + /** Millisecond timestamp used for recency ranking and display. */ + timestamp: number; + /** Short, already-truncated snippet describing the match. */ + snippet: string; + /** True when the query matched the primary name/path exactly (case-insensitive). */ + exactMatch: boolean; + /** Navigation target for the jump-to action. */ + jumpTo: SearchJumpTarget; +} + +/** A group of results for one source type, in render order. */ +export interface SearchResultGroup { + type: SearchSourceType; + results: SearchResult[]; +} + +/** Payload returned as `data` inside the standard ApiResponse envelope. */ +export interface SearchResponseData { + /** The normalized query that was executed. */ + query: string; + /** Results grouped by source type, ordered sessions → events → files. */ + groups: SearchResultGroup[]; + /** Total number of results across all groups (after caps applied). */ + totalResults: number; + /** True if any group or the total was capped (more matches existed). */ + truncated: boolean; +} 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 @@

Codeman

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