Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

This commit is contained in:
Chinesezjc
2026-07-28 15:02:02 +08:00
60 changed files with 760 additions and 260 deletions

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
2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8
2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6
2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b

View File

@@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` |
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
| business | `I` | inject return type | plain data + callbacks (hooks banned) |
| business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use<Name>` selector hooks (`InjectFace<I>`) |
`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
@@ -80,11 +80,11 @@ Store scope is **derived from the mounting entry's scope** (session slot → one
### inject: the registrant's business face, on its own ctx
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks, plus at most the reserved `hooks` compartment: a map of bare observable sources (getSnapshot+subscribe) the renderer binds into `use<Name>` selector hooks before the face reaches the component — the registrant-private twin of the provide channel's hooks compartment, for reactive facts too niche for the global standard kit (composer notices/lexicon, the settings nav rows). Components never receive the raw sources, so business code still contains no subscription machinery. Everything else stays plain: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hand-made hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
### Data-boundary discipline
Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` plus the hooks bound from provide contributions and inject `hooks` compartments — every one synthesized by the renderer's single binding machinery; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
### Tree context and the renderer seam
@@ -111,7 +111,7 @@ Render authority is enforceable rather than conventional: who renders what is a
| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
| Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
| `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine |
| Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery |
| Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
| `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |

View File

