Merge branch 'claude/brave-hofstadter-bc68ae': global session full-text search

This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 19:08:10 +08:00
commit a4799ae5ea
16 changed files with 1393 additions and 95 deletions

View File

@ -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')
})
})

View File

@ -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<SearchResponse>('/api/search', params)
},
searchSessions(query: string) {
return api.post<SessionSearchResponse>('/api/search/sessions', { query })
searchSessions(query: string, options?: SessionSearchOptions) {
return api.post<SessionSearchResponse>('/api/search/sessions', { query, ...options })
},
}

View File

@ -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()
})

View File

@ -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' }}
>
<div className="flex items-center gap-1.5">
<div className="flex h-9 min-w-0 flex-1 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
<button
type="button"
onClick={() => openModal('globalSearch')}
className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-2 text-left text-[13px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] focus-visible:border-[var(--color-border-focus)] focus-visible:outline-none"
aria-label={t('search.global.trigger')}
title={t('search.global.trigger')}
>
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
<SearchIcon />
</span>
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
</div>
<span className="min-w-0 flex-1 truncate pl-2">{t('search.global.trigger')}</span>
<kbd className="pointer-events-none shrink-0 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 font-mono text-[10px] leading-tight text-[var(--color-text-tertiary)]">K</kbd>
</button>
<button
type="button"
onClick={() => void refreshSessionsNow()}
@ -804,7 +801,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
</div>
) : filteredSessions.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
{t('sidebar.noSessions')}
</div>
)}
{orderedProjectGroups.length > 0 && (
@ -1205,6 +1202,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
confirmVariant="danger"
loading={isBatchDeleting}
/>
<GlobalSearchModal open={activeModal === 'globalSearch'} onClose={closeModal} />
</aside>
)
}

View File

@ -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>): 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<ReturnType<typeof useTabStore.getState>>)
mockSearch.mockResolvedValue({ results: [], total: 0, truncated: false })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('GlobalSearchModal', () => {
it('renders nothing when closed', () => {
render(<GlobalSearchModal open={false} onClose={() => {}} />)
expect(screen.queryByPlaceholderText(/search all chats/i)).toBeNull()
})
it('renders the input and focuses it when open', async () => {
render(<GlobalSearchModal open onClose={() => {}} />)
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(<GlobalSearchModal open onClose={() => {}} />)
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(<GlobalSearchModal open onClose={() => {}} />)
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(<GlobalSearchModal open onClose={() => {}} />)
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<ReturnType<typeof useTabStore.getState>>)
const onClose = vi.fn()
mockSearch.mockResolvedValue({
results: [makeResult('s1', 'Session One'), makeResult('s2', 'Session Two')],
total: 2,
truncated: false,
})
render(<GlobalSearchModal open onClose={onClose} />)
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<ReturnType<typeof useTabStore.getState>>)
const onClose = vi.fn()
mockSearch.mockResolvedValue({ results: [makeResult('s1', 'Clickable')], total: 1, truncated: false })
render(<GlobalSearchModal open onClose={onClose} />)
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(<GlobalSearchModal open onClose={onClose} />)
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(<GlobalSearchModal open onClose={() => {}} />)
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(<GlobalSearchModal open onClose={() => {}} />)
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(<GlobalSearchModal open onClose={() => {}} />)
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(<GlobalSearchModal open onClose={() => {}} />)
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()
})
})

View File

