feat(web): add basic past-session search (round 1)

This commit is contained in:
Hypatia May
2026-07-27 12:15:06 +08:00
parent 79eb3a9035
commit 891e9035e7
56 changed files with 1646 additions and 269 deletions
@@ -0,0 +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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md
2026-07-27-web-session-search.md: 3cc44ba3652415e9fa32ce67bceefc822c41059b
2026-07-27-web-session-search.zh.md: 2b6ea7a60e1b051757852ff331c0b93c48bd2b57
@@ -0,0 +1,42 @@
# Agent Note: Web past-session search
Status: implemented
English | [中文](2026-07-27-web-session-search.zh.md)
## Problem
The Web sidebar exposes session titles and Workspace membership but cannot retrieve a past conversation from words that appear only inside its messages. Scanning histories in the browser would require attaching or loading every session, duplicate the existing indexed-search service, and make cold persisted sessions both slow and easy to omit. The product also needs a predictable failure path: an unavailable derived index must not erase title matches that the client can compute locally.
## Decision
The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at `.sessions/session-query.db`. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence.
The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store.
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. 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.
Content matching inherits the SQLite backend's normalized literal token/phrase semantics. 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
Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and the query receives only ids from that baseline. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits.
While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint.
## Alternatives considered
- **Scan every session history in the browser** — rejected because it attaches transport and fold cost to the UI, misses cold logs unless they are loaded, and duplicates the semantic extraction and source reconciliation already owned by `ctx.sessionQuery`.
- **Make trigram or fuzzy search part of the first release** — rejected because it changes index size, ranking, short-query behavior, and product expectations. Trigrams also do not by themselves solve two-character queries. The first release uses the existing backend contract and leaves recall expansion as a separate measured decision.
- **Return event addresses and jump to the exact match** — rejected for this release because conversation virtualization and stable event navigation need a separate UI contract. Session-level navigation is useful without coupling search to that work.
- **Expose cursor pagination in the sidebar** — rejected in favor of a fixed top-20 surface and a narrow-query hint; this keeps the interaction and cancellation state bounded.
## Consequences
Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search.
The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation.
## Testing
Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains.
@@ -0,0 +1,42 @@
# Agent Note: Web 历史会话搜索
Status: implemented
[English](2026-07-27-web-session-search.md) | 中文
## 问题
Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只出现在消息中的词语检索历史对话。在浏览器中扫描历史记录,需要附加或加载每个会话,重复实现现有的索引搜索服务,也会让冷态持久化会话的检索既缓慢又容易遗漏。产品还需要一条可预测的故障路径:派生索引不可用时,不得抹去客户端能够在本地计算出的标题匹配结果。
## 决策
Web 与 headless 共用的组合会在 `.sessions/session-query.db` 挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message``assistant/message``steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。
## 故障与可见性契约
搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;查询只会接收这条基线提供的 id。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。
首个或后续内容请求仍在处理期间,UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。
## 曾考虑的替代方案
- **在浏览器中扫描每个会话的历史记录**:不予采纳,因为这会让 UI 承担传输与折叠开销;除非加载冷态日志,否则还会漏掉这些日志;并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。
- **首版即加入 trigram 或模糊搜索**:不予采纳,因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端契约,将召回扩展留作另一项基于度量结果的决策。
- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 契约。会话级导航本身已有价值,无需让搜索与这项工作耦合。
- **在侧边栏公开游标分页**:不予采纳,改为固定显示前 20 条结果并提示缩小查询范围;这样可使交互与取消状态保持有界。
## 后果
无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。
首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。
## 测试
宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、取消与故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。
+3 -3
View File
@@ -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: 42d2a9641cf5d497c9aae45d9f60fce4498addb9
README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: bb3f4ee98700e4644535d1d3c05d29a9a558275d
README.zh.md: 44edea0f5b36598cfda1b61914da0b6622b973d6
+1 -1
View File
@@ -14,7 +14,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable SQLite content index at `.sessions/session-query.db`. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
+1 -1
View File
@@ -14,7 +14,7 @@ TUI 界面:
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query.db` 挂载一个可丢弃的 SQLite 内容索引。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
+7
View File
@@ -89,6 +89,13 @@
config:
root: './.sessions'
# Lazy content index for session.search. Opening the database at boot does
# not scan logs; the first search reconciles changed live/persisted sessions.
- id: session-query-sqlite
name: '@deepseek-ai/dsh-session-query-sqlite'
config:
path: './.sessions/session-query.db'
- id: storage
name: '@deepseek-ai/dsh-storage'
+2
View File
@@ -55,6 +55,8 @@
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
+32 -30
View File
@@ -26,6 +26,7 @@ const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
@@ -90,39 +91,39 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
}, 400_000)
it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
// Expand the collapsed group row, then open the revealed session row.
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
const search = page.getByPlaceholder('搜索名称或关键词', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
await search.fill('zzzqx-no-such-session')
await page.getByText('没有匹配结果').waitFor({ timeout: 30_000 })
await expect.poll(
() => page.getByRole('tree', { name: '搜索结果' }).getByRole('treeitem').count(),
{ timeout: 10_000 },
).toBe(0)
await search.fill('WATERFALL')
const resultTree = page.getByRole('tree', { name: '搜索结果' })
const result = resultTree.getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
timeout: 10_000,
}).toBeGreaterThanOrEqual(1)
const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
await result.click()
// Search navigation addresses the session, not a specific event, and the
// query remains until the user explicitly clears it.
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
}, 90_000)
it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// Runs after the session is open: a cold summary carries no title (the
// sidebar shows the cwd basename), and the durable title lands with the
// attach subscription's baseline — which is itself worth pinning: search
// matches the title the user sees, not a hidden cold field.
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Negative: a garbage query empties the tree (group rows hide too).
await search.fill('zzzqx-no-such-session')
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
// Positive: a title word narrows to the matched session + its group,
// force-expanded by search mode (case-insensitive client-side filter).
await search.fill('navscenario')
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
// Clear restores the unfiltered tree.
await page.getByRole('button', { name: 'Clear search' }).click()
await page.getByRole('button', { name: '清除搜索' }).click()
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
}, 60_000)
}, 90_000)
it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
@@ -184,7 +185,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'waterfall.expected.md', 'details-open.expected.md',
])
})
})
+1
View File
@@ -163,6 +163,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// stops matching a row fails the boot sweep loudly instead of drifting).
const patches: PatchOptions[] = [
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
{ id: 'session-query-sqlite', config: { path: join(persistenceRoot, 'session-query.db') } },
// storage-json's './.storages' yml default is cwd-relative and resolves
// per write; the scaffold restores the original cwd after boot, so the
// row gets an absolute temp root (removed with the workspace at close).
@@ -0,0 +1,2 @@
- tree "搜索结果":
- 'treeitem "{{workspace}} {{workspace}} The user wants me to reply with a specific format. Let me do that. ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"'
+1 -1
View File
@@ -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,
+8 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2
View File
@@ -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.
+2
View File
@@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-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`)。
+1 -1
View File
@@ -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.
+8 -1
View File
@@ -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
+5 -2
View File
@@ -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.
+5 -2
View File
@@ -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()
+112 -72
View File
@@ -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()
+115 -57
View File
@@ -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)
})
})
+3 -3
View File
@@ -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
+2
View File
@@ -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)
+2
View File
@@ -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` + 根路径)
+1
View File
@@ -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:^",
+84 -12
View File
@@ -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) {
+1 -1
View File
@@ -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),
+4 -1
View File
@@ -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 Wireexact 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) },
@@ -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')
+3
View File
@@ -32,6 +32,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
},
+9
View File
@@ -221,6 +221,12 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../packages/session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../../packages/session-query/session-query
'@deepseek-ai/dsh-session-query-sqlite':
specifier: workspace:^
version: link:../../packages/session-query/session-query-sqlite
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../packages/session-title/session-title
@@ -2559,6 +2565,9 @@ importers:
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../../session-persistence/session-persistence
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../../session-query/session-query
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session-title/session-title