@@ -45,7 +45,7 @@ ctx.slots.register({
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` |
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 Schain 键另有 `renderSlotChain` |
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook |
| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use<Name>` 选择器 hook 到达(`InjectFace<I>` |
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
@@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**session 坑→每个会
### inject注册方的业务面立足自己的 ctx
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable sourcegetSnapshot+subscribe渲染器在业务面抵达组件前把每个 source 绑成 `use<Name>` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生供太小众、不该进全局标准件的响应式事实composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
### 数据界线纪律
hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 五席,加上 provide 贡献与 inject `hooks` 格绑出的 hook——全部出自渲染器同一台绑定机械;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
### 树上语境与渲染器安装缝
@@ -111,7 +111,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务package.json 的依赖声明就此失去意义 |
| `children` 用键数组形 | kind/scope 是运行时分派数据SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 |
| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械inject `hooks` 格让同样的事实走那一台受审计的绑定机械 |
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |

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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5
2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151

View File

@@ -90,7 +90,7 @@ The sole provisioning path by which session slot components fetch their own sess
Slot scope is the closed set `root | session-maybe | session`:
- `root` receives only the global standard kit, with no session identity or provisioning.
- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip.

View File

@@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件
slot scope 是闭集 `root | session-maybe | session`
- `root` 只拿全局标准件,不接收 session 身份或供数。
- `session-maybe` 跟随 current session但组件实例不因 id 有无或切换而换 key无 session 时 `sessionId``useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
- `session-maybe` 跟随 current session但组件实例不因 id 有无或切换而换 key无 session 时 `sessionId``useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布current id 不变时的名册变化也会重发已挂载 bundle而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
- `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key切换 session 会重建该 entry 及其 session store。
`conversation``session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/viewcomposer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`session 出现后把输入体换成严格绑定的 InputBartextarea 允许重建Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内InputBar 不因 phase 翻转而重建。

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
2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b
2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c
2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b

View File

@@ -28,8 +28,8 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx
### Reference sources (seeing only projections plus their own apply closures, on the root ctx)
- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream).
- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream).
### Fixture command routing and assembly

View File

@@ -28,8 +28,8 @@ Status: implemented
### 引用源(只见投影 + 自家 apply 闭包的 root ctx
- **ui-skill**`skill.list({sessionId})` 按会话寻址host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome`/name ` 原文,决策 21`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子引用不进命令裁决。skill 引用以原文随普通 prompt 走命令平面之外tool-skill 不变session-prefix 目录提供协作关联)。
- **ui-subagent**:候选零 RPCsessions.list 快照按 parentId/running 过滤pick 产出 text outcome`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。
- **ui-skill**`skill.list({sessionId})` 按会话寻址host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome`/name ` 原文,决策 21`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined``subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子引用不进命令裁决。skill 引用以原文随普通 prompt 走命令平面之外tool-skill 不变session-prefix 目录提供协作关联)。
- **ui-subagent**:候选零 RPCsessions.list 快照按 parentId/running 过滤pick 产出 text outcome`@name ` 原文);`lexicon` 同快照派生`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。
### fixture 命令路由与装配

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
2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640

View File

@@ -81,10 +81,10 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived:
- PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface.
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once.
- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
### Per-session provide contributions and the private keyboard surface

View File

@@ -81,10 +81,10 @@ occurrence 表与 chip 三投影:
skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draftchip 视觉纯派生:
- PickOutcome 增 `{text}` arm新 scoped bail 事件 `slash/input-insert-text` `{text, span}`与另三个同契约draftRev CAS、返回 true ⟺ 实际改写facade.insertText 走 setDraft 拼接,机器零改动。
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch渲染路径保持同步无副作用controller 聚合为 `lexicon()` 公面
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch渲染路径保持同步无副作用配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store每次 source 通知重拉scope 出生后才注册的 source 由 service 广播给活 controller补 warm 并并入名录
- `decorations.scanTextRefs`:词边界扫描 draft行首/空白后的 `/name``@name``x/name` 永不命中)对照名录,命中即 `.textRef` markbackdrop 纯 range 高亮,同 hlToken编辑破坏匹配形状下次扫描自然消失。
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰legacy `<skill>` 标签 + 纯文本 token
- 旧 occurrence/paste/serialize 链全部保留在盘未删additive删除另成将来一刀已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮
- 旧 occurrence/paste/serialize 链全部保留在盘未删additive删除另成将来一刀装饰响应性InputBar 以 uSES 订阅 shell 的 lexicon sourcescope 出生预热后才 settle 的名录会直接点亮已有 draft token无需菜单交互或无关重渲染
### per-session 供数贡献与键盘私面

View File

@@ -658,7 +658,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
```
Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts)
Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts)
### `slash/input-consume-token` — bail
@@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
```
Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts)
Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts)
### `slash/input-insert-reference` — bail
@@ -690,7 +690,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
```
Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts)
Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts)
### `slash/input-insert-text` — bail
@@ -707,7 +707,7 @@ Replaces the trigger token span with literal text — the plain-text reference p
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
```
Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts)
Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts)
## `subagent/*`

View File

@@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

View File

@@ -11,10 +11,21 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
## Reactive read and contract-currency discipline
How live data reaches render code, and what may cross a business boundary:
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
## Export discipline (client plugin packages)

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 packages/client/runtime/README.md
README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5

View File

@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).

View File

@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 已知限制与暂缓事项
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage在 staged 状态下被移除的 Session其 scope 会冻结保留,直到 stage 转向其他 Session而非直到真实观察者数量降为零。解析`provideInfo()``binding()``scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage在 staged 状态下被移除的 Session其 scope 会冻结保留,直到 stage 转向其他 Session而非直到真实观察者数量降为零。解析`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。

View File

@@ -151,6 +151,13 @@ export class SessionsService {
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
private readonly manager: SessionManager
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source (the renderer
* host's `sessions.provide` feed), so a roster change under a stable
* current id republishes the bundle instead of stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* Persisted selection cell (the durable half of `list.current`). Private on
@@ -167,6 +174,10 @@ export class SessionsService {
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
private readonly currentProvideInfoListeners = new Set<() => void>()
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
@@ -198,7 +209,11 @@ export class SessionsService {
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
// The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
this.updateCurrentProvideInfo()
})
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
@@ -206,6 +221,14 @@ export class SessionsService {
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
this.currentProvideInfoSnapshot = this.maybeInfo
this.currentProvideInfo = {
getSnapshot: () => this.currentProvideInfoSnapshot,
subscribe: (fn) => {
this.currentProvideInfoListeners.add(fn)
return () => { this.currentProvideInfoListeners.delete(fn) }
},
}
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -238,6 +261,30 @@ export class SessionsService {
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
this.updateCurrentProvideInfo()
}
/**
* Re-derive the current selection's provide bundle and publish it when it
* changed. Bundles are identity-stable per (scope, roster)
* materialization, so an identity compare is exact; synchronous notify —
* both call sites (list.subscribe, provide()) already sit behind their own
* batching or registration edges.
*/
private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
for (const fn of [...this.currentProvideInfoListeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/** Build the static no-session kit and reject duplicate declared names. */
@@ -404,25 +451,21 @@ export class SessionsService {
}
/**
* Resolve the render-layer standard-props bundle (SessionProvider's feed
* through the renderer host; ctx never enters the render layer). Pure
* resolution — render-safe: SessionProvider calls this during render, so no
* staging, no window side effects (StrictMode double-invokes and concurrent
* discarded passes must stay free).
* @param id - session id.
* @returns the provide info, or undefined for a session neither listed nor already scoped.
* Resolve one session's render-layer standard-props bundle (ctx never
* enters the render layer; the renderer subscribes to
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
* no staging, no window side effects (StrictMode double-invokes and
* concurrent discarded passes must stay free).
*/
provideInfo(id: string): SessionProvideInfo | undefined {
private provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}

View File

@@ -246,13 +246,6 @@ export class SlotsService extends Service {
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
provideInfo: id => sessions.provideInfo(id),
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
}

View File

@@ -79,7 +79,8 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
b.svc.open(sid('s1'))
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
@@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
})
describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const info = b.svc.provideInfo('s1')
expect(info).toBeDefined()
expect(info?.sessionId).toBe('s1')
b.svc.open(sid('s1'))
const info = b.svc.currentProvideInfo.getSnapshot()
expect(info.sessionId).toBe('s1')
// The bundle carries bare observables; hook binding happens in React.
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(b.svc.provideInfo('s1')).toBe(info)
expect(b.svc.provideInfo('ghost')).toBeUndefined()
expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
// Re-staging the same id republishes nothing: identity holds.
b.svc.open(sid('s1'))
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
})
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const absent = b.svc.currentProvideInfo.getSnapshot()
expect(absent.sessionId).toBeUndefined()
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
b.svc.open(sid('s1'))
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s1Bundle.sessionId).toBe('s1')
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(notified).toHaveBeenCalledTimes(1)
b.svc.open(sid('s2'))
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s2Bundle.sessionId).toBe('s2')
expect(s2Bundle).not.toBe(s1Bundle)
expect(notified).toHaveBeenCalledTimes(2)
b.svc.clear()
await Promise.resolve() // clearSelection projects through the manager notifier
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
})
it('a provider roster change under a stable current id republishes the bundle', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const before = b.svc.currentProvideInfo.getSnapshot()
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
const dispose = b.svc.provide({
hooks: ['extra'],
props: ['marker'],
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
})
const added = b.svc.currentProvideInfo.getSnapshot()
expect(added).not.toBe(before)
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
expect(added.hooks['extra']).toBe(source)
expect(notified).toHaveBeenCalledTimes(1)
dispose()
const removed = b.svc.currentProvideInfo.getSnapshot()
expect(removed).not.toBe(added)
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
expect(notified).toHaveBeenCalledTimes(2)
})
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const notified = vi.fn()
const off = b.svc.currentProvideInfo.subscribe(notified)
off()
b.svc.open(sid('s1'))
expect(notified).not.toHaveBeenCalled()
})
it('binding() is pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
b.svc.binding(sid('s2'))
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
@@ -211,7 +270,6 @@ describe('cell (render-layer session kit)', () => {
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.provideInfo('s1')
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))

View File

@@ -97,18 +97,13 @@ function fakeWorkspaces() {
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host seam (list observable + provide bundle). */
/** Minimal sessions face for the host seam (list observable + current provide projection). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
return {
list: { getSnapshot: () => state, subscribe: () => () => undefined },
provideInfo: (id: string) => (id === 'known'
? {
sessionId: id,
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
props: {},
}
: undefined),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
}
}
@@ -232,13 +227,11 @@ describe('host face', () => {
expect(host.entriesOf('t.host')).toHaveLength(0)
})
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
it('exposes the session list and the atomic current provide projection', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.current.getSnapshot()).toBeUndefined()
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
})
it('exposes the independent Workspace list source', async () => {

View File

@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
const shell = inputHub.shell(sessionId)
return {
keyboard: inputHub.keyboard(sessionId),
keyboard: shell,
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
}, InputBar)

View File

@@ -1,11 +1,11 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -220,6 +220,13 @@ export interface ComposerBarInjected {
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop: () => void
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
}
/**
@@ -231,11 +238,11 @@ export interface InputControlOwnerProps {
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& ComposerBarInjected
& InjectFace<ComposerBarInjected>
/**
* Composer chain currency: what ConversationRoot dispatches at its

View File

@@ -77,8 +77,6 @@ export interface InputNotice {
* satisfies it structurally.
*/
export interface ComposerKeyboard {
/** Latest surfaced notice store (null after none). */
readonly notices: SnapshotStore<InputNotice | null>
/** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */

View File

@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
}
/**
* Hot plain-text reference lexicons for the decoration scan (decision 21).
* @returns the controller's per-trigger aggregation; empty Map without a pipeline.
* Hot plain-text reference lexicon source for the decoration scan
* (decision 21): delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
}
/**

View File

@@ -1,11 +1,12 @@
/** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject; layout-phase inputs (variant, placeholder,
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
]
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot,
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
@@ -244,7 +242,7 @@ export function InputBar({
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, keyboard.lexicon())
const deco = deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every

View File

@@ -87,12 +87,13 @@ async function bench() {
}
}
const providers: TestProvider[] = []
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
scopeOf,
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),

View File

@@ -32,12 +32,13 @@ async function bench() {
current: undefined,
phase: 'ready',
})
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: vi.fn(),
scope: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: vi.fn(() => () => {}),
create: vi.fn(),
open: vi.fn(),

View File

@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
// Provide-channel contributions land in this bundle the way the runtime
// materializes them; the renderer host serves it through provideInfo.
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
// Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
// materialized on first render after the provide contributions landed.
let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
const sessionsFake = {
list,
binding: (id: SessionId) => (id === SID
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
provideInfo: (id: string) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: undefined),
maybeProvideInfo: (id: string | undefined) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: { hooks: provided.hooks, props: provided.props }),
currentProvideInfo: {
getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
subscribe: () => () => {},
},
create: vi.fn(),
open: vi.fn(),
}

View File

@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
const SID = 's1' as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
afterEach(cleanup)
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
subscribe: (fn: () => void) => session.subscribe(fn),
},
})
const provideInfo = (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
}
ctx.provide('sessions', {
list,
binding: bindingOf,
scope: () => actxFake,
provideInfo: (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
},
maybeProvideInfo(id: string | undefined) {
// `this` inside an object-literal method is any under strict lint; the
// fake resolves through its own provideInfo above.
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
provideInfo,
currentProvideInfo: {
getSnapshot: () => provideInfo(SID),
subscribe: () => () => {},
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
scopeOf: () => SID,
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
binding: () => undefined,
scope: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
create: vi.fn(),
open: vi.fn(),

View File

@@ -56,7 +56,11 @@ function bench(over?: BenchOptions) {
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
? {
slash: (() => ({
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
})) as unknown as NonNullable<ShellDeps['slash']>,
}
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
@@ -87,6 +91,8 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
stop,
renderSlot,
variant: over?.variant ?? 'composer',

View File

@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',

View File

@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
@@ -234,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => {
})
})
describe('scenario: reference decoration lights up when the lexicon settles', () => {
it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
let roll: readonly string[] | undefined
let notify: (() => void) | undefined
const b = await scopedBench((slash) => {
slash.registerSource({
trigger: '/', name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
notify = listener
return () => { notify = undefined }
},
} as never)
})
// Typed before the catalog settled: a plain token, no decoration.
b.type('/deploy now')
expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
// The catalog settles (ui-skill's settle path fires the same notification).
act(() => {
roll = ['deploy']
notify?.()
})
const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('/deploy')
})
})
describe('scenario I: unknown /xyz + enter', () => {
it('adjudication misses in one hop and the whole line rides the default sink', async () => {
const b = await bench()

View File

@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
const sid = (s: string): SessionId => s as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
interface Bench {
slots: SlotsService
chat: ReturnType<typeof createChatStore>
@@ -23,7 +26,10 @@ function bench(): Bench {
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
})
ctx.provide('workspaces', {

View File

@@ -118,6 +118,8 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
stop={stop}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}

View File

@@ -10,7 +10,7 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps } from './contract/slots.ts'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
@@ -20,7 +20,7 @@ function navIcon(id: string) {
}
type PanelProps = {
rows: ReturnType<SettingsRootComponentProps['sections']>
rows: readonly SettingsSectionRow[]
renderSlot: SettingsRootComponentProps['renderSlot']
onClose: () => void
}
@@ -92,20 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
const { wide, useSections, renderSlot } = props
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
// The ledger tick keeps the nav rows fresh: registrants re-register with
// freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions.
// State = ledger version: same-version notifications dedupe to no render.
const [, setSectionsRev] = useState(() => sectionsVersion())
useEffect(
() => subscribeSections(() => { setSectionsRev(sectionsVersion()) }),
[subscribeSections, sectionsVersion],
)
const rows = sections()
const rows = useSections(s => s)
return (
<>

View File

@@ -7,7 +7,7 @@
* setting never means editing the shell; copy that belongs to no single
* feature (chrome, the General section) is owned by ui-settings-general.
*/
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
children?: never
}
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
order: number
label: string
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): ledger projections only — the shell reads no locale state.
* apply): the ledger's nav-row projection as a hooks-compartment source —
* the shell reads no locale state and subscribes through the bound hook.
*/
export type SettingsRootInjected = {
/** Read the settings.section ledger version (nav invalidation). */
sectionsVersion: () => number
/** Subscribe to settings.section ledger changes. */
subscribeSections: (listener: () => void) => () => void
/** Project the settings.section ledger into nav rows (id/order/label). */
sections: () => readonly { id: string; order: number; label: string }[]
hooks: {
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
}
}
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared render shares and the injected face.
* No store is registered — modal open state and active section id are
* component-local viewing state.
* (wide/rail state) plus the declared render shares and the injected face
* (hooks compartment bound to useSections). No store is registered — modal
* open state and active section id are component-local viewing state.
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
& SettingsRootInjected
& InjectFace<SettingsRootInjected>

