fix(web): align session search contracts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Central contract re-export point: every contract import inside
|
||||
// web-runtime goes through this single file.
|
||||
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
|
||||
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
|
||||
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
|
||||
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels added for this.
|
||||
|
||||
@@ -19,7 +19,11 @@ export type {
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export {
|
||||
RpcId,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
transportError,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId } from './api.ts'
|
||||
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
@@ -302,8 +302,9 @@ function pageOf(
|
||||
function searchBlockText(block: ContentBlock): string[] {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
return [block.text]
|
||||
case 'reasoning':
|
||||
return []
|
||||
case 'tool-call':
|
||||
return [block.name, block.arguments]
|
||||
case 'tool-result':
|
||||
@@ -425,7 +426,7 @@ interface FixtureSearchCandidate {
|
||||
documentLength: number
|
||||
}
|
||||
|
||||
/** Same rank keys as session-query-sqlite's cross-session result order. */
|
||||
/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */
|
||||
function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number {
|
||||
if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount
|
||||
if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength
|
||||
@@ -741,11 +742,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return best === undefined ? [] : [best]
|
||||
}).sort(compareSearchCandidates)
|
||||
return ok(request, {
|
||||
items: matches.slice(0, 20).map(match => ({
|
||||
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
|
||||
sessionId: match.sessionId,
|
||||
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
|
||||
})),
|
||||
hasMore: matches.length > 20,
|
||||
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
},
|
||||
create: async (request) => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
@@ -19,7 +20,12 @@ export type {
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
export {
|
||||
RpcId,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
AbstractApiClient,
|
||||
transportError,
|
||||
} from './api.ts'
|
||||
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
@@ -37,6 +43,8 @@ export const inject: string[] = []
|
||||
export interface ConnectionHandle {
|
||||
/** Shared api client (fixture or real, decided at boot from the page URL). */
|
||||
readonly api: IApiClient
|
||||
/** Protocol-owned maximum rows for one session-search response. */
|
||||
readonly sessionSearchResultLimit: number
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -58,6 +66,7 @@ export function apply(ctx: Context): void {
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
sessionSearchResultLimit: SESSION_SEARCH_RESULT_LIMIT,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
@@ -89,6 +89,11 @@ describe('createFixtureApi', () => {
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal)
|
||||
expect(reasoningOnly.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 8a83a7c377e53a19e6d22b49219ba2ed3441cf3c
|
||||
README.zh.md: 007eb546a922f5711fc14d44c299627c76bf0776
|
||||
README.md: aed0b21829e06cf67486084101d2f8c016264ab8
|
||||
README.zh.md: 4f5c907c378094330dee777b2efa369c3c5a49c1
|
||||
|
||||
@@ -12,7 +12,7 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation.
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` exposes the protocol-owned page bound as injected presentation data, so client plugins do not duplicate it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将协议定义的分页上限作为注入的呈现数据公开,使客户端插件无需复制该值。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
|
||||
@@ -113,7 +113,11 @@ export const inject = ['connection']
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const sessions = new SessionsService(
|
||||
ctx,
|
||||
connection.api,
|
||||
connection.sessionSearchResultLimit,
|
||||
)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
|
||||
@@ -149,6 +149,8 @@ export interface SessionProvideDescriptor {
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** Fixed sidebar result bound supplied to presentation plugins as injected data. */
|
||||
readonly searchResultLimit: number
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
@@ -182,8 +184,14 @@ export class SessionsService {
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
* @param searchResultLimit - protocol-owned search bound from the connection service.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
searchResultLimit: number,
|
||||
) {
|
||||
this.searchResultLimit = searchResultLimit
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
|
||||
@@ -25,6 +25,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
sessionSearchResultLimit: 7,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
@@ -50,6 +51,7 @@ describe('runtime client apply', () => {
|
||||
const workspaces = bench.ctx.get('workspaces')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(workspaces !== undefined).toBe(true)
|
||||
expect((sessions as SessionsService).searchResultLimit).toBe(7)
|
||||
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Bench {
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const svc = new SessionsService(ctx, api)
|
||||
const svc = new SessionsService(ctx, api, 20)
|
||||
return { ctx, api, svc }
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
sessionSearchResultLimit: 20,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('WorkspacesService', () => {
|
||||
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
@@ -152,7 +152,7 @@ describe('WorkspacesService', () => {
|
||||
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha'), workspace('beta')] as never[],
|
||||
@@ -188,7 +188,7 @@ describe('WorkspacesService', () => {
|
||||
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
@@ -208,7 +208,7 @@ describe('WorkspacesService', () => {
|
||||
it('returns created Workspaces and preserves Host business errors', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
|
||||
@@ -221,7 +221,7 @@ describe('WorkspacesService', () => {
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
await workspaces.refresh()
|
||||
|
||||
@@ -94,7 +94,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
|
||||
}) as never)
|
||||
const sessions = new SessionsService(ctx, api) // provides 'sessions' itself
|
||||
const sessions = new SessionsService(ctx, api, 20) // provides 'sessions' itself
|
||||
await sessions.refresh()
|
||||
await Promise.resolve() // manager notifier flush
|
||||
await ctx.plugin(SlashService).await()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user