@ -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<SessionSearchResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [truncated, setTruncated] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(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<HTMLInputElement>) {
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(
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]">
<div
className="absolute inset-0 bg-[var(--color-overlay-scrim)] transition-opacity duration-200"
onClick={onClose}
/>
<div
className="glass-panel relative z-10 flex max-h-[70vh] w-[640px] max-w-[calc(100vw-48px)] flex-col overflow-hidden rounded-[var(--radius-xl)]"
role="dialog"
aria-modal="true"
aria-label={t('search.global.placeholder')}
>
{/* Search input */}
<div className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('search.global.placeholder')}
aria-label={t('search.global.placeholder')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
{loading && (
<span className="material-symbols-outlined animate-spin text-[16px] text-[var(--color-text-tertiary)]">
progress_activity
</span>
)}
<button
type="button"
onClick={onClose}
aria-label={t('search.global.close')}
title={t('search.global.close')}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* Results */}
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto py-1.5" role="listbox">
{!isSearching ? (
<>
{rows.length > 0 && (
<div className="px-4 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('search.global.recentTitle')}
</div>
)}
{rows.map((row, i) => (
<ResultRow
key={row.sessionId}
row={row}
index={i}
active={i === activeIndex}
onActivate={setActiveIndex}
onOpen={openRow}
t={t}
/>
))}
</>
) : loading && results.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
{t('search.global.loading')}
</div>
) : error ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-error)]">
{t('search.global.error')}
</div>
) : rows.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
{t('search.global.noResults')}
</div>
) : (
<>
{rows.map((row, i) => (
<ResultRow
key={row.sessionId}
row={row}
index={i}
active={i === activeIndex}
onActivate={setActiveIndex}
onOpen={openRow}
t={t}
/>
))}
{truncated && (
<div className="px-4 py-2 text-center text-[11px] text-[var(--color-text-tertiary)]">
{t('search.global.truncated', { count: SEARCH_LIMIT })}
</div>
)}
</>
)}
</div>
{/* Footer hints */}
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono"></kbd>
<span>{t('fileSearch.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
<span>{t('fileSearch.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
<span>{t('fileSearch.close')}</span>
</div>
</div>
</div>,
document.body,
)
}
type RowProps = {
row: Row
index: number
active: boolean
onActivate: (index: number) => void
onOpen: (row: Row) => void
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}
function ResultRow({ row, index, active, onActivate, onOpen, t }: RowProps) {
return (
<button
type="button"
data-index={index}
role="option"
aria-selected={active}
onMouseEnter={() => onActivate(index)}
onClick={() => onOpen(row)}
className={`flex w-full flex-col gap-0.5 px-4 py-2 text-left transition-colors focus-visible:outline-none ${
active ? 'bg-[var(--color-surface-hover)]' : ''
}`}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text-primary)]">
{row.title}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-[var(--color-text-tertiary)]">
{formatRelativeTime(row.modifiedAt, t)}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span className="min-w-0 truncate">{projectLabel(row)}</span>
{row.matchCount > 0 && (
<>
<span aria-hidden="true">·</span>
<span className="shrink-0">{t('search.global.matchCount', { count: row.matchCount })}</span>
</>
)}
</div>
{row.matches.slice(0, MATCH_PREVIEW_PER_SESSION).map((m, j) => (
<div key={`${m.lineNumber}-${j}`} className="mt-0.5 flex items-start gap-2">
<RoleBadge role={m.role} t={t} />
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
{renderHighlighted(m.snippet, m.highlights)}
</span>
</div>
))}
</button>
)
}
function RoleBadge({
role,
t,
}: {
role: SessionMatchRole
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}) {
const isUser = role === 'user'
return (
<span
className={`mt-px shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium leading-none ${
isUser
? 'bg-[var(--color-brand)]/15 text-[var(--color-brand)]'
: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)]'
}`}
>
{isUser ? t('search.global.roleUser') : t('search.global.roleAssistant')}
</span>
)
}
/** Wrap matched ranges of `snippet` in <mark> 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(
<mark
key={`${start}-${end}`}
className="rounded-[3px] bg-[var(--color-brand)]/25 px-0.5 text-[var(--color-text-primary)]"
>
{snippet.slice(start, end)}
</mark>,
)
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 | number>) => 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)
}

View File

@ -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])
}

View File

@ -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',

View File

@ -31,6 +31,17 @@ export const jp: Record<TranslationKey, string> = {
'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': '新しいプロジェクト',

View File

@ -31,6 +31,17 @@ export const kr: Record<TranslationKey, string> = {
'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': '새 프로젝트',

View File

@ -31,6 +31,17 @@ export const zh: Record<TranslationKey, string> = {
'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': '新建專案',

View File

@ -31,6 +31,17 @@ export const zh: Record<TranslationKey, string> = {
'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': '新建项目',

View File

@ -0,0 +1,256 @@
/**
* Unit tests for SearchService.searchSessions global session full-text search.
*
* Builds throwaway ~/.claude/projects/<dir>/<uuid>.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<void> {
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<void> {
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<Record<string, unknown> | string>,
): Promise<string> {
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: '<command-name>deploy</command-name> 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<boolean> }).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)
})
})

View File

@ -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 ───────────────────────────────────────────────────

View File

@ -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<SessionSearchResult[]> {
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<SessionSearchOutput> {
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<string, unknown>
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<Map<string, Set<number>>> {
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<Map<string, Set<number>>> {
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<string, Set<number>>()
for (const line of output.split('\n')) {
if (!line) continue
try {
const obj = JSON.parse(line) as Record<string, unknown>
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<number>()
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<Map<string, Set<number>>> {
const files = await this.walkJsonlFiles(projectsDir)
const needle = opts.caseSensitive ? query : query.toLowerCase()
const map = new Map<string, Set<number>>()
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<number>()
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<number>,
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<string, unknown>
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('<command-name>') ||
content.includes('<command-message>') ||
content.includes('<command-args>') ||
content.includes('<local-command-caveat>')
)
}
/** 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 }],
}
}
// ---------------------------------------------------------------------------

View File

@ -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<string, unknown>): Promise<void> {
const line = JSON.stringify(entry) + '\n'
await fs.appendFile(filePath, line, 'utf-8')