View File

@@ -10,12 +10,12 @@
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { SettingsRootInjected } from './contract/slots.ts'
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
/**
@@ -32,17 +32,31 @@ export const inject = ['slots']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
// Ledger → nav-row projection as an observable source (uSES contract:
// getSnapshot returns the cached rows until the ledger version moves).
let rowsVersion = -1
let rows: readonly SettingsSectionRow[] = []
const injected = (): SettingsRootInjected => ({
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
sections: () => ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: e.options.label ?? '',
}))
.sort((a, b) => a.order - b.order),
hooks: {
sections: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.section')
if (version !== rowsVersion) {
rowsVersion = version
rows = ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: e.options.label ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return rows
},
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
},
},
})
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>

View File

@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots)
const { sections } = injectedOf(b.slots).hooks
// The shell ships no sections of its own — registrants fill the ledger.
expect(injected.sections()).toEqual([])
expect(sections.getSnapshot()).toEqual([])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
expect(injected.sections()).toEqual([
const rows = sections.getSnapshot()
expect(rows).toEqual([
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
// Snapshot identity is stable until the ledger moves (uSES contract).
expect(sections.getSnapshot()).toBe(rows)
const listener = vi.fn()
const off = injected.subscribeSections(listener)
const off = sections.subscribe(listener)
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalled()
expect(sections.getSnapshot()).not.toBe(rows)
off()
})

View File

@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
@@ -22,9 +23,9 @@ function mount({
{ id: 'models', order: 10, label: 'Models' },
],
}: { wide?: boolean; rows?: Row[] } = {}) {
// Mutable row store standing in for the ledger; bump() plays a change.
// Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows
let version = 0
const listeners = new Set<() => void>()
const renderSlot = vi.fn(
((key: string, _owner: unknown, opts?: { only?: string }) => {
@@ -38,19 +39,21 @@ function mount({
useSessions: unusedHook,
useWorkspaces: unusedHook,
wide,
sectionsVersion: () => version,
subscribeSections: (listener) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
useSections: (select) => {
const [, force] = useState(0)
useEffect(() => {
const listener = () => { force(n => n + 1) }
listeners.add(listener)
return () => { listeners.delete(listener) }
}, [])
return select(current)
},
sections: () => current,
renderSlot,
}
const view = render(<SettingsRoot {...props} />)
const bump = (next: Row[]) => {
act(() => {
current = next
version += 1
for (const fn of [...listeners]) fn()
})
}

View File

@@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void {
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
// the fiber effect below is its teardown boundary.
const fetches = new Map<SessionId, CatalogFetch>()
// Per-session lexicon invalidation listeners (subscribeLexicon consumers).
const lexiconListeners = new Map<SessionId, Set<() => void>>()
const notifyLexicon = (sessionId: SessionId): void => {
for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
try {
listener()
} catch (error) {
// Contain listener failures: settlement notifies from an ignored
// promise chain (a throw would surface as an unhandled rejection)
// and one faulty consumer must not starve the others.
console.error('[ui-skill] lexicon listener failed:', error)
}
}
}
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
const existing = fetches.get(sessionId)
@@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void {
fetches.set(sessionId, entry)
promise.then(
// Settled snapshot backs the synchronous lexicon reads.
(skills) => { entry.settled = skills },
(skills) => {
entry.settled = skills
notifyLexicon(sessionId)
},
// A failed fetch must not poison the key: the next consumer retries.
() => {
if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
@@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void {
if (entry === undefined) return
fetches.delete(key)
entry.abort.abort()
notifyLexicon(key)
}
const clearAll = (): void => {
@@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void {
lexicon(session) {
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
},
subscribeLexicon(session, listener) {
const key = session.sessionId
const listeners = lexiconListeners.get(key) ?? new Set()
listeners.add(listener)
lexiconListeners.set(key, listeners)
return () => {
listeners.delete(listener)
if (listeners.size === 0) lexiconListeners.delete(key)
}
},
onPick({ candidate }) {
// Decision 21: plain-text reference — the literal lands in the draft
// and ships to the model verbatim (trailing space closes the token).

View File

@@ -208,6 +208,33 @@ describe('lexicon', () => {
// Another session's key is independent — cold until its own fetch.
expect(source.lexicon!(proj('s2'))).toBeUndefined()
})
it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => {
const { list } = countingList()
const { ctx, source } = await bench(list)
const s1 = vi.fn()
const s2 = vi.fn()
source.subscribeLexicon!(proj('s1'), s1)
source.subscribeLexicon!(proj('s2'), s2)
await source.candidates(proj('s1'), req(''))
expect(s1).toHaveBeenCalledTimes(1)
expect(s2).not.toHaveBeenCalled()
// Reset invalidates every cached session: each key notifies its own listeners.
await source.candidates(proj('s2'), req(''))
ctx.emit('connection/reset')
expect(s1).toHaveBeenCalledTimes(2)
expect(s2).toHaveBeenCalledTimes(2)
})
it('an unsubscribed lexicon listener stops receiving notifications', async () => {
const { list } = countingList()
const { source } = await bench(list)
const listener = vi.fn()
const off = source.subscribeLexicon!(proj('s1'), listener)
off()
await source.candidates(proj('s1'), req(''))
expect(listener).not.toHaveBeenCalled()
})
})
describe('pick and codec', () => {

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: d2978695d71686059bfbcbb4fc3ef896d92add4a
README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3

View File

@@ -2,7 +2,7 @@
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.
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. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. 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

@@ -2,7 +2,7 @@
[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 的应答胜出。
输入触发管线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `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);变更需经主线程仲裁。

View File

@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
export class SlashController {
/** Menu state store (per-session; survives session switches, dies with the scope). */
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
/**
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
* sources implementing the lexicon hook are polled with the session
* projection; undefined answers (roll not hot yet) are skipped; multiple
* sources on one trigger concatenate in registration order. A snapshot
* store because rolls change asynchronously (catalog settles, children
* spawn/exit) — render-side consumers subscribe instead of re-reading a
* mutable answer.
*/
readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
private hit: TriggerHit | null = null
private fetch: AbortController | null = null
private disposed = false
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
private readonly lexiconOffs = new Map<SlashSource, () => void>()
constructor(private readonly deps: SlashControllerDeps) {
// Scope-birth prewarm: sessions are always agent-backed, so the one-time
// roster warm here replaces the projection-transition watch — there are
// no capability steps to react to.
const projection = this.project()
for (const src of deps.roster.all()) src.warm?.(projection)
for (const src of deps.roster.all()) {
src.warm?.(projection)
this.watchLexicon(src, projection)
}
this.refreshLexicon()
}
/**
@@ -220,6 +237,23 @@ export class SlashController {
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
}
this.lexiconOffs.get(source)?.()
this.lexiconOffs.delete(source)
this.refreshLexicon()
}
/**
* Admit a source registered after this controller's birth (root registry
* change notification): warm it and fold its roll into the live lexicon —
* the constructor-time prewarm covers only the roster present at scope
* birth.
* @param source - the newly registered source.
*/
sourceAdded(source: SlashSource): void {
const projection = this.project()
source.warm?.(projection)
this.watchLexicon(source, projection)
this.refreshLexicon()
}
/** Scope teardown: close and abort (the service deletes the map entry). */
@@ -228,6 +262,8 @@ export class SlashController {
this.stopFetch()
this.reduce({ type: 'close' })
this.hit = null
for (const off of this.lexiconOffs.values()) off()
this.lexiconOffs.clear()
}
/** The session projection handed to sources (agent-backed identity; constant per scope). */
@@ -248,25 +284,33 @@ export class SlashController {
return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
}
/**
* Aggregate the sources' plain-text reference lexicons (decision 21),
* grouped by trigger: sources implementing the hook are polled with the
* session projection (onSpace's poll pattern); undefined answers (roll not
* hot yet) are skipped; multiple sources on one trigger concatenate in
* registration order.
* @returns trigger → decorated-name roll for the decoration scan.
*/
lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
/** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
private refreshLexicon(): void {
const projection = this.project()
const rolls = new Map<TriggerChar, readonly string[]>()
for (const src of this.deps.roster.all()) {
if (src.lexicon === undefined) continue
const names = src.lexicon(projection)
let names: readonly string[] | undefined
try {
names = src.lexicon(projection)
} catch (error) {
// A faulty source drops silently with a console record (the
// candidate-fetch failure policy); the refresh runs inside
// notification callbacks, where a throw would starve other consumers.
console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error)
continue
}
if (names === undefined) continue
const prev = rolls.get(src.trigger)
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
}
return rolls
this.lexicon.set(rolls)
}
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
private watchLexicon(source: SlashSource, projection: ClientSessionContext): void {
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
}
/** Launch the candidate fetch for one hit generation, superseding the previous one. */

