feat(web): add basic past-session search (round 1)
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
// 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,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
@@ -281,6 +282,78 @@ 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':
|
||||
case 'reasoning':
|
||||
return [block.text]
|
||||
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 {
|
||||
if (
|
||||
event.type !== 'user/message'
|
||||
&& event.type !== 'assistant/message'
|
||||
&& event.type !== 'steering/message'
|
||||
) return ''
|
||||
return event.data.content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 searchTokens(value: string): string[] {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}+/gu, '')
|
||||
.toLowerCase()
|
||||
.match(/[\p{L}\p{N}\p{Co}]+/gu) ?? []
|
||||
}
|
||||
|
||||
/** Count exact contiguous token-phrase occurrences in one fixture document. */
|
||||
function phraseMatchCount(document: readonly string[], phrase: readonly string[]): number {
|
||||
if (phrase.length === 0 || phrase.length > document.length) return 0
|
||||
let count = 0
|
||||
for (let start = 0; start <= document.length - phrase.length; start++) {
|
||||
if (phrase.every((token, offset) => document[start + offset] === token)) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** One-line fixture excerpt, bounded so the sidebar remains readable. */
|
||||
function searchSnippet(value: string): string {
|
||||
const oneLine = value.replace(/\s+/gu, ' ').trim()
|
||||
return oneLine.length <= 120 ? oneLine : `${oneLine.slice(0, 117)}…`
|
||||
}
|
||||
|
||||
interface FixtureSearchCandidate {
|
||||
sessionId: SessionId
|
||||
seq: number
|
||||
time: number
|
||||
text: string
|
||||
matchCount: number
|
||||
documentLength: number
|
||||
}
|
||||
|
||||
/** Same rank keys as session-query-sqlite's cross-session result order. */
|
||||
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
|
||||
}
|
||||
|
||||
interface StreamConn<F> {
|
||||
push(envelope: RpcRequest<F>): void
|
||||
}
|
||||
@@ -547,6 +620,42 @@ 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 = searchTokens(request.payload.query)
|
||||
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 matchCount = phraseMatchCount(searchTokens(eventText), query)
|
||||
if (matchCount === 0) return []
|
||||
return [{
|
||||
sessionId: summary.sessionId,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
text: eventText,
|
||||
matchCount,
|
||||
documentLength: Array.from(eventText).length,
|
||||
}]
|
||||
}).sort(compareSearchCandidates)[0]
|
||||
return best === undefined ? [] : [best]
|
||||
}).sort(compareSearchCandidates)
|
||||
return ok(request, {
|
||||
items: matches.slice(0, 20).map(match => ({
|
||||
sessionId: match.sessionId,
|
||||
snippet: searchSnippet(match.text),
|
||||
})),
|
||||
hasMore: matches.length > 20,
|
||||
})
|
||||
},
|
||||
create: async (request) => {
|
||||
const workspace = request.payload.workspaceId === undefined
|
||||
? undefined
|
||||
@@ -892,20 +1001,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.prompt': return this.api.sessions.prompt(request)
|
||||
@@ -916,8 +1035,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.rename': return this.api.workspace.rename(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SkillEntry,
|
||||
RpcRequest, RpcResponse, SessionId, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -43,6 +43,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 }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
@@ -55,12 +57,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,37 @@ 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 历史消息')
|
||||
|
||||
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 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 }))
|
||||
@@ -601,6 +632,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98
|
||||
README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: c83e70d574b85ff1128f48725b03811411b62fe2
|
||||
README.zh.md: ab97832760bb7f147211cf430aa5c7601b4dcbd8
|
||||
|
||||
@@ -10,6 +10,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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。
|
||||
|
||||
## 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` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
@@ -18,7 +18,7 @@ export type { Session } from './sessions/session.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 { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -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'
|
||||
@@ -22,6 +25,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[]
|
||||
@@ -213,6 +222,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,9 @@
|
||||
* 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'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -24,7 +26,7 @@ import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.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 type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
@@ -318,6 +320,20 @@ export class SessionsService {
|
||||
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.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -60,6 +60,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 }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
@@ -72,12 +74,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)),
|
||||
|
||||
@@ -194,6 +194,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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33
|
||||
README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: 9cb919a1a64394d5e116d35bdddfdee738994a02
|
||||
README.zh.md: b3add7f89cb0feb7f44238b7199d0633cdfbf641
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
|
||||
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 modals.
|
||||
|
||||
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. 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; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new 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.
|
||||
|
||||
@@ -18,5 +20,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Workspace rename/delete controls** — the picker supports selection and creation only.
|
||||
- **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 Workspace delete control** — the browser supports creation and rename, while the picker supports selection and creation.
|
||||
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。
|
||||
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建模态框。
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。
|
||||
|
||||
@@ -18,5 +20,6 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。
|
||||
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
|
||||
- **没有 Workspace 删除控件**:浏览器支持创建和重命名,选择器支持选择和创建。
|
||||
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。
|
||||
|
||||
@@ -209,6 +209,22 @@
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.list > [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,16 +13,20 @@ 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'
|
||||
|
||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — 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
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ type: 'label' as const, id: 'group-by', text: 'Group by' },
|
||||
@@ -83,14 +87,12 @@ type SessionTreeProps = Pick<
|
||||
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
|
||||
> & {
|
||||
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
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
|
||||
function SessionTree({ useSessions, startSession, open, workspaces, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
@@ -106,8 +108,8 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
|
||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
[list, workspaces, expandedProjects, expandedSessions, query],
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions }),
|
||||
[list, workspaces, expandedProjects, expandedSessions],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -115,7 +117,7 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
<div className={css.empty}>No sessions yet</div>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
// Group section: header row + expanded session subtree. The
|
||||
@@ -136,10 +138,10 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace group roots outside search. The drag
|
||||
// Draggable: real-workspace group roots. 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: () => {
|
||||
@@ -192,15 +194,15 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
|
||||
function FlatList({ useSessions, open }: Pick<SessionTreeProps, 'useSessions' | 'open'>) {
|
||||
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="Sessions">
|
||||
{rows.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
<div className={css.empty}>No sessions yet</div>
|
||||
)}
|
||||
{rows.map(node => (
|
||||
<SessionNodeItem
|
||||
@@ -221,6 +223,67 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}: Pick<SessionTreeProps, 'useSessions' | 'open'> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
query: string
|
||||
remote: RemoteSearchState
|
||||
}) {
|
||||
const list = useSessions((s) => s)
|
||||
const currentRemote = remote.query === query
|
||||
? remote
|
||||
: { query, status: 'loading' as const, items: [], hasMore: false }
|
||||
const results = useMemo(
|
||||
() => deriveSearchResults(list, workspaces, query, currentRemote),
|
||||
[list, workspaces, query, currentRemote],
|
||||
)
|
||||
const pending = currentRemote.status === 'loading'
|
||||
const failed = currentRemote.status === 'error'
|
||||
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="搜索结果">
|
||||
{results.items.map(result => (
|
||||
<SearchResultItem
|
||||
key={result.id}
|
||||
result={result}
|
||||
currentId={list.current}
|
||||
onOpen={open}
|
||||
/>
|
||||
))}
|
||||
{pending && (
|
||||
<div className={css.searchStatus} role="status">正在搜索历史…</div>
|
||||
)}
|
||||
{failed && (
|
||||
<div className={css.searchWarning} role="status">
|
||||
历史内容搜索暂时不可用,仍显示名称匹配。
|
||||
</div>
|
||||
)}
|
||||
{!pending && results.items.length === 0 && (
|
||||
<div className={css.empty}>没有匹配结果</div>
|
||||
)}
|
||||
{results.hasMore && (
|
||||
<div className={css.searchStatus}>仅显示前 20 项,请缩小搜索范围。</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the browsing region.
|
||||
* @param props - composed slot props (shell owner share + store + injected actions).
|
||||
@@ -238,12 +301,20 @@ export function WorkspaceBrowser({
|
||||
renameWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
searchSessions,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
// 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 = 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).
|
||||
@@ -263,6 +334,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('')
|
||||
@@ -331,11 +439,11 @@ export function WorkspaceBrowser({
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
decorative). Rail: the icon is the region's search control. */}
|
||||
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
|
||||
<Tooltip label="Search" disabled={wide}>
|
||||
<Tooltip label="搜索" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
aria-label="搜索会话"
|
||||
tabIndex={wide ? -1 : 0}
|
||||
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
|
||||
>
|
||||
@@ -347,7 +455,7 @@ export function WorkspaceBrowser({
|
||||
ref={searchInput}
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder="Search name, keywords..."
|
||||
placeholder="搜索名称或关键词…"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
/>
|
||||
@@ -356,7 +464,7 @@ export function WorkspaceBrowser({
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.clearButton, css.wide)}
|
||||
aria-label="Clear search"
|
||||
aria-label="清除搜索"
|
||||
onClick={() => { setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
@@ -367,15 +475,24 @@ 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'
|
||||
? <FlatList useSessions={useSessions} open={open} query={query} />
|
||||
{wide && (normalizedQuery !== ''
|
||||
? (
|
||||
<SearchResults
|
||||
useSessions={useSessions}
|
||||
open={open}
|
||||
workspaces={workspaces}
|
||||
query={normalizedQuery}
|
||||
remote={remoteSearch}
|
||||
/>
|
||||
)
|
||||
: groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// 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'
|
||||
|
||||
/**
|
||||
@@ -30,6 +32,14 @@ export type WorkspaceBrowserInjected = {
|
||||
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 }>
|
||||
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/**
|
||||
|
||||
@@ -33,11 +33,17 @@ export const inject = ['slots', 'sessions', 'workspaces']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
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
|
||||
}
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
// Explicit group actions keep their target; unscoped New Session rides
|
||||
// the runtime's shared action (recent-Workspace projection inside).
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
searchSessions,
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GroupNode, SessionNode } from '../tree.ts'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
|
||||
import { formatRelativeTime } from '../tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
@@ -149,6 +149,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 = ''
|
||||
@@ -15,7 +17,7 @@ export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
export interface SessionNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
/** Visible children, already expansion/search-filtered (empty when folded). */
|
||||
/** Visible children, already expansion-filtered (empty when folded). */
|
||||
children: readonly SessionNode[]
|
||||
/** The session HAS children in the data (the twist renders even while folded). */
|
||||
hasChildren: boolean
|
||||
@@ -41,11 +43,25 @@ 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 — the component's local useState arrays, taken as-is. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
expandedSessions: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
@@ -204,47 +220,15 @@ function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionN
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
for (const m of g.summaries.values()) {
|
||||
if (!sessionTitle(m).toLowerCase().includes(q)) continue
|
||||
let cur: SessionSummary | undefined = m
|
||||
while (cur !== undefined && !visible.has(cur.id)) {
|
||||
visible.add(cur.id)
|
||||
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id) || !visible.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
return sessionNode(s, children, kids.length > 0, kids.length > 0)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the nested workspace browser group structure.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* descending only into expanded sessions. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, and a label-only hit
|
||||
* keeps the bare group header. Blank sessions are excluded everywhere.
|
||||
* Every group shows; sessions populate under expanded groups, descending
|
||||
* only into expanded sessions. Blank sessions are excluded except for the
|
||||
* selected provisional New Session row.
|
||||
* @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(
|
||||
@@ -252,7 +236,6 @@ export function deriveGroups(
|
||||
workspaces: readonly WorkspaceView[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const expandedSessions = new Set(view.expandedSessions)
|
||||
const currentGroup = list.current === undefined
|
||||
@@ -261,32 +244,17 @@ 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,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? buildVisible(g, expandedSessions) : [],
|
||||
})
|
||||
} else {
|
||||
const visible = searchVisible(g, q)
|
||||
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded: visible.size > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: buildSearch(g, visible),
|
||||
})
|
||||
}
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? buildVisible(g, expandedSessions) : [],
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -295,25 +263,97 @@ 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; rows reuse SessionNode with children always
|
||||
* empty so the renderer stays branch-free. Search mode filters by
|
||||
* case-insensitive display-title substring.
|
||||
* empty so the renderer stays branch-free.
|
||||
* @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 !== '' && !sessionTitle(s).toLowerCase().includes(q)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
return rows.map(s => sessionNode(s, [], false, false))
|
||||
}
|
||||
|
||||
/** Maximum rows rendered by the basic search surface. */
|
||||
const SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Merge immediate title/Workspace substring matches with ranked Host content
|
||||
* matches. Local rows lead newest-first, content-only rows retain backend
|
||||
* 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.
|
||||
* @returns at most 20 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 },
|
||||
): 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]
|
||||
if (summary === undefined || !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 && sessionVisible(summary, list.current)) include(summary)
|
||||
}
|
||||
|
||||
return {
|
||||
items: ordered.slice(0, SEARCH_RESULT_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 > SEARCH_RESULT_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
|
||||
* @param updatedAt - epoch ms of the session's last activity.
|
||||
|
||||
@@ -19,11 +19,25 @@ async function bench() {
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
const clear = vi.fn()
|
||||
const search = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
|
||||
}))
|
||||
ctx.provide('workspaces', {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
|
||||
ctx.provide('sessions', { open, clear, search } as never)
|
||||
return {
|
||||
ctx,
|
||||
slots: ctx.get('slots') as SlotsService,
|
||||
create,
|
||||
startSession,
|
||||
rename,
|
||||
insertSessionBefore,
|
||||
open,
|
||||
clear,
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||
@@ -66,6 +80,12 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
|
||||
browser.open('session' as never)
|
||||
expect(b.open).toHaveBeenCalledWith('session')
|
||||
const signal = new AbortController().signal
|
||||
await expect(browser.searchSessions('match', signal)).resolves.toEqual({
|
||||
items: [{ sessionId: 'session', snippet: 'match' }],
|
||||
hasMore: false,
|
||||
})
|
||||
expect(b.search).toHaveBeenCalledWith('match', signal)
|
||||
await browser.renameWorkspace('ws' as never, 'renamed')
|
||||
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
|
||||
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
|
||||
@@ -78,6 +98,19 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
})
|
||||
|
||||
it('rejects the browser search callback on a runtime business error', async () => {
|
||||
const b = await bench()
|
||||
b.search.mockImplementationOnce(async () => ({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'index unavailable', details: {} },
|
||||
}) as never)
|
||||
declare(b.slots, 'sidebar.workspaces')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
await expect(browser.searchSessions('needle', new AbortController().signal))
|
||||
.rejects.toThrow('index unavailable')
|
||||
})
|
||||
|
||||
it('unregisters every entry on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace')
|
||||
|
||||
@@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -38,6 +38,25 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
|
||||
}
|
||||
|
||||
describe('workspace browser rows', () => {
|
||||
it('renders a selected content-search row and opens only its session', () => {
|
||||
const onOpen = vi.fn()
|
||||
const result: SearchResultNode = {
|
||||
id: sid('result'),
|
||||
title: 'Result title',
|
||||
workspace: 'Workspace context',
|
||||
running: true,
|
||||
snippet: 'matching message excerpt',
|
||||
}
|
||||
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('Workspace context')).toBeTruthy()
|
||||
expect(screen.getByText('matching message excerpt')).toBeTruthy()
|
||||
expect(row.hasAttribute('draggable')).toBe(false)
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledWith(result.id)
|
||||
})
|
||||
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import {
|
||||
deriveFlat, deriveGroups, deriveSearchResults, formatRelativeTime, projectLabel,
|
||||
UNGROUPED_KEY, UNGROUPED_LABEL,
|
||||
} from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
@@ -16,12 +19,12 @@ const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id,
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const view = (expandedProjects: readonly string[] = [], query = '') => ({
|
||||
expandedProjects, expandedSessions: [] as string[], query,
|
||||
const view = (expandedProjects: readonly string[] = []) => ({
|
||||
expandedProjects, expandedSessions: [] as string[],
|
||||
})
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
@@ -59,21 +62,6 @@ describe('deriveGroups', () => {
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('searches the current blank session by its New Session title', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['opaque-current', 'new session stale'])], view([], 'new session'),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id])
|
||||
expect(groups[0]!.sessions[0]!.title).toBe('New Session')
|
||||
expect(groups[0]!.sessionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
|
||||
const parent = summary('parent', 1)
|
||||
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
|
||||
@@ -87,7 +75,7 @@ describe('deriveGroups', () => {
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id] },
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
@@ -113,31 +101,6 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
|
||||
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
|
||||
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
|
||||
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
|
||||
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
|
||||
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
|
||||
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
|
||||
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
|
||||
|
||||
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
|
||||
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
])
|
||||
|
||||
const labelOnly = deriveGroups(
|
||||
list(summary('hidden', 1)),
|
||||
[workspace('label-hit', ['hidden']), workspace('other', [])],
|
||||
view([], 'label'),
|
||||
)
|
||||
expect(labelOnly).toEqual([
|
||||
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
@@ -155,21 +118,15 @@ describe('deriveFlat', () => {
|
||||
const child = { ...summary('child', 30), parentId: parent.id }
|
||||
const tieB = summary('tie-b', 20)
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA))
|
||||
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
|
||||
// Rows are branch-free: no children, no expansion.
|
||||
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
|
||||
})
|
||||
|
||||
it('search filters by case-insensitive display-title substring', () => {
|
||||
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
|
||||
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
|
||||
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
|
||||
})
|
||||
|
||||
it('tolerates ids whose summary has not landed yet', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
|
||||
expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('shows only the current blank session with its New Session title', () => {
|
||||
@@ -179,11 +136,112 @@ describe('deriveFlat', () => {
|
||||
...list(summary('real', 1), currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const rows = deriveFlat(sessions, { query: '' })
|
||||
const rows = deriveFlat(sessions)
|
||||
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
|
||||
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
|
||||
expect(deriveFlat(sessions, { query: 'new session' }).map(row => row.id)).toEqual([currentBlank.id])
|
||||
expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults', () => {
|
||||
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
|
||||
const titleHit = summary('title-hit', 30, '/projects/a')
|
||||
titleHit.displayTitle = 'Needle title'
|
||||
const workspaceHit = summary('workspace-hit', 20, '/projects/b')
|
||||
workspaceHit.displayTitle = 'Ordinary title'
|
||||
const contentHit = summary('content-hit', 10, '/projects/c')
|
||||
const sessions = list(titleHit, workspaceHit, contentHit)
|
||||
const result = deriveSearchResults(
|
||||
sessions,
|
||||
[
|
||||
workspace('a', ['title-hit'], 'Alpha'),
|
||||
workspace('b', ['workspace-hit'], 'Needle Workspace'),
|
||||
],
|
||||
' NEEDLE ',
|
||||
{
|
||||
items: [
|
||||
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
|
||||
{ sessionId: titleHit.id, snippet: 'title session body excerpt' },
|
||||
{ sessionId: sid('unknown'), snippet: 'not in session.list' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: titleHit.id,
|
||||
title: 'Needle title',
|
||||
workspace: 'Alpha',
|
||||
running: false,
|
||||
snippet: 'title session body excerpt',
|
||||
},
|
||||
{
|
||||
id: workspaceHit.id,
|
||||
title: 'Ordinary title',
|
||||
workspace: 'Needle Workspace',
|
||||
running: false,
|
||||
},
|
||||
{
|
||||
id: contentHit.id,
|
||||
title: 'content-hit',
|
||||
workspace: 'c',
|
||||
running: false,
|
||||
snippet: 'body needle excerpt',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the current blank row and uses its New Session display title', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const result = deriveSearchResults(
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale'])],
|
||||
'new session',
|
||||
{
|
||||
items: [
|
||||
{ sessionId: staleBlank.id, snippet: 'stale body' },
|
||||
{ sessionId: currentBlank.id, snippet: 'current body' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
)
|
||||
expect(result.items).toEqual([{
|
||||
id: currentBlank.id,
|
||||
title: 'New Session',
|
||||
workspace: 'first',
|
||||
running: false,
|
||||
snippet: 'current body',
|
||||
}])
|
||||
})
|
||||
|
||||
it('caps merged rows at 20 and preserves either local overflow or backend hasMore', () => {
|
||||
const rows = Array.from({ length: 22 }, (_, index) => {
|
||||
const item = summary(`s-${String(index).padStart(2, '0')}`, index)
|
||||
item.displayTitle = `Needle ${String(index)}`
|
||||
return item
|
||||
})
|
||||
const overflow = deriveSearchResults(list(...rows), [], 'needle', { items: [], hasMore: false })
|
||||
expect(overflow.items).toHaveLength(20)
|
||||
expect(overflow.hasMore).toBe(true)
|
||||
|
||||
const backendMore = deriveSearchResults(
|
||||
list(summary('body', 1)),
|
||||
[],
|
||||
'needle',
|
||||
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
|
||||
)
|
||||
expect(backendMore.items).toHaveLength(1)
|
||||
expect(backendMore.hasMore).toBe(true)
|
||||
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }))
|
||||
.toEqual({ items: [], hasMore: false })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
actions: store.actions,
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
@@ -198,42 +199,168 @@ describe('WorkspaceBrowser', () => {
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'new session' } })
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('searches across groups, clears via the clear button, and shows the empty states', () => {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
// Search forces matches visible without expansion state.
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(input.value).toBe('')
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称或关键词…')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
expect(screen.getByRole('tree', { name: '搜索结果' })).toBeTruthy()
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('没有匹配结果')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(input.value).toBe('')
|
||||
expect(screen.getByRole('tree', { name: 'Sessions' })).toBeTruthy()
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
// Flat search misses show No matches.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const open = vi.fn()
|
||||
const searchSessions = vi.fn(async () => ({
|
||||
items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('body-hit', 1, { displayTitle: 'Research notes' }),
|
||||
])),
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('research', ['body-hit'], 'Research Workspace'),
|
||||
])),
|
||||
open,
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称或关键词…')
|
||||
fireEvent.change(input, { target: { value: 'waterfall token' } })
|
||||
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
|
||||
expect(screen.queryByText('Research notes')).toBeNull()
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
|
||||
expect(searchSessions).toHaveBeenCalledWith('waterfall token', expect.any(AbortSignal))
|
||||
expect(screen.getByText('Research notes')).toBeTruthy()
|
||||
expect(screen.getByText('Research Workspace')).toBeTruthy()
|
||||
expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy()
|
||||
expect(screen.getByText('仅显示前 20 项,请缩小搜索范围。')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('treeitem'))
|
||||
expect(open).toHaveBeenCalledWith(sid('body-hit'))
|
||||
expect(input.value).toBe('waterfall token')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps local matches and shows a lightweight warning when Host search fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const searchSessions = vi.fn(async () => { throw new Error('index unavailable') })
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('local-hit', 1, { displayTitle: 'Needle title' }),
|
||||
])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
|
||||
searchSessions,
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), {
|
||||
target: { value: 'needle' },
|
||||
})
|
||||
expect(screen.getByText('Needle title')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('Needle title')).toBeTruthy()
|
||||
expect(screen.getByText('历史内容搜索暂时不可用,仍显示名称匹配。')).toBeTruthy()
|
||||
expect(screen.queryByText('没有匹配结果')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts a superseded request and ignores its stale result', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let resolveFirst!: (value: {
|
||||
items: { sessionId: SessionId; snippet: string }[]
|
||||
hasMore: boolean
|
||||
}) => void
|
||||
const first = new Promise<{
|
||||
items: { sessionId: SessionId; snippet: string }[]
|
||||
hasMore: boolean
|
||||
}>((resolve) => { resolveFirst = resolve })
|
||||
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
|
||||
? first
|
||||
: Promise.resolve({
|
||||
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('first-hit', 2, { displayTitle: 'Old result' }),
|
||||
summary('second-hit', 1, { displayTitle: 'Fresh result' }),
|
||||
])),
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText('搜索名称或关键词…')
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
|
||||
expect(firstSignal.aborted).toBe(false)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
expect(firstSignal.aborted).toBe(true)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('Fresh result')).toBeTruthy()
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst({
|
||||
items: [{ sessionId: sid('first-hit'), snippet: 'stale excerpt' }],
|
||||
hasMore: false,
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('Old result')).toBeNull()
|
||||
expect(screen.getByText('Fresh result')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes and resolves an empty search', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const b = mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('没有匹配结果')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rail state renders icon controls that request expansion', () => {
|
||||
@@ -243,16 +370,16 @@ describe('WorkspaceBrowser', () => {
|
||||
const b = mount({ wide: false, expandSidebar })
|
||||
// No wide chrome in rail state.
|
||||
expect(screen.queryByText('Workspaces')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(screen.queryByPlaceholderText('搜索名称或关键词…')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
// The wide flip mounts the input and focuses it after the slide.
|
||||
rerender(b, { wide: true })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
const input = screen.getByPlaceholderText('搜索名称或关键词…')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
// Wide search button is decorative (tabIndex -1, no expand call).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -463,8 +590,8 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'needle' } })
|
||||
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
expect(row.hasAttribute('draggable')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f
|
||||
README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: deb1073c2ff9e7a533e595ee3f5537e649660e5b
|
||||
README.zh.md: e3c521d5f6a09414d087e3fb142852e6d3eb0cb7
|
||||
|
||||
@@ -14,6 +14,8 @@ The mux stream projects the latest log-backed title as a validated `session/titl
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway passes only those session ids and current-surface user, assistant, and steering messages to the optional `ctx.sessionQuery` service, returns at most 20 session/snippet pairs plus a refine-query bit, and forwards the carrier request signal for cancellation. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
@@ -14,6 +14,8 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关仅将这些会话 id 以及当前表层中的 user、assistant 和 steering(中途引导)消息传给可选的 `ctx.sessionQuery` 服务,返回至多 20 个会话/snippet 对和一个提示细化查询的标志位,并转发载体请求信号以支持取消。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
@@ -20,8 +21,8 @@ import {
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSearchItem,
|
||||
SessionSummary, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
@@ -37,9 +38,17 @@ import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Product contract: sidebar search returns one bounded page and no cursor. */
|
||||
const SESSION_SEARCH_LIMIT = 20
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/** Read live abort state across awaits without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
@@ -561,6 +570,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session.list baseline shared by listing and search visibility.
|
||||
* Attached sessions come from memory; servable cold sessions merge from
|
||||
* persistence, and the final order is newest-first.
|
||||
*/
|
||||
async function listVisibleSessionSummaries(): Promise<SessionSummary[]> {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return items
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -568,18 +597,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Legacy logs without a cwd (pre-project stance) are not served — every
|
||||
// session now records its project at create time.
|
||||
async list(request) {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
return ok(request, { items: await listVisibleSessionSummaries() })
|
||||
},
|
||||
|
||||
async search(request, signal) {
|
||||
const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, {
|
||||
code: 'cancelled',
|
||||
message: 'session search was aborted',
|
||||
details: {},
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
if (isAborted(signal)) return cancelled()
|
||||
const sessionQuery = ctx.get('sessionQuery')
|
||||
if (sessionQuery === undefined) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
try {
|
||||
const visible = await listVisibleSessionSummaries()
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (visible.length === 0) return ok(request, { items: [], hasMore: false })
|
||||
const visibleIds = new Set(visible.map(item => item.sessionId))
|
||||
const page = await sessionQuery.searchSessions({
|
||||
query: request.payload.query,
|
||||
sessionFilters: [{ kind: 'id', values: [...visibleIds] }],
|
||||
eventFilters: [
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: SESSION_SEARCH_LIMIT,
|
||||
}, { signal })
|
||||
if (isAborted(signal)) return cancelled()
|
||||
// The id filter is the authorization boundary. Re-check the provider
|
||||
// projection before emitting it so a backend regression cannot leak
|
||||
// a session that `session.list` withheld.
|
||||
const authorized = page.items.filter(hit => visibleIds.has(hit.header.id))
|
||||
return ok(request, {
|
||||
items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({
|
||||
sessionId: hit.header.id,
|
||||
snippet: hit.bestMatch.snippet,
|
||||
})),
|
||||
hasMore: page.nextCursor !== undefined || authorized.length > SESSION_SEARCH_LIMIT,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isAborted(signal)
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
|
||||
) return cancelled()
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `session search failed: ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return ok(request, { items })
|
||||
},
|
||||
|
||||
async create(request) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ApiProxy {
|
||||
}
|
||||
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type { HistoryEntry, SessionSearchItem, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { RpcResponse } from './rpc.ts'
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'session.list': SessionsApi['list']
|
||||
'session.search': SessionsApi['search']
|
||||
'session.create': SessionsApi['create']
|
||||
'session.history': SessionsApi['history']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
|
||||
@@ -9,7 +9,7 @@ import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSummary } from './sessions.ts'
|
||||
import type { HistoryEntry, SessionSearchItem, SessionSummary } from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
@@ -54,6 +54,29 @@ export const sessionListValueSchema = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
|
||||
/** Fixed wire bound for one interactive sidebar query. */
|
||||
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
|
||||
/** Product response bound validated independently by every client carrier. */
|
||||
const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
/** session.search request payload. */
|
||||
export const sessionSearchRequestSchema = z.object({
|
||||
query: z.string().trim().min(1).max(SESSION_SEARCH_QUERY_MAX_CHARS)
|
||||
.refine(query => !query.includes('\0'), { message: 'search query must not contain NUL' }),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.search'>>>
|
||||
|
||||
/** One session.search result. */
|
||||
export const sessionSearchItemSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
snippet: z.string(),
|
||||
}) satisfies z.ZodType<Wire<SessionSearchItem>>
|
||||
|
||||
/** session.search response value. */
|
||||
export const sessionSearchValueSchema = z.object({
|
||||
items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT),
|
||||
hasMore: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.search'>>>
|
||||
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
|
||||
@@ -53,11 +53,28 @@ export interface SessionSummary {
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** One session-content search result; display metadata stays owned by `session.list`. */
|
||||
export interface SessionSearchItem {
|
||||
sessionId: SessionId
|
||||
/** Plain-text excerpt around the strongest matching visible message. */
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
|
||||
export interface SessionsApi {
|
||||
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
|
||||
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
|
||||
|
||||
/**
|
||||
* Searches the current user/assistant/steering message surface across
|
||||
* sessions visible to `list`. Results contain at most 20 sessions and carry
|
||||
* no continuation cursor; `hasMore` asks the client to refine the query.
|
||||
*/
|
||||
search(
|
||||
request: RpcRequest<{ query: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>>
|
||||
|
||||
/**
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionPromptValueSchema,
|
||||
sessionSearchValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
@@ -48,6 +49,7 @@ import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
export interface IApiClient {
|
||||
sessions: {
|
||||
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
|
||||
search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.search'>>>
|
||||
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
|
||||
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
@@ -83,6 +85,7 @@ export interface IApiClient {
|
||||
*/
|
||||
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
|
||||
'session.list': sessionListValueSchema,
|
||||
'session.search': sessionSearchValueSchema,
|
||||
'session.create': sessionCreateValueSchema,
|
||||
'session.history': sessionHistoryValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
@@ -271,6 +274,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
search: (payload, signal) => this.callUnary('session.search', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('session.create', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('session.history', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionPromptRequestSchema,
|
||||
sessionSearchRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
@@ -38,7 +39,8 @@ import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
||||
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
||||
* Every invoke receives the carrier Request's signal; methods whose contract
|
||||
* declares a signal parameter (command.execute) forward it, the rest ignore it.
|
||||
* declares a signal parameter (session.search and command.execute) forward it,
|
||||
* the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
@@ -49,6 +51,7 @@ type UnaryRoutes = {
|
||||
|
||||
const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
|
||||
'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) },
|
||||
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
|
||||
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
|
||||
237
packages/host/apiproxy/tests/api-proxy-search.spec.ts
Normal file
237
packages/host/apiproxy/tests/api-proxy-search.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Host session.search projection: list-equivalent visibility, fixed message
|
||||
* filters and result bound, cancellation mapping, and unavailable/failure
|
||||
* behavior.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
SessionQueryError,
|
||||
type SessionSearchHit,
|
||||
type SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
function request(query: string): RpcRequest<{ query: string }> {
|
||||
return { rpcId: RpcId(`search-${query}`), payload: { query } }
|
||||
}
|
||||
|
||||
function header(id: string, cwd: string | null = '/project'): SessionHeader {
|
||||
return {
|
||||
version: 0,
|
||||
id: sid(id),
|
||||
createdAt: 100,
|
||||
...(cwd === null ? {} : { cwd }),
|
||||
}
|
||||
}
|
||||
|
||||
function hit(id: string, index = 0): SessionSearchHit {
|
||||
const session = header(id)
|
||||
return {
|
||||
header: session,
|
||||
live: true,
|
||||
persisted: false,
|
||||
bestMatch: {
|
||||
sessionId: session.id,
|
||||
seq: index,
|
||||
type: 'user/message',
|
||||
time: 200 + index,
|
||||
surface: 'current',
|
||||
snippet: `match ${index}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function baseContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('session.search', () => {
|
||||
it('searches only list-visible ids and current conversation-message events', async () => {
|
||||
const ctx = await baseContext()
|
||||
const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
|
||||
live.append('user/message', {
|
||||
content: [{ type: 'text', text: 'live text' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const cold = header('cold', '/cold')
|
||||
const legacy = header('legacy', null)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([cold, legacy]),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
|
||||
const searchSessions = vi.fn((
|
||||
_request: SessionSearchRequest,
|
||||
_exec?: { signal?: AbortSignal },
|
||||
) => Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
header: legacy,
|
||||
live: false,
|
||||
persisted: true,
|
||||
bestMatch: {
|
||||
sessionId: legacy.id,
|
||||
seq: 3,
|
||||
type: 'user/message' as const,
|
||||
time: 190,
|
||||
surface: 'current' as const,
|
||||
snippet: 'must remain hidden',
|
||||
},
|
||||
},
|
||||
{
|
||||
header: cold,
|
||||
live: false,
|
||||
persisted: true,
|
||||
bestMatch: {
|
||||
sessionId: cold.id,
|
||||
seq: 4,
|
||||
type: 'assistant/message' as const,
|
||||
time: 200,
|
||||
surface: 'current' as const,
|
||||
snippet: 'the matching answer',
|
||||
},
|
||||
},
|
||||
],
|
||||
nextCursor: 'more' as never,
|
||||
}))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
const response = await api.sessions.search(request('matching answer'), signal)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'cold', snippet: 'the matching answer' }],
|
||||
hasMore: true,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
const [query, exec] = searchSessions.mock.calls[0] as unknown as [
|
||||
SessionSearchRequest,
|
||||
{ signal: AbortSignal },
|
||||
]
|
||||
expect(query).toEqual({
|
||||
query: 'matching answer',
|
||||
sessionFilters: [{ kind: 'id', values: ['live', 'cold'] }],
|
||||
eventFilters: [
|
||||
{
|
||||
kind: 'type',
|
||||
values: ['user/message', 'assistant/message', 'steering/message'],
|
||||
},
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: 20,
|
||||
})
|
||||
expect(exec.signal).toBe(signal)
|
||||
})
|
||||
|
||||
it('returns an empty page without invoking the index when no session is visible', async () => {
|
||||
const ctx = await baseContext()
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
|
||||
const response = await api.sessions.search(
|
||||
request('anything'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enforces the 20-item Host boundary even if a provider overproduces', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({ items }),
|
||||
} as never)
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('match'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: true },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items).toHaveLength(20)
|
||||
expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19')
|
||||
})
|
||||
|
||||
it('maps missing composition, query cancellation, and provider failure', async () => {
|
||||
const missingCtx = await baseContext()
|
||||
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const missingApi = createApiProxy(missingCtx, defaults)
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
const cancelledBeforeLookup = await missingApi.sessions.search(
|
||||
request('cancel-before-lookup'),
|
||||
preAborted.signal,
|
||||
)
|
||||
expect(cancelledBeforeLookup.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
|
||||
const missing = await missingApi.sessions.search(
|
||||
request('needle'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(missing.result.ok).toBe(false)
|
||||
if (missing.result.ok) throw new Error('unreachable')
|
||||
expect(missing.result.error.code).toBe('internal')
|
||||
expect(missing.result.error.message).toContain('does not mount')
|
||||
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
|
||||
const searchSessions = vi.fn()
|
||||
.mockRejectedValueOnce(aborted)
|
||||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
|
||||
const cancelled = await api.sessions.search(
|
||||
request('first'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(cancelled.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
|
||||
const failed = await api.sessions.search(
|
||||
request('second'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(failed.result.ok).toBe(false)
|
||||
if (failed.result.ok) throw new Error('unreachable')
|
||||
expect(failed.result.error.code).toBe('internal')
|
||||
expect(failed.result.error.message).toContain('database unavailable')
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ function scriptedApi(overrides: {
|
||||
return {
|
||||
sessions: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
search: r => ok(r, { items: [], hasMore: false }),
|
||||
create: r => ok(r, { sessionId: sid('s-new') }),
|
||||
history: r => ok(r, { events: [], hasMore: false }),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
@@ -76,6 +77,30 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('round-trips a trimmed session search query and its bounded result metadata', async () => {
|
||||
let seen: RpcRequest<{ query: string }> | undefined
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
search: (request) => {
|
||||
seen = request
|
||||
return ok(request, {
|
||||
items: [{ sessionId: sid('s1'), snippet: 'matching message text' }],
|
||||
hasMore: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await client(api).sessions.search({ query: ' message text ' })
|
||||
expect(seen?.payload).toEqual({ query: 'message text' })
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 's1', snippet: 'matching message text' }],
|
||||
hasMore: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('routes workspace rename and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
|
||||
@@ -21,6 +21,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
|
||||
},
|
||||
async search(request, signal) {
|
||||
if (request.payload.query === 'hang') {
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false },
|
||||
},
|
||||
}
|
||||
},
|
||||
async create(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
|
||||
},
|
||||
@@ -124,6 +144,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
|
||||
it('covers create/prompt/cancel/describe passthrough', async () => {
|
||||
const c = client()
|
||||
expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false },
|
||||
})
|
||||
expect((await c.sessions.create({})).result.ok).toBe(true)
|
||||
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
|
||||
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
@@ -155,6 +179,29 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(parsed.rpcId).toBe('r-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into session.search', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'r-search-sig',
|
||||
method: 'session.search',
|
||||
payload: { query: 'hang' },
|
||||
})
|
||||
const pending = handler.fetch(new Request(
|
||||
'http://x/api/session.search',
|
||||
{ method: 'POST', body, signal: controller.signal },
|
||||
))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as {
|
||||
rpcId: string
|
||||
result: { error?: { code: string } }
|
||||
}
|
||||
expect(parsed.rpcId).toBe('r-search-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handler carrier-layer statuses', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
|
||||
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
|
||||
sessionPromptValueSchema, sessionSummarySchema,
|
||||
sessionPromptValueSchema, sessionSearchRequestSchema, sessionSearchValueSchema, sessionSummarySchema,
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
|
||||
import {
|
||||
@@ -121,6 +121,28 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionListRequestSchema.parse({})).toEqual({})
|
||||
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
|
||||
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
|
||||
expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' })
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow()
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/)
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow()
|
||||
expect(sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: 's1', snippet: 'matching text' }],
|
||||
hasMore: true,
|
||||
})).toEqual({
|
||||
items: [{ sessionId: 's1', snippet: 'matching text' }],
|
||||
hasMore: true,
|
||||
})
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: '', snippet: 'matching text' }],
|
||||
hasMore: false,
|
||||
})).toThrow()
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: Array.from(
|
||||
{ length: 21 },
|
||||
(_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }),
|
||||
),
|
||||
hasMore: true,
|
||||
})).toThrow()
|
||||
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
|
||||
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
|
||||
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user