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
@@ -213,6 +213,10 @@
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchStatus,
.searchWarning {
padding: 10px 12px;
@@ -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'
@@ -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. */
@@ -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) => {
@@ -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,
}
}