View File

@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
}
/**
* Register one trigger source.
* Register one trigger source. Live session controllers are notified so a
* source arriving after scope birth still warms and joins the lexicon.
* @param src - the source; (trigger, name) must be unique — duplicates throw.
* @returns the disposer (callers wrap registration in ctx.effect). Disposal
* while a controller shows the source's menu group drops that group.
@@ -49,6 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
}
live.sources.push(src)
for (const controller of live.controllers.values()) {
try {
controller.sourceAdded(src)
} catch (error) {
// Contain faulty source callbacks (warm/subscribeLexicon): the
// registration must stand with a usable disposer and the remaining
// controllers must still be notified.
console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
}
}
return () => {
const at = live.sources.indexOf(src)
if (at < 0) return

View File

@@ -165,6 +165,16 @@ export interface SlashSource {
* (the render path must stay synchronous and side-effect free).
*/
lexicon?(session: ClientSessionContext): readonly string[] | undefined
/**
* Subscribe to changes of this source's {@link SlashSource.lexicon} answer
* for one session (backing data settled, invalidated, or refreshed). The
* controller re-polls lexicon on each notification; a source whose roll
* never changes after warm omits the hook.
* @param session - stable session projection.
* @param listener - invalidation callback.
* @returns unsubscribe.
*/
subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
/** Reference codec; required for sources producing insert outcomes. */
readonly codec?: ReferenceCodec
}

