feat(web): add basic past-session search (round 1)
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user