feat(web): add basic past-session search (round 1)
This commit is contained in:
@@ -19,11 +19,25 @@ async function bench() {
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
const clear = vi.fn()
|
||||
const search = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
|
||||
}))
|
||||
ctx.provide('workspaces', {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
|
||||
ctx.provide('sessions', { open, clear, search } as never)
|
||||
return {
|
||||
ctx,
|
||||
slots: ctx.get('slots') as SlotsService,
|
||||
create,
|
||||
startSession,
|
||||
rename,
|
||||
insertSessionBefore,
|
||||
open,
|
||||
clear,
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||
@@ -66,6 +80,12 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
|
||||
browser.open('session' as never)
|
||||
expect(b.open).toHaveBeenCalledWith('session')
|
||||
const signal = new AbortController().signal
|
||||
await expect(browser.searchSessions('match', signal)).resolves.toEqual({
|
||||
items: [{ sessionId: 'session', snippet: 'match' }],
|
||||
hasMore: false,
|
||||
})
|
||||
expect(b.search).toHaveBeenCalledWith('match', signal)
|
||||
await browser.renameWorkspace('ws' as never, 'renamed')
|
||||
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
|
||||
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
|
||||
@@ -78,6 +98,19 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
})
|
||||
|
||||
it('rejects the browser search callback on a runtime business error', async () => {
|
||||
const b = await bench()
|
||||
b.search.mockImplementationOnce(async () => ({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'index unavailable', details: {} },
|
||||
}) as never)
|
||||
declare(b.slots, 'sidebar.workspaces')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
await expect(browser.searchSessions('needle', new AbortController().signal))
|
||||
.rejects.toThrow('index unavailable')
|
||||
})
|
||||
|
||||
it('unregisters every entry on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace')
|
||||
|
||||
@@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -38,6 +38,25 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
|
||||
}
|
||||
|
||||
describe('workspace browser rows', () => {
|
||||
it('renders a selected content-search row and opens only its session', () => {
|
||||
const onOpen = vi.fn()
|
||||
const result: SearchResultNode = {
|
||||
id: sid('result'),
|
||||
title: 'Result title',
|
||||
workspace: 'Workspace context',
|
||||
running: true,
|
||||
snippet: 'matching message excerpt',
|
||||
}
|
||||
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('Workspace context')).toBeTruthy()
|
||||
expect(screen.getByText('matching message excerpt')).toBeTruthy()
|
||||
expect(row.hasAttribute('draggable')).toBe(false)
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledWith(result.id)
|
||||
})
|
||||
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import {
|
||||
deriveFlat, deriveGroups, deriveSearchResults, formatRelativeTime, projectLabel,
|
||||
UNGROUPED_KEY, UNGROUPED_LABEL,
|
||||
} from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
@@ -16,12 +19,12 @@ const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id,
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const view = (expandedProjects: readonly string[] = [], query = '') => ({
|
||||
expandedProjects, expandedSessions: [] as string[], query,
|
||||
const view = (expandedProjects: readonly string[] = []) => ({
|
||||
expandedProjects, expandedSessions: [] as string[],
|
||||
})
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
@@ -59,21 +62,6 @@ describe('deriveGroups', () => {
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('searches the current blank session by its New Session title', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['opaque-current', 'new session stale'])], view([], 'new session'),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id])
|
||||
expect(groups[0]!.sessions[0]!.title).toBe('New Session')
|
||||
expect(groups[0]!.sessionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
|
||||
const parent = summary('parent', 1)
|
||||
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
|
||||
@@ -87,7 +75,7 @@ describe('deriveGroups', () => {
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id] },
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
@@ -113,31 +101,6 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
|
||||
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
|
||||
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
|
||||
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
|
||||
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
|
||||
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
|
||||
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
|
||||
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
|
||||
|
||||
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
|
||||
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
])
|
||||
|
||||
const labelOnly = deriveGroups(
|
||||
list(summary('hidden', 1)),
|
||||
[workspace('label-hit', ['hidden']), workspace('other', [])],
|
||||
view([], 'label'),
|
||||
)
|
||||
expect(labelOnly).toEqual([
|
||||
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
@@ -155,21 +118,15 @@ describe('deriveFlat', () => {
|
||||
const child = { ...summary('child', 30), parentId: parent.id }
|
||||
const tieB = summary('tie-b', 20)
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA))
|
||||
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
|
||||
// Rows are branch-free: no children, no expansion.
|
||||
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
|
||||
})
|
||||
|
||||
it('search filters by case-insensitive display-title substring', () => {
|
||||
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
|
||||
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
|
||||
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
|
||||
})
|
||||
|
||||
it('tolerates ids whose summary has not landed yet', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
|
||||
expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('shows only the current blank session with its New Session title', () => {
|
||||
@@ -179,11 +136,112 @@ describe('deriveFlat', () => {
|
||||
...list(summary('real', 1), currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const rows = deriveFlat(sessions, { query: '' })
|
||||
const rows = deriveFlat(sessions)
|
||||
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
|
||||
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
|
||||
expect(deriveFlat(sessions, { query: 'new session' }).map(row => row.id)).toEqual([currentBlank.id])
|
||||
expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults', () => {
|
||||
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
|
||||
const titleHit = summary('title-hit', 30, '/projects/a')
|
||||
titleHit.displayTitle = 'Needle title'
|
||||
const workspaceHit = summary('workspace-hit', 20, '/projects/b')
|
||||
workspaceHit.displayTitle = 'Ordinary title'
|
||||
const contentHit = summary('content-hit', 10, '/projects/c')
|
||||
const sessions = list(titleHit, workspaceHit, contentHit)
|
||||
const result = deriveSearchResults(
|
||||
sessions,
|
||||
[
|
||||
workspace('a', ['title-hit'], 'Alpha'),
|
||||
workspace('b', ['workspace-hit'], 'Needle Workspace'),
|
||||
],
|
||||
' NEEDLE ',
|
||||
{
|
||||
items: [
|
||||
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
|
||||
{ sessionId: titleHit.id, snippet: 'title session body excerpt' },
|
||||
{ sessionId: sid('unknown'), snippet: 'not in session.list' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: titleHit.id,
|
||||
title: 'Needle title',
|
||||
workspace: 'Alpha',
|
||||
running: false,
|
||||
snippet: 'title session body excerpt',
|
||||
},
|
||||
{
|
||||
id: workspaceHit.id,
|
||||
title: 'Ordinary title',
|
||||
workspace: 'Needle Workspace',
|
||||
running: false,
|
||||
},
|
||||
{
|
||||
id: contentHit.id,
|
||||
title: 'content-hit',
|
||||
workspace: 'c',
|
||||
running: false,
|
||||
snippet: 'body needle excerpt',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the current blank row and uses its New Session display title', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const result = deriveSearchResults(
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale'])],
|
||||
'new session',
|
||||
{
|
||||
items: [
|
||||
{ sessionId: staleBlank.id, snippet: 'stale body' },
|
||||
{ sessionId: currentBlank.id, snippet: 'current body' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
)
|
||||
expect(result.items).toEqual([{
|
||||
id: currentBlank.id,
|
||||
title: 'New Session',
|
||||
workspace: 'first',
|
||||
running: false,
|
||||
snippet: 'current body',
|
||||
}])
|
||||
})
|
||||
|
||||
it('caps merged rows at 20 and preserves either local overflow or backend hasMore', () => {
|
||||
const rows = Array.from({ length: 22 }, (_, index) => {
|
||||
const item = summary(`s-${String(index).padStart(2, '0')}`, index)
|
||||
item.displayTitle = `Needle ${String(index)}`
|
||||
return item
|
||||
})
|
||||
const overflow = deriveSearchResults(list(...rows), [], 'needle', { items: [], hasMore: false })
|
||||
expect(overflow.items).toHaveLength(20)
|
||||
expect(overflow.hasMore).toBe(true)
|
||||
|
||||
const backendMore = deriveSearchResults(
|
||||
list(summary('body', 1)),
|
||||
[],
|
||||
'needle',
|
||||
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
|
||||
)
|
||||
expect(backendMore.items).toHaveLength(1)
|
||||
expect(backendMore.hasMore).toBe(true)
|
||||
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }))
|
||||
.toEqual({ items: [], hasMore: false })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
actions: store.actions,
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
@@ -198,42 +199,168 @@ describe('WorkspaceBrowser', () => {
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'new session' } })
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('searches across groups, clears via the clear button, and shows the empty states', () => {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
// Search forces matches visible without expansion state.
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(input.value).toBe('')
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称或关键词…')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
expect(screen.getByRole('tree', { name: '搜索结果' })).toBeTruthy()
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('没有匹配结果')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(input.value).toBe('')
|
||||
expect(screen.getByRole('tree', { name: 'Sessions' })).toBeTruthy()
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
// Flat search misses show No matches.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const open = vi.fn()
|
||||
const searchSessions = vi.fn(async () => ({
|
||||
items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('body-hit', 1, { displayTitle: 'Research notes' }),
|
||||
])),
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('research', ['body-hit'], 'Research Workspace'),
|
||||
])),
|
||||
open,
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称或关键词…')
|
||||
fireEvent.change(input, { target: { value: 'waterfall token' } })
|
||||
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
|
||||
expect(screen.queryByText('Research notes')).toBeNull()
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
|
||||
expect(searchSessions).toHaveBeenCalledWith('waterfall token', expect.any(AbortSignal))
|
||||
expect(screen.getByText('Research notes')).toBeTruthy()
|
||||
expect(screen.getByText('Research Workspace')).toBeTruthy()
|
||||
expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy()
|
||||
expect(screen.getByText('仅显示前 20 项,请缩小搜索范围。')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('treeitem'))
|
||||
expect(open).toHaveBeenCalledWith(sid('body-hit'))
|
||||
expect(input.value).toBe('waterfall token')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps local matches and shows a lightweight warning when Host search fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const searchSessions = vi.fn(async () => { throw new Error('index unavailable') })
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('local-hit', 1, { displayTitle: 'Needle title' }),
|
||||
])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
|
||||
searchSessions,
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), {
|
||||
target: { value: 'needle' },
|
||||
})
|
||||
expect(screen.getByText('Needle title')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('Needle title')).toBeTruthy()
|
||||
expect(screen.getByText('历史内容搜索暂时不可用,仍显示名称匹配。')).toBeTruthy()
|
||||
expect(screen.queryByText('没有匹配结果')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts a superseded request and ignores its stale result', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let resolveFirst!: (value: {
|
||||
items: { sessionId: SessionId; snippet: string }[]
|
||||
hasMore: boolean
|
||||
}) => void
|
||||
const first = new Promise<{
|
||||
items: { sessionId: SessionId; snippet: string }[]
|
||||
hasMore: boolean
|
||||
}>((resolve) => { resolveFirst = resolve })
|
||||
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
|
||||
? first
|
||||
: Promise.resolve({
|
||||
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('first-hit', 2, { displayTitle: 'Old result' }),
|
||||
summary('second-hit', 1, { displayTitle: 'Fresh result' }),
|
||||
])),
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText('搜索名称或关键词…')
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
|
||||
expect(firstSignal.aborted).toBe(false)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
expect(firstSignal.aborted).toBe(true)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('Fresh result')).toBeTruthy()
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst({
|
||||
items: [{ sessionId: sid('first-hit'), snippet: 'stale excerpt' }],
|
||||
hasMore: false,
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('Old result')).toBeNull()
|
||||
expect(screen.getByText('Fresh result')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes and resolves an empty search', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const b = mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('没有匹配结果')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rail state renders icon controls that request expansion', () => {
|
||||
@@ -243,16 +370,16 @@ describe('WorkspaceBrowser', () => {
|
||||
const b = mount({ wide: false, expandSidebar })
|
||||
// No wide chrome in rail state.
|
||||
expect(screen.queryByText('Workspaces')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(screen.queryByPlaceholderText('搜索名称或关键词…')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
// The wide flip mounts the input and focuses it after the slide.
|
||||
rerender(b, { wide: true })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
const input = screen.getByPlaceholderText('搜索名称或关键词…')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
// Wide search button is decorative (tabIndex -1, no expand call).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -463,8 +590,8 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'needle' } })
|
||||
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
expect(row.hasAttribute('draggable')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user