View File

@@ -126,6 +126,18 @@ describe('registerSource', () => {
slash.registerSource(deferredSource('/', 'beta').source)
})
it('a source registered after controller birth warms in every live controller', async () => {
const { slash, mint } = await serviceBench()
const ca = slash.sessionOf(mint('a').actx)
const cb = slash.sessionOf(mint('b').actx)
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
slash.registerSource(late.source)
expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
})
it('HMR shape: dispose of the registering fiber removes the source', async () => {
const { root, slash, mint } = await serviceBench()
const controller = slash.sessionOf(mint('a').actx)
@@ -513,7 +525,7 @@ describe('lexicon', () => {
skill,
lexSource('@', 'subagent', ['worker-1']),
])
const rolls = controller.lexicon()
const rolls = controller.lexicon.getSnapshot()
expect([...rolls.keys()]).toEqual(['/', '@'])
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
expect(rolls.get('@')).toEqual(['worker-1'])
@@ -522,7 +534,7 @@ describe('lexicon', () => {
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
expect(controller.lexicon().size).toBe(0)
expect(controller.lexicon.getSnapshot().size).toBe(0)
})
it('two sources on one trigger concatenate in registration order', () => {
@@ -531,10 +543,63 @@ describe('lexicon', () => {
lexSource('/', 'prompt', ['c']),
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
])
const rolls = controller.lexicon()
const rolls = controller.lexicon.getSnapshot()
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
expect(rolls.has('@')).toBe(false)
})
it('a source lexicon notification republishes the aggregated store', () => {
let roll: readonly string[] | undefined = undefined
let notify: (() => void) | undefined
const source: SlashSource = {
trigger: '/',
name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session, listener) => {
notify = listener
return () => { notify = undefined }
},
}
const { controller } = controllerBench([source])
expect(controller.lexicon.getSnapshot().size).toBe(0)
const seen: number[] = []
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
roll = ['commit-helper']
notify?.()
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
expect(seen).toEqual([1])
controller.dispose()
expect(notify).toBeUndefined()
})
it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
const { controller, sources } = controllerBench([])
expect(controller.lexicon.getSnapshot().size).toBe(0)
const warm = vi.fn()
const late: SlashSource = {
trigger: '/',
name: 'late',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
warm,
lexicon: () => ['fresh'],
}
sources.push(late)
controller.sourceAdded(late)
expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
})
it('a removed source leaves the aggregated lexicon', () => {
const src = lexSource('/', 'skill', ['gone'])
const { controller, sources } = controllerBench([src])
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
sources.splice(sources.indexOf(src), 1)
controller.sourceRemoved(src)
expect(controller.lexicon.getSnapshot().size).toBe(0)
})
})
describe('arbitrate', () => {

View File

@@ -14,6 +14,7 @@
* consumer merges keys in and the intersection is what keeps them string-typed.
* The rule fires on the empty-map view, not on real redundancy. */
import type { ReactNode } from 'react'
import type { HostObservable } from './renderer.ts'
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
export * from './store.ts'
@@ -214,11 +215,40 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
*/
export type SlotComponent<P> = (props: P) => ReactNode
/**
* Registrant hooks compartment: bare observable sources (getSnapshot +
* subscribe pairs) supplied under the reserved `hooks` key of an inject
* face. The registrant-private twin of the `sessions.provide` hooks
* compartment: the renderer binds each source into a `use<Name>` selector
* hook, so the sources never reach the component and plugin-private reactive
* facts ride the same subscription machinery as the standard kit instead of
* hand-rolled component subscriptions.
*/
export type HooksSources = Record<string, HostObservable<unknown>>
/**
* Selector-hook share synthesized from a hooks compartment: each source
* `name` becomes a `use<Name>` selector hook over its snapshot type.
*/
export type PropsHooks<HS extends HooksSources> = {
[N in keyof HS & string as `use${Capitalize<N>}`]:
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
}
/**
* The component-side view of an inject face: the reserved `hooks`
* compartment (when declared) arrives as bound `use<Name>` selector hooks;
* every other member passes through verbatim.
*/
export type InjectFace<I extends object> =
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
/**
* The four-share component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face. Each share derives from its single
* source of truth; components reference this composition, never re-type it.
* the registrant's injected business face (its hooks compartment bound, see
* {@link InjectFace}). Each share derives from its single source of truth;
* components reference this composition, never re-type it.
*/
export type ComposedProps<
K extends keyof SlotMap & string,
@@ -226,7 +256,7 @@ export type ComposedProps<
H,
I extends object,
M = never,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
/**
* Inject factory parameter list, derived from the registration's declaration:

View File

@@ -105,18 +105,14 @@ export interface SlotRendererHost {
sessions: {
/** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown>
/** Current-session source used by SessionProvider. */
current: HostObservable<string | undefined>
/** Resolve a definite session bundle, or undefined when the id is unknown. */
provideInfo(id: string): SessionProvideInfo | undefined
/**
* Resolve the current-session-optional standard props bundle. The result
* always carries the static provider roster, even when `id` is absent or
* cannot resolve to a live session.
* @param id - current session id, when selected.
* @returns the optional provide info.
* Atomic current-session provide projection used by SessionProvider:
* selection changes and provider-roster changes publish through this one
* source, so a stable current id cannot strand mounted entries on an
* obsolete hook/prop schema. Carries the static roster with sessionId
* undefined while no current session resolves.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo
provideInfo: HostObservable<SessionMaybeProvideInfo>
}
/** Workspace-side standard-kit sources. */
workspaces: {

View File

@@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void {
// The list snapshot is always warm — the full running-children roster.
return childLabels(session, '')
},
subscribeLexicon(_session, listener) {
// The roll derives from the list snapshot, so its change feed IS the list's.
return sessions.list.subscribe(listener)
},
onPick({ candidate }) {
// Decision 21: plain-text reference — the literal lands in the draft
// and ships to the model verbatim (trailing space closes the token).

View File

@@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) {
const byId: Record<string, SessionSummary> = {}
for (const s of sessions) byId[s.id] = s
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
return { list: { getSnapshot: () => snapshot } }
const subs = new Set<() => void>()
return {
list: {
getSnapshot: () => snapshot,
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
},
notify: () => { for (const fn of [...subs]) fn() },
listenerCount: () => subs.size,
}
}
/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
async function fullBench(sessions: SessionSummary[]) {
const ctx = new Context()
let captured: SlashSource | undefined
const face = sessionsWith(sessions)
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('sessions', sessionsWith(sessions))
ctx.provide('sessions', face)
await ctx.plugin({ inject: [...inject], apply }).await()
return captured!
return { source: captured!, face }
}
/** Source-only bench for the behavior-contract suites. */
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
return (await fullBench(sessions)).source
}
const FAMILY: SessionSummary[] = [
@@ -113,6 +127,19 @@ describe('lexicon', () => {
expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
expect(source.lexicon!(proj('childless'))).toEqual([])
})
it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => {
const { source, face } = await fullBench(FAMILY)
let notified = 0
const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 })
expect(face.listenerCount()).toBe(1)
face.notify()
expect(notified).toBe(1)
off()
expect(face.listenerCount()).toBe(0)
face.notify()
expect(notified).toBe(1)
})
})
describe('pick and codec', () => {

View File

@@ -5,8 +5,8 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
const args: unknown[] = []
if (info !== undefined) args.push(info.sessionId)
if (actions !== undefined) args.push(actions)
return (inject as (...args: unknown[]) => InjectedProps)(...args)
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
}
/**
* Bind an inject face's reserved `hooks` compartment (bare observable
* sources, see HooksSources) into `use<Name>` selector hooks — the
* registrant-private twin of the provide-bundle binding in standardKit.
* Runs once per cached inject result; hook identity rides observableHook's
* per-source cache.
*/
function bindInjectHooks(face: InjectedProps): InjectedProps {
const sources = face['hooks']
if (sources === undefined) return face
const { hooks: _hooks, ...rest } = face
const bound: InjectedProps = rest
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
bound[hookName] = observableHook(source)
}
return bound
}
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {

View File

@@ -90,9 +90,9 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
const id = observableHook(host.sessions.current)(s => s)
const info = observableHook(host.sessions.provideInfo)(s => s)
return (
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
<BindingContext.Provider value={info}>
{children}
</BindingContext.Provider>
)
@@ -107,17 +107,17 @@ export interface SessionProviderProps {
}
/**
* Framework-wired session area: subscribes to the host's current-session
* source, resolves the session cell, and remounts the body under
* `key={sessionId}` so a session switch rebuilds the session subtree. This
* dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
* branded type at the component boundary.
* Framework-wired session area: subscribes to the host's current provide
* source and remounts the body under `key={sessionId}` so a session switch
* rebuilds the session subtree. This dependency-inverted layer uses plain
* string ids; `PropsRuntime` applies the branded type at the component
* boundary.
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost()
const id = observableHook(host.sessions.current)(s => s)
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
const info = observableHook(host.sessions.provideInfo)(s => s)
const id = info.sessionId
if (id === undefined) return <>{empty?.() ?? null}</>
return (
<BindingContext.Provider value={info} key={id}>
{children(id)}

View File

@@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
/** Passthrough host over the real core (store/session seats unused here). */
function hostOver(core: SlotCore): SlotRendererHost {
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
return {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: key => core.getVersion(key),
@@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
provideInfo: () => undefined,
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },

View File

@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, type ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionProvideInfo,
@@ -85,7 +86,9 @@ function makeHost() {
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
const list = observable<{ ids: string[] }>({ ids: [] })
const workspaces = observable<{ ids: string[] }>({ ids: [] })
const current = observable<string | undefined>(undefined)
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const bump = (key: string) => {
@@ -123,10 +126,7 @@ function makeHost() {
},
sessions: {
list,
current,
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: {}, props: {} },
provideInfo: provide,
},
workspaces: { list: workspaces },
}
@@ -134,7 +134,14 @@ function makeHost() {
host,
list,
workspaces,
current,
// Same driver surface as the old current cell: set(id) publishes the
// resolved bundle (or the absent projection) through the provide source.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
},
},
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
const entry = entryOf(partial)
@@ -161,6 +168,7 @@ function makeHost() {
props: {},
}
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
},
}
@@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(inject).toHaveBeenCalledWith()
})
it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const badge = observable('cold')
const seen: Record<string, unknown>[] = []
h.add('k.single', {
component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
return null
},
inject: () => ({ plain: 'kept', hooks: { badge } }),
})
mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
// The raw compartment is consumed by the binding; the plain member passes through.
expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
act(() => { badge.set('hot') })
expect(seen.at(-1)!['read']).toBe('hot')
})
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)

