mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #1703 from deepseek-harness/worktree-conv-split
feat(client): inject slot declaration lifetimes
This commit is contained in:
@@ -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-19-gui-web-client-architecture.md
|
||||
2026-07-19-gui-web-client-architecture.md: b306f3b155d9d9208066c3f25ad2c4fb4683b1ee
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 28632667c45b360eb2bc5f0d06f10b9df910770d
|
||||
2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1
|
||||
|
||||
@@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien
|
||||
|
||||
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
|
||||
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
|
||||
|
||||
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
|
||||
|
||||
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
|
||||
|
||||
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。
|
||||
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`([决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。
|
||||
|
||||
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
|
||||
|
||||
|
||||
@@ -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-22-slot-type-chain-implementation.md
|
||||
2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 8ca6781e42764fc8d7f7de0f9f25ca6c110d4be0
|
||||
2026-07-22-slot-type-chain-implementation.md: 2f0ec32766100e492c68c474f8798be3df0a3d15
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 75e89d3f57b96a1699981123e8361c775db2b8a7
|
||||
|
||||
@@ -36,6 +36,8 @@ There is no separate slot-definition API. The `children` object both **declares
|
||||
|
||||
Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes; a chain registration missing its `select`.
|
||||
|
||||
A contributor whose activation order is independent from the declaring entry uses `ctx.slots.inject(key, callback)` and keeps direct `register()` fail-loud. The declaration, contributor, replacement, and failure lifetimes are specified by the [slot declaration injection decision](2026-08-05-slot-declaration-injection.md).
|
||||
|
||||
`SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type").
|
||||
|
||||
### Component props: four shares, each from its own source of truth
|
||||
|
||||
@@ -36,6 +36,8 @@ ctx.slots.register({
|
||||
|
||||
对等原则:**声明子 slot 的 entry 独占渲染这些子 slot 的权力**,全部在 register 时确定(配置错误会在装载时明确失败;渲染热路径不再校验)。装载即炸的情形:第二个 entry 声明已被声明的 slot;向未声明的 slot register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。
|
||||
|
||||
激活顺序独立于声明条目的贡献方使用 `ctx.slots.inject(key, callback)`,并让直接调用 `register()` 继续大声失败。声明、贡献方、替换与失败各自的生命周期由 [slot 声明注入决策](2026-08-05-slot-declaration-injection.md) 规定。
|
||||
|
||||
`SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。
|
||||
|
||||
### 组件 props:四份额,各有唯一真源
|
||||
|
||||
@@ -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-23-toolview-dissolution.md
|
||||
2026-07-23-toolview-dissolution.md: 406e5c181aabb635f9d6dcb12d8a9b8b6697368e
|
||||
2026-07-23-toolview-dissolution.zh.md: f42881c5f2e4c661d7fa40bfca7d0b53c1beef5e
|
||||
2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32
|
||||
2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2
|
||||
|
||||
@@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one
|
||||
|
||||
The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively.
|
||||
|
||||
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
|
||||
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
|
||||
|
||||
Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option.
|
||||
|
||||
@@ -34,4 +34,4 @@ Four behavioral deltas were accepted deliberately, not overlooked. Cross-view ap
|
||||
|
||||
## Consequences
|
||||
|
||||
The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties.
|
||||
The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
|
||||
|
||||
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<domain>.<entry>.<hole>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
|
||||
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md))。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
|
||||
|
||||
registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。
|
||||
|
||||
@@ -34,4 +34,4 @@ registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘
|
||||
|
||||
## Consequences
|
||||
|
||||
client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。
|
||||
client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md
|
||||
2026-08-05-slot-declaration-injection.md: cb15125977c060144553d7cf75e3c2c26fb1b23b
|
||||
2026-08-05-slot-declaration-injection.zh.md: 385cab875bb445ba1ca324fc9b45363b8daf50b6
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Slot declaration injection and reload lifetimes
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-05-slot-declaration-injection.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Client plugins may contribute to a slot before or after the plugin that declares it. Cordis service injection cannot express this dependency: a service is only an indirect ordering signal, client manifest dependency rows do not sequence activation, and a slot can disappear and return while every related service remains mounted. Registering immediately therefore races an undeclared slot, while waiting on an unrelated service couples independently reloadable features.
|
||||
|
||||
Slot-level hot replacement also requires two independent owners. Removing the declaring plugin must remove every contribution under its child slots; removing a contributing plugin must remove only that plugin's entries. A replacement declaration with the same key is a new lifetime even when disappearance and reappearance batch into one notification.
|
||||
|
||||
## Decision
|
||||
|
||||
`SlotsService.inject(name, callback)` makes the declared slot itself the dependency. The full `SlotMap` key is statically checked; there is no namespace builder, synthetic Cordis service, or slot-specific `Context`. The callback runs immediately when the declaration exists, otherwise waits, and returns either one synchronous disposer or a synchronous iterable of disposers. Iterable effects install transactionally: a later setup failure disposes every earlier yielded effect in reverse order.
|
||||
|
||||
The ledger records a declaration epoch distinct from the slot's ordinary entry version. An epoch changes whenever a child declaration is created or collapsed. Injection remembers the active epoch, disposes its callback effect when that epoch ends, and reruns the callback for a replacement declaration even when the final observed state is continuously declared. Ordinary contribution changes do not restart injection.
|
||||
|
||||
Both sides retain their natural ownership. The injection controller and every contribution run on the contributing plugin's caller `Context`, so disposing that plugin removes its wait and active entries. The slot ledger's existing child-collapse cascade removes entries when the declarer disappears; injection then runs their disposers to release service-layer resources and remains ready for a later declaration. The declaring plugin's `Context` is neither retained as a capability source nor exposed to contributors.
|
||||
|
||||
Dynamic reload code uses an ordinary Cordis plugin fiber as its replacement unit: activate the new module through `ctx.plugin()`, dispose and await the old fiber before mounting its replacement, and let its `slots.inject` and `slots.register` effects leave with that fiber. Renderer subscriptions observe the ledger removal and unmount the component; no slot-owned fiber tree is required.
|
||||
|
||||
## Failure and lifecycle contract
|
||||
|
||||
An injection whose declaration already exists reports callback setup failures synchronously. A callback failure after a delayed declaration first unsubscribes and rolls back its collected effects, then reports the failure outside the slot notification flush so one registrant cannot starve other listeners. Direct `slots.register()` into an undeclared slot continues to throw: injection is explicit and does not weaken load-time validation.
|
||||
|
||||
Disposing an injection is idempotent. It unsubscribes before releasing the active callback effect, preventing teardown-triggered ledger notifications from resurrecting the contribution. Declaration-bound teardown is synchronous with the ledger boundary, so it releases service-layer resources before any subsequent same-tick registration. A waiting injection disposed with its plugin cannot activate later.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use `ConversationService` or another service as an ordering barrier.** Service presence does not identify the declaration or follow its reload lifetime, and it creates a false package dependency for presentation-only contributors.
|
||||
|
||||
**Bridge each declaration into a `slot:<name>` Cordis service.** This pollutes the service namespace, turns a misspelled dynamic key into a silent service wait, and disguises ledger state as a business capability. Native slot injection provides the same wait without changing Cordis topology.
|
||||
|
||||
**Create a Cordis context or fiber for every slot.** A contributor needs the intersection of its own plugin lifetime and the declaration lifetime, not the declarer's capabilities. A slot-owned context introduces capability inheritance and dual-parent teardown problems without improving ledger ownership.
|
||||
|
||||
**Make `register()` wait implicitly.** Immediate failure on an undeclared target is a valuable configuration check. Explicit injection distinguishes an intentional independently ordered contribution from a broken composition.
|
||||
|
||||
**Judge replacement from `spec(name) !== undefined` alone.** Collapse and redeclaration can batch into one continuously present final state while the old contributions have already been removed. The declaration epoch preserves that boundary.
|
||||
|
||||
## Consequences
|
||||
|
||||
Slot dependencies become auditable at the registration site and follow declaration replacement without package-specific ordering conventions. Dynamic plugin disposal removes rendered entries through existing Cordis effects, while declaration replacement has a stable hook for later slot-level HMR.
|
||||
|
||||
The runtime carries one additional monotonic epoch per touched slot and injection callbacks must return their cleanup. Multi-registration callbacks use iterable effects so setup and teardown remain atomic. The flat dotted-key ledger and the single `register()` composition authority remain unchanged.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note(agent 决策记录):slot 声明注入与重载生命周期
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-05-slot-declaration-injection.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
客户端插件可能在声明某个 slot 的插件之前或之后向该 slot 贡献内容。Cordis 服务注入无法表达这种依赖:服务只能作为间接的顺序信号;客户端 manifest(元数据清单)的依赖项不会规定激活顺序;即使所有相关服务始终挂载,slot 仍可能消失后重新出现。因此,立即注册会与尚未声明的 slot 形成竞态,而等待无关服务则会耦合本可独立重载的功能。
|
||||
|
||||
slot 级热替换还要求两个相互独立的所有者。移除声明方插件必须移除其子 slot 下的所有贡献;移除贡献方插件只能移除该插件自己的条目。即使消失与重新出现合并在同一次通知中,同一个 key 的替换声明也属于新的生命周期。
|
||||
|
||||
## 决策
|
||||
|
||||
`SlotsService.inject(name, callback)` 以已声明的 slot 本身作为依赖。完整的 `SlotMap` key 会经过静态检查;系统不引入命名空间构建器、合成的 Cordis 服务或 slot 专属 `Context`。声明存在时回调同步执行,否则等待;回调返回一个同步 disposer,或由多个 disposer 构成的同步 iterable。iterable effect 的安装具有事务性:后续 setup 失败时,系统会按逆序 dispose(资源释放)之前 yield 的所有 effect。
|
||||
|
||||
该账本记录独立于 slot 普通条目版本的 declaration epoch(声明代次)。每当子声明创建或折叠时,epoch 都会变化。注入会记住活跃 epoch;该 epoch 结束时,注入会 dispose 其回调 effect;即使最终观测到的状态始终为已声明,也会为替换声明重新执行回调。普通贡献变更不会重启注入。
|
||||
|
||||
声明方与贡献方各自保留其自然所有权。注入控制器和每项贡献都运行在贡献方插件调用时的 `Context` 上,因此 dispose 该插件会同时移除其等待与活跃条目。slot 账本现有的子项折叠级联会在声明方消失时移除条目;随后,注入会运行其 disposer 以释放服务层资源,并继续等待后续声明。系统既不会将声明方插件的 `Context` 保留为 capability 来源,也不会向贡献方公开它。
|
||||
|
||||
动态重载代码使用普通 Cordis 插件 fiber 作为替换单元:通过 `ctx.plugin()` 激活新模块;挂载替换模块之前,先 dispose 并等待旧 fiber;该 fiber 的 `slots.inject` 与 `slots.register` effect 会随之退出。renderer 订阅会观察到账本移除并卸载组件;无需建立 slot 自有的 fiber 树。
|
||||
|
||||
## 失败与生命周期契约
|
||||
|
||||
如果注入创建时声明已经存在,回调 setup 失败会同步上报。延迟声明出现后发生的回调失败,会先取消订阅并回滚已收集的 effect,再在 slot 通知刷新之外上报,避免一个注册方使其他 listener 得不到执行机会。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常:注入是显式机制,不会削弱加载时验证。
|
||||
|
||||
对注入执行 dispose 具有幂等性。它会先取消订阅,再释放活跃的回调 effect,避免拆卸触发的账本通知复活该项贡献。声明绑定的 teardown 与账本边界同步,因此会在同一 tick 的任何后续注册之前释放服务层资源。随插件一同 dispose 的待命注入无法在之后激活。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**将 `ConversationService` 或其他服务用作顺序屏障。** 服务存在并不能标识相应声明,也不会跟随声明的重载生命周期;只负责呈现的贡献方还会因此产生虚假的包(package)依赖。
|
||||
|
||||
**将每项声明桥接为 `slot:<name>` Cordis 服务。** 这会污染服务命名空间,使拼错的动态 key 变成静默的服务等待,并把账本状态伪装成业务 capability。原生 slot 注入无需改变 Cordis 拓扑,即可提供同样的等待能力。
|
||||
|
||||
**为每个 slot 创建 Cordis 上下文或 fiber。** 贡献方需要的是自身插件生命周期与声明生命周期的交集,而不是声明方的 capability。slot 所有的上下文会引入 capability 继承和双父级拆卸问题,却无法改善账本所有权。
|
||||
|
||||
**让 `register()` 隐式等待。** 对未声明目标立即失败是一项有价值的配置检查。显式注入能够区分有意独立排序的贡献与错误组合。
|
||||
|
||||
**只根据 `spec(name) !== undefined` 判断替换。** 折叠与重新声明可以合并成一个最终状态始终存在的通知,而旧贡献此时已经被移除。declaration epoch 保留了这条生命周期边界。
|
||||
|
||||
## 影响
|
||||
|
||||
slot 依赖可以在注册点审计,并且无需特定于包的顺序约定即可跟随声明替换。动态插件 dispose 会通过既有 Cordis effect 移除已渲染条目,而声明替换则为后续 slot 级 HMR(热模块替换)提供稳定钩子。
|
||||
|
||||
运行时为每个被访问的 slot 多维护一个单调 epoch,且注入回调必须返回清理操作。多注册回调使用 iterable effect,使 setup 与 teardown 保持原子性。扁平的点分 key 账本和唯一的 `register()` 组合权威保持不变。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md
|
||||
2026-07-23-web-todo-display.md: 7223ff9adbf1fa6dca39c9eb4949b6d3861bdd6d
|
||||
2026-07-23-web-todo-display.zh.md: 98390c8c2cd95be9d5565b4062d00c1d99215cea
|
||||
2026-07-23-web-todo-display.md: d5939c0fa6ab83a61f1932622c4b6a150ccaded7
|
||||
2026-07-23-web-todo-display.zh.md: b1ecf679efe81016e00c5c1221c04730b3ce98bb
|
||||
|
||||
@@ -18,11 +18,11 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it
|
||||
|
||||
### TodoPanel: the durable list as a persistent strip
|
||||
|
||||
The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"<done>/<total> tasks · <n> in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.
|
||||
The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, using `ctx.slots.inject` with no `ConversationService` edge, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"<done>/<total> tasks · <n> in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.
|
||||
|
||||
### TodoRow: the per-call row through the keyed toolview slot
|
||||
|
||||
The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot via `ctx.slots.register` — the same seam and load-order posture as the bash sample (`inject: ['slots', 'conversation']`), but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card.
|
||||
The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot through `ctx.slots.inject`, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ Status: implemented
|
||||
|
||||
### TodoPanel:持久化列表作为一条常驻横条
|
||||
|
||||
面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,采用与 QueueDock 相同的模式:以 `inject: ['slots', 'conversation']` 作为加载顺序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(折叠态不显示进行中条目的内容提示)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配器只是一行包装。
|
||||
面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry` 使用 `ctx.slots.inject`,不依赖 `ConversationService`,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。
|
||||
|
||||
### TodoRow:经 keyed toolview slot 的逐调用行
|
||||
|
||||
专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.register` 注册进 keyed 的 `conversation.chat.toolview` slot——与 bash 样例使用同一 seam、相同的加载顺序模式(`inject: ['slots', 'conversation']`),但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,持久化列表从会话事件渲染,而非工具卡。
|
||||
专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.inject` 注册进 keyed 的 `conversation.chat.toolview` slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: c732758bc567376a0be4ac348aa9129aa8126ac4
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dc7e0ccf5f9a99ad35c859a7ecfb9f98d93d530
|
||||
|
||||
@@ -12,7 +12,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only.
|
||||
|
||||
**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
|
||||
**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
|
||||
|
||||
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
|
||||
|
||||
**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约,独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
|
||||
**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
|
||||
|
||||
**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
|
||||
2026-07-30-web-read-card-frontend.md: f504cab7705d03f6d3e911da05c509da50bb9abe
|
||||
2026-07-30-web-read-card-frontend.zh.md: 983e03b36a3e6d6d4ec0ba416fdcca227d82bb41
|
||||
2026-07-30-web-read-card-frontend.md: 06559d70c655b86a6aa70c8a9e948f4f21a1f522
|
||||
2026-07-30-web-read-card-frontend.zh.md: 92fb724658d4c23b915f61efc3b482fdf1ce7c7b
|
||||
|
||||
@@ -16,7 +16,7 @@ The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent car
|
||||
|
||||
`readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache.
|
||||
|
||||
The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.
|
||||
The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` through `ctx.slots.inject`, exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.
|
||||
|
||||
Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
`readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView`(`kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null,包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。
|
||||
|
||||
聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`(8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow`(在 `apply.ts` 里以 `read` 键注册,加载顺序 seam 为 `inject: ['slots', 'conversation']`,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。
|
||||
聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`(8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow`(经 `ctx.slots.inject` 以 `read` 键注册,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。
|
||||
|
||||
整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。
|
||||
|
||||
|
||||
@@ -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/proposed/architecture/2026-07-25-client-settings-locale-theme.md
|
||||
2026-07-25-client-settings-locale-theme.md: c86d6ac053f7bb87ce758613a5f3a0a34951e428
|
||||
2026-07-25-client-settings-locale-theme.zh.md: 379c6d0afee71b51dd15605fcac5689fb53ee8a3
|
||||
2026-07-25-client-settings-locale-theme.md: fdcdcff92fa324803d5f2343f3fc3d9dcca069c7
|
||||
2026-07-25-client-settings-locale-theme.zh.md: b1536a1515fa7022320934dfdabf94c815e8ba04
|
||||
|
||||
@@ -55,11 +55,11 @@ root
|
||||
└─ models (order 10) ui-models 注册
|
||||
```
|
||||
|
||||
Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, one-call disposal; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md), not `refresh()`) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam.
|
||||
Section and item contributions use `ctx.slots.inject()` and do not depend on the client manifest's apply order; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md). The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam.
|
||||
|
||||
### Future work: promote slot declarations to first-class injectable waits
|
||||
### Slot declarations are first-class injectable waits
|
||||
|
||||
`deferRegistration()` is behaviorally isomorphic to `ctx.inject` — one waits on a ledger declaration, the other on service presence, with matching disappear/reappear lifecycle semantics; the difference is that the fiber form's disposer lifetime naturally equals the declaration's lifetime, so the stale-disposer presence-judging machinery disappears entirely. Direction (a separate PR): SlotsService bridges each slot into a `slot:<name>` service (value = the slot spec) at declaration commit / cascade removal, registrants migrate from `deferRegistration()` to a nested `ctx.inject(['slot:<name>'], cb)`, then `deferRegistration()` is deleted and packages/client/AGENTS.md checklist item 4 is rewritten. Boundaries to pin down: the nested fiber's harmless wait must not be named by the boot fail-loud scan (needs a test); the `slot:` namespace and the silent-wait-on-typo stance; provide keys are flat names (`slot:a.b` is one key, not a property path on `ctx.slots`). This phase keeps the `deferRegistration()` function form.
|
||||
`SlotsService.inject()` now waits on the typed ledger key directly; it does not bridge declarations into synthetic `slot:<name>` Cordis services. The callback follows declaration collapse and redeclaration while its controller remains owned by the contributing plugin fiber, and direct registration into an undeclared slot still fails loud. This removes the stale-disposer presence machine and the typo-prone parallel service namespace. The complete lifecycle and failure contract lives in the [slot declaration injection decision](../../implemented/architecture/2026-08-05-slot-declaration-injection.md).
|
||||
|
||||
### Service contracts
|
||||
|
||||
@@ -127,4 +127,4 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on
|
||||
|
||||
## Risks
|
||||
|
||||
The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR.
|
||||
The apply order of slot declarations and contributions is not fixed, so every section/item registrant must use `ctx.slots.inject()` rather than a service or local-disposer presence signal. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR.
|
||||
|
||||
@@ -55,11 +55,11 @@ root
|
||||
└─ models (order 10) ui-models 注册
|
||||
```
|
||||
|
||||
section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、一键 dispose(资源释放);本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk,不再 `refresh()`),不依赖 client manifest(元数据清单)的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings 契约(消费方 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的契约对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。
|
||||
section/item contribution 使用 `ctx.slots.inject()`,不依赖 client manifest 的 apply 顺序;本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。
|
||||
|
||||
### 未来工作:slot 声明升格为可 inject 的一等等待物
|
||||
### slot 声明是一等可注入等待对象
|
||||
|
||||
`deferRegistration()` 与 `ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,陈旧 disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个 slot 桥接成 `slot:<name>` 服务(value 为 slot spec),注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:<name>'], cb)`,随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);`slot:` 名字空间与 typo 静默等待的口径;provide 键是平面名(`slot:a.b` 是一个键,不是 `ctx.slots` 的属性路径)。本期维持 `deferRegistration()` 函数形式。
|
||||
`SlotsService.inject()` 直接等待有类型约束的 ledger key;它不会将声明桥接为合成的 `slot:<name>` Cordis 服务。回调会跟随声明折叠与重新声明,而其控制器仍归贡献方插件 fiber 所有;直接向未声明 slot 注册仍会大声失败。这删除了陈旧 disposer 判在位机器和容易因拼写错误出错的平行服务命名空间。完整的生命周期与失败契约见 [slot 声明注入决策](../../implemented/architecture/2026-08-05-slot-declaration-injection.md)。
|
||||
|
||||
### 服务契约
|
||||
|
||||
@@ -127,4 +127,4 @@ Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未
|
||||
|
||||
## 风险
|
||||
|
||||
slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。服务事件可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR(热模块替换)后残留。
|
||||
slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须使用 `ctx.slots.inject()`,而不能以服务或本地 disposer 作为在位信号。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。
|
||||
|
||||
@@ -7,7 +7,7 @@ line=138: export function SearchBlock(props: SearchBlockProps) {
|
||||
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
line=35: const search = searchCardModel(block)
|
||||
line=52: search={search}
|
||||
line=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
expand=… 其余 4 行
|
||||
recovery=Found 9 of 42 matches
|
||||
|
||||
@@ -22,6 +22,6 @@ packages/client/ui-conversation/src/client/toolviews/search-row.tsx
|
||||
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
Line 35: const search = searchCardModel(block)
|
||||
Line 52: search={search}
|
||||
Line 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
|
||||
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)
|
||||
@@ -88,7 +88,7 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
|
||||
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
## New component checklist
|
||||
|
||||
@@ -167,7 +167,7 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
|
||||
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 52, line: ' search={search}' },
|
||||
{ lineNumber: 73, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
deferRegistration,
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -384,16 +383,12 @@ export function apply(ctx: ClientContext): void {
|
||||
setLocale: (id) => { locale.setLocale(id) },
|
||||
}
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'language',
|
||||
order: 0,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, LanguageRow))
|
||||
return () => { deferred.dispose() }
|
||||
}, 'locale: language settings row registration')
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'language',
|
||||
order: 0,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, LanguageRow))
|
||||
}
|
||||
|
||||
@@ -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: b390c2830a47ed380e01bc7ec763b4bd8d8459e8
|
||||
README.zh.md: 0aaad0b7620394f151b6a757f924d22d4f2140ab
|
||||
README.md: f95e06162bca132a9aa83e0b84e875a81d2f8fc6
|
||||
README.zh.md: 4b1a9dda3bbe02ef9241a8a797a07e5285e6daae
|
||||
|
||||
@@ -4,6 +4,12 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
## Slot declaration injection
|
||||
|
||||
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
|
||||
|
||||
The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several `slots.register()` calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the [declaration-injection decision](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md).
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Slot 声明注入
|
||||
|
||||
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
|
||||
|
||||
回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此,generator 可以 yield 多个 `slots.register()` 调用,并将它们组成一项事务:setup 失败会回滚先前 yield 的 effect,teardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch(声明代次),因此,即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md)。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
@@ -46,7 +52,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## 模型重试投影
|
||||
|
||||
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
|
||||
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
|
||||
|
||||
## 会话 fork
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* SlotsService: the cordis Service layer of the slot system over the pure
|
||||
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
|
||||
* the load-time validations, and the unload cascade). This layer owns what
|
||||
* needs the runtime: the 'slots/changed' event bridge, register through the
|
||||
* caller's ctx.effect (fiber unload collects registrations), the renderer
|
||||
* install seam (install()/renderSlot('root') + the SlotRendererHost face),
|
||||
* and the store INSTANCE axis — handle x scope key -> create/cache, dropped
|
||||
* with the last holding entry, session instances cleared (with persisted
|
||||
* state) on scope death.
|
||||
* needs the runtime: the 'slots/changed' event bridge, register and
|
||||
* declaration injection through the caller's ctx.effect (fiber unload
|
||||
* collects both), the renderer install seam (install()/renderSlot('root') +
|
||||
* the SlotRendererHost face), and the store INSTANCE axis — handle x scope
|
||||
* key -> create/cache, dropped with the last holding entry, session instances
|
||||
* cleared (with persisted state) on scope death.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
|
||||
@@ -78,6 +78,9 @@ interface ErasedRegisterOptions {
|
||||
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
|
||||
interface ErasedCore { register(options: object, component: unknown): () => void }
|
||||
|
||||
/** One synchronous effect installed while an injected slot declaration is live. */
|
||||
type SlotInjectionEffect = (() => void) | Iterable<() => void, void, void>
|
||||
|
||||
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
|
||||
export class SlotsService extends Service {
|
||||
private readonly _core = new SlotCore()
|
||||
@@ -114,6 +117,85 @@ export class SlotsService extends Service {
|
||||
*/
|
||||
declare readonly register: SlotCore['register']
|
||||
|
||||
/**
|
||||
* Install an effect for each declaration lifetime of a slot. The callback
|
||||
* runs synchronously when the declaration already exists; otherwise it runs
|
||||
* inside the declaring `register()` call after the declaration is committed.
|
||||
* Collapse disposes the effect and a later declaration runs it again.
|
||||
* Callback effects are synchronous disposers; iterable effects install
|
||||
* transactionally and dispose in reverse order. The controller belongs to
|
||||
* the caller's fiber, so plugin unload cancels a pending wait and removes any
|
||||
* active contribution.
|
||||
*
|
||||
* @param key - declared SlotMap key to depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer for the wait and active effect.
|
||||
* @throws callback setup failures synchronously when the slot is already declared.
|
||||
*/
|
||||
inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void {
|
||||
const ctx = this.ctx
|
||||
const disposeController = ctx.effect(() => {
|
||||
let active: (() => void) | undefined
|
||||
let activeEpoch: number | undefined
|
||||
let stopped = false
|
||||
let unsubscribe = (): void => {}
|
||||
|
||||
const stop = (): void => {
|
||||
if (stopped) return
|
||||
// Failure callers retire the injection permanently: a delayed setup
|
||||
// failure never retries on a later declaration.
|
||||
stopped = true
|
||||
unsubscribe()
|
||||
const dispose = active
|
||||
active = undefined
|
||||
activeEpoch = undefined
|
||||
dispose?.()
|
||||
}
|
||||
|
||||
const reconcile = (): void => {
|
||||
if (stopped) return
|
||||
const spec = this._core.specDynamic(key)
|
||||
const epoch = this._core.declarationEpoch(key)
|
||||
if (active !== undefined && activeEpoch === epoch) return
|
||||
const dispose = active
|
||||
active = undefined
|
||||
activeEpoch = undefined
|
||||
dispose?.()
|
||||
if (spec === undefined) return
|
||||
// A declaration lifetime is a nested Cordis effect. This gives
|
||||
// generator callbacks the same transactional setup, reverse teardown,
|
||||
// diagnostics tree, and idempotence as every other plugin effect.
|
||||
const disposeEffect = ctx.effect(callback, `slots.inject(${JSON.stringify(key)}): declaration`)
|
||||
active = () => { void disposeEffect() }
|
||||
activeEpoch = epoch
|
||||
}
|
||||
|
||||
const changed = (): void => {
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
stop()
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
queueMicrotask(() => { throw failure })
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe = this._core.subscribeDeclaration(key, changed)
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
stop()
|
||||
throw error
|
||||
}
|
||||
return stop
|
||||
}, `slots.inject(${JSON.stringify(key)})`)
|
||||
return () => { void disposeController() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the shell's renderer (web-react's createSlotRenderer product).
|
||||
* Boot-once: a second install throws. Runs through the caller's ctx.effect,
|
||||
|
||||
@@ -29,6 +29,7 @@ const C: FC<object> = () => null
|
||||
*/
|
||||
interface ErasedService {
|
||||
register(options: object, component: unknown): () => void
|
||||
inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
|
||||
install(renderer: object): void
|
||||
renderSlot(key: string, owner: object): unknown
|
||||
}
|
||||
@@ -166,6 +167,266 @@ describe('load-time validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('declaration injection', () => {
|
||||
it('activates immediately and ignores ordinary entry mutations', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)
|
||||
const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C))
|
||||
const dispose = bench.erased.inject('t.rows', setup)
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
bench.erased.register({ name: 't.rows', id: 'ordinary' }, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
dispose()
|
||||
expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary'])
|
||||
})
|
||||
|
||||
it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => {
|
||||
const bench = await boot()
|
||||
const cleanup = vi.fn()
|
||||
const setup = vi.fn(() => {
|
||||
const unregister = bench.erased.register({ name: 't.host' }, C)
|
||||
return () => { unregister(); cleanup() }
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
disposeFrame()
|
||||
await Promise.resolve()
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => {
|
||||
const bench = await boot()
|
||||
const firstFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const cleanup = vi.fn()
|
||||
const setup = vi.fn(() => {
|
||||
const unregister = bench.erased.register({ name: 't.host' }, C)
|
||||
return () => { unregister(); cleanup() }
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
firstFrame()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(setup).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => {
|
||||
const active = await boot()
|
||||
active.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const activeFiber = active.ctx.plugin({
|
||||
name: 'active-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) },
|
||||
})
|
||||
await activeFiber.await()
|
||||
expect(active.svc.entries('t.host')).toHaveLength(1)
|
||||
await activeFiber.dispose()
|
||||
expect(active.svc.entries('t.host')).toHaveLength(0)
|
||||
|
||||
const waiting = await boot()
|
||||
const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C))
|
||||
const waitingFiber = waiting.ctx.plugin({
|
||||
name: 'waiting-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) },
|
||||
})
|
||||
await waitingFiber.await()
|
||||
await waitingFiber.dispose()
|
||||
waiting.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back earlier yielded registrations when generator setup fails', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
't.host': { kind: 'single', scope: 'root' },
|
||||
't.rows': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.host' }, C)
|
||||
expect(() => bench.erased.inject('t.rows', function* () {
|
||||
yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C)
|
||||
yield bench.erased.register({ name: 't.host' }, C)
|
||||
})).toThrow(/already has a registration/)
|
||||
expect(bench.svc.entries('t.rows')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('contains and wraps a delayed setup failure so later slot listeners still run', async () => {
|
||||
const bench = await boot()
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (error: unknown): void => { failures.push(error) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const setup = vi.fn(function* () {
|
||||
yield bench.erased.register({ name: 't.host' }, C)
|
||||
throw null
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', later)
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]).toBeInstanceOf(Error)
|
||||
expect(String(failures[0])).toContain('null')
|
||||
expect(later).toHaveBeenCalledOnce()
|
||||
disposeFrame()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips a stopped controller retained by the current declaration snapshot', async () => {
|
||||
const bench = await boot()
|
||||
let stopLater = (): void => {}
|
||||
const first = vi.fn(() => {
|
||||
stopLater()
|
||||
return () => undefined
|
||||
})
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', first)
|
||||
stopLater = bench.erased.inject('t.host', later)
|
||||
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
expect(first).toHaveBeenCalledOnce()
|
||||
expect(later).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a nested redeclaration activation when the outer collapse resumes', async () => {
|
||||
const bench = await boot()
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
let disposeReplacement = (): void => {}
|
||||
let replaced = false
|
||||
const first = vi.fn(() => () => {
|
||||
if (replaced) return
|
||||
replaced = true
|
||||
disposeReplacement = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
})
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', first)
|
||||
bench.erased.inject('t.host', later)
|
||||
|
||||
disposeFrame()
|
||||
expect(first).toHaveBeenCalledTimes(2)
|
||||
expect(later).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.spec('t.host')).toBeDefined()
|
||||
disposeReplacement()
|
||||
})
|
||||
|
||||
it('cancels a waiting injection when its contributor is already unloading', async () => {
|
||||
const bench = await boot()
|
||||
const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C))
|
||||
let release = (): void => {}
|
||||
const blocked = new Promise<void>((resolve) => { release = resolve })
|
||||
const pauseUnload = vi.fn(async () => { await blocked })
|
||||
const contributor = bench.ctx.plugin({
|
||||
name: 'unloading-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => {
|
||||
ctx.slots.inject('t.host', setup)
|
||||
ctx.effect(() => pauseUnload, 'pause contributor unload')
|
||||
},
|
||||
})
|
||||
await contributor.await()
|
||||
const disposing = contributor.dispose()
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).not.toThrow()
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
|
||||
release()
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('supports dynamic plugin replacement without retaining the old rendered entry', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const componentA = (): null => null
|
||||
const componentB = (): null => null
|
||||
const mount = (name: string, component: FC<object>) => bench.ctx.plugin({
|
||||
name,
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) },
|
||||
})
|
||||
const first = mount('replacement-a', componentA)
|
||||
await first.await()
|
||||
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA)
|
||||
await first.dispose()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
const second = mount('replacement-b', componentB)
|
||||
await second.await()
|
||||
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB)
|
||||
})
|
||||
|
||||
it('releases service-layer store state when the declaration collapses', async () => {
|
||||
const bench = await boot()
|
||||
let host: SlotRendererHost | undefined
|
||||
bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
bench.erased.renderSlot('root', {})
|
||||
if (host === undefined) throw new Error('renderer never received the host')
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C))
|
||||
const oldEntry = host.entriesOf('t.host')[0]
|
||||
expect(host.storeOf(oldEntry as never, undefined)).toBeDefined()
|
||||
disposeFrame()
|
||||
expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/)
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } },
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.panel', store: handle }, C)
|
||||
const panelEntry = host.entriesOf('t.panel')[0]
|
||||
expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
|
||||
expect(handle.create).toHaveBeenLastCalledWith('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderer install seam', () => {
|
||||
it('throws on renderSlot before install (boot-order guidance)', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
@@ -55,14 +55,10 @@ export const inject = ['slash', 'sessions', 'connection', 'locale']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries')
|
||||
ctx.plugin(CommandService)
|
||||
// Conditional mount, same seam as ui-slash's MenuView registration:
|
||||
// 'conversation.input.overlay' is declared by the conversation composer
|
||||
// entry, and the conversation service's presence is the registration-safe
|
||||
// signal that the declaration is on the ledger.
|
||||
ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => {
|
||||
ctx.inject(['slots', 'command', 'sessions'], (scope: ClientContext) => {
|
||||
const command = scope.command
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => scope.slots.register({
|
||||
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
|
||||
name: 'conversation.input.overlay',
|
||||
id: 'command-popup',
|
||||
order: 1,
|
||||
@@ -72,6 +68,6 @@ export function apply(ctx: ClientContext): void {
|
||||
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
|
||||
return { popup: command.popupFor(actx) }
|
||||
},
|
||||
}, PopupSelectView), 'ui-command: popupSelect overlay registration')
|
||||
}, PopupSelectView))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
* ui-command browser half on a real cordis Context with fake slash/slots
|
||||
* faces and real session scopes: the plugin body mounts CommandService as
|
||||
* `command`, the popupSelect shell registers into conversation.input.overlay
|
||||
* once the conversation seam is up with a per-session inject (sessionId →
|
||||
* through slot declaration injection with a per-session inject (sessionId →
|
||||
* scope → popupFor; unknown id fails loud), both fold up on fiber disposal
|
||||
* (HMR safety), and the service satisfies the frozen CommandServiceContract.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandServiceContract } from '../src/client/contract.ts'
|
||||
@@ -21,7 +21,6 @@ const sid = (k: string): SessionId => k as SessionId
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const sources = new Map<string, SlashSource>()
|
||||
const overlays = new Map<string, { inject: unknown }>()
|
||||
ctx.provide('slash', {
|
||||
registerSource(src: SlashSource) {
|
||||
sources.set(`${src.trigger} ${src.name}`, src)
|
||||
@@ -34,14 +33,10 @@ async function bench() {
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } })
|
||||
ctx.provide('slots', {
|
||||
register(options: { name: string; id?: string; inject?: unknown }) {
|
||||
const key = `${options.name}#${options.id ?? ''}`
|
||||
overlays.set(key, { inject: options.inject })
|
||||
return () => { overlays.delete(key) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
@@ -50,7 +45,7 @@ async function bench() {
|
||||
scopes.set(sid(key), handle.ctx)
|
||||
return handle
|
||||
}
|
||||
return { ctx, fiber, sources, overlays, mint }
|
||||
return { ctx, fiber, sources, slots: ctx.slots, mint }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
@@ -59,7 +54,7 @@ describe('apply', () => {
|
||||
})
|
||||
|
||||
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
|
||||
const { ctx, fiber, sources, overlays } = await bench()
|
||||
const { ctx, fiber, sources, slots } = await bench()
|
||||
const command = ctx.get('command')
|
||||
expect(command).toBeInstanceOf(CommandService)
|
||||
// Frozen-contract conformance (compile-time check rides the assignment).
|
||||
@@ -67,18 +62,18 @@ describe('apply', () => {
|
||||
expect(typeof contract.register).toBe('function')
|
||||
expect(typeof contract.popupFor).toBe('function')
|
||||
expect([...sources.keys()]).toEqual(['/ command'])
|
||||
expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup'])
|
||||
expect(slots.entries('conversation.input.overlay').map(entry => entry.options.id)).toEqual(['command-popup'])
|
||||
await fiber.dispose()
|
||||
expect(sources.size).toBe(0)
|
||||
expect(overlays.size).toBe(0)
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
|
||||
const { ctx, overlays, mint } = await bench()
|
||||
const { ctx, slots, mint } = await bench()
|
||||
const command = ctx.get('command') as CommandService
|
||||
const scope = mint('s1')
|
||||
const entry = overlays.get('conversation.input.overlay#command-popup')!
|
||||
const injectEntry = entry.inject as (sessionId: SessionId) => PopupSelectInjected
|
||||
const entry = slots.entries('conversation.input.overlay')[0]!
|
||||
const injectEntry = entry.inject as unknown as (sessionId: SessionId) => PopupSelectInjected
|
||||
expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx))
|
||||
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
})
|
||||
|
||||
@@ -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/ui-conversation/README.md
|
||||
README.md: 4869fa4df929027f031082deb04cc1ab3d18921c
|
||||
README.zh.md: 5c3091efa11b65f43ea5ea3037a60d0aa1bafbda
|
||||
README.md: 873a0053e38d2dfddd81c7c6ff6368b2d39e83b7
|
||||
README.zh.md: e1540a300bce67ab07e3355655ed90e28d9224d6
|
||||
|
||||
@@ -32,9 +32,9 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
|
||||
|
||||
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
|
||||
|
||||
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
|
||||
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
|
||||
|
||||
工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot;其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam。Trajectory 与 waterfall(瀑布式事件)工具视图 slot 共享此形状并使用各自的渲染点;RendersCheck 会拒绝没有任何渲染方的声明。
|
||||
工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot;其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 将其与 Session 标准工具包组合。注册方是只依赖 slot 服务的普通插件:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`。声明本身就是激活与重载依赖;只有调用 `ConversationService` 操作的注册项才需要该服务。Trajectory 与 waterfall(瀑布式事件)工具视图 slot 共享此形状并使用各自的渲染点;RendersCheck 会拒绝没有任何渲染方的声明。
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
@@ -98,20 +98,16 @@ export function apply(ctx: Context): void {
|
||||
const chatStore = createChatStore()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy()
|
||||
|
||||
ctx.effect(() => {
|
||||
const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'composer-enter',
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: (): EnterBehaviorRowInjected => ({
|
||||
hooks: { busyEnter: submissionPolicy.busyEnter },
|
||||
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
|
||||
}),
|
||||
}, EnterBehaviorRow))
|
||||
return () => { row.dispose() }
|
||||
}, 'ui-conversation: Enter behavior settings row')
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'composer-enter',
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: (): EnterBehaviorRowInjected => ({
|
||||
hooks: { busyEnter: submissionPolicy.busyEnter },
|
||||
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
|
||||
}),
|
||||
}, EnterBehaviorRow))
|
||||
|
||||
// Chat semantic reader positions by session, surviving view switches and
|
||||
// width reflow when the tab ring remounts the view. Deliberately not
|
||||
@@ -338,13 +334,11 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
// Mounted AFTER the chat entry register above — construction guarantee for
|
||||
// toolview registrants using `inject: ['conversation']` as their load-order
|
||||
// seam: the service being present implies the chat entry (and with it the
|
||||
// 'conversation.chat.toolview' declaration) is on the ledger.
|
||||
// Presentation registrants depend directly on their slot declarations;
|
||||
// this service remains only where conversation actions are required.
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
|
||||
// The bash sample rides that exact seam, in third-party posture
|
||||
// The bash sample rides the same declaration seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
|
||||
@@ -213,8 +213,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
}
|
||||
|
||||
/**
|
||||
* The dock entry as a plain registrant plugin. The conversation service is the
|
||||
* ordering and action seam; session scopes provide the exact queue owner.
|
||||
* The dock entry as a plain registrant plugin. The conversation service is
|
||||
* the action seam; the slot declaration is its independent lifecycle seam.
|
||||
*/
|
||||
export const queueDockEntry = {
|
||||
name: 'conversation-queue-dock',
|
||||
@@ -224,7 +224,7 @@ export const queueDockEntry = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({
|
||||
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'queue',
|
||||
order: 20,
|
||||
@@ -239,6 +239,6 @@ export const queueDockEntry = {
|
||||
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
|
||||
}
|
||||
},
|
||||
}, QueueDock)
|
||||
}, QueueDock))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -137,19 +137,18 @@ export function TodoDock({ useProjection, t }: TodoDockProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan strip as a plain registrant plugin (QueueDock posture).
|
||||
* `inject: ['conversation']` is the ordering seam: the conversation service
|
||||
* mounts after ui-conversation's slot registrations, so the
|
||||
* 'conversation.input.dock' declaration is on the ledger by then.
|
||||
* The plan strip as a plain registrant plugin (QueueDock posture), following
|
||||
* the input-dock declaration across independent activation and reload.
|
||||
*/
|
||||
export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the plan strip before the goal and queue entries (order 0).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
ctx.slots.inject('conversation.input.dock', () =>
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,19 +83,19 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr
|
||||
}
|
||||
|
||||
/**
|
||||
* The ask-question row as a plain registrant plugin, riding the same
|
||||
* load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
|
||||
* chat entry (and with it the 'conversation.chat.toolview' declaration) is on
|
||||
* the ledger.
|
||||
* The ask-question row as a plain registrant plugin following the chat
|
||||
* toolview declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const askQuestionToolview = {
|
||||
name: 'ask-question-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the ask-question row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS }, AskQuestionRow)
|
||||
ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({
|
||||
name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS,
|
||||
}, AskQuestionRow))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -166,19 +166,18 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample as a plain registrant plugin. `inject` carries the load-order
|
||||
* seam: requiring the conversation service guarantees the chat entry (and
|
||||
* with it the 'conversation.chat.toolview' declaration) is registered —
|
||||
* ui-conversation's apply mounts the service after the chat entry.
|
||||
* The sample as a plain registrant plugin. Slot injection follows the chat
|
||||
* toolview declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const bashToolviewSample = {
|
||||
name: 'bash-toolview-sample',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the bash row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow)
|
||||
ctx.slots.inject('conversation.chat.toolview', () =>
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,21 +53,21 @@ export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }:
|
||||
}
|
||||
|
||||
/**
|
||||
* The file-mutation rows as a plain registrant plugin. `inject` carries the
|
||||
* load-order seam: requiring the conversation service guarantees the chat entry
|
||||
* (and with it the 'conversation.chat.toolview' declaration) is registered —
|
||||
* ui-conversation's apply mounts the service after the chat entry.
|
||||
* The file-mutation rows as a plain registrant plugin following the chat
|
||||
* toolview declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const fileMutationToolview = {
|
||||
name: 'file-mutation-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the file-mutation row into the chat view's keyed toolview hole
|
||||
* under both mutation tool names.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
|
||||
ctx.slots.inject('conversation.chat.toolview', function* () {
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,19 +48,18 @@ export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowP
|
||||
}
|
||||
|
||||
/**
|
||||
* The read row as a plain registrant plugin. `inject` carries the load-order
|
||||
* seam: requiring the conversation service guarantees the chat entry (and with
|
||||
* it the 'conversation.chat.toolview' declaration) is registered —
|
||||
* ui-conversation's apply mounts the service after the chat entry.
|
||||
* The read row as a plain registrant plugin following the chat toolview
|
||||
* declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const readToolview = {
|
||||
name: 'read-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the read row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow)
|
||||
ctx.slots.inject('conversation.chat.toolview', () =>
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -61,22 +61,22 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The search toolview as a plain registrant plugin. `inject` carries the
|
||||
* load-order seam: requiring the conversation service guarantees the chat entry
|
||||
* (and with it the 'conversation.chat.toolview' declaration) is registered.
|
||||
* The one component registers under both keys, since `grep` and `glob` are the
|
||||
* same visual object discriminated only by the result view's `kind`.
|
||||
* The search toolview follows the chat toolview declaration across activation
|
||||
* and reload. One component registers under both keys because `grep` and
|
||||
* `glob` are the same visual object discriminated by the result view's `kind`.
|
||||
*/
|
||||
export const searchToolview = {
|
||||
name: 'search-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the search row into the chat view's keyed toolview hole under both
|
||||
* the `grep` and `glob` tool names.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
|
||||
ctx.slots.inject('conversation.chat.toolview', function* () {
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -71,18 +71,18 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The todo row as a plain registrant plugin, riding the same load-order seam
|
||||
* as the bash sample: `inject: ['conversation']` guarantees the chat entry
|
||||
* (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
|
||||
* The todo row as a plain registrant plugin following the chat toolview
|
||||
* declaration across independent activation and reload lifetimes.
|
||||
*/
|
||||
export const todoToolview = {
|
||||
name: 'todo-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the todo row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
ctx.slots.inject('conversation.chat.toolview', () =>
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,20 +55,20 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The web rows as a plain registrant plugin, riding the same load-order seam as
|
||||
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
|
||||
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
|
||||
* WebRow component registers under both web tool names.
|
||||
* The web rows follow the chat toolview declaration across activation and
|
||||
* reload. One WebRow component registers under both web tool names.
|
||||
*/
|
||||
export const webToolview = {
|
||||
name: 'web-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
/**
|
||||
* Register the web row under both web tool names' keyed toolview holes.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
|
||||
ctx.slots.inject('conversation.chat.toolview', function* () {
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
|
||||
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -124,11 +124,13 @@ describe('AskQuestionRow', () => {
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
it('askQuestionToolview injects the toolview declaration directly', () => {
|
||||
expect(askQuestionToolview.name).toBe('ask-question-toolview')
|
||||
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
askQuestionToolview.apply({ slots: { register } } as never)
|
||||
expect(askQuestionToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
askQuestionToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
|
||||
AskQuestionRow,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
||||
// hole, the slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all strict
|
||||
// session entries, and the bash sample + todo row mount through the
|
||||
// load-order seam as keyed entries. Full-chain rendering belongs to the
|
||||
// session entries, and the bash sample + todo row mount through declaration
|
||||
// injection as keyed entries. Full-chain rendering belongs to the
|
||||
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
|
||||
// stops at the assembly surface.
|
||||
|
||||
@@ -90,10 +90,9 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the tool rows as keyed entries through declaration injection', async () => {
|
||||
const b = await bench()
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first. The
|
||||
// The actual toolview declaration activates every registrant. The
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// one search row registers under both grep and glob; the web rows register
|
||||
// one component under both web tool names.
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
// entryKey (the bash sample lands through its plugin), unregistered tools
|
||||
// fall back to GenericToolCard at the render site, live registration/unload
|
||||
// flips rows in place, duplicate keys fail loud, the inject channel feeds
|
||||
// (sessionId) => I into row components, and a registrant's
|
||||
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
||||
// semantics until the service (and with it the hole declaration) is present.
|
||||
// (sessionId) => I into row components, and a registrant can activate before
|
||||
// the declaration then land through slots.inject when the chat entry appears.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
@@ -191,8 +190,8 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
describe('registrant declaration injection', () => {
|
||||
it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
@@ -200,31 +199,26 @@ describe('registrant load-order seam', () => {
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
// semantics hold it — apply must not run while 'conversation' is absent.
|
||||
// Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
|
||||
// would fail loud on the missing service). (Plain arrow, not vi.fn: mock
|
||||
// functions carry a prototype and trip the fiber's isConstructor branch.)
|
||||
// Third-party posture, mounted BEFORE ui-conversation. Plugin apply runs,
|
||||
// while slots.inject waits for the declaration itself.
|
||||
let applyRuns = 0
|
||||
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
|
||||
applyRuns += 1
|
||||
registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
||||
registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null))
|
||||
}
|
||||
const late = runtime.ctx.plugin({
|
||||
name: 'late-registrant',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
apply: registrantApply,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(applyRuns).toBe(0)
|
||||
|
||||
// Mounting the package resolves the seam: service present ⟹ the chat
|
||||
// entry (and its hole declaration) is already on the ledger, so the
|
||||
// suspended registrant lands without an undeclared-slot throw.
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
await late.await()
|
||||
expect(applyRuns).toBe(1)
|
||||
expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
|
||||
// Mounting the package declares the slot and activates the waiting entry.
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
.toEqual(expect.arrayContaining(['bash', 'late']))
|
||||
await runtime.dispose()
|
||||
|
||||
@@ -274,8 +274,14 @@ describe('fileMutationToolview registration', () => {
|
||||
it('registers one component under both edit and write, and each disposes', () => {
|
||||
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
|
||||
const disposers: (() => void)[] = []
|
||||
let disposeInjection = (): void => {}
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
const active = [...callback()]
|
||||
disposeInjection = () => { for (const dispose of active.reverse()) dispose() }
|
||||
return disposeInjection
|
||||
},
|
||||
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
|
||||
const entry = { key, locale, disposed: false }
|
||||
registered.push(entry)
|
||||
@@ -289,10 +295,9 @@ describe('fileMutationToolview registration', () => {
|
||||
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
|
||||
// Both keys claim the conversation locale seat ToolRow's body copy needs.
|
||||
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
|
||||
// The registrant's inject seam is the load-order contract the row relies on.
|
||||
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(fileMutationToolview.inject).toEqual(['slots'])
|
||||
// Disposal removes each contribution (packages/AGENTS.md registry contract).
|
||||
for (const dispose of disposers) dispose()
|
||||
disposeInjection()
|
||||
expect(registered.every(r => r.disposed)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -375,8 +375,10 @@ describe('QueueDock', () => {
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
queueDockEntry.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
|
||||
@@ -237,11 +237,14 @@ describe('ReadRow keyed toolview', () => {
|
||||
|
||||
it('registers under the read key of the keyed toolview slot', () => {
|
||||
const registered: { name: unknown; key?: unknown }[] = []
|
||||
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
|
||||
const ctx = { slots: {
|
||||
inject: (_name: string, callback: () => () => void) => callback(),
|
||||
register: (options: { name: unknown; key?: unknown }) => { registered.push(options); return () => undefined },
|
||||
} } as unknown as Context
|
||||
readToolview.apply(ctx)
|
||||
// The row composes ToolRow, so it declares its locale namespace at the seat.
|
||||
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
|
||||
expect(readToolview.inject).toContain('conversation')
|
||||
expect(readToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -349,8 +349,13 @@ describe('SearchRow keyed card', () => {
|
||||
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
for (const _dispose of callback()) { /* exhaust transactional setup */ }
|
||||
return () => undefined
|
||||
},
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
return () => undefined
|
||||
},
|
||||
},
|
||||
} as never
|
||||
@@ -361,7 +366,7 @@ describe('SearchRow keyed card', () => {
|
||||
// One component, two keys.
|
||||
expect(registered[0]!.component).toBe(SearchRow)
|
||||
expect(registered[1]!.component).toBe(SearchRow)
|
||||
expect(searchToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(searchToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -111,9 +111,11 @@ describe('TodoDock', () => {
|
||||
|
||||
it('registers before the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(todoDockEntry.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoDockEntry.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
@@ -196,11 +198,13 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('todo_write · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
it('todoToolview injects the toolview declaration directly', () => {
|
||||
expect(todoToolview.name).toBe('todo-toolview')
|
||||
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoToolview.apply({ slots: { register } } as never)
|
||||
expect(todoToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,6 +272,10 @@ describe('web toolview registration', () => {
|
||||
const registered: { key: string; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
for (const _dispose of callback()) { /* exhaust transactional setup */ }
|
||||
return () => undefined
|
||||
},
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
return () => {}
|
||||
@@ -285,7 +289,6 @@ describe('web toolview registration', () => {
|
||||
// One component under both keys, not two thin rows.
|
||||
expect(registered[0]?.component).toBe(WebRow)
|
||||
expect(registered[1]?.component).toBe(WebRow)
|
||||
// The load-order seam the render site depends on.
|
||||
expect(webToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(webToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,52 +53,47 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
const { goals } = (ctx.get('connection') as ConnectionHandle).api
|
||||
|
||||
// Conditional mount: 'conversation.input.dock' is declared by the
|
||||
// conversation entry; the conversation service being up is the
|
||||
// registration-safe signal (the TodoDock/QueueDock seam).
|
||||
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
|
||||
const sessions = scope.sessions
|
||||
const sessions = ctx.sessions
|
||||
|
||||
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
|
||||
const refOf = (sessionId: SessionId): GoalRef | undefined => {
|
||||
const face = sessions.binding(sessionId)?.session.projections.faceOf('goal')
|
||||
const projection = face?.getSnapshot() as GoalProjection | null | undefined
|
||||
if (projection == null) return undefined
|
||||
return { id: projection.goal.id, revision: projection.goal.revision }
|
||||
}
|
||||
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
|
||||
const refOf = (sessionId: SessionId): GoalRef | undefined => {
|
||||
const face = sessions.binding(sessionId)?.session.projections.faceOf('goal')
|
||||
const projection = face?.getSnapshot() as GoalProjection | null | undefined
|
||||
if (projection == null) return undefined
|
||||
return { id: projection.goal.id, revision: projection.goal.revision }
|
||||
}
|
||||
|
||||
const noCurrentGoal: GoalActionResult = {
|
||||
ok: false,
|
||||
error: { code: 'no-current-goal', message: 'no current goal to mutate' },
|
||||
}
|
||||
const noCurrentGoal: GoalActionResult = {
|
||||
ok: false,
|
||||
error: { code: 'no-current-goal', message: 'no current goal to mutate' },
|
||||
}
|
||||
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.edit({ sessionId, ref, objective })).result)
|
||||
},
|
||||
onPause: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.pause({ sessionId, ref })).result)
|
||||
},
|
||||
onResume: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.resume({ sessionId, ref })).result)
|
||||
},
|
||||
onClear: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.clear({ sessionId, ref })).result)
|
||||
},
|
||||
}),
|
||||
}, GoalDock), 'ui-goal: GoalBar dock registration')
|
||||
})
|
||||
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.edit({ sessionId, ref, objective })).result)
|
||||
},
|
||||
onPause: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.pause({ sessionId, ref })).result)
|
||||
},
|
||||
onResume: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.resume({ sessionId, ref })).result)
|
||||
},
|
||||
onClear: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.clear({ sessionId, ref })).result)
|
||||
},
|
||||
}),
|
||||
}, GoalDock))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -45,7 +45,7 @@ function makeProjection(revision = 3): GoalProjection {
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */
|
||||
function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
|
||||
async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; payload: unknown }[] = []
|
||||
function answer<T>(method: string, value: T) {
|
||||
@@ -65,14 +65,10 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi
|
||||
resume: answer('goal.resume', { ref }),
|
||||
clear: answer('goal.clear', { cleared: true as const }),
|
||||
} } })
|
||||
const entries = new Map<string, { id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }>()
|
||||
ctx.provide('slots', {
|
||||
register(reg: { name: string; id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }) {
|
||||
entries.set(reg.name, reg)
|
||||
return () => { entries.delete(reg.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
ctx.provide('sessions', {
|
||||
binding: (id: SessionId) => ({
|
||||
@@ -89,20 +85,28 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
entry: () => entries.get('conversation.input.dock'),
|
||||
entry: () => {
|
||||
const entry = ctx.slots.entries('conversation.input.dock')[0]
|
||||
if (entry === undefined) return undefined
|
||||
return {
|
||||
...entry.options,
|
||||
locale: entry.locale,
|
||||
inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('verbs read the CAS ref from the current projected value at call time', async () => {
|
||||
const b = bench({ projection: makeProjection(5) })
|
||||
const b = await bench({ projection: makeProjection(5) })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
|
||||
@@ -119,7 +123,7 @@ describe('ui-goal browser plugin', () => {
|
||||
|
||||
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
|
||||
for (const projection of [null, undefined]) {
|
||||
const b = bench({ projection })
|
||||
const b = await bench({ projection })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
|
||||
@@ -130,14 +134,14 @@ describe('ui-goal browser plugin', () => {
|
||||
})
|
||||
|
||||
it('maps a settled RPC error onto the inline-render shape', async () => {
|
||||
const b = bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
|
||||
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
|
||||
})
|
||||
|
||||
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
|
||||
const b = bench()
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toBeDefined()
|
||||
await b.fiber.dispose()
|
||||
|
||||
@@ -148,12 +148,10 @@ export function apply(ctx: ClientContext): void {
|
||||
})
|
||||
|
||||
// Entry 2: the composer's named model seat over the SAME directory.
|
||||
// Conditional mount: the seat is declared by the composer-bar entry; the
|
||||
// conversation service's presence is the registration-safe signal.
|
||||
ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => {
|
||||
ctx.inject(['slots', 'models'], (scope: ClientContext) => {
|
||||
const models = scope.models
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => scope.slots.register({
|
||||
scope.slots.inject('conversation.input.model', () => scope.slots.register({
|
||||
name: 'conversation.input.model',
|
||||
locale: NS,
|
||||
inject: (sessionId): ModelSelectInjected => {
|
||||
@@ -170,6 +168,6 @@ export function apply(ctx: ClientContext): void {
|
||||
: Promise.resolve(false),
|
||||
}
|
||||
},
|
||||
}, ModelSelect), 'ui-model: composer model seat registration')
|
||||
}, ModelSelect))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,12 +85,12 @@ async function bench() {
|
||||
locale: string | undefined
|
||||
}>()
|
||||
ctx.provide('slots', {
|
||||
inject(_name: string, callback: () => () => void) { return callback() },
|
||||
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject, locale: options.locale })
|
||||
return () => { seats.delete(options.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const addressed = new Set<SessionId>()
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
|
||||
@@ -47,7 +46,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration goes through declaration-aware deferral.
|
||||
* constrained; registration depends on each slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
@@ -91,29 +90,17 @@ export function apply(ctx: ClientContext): void {
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-models: pushed invalidations')
|
||||
|
||||
ctx.effect(() => {
|
||||
const section = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'models',
|
||||
order: 10,
|
||||
label: () => t('nav'),
|
||||
inject: injected,
|
||||
}, ModelsSection))
|
||||
const onboarding = deferRegistration(
|
||||
ctx.slots,
|
||||
'settings.onboarding',
|
||||
DeepSeekOnboardingDialog,
|
||||
() => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'deepseek-official',
|
||||
order: 0,
|
||||
inject: onboardingInjected,
|
||||
}, DeepSeekOnboardingDialog),
|
||||
)
|
||||
return () => {
|
||||
section.dispose()
|
||||
onboarding.dispose()
|
||||
}
|
||||
}, 'ui-models: settings registrations')
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'models',
|
||||
order: 10,
|
||||
label: () => t('nav'),
|
||||
inject: injected,
|
||||
}, ModelsSection))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'deepseek-official',
|
||||
order: 0,
|
||||
inject: onboardingInjected,
|
||||
}, DeepSeekOnboardingDialog))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Models section registration: declaration-aware deferral, the locale-following label thunk, and HMR recovery. */
|
||||
/** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import { PermissionRow } from './PermissionRow.tsx'
|
||||
import type { PermissionRowInjected } from './PermissionRow.tsx'
|
||||
@@ -133,17 +132,13 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
}, 'ui-permission: settings invalidations')
|
||||
|
||||
ctx.effect(() => {
|
||||
const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'permission',
|
||||
order: -20,
|
||||
locale: 'settings.permission',
|
||||
inject: injected,
|
||||
}, PermissionRow))
|
||||
return () => { row.dispose() }
|
||||
}, 'ui-permission: General settings row')
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'permission',
|
||||
order: -20,
|
||||
locale: 'settings.permission',
|
||||
inject: injected,
|
||||
}, PermissionRow))
|
||||
|
||||
ctx.effect(() => command.decorate({
|
||||
name: 'permission',
|
||||
|
||||
@@ -39,12 +39,8 @@ export interface PlanChipInjected {
|
||||
exitPlanMode: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Required services: the seat's slot registry, the transport, the copy's
|
||||
* locale registry, and the conversation service whose presence guarantees
|
||||
* the seat is declared.
|
||||
*/
|
||||
export const inject = ['slots', 'connection', 'conversation', 'locale']
|
||||
/** Required services: the seat's slot registry, transport, and locale registry. */
|
||||
export const inject = ['slots', 'connection', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the plan chip over the command channel.
|
||||
@@ -53,7 +49,7 @@ export const inject = ['slots', 'connection', 'conversation', 'locale']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries')
|
||||
|
||||
ctx.effect(() => ctx.slots.register({
|
||||
ctx.slots.inject('conversation.input.plan', () => ctx.slots.register({
|
||||
name: 'conversation.input.plan',
|
||||
locale: NS,
|
||||
inject: (sessionId: SessionId): PlanChipInjected => ({
|
||||
@@ -66,5 +62,5 @@ export function apply(ctx: ClientContext): void {
|
||||
return null
|
||||
},
|
||||
}),
|
||||
}, PlanChip), 'ui-plan: composer plan chip registration')
|
||||
}, PlanChip))
|
||||
}
|
||||
|
||||
@@ -28,28 +28,32 @@ async function bench() {
|
||||
const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) =>
|
||||
Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } }))
|
||||
ctx.provide('connection', { api: { commands: { execute } } })
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
return { ctx, slots, execute }
|
||||
}
|
||||
|
||||
describe('ui-plan browser apply', () => {
|
||||
it('declares every service it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'connection', 'conversation', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'connection', 'locale'])
|
||||
})
|
||||
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails loud when conversation did not declare the plan seat', async () => {
|
||||
it('waits until conversation declares the plan seat', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('connection', {})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(0)
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
await Promise.resolve()
|
||||
expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
|
||||
|
||||
@@ -36,13 +36,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'question'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
* edge, not a call dependency: the 'conversation.composer' chain slot is
|
||||
* declared by ui-conversation's apply, and register() into an undeclared
|
||||
* slot throws — service waiting orders this apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation', 'locale']
|
||||
/** Required services: the slot registry and the question composer's copy. */
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
|
||||
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
|
||||
@@ -58,11 +53,8 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
|
||||
|
||||
ctx.effect(
|
||||
() => ctx.slots.register(
|
||||
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
|
||||
QuestionComposer,
|
||||
),
|
||||
'ui-question: composer chain registration',
|
||||
)
|
||||
ctx.slots.inject('conversation.composer', () => ctx.slots.register(
|
||||
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
|
||||
QuestionComposer,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
|
||||
* registered as the `question` entry of the conversation-declared composer
|
||||
* slot with ZERO business face (data and verbs ride the dispatched carrier),
|
||||
* load-order fail-loud, and fiber-teardown unregistration. Component and
|
||||
* declaration-aware activation, and fiber-teardown unregistration. Component and
|
||||
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
|
||||
* no renderer machinery here.
|
||||
*/
|
||||
@@ -22,27 +22,28 @@ async function bench() {
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
// 'conversation' inject is an ordering edge (the declaring plugin provides
|
||||
// it after declaring the chain); the bench declares the chain itself.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
return { ctx, slots }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'conversation', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
})
|
||||
|
||||
it('fails loud when no live entry has declared the composer slot', async () => {
|
||||
it('waits until a live entry declares the composer slot', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
// Satisfy the ordering inject without declaring the chain: apply must
|
||||
// then hit the undeclared-slot throw, not sit waiting on the service.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation.composer" is not declared/)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.composer')).toHaveLength(0)
|
||||
ctx.slots.register(
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
await Promise.resolve()
|
||||
expect(ctx.slots.entries('conversation.composer')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('registers the question entry: routing selector, no inject face', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
|
||||
@@ -50,7 +49,7 @@ const NS = 'settings'
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration goes through declaration-aware deferral.
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
@@ -97,47 +96,34 @@ export function apply(ctx: ClientContext): void {
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-general: metadata invalidations')
|
||||
ctx.effect(() => {
|
||||
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
|
||||
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
|
||||
const action = documentInjected === undefined
|
||||
? undefined
|
||||
: deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.action',
|
||||
id: 'open-document',
|
||||
order: 0,
|
||||
locale: NS,
|
||||
inject: documentInjected,
|
||||
}, SettingsDocumentAction))
|
||||
const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
|
||||
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
|
||||
const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'general',
|
||||
order: 0,
|
||||
label: () => t('general.nav'),
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
}, GeneralSection))
|
||||
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'welcome-notice',
|
||||
order: -100,
|
||||
locale: NS,
|
||||
inject: welcomeInjected,
|
||||
}, WelcomeNotice))
|
||||
return () => {
|
||||
trigger.dispose()
|
||||
header.dispose()
|
||||
action?.dispose()
|
||||
close.dispose()
|
||||
general.dispose()
|
||||
welcome.dispose()
|
||||
}
|
||||
}, 'ui-settings-general: chrome, action, section, and onboarding registrations')
|
||||
ctx.slots.inject('settings.trigger', () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
ctx.slots.inject('settings.header', () =>
|
||||
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
|
||||
if (documentInjected !== undefined) {
|
||||
ctx.slots.inject('settings.action', () => ctx.slots.register({
|
||||
name: 'settings.action',
|
||||
id: 'open-document',
|
||||
order: 0,
|
||||
locale: NS,
|
||||
inject: documentInjected,
|
||||
}, SettingsDocumentAction))
|
||||
}
|
||||
ctx.slots.inject('settings.close', () =>
|
||||
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'general',
|
||||
order: 0,
|
||||
label: () => t('general.nav'),
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
}, GeneralSection))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'welcome-notice',
|
||||
order: -100,
|
||||
locale: NS,
|
||||
inject: welcomeInjected,
|
||||
}, WelcomeNotice))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// read (nav labels may be locale-following thunks; the shell still ships no
|
||||
// copy of its own and takes no hard locale dependency).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
||||
} from './contract/slots.ts'
|
||||
@@ -27,8 +27,8 @@ export type {
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* ui-sidebar's apply, whose activation order relative to this one is NOT
|
||||
* constrained (dshClient.inject edges are informational); registration goes
|
||||
* through declaration-aware deferral.
|
||||
* constrained (dshClient.inject edges are informational); registration
|
||||
* depends on the slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots']
|
||||
|
||||
@@ -96,20 +96,16 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
|
||||
ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: injected,
|
||||
}, SettingsRoot))
|
||||
return () => { deferred.dispose() }
|
||||
}, 'ui-settings: shell registration')
|
||||
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: injected,
|
||||
}, SettingsRoot))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */
|
||||
/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -55,14 +55,10 @@ export const inject = ['sessions', 'locale']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(SlashService)
|
||||
ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-slash: menu dictionaries')
|
||||
// Conditional mount: 'conversation.input.overlay' is declared by the
|
||||
// conversation composer entry, and the conversation service is mounted
|
||||
// after that declaration lands on the ledger — its presence is the
|
||||
// registration-safe signal (same seam as toolview registrants).
|
||||
ctx.inject(['slots', 'conversation', 'slash', 'sessions'], (scope: ClientContext) => {
|
||||
ctx.inject(['slots', 'slash', 'sessions'], (scope: ClientContext) => {
|
||||
const slash = scope.slash
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => scope.slots.register({
|
||||
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
|
||||
name: 'conversation.input.overlay',
|
||||
id: 'slash-menu',
|
||||
order: 0,
|
||||
@@ -79,6 +75,6 @@ export function apply(ctx: ClientContext): void {
|
||||
onDismiss: () => { controller.dismiss() },
|
||||
}
|
||||
},
|
||||
}, MenuView), 'ui-slash: MenuView overlay registration')
|
||||
}, MenuView))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* apply wiring on a real cordis Context + SlotsService: SlashService mounts
|
||||
* as ctx.slash once its sessions dependency is up; the MenuView overlay
|
||||
* registration waits on the conversation seam (ctx.inject scope), lands once
|
||||
* the declarer is up, resolves the per-session controller from the slot's
|
||||
* registration follows the slot declaration, resolves the per-session controller from the slot's
|
||||
* sessionId, and unregisters on fiber teardown.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -25,8 +24,8 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
// Stand-in for the ui-conversation composer entry: declare the overlay
|
||||
// slot, then provide the conversation service (declaration precedes the
|
||||
// service exactly as the real apply orders them).
|
||||
// slot without providing ConversationService, which is not its lifecycle
|
||||
// signal.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
@@ -67,11 +66,7 @@ describe('apply', () => {
|
||||
it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
|
||||
|
||||
ctx.provide('conversation', {})
|
||||
// The inject scope activates asynchronously on the service arrival.
|
||||
await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
|
||||
const entries = slots.entries('conversation.input.overlay')
|
||||
expect(entries[0]!.options.id).toBe('slash-menu')
|
||||
// Copy rides the standard locale seat, not the business face.
|
||||
@@ -100,8 +95,7 @@ describe('apply', () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
ctx.provide('conversation', {})
|
||||
await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
|
||||
|
||||
@@ -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/ui-slots/README.md
|
||||
README.md: ed6f052b3a47e08d693928b6763e32427b829467
|
||||
README.zh.md: e12e4bdad738c70657927b62d4a7bbd46d4b1519
|
||||
README.md: bb489dea0c3848cf3d501dcf095a65fe1cef9ef6
|
||||
README.zh.md: b4a2915b9d85c45ef7c6dccf27761794e21aa65f
|
||||
|
||||
@@ -19,7 +19,7 @@ The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are
|
||||
|
||||
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.
|
||||
|
||||
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
|
||||
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; the runtime uses it for [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection), independently from ordinary entry versions. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ chain-kind slot 会反转键控路由:条目自行提名,而不是由分发
|
||||
|
||||
store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React hook;hook 绑定属于渲染机制这一侧的 seam,只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。
|
||||
|
||||
`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
|
||||
`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与折叠时递增;运行时将其用于 [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection),且与普通条目版本相互独立。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Declaration-aware registration deferral: the shared timing machinery for
|
||||
* registering into a slot whose declaring entry activates in unconstrained
|
||||
* order (dshClient.inject edges never sequence apply). Presence is judged on
|
||||
* the LEDGER, not a local flag — after an HMR collapse re-declares the slot,
|
||||
* the cascade has already removed the entry while the local disposer went
|
||||
* stale, and a flag guard would block the re-registration.
|
||||
*/
|
||||
|
||||
/** Minimal registry face the deferral reads (SlotsService satisfies it). */
|
||||
export interface DeferralRegistry {
|
||||
/** Declared spec lookup (undefined = not declared yet). */
|
||||
spec(name: string): unknown
|
||||
/** Current entries of the slot (component identity is the presence judge). */
|
||||
entries(name: string): readonly { component: unknown }[]
|
||||
/** Subscribe to the slot's ledger changes; returns the unsubscriber. */
|
||||
subscribe(name: string, listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Handle over one deferred registration. */
|
||||
export interface DeferredRegistration {
|
||||
/**
|
||||
* Drop the current registration (stale disposers are harmless no-ops) and
|
||||
* immediately re-attempt — the refresh path for registrants whose options
|
||||
* carry localized text.
|
||||
*/
|
||||
refresh(): void
|
||||
/** Unsubscribe and unregister (idempotent through the slot core). */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Register into `name` as soon as its declaration is on the ledger, and
|
||||
* re-register whenever the declaration reappears after a collapse.
|
||||
* @param registry - the slot registry face.
|
||||
* @param name - target slot name.
|
||||
* @param component - the component whose ledger presence marks "registered".
|
||||
* @param register - performs the actual registration; returns its disposer.
|
||||
* @param onFailure - owns a registration failure that fires from a LATER
|
||||
* ledger flush (a declaration landing after two providers deferred, say):
|
||||
* the deferral first removes its own subscription, then hands the error
|
||||
* over instead of throwing through the flush — the callback's chance to
|
||||
* roll back sibling deferrals and surface the conflict on a loud channel.
|
||||
* Absent, a late failure rethrows out of the flush.
|
||||
* @returns the deferral handle (dispose in the owning effect's disposer).
|
||||
* @throws the immediate registration's failure, after removing the
|
||||
* just-installed subscription — a throwing construction leaves nothing live.
|
||||
*/
|
||||
export function deferRegistration(
|
||||
registry: DeferralRegistry,
|
||||
name: string,
|
||||
component: unknown,
|
||||
register: () => () => void,
|
||||
onFailure?: (error: unknown) => void,
|
||||
): DeferredRegistration {
|
||||
let dispose: (() => void) | undefined
|
||||
const tryRegister = (): void => {
|
||||
if (registry.spec(name) === undefined) return
|
||||
if (registry.entries(name).some(e => e.component === component)) return
|
||||
dispose = register()
|
||||
}
|
||||
const unsubscribe = registry.subscribe(name, () => {
|
||||
try {
|
||||
tryRegister()
|
||||
} catch (error) {
|
||||
unsubscribe()
|
||||
if (onFailure === undefined) throw error
|
||||
onFailure(error)
|
||||
}
|
||||
})
|
||||
try {
|
||||
tryRegister()
|
||||
} catch (error) {
|
||||
// A synchronous registration failure (the declared slot is already
|
||||
// occupied) must not leave the just-installed subscription behind: the
|
||||
// caller receives no handle to dispose it through.
|
||||
unsubscribe()
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
refresh() {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
tryRegister()
|
||||
},
|
||||
dispose() {
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer ONE occupant into several holes as a unit. Construction that throws
|
||||
* partway (a declared hole already occupied registers synchronously) rolls
|
||||
* every earlier deferral back before rethrowing; a failure surfacing from a
|
||||
* LATER ledger flush (holes declared after rival providers activated) rolls
|
||||
* the whole group back the same way and re-raises the wrapped error on the
|
||||
* global channel the boot's fail-loud handler owns — never a throw through
|
||||
* the slot flush, never partial occupancy from the group's owner.
|
||||
* @param registry - the slot registry face.
|
||||
* @param names - the target holes (one registration per name).
|
||||
* @param component - the occupant whose ledger presence marks "registered".
|
||||
* @param register - performs one hole's registration; returns its disposer.
|
||||
* @returns the group handle (dispose in the owning effect's disposer).
|
||||
* @throws the immediate registration's failure, after rolling the group back.
|
||||
*/
|
||||
export function deferGroupRegistration<K extends string>(
|
||||
registry: DeferralRegistry,
|
||||
names: readonly K[],
|
||||
component: unknown,
|
||||
register: (name: K) => () => void,
|
||||
): { dispose: () => void } {
|
||||
const deferred: DeferredRegistration[] = []
|
||||
const lateFailure = (error: unknown): void => {
|
||||
for (const entry of deferred) entry.dispose()
|
||||
queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) })
|
||||
}
|
||||
try {
|
||||
for (const name of names) {
|
||||
deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure))
|
||||
}
|
||||
} catch (error) {
|
||||
for (const entry of deferred) entry.dispose()
|
||||
throw error
|
||||
}
|
||||
return { dispose: () => { for (const entry of deferred) entry.dispose() } }
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec
|
||||
|
||||
export * from './store.ts'
|
||||
export * from './renderer.ts'
|
||||
export * from './deferred.ts'
|
||||
|
||||
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
|
||||
export interface SlotMap {}
|
||||
@@ -457,9 +456,12 @@ interface SlotRecord {
|
||||
spec: SlotSpec<SlotEntryDef> | undefined
|
||||
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
|
||||
declaredBy: string | undefined
|
||||
/** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
|
||||
declarationEpoch: number
|
||||
entries: readonly StoredEntry[]
|
||||
version: number
|
||||
listeners: Set<() => void>
|
||||
declarationListeners: Set<() => void>
|
||||
}
|
||||
|
||||
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
|
||||
@@ -473,8 +475,10 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
|
||||
*
|
||||
* Change propagation contract: versions bump and {@link SlotCore.onMutate}
|
||||
* fires synchronously per mutation (registry state is consistent when they
|
||||
* fire); {@link SlotCore.subscribe} notifications batch per microtask, so N
|
||||
* same-tick mutations produce one notification per touched key.
|
||||
* fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each
|
||||
* declaration lifetime boundary; {@link SlotCore.subscribe} notifications
|
||||
* batch per microtask, so N same-tick mutations produce one notification per
|
||||
* touched key.
|
||||
*/
|
||||
export class SlotCore {
|
||||
private records = new Map<string, SlotRecord>()
|
||||
@@ -491,6 +495,7 @@ export class SlotCore {
|
||||
const root = this.record('root')
|
||||
root.spec = { kind: 'single', scope: 'root' }
|
||||
root.declaredBy = '(built-in)'
|
||||
root.declarationEpoch = 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -631,12 +636,22 @@ export class SlotCore {
|
||||
rec.entries = next
|
||||
this.markDirty(options.name, rec)
|
||||
if (options.children) {
|
||||
const declarations: [key: string, record: SlotRecord][] = []
|
||||
for (const [childKey, childSpec] of Object.entries(options.children)) {
|
||||
const childRec = this.record(childKey)
|
||||
childRec.spec = childSpec
|
||||
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
|
||||
childRec.declarationEpoch += 1
|
||||
declarations.push([childKey, childRec])
|
||||
}
|
||||
// Synchronous listeners may register into or try to redeclare a sibling;
|
||||
// publish only after the whole children table owns its declarations.
|
||||
for (const [childKey, childRec] of declarations) {
|
||||
this.markDirty(childKey, childRec)
|
||||
}
|
||||
for (const [, childRec] of declarations) {
|
||||
this.notifyDeclaration(childRec)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (!rec.entries.includes(entry)) return
|
||||
@@ -692,6 +707,16 @@ export class SlotCore {
|
||||
return this.records.get(key)?.spec
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the declaration lifetime of a key. Entry additions and removals do
|
||||
* not change it; declaration creation and collapse each advance it.
|
||||
* @param key - slot key.
|
||||
* @returns monotonic epoch (0 before the first declaration).
|
||||
*/
|
||||
declarationEpoch(key: string): number {
|
||||
return this.records.get(key)?.declarationEpoch ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to registration changes for a key (microtask-batched).
|
||||
* Subscribing ahead of declaration is allowed; the declaration notifies.
|
||||
@@ -705,6 +730,22 @@ export class SlotCore {
|
||||
return () => { rec.listeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to declaration lifetime boundaries for a key. Notifications
|
||||
* are synchronous so declaration teardown finishes before a subsequent
|
||||
* same-tick registration can observe stale resources. Ordinary entry
|
||||
* mutations do not notify this surface. A children table commits every
|
||||
* sibling declaration before its first notification.
|
||||
* @param key - slot key.
|
||||
* @param fn - declaration or collapse callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribeDeclaration(key: string, fn: () => void): () => void {
|
||||
const rec = this.record(key)
|
||||
rec.declarationListeners.add(fn)
|
||||
return () => { rec.declarationListeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic version for a key, bumped synchronously per mutation so a
|
||||
* uSES getSnapshot read is never stale when its batched notification lands.
|
||||
@@ -746,8 +787,10 @@ export class SlotCore {
|
||||
const doomed = childRec.entries
|
||||
childRec.spec = undefined
|
||||
childRec.declaredBy = undefined
|
||||
childRec.declarationEpoch += 1
|
||||
childRec.entries = NO_ENTRIES
|
||||
this.markDirty(childKey, childRec)
|
||||
this.notifyDeclaration(childRec)
|
||||
for (const dead of doomed) this.releaseEntry(dead)
|
||||
}
|
||||
}
|
||||
@@ -755,7 +798,15 @@ export class SlotCore {
|
||||
private record(key: string): SlotRecord {
|
||||
let rec = this.records.get(key)
|
||||
if (!rec) {
|
||||
rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
|
||||
rec = {
|
||||
spec: undefined,
|
||||
declaredBy: undefined,
|
||||
declarationEpoch: 0,
|
||||
entries: NO_ENTRIES,
|
||||
version: 0,
|
||||
listeners: new Set(),
|
||||
declarationListeners: new Set(),
|
||||
}
|
||||
this.records.set(key, rec)
|
||||
}
|
||||
return rec
|
||||
@@ -771,6 +822,10 @@ export class SlotCore {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyDeclaration(rec: SlotRecord): void {
|
||||
for (const fn of [...rec.declarationListeners]) fn()
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
// Reset before iterating so a mutation from inside a listener re-schedules.
|
||||
this.flushScheduled = false
|
||||
|
||||
@@ -231,6 +231,22 @@ describe('store scope pinning', () => {
|
||||
})
|
||||
|
||||
describe('subscription surface', () => {
|
||||
it('tracks declaration epochs separately from ordinary entry mutations', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.declarationEpoch('root')).toBe(1)
|
||||
expect(core.declarationEpoch('test.list')).toBe(0)
|
||||
const disposeFrame = mountFrame(core)
|
||||
const declared = core.declarationEpoch('test.list')
|
||||
expect(declared).toBe(1)
|
||||
const disposeEntry = core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
disposeEntry()
|
||||
expect(core.declarationEpoch('test.list')).toBe(declared)
|
||||
disposeFrame()
|
||||
expect(core.declarationEpoch('test.list')).toBe(declared + 1)
|
||||
mountFrame(core)
|
||||
expect(core.declarationEpoch('test.list')).toBe(declared + 2)
|
||||
})
|
||||
|
||||
it('entries() returns a stable cached reference between mutations', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
@@ -267,6 +283,44 @@ describe('subscription surface', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('notifies declaration subscribers synchronously, excluding entries, until unsubscribe', () => {
|
||||
const core = new SlotCore()
|
||||
const fn = vi.fn()
|
||||
const unsubscribe = core.subscribeDeclaration('test.list', fn)
|
||||
const disposeFrame = mountFrame(core)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
core.register({ name: 'test.list', id: 'ordinary' }, Comp)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
disposeFrame()
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
mountFrame(core)
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('commits sibling declarations before notifying declaration subscribers', () => {
|
||||
const core = new SlotCore()
|
||||
let duplicateDeclaration: unknown
|
||||
const unsubscribe = core.subscribeDeclaration('test.single', () => {
|
||||
core.register({ name: 'test.list', id: 'from-listener' }, Comp)
|
||||
try {
|
||||
core.register({
|
||||
name: 'test.single',
|
||||
children: { 'test.list': { kind: 'list', scope: 'root' } },
|
||||
}, Comp as never)
|
||||
} catch (error) {
|
||||
duplicateDeclaration = error
|
||||
}
|
||||
})
|
||||
|
||||
const disposeFrame = mountFrame(core)
|
||||
expect(core.entries('test.list')).toHaveLength(1)
|
||||
expect(String(duplicateDeclaration)).toContain('already declared')
|
||||
unsubscribe()
|
||||
disposeFrame()
|
||||
expect(core.specDynamic('test.list')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// deferRegistration lifecycle: declaration-aware registration, HMR
|
||||
// re-registration, and — the failure contract — no subscription survives a
|
||||
// construction that throws synchronously (an already-occupied single slot).
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Shares the merges declared by core.spec.ts (same program); reuse its keys.
|
||||
const HOLE = 'test.single' as const
|
||||
|
||||
function declared(): SlotCore {
|
||||
const core = new SlotCore()
|
||||
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
|
||||
return core
|
||||
}
|
||||
|
||||
describe('deferRegistration', () => {
|
||||
it('registers immediately under an existing declaration and disposes cleanly', () => {
|
||||
const core = declared()
|
||||
const component = (): null => null
|
||||
const handle = deferRegistration(core, HOLE, component, () =>
|
||||
core.register({ name: HOLE } as never, component as never))
|
||||
expect(core.entries(HOLE)).toHaveLength(1)
|
||||
handle.dispose()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hands a late registration failure to onFailure after unsubscribing itself', async () => {
|
||||
const core = new SlotCore()
|
||||
const component = (): null => null
|
||||
const foreign = (): null => null
|
||||
const failures: unknown[] = []
|
||||
// Nothing is declared yet: the deferral just subscribes and waits.
|
||||
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
|
||||
deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) })
|
||||
// The declaration lands with a foreign occupant racing in first: the
|
||||
// deferral's flush-time attempt fails, unsubscribes itself, and reports
|
||||
// through onFailure instead of throwing out of the flush.
|
||||
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
|
||||
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
|
||||
await Promise.resolve()
|
||||
expect(failures.map(String).join('')).toContain('already has a registration')
|
||||
// Unsubscribed: freeing the hole must not resurrect the loser.
|
||||
disposeForeign()
|
||||
await Promise.resolve()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops its subscription when the immediate registration throws', async () => {
|
||||
const core = declared()
|
||||
const foreign = (): null => null
|
||||
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
|
||||
const component = (): null => null
|
||||
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
|
||||
// The single hole is occupied: the immediate attempt throws out of the
|
||||
// constructor, and the caller never receives a handle to dispose.
|
||||
expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/)
|
||||
expect(register).toHaveBeenCalledOnce()
|
||||
// The subscription rolled back with it: freeing the hole flushes a
|
||||
// notification that must not resurrect the failed registration.
|
||||
disposeForeign()
|
||||
await Promise.resolve()
|
||||
expect(register).toHaveBeenCalledOnce()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deferGroupRegistration', () => {
|
||||
const HOLES = ['test.single', 'test.grandchild'] as const
|
||||
|
||||
function declaredPair(): SlotCore {
|
||||
const core = new SlotCore()
|
||||
core.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, (() => null) as never)
|
||||
return core
|
||||
}
|
||||
|
||||
it('registers the whole group and disposes it as a unit', () => {
|
||||
const core = declaredPair()
|
||||
const component = (): null => null
|
||||
const group = deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))
|
||||
for (const name of HOLES) expect(core.entries(name)).toHaveLength(1)
|
||||
group.dispose()
|
||||
for (const name of HOLES) expect(core.entries(name)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls the group back when construction fails partway', () => {
|
||||
const core = declaredPair()
|
||||
const component = (): null => null
|
||||
core.register({ name: HOLES[1] } as never, (() => null) as never)
|
||||
expect(() => deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))).toThrow(/already has a registration/)
|
||||
// The first hole's registration and subscription rolled back with it.
|
||||
expect(core.entries(HOLES[0])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls the group back and re-raises loudly on a late conflict', async () => {
|
||||
const core = new SlotCore()
|
||||
const component = (): null => null
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (reason: unknown): void => { failures.push(reason) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const group = deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))
|
||||
// Declaration lands with a rival racing in ahead of the flush.
|
||||
core.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, (() => null) as never)
|
||||
core.register({ name: HOLES[0] } as never, (() => null) as never)
|
||||
core.register({ name: HOLES[1] } as never, (() => null) as never)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(failures.map(String).join('')).toContain('already has a registration')
|
||||
// No partial occupancy from the group's owner survives.
|
||||
for (const name of HOLES) {
|
||||
expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0)
|
||||
}
|
||||
group.dispose()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@ export type {
|
||||
} from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
/** Required services for references, conversation slots, and session navigation. */
|
||||
export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale']
|
||||
export const inject = ['slash', 'sessions', 'slots', 'locale']
|
||||
|
||||
/** Claim the composer for one-shot history or an unavailable continuation owner. */
|
||||
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
|
||||
@@ -103,7 +103,8 @@ export function apply(ctx: ClientContext): void {
|
||||
sessions.setSubagentCatalogOpen(parentSessionId, open)
|
||||
},
|
||||
})
|
||||
ctx.effect(
|
||||
ctx.slots.inject(
|
||||
'conversation.session.header.actions',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'subagent-catalog',
|
||||
@@ -111,15 +112,14 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
inject: catalogActions,
|
||||
}, SubagentCatalogAction),
|
||||
'ui-subagent: lazy descendant catalog action',
|
||||
)
|
||||
ctx.effect(
|
||||
ctx.slots.inject(
|
||||
'conversation.composer',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.composer',
|
||||
priority: -10,
|
||||
locale: NS,
|
||||
select: selectReadOnlySubagent,
|
||||
}, SubagentReadOnlyComposer),
|
||||
'ui-subagent: read-only addressed composer',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ async function provideSlotFaces(ctx: Context): Promise<void> {
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('conversation', {})
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
|
||||
@@ -113,7 +112,7 @@ const req = (query: string) =>
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale'])
|
||||
expect(inject).toEqual(['slash', 'sessions', 'slots', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* section — the theme feature owns its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -254,16 +254,12 @@ export function apply(ctx: ClientContext): void {
|
||||
setTheme: (id) => { theme.setTheme(id) },
|
||||
}
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'appearance',
|
||||
order: 10,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, AppearanceRow))
|
||||
return () => { deferred.dispose() }
|
||||
}, 'ui-theme: appearance settings row registration')
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'appearance',
|
||||
order: 10,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, AppearanceRow))
|
||||
}
|
||||
|
||||
@@ -10,14 +10,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
* edge, not a call dependency: the 'conversation.view' slot is declared by
|
||||
* ui-conversation's apply (which then provides the service), and register()
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
/** Required services: the conversation view slot and independent history source. */
|
||||
export const inject = ['slots', 'sessionHistory']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -26,7 +20,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const duration = createTrajectoryDurationStore()
|
||||
ctx.slots.register({
|
||||
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
@@ -40,5 +34,5 @@ export function apply(ctx: Context): void {
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
}
|
||||
},
|
||||
}, TrajectoryView)
|
||||
}, TrajectoryView))
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory'])
|
||||
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||
@@ -73,10 +73,8 @@ describe('tsdown client artifact', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin injects 'conversation' as an ordering edge and
|
||||
// 'sessionHistory' for its per-session history source; this bench
|
||||
// supplies both.
|
||||
ctx.provide('conversation', {})
|
||||
// The plugin reads sessionHistory for its per-session history source;
|
||||
// slot availability is tracked by slots.inject.
|
||||
ctx.provide('sessionHistory', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
|
||||
@@ -168,9 +168,6 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessionHistory', { source: () => history })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
|
||||
@@ -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/ui-workspace/README.md
|
||||
README.md: 3107793233d18c65b12a9a91a5be85d22acc733a
|
||||
README.zh.md: 98912ac079a13fa62d5829d7d2469ff63c3da5bb
|
||||
README.md: bd7313b560e76378e4fff274c99bb976819aebae
|
||||
README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2
|
||||
|
||||
@@ -14,7 +14,7 @@ The Session row's Fork action forks at the source's last completed turn, increme
|
||||
|
||||
Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator; ordinary rows repeat the localized status in their hover card, and both ordinary and search-result rows carry the same text as a visually hidden label for assistive technology. Running uses the blue indicator and its hidden label; an idle row leaves the reserved status slot empty.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored.
|
||||
|
||||
The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,
|
||||
|
||||
Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器;普通行的悬浮卡片重复显示本地化状态,普通行和搜索结果行则都以相同文本提供面向辅助技术的视觉隐藏标签。运行状态使用蓝色指示器及其隐藏标签;空闲行会保留空的状态槽位。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。
|
||||
|
||||
共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* client half (see the contract module doc). Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
@@ -40,8 +39,8 @@ const NS = 'workspace'
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
* to this one is NOT constrained: dshClient.inject edges are informational
|
||||
* (loading/prefetch metadata, never apply sequencing) and neither owner
|
||||
* provides a waitable service. apply therefore registers via
|
||||
* declaration-aware deferral instead of assuming order.
|
||||
* provides a waitable service. apply therefore depends on each slot
|
||||
* declaration through `slots.inject()` instead of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'sessions', 'workspaces', 'locale']
|
||||
|
||||
@@ -103,36 +102,25 @@ export function apply(ctx: ClientContext): void {
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
hooks: { directoryFlow: pickerFlowSource },
|
||||
})
|
||||
// Declaration-aware registration (deferRegistration): each owner's
|
||||
// declaring apply may activate after this one, and a register into an
|
||||
// undeclared slot throws; the deferral also re-registers after an HMR
|
||||
// collapse re-declares the slot. Each registration declares its own
|
||||
// directory-flow child hole in the same call (declaration = render
|
||||
// authorization, one table).
|
||||
ctx.effect(() => {
|
||||
const deferred = [
|
||||
deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: 'sidebar.workspaces',
|
||||
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
store: createWorkspaceViewStore(),
|
||||
inject: browserInjected,
|
||||
locale: NS,
|
||||
},
|
||||
WorkspaceBrowser,
|
||||
)),
|
||||
deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: 'conversation.hero.workspace',
|
||||
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
inject: pickerInjected,
|
||||
locale: NS,
|
||||
},
|
||||
WorkspacePicker,
|
||||
)),
|
||||
]
|
||||
return () => { for (const entry of deferred) entry.dispose() }
|
||||
}, 'ui-workspace: browser + picker registrations')
|
||||
// Each registration declares its directory-flow child in the same call;
|
||||
// slot injection follows both the owner and declaration HMR lifetimes.
|
||||
ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register(
|
||||
{
|
||||
name: 'sidebar.workspaces',
|
||||
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
store: createWorkspaceViewStore(),
|
||||
inject: browserInjected,
|
||||
locale: NS,
|
||||
},
|
||||
WorkspaceBrowser,
|
||||
))
|
||||
ctx.slots.inject('conversation.hero.workspace', () => ctx.slots.register(
|
||||
{
|
||||
name: 'conversation.hero.workspace',
|
||||
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
inject: pickerInjected,
|
||||
locale: NS,
|
||||
},
|
||||
WorkspacePicker,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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/host/directory-picker-browse/README.md
|
||||
README.md: bd28be4119a542eda8c8ee2e53aa860f9c02c17d
|
||||
README.zh.md: 03306f2fd1c54f9c3c5dbc808920abce944141e5
|
||||
README.md: 11ddece6f68a752f8afc0392029c752013389d4a
|
||||
README.zh.md: c73ddeb52264bf7e2d2b9a74f0a7da9194e27c16
|
||||
|
||||
@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
|
||||
|
||||
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即「不可进入」),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空白段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* cordis.yml row; no client code branches on a capability kind. The dialog's
|
||||
* copy is locale-registered here — the flow package owns its own strings.
|
||||
*/
|
||||
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -22,8 +21,8 @@ export const inject = ['slots', 'workspaces', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dialog's dictionaries and the browse flow
|
||||
* into both directory-flow holes (declaration-aware deferral — the declaring
|
||||
* ui-workspace entries may activate later, and an HMR collapse re-declares).
|
||||
* into both directory-flow holes through `slots.inject()` because the
|
||||
* ui-workspace entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -78,16 +77,16 @@ export function apply(ctx: ClientContext): void {
|
||||
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
|
||||
t: ctx.locale.bind(LOCALE_NS),
|
||||
})
|
||||
ctx.effect(() => {
|
||||
// One occupant, both holes, as a unit: construction or late conflicts
|
||||
// (holes declared after rival providers activated) roll the whole pair
|
||||
// back and fail loud — semantics owned by deferGroupRegistration.
|
||||
const group = deferGroupRegistration(
|
||||
ctx.slots,
|
||||
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
|
||||
BrowseDirectoryFlow,
|
||||
name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow),
|
||||
)
|
||||
return () => { group.dispose() }
|
||||
}, 'directory-picker-browse: flow registrations')
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@ describe('directory-picker-browse client half', () => {
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back the first deferral when the second hole is already occupied', async () => {
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the first deferral installed its subscription.
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
@@ -98,19 +98,21 @@ describe('directory-picker-browse client half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// This provider activates BEFORE any hole exists: both deferrals wait.
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
// A rival occupies both holes ahead of the pending microtask flush.
|
||||
b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
|
||||
@@ -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/host/directory-picker-native/README.md
|
||||
README.md: 0ff5524791094095550542de9bb525a4a633f5b5
|
||||
README.zh.md: 19915340c14249ae07d56001911165841dd550d2
|
||||
README.md: 4dd0c79d080fe097d063cfd2200e30aafc2d2d42
|
||||
README.zh.md: 8ebf2a6e978ad042e522e7e2831a508501d3eca5
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* both sides of the native interaction with one cordis.yml row; no client
|
||||
* code branches on a capability kind.
|
||||
*/
|
||||
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -20,22 +19,22 @@ export const inject = ['slots', 'workspaces']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the renderless native flow into both
|
||||
* directory-flow holes (declaration-aware deferral — the declaring
|
||||
* ui-workspace entries may activate later, and an HMR collapse re-declares).
|
||||
* directory-flow holes through `slots.inject()` because the ui-workspace
|
||||
* entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
|
||||
ctx.effect(() => {
|
||||
// One occupant, both holes, as a unit: construction or late conflicts
|
||||
// (holes declared after rival providers activated) roll the whole pair
|
||||
// back and fail loud — semantics owned by deferGroupRegistration.
|
||||
const group = deferGroupRegistration(
|
||||
ctx.slots,
|
||||
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
|
||||
NativeDirectoryFlow,
|
||||
name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow),
|
||||
)
|
||||
return () => { group.dispose() }
|
||||
}, 'directory-picker-native: flow registrations')
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -56,7 +56,16 @@ describe('directory-picker-native client half', () => {
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
||||
it('fails loudly instead of deduplicating a duplicate package row', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const duplicate = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
@@ -64,15 +73,14 @@ describe('directory-picker-native client half', () => {
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// This provider activates BEFORE any hole exists: both deferrals wait.
|
||||
// (Duplicate rows of the SAME package converge silently — the deferral
|
||||
// skips a hole its own component already occupies; the conflict needs
|
||||
// a rival provider.)
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
// A rival occupies both holes ahead of the pending microtask flush.
|
||||
b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
@@ -97,11 +105,11 @@ describe('directory-picker-native client half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the first deferral when the second hole is already occupied', async () => {
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the first deferral installed its subscription.
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
|
||||
Reference in New Issue
Block a user