fix(web): align session search contracts

This commit is contained in:
Hypatia May
2026-07-27 18:38:00 +08:00
parent 621f414407
commit 4727d742db
42 changed files with 235 additions and 196 deletions

View File

@@ -213,6 +213,10 @@
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchStatus,
.searchWarning {
padding: 10px 12px;

View File

@@ -261,33 +261,37 @@ function SearchResults({
workspaces,
query,
remote,
resultLimit,
}: Pick<SessionTreeProps, 'useSessions' | 'open'> & {
workspaces: readonly WorkspaceView[]
query: string
remote: RemoteSearchState
resultLimit: number
}) {
const list = useSessions((s) => s)
const currentRemote = remote.query === query
? remote
: { query, status: 'loading' as const, items: [], hasMore: false }
const results = useMemo(
() => deriveSearchResults(list, workspaces, query, currentRemote),
[list, workspaces, query, currentRemote],
() => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit),
[list, workspaces, query, currentRemote, resultLimit],
)
const pending = currentRemote.status === 'loading'
const failed = currentRemote.status === 'error'
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Search results">
{results.items.map(result => (
<SearchResultItem
key={result.id}
result={result}
currentId={list.current}
onOpen={open}
/>
))}
<div className={css.list}>
<div className={css.searchTree} role="tree" aria-label="Search results">
{results.items.map(result => (
<SearchResultItem
key={result.id}
result={result}
currentId={list.current}
onOpen={open}
/>
))}
</div>
{pending && (
<div className={css.searchStatus} role="status">Searching session history…</div>
)}
@@ -300,7 +304,9 @@ function SearchResults({
<div className={css.empty}>No matching sessions</div>
)}
{results.hasMore && (
<div className={css.searchStatus}>Showing the first 20 results. Narrow your search.</div>
<div className={css.searchStatus}>
Showing the first {resultLimit} results. Narrow your search.
</div>
)}
</div>
<span className={css.fade} />
@@ -327,6 +333,7 @@ export function WorkspaceBrowser({
insertSessionBefore,
createWorkspace,
searchSessions,
searchResultLimit,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -544,6 +551,7 @@ export function WorkspaceBrowser({
workspaces={workspaces}
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
/>
)
: groupBy === 'flat'

View File

@@ -40,6 +40,8 @@ export type WorkspaceBrowserInjected = {
query: string,
signal: AbortSignal,
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
/** Maximum number of merged rows rendered for one search. */
searchResultLimit: number
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */

View File

@@ -44,6 +44,7 @@ export function apply(ctx: ClientContext): void {
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
searchSessions,
searchResultLimit: ctx.sessions.searchResultLimit,
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

View File

@@ -278,9 +278,6 @@ export function deriveFlat(list: SessionListState): SessionNode[] {
return rows.map(s => sessionNode(s, [], false, false))
}
/** Maximum rows rendered by the basic search surface. */
const SEARCH_RESULT_LIMIT = 20
/**
* Merge immediate title/Workspace substring matches with ranked Host content
* matches. Local rows lead newest-first, content-only rows retain backend
@@ -289,13 +286,15 @@ const SEARCH_RESULT_LIMIT = 20
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
* @param content - ranked Host content-search page.
* @returns at most 20 deduplicated flat rows and a refine-query hint bit.
* @param limit - protocol-owned maximum merged row count.
* @returns bounded deduplicated flat rows and a refine-query hint bit.
*/
export function deriveSearchResults(
list: SessionListState,
workspaces: readonly WorkspaceView[],
query: string,
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
limit: number,
): SearchResultSet {
const q = query.trim().toLowerCase()
if (q === '') return { items: [], hasMore: false }
@@ -340,7 +339,7 @@ export function deriveSearchResults(
}
return {
items: ordered.slice(0, SEARCH_RESULT_LIMIT).map((summary) => {
items: ordered.slice(0, limit).map((summary) => {
const match = contentBySession.get(summary.id)
return {
id: summary.id,
@@ -350,7 +349,7 @@ export function deriveSearchResults(
...match === undefined ? {} : { snippet: match.snippet },
}
}),
hasMore: content.hasMore || ordered.length > SEARCH_RESULT_LIMIT,
hasMore: content.hasMore || ordered.length > limit,
}
}

View File

@@ -26,7 +26,7 @@ async function bench() {
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear, search } as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20 } as never)
return {
ctx,
slots: ctx.get('slots') as SlotsService,
@@ -86,6 +86,7 @@ describe('ui-workspace apply', () => {
hasMore: false,
})
expect(b.search).toHaveBeenCalledWith('match', signal)
expect(browser.searchResultLimit).toBe(20)
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)

View File

@@ -167,6 +167,7 @@ describe('deriveSearchResults', () => {
],
hasMore: false,
},
10,
)
expect(result).toEqual({
@@ -214,6 +215,7 @@ describe('deriveSearchResults', () => {
],
hasMore: false,
},
10,
)
expect(result.items).toEqual([{
id: currentBlank.id,
@@ -224,14 +226,20 @@ describe('deriveSearchResults', () => {
}])
})
it('caps merged rows at 20 and preserves either local overflow or backend hasMore', () => {
const rows = Array.from({ length: 22 }, (_, index) => {
it('uses the supplied cap and preserves either local overflow or backend hasMore', () => {
const rows = Array.from({ length: 5 }, (_, 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)
const overflow = deriveSearchResults(
list(...rows),
[],
'needle',
{ items: [], hasMore: false },
3,
)
expect(overflow.items).toHaveLength(3)
expect(overflow.hasMore).toBe(true)
const backendMore = deriveSearchResults(
@@ -239,10 +247,11 @@ describe('deriveSearchResults', () => {
[],
'needle',
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
3,
)
expect(backendMore.items).toHaveLength(1)
expect(backendMore.hasMore).toBe(true)
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }))
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3))
.toEqual({ items: [], hasMore: false })
})
})

View File

@@ -54,6 +54,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
startSession: vi.fn(),
open: vi.fn(),
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
searchResultLimit: 20,
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
@@ -217,10 +218,12 @@ describe('WorkspaceBrowser', () => {
})
const input = screen.getByPlaceholderText<HTMLInputElement>('Search names or content…')
fireEvent.change(input, { target: { value: 'needle' } })
expect(screen.getByRole('tree', { name: 'Search results' })).toBeTruthy()
const resultTree = screen.getByRole('tree', { name: 'Search results' })
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
expect(screen.getByText('Searching session history…')).toBeTruthy()
const status = screen.getByRole('status')
expect(status.textContent).toBe('Searching session history…')
expect(resultTree.contains(status)).toBe(false)
fireEvent.change(input, { target: { value: 'zzz' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })