diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 67a9261d81..0a326d42a0 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index 3dd042056d..7d7bcb8487 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 1600044ce1..54ab7e86eb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.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 匹配仍采用普通的客户端子串匹配。 ## 故障与可见性契约 diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 43d33154e3..7ce8fe448f 100644 --- a/packages/client/connection/src/client/index.ts +++ b/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 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index b6660a7fda..8bd864f7f9 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/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 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 0d6a4df04f..fd015bf953 100644 --- a/packages/client/runtime/README.md +++ b/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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 72ae712f67..8f539710d5 100644 --- a/packages/client/runtime/README.zh.md +++ b/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 镜像 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 848acd74d9..2fb0645cdd 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/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(), diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 53aa23a26b..ced825c33b 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/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 /** 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' } }) diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index ab5c96d8df..d389efe319 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/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 { 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() diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 812b52a518..0d19b914b7 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/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 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 055dbc555b..01a6691a4b 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,7 +20,6 @@ async function mount(): Promise { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, - sessionSearchResultLimit: 20, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index bc4c4bb2d7..5327066651 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/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() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 41858c42da..414f3c15b4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/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()