feat(web): wire startup Workspace selection and sync docs

- Mount WorkspacesService.startInitialSelection in the runtime apply (the
  one-shot baseline follower shipped in 98633b5aa without a caller): a
  restored current session wins, an explicit clear stays cleared, a failed
  connect retries on the next baseline projection.
- Cover the policy in client-apply and the assembled workspace-flow
  snapshot; startup now lands in the recent Workspace's blank session, so
  the draft-carry scenario starts from the hero directly.
- Bring docs along: startup-selection paragraphs in the session-scope RFC
  note (both languages), bilingual README pairs for the four new client
  packages, doc-graph regeneration with client-declared events exempt from
  the dispatcher requirement (client dispatch sites are structurally
  invisible to the host-side ts.Program), and pairing re-records.
This commit is contained in:
imccyu
2026-07-27 06:24:09 +08:00
parent 22e4c05e69
commit cbe8735d7c
28 changed files with 372 additions and 96 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 7776a5c2cf1d0990c9c339c6e5fc66401f935810
README.zh.md: 8a0b7394c07878b8de958eae43d11203c92b5827
README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98
README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5

View File

@@ -115,6 +115,10 @@ export function apply(ctx: Context): void {
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(
() => workspaces.startInitialSelection(),
'runtime: initial Workspace selection',
)
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => {

View File

@@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
interface Bench {
ctx: Context
@@ -33,6 +35,10 @@ async function mount(): Promise<Bench> {
return bench
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 12; i++) await Promise.resolve()
}
describe('runtime client apply', () => {
it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => {
const bench = await mount()
@@ -71,6 +77,31 @@ describe('runtime client apply', () => {
bench.sinks?.onConnected?.()
})
it('selects the recent Workspace once when the first baselines have no current session', async () => {
const bench = await mount()
bench.api.onWorkspaceList = () => Promise.resolve(ok({
items: [{
workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}] as never[],
}))
bench.api.onList = () => Promise.resolve(ok({ items: [] }))
bench.sinks?.onConnected?.()
await flushMicrotasks()
const sessions = bench.ctx.get('sessions') as SessionsService
const workspaces = bench.ctx.get('workspaces') as WorkspacesService
expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
expect(sessions.list.getSnapshot().current).toBe('fk-new')
sessions.clear()
await workspaces.refresh()
await flushMicrotasks()
expect(sessions.list.getSnapshot().current).toBeUndefined()
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))

View File

@@ -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
README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b
README.zh.md: 1291556409b993aa893e102386f75c45bb195adf

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-ui-command
English | [中文](README.zh.md)
Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-client-ui-command
[English](README.md) | 中文
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key每个会话恒为 agent-backed因此 `command.list({sessionId})` 是唯一的寻址形状source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`PopupSelectController``src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边。壳是打开期间持有焦点的瞬态层onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
`/client` 导出表层是插件主体(`apply``inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。
## 模型体验
间接影响,途径是本包的派发与 `claim.submit` 路径触发的 host `command.execute` RPC匹配命中的命令其 handler 会修改 host 领域状态,其他包再把该状态投影进下一个请求(`/plan` 的 handler 翻转 plan 模式,其归属包注入 `plan:policy` 系统提示词 section而命令行本身、detached result 与所有菜单notice 渲染都留在客户端,永不进入会话日志。
#### KV Cache 影响
无直接影响;该包既不组装也不发送提供方请求。它触发的命令 handler 可能改变归属 host 包对下一个请求系统提示词的贡献(某个 section 的出现或消失会替换较早的请求 token并使提供方前缀从该点起失效但这一影响由各命令的 host 包拥有并记录。
## 已知限制与暂缓事项
- **popupSelect 壳还没有已上架的业务消费者**模型选择host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。
- **脱离会话后detached result 的 notice 回退到 console**fire-and-forget 路径经 `SessionInput.notify` 把结果送到触发会话的编辑器会话拆除后console 输出行是仅剩的呈现面。

View File

@@ -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
README.md: 4838be893c1d5422cc707cb0d7542a056be41fa7
README.zh.md: 368171a43ef3a449049542cd227459f82ec43086

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-ui-skill
English | [中文](README.zh.md)
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText``/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-client-ui-skill
[English](README.md) | 中文
skill技能引用 source 的浏览器半侧:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话恒为 agent-backedhost 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flightscope 出生的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
`skill.list` 失败时 `candidates` 抛出异常slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pendingready 状态。
`/client` 导出表层只有插件主体(`apply``inject`source 对象是注册 effect 的内部实现。
## 模型体验
### 用户提示词中的 skill 引用文本
#### 模型所见
被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21纯文本`<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且不确定:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。
#### Token 影响
有条件且极小:只有 pick或手动键入相同文本会把引用的字符加进那一条用户消息。浏览菜单和候选拉取增加零模型 token。
#### KV Cache 影响
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
## 已知限制与暂缓事项
- **skill 加载不确定**引用是协作线索不是保证模型可能忽略它。命中率被证明不足时的返工路径host 侧 `context/skill-reference` 引导包或全文注入记录在设计台账中wire 上的文本形状不会改变。
- **首次击键可能与预热竞速**scope 出生的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
- **文本即真身**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。

View File

@@ -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
README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a
README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-ui-slash
English | [中文](README.zh.md)
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-client-ui-slash
[English](README.md) | 中文
输入触发管线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed因此投影只含会话身份roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace``matchEnter` 钩子,第一个非 undefined 的应答胜出。
分层:`src/core/`T2是纯内核——`detectTrigger``menuReduce``seedGroups``MENU_CLOSED``exactMatch`,零 ReactDOMcordis`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类会话 scope菜单关闭期间渲染 null。该 slot 由 ui-conversation 的编辑器配置项拥有锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`因为依赖方向ui-conversation → ui-slash不允许反向的类型导入。combobox 模式:焦点始终留在 textarea行在 mousedown 时完成 pick高亮由 `aria-activedescendant` 承载。
`/client` 导出表层是插件主体(`apply``inject`)、`SlashService``MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
## 模型体验
无。触发管线只是浏览器呈现——pick 产出 `CommandClaim``ReferenceInsert` 数据其模型可见后果host 命令执行;插入的引用文本随普通提示词发送)由消费方的 host 包与输入状态机包拥有。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。
- **`SlashCandidate.icon` 以文本渲染**MenuView 把该字符串原样放进图标位接到设计系统图标枚举iconFile 五变体家族)的接线等该枚举交付后落地。
- **overlay 的 SlotMap 合并归属与 slot 所有权分离**`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义锚点、children 声明、生命周期)留在 ui-conversation依赖方向ui-conversation → ui-slash迫使这一拆分未来依赖关系调整时应重新审视。
- **菜单组顺序即注册顺序**source 之间没有显式排序 seamroster 还是 commandskillsubagent 时可以接受,业务 source 加入后需重新审视。

View File

@@ -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
README.md: 7a70add139eae7bc507469b4fe7170359efdec31
README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-ui-subagent
English | [中文](README.zh.md)
Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink.
A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work.

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-client-ui-subagent
[English](README.md) | 中文
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running``displayTitle` 包含 querypick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace``matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。
没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering中途引导、恢复已 dispose 的子会话)是未来的业务工作。
`/client` 导出表层只有插件主体(`apply``inject`source 对象是注册 effect 的内部实现。
## 模型体验
### 用户提示词中的 subagent label 文本
#### 模型所见
被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。
#### Token 影响
有条件且极小:只有 pick或手动键入相同文本会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token候选永不离开浏览器
#### KV Cache 影响
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
## 已知限制与暂缓事项
- **`@` 消费语义尚未构建**:引用只是惰性文本;把它接到对指名子会话的 steering发消息以及是否允许恢复已 dispose 的子会话),等待台账中它自己的设计决策。
- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现roster 只含 scope 所指会话的直接子会话(不含孙辈,不含跨会话 agent
- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用还是惰性文本时可以接受;消费功能必须绑定到会话 id。