From 65ceddbe81ba3e11ca8778a7e4d2b40072312880 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Wed, 3 Jun 2026 09:36:09 +0700 Subject: [PATCH] feat(desktop): search sessions by id --- apps/desktop/src/app/chat/sidebar/index.tsx | 4 +- apps/desktop/src/app/command-center/index.tsx | 2 +- .../src/app/settings/sessions-settings.tsx | 22 +++--- apps/desktop/src/lib/session-search.test.ts | 58 +++++++++++++++ apps/desktop/src/lib/session-search.ts | 19 +++++ hermes_cli/web_server.py | 39 ++++++++-- hermes_state.py | 44 +++++++++++ .../test_web_server_session_search.py | 73 +++++++++++++++++++ tests/test_hermes_state.py | 54 ++++++++++++++ 9 files changed, 297 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/lib/session-search.test.ts create mode 100644 apps/desktop/src/lib/session-search.ts create mode 100644 tests/hermes_cli/test_web_server_session_search.py diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index d02c0e8e9555..1d920acc6fdc 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -34,6 +34,7 @@ import { } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' +import { sessionMatchesSearch } from '@/lib/session-search' import { cn } from '@/lib/utils' import { $pinnedSessionIds, @@ -306,11 +307,10 @@ export function ChatSidebar({ return [] } - const needle = trimmedQuery.toLowerCase() const out = new Map() for (const s of sortedSessions) { - if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) { + if (sessionMatchesSearch(s, trimmedQuery)) { out.set(s.id, s) } } diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index eb41568949dd..cefcf5ddccf1 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -152,7 +152,7 @@ function formatTimestamp(value?: number | null): string { function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map) { const row = sessionsById.get(result.session_id) const title = row ? sessionTitle(row) : result.session_id - const detail = [result.model, result.source].filter(Boolean).join(' · ') + const detail = [result.model, result.source, result.session_id].filter(Boolean).join(' · ') return { detail, title } } diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index b56af9020403..29e36f452687 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -5,6 +5,7 @@ import { deleteSession, listSessions, setSessionArchived } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons' +import { sessionMatchesSearch } from '@/lib/session-search' import { notify, notifyError } from '@/store/notifications' import { setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' @@ -88,15 +89,11 @@ export function SessionsSettings({ query }: SearchProps) { }, []) const filtered = useMemo(() => { - const needle = query.trim().toLowerCase() - - if (!needle) { + if (!query.trim()) { return sessions } - return sessions.filter(session => - [sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle) - ) + return sessions.filter(session => sessionMatchesSearch(session, query)) }, [query, sessions]) if (loading) { @@ -192,7 +189,10 @@ function DefaultProjectDirSetting() { let alive = true void settings.getDefaultProjectDir().then(result => { - if (!alive) return + if (!alive) { + return + } + setDir(result.dir) setFallback(result.defaultLabel) }) @@ -205,7 +205,9 @@ function DefaultProjectDirSetting() { const choose = useCallback(async () => { const settings = window.hermesDesktop?.settings - if (!settings) return + if (!settings) { + return + } setBusy(true) @@ -229,7 +231,9 @@ function DefaultProjectDirSetting() { const clear = useCallback(async () => { const settings = window.hermesDesktop?.settings - if (!settings) return + if (!settings) { + return + } setBusy(true) diff --git a/apps/desktop/src/lib/session-search.test.ts b/apps/desktop/src/lib/session-search.test.ts new file mode 100644 index 000000000000..aa40fe59c0c1 --- /dev/null +++ b/apps/desktop/src/lib/session-search.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { sessionMatchesSearch } from './session-search' + +function makeSession(overrides: Partial = {}): SessionInfo { + return { + archived: false, + cwd: '/home/user/projects/hermes-agent', + ended_at: null, + id: '20260603_090200_abcd12', + input_tokens: 0, + is_active: false, + last_active: 1_000, + message_count: 2, + model: 'claude', + output_tokens: 0, + preview: 'Fix Desktop session search', + source: 'cli', + started_at: 1_000, + title: 'Desktop Search Feature', + tool_call_count: 0, + ...overrides + } +} + +describe('sessionMatchesSearch', () => { + it('matches loaded sessions by full and partial session id', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, '20260603_090200_abcd12')).toBe(true) + expect(sessionMatchesSearch(session, '090200')).toBe(true) + expect(sessionMatchesSearch(session, 'ABCD12')).toBe(true) + }) + + it('matches projected compression sessions by lineage root id', () => { + const session = makeSession({ + _lineage_root_id: '20260602_235959_root99', + id: '20260603_010000_tip01' + }) + + expect(sessionMatchesSearch(session, 'root99')).toBe(true) + expect(sessionMatchesSearch(session, '20260602')).toBe(true) + }) + + it('preserves title, preview, and workspace matching', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, 'desktop search')).toBe(true) + expect(sessionMatchesSearch(session, 'session search')).toBe(true) + expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true) + }) + + it('does not match unrelated queries', () => { + expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/session-search.ts b/apps/desktop/src/lib/session-search.ts new file mode 100644 index 000000000000..b8ee6ebf30cf --- /dev/null +++ b/apps/desktop/src/lib/session-search.ts @@ -0,0 +1,19 @@ +import type { SessionInfo } from '@/types/hermes' + +import { sessionTitle } from './chat-runtime' + +export function sessionMatchesSearch(session: SessionInfo, query: string): boolean { + const needle = query.trim().toLowerCase() + + if (!needle) { + return true + } + + return [ + session.id, + session._lineage_root_id ?? '', + sessionTitle(session), + session.preview ?? '', + session.cwd ?? '' + ].some(value => value.toLowerCase().includes(needle)) +} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e5333928d877..2e4497f3600e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1463,13 +1463,39 @@ async def get_sessions( @app.get("/api/sessions/search") async def search_sessions(q: str = "", limit: int = 20): - """Full-text search across session message content using FTS5.""" + """Search sessions by ID plus full-text message content using FTS5.""" if not q or not q.strip(): return {"results": []} try: from hermes_state import SessionDB db = SessionDB() try: + safe_limit = max(1, min(int(limit or 20), 100)) + seen: dict = {} + + def add_result(sid: str, payload: dict) -> None: + if sid and sid not in seen and len(seen) < safe_limit: + seen[sid] = payload + + # Direct ID matches first: users often paste a session id from CLI, + # logs, or another Hermes surface. FTS can't find those unless the + # id happens to appear in message text. + for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True): + sid = row.get("id") + preview = (row.get("preview") or "").strip() + snippet = preview or f"Session ID: {sid}" + add_result( + sid, + { + "session_id": sid, + "snippet": snippet, + "role": None, + "source": row.get("source"), + "model": row.get("model"), + "session_started": row.get("started_at"), + }, + ) + # Auto-add prefix wildcards so partial words match # e.g. "nimb" → "nimb*" matches "nimby" # Preserve quoted phrases and existing wildcards as-is @@ -1481,20 +1507,21 @@ async def search_sessions(q: str = "", limit: int = 20): else: terms.append(token + "*") prefix_query = " ".join(terms) - matches = db.search_messages(query=prefix_query, limit=limit) + matches = db.search_messages(query=prefix_query, limit=safe_limit) # Group by session_id — return unique sessions with their best snippet - seen: dict = {} for m in matches: sid = m["session_id"] - if sid not in seen: - seen[sid] = { + add_result( + sid, + { "session_id": sid, "snippet": m.get("snippet", ""), "role": m.get("role"), "source": m.get("source"), "model": m.get("model"), "session_started": m.get("session_started"), - } + }, + ) return {"results": list(seen.values())} finally: db.close() diff --git a/hermes_state.py b/hermes_state.py index f08acdce295b..8d2d89131c16 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3007,6 +3007,50 @@ def search_messages( return matches + def search_sessions_by_id( + self, + query: str, + limit: int = 20, + include_archived: bool = True, + ) -> List[Dict[str, Any]]: + """Search surfaced sessions by exact/prefix/substring session id. + + Desktop search uses this alongside FTS message search so users can paste + a session id from logs, CLI output, or another Hermes surface and jump + straight to that conversation. Matching also checks ``_lineage_root_id`` + for projected compression-chain tips, so an old root id still resolves to + the live continuation row. + """ + needle = (query or "").strip().lower() + if not needle or limit <= 0: + return [] + + scan_limit = max(limit, 10_000) + sessions = self.list_sessions_rich( + limit=scan_limit, + offset=0, + include_archived=include_archived, + order_by_last_active=True, + ) + + def score(row: Dict[str, Any]) -> int: + ids = [str(row.get("id") or ""), str(row.get("_lineage_root_id") or "")] + normalized = [value.lower() for value in ids if value] + if any(value == needle for value in normalized): + return 0 + if any(value.startswith(needle) for value in normalized): + return 1 + return 2 + + matches = [ + (score(row), idx, row) + for idx, row in enumerate(sessions) + if needle in str(row.get("id") or "").lower() + or needle in str(row.get("_lineage_root_id") or "").lower() + ] + matches.sort(key=lambda item: (item[0], item[1])) + return [row for _, _, row in matches[:limit]] + def search_sessions( self, source: str = None, diff --git a/tests/hermes_cli/test_web_server_session_search.py b/tests/hermes_cli/test_web_server_session_search.py new file mode 100644 index 000000000000..035f4286247c --- /dev/null +++ b/tests/hermes_cli/test_web_server_session_search.py @@ -0,0 +1,73 @@ +import asyncio + +from hermes_cli import web_server + + +class _FakeSessionDB: + closed = False + + def search_sessions_by_id(self, query, limit=20, include_archived=True): + assert query == "20260603" + assert limit == 2 + assert include_archived is True + return [ + { + "id": "20260603_090200_exact", + "preview": "ID match preview", + "source": "cli", + "model": "claude", + "started_at": 100, + } + ] + + def search_messages(self, query, limit=20): + assert query == "20260603*" + assert limit == 2 + return [ + { + "session_id": "20260603_090200_exact", + "snippet": "duplicate content hit should not replace ID hit", + "role": "user", + "source": "cli", + "model": "claude", + "session_started": 100, + }, + { + "session_id": "content_session", + "snippet": "content hit", + "role": "assistant", + "source": "desktop", + "model": "gpt", + "session_started": 200, + }, + ] + + def close(self): + self.closed = True + + +def test_desktop_session_search_merges_id_matches_before_content_matches(monkeypatch): + monkeypatch.setattr("hermes_state.SessionDB", _FakeSessionDB) + + response = asyncio.run(web_server.search_sessions(q="20260603", limit=2)) + + assert response == { + "results": [ + { + "session_id": "20260603_090200_exact", + "snippet": "ID match preview", + "role": None, + "source": "cli", + "model": "claude", + "session_started": 100, + }, + { + "session_id": "content_session", + "snippet": "content hit", + "role": "assistant", + "source": "desktop", + "model": "gpt", + "session_started": 200, + }, + ] + } diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 572fd6489d3d..f5e4f69ae66c 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -3786,3 +3786,57 @@ def test_archived_only_and_include(self, db): both = {s["id"] for s in db.list_sessions_rich(include_archived=True)} assert both == {"live", "hidden"} assert db.session_count(include_archived=True) == 2 + + + +class TestSessionIdSearch: + """Session id search backs Desktop's Search Sessions UX.""" + + def _seed(self, db, sid, *, content="ordinary message", archived=False): + db.create_session(session_id=sid, source="cli", model="test-model") + db.append_message(session_id=sid, role="user", content=content) + if archived: + db.set_session_archived(sid, True) + + def test_search_sessions_by_id_matches_exact_prefix_and_substring(self, db): + self._seed(db, "20260603_090200_abcd12", content="content without id") + self._seed(db, "20260602_111111_other99", content="other content") + + assert [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12")] == [ + "20260603_090200_abcd12" + ] + assert [s["id"] for s in db.search_sessions_by_id("20260603")] == ["20260603_090200_abcd12"] + assert [s["id"] for s in db.search_sessions_by_id("ABCD12")] == ["20260603_090200_abcd12"] + + def test_search_sessions_by_id_respects_limit_and_prioritizes_exact_matches(self, db): + self._seed(db, "20260603_090200_abcd12") + self._seed(db, "20260603_090200_abcd12_child") + self._seed(db, "x_20260603_090200_abcd12") + + ids = [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12", limit=2)] + + assert ids == ["20260603_090200_abcd12", "20260603_090200_abcd12_child"] + + def test_search_sessions_by_id_can_include_or_exclude_archived(self, db): + self._seed(db, "20260603_090200_live") + self._seed(db, "20260603_090200_archived", archived=True) + + included = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=True)} + excluded = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=False)} + + assert included == {"20260603_090200_live", "20260603_090200_archived"} + assert excluded == {"20260603_090200_live"} + + def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db): + root = "20260602_235959_root99" + tip = "20260603_010000_tip01" + db.create_session(session_id=root, source="cli") + db.append_message(root, role="user", content="root conversation") + db.end_session(root, "compression") + db.create_session(session_id=tip, source="cli", parent_session_id=root) + db.append_message(tip, role="user", content="continued conversation") + + matches = db.search_sessions_by_id("root99") + + assert [s["id"] for s in matches] == [tip] + assert matches[0]["_lineage_root_id"] == root