mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(web): source the search result bound from its protocol constant
`ConnectionHandle.sessionSearchResultLimit` mirrored `SESSION_SEARCH_RESULT_LIMIT` as per-connection state, giving one fact two homes in the same module and implying a transport-varying or server-negotiated bound that the response schema's fixed `max` forbids. `SessionsService` now reads the constant directly and its constructor drops the parameter; the connection handle and its unreachable `/client` value re-export go away. The import comes from the inline-safe wire layer rather than the connection plugin's `/client` surface, which the bundle-purity gate rejects for value imports.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md
|
||||
2026-07-27-web-session-search.md: 3dd042056d97dd5d70bdb8c196b8356bae703a22
|
||||
2026-07-27-web-session-search.zh.md: 1600044ce15bd773f7bd21872ebdb13a9fd71d64
|
||||
2026-07-27-web-session-search.md: 7d7bcb84877b60d937de12afdb55925042992efb
|
||||
2026-07-27-web-session-search.zh.md: 54ab7e86ebd76061c94856462d22447c038edaf4
|
||||
|
||||
@@ -16,6 +16,8 @@ The host gateway exposes `session.search` through the existing typed RPC stack.
|
||||
|
||||
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event.
|
||||
|
||||
The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives beside the response schema that enforces it in `dsh-host-apiproxy`, and `SessionsService.searchResultLimit` re-exposes that constant for presentation plugins. The connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound that the schema's fixed `max` forbids, and would leave the same fact with two homes in the same module.
|
||||
|
||||
Content matching inherits the SQLite backend's normalized literal token/phrase semantics. The shared semantic projection excludes reasoning blocks, so UI search never returns a model's private reasoning as a hit or snippet; the derived-index schema version advances so existing persistent indexes rebuild without the former documents. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching.
|
||||
|
||||
## Failure and visibility contract
|
||||
|
||||
@@ -16,6 +16,8 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据
|
||||
|
||||
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
|
||||
|
||||
结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionsService.searchResultLimit` 则把该常量重新公开给呈现插件。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。
|
||||
|
||||
内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会随之前进,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。
|
||||
|
||||
## 故障与可见性契约
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
@@ -24,7 +23,6 @@ export type {
|
||||
} from './api.ts'
|
||||
export {
|
||||
RpcId,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
AbstractApiClient,
|
||||
transportError,
|
||||
} from './api.ts'
|
||||
@@ -45,8 +43,6 @@ export const inject: string[] = []
|
||||
export interface ConnectionHandle {
|
||||
/** Shared api client (fixture or real, decided at boot from the page URL). */
|
||||
readonly api: IApiClient
|
||||
/** Protocol-owned maximum rows for one session-search response. */
|
||||
readonly sessionSearchResultLimit: number
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -68,7 +64,6 @@ export function apply(ctx: Context): void {
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
sessionSearchResultLimit: SESSION_SEARCH_RESULT_LIMIT,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 0d6a4df04f626b378082f94ebe817da9c288c7fe
|
||||
README.zh.md: 72ae712f67022a3243a6b84d0b71ae937b8944a9
|
||||
README.md: fd015bf95364974eae0a5e620f8484872231adfc
|
||||
README.zh.md: 8f539710d5e7556a62a9d0fea555cc7dbd10a6ab
|
||||
|
||||
@@ -12,7 +12,7 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` exposes the protocol-owned page bound as injected presentation data, so client plugins do not duplicate it.
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将协议定义的分页上限作为注入的呈现数据公开,使客户端插件无需复制该值。
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
|
||||
@@ -113,11 +113,7 @@ export const inject = ['connection']
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(
|
||||
ctx,
|
||||
connection.api,
|
||||
connection.sessionSearchResultLimit,
|
||||
)
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
|
||||
@@ -19,6 +19,9 @@ import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -149,8 +152,13 @@ export interface SessionProvideDescriptor {
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** Fixed sidebar result bound supplied to presentation plugins as injected data. */
|
||||
readonly searchResultLimit: number
|
||||
/**
|
||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||
* injected data. Not per-connection state: the `session.search` response
|
||||
* schema caps `items` at this constant, so every transport (fixture included)
|
||||
* reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
@@ -184,14 +192,11 @@ export class SessionsService {
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
* @param searchResultLimit - protocol-owned search bound from the connection service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
searchResultLimit: number,
|
||||
) {
|
||||
this.searchResultLimit = searchResultLimit
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
@@ -25,7 +26,6 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
sessionSearchResultLimit: 7,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
@@ -51,7 +51,8 @@ describe('runtime client apply', () => {
|
||||
const workspaces = bench.ctx.get('workspaces')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(workspaces !== undefined).toBe(true)
|
||||
expect((sessions as SessionsService).searchResultLimit).toBe(7)
|
||||
// The bound the wire schema enforces, not a per-connection negotiation.
|
||||
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
|
||||
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Bench {
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const svc = new SessionsService(ctx, api, 20)
|
||||
const svc = new SessionsService(ctx, api)
|
||||
return { ctx, api, svc }
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
sessionSearchResultLimit: 20,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('WorkspacesService', () => {
|
||||
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
@@ -152,7 +152,7 @@ describe('WorkspacesService', () => {
|
||||
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha'), workspace('beta')] as never[],
|
||||
@@ -188,7 +188,7 @@ describe('WorkspacesService', () => {
|
||||
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
@@ -208,7 +208,7 @@ describe('WorkspacesService', () => {
|
||||
it('returns created Workspaces and preserves Host business errors', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceCreate = () => Promise.resolve(ok({
|
||||
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
|
||||
@@ -227,7 +227,7 @@ describe('WorkspacesService', () => {
|
||||
it('passes native directory selection and cancellation through without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
|
||||
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
|
||||
@@ -239,7 +239,7 @@ describe('WorkspacesService', () => {
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, 20)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
await workspaces.refresh()
|
||||
|
||||
@@ -93,7 +93,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
|
||||
}) as never)
|
||||
const sessions = new SessionsService(ctx, api, 20) // provides 'sessions' itself
|
||||
const sessions = new SessionsService(ctx, api) // provides 'sessions' itself
|
||||
await sessions.refresh()
|
||||
await Promise.resolve() // manager notifier flush
|
||||
await ctx.plugin(SlashService).await()
|
||||
|
||||
Reference in New Issue
Block a user