mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): query session suggestions directly from SQLite
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { api } from '@/api/client'
|
||||
import { filesystemApi } from '@/api/filesystem'
|
||||
vi.mock('@/api/client', () => ({ api: { get: vi.fn() } }))
|
||||
|
||||
it('forwards cancellation for keyword searches and directory browsing', () => {
|
||||
const signal = new AbortController().signal
|
||||
filesystemApi.search('修复', '/work', { signal })
|
||||
filesystemApi.browse('/work/src', { includeFiles: true, signal })
|
||||
expect(api.get).toHaveBeenCalledWith('/api/filesystem/browse?search=%E4%BF%AE%E5%A4%8D&maxResults=200&includeFiles=true&path=%2Fwork', { signal })
|
||||
expect(api.get).toHaveBeenCalledWith('/api/filesystem/browse?path=%2Fwork%2Fsrc&includeFiles=true', { includeFiles: true, signal })
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from './client'
|
||||
import { api, type ApiRequestOptions } from './client'
|
||||
|
||||
type DirEntry = {
|
||||
name: string
|
||||
@@ -15,17 +15,17 @@ type BrowseResult = {
|
||||
}
|
||||
|
||||
export const filesystemApi = {
|
||||
browse(path?: string, options?: { includeFiles?: boolean }) {
|
||||
browse(path?: string, options?: { includeFiles?: boolean } & ApiRequestOptions) {
|
||||
const q = new URLSearchParams()
|
||||
if (path) q.set('path', path)
|
||||
if (options?.includeFiles) q.set('includeFiles', 'true')
|
||||
const qs = q.toString()
|
||||
return api.get<BrowseResult>(`/api/filesystem/browse${qs ? `?${qs}` : ''}`)
|
||||
return api.get<BrowseResult>(`/api/filesystem/browse${qs ? `?${qs}` : ''}`, options)
|
||||
},
|
||||
|
||||
search(query: string, cwd?: string) {
|
||||
search(query: string, cwd?: string, options?: ApiRequestOptions) {
|
||||
const q = new URLSearchParams({ search: query, maxResults: '200', includeFiles: 'true' })
|
||||
if (cwd) q.set('path', cwd)
|
||||
return api.get<BrowseResult>(`/api/filesystem/browse?${q}`)
|
||||
return api.get<BrowseResult>(`/api/filesystem/browse?${q}`, options)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { api } from '@/api/client'
|
||||
import { sessionCollaborationApi } from './sessionCollaboration'
|
||||
vi.mock('@/api/client', () => ({ api: { get: vi.fn(), post: vi.fn() } }))
|
||||
it('uses the authenticated API client and encodes session identifiers and search text', () => {
|
||||
sessionCollaborationApi.list('Auth / 中文')
|
||||
const signal = new AbortController().signal
|
||||
sessionCollaborationApi.list('Auth / 中文', { signal })
|
||||
sessionCollaborationApi.status('session/a')
|
||||
sessionCollaborationApi.stop('session/a')
|
||||
expect(api.get).toHaveBeenCalledWith('/api/session-collaboration?query=Auth%20%2F%20%E4%B8%AD%E6%96%87')
|
||||
expect(api.get).toHaveBeenCalledWith('/api/session-collaboration?query=Auth%20%2F%20%E4%B8%AD%E6%96%87', { signal })
|
||||
expect(api.get).toHaveBeenCalledWith('/api/session-collaboration/session%2Fa/status')
|
||||
expect(api.post).toHaveBeenCalledWith('/api/session-collaboration/session%2Fa/stop', {})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from '@/api/client'
|
||||
import { api, type ApiRequestOptions } from '@/api/client'
|
||||
|
||||
export type SessionCandidate = {
|
||||
sessionId: string
|
||||
@@ -15,7 +15,7 @@ export type SessionCollaborationStatus = {
|
||||
}
|
||||
const base = '/api/session-collaboration'
|
||||
export const sessionCollaborationApi = {
|
||||
list(query = '') { return api.get<{ sessions: SessionCandidate[] }>(`${base}?query=${encodeURIComponent(query)}`) },
|
||||
list(query = '', options?: ApiRequestOptions) { return api.get<{ sessions: SessionCandidate[] }>(`${base}?query=${encodeURIComponent(query)}`, options) },
|
||||
status(sessionId: string) { return api.get<SessionCollaborationStatus>(`${base}/${encodeURIComponent(sessionId)}/status`) },
|
||||
stop(sessionId: string) { return api.post(`${base}/${encodeURIComponent(sessionId)}/stop`, {}) },
|
||||
}
|
||||
|
||||
@@ -1961,7 +1961,7 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md')
|
||||
expect(getComposerText()).toContain('Please review @README.md')
|
||||
expect(mocks.search).toHaveBeenCalledWith('README', '/repo')
|
||||
expect(mocks.search).toHaveBeenCalledWith('README', '/repo', { signal: expect.any(AbortSignal) })
|
||||
expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument()
|
||||
expect(mocks.wsSend).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('ComposerCapabilityMenu', () => {
|
||||
|
||||
fireEvent.change(searchInput(), { target: { value: 'no-such-capability' } })
|
||||
expect(await screen.findByText('No matching references')).toBeInTheDocument()
|
||||
expect(sessionCollaborationApi.list).toHaveBeenCalledWith('no-such-capability')
|
||||
expect(sessionCollaborationApi.list).toHaveBeenCalledWith('no-such-capability', expect.objectContaining({ signal: expect.any(AbortSignal) }))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ it('browses directory queries without filtering out their children', async () =>
|
||||
const onSelect = vi.fn()
|
||||
render(<ComposerReferenceMenu id="path" cwd="/work" filter="src/" references={references} onSelect={onSelect} />)
|
||||
await screen.findByRole('option', { name: 'app.ts' })
|
||||
expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true })
|
||||
expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true, signal: expect.any(AbortSignal) })
|
||||
expect(screen.queryByRole('option', { name: 'Design' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -90,6 +90,7 @@ it('discards late query and workspace results and never selects stale files', as
|
||||
const onSelect = vi.fn()
|
||||
const ref = createRef<ComposerReferenceMenuHandle>()
|
||||
const view = render(<ComposerReferenceMenu ref={ref} id="search" cwd="/old" filter="old" references={[]} onSelect={onSelect} />)
|
||||
await waitFor(() => expect(filesystemApi.search).toHaveBeenCalled())
|
||||
view.rerender(<ComposerReferenceMenu ref={ref} id="search" cwd="/work" filter="app" references={[]} onSelect={onSelect} />)
|
||||
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) })
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
@@ -118,6 +119,7 @@ it('preserves explicitly highlighted results when asynchronous files change thei
|
||||
const onSelect = vi.fn()
|
||||
render(<ComposerReferenceMenu ref={ref} id="pending" cwd="/work" filter="hyper" references={references} onSelect={onSelect} />)
|
||||
fireEvent.mouseEnter(screen.getByRole('option', { name: 'HyperFrames' }))
|
||||
await waitFor(() => expect(filesystemApi.search).toHaveBeenCalled())
|
||||
await act(async () => { resolveFiles({ currentPath: '/work', parentPath: '/', entries: [{ ...file, name: 'hyper', path: '/work/hyper' }] }) })
|
||||
expect(screen.getAllByRole('option')[0]).toHaveAccessibleName('hyper')
|
||||
expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveAttribute('aria-selected', 'true')
|
||||
@@ -138,7 +140,7 @@ it.each(['src/', 'src\\'])('keeps every child accessible while browsing %s', asy
|
||||
render(<ComposerReferenceMenu id="directory" cwd="/work" filter={filter} references={[]} onSelect={vi.fn()} />)
|
||||
await screen.findByRole('option', { name: 'file-14.ts' })
|
||||
expect(screen.getAllByRole('option')).toHaveLength(15)
|
||||
expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true })
|
||||
expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true, signal: expect.any(AbortSignal) })
|
||||
expect(filesystemApi.search).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -155,7 +157,7 @@ it('searches previous sessions and selects a structured reference without treati
|
||||
const onSelect = vi.fn()
|
||||
render(<ComposerReferenceMenu id="sessions" cwd="/work" filter="Auth" references={[]} onSelect={onSelect} />)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Auth review' }))
|
||||
expect(sessionCollaborationApi.list).toHaveBeenCalledWith('Auth')
|
||||
expect(sessionCollaborationApi.list).toHaveBeenCalledWith('Auth', expect.objectContaining({ signal: expect.any(AbortSignal) }))
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ kind: 'session', id: 'past-session', label: 'Auth review', path: '' }))
|
||||
})
|
||||
|
||||
@@ -206,7 +208,7 @@ it('discards stale session results and keeps colliding ID prefixes distinguishab
|
||||
const ref = createRef<ComposerReferenceMenuHandle>()
|
||||
const onSelect = vi.fn()
|
||||
const view = render(<ComposerReferenceMenu ref={ref} id="switch-query" cwd="/work" filter="old" references={[]} onSelect={onSelect} />)
|
||||
await waitFor(() => expect(sessionCollaborationApi.list).toHaveBeenCalledWith('old'))
|
||||
await waitFor(() => expect(sessionCollaborationApi.list).toHaveBeenCalledWith('old', expect.objectContaining({ signal: expect.any(AbortSignal) })))
|
||||
const sessions = ['same1234-A', 'same1234-B'].map(sessionId => ({ sessionId, title: 'Review', cwd: '/work/api', status: 'idle', updatedAt: '' }))
|
||||
vi.mocked(sessionCollaborationApi.list).mockResolvedValue({ sessions })
|
||||
view.rerender(<ComposerReferenceMenu ref={ref} id="switch-query" cwd="/work" filter="Review" references={[]} onSelect={onSelect} />)
|
||||
@@ -218,3 +220,43 @@ it('discards stale session results and keeps colliding ID prefixes distinguishab
|
||||
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); })
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'same1234-B' }))
|
||||
})
|
||||
|
||||
it('debounces keyword requests and aborts superseded and unmounted requests', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(filesystemApi.search).mockImplementation(() => new Promise(() => {}))
|
||||
vi.mocked(sessionCollaborationApi.list).mockImplementation(() => new Promise(() => {}))
|
||||
try {
|
||||
const view = render(<ComposerReferenceMenu id="cancel" cwd="/work" filter="修" references={[]} onSelect={vi.fn()} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100) })
|
||||
view.rerender(<ComposerReferenceMenu id="cancel" cwd="/work" filter="修复" references={[]} onSelect={vi.fn()} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(149) })
|
||||
expect(filesystemApi.search).not.toHaveBeenCalled()
|
||||
expect(sessionCollaborationApi.list).not.toHaveBeenCalled()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
|
||||
expect(filesystemApi.search).toHaveBeenCalledTimes(1)
|
||||
expect(sessionCollaborationApi.list).toHaveBeenCalledTimes(1)
|
||||
const fileSignal = vi.mocked(filesystemApi.search).mock.calls[0]![2]!.signal!
|
||||
const sessionSignal = vi.mocked(sessionCollaborationApi.list).mock.calls[0]![1]!.signal!
|
||||
expect(fileSignal.aborted).toBe(false)
|
||||
expect(sessionSignal.aborted).toBe(false)
|
||||
view.rerender(<ComposerReferenceMenu id="cancel" cwd="/work" filter="修复中" references={[]} onSelect={vi.fn()} />)
|
||||
expect(fileSignal.aborted).toBe(true)
|
||||
expect(sessionSignal.aborted).toBe(true)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
|
||||
const nextFileSignal = vi.mocked(filesystemApi.search).mock.calls[1]![2]!.signal!
|
||||
const nextSessionSignal = vi.mocked(sessionCollaborationApi.list).mock.calls[1]![1]!.signal!
|
||||
view.unmount()
|
||||
expect(nextFileSignal.aborted).toBe(true)
|
||||
expect(nextSessionSignal.aborted).toBe(true)
|
||||
} finally { vi.useRealTimers() }
|
||||
})
|
||||
|
||||
it('browses directories immediately and cancels their request on workspace change', () => {
|
||||
vi.mocked(filesystemApi.browse).mockImplementation(() => new Promise(() => {}))
|
||||
const view = render(<ComposerReferenceMenu id="browse-cancel" cwd="/work" filter="src/" references={[]} onSelect={vi.fn()} />)
|
||||
expect(filesystemApi.browse).toHaveBeenCalledTimes(1)
|
||||
const signal = vi.mocked(filesystemApi.browse).mock.calls[0]![1]!.signal!
|
||||
view.rerender(<ComposerReferenceMenu id="browse-cancel" cwd="/other" filter="src/" references={[]} onSelect={vi.fn()} />)
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(filesystemApi.browse).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -44,14 +44,15 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
|
||||
useEffect(() => {
|
||||
if (browseReferences) return
|
||||
let active = true
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
void sessionCollaborationApi.list(filter).then(data => {
|
||||
void sessionCollaborationApi.list(filter, { signal: controller.signal }).then(data => {
|
||||
if (active) setSessionResult({ query: filter, sessions: data.sessions })
|
||||
}, () => {
|
||||
if (active) setSessionResult({ query: filter, sessions: [], error: true })
|
||||
})
|
||||
}, 150)
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
return () => { active = false; clearTimeout(timer); controller.abort() }
|
||||
}, [filter, browseReferences])
|
||||
const sessionCandidates = !browseReferences && sessionResult?.query === filter ? sessionResult.sessions : []
|
||||
const sessionLoading = !browseReferences && sessionResult?.query !== filter
|
||||
@@ -73,18 +74,23 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
|
||||
useEffect(() => {
|
||||
if (browseReferences || !filter.trim() && override === undefined) return
|
||||
let active = true
|
||||
const controller = new AbortController()
|
||||
const base = (cwd || rootRef.current.path).replace(/[\\/]+$/, '')
|
||||
const path = override ?? (browsingDirectory && base ? `${base}/${directoryQuery.replace(/\/+$/, '')}` : base)
|
||||
const search = override || browsingDirectory ? '' : directoryQuery
|
||||
const request = search ? filesystemApi.search(search, path) : filesystemApi.browse(path, { includeFiles: true })
|
||||
void request.then(data => {
|
||||
if (!active) return
|
||||
if (!rootRef.current.path) rootRef.current = { cwd, path: data.currentPath }
|
||||
setResult({ key: queryKey, entries: data.entries, current: data.currentPath, root: rootRef.current.path })
|
||||
}, error => {
|
||||
if (active) setResult({ key: queryKey, entries: [], current: path, root: base, error: error instanceof ApiError && error.status === 403 ? 'denied' : 'failed' })
|
||||
})
|
||||
return () => { active = false }
|
||||
const run = () => {
|
||||
const request = search ? filesystemApi.search(search, path, { signal: controller.signal }) : filesystemApi.browse(path, { includeFiles: true, signal: controller.signal })
|
||||
void request.then(data => {
|
||||
if (!active) return
|
||||
if (!rootRef.current.path) rootRef.current = { cwd, path: data.currentPath }
|
||||
setResult({ key: queryKey, entries: data.entries, current: data.currentPath, root: rootRef.current.path })
|
||||
}, error => {
|
||||
if (active) setResult({ key: queryKey, entries: [], current: path, root: base, error: error instanceof ApiError && error.status === 403 ? 'denied' : 'failed' })
|
||||
})
|
||||
}
|
||||
const timer = search ? setTimeout(run, 150) : undefined
|
||||
if (!search) run()
|
||||
return () => { active = false; clearTimeout(timer); controller.abort() }
|
||||
}, [cwd, filter, override, queryKey, browseReferences, directoryQuery, browsingDirectory])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('EmptySession', () => {
|
||||
})
|
||||
expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md')
|
||||
expect(getComposerText()).toContain('Please review @README.md')
|
||||
expect(mocks.search).toHaveBeenCalledWith('README', '/workspace/project')
|
||||
expect(mocks.search).toHaveBeenCalledWith('README', '/workspace/project', { signal: expect.any(AbortSignal) })
|
||||
expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument()
|
||||
expect(mocks.wsSend).not.toHaveBeenCalled()
|
||||
expect(mocks.createSession).not.toHaveBeenCalled()
|
||||
|
||||
@@ -4,6 +4,23 @@ import type { SessionCollaborationService } from '../services/sessionCollaborati
|
||||
|
||||
const request = (body: unknown) => new Request('http://127.0.0.1/api/session-collaboration/send', { method: 'POST', body: JSON.stringify(body) })
|
||||
|
||||
test('UI and tool search pass request cancellation to the query service', async () => {
|
||||
const controller = new AbortController()
|
||||
const signals: AbortSignal[] = []
|
||||
const service = {
|
||||
candidates: async (_query: string, signal: AbortSignal) => { signals.push(signal); return { sessions: [] } },
|
||||
list: async ({ signal }: { signal: AbortSignal }) => { signals.push(signal); return { sessions: [] } },
|
||||
} as unknown as SessionCollaborationService
|
||||
const url = new URL('http://127.0.0.1/api/session-collaboration?query=repair')
|
||||
const uiRequest = new Request(url, { signal: controller.signal })
|
||||
expect((await handleSessionCollaborationUiApi(uiRequest, url, service)).status).toBe(200)
|
||||
const toolRequest = new Request(url, { method: 'POST', body: JSON.stringify({ query: 'repair' }), signal: controller.signal })
|
||||
expect((await handleSessionCollaborationApi(toolRequest, 'list', 'caller', service)).status).toBe(200)
|
||||
controller.abort()
|
||||
expect(signals).toHaveLength(2)
|
||||
expect(signals.every(signal => signal.aborted)).toBe(true)
|
||||
})
|
||||
|
||||
test('tool API derives message sender from authenticated caller, not request body', async () => {
|
||||
let args: unknown[] = []
|
||||
const service = { send: async (...input: unknown[]) => { args = input; return { id: 'm' } } } as unknown as SessionCollaborationService
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function handleSessionCollaborationApi(req: Request, action: string
|
||||
body = value
|
||||
} catch { throw ApiError.badRequest('A JSON object is required') }
|
||||
switch (action) {
|
||||
case 'list': return Response.json(await service.list({ query: stringField(body, 'query'), limit: numberField(body, 'limit'), offset: numberField(body, 'offset') }))
|
||||
case 'list': return Response.json(await service.list({ query: stringField(body, 'query'), limit: numberField(body, 'limit'), offset: numberField(body, 'offset'), signal: req.signal }))
|
||||
case 'read': return Response.json(await service.read(stringField(body, 'sessionId', true)!, {
|
||||
cursor: stringField(body, 'cursor'), limit: numberField(body, 'limit'), signal: req.signal,
|
||||
includeOutputs: body.includeOutputs === true, maxOutputCharsPerItem: numberField(body, 'maxOutputCharsPerItem'),
|
||||
@@ -53,7 +53,7 @@ export async function handleSessionCollaborationApi(req: Request, action: string
|
||||
export async function handleSessionCollaborationUiApi(req: Request, url: URL, service: SessionCollaborationService): Promise<Response> {
|
||||
try {
|
||||
const parts = url.pathname.split('/').filter(Boolean).slice(2)
|
||||
if (!parts.length && req.method === 'GET') return Response.json(await service.candidates(url.searchParams.get('query') || undefined))
|
||||
if (!parts.length && req.method === 'GET') return Response.json(await service.candidates(url.searchParams.get('query') || undefined, req.signal))
|
||||
const sessionId = parts[0]
|
||||
if (!sessionId) throw ApiError.notFound('Session id required')
|
||||
if (parts.length === 2 && parts[1] === 'status' && req.method === 'GET') return Response.json(await service.groupStatus(sessionId))
|
||||
|
||||
@@ -1437,6 +1437,18 @@ export function createLocalIndexCoordinator(
|
||||
}
|
||||
},
|
||||
|
||||
getSessionSuggestionMetadata(sessionIds) {
|
||||
if (!indexReadAllowed() || !index?.getSessionSuggestionMetadata) return null
|
||||
try { return index.getSessionSuggestionMetadata(sessionIds) }
|
||||
catch (error) { markDegraded(error, 'LOCAL_INDEX_READ_FAILED'); return null }
|
||||
},
|
||||
|
||||
searchSessionMetadata(query, options): SessionIndexPage | null {
|
||||
if (!indexReadAllowed() || !index?.searchSessionMetadata) return null
|
||||
try { return index.searchSessionMetadata(query, options) }
|
||||
catch (error) { markDegraded(error, 'LOCAL_INDEX_READ_FAILED'); return null }
|
||||
},
|
||||
|
||||
findSearchCandidates(
|
||||
filters: SessionSearchCandidateFilters,
|
||||
): IndexedSessionSearchCandidate[] | null {
|
||||
|
||||
@@ -175,16 +175,21 @@ describe('search content coordinator', () => {
|
||||
|
||||
await coordinator.start()
|
||||
expect(coordinator.search('sqlite')).toBeNull()
|
||||
expect(coordinator.suggestSessions('sqlite')).toBeNull()
|
||||
await waitForStatus(coordinator.getStatus, 'ready')
|
||||
expect(coordinator.search('sqlite')?.sessions[0]).toMatchObject({
|
||||
ownerSessionId: 'owner-session',
|
||||
matchCount: 1,
|
||||
})
|
||||
|
||||
expect(coordinator.suggestSessions('sqlite')?.sessions[0]?.ownerSessionId).toBe('owner-session')
|
||||
expect(coordinator.suggestSessions('sqlite', { signal: AbortSignal.abort() })).toBeNull()
|
||||
|
||||
await mkdir(join(nested, '..'), { recursive: true })
|
||||
await writeFile(nested, userLine('nested workflow needle', 'nested-message'))
|
||||
watcherOptions?.onDirty?.()
|
||||
expect(coordinator.search('needle')).toBeNull()
|
||||
expect(coordinator.suggestSessions('needle')).toBeNull()
|
||||
await watcherOptions?.onBatch({ paths: [nested], fullSweep: false } satisfies ReconciliationBatch)
|
||||
await waitForStatus(coordinator.getStatus, 'ready')
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
createSearchContentIndex,
|
||||
type SearchContentIndex,
|
||||
type SearchContentSuggestions,
|
||||
type SearchContentQueryOptions,
|
||||
type SearchContentQueryResult,
|
||||
} from './searchContentIndex.js'
|
||||
@@ -47,6 +48,7 @@ export type SearchContentCoordinatorStatus = {
|
||||
}
|
||||
|
||||
export interface SearchContentCoordinator {
|
||||
suggestSessions(query: string, options?: { limit?: number; signal?: AbortSignal }): SearchContentSuggestions | null
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
search(
|
||||
@@ -765,6 +767,17 @@ export function createSearchContentCoordinator(
|
||||
resetStatus: true,
|
||||
})
|
||||
},
|
||||
suggestSessions(query, options = {}) {
|
||||
if (!started || !hasCompleteSweep || status.state !== 'ready' || !index || options.signal?.aborted) return null
|
||||
try {
|
||||
const result = index.querySessionSuggestions(query, options.limit)
|
||||
return options.signal?.aborted ? null : result
|
||||
} catch {
|
||||
// Suggestions are a disposable projection; never repair or scan
|
||||
// transcript files on the keystroke path when SQLite is unavailable.
|
||||
return null
|
||||
}
|
||||
},
|
||||
search(query, options = {}) {
|
||||
if (
|
||||
!started ||
|
||||
|
||||
@@ -244,3 +244,75 @@ describe('search content index', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds Chinese broad-match suggestions to distinct owners without materializing transcript bodies', async () => {
|
||||
const { database, index } = await setup()
|
||||
try {
|
||||
const body = '修复中文协作问题 '.repeat(160)
|
||||
for (let owner = 0; owner < 120; owner++) {
|
||||
index.replaceSource(source({ path: `/fixture/${owner}.jsonl`, ownerSessionId: `owner-${owner}`,
|
||||
ownerTranscriptPath: `/fixture/${owner}.jsonl`, modifiedAtMs: owner }),
|
||||
Array.from({ length: 100 }, (_, line) => ({ jsonlLine: line + 1, byteStart: line * 100,
|
||||
byteLength: 100, segmentIndex: 0, role: 'user' as const, messageId: null,
|
||||
timestamp: null, body, normalizedBody: normalizeSearchContent(body) })))
|
||||
}
|
||||
index.replaceSource(source({ path: '/fixture/subagent.jsonl', ownerSessionId: 'owner-119',
|
||||
ownerTranscriptPath: '/fixture/119.jsonl', modifiedAtMs: 119 }), [{ jsonlLine: 1,
|
||||
byteStart: 0, byteLength: 100, segmentIndex: 0, role: 'assistant', messageId: null,
|
||||
timestamp: null, body, normalizedBody: normalizeSearchContent(body) }])
|
||||
expect(index.querySessionSuggestions('修复', 20)).toBeNull()
|
||||
index.setReadiness({ state: 'ready', discovered: 121, indexed: 121 })
|
||||
const start = performance.now()
|
||||
const result = index.querySessionSuggestions('修复', 20)!
|
||||
const elapsed = performance.now() - start
|
||||
console.log(`[suggestion benchmark] 12001 matching Chinese documents: ${elapsed.toFixed(1)}ms, ${JSON.stringify(result).length} response chars`)
|
||||
expect(result.sessions).toHaveLength(20)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(new Set(result.sessions.map(row => row.ownerSessionId)).size).toBe(20)
|
||||
expect(result.sessions[0]?.ownerSessionId).toBe('owner-119')
|
||||
expect(JSON.stringify(result)).not.toContain('修复')
|
||||
expect(elapsed).toBeLessThan(1000)
|
||||
expect(index.querySessionSuggestions('中文协作', 5)?.sessions).toHaveLength(5)
|
||||
expect(index.querySessionSuggestions('不存在', 5)?.sessions).toHaveLength(0)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('scans global FTS once for common and rare long queries across a thousand sources', async () => {
|
||||
const { database, index } = await setup()
|
||||
try {
|
||||
for (let owner = 0; owner < 1000; owner++) {
|
||||
const body = '修复一下这些中文协作问题 '.repeat(80) + (owner === 999 ? '稀有字符串专用' : '')
|
||||
index.replaceSource(source({ path: `/fixture/${owner}.jsonl`, ownerSessionId: `owner-${owner}`,
|
||||
ownerTranscriptPath: `/fixture/${owner}.jsonl`, modifiedAtMs: owner }),
|
||||
Array.from({ length: 10 }, (_, line) => ({ jsonlLine: line + 1, byteStart: line * 100,
|
||||
byteLength: 100, segmentIndex: 0, role: 'user' as const, messageId: null,
|
||||
timestamp: null, body, normalizedBody: normalizeSearchContent(body) })))
|
||||
}
|
||||
index.setReadiness({ state: 'ready', discovered: 1000, indexed: 1000 })
|
||||
const plans: string[][] = []
|
||||
const inspected = createSearchContentIndex({ ...database,
|
||||
read: operation => database.read(reader => operation({ ...reader,
|
||||
all: (sql, ...bindings) => {
|
||||
if (sql.includes('search_documents_fts MATCH')) {
|
||||
plans.push(reader.all<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, ...bindings).map(row => row.detail))
|
||||
}
|
||||
return reader.all(sql, ...bindings)
|
||||
},
|
||||
})),
|
||||
}, { scope: index.getReadiness()!.scope })
|
||||
for (const [query, count] of [['修复一下', 60], ['稀有字符串专用', 1]] as const) {
|
||||
const start = performance.now()
|
||||
const result = inspected.querySessionSuggestions(query, 60)!
|
||||
console.log(`[FTS suggestion benchmark] 1000 sources / 10000 documents, ${query}: ${(performance.now() - start).toFixed(1)}ms`)
|
||||
expect(result.sessions).toHaveLength(count)
|
||||
expect(result.sessions[0]?.ownerSessionId).toBe('owner-999')
|
||||
expect(result.truncated).toBe(count === 60)
|
||||
expect(new Set(result.sessions.map(item => item.ownerSessionId)).size).toBe(count)
|
||||
}
|
||||
expect(plans).toHaveLength(2)
|
||||
for (const plan of plans) {
|
||||
expect(plan.some(step => step.includes('CORRELATED'))).toBe(false)
|
||||
expect(plan.filter(step => step.includes('search_documents_fts'))).toHaveLength(1)
|
||||
}
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
@@ -106,7 +106,13 @@ export type SearchContentQueryOptions = {
|
||||
caseSensitive?: boolean
|
||||
}
|
||||
|
||||
export type SearchContentSuggestions = {
|
||||
sessions: Array<{ ownerSessionId: string; ownerTranscriptPath: string; projectPath: string; modifiedAtMs: number }>
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface SearchContentIndex {
|
||||
querySessionSuggestions(query: string, limit?: number): SearchContentSuggestions | null
|
||||
getSource(path: string): SearchContentSource | null
|
||||
listSources(): SearchContentSource[]
|
||||
countSources(): number
|
||||
@@ -396,6 +402,45 @@ export function createSearchContentIndex(
|
||||
readiness.updatedAtMs ?? now())
|
||||
})
|
||||
},
|
||||
querySessionSuggestions(query, requestedLimit) {
|
||||
const normalized = normalizeSearchContent(query.trim())
|
||||
if (!normalized) return { sessions: [], truncated: false }
|
||||
const ready = database.read(reader => reader.get<ReadinessRow>(
|
||||
'SELECT * FROM search_backfill_state WHERE scope = ?', options.scope,
|
||||
))
|
||||
if (ready?.state !== 'ready') return null
|
||||
const limit = clampInteger(requestedLimit, DEFAULT_SESSION_LIMIT, MAX_SESSION_LIMIT)
|
||||
const useFts = Array.from(normalized).length >= 3
|
||||
// Scan global FTS matches once. Correlating MATCH with each source
|
||||
// repeats that global scan for every transcript on common long queries.
|
||||
// Short queries instead seek each source and stop at its first segment.
|
||||
const selection = useFts
|
||||
? `FROM search_documents_fts
|
||||
JOIN search_documents AS document ON document.id = search_documents_fts.rowid
|
||||
JOIN search_sources AS source ON source.path = document.source_path
|
||||
WHERE search_documents_fts MATCH ? AND source.state = 'ready'
|
||||
AND instr(document.normalized_body, ?) > 0`
|
||||
: `FROM search_sources AS source
|
||||
WHERE source.state = 'ready' AND EXISTS (
|
||||
SELECT 1 FROM search_documents AS document
|
||||
WHERE document.source_path = source.path AND instr(document.normalized_body, ?) > 0
|
||||
)`
|
||||
const rows = database.read(reader => reader.all<{
|
||||
owner_session_id: string; owner_transcript_path: string; project_path: string; modified_at_ms: number
|
||||
}>(`
|
||||
SELECT source.owner_session_id, source.owner_transcript_path,
|
||||
source.project_path, MAX(source.modified_at_ms) AS modified_at_ms
|
||||
${selection}
|
||||
GROUP BY source.owner_transcript_path
|
||||
ORDER BY modified_at_ms DESC, source.owner_transcript_path
|
||||
LIMIT ?
|
||||
`, ...(useFts ? [ftsPhrase(normalized)] : []), normalized, limit + 1))
|
||||
return {
|
||||
sessions: rows.slice(0, limit).map(row => ({ ownerSessionId: row.owner_session_id,
|
||||
ownerTranscriptPath: row.owner_transcript_path, projectPath: row.project_path, modifiedAtMs: row.modified_at_ms })),
|
||||
truncated: rows.length > limit,
|
||||
}
|
||||
},
|
||||
query(query, queryOptions = {}) {
|
||||
const literalQuery = query.trim()
|
||||
const normalizedQuery = normalizeSearchContent(literalQuery)
|
||||
|
||||
@@ -57,6 +57,8 @@ export type SessionEntryLocatorPage = {
|
||||
}
|
||||
|
||||
export interface SessionIndexReader {
|
||||
getSessionSuggestionMetadata?(sessionIds: string[]): IndexedSessionRow[] | null
|
||||
searchSessionMetadata?(query: string, options?: { limit?: number; offset?: number }): SessionIndexPage | null
|
||||
listSessions(options?: {
|
||||
project?: string
|
||||
limit?: number
|
||||
@@ -300,6 +302,46 @@ export function createSessionIndex(database: LocalIndexDatabase): SessionIndex {
|
||||
})
|
||||
},
|
||||
|
||||
getSessionSuggestionMetadata(sessionIds): IndexedSessionRow[] {
|
||||
const ids = [...new Set(sessionIds)].slice(0, 100)
|
||||
if (!ids.length) return []
|
||||
return database.read(operation => operation.all<SessionRow>(`
|
||||
SELECT transcript_path, session_id, project_path, title, created_at,
|
||||
modified_at, message_count, work_dir, permission_mode,
|
||||
runtime_provider_id, runtime_provider_present, runtime_model_id,
|
||||
effort_level, repository_json, worktree_session_json
|
||||
FROM sessions WHERE session_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY modified_at_ms DESC, session_id ASC, transcript_path ASC
|
||||
LIMIT 100
|
||||
`, ...ids).map(sessionFromRow))
|
||||
},
|
||||
|
||||
searchSessionMetadata(query, options): SessionIndexPage {
|
||||
const needle = query.trim().toLowerCase()
|
||||
const limit = Math.min(100, Math.max(1, boundedInteger(options?.limit, 30)))
|
||||
const offset = boundedInteger(options?.offset, 0)
|
||||
return database.read(operation => {
|
||||
const where = `instr(lower(title), ?) > 0 OR instr(lower(session_id), ?) > 0 OR instr(lower(coalesce(work_dir, '')), ?) > 0 OR instr(lower(project_path), ?) > 0`
|
||||
const total = operation.get<{ total: number }>(`SELECT COUNT(*) AS total FROM sessions WHERE ${where}`, needle, needle, needle, needle)?.total ?? 0
|
||||
const rows = operation.all<SessionRow>(`
|
||||
SELECT transcript_path, session_id, project_path, title, created_at,
|
||||
modified_at, message_count, work_dir, permission_mode,
|
||||
runtime_provider_id, runtime_provider_present, runtime_model_id,
|
||||
effort_level, repository_json, worktree_session_json
|
||||
FROM sessions WHERE ${where}
|
||||
ORDER BY CASE
|
||||
WHEN ? = '' THEN 0
|
||||
WHEN lower(title) = ? OR lower(session_id) = ? THEN 3
|
||||
WHEN instr(lower(title), ?) = 1 OR instr(lower(session_id), ?) = 1 THEN 2
|
||||
WHEN instr(lower(title), ?) > 0 OR instr(lower(session_id), ?) > 0 THEN 1
|
||||
ELSE 0 END DESC,
|
||||
modified_at_ms DESC, session_id ASC, transcript_path ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`, needle, needle, needle, needle, needle, needle, needle, needle, needle, needle, needle, limit, offset)
|
||||
return { sessions: rows.map(sessionFromRow), total }
|
||||
})
|
||||
},
|
||||
|
||||
findSessionFiles(sessionId): SessionFileMatch[] {
|
||||
return database.read(operation => operation.all<{
|
||||
transcript_path: string
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { openLocalIndexDatabase, type LocalIndexDatabase } from './database.js'
|
||||
import { createSessionIndex, type SessionIndex } from './sessionIndex.js'
|
||||
|
||||
let directory: string
|
||||
let database: LocalIndexDatabase
|
||||
let index: SessionIndex
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'metadata-search-'))
|
||||
database = openLocalIndexDatabase({ scope: directory, path: join(directory, 'cc-haha/db/index-v1.sqlite') })
|
||||
index = createSessionIndex(database)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
database.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seed(rows: Array<{ id: string; title: string; modified?: number; workDir?: string }>) {
|
||||
database.transaction(op => {
|
||||
for (const row of rows) {
|
||||
const path = `/fixture/${row.id}.jsonl`
|
||||
op.run("INSERT INTO source_files(path,kind,size_bytes,mtime_ms,prefix_hash,parser_version,state,updated_at_ms) VALUES (?,'transcript',0,0,'fixture',1,'ready',0)", path)
|
||||
op.run('INSERT INTO sessions(transcript_path,session_id,project_path,title,created_at,modified_at,modified_at_ms,message_count,work_dir) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
path, row.id, 'fixture-project', row.title, '2026-01-01',
|
||||
new Date(row.modified ?? 0).toISOString(), row.modified ?? 0, 1, row.workDir ?? '/fixture',
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('Chinese exact title and id outrank thousands of recent fuzzy hits before LIMIT', () => {
|
||||
seed([
|
||||
...Array.from({ length: 10000 }, (_, i) => ({ id: `recent-${i}`, title: `最近讨论设计文档 ${i}`, modified: i + 100 })),
|
||||
{ id: 'old-title', title: '设计文档' },
|
||||
{ id: '设计文档', title: '旧对话' },
|
||||
])
|
||||
const started = performance.now()
|
||||
const result = index.searchSessionMetadata!('设计文档', { limit: 30 })!
|
||||
expect(result.total).toBe(10002)
|
||||
expect(result.sessions).toHaveLength(30)
|
||||
expect(result.sessions.slice(0, 2).map(row => row.id)).toEqual(['old-title', '设计文档'])
|
||||
const second = index.searchSessionMetadata!('设计文档', { limit: 30, offset: 30 })!
|
||||
expect(second.sessions.some(row => result.sessions.some(previous => previous.id === row.id))).toBe(false)
|
||||
console.info(`[metadata-search benchmark] 10002 rows, two bounded queries: ${(performance.now() - started).toFixed(1)}ms`)
|
||||
})
|
||||
|
||||
test('wildcards are literal, path matches work, and metadata batches stay bounded', () => {
|
||||
seed([
|
||||
{ id: 'literal', title: '100%_done' },
|
||||
{ id: 'wildcard-lookalike', title: '100XXdone' },
|
||||
{ id: 'path', title: 'other', workDir: '/项目/中文路径' },
|
||||
...Array.from({ length: 150 }, (_, i) => ({ id: `batch-${i}`, title: 'batch' })),
|
||||
])
|
||||
expect(index.searchSessionMetadata!('%_', { limit: 30 })!.sessions.map(row => row.id)).toEqual(['literal'])
|
||||
expect(index.searchSessionMetadata!('中文路径')!.sessions.map(row => row.id)).toEqual(['path'])
|
||||
expect(index.searchSessionMetadata!('FIXTURE-PROJECT')!.total).toBe(153)
|
||||
expect(index.getSessionSuggestionMetadata!(Array.from({ length: 150 }, (_, i) => `batch-${i}`))).toHaveLength(100)
|
||||
expect(index.getSessionSuggestionMetadata!([])).toEqual([])
|
||||
})
|
||||
|
||||
test('empty query is recent metadata order even when an old title is empty', () => {
|
||||
seed([
|
||||
{ id: 'old-empty', title: '', modified: 0 },
|
||||
{ id: 'recent', title: 'recent', modified: 100 },
|
||||
{ id: 'middle', title: 'middle', modified: 50 },
|
||||
])
|
||||
expect(index.searchSessionMetadata!('', { limit: 2 })!.sessions.map(row => row.id)).toEqual(['recent', 'middle'])
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, mock, test } from 'bun:test'
|
||||
import { SearchService } from './searchService.js'
|
||||
|
||||
test('session suggestions use only projected IDs even when files are unavailable', async () => {
|
||||
const forbidden = mock(() => { throw new Error('Transcript IO is forbidden for suggestions') })
|
||||
const service = new SearchService({
|
||||
searchIndexedContent: forbidden, readEntriesAtLines: forbidden,
|
||||
getMetadataForPaths: forbidden, getCandidatesForFilters: forbidden, resolveRipgrepCommand: forbidden,
|
||||
suggestIndexedSessions: () => ({ sessions: [{ ownerSessionId: 'id', ownerTranscriptPath: '/missing/id.jsonl', projectPath: '-repo', modifiedAtMs: 1 }], truncated: true }),
|
||||
})
|
||||
expect(await service.searchSessionSuggestions('修复')).toEqual({ sessions: [{ sessionId: 'id', ownerTranscriptPath: '/missing/id.jsonl', projectPath: '-repo', modifiedAt: new Date(1).toISOString() }], truncated: true, indexUnavailable: false })
|
||||
expect(forbidden).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('unready indexes produce an explicit partial response without fallback and honor cancellation', async () => {
|
||||
const forbidden = mock(() => { throw new Error('No fallback') })
|
||||
const service = new SearchService({ suggestIndexedSessions: () => null, searchIndexedContent: forbidden, resolveRipgrepCommand: forbidden })
|
||||
expect(await service.searchSessionSuggestions('修复')).toEqual({ sessions: [], truncated: true, indexUnavailable: true })
|
||||
expect(forbidden).not.toHaveBeenCalled()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(service.searchSessionSuggestions('修复', { signal: controller.signal })).rejects.toThrow()
|
||||
const during = new AbortController()
|
||||
const cancelling = new SearchService({ suggestIndexedSessions: () => { during.abort(); return null } })
|
||||
await expect(cancelling.searchSessionSuggestions('修复', { signal: during.signal })).rejects.toThrow()
|
||||
})
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
searchContentCoordinator,
|
||||
} from './localIndex/searchContentCoordinator.js'
|
||||
import type {
|
||||
SearchContentSuggestions,
|
||||
SearchContentMatch,
|
||||
SearchContentQueryOptions,
|
||||
SearchContentQueryResult,
|
||||
@@ -203,6 +204,7 @@ type SearchEntryLineRead = {
|
||||
}
|
||||
|
||||
type SearchServiceOptions = {
|
||||
suggestIndexedSessions?: (query: string, options: { limit?: number; signal?: AbortSignal }) => SearchContentSuggestions | null
|
||||
readEntriesAtLines?: (
|
||||
filePath: string,
|
||||
lineNumbers: Set<number>,
|
||||
@@ -231,6 +233,7 @@ function isAbortError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
export class SearchService {
|
||||
private readonly suggestIndexedSessions: NonNullable<SearchServiceOptions['suggestIndexedSessions']>
|
||||
private readonly readEntriesAtLines: NonNullable<SearchServiceOptions['readEntriesAtLines']>
|
||||
private readonly getMetadataForPaths: NonNullable<SearchServiceOptions['getMetadataForPaths']>
|
||||
private readonly getCandidatesForFilters: NonNullable<SearchServiceOptions['getCandidatesForFilters']>
|
||||
@@ -239,6 +242,7 @@ export class SearchService {
|
||||
private readonly commandAvailability = new Map<string, Promise<boolean>>()
|
||||
|
||||
constructor(options: SearchServiceOptions = {}) {
|
||||
this.suggestIndexedSessions = options.suggestIndexedSessions ?? ((query, options) => searchContentCoordinator.suggestSessions(query, options))
|
||||
this.readEntriesAtLines = options.readEntriesAtLines ?? ((filePath, lineNumbers) =>
|
||||
sessionService.readSessionEntriesAtLines(
|
||||
filePath,
|
||||
@@ -302,6 +306,21 @@ export class SearchService {
|
||||
// 会话历史搜索
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Menu suggestions deliberately trust the disposable index, never opening canonical transcripts. */
|
||||
async searchSessionSuggestions(query: string, options: { limit?: number; signal?: AbortSignal } = {}) {
|
||||
throwIfAborted(options.signal)
|
||||
if (!query.trim()) return { sessions: [], truncated: false, indexUnavailable: false }
|
||||
const projected = this.suggestIndexedSessions(query, options)
|
||||
throwIfAborted(options.signal)
|
||||
return {
|
||||
sessions: projected?.sessions.map(item => ({ sessionId: item.ownerSessionId,
|
||||
ownerTranscriptPath: item.ownerTranscriptPath, projectPath: item.projectPath,
|
||||
modifiedAt: new Date(item.modifiedAtMs).toISOString() })) ?? [],
|
||||
truncated: projected?.truncated ?? true,
|
||||
indexUnavailable: projected === null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search across all session transcripts.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { SessionCollaborationService, type CollaborationMessage } from './sessionCollaborationService.js'
|
||||
import { handleSessionCollaborationEvent, configureSessionCollaborationHost, getSessionCollaborationService, findSessionMetadataMatches } from './sessionCollaborationHost.js'
|
||||
import { handleSessionCollaborationEvent, configureSessionCollaborationHost, getSessionCollaborationService } from './sessionCollaborationHost.js'
|
||||
import { sessionService } from './sessionService.js'
|
||||
import { admitSessionUserTurn, emitSessionTurnEvent } from './sessionTurnEvents.js'
|
||||
import { SearchService } from './searchService.js'
|
||||
@@ -69,24 +69,6 @@ test('host rejects unavailable source workspace explicitly instead of silently c
|
||||
}
|
||||
})
|
||||
|
||||
test('metadata search finds old titles and ids beyond the first hundred sessions without conflating equal titles', async () => {
|
||||
const sessions = Array.from({ length: 235 }, (_, index) => ({
|
||||
id: `session-${index}`, title: index === 230 ? 'Unique archived design' : index === 1 || index === 201 ? 'Same title' : 'Ordinary',
|
||||
workDir: index === 234 ? '/fixture/old-project' : '/fixture/project',
|
||||
}))
|
||||
const requests: number[] = []
|
||||
const list = async ({ limit, offset }: { limit: number; offset: number }) => {
|
||||
requests.push(offset)
|
||||
return { sessions: sessions.slice(offset, offset + limit), total: sessions.length }
|
||||
}
|
||||
expect((await findSessionMetadataMatches('unique archived', list)).map(item => item.id)).toEqual(['session-230'])
|
||||
expect(requests).toEqual([0, 100, 200])
|
||||
expect((await findSessionMetadataMatches('session-229', list)).map(item => item.id)).toEqual(['session-229'])
|
||||
expect((await findSessionMetadataMatches('same title', list)).map(item => item.id)).toEqual(['session-1', 'session-201'])
|
||||
expect((await findSessionMetadataMatches('/old-project', list)).map(item => item.id)).toEqual(['session-234'])
|
||||
})
|
||||
|
||||
|
||||
test('manual admission waits for already emitted Stop events before reserving its slot', async () => {
|
||||
const previous = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = directory
|
||||
@@ -117,25 +99,52 @@ test('manual admission waits for already emitted Stop events before reserving it
|
||||
})
|
||||
|
||||
|
||||
test('old exact title and id matches rank before the server candidate limit', async () => {
|
||||
test('a full metadata page skips transcript search and returns immediately from the bounded index', async () => {
|
||||
const previous = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = directory
|
||||
const dispose = configureSessionCollaborationHost('127.0.0.1', 1234)
|
||||
const sessions = [
|
||||
...Array.from({ length: 45 }, (_, i) => ({ id: `recent-${i}`, title: `Recent needle discussion ${i}`, workDir: '/fixture' })),
|
||||
{ id: 'old-title', title: 'needle', workDir: '/fixture' },
|
||||
{ id: 'needle', title: 'Old conversation', workDir: '/fixture' },
|
||||
]
|
||||
spyOn(sessionService, 'listSessions').mockImplementation(async ({ offset = 0, limit = 30 } = {}) => ({ sessions: sessions.slice(offset, offset + limit), total: sessions.length }) as any)
|
||||
spyOn(SearchService.prototype, 'searchSessions').mockResolvedValue({ results: [], truncated: false } as any)
|
||||
const sessions = Array.from({ length: 30 }, (_, i) => ({ id: `match-${i}`, title: '修复', workDir: '/fixture', projectPath: 'project', modifiedAt: 'now' }))
|
||||
const metadata = spyOn(sessionService, 'searchSessionMetadata').mockResolvedValue({ sessions, total: 20_000 })
|
||||
const list = spyOn(sessionService, 'listSessions').mockRejectedValue(new Error('Must not hydrate the sidebar'))
|
||||
const fullText = spyOn(SearchService.prototype, 'searchSessions').mockRejectedValue(new Error('Must not read transcripts'))
|
||||
const suggestions = spyOn(SearchService.prototype, 'searchSessionSuggestions').mockRejectedValue(new Error('Already have a full metadata page'))
|
||||
try {
|
||||
const service = await getSessionCollaborationService()
|
||||
const result = await service.candidates('needle')
|
||||
expect(result.sessions).toHaveLength(30)
|
||||
expect(result.sessions.slice(0, 2).map(session => session.sessionId)).toEqual(['old-title', 'needle'])
|
||||
const result = await service.candidates('修复')
|
||||
expect(result.sessions.map(item => item.sessionId)).toEqual(sessions.map(item => item.id))
|
||||
expect(metadata).toHaveBeenCalledWith('修复', { limit: 30, offset: 0, signal: undefined })
|
||||
expect(await service.list({ query: '修复' })).toMatchObject({ total: 20_000, totalIsLowerBound: true, truncated: true })
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
expect(fullText).not.toHaveBeenCalled()
|
||||
expect(suggestions).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
mock.restore()
|
||||
dispose(); mock.restore()
|
||||
if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = previous
|
||||
}
|
||||
})
|
||||
|
||||
test('content suggestions fill metadata pages without duplicate metadata hits or canonical history reads', async () => {
|
||||
const previous = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = directory
|
||||
const dispose = configureSessionCollaborationHost('127.0.0.1', 1234)
|
||||
const title = { id: 'title', title: 'Project match', workDir: '/fixture', projectPath: '修复-project', modifiedAt: 'now' }
|
||||
const body = { id: 'body', title: 'Older discussion', workDir: '/fixture', projectPath: 'project', modifiedAt: 'before' }
|
||||
spyOn(sessionService, 'searchSessionMetadata').mockImplementation(async (_query, options) => ({ sessions: options?.offset ? [] : [title], total: 1 }))
|
||||
spyOn(sessionService, 'getSessionSuggestionMetadata').mockReturnValue([title, body])
|
||||
const fullText = spyOn(SearchService.prototype, 'searchSessions').mockRejectedValue(new Error('Must not scan files'))
|
||||
spyOn(SearchService.prototype, 'searchSessionSuggestions').mockResolvedValue({
|
||||
sessions: [title, body, body].map(item => ({ sessionId: item.id, projectPath: item.projectPath, modifiedAt: item.modifiedAt, ownerTranscriptPath: `/fixture/${item.id}.jsonl` })),
|
||||
truncated: false, indexUnavailable: false,
|
||||
})
|
||||
try {
|
||||
const service = await getSessionCollaborationService()
|
||||
expect((await service.candidates('修复')).sessions.map(item => item.sessionId)).toEqual(['title', 'body'])
|
||||
expect((await service.list({ query: '修复', offset: 1, limit: 1 }) as any).sessions.map((item: any) => item.id)).toEqual(['body'])
|
||||
expect((await service.list({ query: '修复', offset: 2, limit: 1 }) as any).sessions).toEqual([])
|
||||
expect(fullText).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose(); mock.restore()
|
||||
if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = previous
|
||||
}
|
||||
|
||||
@@ -11,28 +11,6 @@ import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
const searchService = new SearchService()
|
||||
|
||||
/** Scan only the existing metadata projection; transcript content has its own bounded index search. */
|
||||
export async function findSessionMetadataMatches(
|
||||
query: string,
|
||||
list: (options: { limit: number; offset: number }) => Promise<{ sessions: Array<{ id: string; title?: string; workDir?: string | null; projectPath?: string }>; total?: number }>,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const needle = query.toLocaleLowerCase()
|
||||
const matches = new Map<string, Record<string, unknown>>()
|
||||
const seen = new Set<string>()
|
||||
for (let offset = 0; ; offset += 100) {
|
||||
const page = await list({ limit: 100, offset })
|
||||
let newSessions = 0
|
||||
for (const session of page.sessions) {
|
||||
if (seen.has(session.id)) continue
|
||||
seen.add(session.id)
|
||||
newSessions++
|
||||
if (`${session.title ?? ''} ${session.id} ${session.workDir ?? session.projectPath ?? ''}`.toLocaleLowerCase().includes(needle)) matches.set(session.id, session)
|
||||
}
|
||||
if (page.sessions.length < 100 || newSessions === 0 || (page.total !== undefined && offset + page.sessions.length >= page.total)) break
|
||||
}
|
||||
return [...matches.values()]
|
||||
}
|
||||
|
||||
let endpoint = { serverHost: '127.0.0.1', serverPort: 0 }
|
||||
let current: { path: string; service: SessionCollaborationService; ready: Promise<void> } | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
@@ -62,27 +40,36 @@ export async function getSessionCollaborationService(): Promise<SessionCollabora
|
||||
const service = new SessionCollaborationService({
|
||||
statePath: path,
|
||||
sessions: {
|
||||
async list({ query, limit, offset }) {
|
||||
if (!query?.trim()) return sessionService.listSessions({ limit, offset })
|
||||
// Search transcript content as well as titles, using the existing
|
||||
// indexed/bounded fallback instead of materializing all histories.
|
||||
const [matches, metadata] = await Promise.all([
|
||||
searchService.searchSessions(query, { limit: Math.min(100, offset + limit), matchesPerSession: 1 }),
|
||||
findSessionMetadataMatches(query, options => sessionService.listSessions(options)),
|
||||
])
|
||||
const selected = new Map<string, Record<string, unknown>>()
|
||||
for (const session of metadata) selected.set(String(session.id), session)
|
||||
for (const match of matches.results) if (!selected.has(match.sessionId)) selected.set(match.sessionId, { ...match, id: match.sessionId })
|
||||
const needle = query.trim().toLocaleLowerCase()
|
||||
const rank = (session: Record<string, unknown>): number => {
|
||||
const names = [session.id ?? session.sessionId, session.title].filter(value => typeof value === 'string').map(value => String(value).toLocaleLowerCase())
|
||||
if (names.some(value => value === needle)) return 3
|
||||
if (names.some(value => value.startsWith(needle))) return 2
|
||||
if (names.some(value => value.includes(needle))) return 1
|
||||
return 0
|
||||
async list({ query, limit, offset, signal }) {
|
||||
const needle = query?.trim() ?? ''
|
||||
const metadata = await sessionService.searchSessionMetadata(needle, { limit, offset, signal })
|
||||
signal?.throwIfAborted()
|
||||
// A full metadata page already answers the picker. Do not wait for
|
||||
// full-text matches (or canonical transcript validation) to display it.
|
||||
if (!needle) return metadata
|
||||
if (metadata.sessions.length === limit) return { ...metadata, truncated: true, totalIsLowerBound: true }
|
||||
const content = await searchService.searchSessionSuggestions(needle, { limit: 100, signal })
|
||||
signal?.throwIfAborted()
|
||||
const details = new Map(sessionService.getSessionSuggestionMetadata(content.sessions.map(item => item.sessionId)).map(item => [item.id, item]))
|
||||
const normalized = needle.toLowerCase()
|
||||
const seen = new Set<string>()
|
||||
const bodyOnly = content.sessions.flatMap(item => {
|
||||
const detail = details.get(item.sessionId)
|
||||
// The metadata page is authoritative for title/project matches;
|
||||
// filter these from content pages too so pagination cannot repeat them.
|
||||
if (seen.has(item.sessionId)) return []
|
||||
seen.add(item.sessionId)
|
||||
if (detail && [detail.id, detail.title, detail.workDir ?? '', detail.projectPath].some(value => value.toLowerCase().includes(normalized))) return []
|
||||
return [detail ?? { id: item.sessionId, title: item.sessionId, projectPath: item.projectPath, modifiedAt: item.modifiedAt }]
|
||||
})
|
||||
const bodyOffset = Math.max(0, offset - metadata.total)
|
||||
return {
|
||||
sessions: [...metadata.sessions, ...bodyOnly.slice(bodyOffset, bodyOffset + limit - metadata.sessions.length)],
|
||||
total: metadata.total + bodyOnly.length,
|
||||
truncated: content.truncated,
|
||||
totalIsLowerBound: content.truncated || content.indexUnavailable,
|
||||
indexUnavailable: content.indexUnavailable,
|
||||
}
|
||||
const ranked = [...selected.values()].sort((a, b) => rank(b) - rank(a))
|
||||
return { sessions: ranked.slice(offset, offset + limit), total: selected.size, truncated: matches.truncated }
|
||||
},
|
||||
read: (sessionId, options) => sessionService.getSessionHistoryPage(sessionId, options),
|
||||
exists: async sessionId => Boolean(await sessionService.getSessionSummary(sessionId)),
|
||||
|
||||
@@ -29,6 +29,28 @@ beforeEach(async () => {
|
||||
afterEach(async () => { await rm(directory, { recursive: true, force: true }) })
|
||||
|
||||
describe('session collaboration', () => {
|
||||
test('suggestions project current member status without materializing message history', async () => {
|
||||
await service.onSessionState('root', 'blocked')
|
||||
service.status = async () => { throw new Error('Suggestions must not clone the full collaboration history') }
|
||||
expect((await service.candidates('Main')).sessions).toEqual([
|
||||
{ sessionId: 'root', title: 'Main', cwd: '/fixture', status: 'blocked', updatedAt: 'now' },
|
||||
])
|
||||
})
|
||||
|
||||
test('suggestions propagate cancellation and reject cancelled results', async () => {
|
||||
const controller = new AbortController()
|
||||
let calls = 0
|
||||
deps.sessions.list = async options => {
|
||||
calls++
|
||||
expect(options.signal).toBe(controller.signal)
|
||||
controller.abort(new Error('Query replaced'))
|
||||
return { sessions: [] }
|
||||
}
|
||||
await expect(service.candidates('old query', controller.signal)).rejects.toThrow('Query replaced')
|
||||
await expect(service.candidates('old query', controller.signal)).rejects.toThrow('Query replaced')
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('shares three worker slots across nested delegation and releases them on completion', async () => {
|
||||
const a = await service.create('root', { prompt: 'a' })
|
||||
expect(a).toMatchObject({ state: 'running', delivery: 'accepted' })
|
||||
|
||||
@@ -36,7 +36,7 @@ export type CollaborationCreateResult = { sessionId: string; workDir?: string; m
|
||||
export type SessionCollaborationDependencies = {
|
||||
statePath: string
|
||||
sessions: {
|
||||
list(options: { query?: string; limit: number; offset: number }): Promise<unknown>
|
||||
list(options: { query?: string; limit: number; offset: number; signal?: AbortSignal }): Promise<unknown>
|
||||
read(sessionId: string, options: { cursor?: string; limit: number; signal?: AbortSignal }): Promise<unknown>
|
||||
exists(sessionId: string): Promise<boolean>
|
||||
/** Must use the caller's effective runtime and an isolated Git worktree. */
|
||||
@@ -176,17 +176,24 @@ export class SessionCollaborationService {
|
||||
return message
|
||||
}
|
||||
|
||||
async list(options: { query?: string; limit?: number; offset?: number } = {}): Promise<unknown> {
|
||||
return this.deps.sessions.list({ ...options, limit: Math.min(100, Math.max(1, options.limit ?? 30)), offset: Math.max(0, options.offset ?? 0) })
|
||||
async list(options: { query?: string; limit?: number; offset?: number; signal?: AbortSignal } = {}): Promise<unknown> {
|
||||
options.signal?.throwIfAborted()
|
||||
const result = await this.deps.sessions.list({ ...options, limit: Math.min(100, Math.max(1, options.limit ?? 30)), offset: Math.max(0, options.offset ?? 0) })
|
||||
options.signal?.throwIfAborted()
|
||||
return result
|
||||
}
|
||||
|
||||
async candidates(query?: string): Promise<{ sessions: Array<{ sessionId: string; title: string; cwd: string; status: string; updatedAt: string }> }> {
|
||||
const result = await this.list({ query }) as { sessions?: Array<Record<string, unknown>> }
|
||||
const snapshot = await this.status()
|
||||
async candidates(query?: string, signal?: AbortSignal): Promise<{ sessions: Array<{ sessionId: string; title: string; cwd: string; status: string; updatedAt: string }> }> {
|
||||
const result = await this.list({ query, signal }) as { sessions?: Array<Record<string, unknown>> }
|
||||
await this.ready
|
||||
await this.tail
|
||||
signal?.throwIfAborted()
|
||||
// Suggestions need only a status lookup for the returned page, not a clone
|
||||
// of every collaboration message and member in the application.
|
||||
return { sessions: (result.sessions ?? []).map(session => ({
|
||||
sessionId: String(session.sessionId ?? session.id), title: String(session.title ?? ''),
|
||||
cwd: String(session.cwd ?? session.workDir ?? session.projectPath ?? ''),
|
||||
status: snapshot.members.find(member => member.sessionId === (session.sessionId ?? session.id))?.state ?? 'idle',
|
||||
status: this.store.members[String(session.sessionId ?? session.id)]?.state ?? 'idle',
|
||||
updatedAt: String(session.updatedAt ?? session.modifiedAt ?? ''),
|
||||
})) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, expect, spyOn, mock, test } from 'bun:test'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { SessionService } from './sessionService.js'
|
||||
import type { LocalIndexGateway } from './localIndex/sessionIndex.js'
|
||||
|
||||
let directory: string
|
||||
let previousHome: string | undefined
|
||||
let previousConfig: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp('/tmp/session-metadata-')
|
||||
previousHome = process.env.HOME
|
||||
previousConfig = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.HOME = directory
|
||||
process.env.CLAUDE_CONFIG_DIR = directory
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
if (previousHome === undefined) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousConfig
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('building index serves bounded metadata without canonical hydration', async () => {
|
||||
const rows = [{ id: 'old', title: '设计', workDir: '/fixture', projectPath: 'fixture', modifiedAt: '2026-01-01' }]
|
||||
const gateway = {
|
||||
getMode: () => 'on',
|
||||
getPublicStatus: () => ({ state: 'building' }),
|
||||
searchSessionMetadata: () => ({ sessions: rows, total: 1 }),
|
||||
getSessionSuggestionMetadata: () => rows,
|
||||
} as unknown as LocalIndexGateway
|
||||
const service = new SessionService(gateway)
|
||||
const hydrate = spyOn(service, 'listSessions').mockImplementation(async () => {
|
||||
throw new Error('no hydration')
|
||||
})
|
||||
expect((await service.searchSessionMetadata('设计')).sessions).toEqual(rows)
|
||||
expect(service.getSessionSuggestionMetadata(['old'])).toEqual(rows)
|
||||
expect(hydrate).not.toHaveBeenCalled()
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
await expect(service.searchSessionMetadata('设计', { signal: abort.signal })).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('unavailable index scans cached summaries once and ranks old exact matches before limiting', async () => {
|
||||
const project = join(directory, 'projects', 'fixture')
|
||||
await mkdir(project, { recursive: true })
|
||||
for (let i = 0; i < 45; i++) {
|
||||
await writeFile(join(project, `id-${i}.jsonl`), JSON.stringify({
|
||||
type: 'custom-title',
|
||||
customTitle: i === 44 ? '设计' : '最近设计讨论',
|
||||
sessionId: `id-${i}`,
|
||||
}) + '\n')
|
||||
}
|
||||
const service = new SessionService({ getMode: () => 'off' } as unknown as LocalIndexGateway)
|
||||
const hydrate = spyOn(service, 'listSessions').mockImplementation(async () => {
|
||||
throw new Error('no hydration')
|
||||
})
|
||||
const result = await service.searchSessionMetadata('设计', { limit: 5 })
|
||||
expect(result.sessions[0]?.id).toBe('id-44')
|
||||
expect(result.total).toBe(45)
|
||||
expect(result.sessions).toHaveLength(5)
|
||||
expect(hydrate).not.toHaveBeenCalled()
|
||||
expect(service.getSessionSuggestionMetadata(['id-44'])).toEqual([])
|
||||
})
|
||||
@@ -3351,6 +3351,54 @@ export class SessionService {
|
||||
return rows
|
||||
}
|
||||
|
||||
getSessionSuggestionMetadata(sessionIds: string[]): Array<{ id: string; title: string; workDir: string | null; projectPath: string; modifiedAt: string }> {
|
||||
this.syncSharedMutationEpoch()
|
||||
if (this.getUsableIndexMode() !== 'on') return []
|
||||
try {
|
||||
const rows = this.localIndexGateway.getSessionSuggestionMetadata?.(sessionIds.slice(0, 100)) ?? []
|
||||
if (!this.indexStatusRemainsUsable()) return []
|
||||
return rows.map(({ id, title, workDir, projectPath, modifiedAt }) => ({ id, title, workDir, projectPath, modifiedAt }))
|
||||
} catch { this.markIndexReadFailure(); return [] }
|
||||
}
|
||||
|
||||
/** Metadata-only reference lookup: no per-result transcript or workspace hydration. */
|
||||
async searchSessionMetadata(query: string, options: { limit?: number; offset?: number; signal?: AbortSignal } = {}): Promise<{ sessions: Array<{ id: string; title: string; workDir: string | null; projectPath: string; modifiedAt: string }>; total: number }> {
|
||||
options.signal?.throwIfAborted()
|
||||
this.syncSharedMutationEpoch()
|
||||
const limit = Math.min(100, Math.max(1, options.limit ?? 30))
|
||||
const offset = Math.max(0, options.offset ?? 0)
|
||||
const epoch = getSharedSessionMutationState(this.localIndexGateway).epoch
|
||||
if (this.getUsableIndexMode() === 'on') {
|
||||
try {
|
||||
const result = this.localIndexGateway.searchSessionMetadata?.(query, { limit, offset })
|
||||
if (result && epoch === getSharedSessionMutationState(this.localIndexGateway).epoch && this.indexStatusRemainsUsable()) {
|
||||
return { sessions: result.sessions.map(({ id, title, workDir, projectPath, modifiedAt }) => ({ id, title, workDir, projectPath, modifiedAt })), total: result.total }
|
||||
}
|
||||
} catch { this.markIndexReadFailure() }
|
||||
}
|
||||
// Scan the metadata projection once, rank before limiting, and reuse its
|
||||
// summary cache. Do not hydrate every workspace or repeatedly page lists.
|
||||
const scope = this.getConfigDir()
|
||||
this.prepareSessionListCaches(scope)
|
||||
const rows: Array<{ id: string; title: string; workDir: string | null; projectPath: string; modifiedAt: string }> = []
|
||||
for (const file of await this.discoverSessionFiles(undefined, scope)) {
|
||||
options.signal?.throwIfAborted()
|
||||
try {
|
||||
const summary = await this.getCachedSessionListSummary(file.filePath, file.projectDir, await fs.stat(file.filePath), scope)
|
||||
rows.push({ id: file.sessionId, title: summary.title, workDir: summary.workDir, projectPath: file.projectDir, modifiedAt: summary.modifiedAt })
|
||||
} catch { /* Match sidebar behavior for unreadable transcripts. */ }
|
||||
}
|
||||
const needle = query.trim().toLowerCase()
|
||||
const rank = (row: typeof rows[number]) => {
|
||||
if (!needle) return 0
|
||||
const names = [row.title.toLowerCase(), row.id.toLowerCase()]
|
||||
return names.includes(needle) ? 3 : names.some(value => value.startsWith(needle)) ? 2 : names.some(value => value.includes(needle)) ? 1 : 0
|
||||
}
|
||||
const matches = rows.filter(row => [row.title, row.id, row.workDir ?? '', row.projectPath].some(value => value.toLowerCase().includes(needle)))
|
||||
matches.sort((a, b) => rank(b) - rank(a) || Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt) || a.id.localeCompare(b.id) || a.projectPath.localeCompare(b.projectPath))
|
||||
return { sessions: matches.slice(offset, offset + limit), total: matches.length }
|
||||
}
|
||||
|
||||
/** List all sessions, optionally filtered by physical project path. */
|
||||
async listSessions(options?: {
|
||||
project?: string
|
||||
|
||||
Reference in New Issue
Block a user