diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 8bd235b66..3a64842c3 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -17,7 +17,7 @@ import { import { CSS } from '@dnd-kit/utilities' import { useStore } from '@nanostores/react' import type * as React from 'react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -33,7 +33,7 @@ import { SidebarMenuItem } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' -import type { SessionInfo } from '@/hermes' +import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { cn } from '@/lib/utils' import { $pinnedSessionIds, @@ -126,6 +126,31 @@ const baseName = (path: string) => .filter(Boolean) .pop() +// FTS results cover sessions that aren't in the loaded page; synthesize a +// minimal SessionInfo so they render in the same row component (resume works +// by id; the snippet stands in for the preview). +function searchResultToSession(result: SessionSearchResult): SessionInfo { + const ts = result.session_started ?? Date.now() / 1000 + + return { + archived: false, + cwd: null, + ended_at: null, + id: result.session_id, + input_tokens: 0, + is_active: false, + last_active: ts, + message_count: 0, + model: result.model ?? null, + output_tokens: 0, + preview: result.snippet?.trim() || null, + source: result.source ?? null, + started_at: ts, + title: null, + tool_call_count: 0 + } +} + function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] { const groups = new Map() @@ -193,6 +218,9 @@ export function ChatSidebar({ const workingSessionIds = useStore($workingSessionIds) const [agentOrderIds, setAgentOrderIds] = useState([]) const [workspaceOrderIds, setWorkspaceOrderIds] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [serverMatches, setServerMatches] = useState([]) + const trimmedQuery = searchQuery.trim() const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null @@ -239,6 +267,60 @@ export function ChatSidebar({ const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions]) + // Full-text search across *all* sessions (not just the loaded page) so 699 + // sessions stay findable. Debounced; loaded sessions are matched instantly + // client-side and merged ahead of the server hits. + useEffect(() => { + if (!trimmedQuery) { + setServerMatches([]) + + return + } + + let cancelled = false + + const id = window.setTimeout(() => { + void searchSessions(trimmedQuery) + .then(res => { + if (!cancelled) { + setServerMatches(res.results) + } + }) + .catch(() => undefined) + }, 200) + + return () => { + cancelled = true + window.clearTimeout(id) + } + }, [trimmedQuery]) + + const searchResults = useMemo(() => { + if (!trimmedQuery) { + return [] + } + + const needle = trimmedQuery.toLowerCase() + const out = new Map() + + for (const s of sortedSessions) { + if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) { + out.set(s.id, s) + } + } + + for (const match of serverMatches) { + if (out.has(match.session_id)) { + continue + } + + const loaded = sessionByAnyId.get(match.session_id) + out.set(match.session_id, loaded ?? searchResultToSession(match)) + } + + return [...out.values()] + }, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId]) + const unpinnedAgentSessions = useMemo( () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), [sortedSessions, pinnedRealIdSet] @@ -369,6 +451,56 @@ export function ChatSidebar({ {sidebarOpen && showSessionSections && ( +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search sessions…" + type="text" + value={searchQuery} + /> + {searchQuery && ( + + )} +
+
+ )} + + {sidebarOpen && showSessionSections && trimmedQuery && ( + + No sessions match “{trimmedQuery}”. + + } + label="Results" + labelMeta={String(searchResults.length)} + onArchiveSession={onArchiveSession} + onDeleteSession={onDeleteSession} + onResumeSession={onResumeSession} + onToggle={() => undefined} + onTogglePin={pinSession} + open + pinned={false} + rootClassName="min-h-0 flex-1 p-0" + sessions={searchResults} + workingSessionIdSet={workingSessionIdSet} + /> + )} + + {sidebarOpen && showSessionSections && !trimmedQuery && ( )} - {sidebarOpen && showSessionSections && ( + {sidebarOpen && showSessionSections && !trimmedQuery && (