Merge pull request #711 from deepseek-harness/codex/basic-session-search
Add basic past-session search
This commit is contained in:
@@ -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/connection/README.md
|
||||
README.md: d2fda9f15125915594259e01e5b153609ceb21bb
|
||||
README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
|
||||
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
|
||||
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
|
||||
|
||||
@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// 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.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
@@ -25,7 +25,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'
|
||||
|
||||
@@ -26,13 +26,14 @@ import type {
|
||||
// Type-only: the brand constructor is host-side; the fixture casts at its
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
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> {
|
||||
@@ -575,6 +576,144 @@ function pageOf(
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
/** Fixture mirror of first-party message extraction used by session-query. */
|
||||
function searchBlockText(block: ContentBlock): string[] {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return [block.text]
|
||||
case 'reasoning':
|
||||
return []
|
||||
case 'tool-call':
|
||||
return [block.name, block.arguments]
|
||||
case 'tool-result':
|
||||
return block.content.flatMap(searchBlockText)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** One current-surface user/assistant/steering document, if searchable. */
|
||||
function searchEventText(event: SessionEvent): string {
|
||||
const content = event.type === 'user/message'
|
||||
? event.data.content
|
||||
: event.type === 'assistant/message' || event.type === 'steering/message'
|
||||
? event.data.message.content
|
||||
: undefined
|
||||
if (content === undefined) return ''
|
||||
return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
interface FixtureSearchToken {
|
||||
value: string
|
||||
/** Inclusive code-point offset in the whitespace-normalized display text. */
|
||||
start: number
|
||||
/** Exclusive code-point offset in the whitespace-normalized display text. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
|
||||
* Keeping phrase matching token-based prevents the development fixture from
|
||||
* promising arbitrary within-token substring behavior that production lacks.
|
||||
*/
|
||||
function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
|
||||
const text = value.replace(/\s+/gu, ' ').trim()
|
||||
const characters = Array.from(text)
|
||||
const tokens: FixtureSearchToken[] = []
|
||||
let start: number | undefined
|
||||
let raw = ''
|
||||
const flush = (end: number): void => {
|
||||
if (start !== undefined) {
|
||||
const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
|
||||
if (folded !== '') tokens.push({ value: folded, start, end })
|
||||
}
|
||||
start = undefined
|
||||
raw = ''
|
||||
}
|
||||
for (let index = 0; index < characters.length; index++) {
|
||||
const character = characters[index] as string
|
||||
const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
if (tokenBase === '') {
|
||||
if (start !== undefined) raw += character
|
||||
continue
|
||||
}
|
||||
if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
|
||||
start ??= index
|
||||
raw += character
|
||||
} else {
|
||||
flush(index)
|
||||
}
|
||||
}
|
||||
flush(characters.length)
|
||||
return { text, tokens }
|
||||
}
|
||||
|
||||
interface FixturePhraseMatch {
|
||||
count: number
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Count exact contiguous token-phrase occurrences and retain the first display span. */
|
||||
function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
|
||||
if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
|
||||
let count = 0
|
||||
let firstStart = 0
|
||||
let firstEnd = 0
|
||||
for (let start = 0; start <= document.length - phrase.length; start++) {
|
||||
if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
|
||||
count++
|
||||
if (count === 1) {
|
||||
firstStart = document[start]?.start ?? 0
|
||||
firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
|
||||
}
|
||||
}
|
||||
return { count, start: firstStart, end: firstEnd }
|
||||
}
|
||||
|
||||
/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
|
||||
function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
|
||||
const characters = Array.from(value)
|
||||
if (characters.length <= 120) return value
|
||||
const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
|
||||
const boundedEnd = Math.min(
|
||||
characters.length,
|
||||
Math.max(boundedStart + 1, matchEnd),
|
||||
)
|
||||
const center = Math.floor((boundedStart + boundedEnd) / 2)
|
||||
let start = Math.min(
|
||||
characters.length - 118,
|
||||
Math.max(0, center - Math.floor(118 / 2)),
|
||||
)
|
||||
let end = start + 118
|
||||
if (start === 0) {
|
||||
end = 119
|
||||
} else if (end === characters.length) {
|
||||
start = characters.length - 119
|
||||
}
|
||||
return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
|
||||
}
|
||||
|
||||
interface FixtureSearchCandidate {
|
||||
sessionId: SessionId
|
||||
seq: number
|
||||
time: number
|
||||
text: string
|
||||
matchCount: number
|
||||
matchStart: number
|
||||
matchEnd: number
|
||||
documentLength: number
|
||||
}
|
||||
|
||||
/** 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
|
||||
if (a.time !== b.time) return b.time - a.time
|
||||
if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1
|
||||
return b.seq - a.seq
|
||||
}
|
||||
|
||||
/**
|
||||
* Current plan projection over the full log (host parallel: latest todo/write
|
||||
* with no later turn/start; a new turn retires the previous plan).
|
||||
@@ -987,6 +1126,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
search: (request, signal) => {
|
||||
if (signal.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'fixture session search was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
|
||||
const matches = sessions.flatMap((summary) => {
|
||||
const log = logs.get(summary.sessionId) ?? []
|
||||
const current = new Set(foldSurface(log).nodes)
|
||||
const best = log.flatMap((event): FixtureSearchCandidate[] => {
|
||||
if (!current.has(event.seq)) return []
|
||||
const eventText = searchEventText(event)
|
||||
const document = searchTokenSpans(eventText)
|
||||
const match = phraseMatch(document.tokens, query)
|
||||
if (match.count === 0) return []
|
||||
return [{
|
||||
sessionId: summary.sessionId,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
text: document.text,
|
||||
matchCount: match.count,
|
||||
matchStart: match.start,
|
||||
matchEnd: match.end,
|
||||
documentLength: Array.from(eventText).length,
|
||||
}]
|
||||
}).sort(compareSearchCandidates)[0]
|
||||
return best === undefined ? [] : [best]
|
||||
}).sort(compareSearchCandidates)
|
||||
return ok(request, {
|
||||
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
|
||||
sessionId: match.sessionId,
|
||||
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
|
||||
})),
|
||||
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
},
|
||||
create: async (request) => {
|
||||
const workspace = request.payload.workspaceId === undefined
|
||||
? undefined
|
||||
@@ -1691,20 +1869,30 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
protected override async callUnary<K extends keyof RpcMethodMap>(
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
const request = rpcRequest(payload)
|
||||
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
|
||||
this.onEnvelope(full)
|
||||
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
|
||||
const response = await this.dispatch(
|
||||
method,
|
||||
request as RpcRequest<never>,
|
||||
signal ?? new AbortController().signal,
|
||||
) as RpcResponse<ResponseValue<K>>
|
||||
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
|
||||
this.onEnvelope(fullResponse)
|
||||
return response
|
||||
}
|
||||
|
||||
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
|
||||
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
|
||||
private dispatch(
|
||||
method: keyof RpcMethodMap,
|
||||
request: RpcRequest<never>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<unknown>> {
|
||||
switch (method) {
|
||||
case 'session.list': return this.api.sessions.list(request)
|
||||
case 'session.search': return this.api.sessions.search(request, signal)
|
||||
case 'session.create': return this.api.sessions.create(request)
|
||||
case 'session.history': return this.api.sessions.history(request)
|
||||
case 'session.models': return this.api.sessions.models(request)
|
||||
@@ -1725,8 +1913,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.delete': return this.api.workspace.delete(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
|
||||
case 'command.execute': return this.api.commands.execute(request, signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
@@ -25,7 +25,11 @@ export type {
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
export {
|
||||
RpcId,
|
||||
AbstractApiClient,
|
||||
transportError,
|
||||
} from './api.ts'
|
||||
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -44,6 +44,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
@@ -87,12 +89,17 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
// Parameter annotations below are local structural types on purpose: the CI
|
||||
// lint lane runs without built artifacts, where IApiClient's wire types
|
||||
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
|
||||
search: (payload: unknown, signal?: AbortSignal) => {
|
||||
this.lastSearchSignal = signal
|
||||
return this.record('session.search', payload, this.onSearch(payload))
|
||||
},
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
|
||||
@@ -48,6 +48,59 @@ describe('createFixtureApi', () => {
|
||||
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
|
||||
})
|
||||
|
||||
it('searches current message text with literal unicode61-style token phrases', async () => {
|
||||
const api = createFixtureApi()
|
||||
const signal = new AbortController().signal
|
||||
const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal)
|
||||
expect(phrase.result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'fx-alpha' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
if (!phrase.result.ok) throw new Error('search failed')
|
||||
expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
|
||||
|
||||
timing().appendUser(
|
||||
'fx-alpha',
|
||||
`${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
|
||||
)
|
||||
const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
|
||||
if (!late.result.ok) throw new Error('late search failed')
|
||||
const lateSnippet = late.result.value.items[0]?.snippet ?? ''
|
||||
expect(lateSnippet).toContain('late café token')
|
||||
expect(lateSnippet.startsWith('…')).toBe(true)
|
||||
expect(lateSnippet.endsWith('…')).toBe(true)
|
||||
expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
|
||||
|
||||
timing().appendUser('fx-alpha', 'Greek final sigma: ος')
|
||||
const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
|
||||
if (!finalSigma.result.ok) throw new Error('final sigma search failed')
|
||||
expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
|
||||
|
||||
const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
|
||||
expect(substring.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal)
|
||||
expect(punctuationOnly.result).toEqual({
|
||||
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()
|
||||
await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
|
||||
.resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
|
||||
})
|
||||
|
||||
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
|
||||
const api = createFixtureApi()
|
||||
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
|
||||
@@ -819,6 +872,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
|
||||
it('covers the whole unary dispatch table', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
expect((await client.sessions.search(
|
||||
{ query: 'fixture' },
|
||||
new AbortController().signal,
|
||||
)).result.ok).toBe(true)
|
||||
const created = await client.sessions.create({})
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
|
||||
@@ -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: a116a5e4ad3070f20e6d90490f2507c1e2369c37
|
||||
README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e
|
||||
README.md: 12023868c577ebcae6898d13358a2456295496c2
|
||||
README.zh.md: 7ef4c93d36b3f0b32c0bfcf8a38892260240c74f
|
||||
|
||||
@@ -12,6 +12,8 @@ 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. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
|
||||
@@ -12,6 +12,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
} from '../sessions/service.ts'
|
||||
@@ -22,6 +23,12 @@ export interface ISessions {
|
||||
readonly list: ObservableSnapshot<SessionListState>
|
||||
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
/**
|
||||
* The `session.search` result bound the wire schema fixes, exposed to
|
||||
* presentation as injected data. Not per-connection state: every transport
|
||||
* (fixture included) reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit: number
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
@@ -29,6 +36,17 @@ export interface ISessions {
|
||||
open(id: SessionId): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results, or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source; on resolution
|
||||
* the child is in the list store and `open()` can target it.
|
||||
|
||||
@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase } from './sessions/manager.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
|
||||
*/
|
||||
export type SessionListPhase = 'pending' | 'ready'
|
||||
|
||||
/** Request-local content hit returned to sidebar search consumers. */
|
||||
export interface SessionSearchResultItem {
|
||||
sessionId: SessionId
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Immutable session-list snapshot for useSessionList. */
|
||||
export interface SessionListSnapshot {
|
||||
items: readonly SessionListEntry[]
|
||||
@@ -248,6 +257,24 @@ export class SessionManager {
|
||||
return this.listInflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visible session message content without adding transient query
|
||||
* state to the list snapshot.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for superseded UI queries.
|
||||
* @returns the Host result or a folded transport error.
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||||
try {
|
||||
return (await this.api.sessions.search({ query }, signal)).result
|
||||
} catch (error: unknown) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh). A created session is blank by definition
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -189,6 +194,13 @@ export interface SessionProvideDescriptor {
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService implements ISessions {
|
||||
/**
|
||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||
* injected data. Not per-connection state: the `session.search` response
|
||||
* schema caps `items` at this constant, so every transport (fixture included)
|
||||
* reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
/** 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. */
|
||||
@@ -228,7 +240,10 @@ export class SessionsService implements ISessions {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
@@ -307,6 +322,20 @@ export class SessionsService implements ISessions {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||||
return this.manager.search(query, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
@@ -50,6 +51,8 @@ describe('runtime client apply', () => {
|
||||
const workspaces = bench.ctx.get('workspaces')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(workspaces !== undefined).toBe(true)
|
||||
// The bound the wire schema enforces, not a per-connection negotiation.
|
||||
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
|
||||
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -61,6 +61,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
@@ -106,12 +108,17 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
// Parameters carry local structural annotations: the CI lint lane runs
|
||||
// without built lib/, so IApiClient's indexed-access types collapse to any
|
||||
// and inferred parameters would trip no-unsafe-argument.
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
|
||||
search: (payload: unknown, signal?: AbortSignal) => {
|
||||
this.lastSearchSignal = signal
|
||||
return this.record('session.search', payload, this.onSearch(payload))
|
||||
},
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
|
||||
@@ -206,6 +206,49 @@ describe('list lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns bounded Host results and forwards the caller signal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSearch = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
},
|
||||
})
|
||||
expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
|
||||
expect(api.lastSearchSignal).toBe(signal)
|
||||
})
|
||||
|
||||
it('preserves business errors and folds transport failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onSearch = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'index unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const signal = new AbortController().signal
|
||||
await expect(manager.search('first', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'index unavailable' },
|
||||
})
|
||||
|
||||
api.onSearch = () => Promise.reject(new Error('wire down'))
|
||||
await expect(manager.search('second', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'wire down' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('host frame routing', () => {
|
||||
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -69,6 +69,29 @@ describe('list store projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('delegates transient content search without changing the list snapshot', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const before = b.svc.list.getSnapshot()
|
||||
b.api.onSearch = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(b.svc.search('needle', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(b.api.lastSearchSignal).toBe(signal)
|
||||
expect(b.svc.list.getSnapshot()).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope tree', () => {
|
||||
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
@@ -4,8 +4,11 @@ import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-cl
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
|
||||
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// The double reports the wire schema's own search bound, like the production
|
||||
// service — a transport-varying limit would be a fiction no client can see.
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { conversationSnapshot } from './fixtures.ts'
|
||||
import type { SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
@@ -151,8 +154,8 @@ export interface TestSessionBinding {
|
||||
*
|
||||
* Implements the same ISessions face features receive as `ctx.sessions`, so
|
||||
* a production face change breaks this double at compile time; the extra
|
||||
* members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
|
||||
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
|
||||
* members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
|
||||
* the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
|
||||
*/
|
||||
export class TestSessions implements ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection). */
|
||||
@@ -168,8 +171,14 @@ export class TestSessions implements ISessions {
|
||||
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
|
||||
private readonly channel: SessionProvideChannel
|
||||
|
||||
/** Calls observed on the service-level face (open/clear), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
|
||||
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
|
||||
|
||||
/** The wire schema's `session.search` result bound (production parity). */
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
|
||||
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
|
||||
private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
|
||||
|
||||
/**
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
@@ -392,6 +401,27 @@ export class TestSessions implements ISessions {
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the sidebar-search result page (the call is still recorded).
|
||||
* @param impl - hits for a query, as the Host would rank them.
|
||||
*/
|
||||
stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
|
||||
this.searchStub = impl
|
||||
}
|
||||
|
||||
/**
|
||||
* Content search over the fixture corpus (recorded). The default answers an
|
||||
* empty page: content ranking is Host behavior, so a scenario that asserts
|
||||
* hits declares them through {@link TestSessions.stubSearch}.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search (recorded and forwarded).
|
||||
* @returns the stubbed or empty result page.
|
||||
*/
|
||||
search(query: string, signal: AbortSignal): ReturnType<ISessions['search']> {
|
||||
this.calls.push({ method: 'search', args: [query, signal] })
|
||||
return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded fork stub: no child materializes (benches asserting the full
|
||||
* fork flow drive the production service; this face only proves the call).
|
||||
|
||||
@@ -221,6 +221,28 @@ describe('sessions', () => {
|
||||
])
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
const signal = new AbortController().signal
|
||||
expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
|
||||
await expect(runtime.sessions.search('marker', signal))
|
||||
.resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
|
||||
runtime.sessions.stubSearch(query => ({
|
||||
items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
|
||||
hasMore: true,
|
||||
}))
|
||||
await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
|
||||
})
|
||||
expect(runtime.sessions.calls).toEqual([
|
||||
{ method: 'search', args: ['marker', signal] },
|
||||
{ method: 'search', args: ['marker', signal] },
|
||||
])
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stores', () => {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/ui-workspace/README.md
|
||||
README.md: 860c24b8a25a1e9968261f586c16163579131a1c
|
||||
README.zh.md: 5a8e88051fc6f4fd46c5f2f6dcdc185eb4559ac6
|
||||
README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47
|
||||
README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
|
||||
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow.
|
||||
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
|
||||
|
||||
@@ -20,5 +22,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
|
||||
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
|
||||
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建流程。
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
|
||||
|
||||
@@ -20,5 +22,6 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
|
||||
- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
|
||||
@@ -217,6 +217,26 @@
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.list > [role='treeitem'] + [role='treeitem'] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.searchTree > [role='treeitem'] + [role='treeitem'] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.searchStatus,
|
||||
.searchWarning {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.searchWarning {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* One workspace section: header row + expanded session run. Rows inside
|
||||
keep the former flat-list 4px gap as sibling margins; the inter-group
|
||||
breathing room (figma 133:7661 batch separator, 20px after an expanded
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
Button, IconCloseFill14, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
||||
import type { SessionNode } from './tree.ts'
|
||||
import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts'
|
||||
import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
|
||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
|
||||
import css from './WorkspaceBrowser.module.css'
|
||||
|
||||
@@ -26,6 +28,21 @@ import css from './WorkspaceBrowser.module.css'
|
||||
* focus() forces a synchronous layout and would jank the slide.
|
||||
*/
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
/** Pause between the latest keystroke and a Host content-search request. */
|
||||
const SEARCH_DEBOUNCE_MS = 250
|
||||
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
|
||||
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
||||
|
||||
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
||||
function sanitizeSearchQuery(value: string): string {
|
||||
const withoutNul = value.replaceAll('\0', '')
|
||||
if (withoutNul.length <= SEARCH_QUERY_MAX_CODE_UNITS) return withoutNul
|
||||
let end = SEARCH_QUERY_MAX_CODE_UNITS
|
||||
const last = withoutNul.charCodeAt(end - 1)
|
||||
const next = withoutNul.charCodeAt(end)
|
||||
if (last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) end--
|
||||
return withoutNul.slice(0, end)
|
||||
}
|
||||
|
||||
/** Immutable membership toggle for the local expansion arrays. */
|
||||
function toggled(list: readonly string[], key: string): string[] {
|
||||
@@ -85,8 +102,6 @@ type SessionTreeProps = Pick<
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Live search filter owned by the browser root (the query outlives the tree). */
|
||||
query: string
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
@@ -97,7 +112,7 @@ type SessionTreeProps = Pick<
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, forkSession, workspaces, query,
|
||||
useSessions, startSession, open, forkSession, workspaces,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
@@ -114,8 +129,8 @@ function SessionTree({
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, query }),
|
||||
[list, workspaces, expandedProjects, query],
|
||||
() => deriveGroups(list, workspaces, { expandedProjects }),
|
||||
[list, workspaces, expandedProjects],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -123,7 +138,7 @@ function SessionTree({
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
|
||||
<div className={css.empty}>{t('empty.none')}</div>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
// Group section: header row + expanded top-level session rows. The
|
||||
@@ -151,10 +166,10 @@ function SessionTree({
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace session rows outside search. The drag
|
||||
// Draggable: real-workspace session rows. The drag
|
||||
// never leaves its group — rows of other groups show no markers
|
||||
// and reject drops (visual movement confined to this section).
|
||||
const draggable = group.workspaceId !== undefined && query === ''
|
||||
const draggable = group.workspaceId !== undefined
|
||||
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
|
||||
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
|
||||
start: () => {
|
||||
@@ -208,15 +223,15 @@ function SessionTree({
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, query, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query' | 't'>) {
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||
const rows = useMemo(() => deriveFlat(list), [list])
|
||||
const now = Date.now()
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
|
||||
{rows.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
|
||||
<div className={css.empty}>{t('empty.none')}</div>
|
||||
)}
|
||||
{rows.map(node => (
|
||||
<SessionNodeItem
|
||||
@@ -236,6 +251,74 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, query, t }:
|
||||
)
|
||||
}
|
||||
|
||||
interface RemoteSearchState {
|
||||
query: string
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
items: readonly SessionSearchResultItem[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/** Flat search body: local metadata matches plus the current Host result page. */
|
||||
function SearchResults({
|
||||
useSessions,
|
||||
open,
|
||||
workspaces,
|
||||
query,
|
||||
remote,
|
||||
resultLimit,
|
||||
t,
|
||||
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
|
||||
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, 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}>
|
||||
<div className={css.searchTree} role="tree" aria-label={t('search.results.aria')}>
|
||||
{results.items.map(result => (
|
||||
<SearchResultItem
|
||||
key={result.id}
|
||||
result={result}
|
||||
currentId={list.current}
|
||||
onOpen={open}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{pending && (
|
||||
<div className={css.searchStatus} role="status">{t('search.pending')}</div>
|
||||
)}
|
||||
{failed && (
|
||||
<div className={css.searchWarning} role="status">
|
||||
{t('search.unavailable')}
|
||||
</div>
|
||||
)}
|
||||
{!pending && results.items.length === 0 && (
|
||||
<div className={css.empty}>{t('search.noMatches')}</div>
|
||||
)}
|
||||
{results.hasMore && (
|
||||
<div className={css.searchStatus}>
|
||||
{t('search.hasMore', { n: resultLimit })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the browsing region.
|
||||
* @param props - composed slot props (shell owner share + store + injected actions).
|
||||
@@ -256,6 +339,8 @@ export function WorkspaceBrowser({
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
searchSessions,
|
||||
searchResultLimit,
|
||||
useDirectoryFlow,
|
||||
renderSlot,
|
||||
t,
|
||||
@@ -265,6 +350,13 @@ export function WorkspaceBrowser({
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const normalizedQuery = sanitizeSearchQuery(query).trim()
|
||||
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
|
||||
query: '',
|
||||
status: 'idle',
|
||||
items: [],
|
||||
hasMore: false,
|
||||
})
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
// Section-header + opens the picker menu (same popover in wide and rail
|
||||
// states; the menu anchors on this button).
|
||||
@@ -285,6 +377,43 @@ export function WorkspaceBrowser({
|
||||
}
|
||||
}, [wide, searchOnExpand])
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedQuery === '') {
|
||||
setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false })
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
setRemoteSearch({
|
||||
query: normalizedQuery,
|
||||
status: 'loading',
|
||||
items: [],
|
||||
hasMore: false,
|
||||
})
|
||||
const timer = window.setTimeout(() => {
|
||||
searchSessions(normalizedQuery, controller.signal).then((result) => {
|
||||
if (controller.signal.aborted) return
|
||||
setRemoteSearch({
|
||||
query: normalizedQuery,
|
||||
status: 'ready',
|
||||
items: result.items,
|
||||
hasMore: result.hasMore,
|
||||
})
|
||||
}).catch(() => {
|
||||
if (controller.signal.aborted) return
|
||||
setRemoteSearch({
|
||||
query: normalizedQuery,
|
||||
status: 'error',
|
||||
items: [],
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [normalizedQuery, searchSessions])
|
||||
|
||||
// Rename dialog (browser-owned so it outlives row unmounts during collapse).
|
||||
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
@@ -442,8 +571,9 @@ export function WorkspaceBrowser({
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
|
||||
/>
|
||||
)}
|
||||
{wide && query !== '' && (
|
||||
@@ -461,35 +591,46 @@ export function WorkspaceBrowser({
|
||||
{/* Always-mounted seat keeps the region's flex slot while the list
|
||||
itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
{wide && (normalizedQuery !== ''
|
||||
? (
|
||||
<FlatList
|
||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||
onSessionRename={onSessionRename} query={query} t={t}
|
||||
<SearchResults
|
||||
useSessions={useSessions}
|
||||
open={open}
|
||||
workspaces={workspaces}
|
||||
query={normalizedQuery}
|
||||
remote={remoteSearch}
|
||||
resultLimit={searchResultLimit}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
t={t}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
: groupBy === 'flat'
|
||||
? (
|
||||
<FlatList
|
||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||
onSessionRename={onSessionRename} t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
t={t}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -24,7 +24,9 @@ import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, Props
|
||||
// runtime shares below.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||
|
||||
/**
|
||||
@@ -93,6 +95,16 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
startSession: (workspaceId?: WorkspaceId) => void
|
||||
/** Open a real Session. */
|
||||
open: (sessionId: SessionId) => void
|
||||
/**
|
||||
* Search current visible conversation messages. The Host fixes the result
|
||||
* bound; `hasMore` means the query needs narrowing.
|
||||
*/
|
||||
searchSessions: (
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
|
||||
/** Maximum number of merged rows rendered for one search. */
|
||||
searchResultLimit: number
|
||||
/** Rename a Session (explicit user title; resolves on host acceptance). */
|
||||
renameSession: (sessionId: SessionId, title: string) => Promise<void>
|
||||
/** Fork a Session at its last completed turn and open the child. */
|
||||
|
||||
@@ -54,6 +54,12 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
|
||||
|
||||
const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
|
||||
const result = await ctx.sessions.search(query, signal)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
return result.value
|
||||
}
|
||||
|
||||
// Stable per-surface occupancy sources (the renderer's hook cache keys by
|
||||
// source identity): true while the surface's directory-flow hole is filled.
|
||||
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
|
||||
@@ -67,6 +73,8 @@ export function apply(ctx: ClientContext): void {
|
||||
// the runtime's shared action (recent-Workspace projection inside).
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
searchSessions,
|
||||
searchResultLimit: ctx.sessions.searchResultLimit,
|
||||
renameSession: async (sessionId, title) => {
|
||||
// Row → session-face hop: rename is a per-session verb (ISession), not
|
||||
// a list-service verb; the binding resolves any listed session.
|
||||
|
||||
@@ -19,6 +19,11 @@ export const zh = {
|
||||
'search.sessions.aria': '搜索会话',
|
||||
'search.placeholder': '搜索名称、关键词…',
|
||||
'search.clear': '清除搜索',
|
||||
'search.results.aria': '搜索结果',
|
||||
'search.pending': '正在搜索会话历史…',
|
||||
'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
|
||||
'search.noMatches': '无匹配会话',
|
||||
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
|
||||
'menu.openFolder': '打开本地文件夹…',
|
||||
'menu.createWorkspace': '新建工作区',
|
||||
'picker.loading': '正在加载工作区…',
|
||||
@@ -77,6 +82,11 @@ export const en = {
|
||||
'search.sessions.aria': 'Search sessions',
|
||||
'search.placeholder': 'Search name, keywords...',
|
||||
'search.clear': 'Clear search',
|
||||
'search.results.aria': 'Search results',
|
||||
'search.pending': 'Searching session history…',
|
||||
'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
|
||||
'search.noMatches': 'No matching sessions',
|
||||
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
|
||||
'menu.openFolder': 'Open local folder…',
|
||||
'menu.createWorkspace': 'Create a new workspace',
|
||||
'picker.loading': 'Loading workspaces…',
|
||||
|
||||
@@ -24,6 +24,64 @@
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.searchResultRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 7px 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.searchResultRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchResultRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.searchResultHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchResultTitle {
|
||||
min-width: 0;
|
||||
margin-left: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.searchResultWorkspace,
|
||||
.searchResultSnippet {
|
||||
margin-left: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.searchResultWorkspace {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.searchResultSnippet {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Two-line row: the leading slot (folder/chevron), title, and trailing
|
||||
actions all top-align on the 20px first text line (figma cell) — content
|
||||
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
|
||||
import type { GroupNode, SessionNode } from '../tree.ts'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
|
||||
import { relativeTime } from '../tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
@@ -202,6 +202,41 @@ export interface RowDragProps {
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One flat search result: title, Workspace context, and optional content
|
||||
* excerpt. Search navigation opens the session only; it does not address an
|
||||
* event inside the conversation.
|
||||
* @param props.result - merged local/content search row.
|
||||
* @param props.currentId - selected session id.
|
||||
* @param props.onOpen - open the selected session.
|
||||
* @returns the result button.
|
||||
*/
|
||||
export function SearchResultItem({ result, currentId, onOpen }: {
|
||||
result: SearchResultNode
|
||||
currentId: string | undefined
|
||||
onOpen: (id: SearchResultNode['id']) => void
|
||||
}) {
|
||||
const selected = result.id === currentId
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.searchResultRow, selected && css.selected)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
onClick={() => { onOpen(result.id) }}
|
||||
>
|
||||
<span className={css.searchResultHeading}>
|
||||
<span className={css.slot}>{result.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.searchResultTitle}>{result.title}</span>
|
||||
</span>
|
||||
<span className={css.searchResultWorkspace}>{result.workspace}</span>
|
||||
{result.snippet !== undefined && (
|
||||
<span className={css.searchResultSnippet}>{result.snippet}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pointer-position half of a row (insert line above or below). */
|
||||
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Unassigned Sessions trail under Ungrouped; only the selected blank Session
|
||||
* remains visible.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSearchResultItem, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for Sessions outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
@@ -41,10 +43,24 @@ export interface GroupNode {
|
||||
sessions: readonly SessionNode[]
|
||||
}
|
||||
|
||||
/** One flat search row combining list metadata with an optional content match. */
|
||||
export interface SearchResultNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
workspace: string
|
||||
running: boolean
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
/** Bounded merged search projection plus the refine-query hint bit. */
|
||||
export interface SearchResultSet {
|
||||
items: readonly SearchResultNode[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/** Viewing state consumed by the derivation. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
@@ -150,16 +166,13 @@ function sessionNode(s: SessionSummary): SessionNode {
|
||||
/**
|
||||
* Derive the workspace browser groups with every session as a top-level row.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* preserving Host account order. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matching sessions are forced visible, groups without a display-title or
|
||||
* label hit are dropped, and a label-only hit
|
||||
* keeps the bare group header. Non-current blank sessions are excluded
|
||||
* everywhere; blank placeholders never match a search query.
|
||||
* Every group shows; sessions populate under expanded groups, preserving
|
||||
* Host account order. Blank sessions are excluded except for the selected
|
||||
* provisional New Session row. Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param view - local expansion arrays and search query.
|
||||
* @param view - local expansion arrays.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
@@ -167,7 +180,6 @@ export function deriveGroups(
|
||||
workspaces: readonly WorkspaceView[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
@@ -175,34 +187,18 @@ export function deriveGroups(
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces)) {
|
||||
if (q === '') {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.sessions.length,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? g.sessions.map(sessionNode) : [],
|
||||
})
|
||||
} else {
|
||||
const matches = g.sessions.filter(session => !session.blank && sessionTitle(session).toLowerCase().includes(q))
|
||||
if (matches.length === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.sessions.length,
|
||||
expanded: matches.length > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: matches.map(sessionNode),
|
||||
})
|
||||
}
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.sessions.length,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? g.sessions.map(sessionNode) : [],
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -210,19 +206,16 @@ export function deriveGroups(
|
||||
/**
|
||||
* Derive the flat session list ("In one list" mode): every session — fork
|
||||
* children included — as a top-level row, strictly newest-first. No grouping,
|
||||
* no parent/child adjacency. Search mode filters by case-insensitive
|
||||
* display-title substring.
|
||||
* no parent/child adjacency. Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot.
|
||||
* @param view - the search query (expansion state does not apply).
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
export function deriveFlat(list: SessionListState): SessionNode[] {
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined || !sessionVisible(s, list.current)) continue
|
||||
if (q !== '' && (s.blank || !sessionTitle(s).toLowerCase().includes(q))) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
@@ -238,6 +231,83 @@ export interface RelativeTime {
|
||||
n: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge immediate title/Workspace substring matches with ranked Host content
|
||||
* matches. Local rows lead newest-first, content-only rows retain backend
|
||||
* order, and duplicate sessions receive the backend snippet in place.
|
||||
* @param list - session metadata authority.
|
||||
* @param workspaces - Workspace membership and display labels.
|
||||
* @param query - caller text; surrounding whitespace is ignored.
|
||||
* @param content - ranked Host content-search page.
|
||||
* @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 }
|
||||
|
||||
const workspaceBySession = new Map<SessionId, string>()
|
||||
for (const workspace of workspaces) {
|
||||
for (const sessionId of workspace.sessionIds) {
|
||||
if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, workspace.title)
|
||||
}
|
||||
}
|
||||
const labelOf = (summary: SessionSummary): string =>
|
||||
workspaceBySession.get(summary.id) ?? projectLabel(summary.cwd)
|
||||
const contentBySession = new Map<SessionId, SessionSearchResultItem>()
|
||||
for (const item of content.items) {
|
||||
if (!contentBySession.has(item.sessionId)) contentBySession.set(item.sessionId, item)
|
||||
}
|
||||
|
||||
const local: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const summary = list.byId[id]
|
||||
// Blank placeholders never match a query (their canonical title displays
|
||||
// localized, so matching it would tie search to one language).
|
||||
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
|
||||
if (
|
||||
sessionTitle(summary).toLowerCase().includes(q)
|
||||
|| labelOf(summary).toLowerCase().includes(q)
|
||||
) {
|
||||
local.push(summary)
|
||||
}
|
||||
}
|
||||
local.sort(byRecency)
|
||||
|
||||
const ordered: SessionSummary[] = []
|
||||
const included = new Set<SessionId>()
|
||||
const include = (summary: SessionSummary): void => {
|
||||
if (included.has(summary.id)) return
|
||||
included.add(summary.id)
|
||||
ordered.push(summary)
|
||||
}
|
||||
for (const summary of local) include(summary)
|
||||
for (const item of content.items) {
|
||||
const summary = list.byId[item.sessionId]
|
||||
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
|
||||
}
|
||||
|
||||
return {
|
||||
items: ordered.slice(0, limit).map((summary) => {
|
||||
const match = contentBySession.get(summary.id)
|
||||
return {
|
||||
id: summary.id,
|
||||
title: sessionTitle(summary),
|
||||
workspace: labelOf(summary),
|
||||
running: summary.running,
|
||||
...match === undefined ? {} : { snippet: match.snippet },
|
||||
}
|
||||
}),
|
||||
hasMore: content.hasMore || ordered.length > limit,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows, as a structured bucket the
|
||||
* renderer localizes ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en).
|
||||
|
||||
@@ -20,18 +20,22 @@ 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 },
|
||||
}))
|
||||
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
|
||||
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
|
||||
const fork = vi.fn(async () => 'forked' as never)
|
||||
ctx.provide('workspaces', {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear, binding, fork } as never)
|
||||
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, locale, create, startSession, rename,
|
||||
insertSessionBefore, open, clear, renameSession, binding, fork,
|
||||
insertSessionBefore, open, clear, search, renameSession, binding, fork,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +83,13 @@ 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)
|
||||
expect(browser.searchResultLimit).toBe(20)
|
||||
await browser.renameSession('session' as never, 'renamed session')
|
||||
expect(b.binding).toHaveBeenCalledWith('session')
|
||||
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
|
||||
@@ -124,6 +135,19 @@ describe('ui-workspace apply', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/cli
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
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'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -44,6 +44,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, projectLabel, relativeTime, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import {
|
||||
deriveFlat, deriveGroups, deriveSearchResults, projectLabel, relativeTime,
|
||||
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, query,
|
||||
const view = (expandedProjects: readonly string[] = []) => ({
|
||||
expandedProjects,
|
||||
})
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
@@ -64,25 +67,6 @@ describe('deriveGroups', () => {
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('excludes blank sessions from search regardless of the query', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const real = { ...summary('real', 3), displayTitle: 'new session notes' }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank, real),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale', 'real'])],
|
||||
view([], 'new session'),
|
||||
)
|
||||
// Only the real title hit matches; the current blank's placeholder title
|
||||
// never participates (it displays localized, so matching it would tie
|
||||
// search to one language).
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id])
|
||||
})
|
||||
|
||||
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
|
||||
const parent = summary('parent', 1)
|
||||
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
|
||||
@@ -96,7 +80,7 @@ describe('deriveGroups', () => {
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], query: '' },
|
||||
{ expandedProjects: [UNGROUPED_KEY] },
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
@@ -120,31 +104,6 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('searches rows independently of lineage and keeps 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.map(node => node.id)).toEqual([
|
||||
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)
|
||||
@@ -162,19 +121,13 @@ 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')])
|
||||
})
|
||||
|
||||
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 and excludes blanks from search', () => {
|
||||
@@ -184,14 +137,120 @@ 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(rows.map(row => row.blank)).toEqual([true, false])
|
||||
// Blank rows never match a query — not their placeholder title, not their id.
|
||||
expect(deriveFlat(sessions, { query: 'new session' })).toEqual([])
|
||||
expect(deriveFlat(sessions, { query: 'current-blank' })).toEqual([])
|
||||
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'),
|
||||
workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
|
||||
],
|
||||
' NEEDLE ',
|
||||
{
|
||||
items: [
|
||||
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
|
||||
{ sessionId: contentHit.id, snippet: 'ignored duplicate excerpt' },
|
||||
{ sessionId: titleHit.id, snippet: 'title session body excerpt' },
|
||||
{ sessionId: sid('unknown'), snippet: 'not in session.list' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
10,
|
||||
)
|
||||
|
||||
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('excludes blank sessions from search regardless of query or content hits', () => {
|
||||
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,
|
||||
}
|
||||
// Blank placeholders never match — not their localized-display title, not
|
||||
// their id, and not even a backend content hit naming them.
|
||||
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,
|
||||
},
|
||||
10,
|
||||
)
|
||||
expect(result.items).toEqual([])
|
||||
})
|
||||
|
||||
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 },
|
||||
3,
|
||||
)
|
||||
expect(overflow.items).toHaveLength(3)
|
||||
expect(overflow.hasMore).toBe(true)
|
||||
|
||||
const backendMore = deriveSearchResults(
|
||||
list(summary('body', 1)),
|
||||
[],
|
||||
'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 }, 3))
|
||||
.toEqual({ items: [], hasMore: false })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
actions: store.actions,
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
searchResultLimit: 20,
|
||||
renameSession: vi.fn(async () => {}),
|
||||
forkSession: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
@@ -219,38 +221,215 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('新会话')).toBeNull()
|
||||
})
|
||||
|
||||
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>('搜索名称、关键词…')
|
||||
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('无匹配结果')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
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' } })
|
||||
const resultTree = screen.getByRole('tree', { name: '搜索结果' })
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
const status = screen.getByRole('status')
|
||||
expect(status.textContent).toBe('正在搜索会话历史…')
|
||||
expect(resultTree.contains(status)).toBe(false)
|
||||
|
||||
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: '会话' })).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('暂无会话')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
// Flat search misses show No matches.
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('无匹配结果')).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('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
|
||||
mount({ searchSessions })
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
expect(input.maxLength).toBe(500)
|
||||
fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
|
||||
expect(input.value).toBe('y'.repeat(500))
|
||||
const expected = `prefix${'x'.repeat(493)}`
|
||||
fireEvent.change(input, {
|
||||
target: { value: `prefix\0${'x'.repeat(493)}😀tail` },
|
||||
})
|
||||
|
||||
expect(input.value).toBe(expected)
|
||||
expect(input.value.length).toBe(499)
|
||||
expect(input.value).not.toContain('\0')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
expect(searchSessions).toHaveBeenCalledWith(expected, expect.any(AbortSignal))
|
||||
} 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('ignores a rejected request after it has been superseded', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let rejectFirst!: (reason: Error) => void
|
||||
const first = new Promise<never>((_resolve, reject) => { rejectFirst = reject })
|
||||
const searchSessions = vi.fn((query: string) => query === 'first'
|
||||
? first
|
||||
: Promise.resolve({ items: [], hasMore: false }))
|
||||
mount({ searchSessions })
|
||||
const input = screen.getByPlaceholderText('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
await act(async () => {
|
||||
rejectFirst(new Error('stale failure'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('内容搜索暂不可用,仅显示名称匹配。')).toBeNull()
|
||||
} 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('暂无会话')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('暂无会话')).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', () => {
|
||||
@@ -551,6 +730,6 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
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