View File

@@ -9,7 +9,7 @@
import { useEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider,
type SessionProvideInfo, type SlotRendererHost,
@@ -26,12 +26,14 @@ function observable<T>(initial: T) {
}
/**
* Minimal host: SessionProvider only reads sessions.current/cell, but it must
* Minimal host: SessionProvider only reads sessions.provideInfo, but it must
* render inside the renderer tree (HostContext), so the harness mounts a real
* root entry whose body is the test's render-prop provider.
*/
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
const current = observable<string | undefined>(undefined)
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const sessionEntries: StoredEntry[] = []
const rootEntry: StoredEntry = {
@@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
current,
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
provideInfo: provide,
},
workspaces: { list: observable<unknown>({ items: [] }) },
}
return {
host,
current,
// Same driver surface as the old current cell: set(id) publishes the
// resolved bundle (or the absent projection) through the provide source.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
},
},
addSession: (id: string) => {
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
const info: SessionProvideInfo = {
@@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
props: {},
}
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
},
/** Swap one session's bundle in place (roster-change stand-in); republish when current. */
replaceSession: (info: SessionProvideInfo) => {
infos.set(info.sessionId, info)
if (currentId === info.sessionId) provide.set(info)
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
}
@@ -149,6 +161,28 @@ describe('SessionProvider', () => {
expect(seen.at(-1)!['sessionId']).toBe('s2')
})
it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
const seen: unknown[] = []
const h = makeHost({
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
const original = h.addSession('s1')
h.registerSession({
component: (props: { feature?: string }) => {
seen.push(props.feature)
return null
},
options: {},
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)).toBeUndefined()
// A provider-roster change rematerializes the bundle; the provide source
// must carry it to already-mounted entries without a selection change.
act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
expect(seen.at(-1)).toBe('now-live')
})
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(

View File

@@ -21,6 +21,7 @@ function makeHost() {
const versions = new Map<string, number>()
const subs = new Map<string, Set<() => void>>()
const live = new Set<StoredEntry>()
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
for (const fn of [...(subs.get(key) ?? [])]) fn()
@@ -39,9 +40,7 @@ function makeHost() {
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
provideInfo: () => undefined,
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },