From 1a050242b7ba60043eb9751b3edf6fe86a152838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 15 Jun 2026 19:06:00 +0800 Subject: [PATCH] feat(search): global session full-text search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Codex-style global search dialog (Cmd+K / sidebar button) that full-text searches across all session transcripts, replacing the old title-only sidebar filter. Backend: rewrite searchService.searchSessions as a two-phase engine — ripgrep finds candidate files + matched lines, then those lines are parsed to keep only user/assistant text, re-confirmed against the cleaned text to drop JSON/UUID/base64 false positives, and windowed into highlighted snippets. Results carry real session titles (new sessionService.getSessionTitleAndMeta reusing the list title precedence), project path, mtime, role and match counts; falls back to a JS scan when ripgrep is unavailable. Frontend: new GlobalSearchModal (debounced, stale-response-safe, keyboard nav, role badges, highlighting, recent-chats empty state); Cmd+K now opens it; the sidebar input is replaced by a search trigger button. Tests: 15 backend cases (searchService.sessions) + 12 frontend cases (GlobalSearchModal); existing Sidebar/pages tests updated for the new trigger. --- desktop/src/__tests__/pages.test.tsx | 2 +- desktop/src/api/search.ts | 40 +- .../src/components/layout/Sidebar.test.tsx | 7 +- desktop/src/components/layout/Sidebar.tsx | 39 +- .../search/GlobalSearchModal.test.tsx | 227 ++++++++++ .../components/search/GlobalSearchModal.tsx | 395 ++++++++++++++++ desktop/src/hooks/useKeyboardShortcuts.ts | 13 +- desktop/src/i18n/locales/en.ts | 11 + desktop/src/i18n/locales/jp.ts | 11 + desktop/src/i18n/locales/kr.ts | 11 + desktop/src/i18n/locales/zh-TW.ts | 11 + desktop/src/i18n/locales/zh.ts | 11 + .../__tests__/searchService.sessions.test.ts | 256 +++++++++++ src/server/api/search.ts | 8 +- src/server/services/searchService.ts | 422 +++++++++++++++--- src/server/services/sessionService.ts | 24 + 16 files changed, 1393 insertions(+), 95 deletions(-) create mode 100644 desktop/src/components/search/GlobalSearchModal.test.tsx create mode 100644 desktop/src/components/search/GlobalSearchModal.tsx create mode 100644 src/server/__tests__/searchService.sessions.test.ts diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index f26f7297..e7398345 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1294,7 +1294,7 @@ describe('AppShell layout renders chrome', () => { expect(container.querySelector('aside')).toBeInTheDocument() expect(container.innerHTML).toContain('New session') expect(container.innerHTML).toContain('Scheduled') - expect(container.innerHTML).toContain('Search sessions') + expect(container.innerHTML).toContain('Search chats') expect(container.innerHTML).toContain('Settings') }) }) diff --git a/desktop/src/api/search.ts b/desktop/src/api/search.ts index dd32d2d0..9b23c650 100644 --- a/desktop/src/api/search.ts +++ b/desktop/src/api/search.ts @@ -9,21 +9,45 @@ type SearchResult = { type SearchResponse = { results: SearchResult[]; total: number } -type SessionSearchResult = { - sessionId: string - title: string - matchCount: number - matches: Array<{ line: number; text: string }> +export type SessionMatchRole = 'user' | 'assistant' + +export type SessionMatch = { + role: SessionMatchRole + messageId: string | null + lineNumber: number + snippet: string + highlights: Array<{ start: number; end: number }> + timestamp?: string } -type SessionSearchResponse = { results: SessionSearchResult[] } +export type SessionSearchResult = { + sessionId: string + title: string + projectPath: string + workDir: string | null + modifiedAt: string + matchCount: number + matches: SessionMatch[] +} + +export type SessionSearchResponse = { + results: SessionSearchResult[] + total: number + truncated: boolean +} + +export type SessionSearchOptions = { + limit?: number + matchesPerSession?: number + caseSensitive?: boolean +} export const searchApi = { search(params: { query: string; cwd?: string; maxResults?: number; glob?: string }) { return api.post('/api/search', params) }, - searchSessions(query: string) { - return api.post('/api/search/sessions', { query }) + searchSessions(query: string, options?: SessionSearchOptions) { + return api.post('/api/search/sessions', { query, ...options }) }, } diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 7edb3c62..64e3f548 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -34,6 +34,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed', 'sidebar.refreshSessions': 'Refresh sessions', + 'search.global.trigger': 'Search chats', 'sidebar.projects': 'Projects', 'sidebar.projectMenu': 'Project menu', 'sidebar.newProject': 'New project', @@ -1057,7 +1058,7 @@ describe('Sidebar', () => { }) expect(useUIStore.getState().sidebarOpen).toBe(false) - expect(screen.queryByPlaceholderText('Search sessions')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Search chats' })).not.toBeInTheDocument() expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'closed') expect(screen.getByTestId('sidebar-expand-button')).toHaveClass('sidebar-toggle-button--collapsed') @@ -1066,7 +1067,7 @@ describe('Sidebar', () => { }) expect(useUIStore.getState().sidebarOpen).toBe(true) - expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Search chats' })).toBeInTheDocument() expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open') }) @@ -1075,7 +1076,7 @@ describe('Sidebar', () => { expect(screen.getByTestId('sidebar-search-controls-section')).toHaveStyle({ overflow: 'visible' }) expect(screen.getByTestId('sidebar-search-controls-section')).toHaveClass('relative', 'z-20') - expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Search chats' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /All projects/i })).not.toBeInTheDocument() expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument() }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 0e468ac8..3dc7ca0a 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' +import { GlobalSearchModal } from '../search/GlobalSearchModal' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' @@ -62,12 +63,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const addToast = useUIStore((s) => s.addToast) const sidebarOpen = useUIStore((s) => s.sidebarOpen) const toggleSidebar = useUIStore((s) => s.toggleSidebar) + const activeModal = useUIStore((s) => s.activeModal) + const openModal = useUIStore((s) => s.openModal) + const closeModal = useUIStore((s) => s.closeModal) const activeTabId = useTabStore((s) => s.activeTabId) const tabs = useTabStore((s) => s.tabs) const chatSessions = useChatStore((s) => s.sessions) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) - const [searchQuery, setSearchQuery] = useState('') const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) const [projectContextMenu, setProjectContextMenu] = useState<{ key: string; x: number; y: number } | null>(null) const [projectHeaderMenu, setProjectHeaderMenu] = useState<{ type: SidebarHeaderMenuType; x: number; y: number } | null>(null) @@ -109,14 +112,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { return () => document.removeEventListener('click', close) }, [contextMenu, projectContextMenu, projectHeaderMenu, projectHeaderSubmenu]) - const filteredSessions = useMemo(() => { - let result = sessions - if (searchQuery) { - const q = searchQuery.toLowerCase() - result = result.filter((s) => s.title.toLowerCase().includes(q)) - } - return result - }, [sessions, searchQuery]) + // Title filtering moved into the global search modal (Cmd+K); the list shows all sessions. + const filteredSessions = sessions const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy]) const orderedProjectGroups = useMemo( @@ -696,19 +693,19 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { style={{ overflow: 'visible' }} >
-
+
+ {t('search.global.trigger')} + ⌘K +
) : filteredSessions.length === 0 && (
- {searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')} + {t('sidebar.noSessions')}
)} {orderedProjectGroups.length > 0 && ( @@ -1205,6 +1202,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { confirmVariant="danger" loading={isBatchDeleting} /> + + ) } diff --git a/desktop/src/components/search/GlobalSearchModal.test.tsx b/desktop/src/components/search/GlobalSearchModal.test.tsx new file mode 100644 index 00000000..6d8eeee6 --- /dev/null +++ b/desktop/src/components/search/GlobalSearchModal.test.tsx @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { GlobalSearchModal } from './GlobalSearchModal' +import { useSessionStore } from '../../stores/sessionStore' +import { useTabStore } from '../../stores/tabStore' +import { useSettingsStore } from '../../stores/settingsStore' +import { searchApi } from '../../api/search' +import type { SessionListItem } from '../../types/session' +import type { SessionSearchResult } from '../../api/search' + +vi.mock('../../api/search', () => ({ + searchApi: { searchSessions: vi.fn() }, +})) + +const mockSearch = vi.mocked(searchApi.searchSessions) + +// jsdom does not implement scrollIntoView (used to keep the active row in view). +Element.prototype.scrollIntoView = vi.fn() + +function makeSession(id: string, title: string): SessionListItem { + return { + id, + title, + createdAt: '2026-06-01T00:00:00.000Z', + modifiedAt: '2026-06-01T00:00:00.000Z', + messageCount: 2, + projectPath: 'proj', + projectRoot: '/home/u/proj', + workDir: '/home/u/proj', + workDirExists: true, + } +} + +function makeResult(sessionId: string, title: string, overrides?: Partial): SessionSearchResult { + return { + sessionId, + title, + projectPath: 'proj', + workDir: '/home/u/proj', + modifiedAt: new Date().toISOString(), + matchCount: 1, + matches: [ + { role: 'user', messageId: 'u1', lineNumber: 1, snippet: `${title} body`, highlights: [] }, + ], + ...overrides, + } +} + +beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ sessions: [] }) + useTabStore.setState({ openTab: vi.fn() } as Partial>) + mockSearch.mockResolvedValue({ results: [], total: 0, truncated: false }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('GlobalSearchModal', () => { + it('renders nothing when closed', () => { + render( {}} />) + expect(screen.queryByPlaceholderText(/search all chats/i)).toBeNull() + }) + + it('renders the input and focuses it when open', async () => { + render( {}} />) + const input = screen.getByPlaceholderText(/search all chats/i) + await waitFor(() => expect(input).toHaveFocus()) + }) + + it('shows recent sessions from the store when the query is empty', () => { + useSessionStore.setState({ sessions: [makeSession('s1', 'Recent One'), makeSession('s2', 'Recent Two')] }) + render( {}} />) + + expect(screen.getByText('Recent chats')).toBeInTheDocument() + expect(screen.getByText('Recent One')).toBeInTheDocument() + expect(screen.getByText('Recent Two')).toBeInTheDocument() + expect(mockSearch).not.toHaveBeenCalled() + }) + + it('debounces input and calls searchApi once with the latest query', async () => { + vi.useFakeTimers() + try { + render( {}} />) + const input = screen.getByPlaceholderText(/search all chats/i) + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'abc' } }) + expect(mockSearch).not.toHaveBeenCalled() + + await act(async () => { + vi.advanceTimersByTime(300) + await Promise.resolve() + }) + + expect(mockSearch).toHaveBeenCalledTimes(1) + expect(mockSearch).toHaveBeenCalledWith('abc', expect.objectContaining({ limit: expect.any(Number) })) + } finally { + vi.useRealTimers() + } + }) + + it('renders results with real title, match count and role badges', async () => { + mockSearch.mockResolvedValue({ + results: [ + makeResult('s1', 'My Session', { + matchCount: 3, + matches: [ + { role: 'user', messageId: 'u1', lineNumber: 1, snippet: 'hello WORLD foo', highlights: [{ start: 6, end: 11 }] }, + { role: 'assistant', messageId: 'a1', lineNumber: 2, snippet: 'assistant reply here', highlights: [] }, + ], + }), + ], + total: 1, + truncated: false, + }) + + render( {}} />) + fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'world' } }) + + await screen.findByText('My Session') + expect(screen.getByText('3 matches')).toBeInTheDocument() + expect(screen.getByText('You')).toBeInTheDocument() + expect(screen.getByText('Assistant')).toBeInTheDocument() + + const mark = screen.getByText('WORLD') + expect(mark.tagName).toBe('MARK') + }) + + it('opens the active result on Enter and closes the modal', async () => { + const openTab = vi.fn() + useTabStore.setState({ openTab } as Partial>) + const onClose = vi.fn() + mockSearch.mockResolvedValue({ + results: [makeResult('s1', 'Session One'), makeResult('s2', 'Session Two')], + total: 2, + truncated: false, + }) + + render() + const input = screen.getByPlaceholderText(/search all chats/i) + fireEvent.change(input, { target: { value: 'x' } }) + await screen.findByText('Session One') + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(openTab).toHaveBeenCalledWith('s2', 'Session Two') + expect(onClose).toHaveBeenCalled() + }) + + it('opens a result on click', async () => { + const openTab = vi.fn() + useTabStore.setState({ openTab } as Partial>) + const onClose = vi.fn() + mockSearch.mockResolvedValue({ results: [makeResult('s1', 'Clickable')], total: 1, truncated: false }) + + render() + fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'x' } }) + fireEvent.click(await screen.findByText('Clickable')) + + expect(openTab).toHaveBeenCalledWith('s1', 'Clickable') + expect(onClose).toHaveBeenCalled() + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByPlaceholderText(/search all chats/i), { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) + + it('shows a no-results message', async () => { + mockSearch.mockResolvedValue({ results: [], total: 0, truncated: false }) + render( {}} />) + fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'zzz' } }) + await screen.findByText('No matches found') + }) + + it('shows an error message when the search fails', async () => { + mockSearch.mockRejectedValue(new Error('boom')) + render( {}} />) + fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'zzz' } }) + await screen.findByText('Search failed') + }) + + it('shows a truncation note when results are capped', async () => { + mockSearch.mockResolvedValue({ results: [makeResult('s1', 'Session One')], total: 1, truncated: true }) + render( {}} />) + fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'x' } }) + await screen.findByText('Session One') + expect(screen.getByText(/showing first/i)).toBeInTheDocument() + }) + + it('ignores a stale response that resolves after a newer one', async () => { + let resolveFirst: (value: { results: SessionSearchResult[]; total: number; truncated: boolean }) => void = () => {} + const firstPromise = new Promise<{ results: SessionSearchResult[]; total: number; truncated: boolean }>((resolve) => { + resolveFirst = resolve + }) + mockSearch + .mockReturnValueOnce(firstPromise) + .mockResolvedValueOnce({ results: [makeResult('s2', 'Session Two')], total: 1, truncated: false }) + + render( {}} />) + const input = screen.getByPlaceholderText(/search all chats/i) + + fireEvent.change(input, { target: { value: 'one' } }) + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1)) + + fireEvent.change(input, { target: { value: 'two' } }) + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(2)) + await screen.findByText('Session Two') + + // The stale first request resolves last — it must not overwrite the newer results. + await act(async () => { + resolveFirst({ results: [makeResult('s1', 'Session One')], total: 1, truncated: false }) + await Promise.resolve() + }) + + expect(screen.queryByText('Session One')).toBeNull() + expect(screen.getByText('Session Two')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/search/GlobalSearchModal.tsx b/desktop/src/components/search/GlobalSearchModal.tsx new file mode 100644 index 00000000..b9d5a5bd --- /dev/null +++ b/desktop/src/components/search/GlobalSearchModal.tsx @@ -0,0 +1,395 @@ +import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { Search, X } from 'lucide-react' +import { useTranslation, type TranslationKey } from '../../i18n' +import { useSessionStore } from '../../stores/sessionStore' +import { useTabStore } from '../../stores/tabStore' +import { searchApi, type SessionSearchResult, type SessionMatch, type SessionMatchRole } from '../../api/search' + +const DEBOUNCE_MS = 250 +const RECENT_LIMIT = 8 +const SEARCH_LIMIT = 50 +const MATCH_PREVIEW_PER_SESSION = 3 + +type Props = { + open: boolean + onClose: () => void +} + +/** A row in the result list — either a recent session (no matches) or a search hit. */ +type Row = { + sessionId: string + title: string + projectPath: string + workDir: string | null + modifiedAt: string + matchCount: number + matches: SessionMatch[] +} + +export function GlobalSearchModal({ open, onClose }: Props) { + const t = useTranslation() + const sessions = useSessionStore((s) => s.sessions) + + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const [truncated, setTruncated] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + + const inputRef = useRef(null) + const listRef = useRef(null) + const requestIdRef = useRef(0) + + // Reset + focus whenever the modal opens. + useEffect(() => { + if (!open) return + setQuery('') + setDebouncedQuery('') + setResults([]) + setError(false) + setTruncated(false) + setActiveIndex(0) + requestIdRef.current += 1 // invalidate any in-flight request + const id = requestAnimationFrame(() => inputRef.current?.focus()) + return () => cancelAnimationFrame(id) + }, [open]) + + // Debounce the query. + useEffect(() => { + const id = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS) + return () => clearTimeout(id) + }, [query]) + + // Run the search (or clear) when the debounced query changes. + useEffect(() => { + setActiveIndex(0) + const q = debouncedQuery.trim() + if (!q) { + setResults([]) + setLoading(false) + setError(false) + setTruncated(false) + return + } + + const reqId = ++requestIdRef.current + setLoading(true) + setError(false) + searchApi + .searchSessions(q, { limit: SEARCH_LIMIT }) + .then((resp) => { + if (reqId !== requestIdRef.current) return // a newer request superseded this one + setResults(resp.results) + setTruncated(resp.truncated) + setLoading(false) + }) + .catch(() => { + if (reqId !== requestIdRef.current) return + setResults([]) + setError(true) + setLoading(false) + }) + }, [debouncedQuery]) + + const recentSessions = useMemo( + () => + [...sessions] + .sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : a.modifiedAt > b.modifiedAt ? -1 : 0)) + .slice(0, RECENT_LIMIT), + [sessions], + ) + + const isSearching = debouncedQuery.trim().length > 0 + + const rows: Row[] = useMemo(() => { + if (!isSearching) { + return recentSessions.map((s) => ({ + sessionId: s.id, + title: s.title, + projectPath: s.projectPath, + workDir: s.workDir, + modifiedAt: s.modifiedAt, + matchCount: 0, + matches: [], + })) + } + return results.map((r) => ({ + sessionId: r.sessionId, + title: r.title, + projectPath: r.projectPath, + workDir: r.workDir, + modifiedAt: r.modifiedAt, + matchCount: r.matchCount, + matches: r.matches, + })) + }, [isSearching, recentSessions, results]) + + // Keep the active row in view. + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex]) + + function openRow(row: Row) { + useTabStore.getState().openTab(row.sessionId, row.title) + onClose() + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((i) => Math.min(i + 1, rows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const row = rows[activeIndex] + if (row) openRow(row) + } else if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + if (!open) return null + + return createPortal( +
+
+ +
+ {/* Search input */} +
+
+ + {/* Results */} +
+ {!isSearching ? ( + <> + {rows.length > 0 && ( +
+ {t('search.global.recentTitle')} +
+ )} + {rows.map((row, i) => ( + + ))} + + ) : loading && results.length === 0 ? ( +
+ {t('search.global.loading')} +
+ ) : error ? ( +
+ {t('search.global.error')} +
+ ) : rows.length === 0 ? ( +
+ {t('search.global.noResults')} +
+ ) : ( + <> + {rows.map((row, i) => ( + + ))} + {truncated && ( +
+ {t('search.global.truncated', { count: SEARCH_LIMIT })} +
+ )} + + )} +
+ + {/* Footer hints */} +
+ ↑↓ + {t('fileSearch.navigate')} + Enter + {t('fileSearch.select')} + Esc + {t('fileSearch.close')} +
+
+
, + document.body, + ) +} + +type RowProps = { + row: Row + index: number + active: boolean + onActivate: (index: number) => void + onOpen: (row: Row) => void + t: (key: TranslationKey, params?: Record) => string +} + +function ResultRow({ row, index, active, onActivate, onOpen, t }: RowProps) { + return ( + + ) +} + +function RoleBadge({ + role, + t, +}: { + role: SessionMatchRole + t: (key: TranslationKey, params?: Record) => string +}) { + const isUser = role === 'user' + return ( + + {isUser ? t('search.global.roleUser') : t('search.global.roleAssistant')} + + ) +} + +/** Wrap matched ranges of `snippet` in for highlighting. */ +function renderHighlighted( + snippet: string, + highlights: Array<{ start: number; end: number }>, +): ReactNode { + if (!highlights.length) return snippet + const parts: ReactNode[] = [] + let cursor = 0 + for (const h of highlights) { + const start = Math.max(0, Math.min(h.start, snippet.length)) + const end = Math.max(start, Math.min(h.end, snippet.length)) + if (start > cursor) parts.push(snippet.slice(cursor, start)) + parts.push( + + {snippet.slice(start, end)} + , + ) + cursor = end + } + if (cursor < snippet.length) parts.push(snippet.slice(cursor)) + return parts +} + +function projectLabel(row: Row): string { + const candidate = row.workDir || row.projectPath + if (!candidate) return '' + const segments = candidate.split('/').filter(Boolean) + return segments[segments.length - 1] ?? candidate +} + +function formatRelativeTime( + dateStr: string, + t: (key: TranslationKey, params?: Record) => string, +): string { + const date = new Date(dateStr) + const timestamp = date.getTime() + if (!Number.isFinite(timestamp)) return '' + + const diff = Date.now() - timestamp + const min = Math.floor(diff / 60000) + if (min < 1) return t('session.timeJustNow') + if (min < 60) return t('session.timeMinutes', { n: min }) + const hr = Math.floor(min / 60) + if (hr < 24) return t('session.timeHours', { n: hr }) + const day = Math.floor(hr / 24) + if (day < 30) return t('session.timeDays', { n: day }) + return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date) +} diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index 282dc99e..8a299e78 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -12,7 +12,7 @@ import { useSettingsStore } from '../stores/settingsStore' export function useKeyboardShortcuts() { const setActiveSession = useSessionStore((s) => s.setActiveSession) const setActiveView = useUIStore((s) => s.setActiveView) - const setSidebarOpen = useUIStore((s) => s.setSidebarOpen) + const openModal = useUIStore((s) => s.openModal) const closeModal = useUIStore((s) => s.closeModal) const activeModal = useUIStore((s) => s.activeModal) const stopGeneration = useChatStore((s) => s.stopGeneration) @@ -50,15 +50,10 @@ export function useKeyboardShortcuts() { setActiveView('code') } - // Cmd+K — Focus search (sidebar search input) + // Cmd+K — Open global session search if (meta && e.key === 'k') { e.preventDefault() - setSidebarOpen(true) - requestAnimationFrame(() => { - const searchInput = document.querySelector('#sidebar-search') as HTMLInputElement | null - searchInput?.focus() - searchInput?.select() - }) + openModal('globalSearch') } // Escape — Close modal or clear state @@ -79,5 +74,5 @@ export function useKeyboardShortcuts() { document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [closeModal, setActiveSession, setActiveView, setSidebarOpen, setUiZoom, stopGeneration]) + }, [closeModal, openModal, setActiveSession, setActiveView, setUiZoom, stopGeneration]) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 41c401a8..a0336040 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -29,6 +29,17 @@ export const en = { 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed to load', 'sidebar.refreshSessions': 'Refresh sessions', + 'search.global.trigger': 'Search chats', + 'search.global.placeholder': 'Search all chats…', + 'search.global.recentTitle': 'Recent chats', + 'search.global.noResults': 'No matches found', + 'search.global.matchCount': '{count} matches', + 'search.global.truncated': 'Showing first {count} chats — refine your query', + 'search.global.roleUser': 'You', + 'search.global.roleAssistant': 'Assistant', + 'search.global.loading': 'Searching…', + 'search.global.error': 'Search failed', + 'search.global.close': 'Close', 'sidebar.projects': 'Projects', 'sidebar.projectMenu': 'Project menu', 'sidebar.newProject': 'New project', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index a477fa82..26fcd163 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -31,6 +31,17 @@ export const jp: Record = { 'sidebar.noMatching': '一致するセッションがありません', 'sidebar.sessionListFailed': 'セッション一覧の読み込みに失敗しました', 'sidebar.refreshSessions': 'セッションを更新', + 'search.global.trigger': 'チャットを検索', + 'search.global.placeholder': 'すべてのチャットを検索…', + 'search.global.recentTitle': '最近のセッション', + 'search.global.noResults': '一致する内容が見つかりません', + 'search.global.matchCount': '{count} 件一致', + 'search.global.truncated': '最初の {count} 件のみ表示しています。キーワードを絞り込んでください', + 'search.global.roleUser': 'あなた', + 'search.global.roleAssistant': 'アシスタント', + 'search.global.loading': '検索中…', + 'search.global.error': '検索に失敗しました', + 'search.global.close': '閉じる', 'sidebar.projects': 'プロジェクト', 'sidebar.projectMenu': 'プロジェクトメニュー', 'sidebar.newProject': '新しいプロジェクト', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 02375e64..f99eff98 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -31,6 +31,17 @@ export const kr: Record = { 'sidebar.noMatching': '일치하는 세션이 없습니다', 'sidebar.sessionListFailed': '세션 목록을 불러오지 못했습니다', 'sidebar.refreshSessions': '세션 새로 고침', + 'search.global.trigger': '대화 검색', + 'search.global.placeholder': '모든 대화 검색…', + 'search.global.recentTitle': '최근 세션', + 'search.global.noResults': '일치하는 내용이 없습니다', + 'search.global.matchCount': '{count}개 일치', + 'search.global.truncated': '처음 {count}개 세션만 표시합니다. 검색어를 좁혀 주세요', + 'search.global.roleUser': '나', + 'search.global.roleAssistant': '어시스턴트', + 'search.global.loading': '검색 중…', + 'search.global.error': '검색 실패', + 'search.global.close': '닫기', 'sidebar.projects': '프로젝트', 'sidebar.projectMenu': '프로젝트 메뉴', 'sidebar.newProject': '새 프로젝트', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 3cb445e5..4a65270b 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -31,6 +31,17 @@ export const zh: Record = { 'sidebar.noMatching': '沒有匹配的會話', 'sidebar.sessionListFailed': '會話列表載入失敗', 'sidebar.refreshSessions': '重新整理會話列表', + 'search.global.trigger': '搜尋聊天', + 'search.global.placeholder': '搜尋全部聊天內容…', + 'search.global.recentTitle': '近期會話', + 'search.global.noResults': '找不到符合的內容', + 'search.global.matchCount': '{count} 筆符合', + 'search.global.truncated': '僅顯示前 {count} 個會話,請縮小關鍵字', + 'search.global.roleUser': '你', + 'search.global.roleAssistant': '助手', + 'search.global.loading': '搜尋中…', + 'search.global.error': '搜尋失敗', + 'search.global.close': '關閉', 'sidebar.projects': '專案', 'sidebar.projectMenu': '專案選單', 'sidebar.newProject': '新建專案', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 17e6b6bc..7d1f3f1f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -31,6 +31,17 @@ export const zh: Record = { 'sidebar.noMatching': '没有匹配的会话', 'sidebar.sessionListFailed': '会话列表加载失败', 'sidebar.refreshSessions': '刷新会话列表', + 'search.global.trigger': '搜索聊天', + 'search.global.placeholder': '搜索全部聊天内容…', + 'search.global.recentTitle': '近期会话', + 'search.global.noResults': '没有找到匹配内容', + 'search.global.matchCount': '{count} 条匹配', + 'search.global.truncated': '仅显示前 {count} 个会话,请细化关键词', + 'search.global.roleUser': '你', + 'search.global.roleAssistant': '助手', + 'search.global.loading': '搜索中…', + 'search.global.error': '搜索失败', + 'search.global.close': '关闭', 'sidebar.projects': '项目', 'sidebar.projectMenu': '项目菜单', 'sidebar.newProject': '新建项目', diff --git a/src/server/__tests__/searchService.sessions.test.ts b/src/server/__tests__/searchService.sessions.test.ts new file mode 100644 index 00000000..b33b155c --- /dev/null +++ b/src/server/__tests__/searchService.sessions.test.ts @@ -0,0 +1,256 @@ +/** + * Unit tests for SearchService.searchSessions — global session full-text search. + * + * Builds throwaway ~/.claude/projects//.jsonl fixtures under a temp + * CLAUDE_CONFIG_DIR and exercises the two-phase (ripgrep → parse/clean) engine. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as os from 'node:os' +import { SearchService } from '../services/searchService.js' + +let tmpDir: string +let service: SearchService + +async function setupTmpConfigDir(): Promise { + tmpDir = path.join( + os.tmpdir(), + `cc-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) + await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true }) + process.env.CLAUDE_CONFIG_DIR = tmpDir +} + +async function cleanupTmpDir(): Promise { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }) + } + delete process.env.CLAUDE_CONFIG_DIR +} + +/** Write a JSONL session file. Entries may be objects (serialized) or raw strings (for malformed-line tests). */ +async function writeSessionFile( + projectDir: string, + sessionId: string, + entries: Array | string>, +): Promise { + const dir = path.join(tmpDir, 'projects', projectDir) + await fs.mkdir(dir, { recursive: true }) + const filePath = path.join(dir, `${sessionId}.jsonl`) + const content = + entries.map((e) => (typeof e === 'string' ? e : JSON.stringify(e))).join('\n') + '\n' + await fs.writeFile(filePath, content, 'utf-8') + return filePath +} + +beforeEach(async () => { + await setupTmpConfigDir() + service = new SearchService() +}) + +afterEach(cleanupTmpDir) + +describe('SearchService.searchSessions', () => { + it('matches user message text and returns role=user with correct highlights', async () => { + await writeSessionFile('proj-a', 'session-1', [ + { + type: 'user', + uuid: 'u1', + timestamp: '2026-06-01T00:00:00.000Z', + message: { role: 'user', content: 'please implement global search feature' }, + }, + ]) + + const { results } = await service.searchSessions('global search') + expect(results).toHaveLength(1) + expect(results[0].sessionId).toBe('session-1') + expect(results[0].projectPath).toBe('proj-a') + + const m = results[0].matches[0] + expect(m.role).toBe('user') + expect(m.messageId).toBe('u1') + expect(m.lineNumber).toBe(1) + expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end).toLowerCase()).toBe( + 'global search', + ) + }) + + it('matches assistant text blocks and returns role=assistant', async () => { + await writeSessionFile('proj-a', 'session-2', [ + { + type: 'assistant', + uuid: 'a1', + message: { role: 'assistant', content: [{ type: 'text', text: 'I recommend ripgrep for this' }] }, + }, + ]) + + const { results } = await service.searchSessions('ripgrep') + expect(results).toHaveLength(1) + expect(results[0].matches[0].role).toBe('assistant') + }) + + it('matches Chinese content with correct highlight slicing', async () => { + await writeSessionFile('proj-a', 'session-3', [ + { type: 'user', message: { role: 'user', content: '帮我做一个全文搜索功能' } }, + ]) + + const { results } = await service.searchSessions('全文搜索') + expect(results).toHaveLength(1) + const m = results[0].matches[0] + expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end)).toBe('全文搜索') + }) + + it('searches only user/assistant text, ignoring tool_use and tool_result blocks', async () => { + await writeSessionFile('proj-a', 'session-4', [ + { + type: 'assistant', + uuid: 'a2', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'running the command now' }, + { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'rg zzytoolmarker --json' } }, + ], + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'zzytoolmarker found in 3 files' }], + }, + }, + ]) + + // 'zzytoolmarker' only lives in tool_use input + tool_result content → must NOT match. + const tool = await service.searchSessions('zzytoolmarker') + expect(tool.results).toHaveLength(0) + + // The assistant's natural-language text is searchable. + const text = await service.searchSessions('running the command') + expect(text.results).toHaveLength(1) + expect(text.results[0].matches[0].role).toBe('assistant') + }) + + it('drops ripgrep false positives that only hit JSON structure (keys/uuids)', async () => { + await writeSessionFile('proj-a', 'session-5', [ + { type: 'user', uuid: 'assistant-like-uuid', message: { role: 'user', content: 'hello world' } }, + ]) + + // 'content' is a JSON key, never part of the readable text 'hello world'. + const noise = await service.searchSessions('content') + expect(noise.results).toHaveLength(0) + + const real = await service.searchSessions('hello world') + expect(real.results).toHaveLength(1) + }) + + it('skips internal command breadcrumb entries', async () => { + await writeSessionFile('proj-a', 'session-6', [ + { type: 'user', message: { role: 'user', content: 'deploy magicword' } }, + ]) + + const { results } = await service.searchSessions('magicword') + expect(results).toHaveLength(0) + }) + + it('resolves the real session title (custom-title wins) instead of the UUID', async () => { + await writeSessionFile('proj-a', 'titled-session', [ + { type: 'user', uuid: 'u1', message: { role: 'user', content: 'discuss searchword topic' } }, + { type: 'ai-title', aiTitle: 'AI Generated Title', sessionId: 'titled-session' }, + { type: 'custom-title', customTitle: 'My Custom Title', sessionId: 'titled-session' }, + ]) + + const { results } = await service.searchSessions('searchword') + expect(results[0].title).toBe('My Custom Title') + expect(results[0].title).not.toBe('titled-session') + }) + + it('falls back to the AI title when there is no custom title', async () => { + await writeSessionFile('proj-a', 'ai-titled', [ + { type: 'user', uuid: 'u1', message: { role: 'user', content: 'another searchword here' } }, + { type: 'ai-title', aiTitle: 'Smart Title', sessionId: 'ai-titled' }, + ]) + + const { results } = await service.searchSessions('another searchword') + expect(results[0].title).toBe('Smart Title') + }) + + it('windows snippets for very long lines', async () => { + const filler = 'x'.repeat(50_000) + await writeSessionFile('proj-a', 'session-8', [ + { type: 'user', message: { role: 'user', content: `${filler} NEEDLEWORD ${filler}` } }, + ]) + + const { results } = await service.searchSessions('NEEDLEWORD') + expect(results).toHaveLength(1) + const m = results[0].matches[0] + expect(m.snippet.length).toBeLessThan(600) + expect(m.snippet).toContain('…') + expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end)).toBe('NEEDLEWORD') + }) + + it('caps matches per session but reports the full matchCount', async () => { + const entries = Array.from({ length: 8 }, (_, i) => ({ + type: 'user', + uuid: `u${i}`, + message: { role: 'user', content: `repeatword occurrence number ${i}` }, + })) + await writeSessionFile('proj-a', 'session-9', entries) + + const { results } = await service.searchSessions('repeatword', { matchesPerSession: 3 }) + expect(results[0].matchCount).toBe(8) + expect(results[0].matches).toHaveLength(3) + }) + + it('orders sessions by most-recently modified first', async () => { + const older = await writeSessionFile('proj-a', 'older', [ + { type: 'user', message: { role: 'user', content: 'sortword in older' } }, + ]) + await writeSessionFile('proj-b', 'newer', [ + { type: 'user', message: { role: 'user', content: 'sortword in newer' } }, + ]) + const past = new Date(Date.now() - 60_000) + await fs.utimes(older, past, past) + + const { results } = await service.searchSessions('sortword') + expect(results.map((r) => r.sessionId)).toEqual(['newer', 'older']) + }) + + it('skips malformed/half-written lines without crashing', async () => { + await writeSessionFile('proj-a', 'session-11', [ + { type: 'user', message: { role: 'user', content: 'valid brokenmarker line' } }, + '{ this is not valid json brokenmarker', + ]) + + const { results } = await service.searchSessions('brokenmarker') + expect(results).toHaveLength(1) + expect(results[0].matchCount).toBe(1) + }) + + it('throws on empty or whitespace-only query', async () => { + await expect(service.searchSessions('')).rejects.toThrow() + await expect(service.searchSessions(' ')).rejects.toThrow() + }) + + it('falls back to a JS scan when ripgrep is unavailable', async () => { + await writeSessionFile('proj-a', 'session-13', [ + { type: 'user', uuid: 'u1', message: { role: 'user', content: 'fallbackword works fine' } }, + ]) + ;(service as unknown as { commandExists: () => Promise }).commandExists = async () => false + + const { results } = await service.searchSessions('fallbackword') + expect(results).toHaveLength(1) + expect(results[0].matches[0].role).toBe('user') + expect(results[0].matches[0].snippet).toContain('fallbackword') + }) + + it('returns empty when the projects dir does not exist', async () => { + await fs.rm(path.join(tmpDir, 'projects'), { recursive: true, force: true }) + const { results, truncated } = await service.searchSessions('anything') + expect(results).toEqual([]) + expect(truncated).toBe(false) + }) +}) diff --git a/src/server/api/search.ts b/src/server/api/search.ts index 1c889bc0..4223f2df 100644 --- a/src/server/api/search.ts +++ b/src/server/api/search.ts @@ -35,8 +35,12 @@ export async function handleSearchApi( // ── POST /api/search/sessions ────────────────────────────────────────── if (sub === 'sessions') { - const results = await searchService.searchSessions(query) - return Response.json({ results }) + const { results, truncated } = await searchService.searchSessions(query, { + limit: body.limit as number | undefined, + matchesPerSession: body.matchesPerSession as number | undefined, + caseSensitive: body.caseSensitive as boolean | undefined, + }) + return Response.json({ results, total: results.length, truncated }) } // ── POST /api/search ─────────────────────────────────────────────────── diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts index ce7c5800..d6be3cb5 100644 --- a/src/server/services/searchService.ts +++ b/src/server/services/searchService.ts @@ -9,6 +9,7 @@ import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { ApiError } from '../middleware/errorHandler.js' +import { sessionService } from './sessionService.js' export type SearchResult = { file: string @@ -17,13 +18,62 @@ export type SearchResult = { context?: string[] } +export type SessionMatchRole = 'user' | 'assistant' + +export type SessionMatch = { + /** Who produced the matched text. */ + role: SessionMatchRole + /** Transcript entry uuid (for future "jump to message"); null on legacy rows. */ + messageId: string | null + /** 1-based line number inside the .jsonl file. */ + lineNumber: number + /** Whitespace-collapsed, window-trimmed readable excerpt. */ + snippet: string + /** Match ranges relative to `snippet`, for highlighting. */ + highlights: Array<{ start: number; end: number }> + timestamp?: string +} + export type SessionSearchResult = { sessionId: string title: string + projectPath: string + workDir: string | null + modifiedAt: string + /** Total readable matches in the session (may exceed matches.length). */ matchCount: number - matches: Array<{ line: number; text: string }> + matches: SessionMatch[] } +export type SessionSearchOptions = { + limit?: number + matchesPerSession?: number + caseSensitive?: boolean +} + +export type SessionSearchOutput = { + results: SessionSearchResult[] + truncated: boolean +} + +/** Minimal transcript-entry shape needed for search (mirrors sessionService's RawEntry). */ +type RawSearchEntry = { + type?: string + uuid?: string + timestamp?: string + message?: { role?: string; content?: unknown } + [key: string]: unknown +} + +/** Cap files parsed in phase B so a broad query can't read hundreds of large files. */ +const SESSION_SEARCH_MAX_FILES = 60 +const SESSION_SEARCH_DEFAULT_LIMIT = 50 +const SESSION_SEARCH_DEFAULT_MATCHES_PER_SESSION = 5 +/** Characters of context kept on each side of a match inside a snippet. */ +const SESSION_SNIPPET_WINDOW = 120 +/** Guard ripgrep output against multi-MB single lines (base64 / big tool results). */ +const RG_MAX_COLUMNS = 500 + export class SearchService { // --------------------------------------------------------------------------- // 工作区搜索 @@ -72,77 +122,345 @@ export class SearchService { // 会话历史搜索 // --------------------------------------------------------------------------- - /** 搜索会话历史文件 */ - async searchSessions(query: string): Promise { - if (!query) { + /** + * Full-text search across all session transcripts. + * + * Two-phase: (A) ripgrep scans `~/.claude/projects` for candidate files + + * matched line numbers (fast — tens of ms over hundreds of MB; falls back to a + * pure-JS scan when rg is unavailable). (B) for the most-recently-modified + * candidate files we parse only the matched lines, keep just user/assistant + * text blocks, re-confirm the query against the cleaned text (dropping + * ripgrep's false positives on JSON keys / UUIDs / base64), and build + * highlighted snippets with real session titles. + */ + async searchSessions( + query: string, + options?: SessionSearchOptions, + ): Promise { + const trimmedQuery = query.trim() + if (!trimmedQuery) { throw ApiError.badRequest('Search query is required') } + const caseSensitive = options?.caseSensitive ?? false + const limit = options?.limit ?? SESSION_SEARCH_DEFAULT_LIMIT + const matchesPerSession = + options?.matchesPerSession ?? SESSION_SEARCH_DEFAULT_MATCHES_PER_SESSION + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') const projectsDir = path.join(configDir, 'projects') - const results: SessionSearchResult[] = [] - try { await fs.access(projectsDir) } catch { - // 目录不存在,返回空 - return results + return { results: [], truncated: false } } - // 遍历 projects/ 下的 JSONL 会话文件 - const entries = await this.walkJsonlFiles(projectsDir) - const lowerQuery = query.toLowerCase() + // ── Phase A: candidate files + matched line numbers ────────────────────── + const candidates = await this.findSessionCandidateLines( + trimmedQuery, + projectsDir, + { caseSensitive }, + ) + if (candidates.size === 0) { + return { results: [], truncated: false } + } - for (const filePath of entries) { - try { - const raw = await fs.readFile(filePath, 'utf-8') - const lines = raw.split('\n').filter(Boolean) - - const matches: Array<{ line: number; text: string }> = [] - let title = path.basename(filePath, '.jsonl') - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - if (line.toLowerCase().includes(lowerQuery)) { - // 尝试提取可读文本 - try { - const obj = JSON.parse(line) as Record - const text = - typeof obj.message === 'string' - ? obj.message - : typeof obj.content === 'string' - ? obj.content - : line.slice(0, 200) - - // 提取 title - if (i === 0 && typeof obj.title === 'string') { - title = obj.title - } - - matches.push({ line: i + 1, text: text.slice(0, 300) }) - } catch { - matches.push({ line: i + 1, text: line.slice(0, 200) }) - } - } + // Prefer the most recently modified sessions; cap how many we parse. + const ranked = await Promise.all( + [...candidates.keys()].map(async (filePath) => { + let mtimeMs = 0 + try { + mtimeMs = (await fs.stat(filePath)).mtimeMs + } catch { + // unreadable — sinks to the bottom } + return { filePath, mtimeMs } + }), + ) + ranked.sort((a, b) => b.mtimeMs - a.mtimeMs) - if (matches.length > 0) { - const sessionId = path.basename(filePath, '.jsonl') - results.push({ - sessionId, - title, - matchCount: matches.length, - matches: matches.slice(0, 20), // 每个会话最多 20 条匹配 + let truncated = false + let filesToParse = ranked + if (filesToParse.length > SESSION_SEARCH_MAX_FILES) { + filesToParse = filesToParse.slice(0, SESSION_SEARCH_MAX_FILES) + truncated = true + } + + // ── Phase B: parse matched lines serially (avoid concurrent big-file reads) + const results: SessionSearchResult[] = [] + for (const { filePath } of filesToParse) { + const lineNumbers = candidates.get(filePath) + if (!lineNumbers || lineNumbers.size === 0) continue + + const { matches, matchCount } = await this.extractSessionMatches( + filePath, + lineNumbers, + trimmedQuery, + { caseSensitive, matchesPerSession }, + ) + // All ripgrep hits were JSON noise (no readable user/assistant text). + if (matchCount === 0) continue + + const sessionId = path.basename(filePath, '.jsonl') + let title = sessionId + let projectPath = path.basename(path.dirname(filePath)) + let workDir: string | null = null + let modifiedAt = new Date(0).toISOString() + try { + const meta = await sessionService.getSessionTitleAndMeta(filePath) + title = meta.title + projectPath = meta.projectPath + workDir = meta.workDir + modifiedAt = meta.modifiedAt + } catch { + // keep fallbacks + } + + results.push({ + sessionId, + title, + projectPath, + workDir, + modifiedAt, + matchCount, + matches, + }) + } + + // Most recently modified first. + results.sort((a, b) => + a.modifiedAt < b.modifiedAt ? 1 : a.modifiedAt > b.modifiedAt ? -1 : 0, + ) + + if (results.length > limit) { + return { results: results.slice(0, limit), truncated: true } + } + return { results, truncated } + } + + // --------------------------------------------------------------------------- + // 会话搜索 — Phase A: 候选文件 + 命中行号 + // --------------------------------------------------------------------------- + + private async findSessionCandidateLines( + query: string, + projectsDir: string, + opts: { caseSensitive: boolean }, + ): Promise>> { + if (await this.commandExists('rg')) { + try { + return await this.findSessionCandidatesWithRipgrep(query, projectsDir, opts) + } catch { + // rg failed — fall back to a portable scan + } + } + return this.findSessionCandidatesWithFilesystem(query, projectsDir, opts) + } + + private async findSessionCandidatesWithRipgrep( + query: string, + projectsDir: string, + opts: { caseSensitive: boolean }, + ): Promise>> { + const args = ['--json', '--max-columns', String(RG_MAX_COLUMNS), '--glob', '*.jsonl'] + if (!opts.caseSensitive) args.push('--ignore-case') + args.push('--', query, projectsDir) + + const output = await this.runCommand('rg', args) + const map = new Map>() + + for (const line of output.split('\n')) { + if (!line) continue + try { + const obj = JSON.parse(line) as Record + if (obj.type !== 'match') continue + const data = obj.data as { path?: { text?: string }; line_number?: number } + const file = data.path?.text + const lineNum = data.line_number + if (!file || !lineNum) continue + let set = map.get(file) + if (!set) { + set = new Set() + map.set(file, set) + } + set.add(lineNum) + } catch { + // skip unparseable rg rows + } + } + return map + } + + private async findSessionCandidatesWithFilesystem( + query: string, + projectsDir: string, + opts: { caseSensitive: boolean }, + ): Promise>> { + const files = await this.walkJsonlFiles(projectsDir) + const needle = opts.caseSensitive ? query : query.toLowerCase() + const map = new Map>() + + for (const filePath of files) { + let raw: string + try { + raw = await fs.readFile(filePath, 'utf-8') + } catch { + continue + } + const lines = raw.split('\n') + const set = new Set() + for (let i = 0; i < lines.length; i++) { + const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase() + if (haystack.includes(needle)) set.add(i + 1) + } + if (set.size > 0) map.set(filePath, set) + } + return map + } + + // --------------------------------------------------------------------------- + // 会话搜索 — Phase B: 解析命中行 → 清洗 → 提取片段 + // --------------------------------------------------------------------------- + + private async extractSessionMatches( + filePath: string, + lineNumbers: Set, + query: string, + opts: { caseSensitive: boolean; matchesPerSession: number }, + ): Promise<{ matches: SessionMatch[]; matchCount: number }> { + let raw: string + try { + raw = await fs.readFile(filePath, 'utf-8') + } catch { + return { matches: [], matchCount: 0 } + } + + const lines = raw.split('\n') + const needle = opts.caseSensitive ? query : query.toLowerCase() + const matches: SessionMatch[] = [] + let matchCount = 0 + + for (const lineNo of [...lineNumbers].sort((a, b) => a - b)) { + const line = lines[lineNo - 1] + if (!line) continue + + let entry: RawSearchEntry + try { + entry = JSON.parse(line) as RawSearchEntry + } catch { + continue // half-written / malformed line + } + + for (const segment of this.extractUserAssistantSegments(entry)) { + const haystack = opts.caseSensitive ? segment.text : segment.text.toLowerCase() + if (!haystack.includes(needle)) continue // ripgrep false positive (JSON noise) + + matchCount += 1 + if (matches.length < opts.matchesPerSession) { + const { snippet, highlights } = this.buildSnippet( + segment.text, + query, + opts.caseSensitive, + ) + matches.push({ + role: segment.role, + messageId: typeof entry.uuid === 'string' ? entry.uuid : null, + lineNumber: lineNo, + snippet, + highlights, + ...(typeof entry.timestamp === 'string' ? { timestamp: entry.timestamp } : {}), }) } - } catch { - // 跳过无法读取的文件 } } - return results + return { matches, matchCount } + } + + /** + * Extract only the user/assistant natural-language text from a transcript + * entry. Tool calls (tool_use) and tool results (tool_result) are skipped, as + * are internal command breadcrumbs — keeping search results clean. + */ + private extractUserAssistantSegments( + entry: RawSearchEntry, + ): Array<{ role: SessionMatchRole; text: string }> { + if (entry.type !== 'user' && entry.type !== 'assistant') return [] + + const content = entry.message?.content + if (this.isInternalCommandBreadcrumb(content)) return [] + + const role: SessionMatchRole = + entry.type === 'assistant' || entry.message?.role === 'assistant' + ? 'assistant' + : 'user' + + return this.extractPlainTextBlocks(content).map((text) => ({ role, text })) + } + + /** Plain text from message content (string, or `text` blocks only). */ + private extractPlainTextBlocks(content: unknown): string[] { + if (typeof content === 'string') { + const trimmed = content.trim() + return trimmed ? [trimmed] : [] + } + if (!Array.isArray(content)) return [] + + const out: string[] = [] + for (const block of content) { + if (block && typeof block === 'object') { + const record = block as Record + if (record.type === 'text' && typeof record.text === 'string') { + const trimmed = record.text.trim() + if (trimmed) out.push(trimmed) + } + } + } + return out + } + + private isInternalCommandBreadcrumb(content: unknown): boolean { + if (typeof content !== 'string') return false + return ( + content.includes('') || + content.includes('') || + content.includes('') || + content.includes('') + ) + } + + /** Window a single match into a one-line, highlighted snippet. */ + private buildSnippet( + text: string, + query: string, + caseSensitive: boolean, + ): { snippet: string; highlights: Array<{ start: number; end: number }> } { + const normalized = text.replace(/\s+/g, ' ').trim() + const haystack = caseSensitive ? normalized : normalized.toLowerCase() + const needle = caseSensitive ? query : query.toLowerCase() + + const idx = haystack.indexOf(needle) + if (idx < 0) { + const head = normalized.slice(0, SESSION_SNIPPET_WINDOW * 2) + return { + snippet: head + (normalized.length > head.length ? '…' : ''), + highlights: [], + } + } + + const start = Math.max(0, idx - SESSION_SNIPPET_WINDOW) + const end = Math.min(normalized.length, idx + needle.length + SESSION_SNIPPET_WINDOW) + const prefix = start > 0 ? '…' : '' + const suffix = end < normalized.length ? '…' : '' + const snippet = prefix + normalized.slice(start, end) + suffix + const highlightStart = prefix.length + (idx - start) + + return { + snippet, + highlights: [{ start: highlightStart, end: highlightStart + needle.length }], + } } // --------------------------------------------------------------------------- diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index e19521d7..c4b4c8d1 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -501,6 +501,30 @@ export class SessionService { } } + /** + * Resolve a session's display title + lightweight metadata from its JSONL file. + * + * Reuses the same title precedence as the session list (custom-title > goal > + * ai-title > first user message), so global session search shows real titles + * instead of the raw UUID file name. + */ + async getSessionTitleAndMeta(filePath: string): Promise<{ + title: string + modifiedAt: string + workDir: string | null + projectPath: string + }> { + const stat = await fs.stat(filePath) + const projectPath = path.basename(path.dirname(filePath)) + const summary = await this.scanSessionListSummary(filePath, projectPath, stat) + return { + title: summary.title, + modifiedAt: stat.mtime.toISOString(), + workDir: summary.workDir ?? null, + projectPath, + } + } + private async appendJsonlEntry(filePath: string, entry: Record): Promise { const line = JSON.stringify(entry) + '\n' await fs.appendFile(filePath, line, 'utf-8')