Pārlūkot izejas kodu

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.
Hypatia May 2 mēneši atpakaļ
vecāks
revīzija
43e81390a3

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml

@@ -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

+ 2 - 0
.agents/notes/implemented/feature/2026-07-27-web-session-search.md

@@ -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

+ 2 - 0
.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md

@@ -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 匹配仍采用普通的客户端子串匹配。
 
 ## 故障与可见性契约

+ 0 - 5
packages/client/connection/src/client/index.ts

@@ -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 - 2
packages/client/runtime/README.i18n.yaml

@@ -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

+ 1 - 1
packages/client/runtime/README.md

@@ -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
 

+ 1 - 1
packages/client/runtime/README.zh.md

@@ -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 镜像
 

+ 1 - 5
packages/client/runtime/src/client/index.ts

@@ -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(),

+ 10 - 5
packages/client/runtime/src/client/sessions/service.ts

@@ -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' } })

+ 3 - 2
packages/client/runtime/tests/client-apply.spec.ts

@@ -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()
 

+ 1 - 1
packages/client/runtime/tests/sessions-service.spec.ts

@@ -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 }
 }
 

+ 0 - 1
packages/client/runtime/tests/wire-events.spec.ts

@@ -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: () => {} }

+ 6 - 6
packages/client/runtime/tests/workspaces-service.spec.ts

@@ -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()

+ 1 - 1
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -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()