前一篇已经把数据流推进到 ConversationViewNode。此时 Runtime 已经完成了历史恢复、实时 Event 合并和业务 Projection,React 面对的是一组稳定的 View Node。
剩下的问题是:谁决定这些 Node 由哪个组件渲染?
一个早期 Agent UI 往往会把判断集中在 ChatView:
switch (node.kind) {
case 'message':
return <Message />
case 'tool-call':
return <ToolCall />
case 'approval':
return <Approval />
case 'plan':
return <Plan />
case 'subagent':
return <SubAgent />
}
这种结构在 Feature 较少时很直接。随着 Harness 插件数量增加,它会逐渐成为所有 UI 功能的中心依赖:增加一个 Workflow 插件需要修改 ChatView,增加一种 Tool Card 需要修改 ToolCall,增加一个新的 Details Panel 又需要修改 Layout。
DeepSeek Harness 当前采用 Slot 系统解决这个问题。其核心思想可以概括为:页面结构由 Slot Owner 声明,Feature Plugin 向已有 Slot 注册实现,React Tree 是当前 Plugin Graph 在当前 Runtime State 下的一次投影。
页面首先是一棵 Slot Tree
DSH Web Shell 自身只渲染一个内建 Slot:
root
ui-layout 插件把 AppFrame 注册到 root,并在同一次 registration 中声明四个 child slots:
root
└── AppFrame
├── sidebar
├── conversation
├── details
└── shell.overlay
当前源码的注册关系是:[1]
ctx.slots.register({
name: 'root',
children: {
sidebar: { kind: 'single', scope: 'root' },
conversation: { kind: 'single', scope: 'session-maybe' },
details: { kind: 'single', scope: 'session' },
'shell.overlay': { kind: 'list', scope: 'root' },
},
store: createLayoutStore,
inject: ...,
}, AppFrame)
这段代码同时完成四件事:
贡献 AppFrame
声明 child slot
声明 AppFrame store
声明业务 inject face
Slot 的声明因此不是单独维护的一份静态 Schema。声明发生在拥有该布局位置的组件 registration 上。
Slot Owner 决定几何结构
AppFrame 本身只通过 Props 获得 renderSlot(),然后在真正拥有布局的位置调用它:[2]
<div className={sidebarCol}>
{renderSlot('sidebar', {
collapsed: sidebarCollapsed,
width: cols.sidebar,
})}
</div>
<CenterColumn>
{renderSlot('conversation', {})}
</CenterColumn>
<DetailsColumn>
{renderSlot('details', {})}
</DetailsColumn>
这里形成一个清晰的所有权原则:
声明 Slot 的组件拥有该位置的布局与渲染权限。
Sidebar Plugin 可以决定 sidebar 里面显示什么,但不能决定 Sidebar 在三栏布局中的宽度。宽度属于 AppFrame,因此作为 owner props 从 renderSlot() 调用点传入。
这避免了插件系统常见的一类问题:Feature Plugin 既贡献内容,又通过全局选择器或 DOM 查询修改宿主布局。
关系变成:
Layout Owner
负责位置、尺寸、出现位置
│
│ owner props
▼
Slot Registrant
负责这个位置里的业务内容
children 同时表示声明与授权
DSH 的 Slot 设计还有一个较强的约束:组件只能渲染自己 registration 中声明的 child slots。[3]
例如 AppFrame 声明:
sidebar
conversation
details
shell.overlay
因此 Renderer 才会向 AppFrame 的 Props 注入对应的 renderSlot() 能力。
如果组件持有了旧的 renderSlot closure,而其 registration 已被卸载,Renderer 会抛出 StaleAuthorizationError;如果组件尝试渲染自己没有声明的 Slot,则抛出 SlotOwnershipError。[4]
这个设计把 UI 生命周期和插件生命周期连接了起来:
Plugin Registration exists
↓
Child Slot Declaration exists
↓
renderSlot authorization exists
↓
Component can compose descendants
插件卸载时:
Registration removed
↓
Child Slot declaration removed
↓
Descendant contributions collapse
↓
retained renderSlot binding becomes stale
这与第三单元讲的 Cordis Effect 生命周期是同一类问题,只是这里作用在 UI Composition Graph 上。
conversation 再声明自己的内部结构
ui-conversation 插件注册到 conversation Slot 后,又继续声明自己的 child slots。[5]
其中包括:
conversation
└── ConversationRoot
├── conversation.session
├── conversation.session.header
├── conversation.composer
├── conversation.composer.bar
├── conversation.input.overlay
├── conversation.input.dock
└── ...
Session body 继续声明:
conversation.session
└── ConversationSession
└── conversation.view
Chat View 最终又拥有 Chat Node 的渲染位置。
于是页面不是在一个 App.tsx 中完整声明出来,而是随着 Plugin Registration 逐层形成:
Shell
↓
root
↓
ui-layout
↓
conversation
↓
ui-conversation
↓
conversation.chat.node
↓
ui-tool / ui-goal / workflow / ...
每一层只知道自己拥有的 child seats。
Tool UI 展示了嵌套插件组合
ui-tool 当前通过:
ctx.slots.inject('conversation.chat.node', () =>
ctx.slots.register({
name: 'conversation.chat.node',
key: 'tool-call',
children: {
'tool.call.toolview': {
kind: 'keyed',
scope: 'session',
},
},
}, ToolCallTree)
)
把 ToolCallTree 注册为 tool-call Chat Node 的 renderer。[6]
这里有两层分发:
Conversation Node
kind = tool-call
↓
conversation.chat.node
↓
ToolCallTree
↓
tool.call.toolview
↓
ReadToolView / BashToolView / WebToolView / ...
ToolCallTree 自己并不知道每一种 Tool 的展示组件。它只把:
callId
toolName
block
cwd
openFile
inspect
组装成 owner props,再按 toolName 调用 keyed Slot:[7]
renderSlot('tool.call.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard ... />,
})
于是新增一个业务 Tool UI 只需要注册:
ctx.slots.inject('tool.call.toolview', () =>
ctx.slots.register({
name: 'tool.call.toolview',
key: 'my-tool',
}, MyToolView)
)
无需修改 ToolCallTree。
为什么还需要 slots.inject()
第三单元已经讨论过 Cordis 的动态依赖。UI Slot 也存在相同的时间问题:Plugin A 可能先加载,但它要注册的 Slot 是 Plugin B 之后才声明的。
如果直接执行:
ctx.slots.register({
name: 'conversation.chat.node',
...
})
而 conversation.chat.node 此时还不存在,注册应该失败,因为系统无法确认这个 Slot 的 kind、scope 和 owner contract。
ctx.slots.inject() 提供的是“依赖 Slot Declaration”的语义。[8]
其 reconcile 逻辑可以压成:
observe declaration epoch
↓
slot absent
→ contribution inactive
slot declared
→ run callback
→ register contribution
slot declaration removed
→ dispose contribution
slot declared again
→ run callback again
这与 Cordis ctx.inject(service, callback) 的依赖激活模型非常接近。
区别在于依赖对象从 Service 变成 Slot Declaration。
因此 UI Plugin 的加载顺序无需严格排列:
ui-tool 先加载
conversation.chat.node 尚未声明
↓
ui-tool 等待
↓
ui-conversation 声明 slot
↓
ToolCallTree registration 生效
Slot Owner 卸载时,Tool Contribution 也自动消失。
Slot 不是单一类型
不同 UI 位置需要不同组合规则。DSH 当前 Slot Core 支持几种主要 kind。[3][4]
single
一个位置只选择一个当前 winner。适合:
root
conversation
details
list
多个 contribution 同时存在并按顺序渲染。适合:
shell.overlay
header.actions
input.dock
keyed
Owner 根据业务 key 选择 renderer。Tool View 使用:
key = toolName
因此:
read → ReadToolView
bash → BashToolView
unknown → fallback
chain
路由方向反过来。Owner 不指定 renderer key,各个 registration 自己提供 select(ownerProps);按 priority 执行,第一个返回非 null 的 entry 被选中。[3][4]
这适合 Approval、Question、Composer takeover 一类“谁当前有资格接管这个位置”的场景。
因此 Slot 不只是“React Component Registry”。它还定义了局部 Composition Policy。
一个组件最终拿到哪些 Props
这是插件化 UI 最容易变得混乱的地方。
如果每个 Plugin 可以随意从 ctx、Global Store、React Context、Service Locator 中取数据,那么 Slot 只解决了组件发现问题,没有解决依赖边界。
DSH 当前把组件 Props 拆成四个 share:[3]
Runtime Share
+ RenderSlot Share
+ Store Share
+ Business Inject Share
再加上 Owner 在 renderSlot() 调用点传来的 owner props。
Runtime Share
框架提供稳定能力,例如:
sessionId
useSession
useSessions
useWorkspaces
useProjection
RenderSlot Share
如果 registration 声明 child slots,则获得对应的:
renderSlot
renderSlotChain
Store Share
如果 registration 声明 store,则获得:
useStore
actions
Business Inject Share
Plugin apply 阶段可以闭包捕获 Cordis Service,并通过 inject 返回普通数据和 callback。
例如:
openFile()
inspect()
stop()
selectWorkspace()
业务组件本身不接触 ctx。
Hook 是在 React Binding 层生成的
Runtime 层维护的是裸 Observable:
interface HostObservable<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
它不携带 React Hook。[4]
到了 web-react,Renderer 才把 Observable 绑定成:
useSession
useStore
useProjection
use<Name>
当前 scoped-slots.tsx 中,standardKit() 会根据 scope、session info、store 和 children 生成框架 Props。[9]
真正 render component 时,Props 合并关系非常明确:[9]
<Comp
{...kit}
{...injected}
{...slotInjected.props}
{...ownerProps}
/>
如果存在 contextual hooks,还会多合并一层动态 Hook Props。
因此最终可以把 Component Props 表达为:
finalProps
= framework runtime props
+ child-slot capabilities
+ store props
+ plugin injected callbacks/data
+ slot-level injected values
+ owner props
Owner props 最后覆盖,因为它表达当前实际 render occurrence 已知的数据。
为什么业务组件不能直接拿 ctx
当前 DSH Client 约束明确规定:Cordis ctx 只存在于 Plugin Apply 与 Inject Factory 世界,Feature .tsx Component 不直接读取 Context。[10]
这条约束的价值在于组件依赖可以完全从 Props 看出。
例如:
function ReadToolView({ block, cwd, openFile }) {
...
}
测试时只需要传入假数据和 callback。
如果组件内部调用:
const ctx = useCordis()
const session = ctx.sessions.current()
const fs = ctx.fs
那么组件的真实依赖会隐藏在 Runtime Container 中,插件 UI 很快重新退化为 Service Locator 架构。
因此 DSH 实际上把 Cordis 和 React 刻意隔开:
Cordis Plugin World
│
│ inject factory
▼
Plain Props / Observable Sources
│
│ web-react binding
▼
React Component World
Store 也有明确边界
Plugin UI 仍然需要共享交互状态,例如:
selected tool call
panel width
active tab
draft
DSH Slot Registration 可以声明 Store,但当前规则明确要求:Session、Connection、Frame 等业务状态不放进这些 Store。[10]
因此状态所有权形成三层:
Client Runtime Object Layer
Session / Connection / Event / Projection
Plugin Store
跨组件共享 UI interaction state
React Local State
组件内部短生命周期状态
这比“所有状态统一 Zustand”更复杂,但边界更稳定。
React Mount 到底什么时候发生
插件加载并不等于组件立即 Mount。
一个 Component 真正出现需要多个条件同时满足:
Plugin Fiber active
↓
Registration exists
↓
Parent Slot declaration exists
↓
Slot Owner mounted
↓
Owner 调用 renderSlot()
↓
kind/key/chain selector 选中该 entry
↓
Session scope 条件满足
↓
React mounts Component
因此至少要区分三个时间点:
1. Plugin activation
2. UI registration
3. React mount
Plugin 可以已经处于 Active,但它注册的是当前页面未渲染的 Slot;Component 此时不会 Mount。
同样,一个 Component Unmount 也不一定意味着 Plugin 被卸载,可能只是:
session switched
slot key changed
chain election changed
owner stopped rendering the slot
这种分离是理解插件 UI 生命周期的基础。
从 SessionEvent 到 Tool Card 的完整链路
现在可以把前两篇和 Slot 系统连接起来:
Host SessionEvent
↓
ConnectionController
↓
SessionManager
↓
Client Session
↓
ConversationNodeAssembler
↓
ConversationNodeDefinition
↓
Chat ConversationViewNode
↓
ChatView
↓
renderSlot('conversation.chat.node', node)
↓
key = tool-call
↓
ToolCallTree
↓
renderSlot('tool.call.toolview', owner, key=toolName)
↓
ReadToolView / BashToolView / GenericToolCard
↓
React mount / update
注意这条链中 Cordis 并不直接“渲染 React”。
Cordis 管理 Plugin 生命周期;Slot Registry 管理 UI Contribution Graph;Conversation Runtime 管理 Event Projection;web-react 把 Observable 和 Slot Entry 绑定到 React;最终 Component 只消费 Props。
React Tree 是 Plugin Graph 的运行时投影
传统 React 应用通常可以从源码中的 JSX 静态看出大部分组件树。
插件化 Harness 不再满足这一点。
当前 React Tree 同时取决于:
哪些 Plugin active
哪些 Slot declaration active
哪些 Contribution registered
当前 Session 是什么
当前 View Node 是什么
keyed/chain 路由选择了谁
因此更准确的关系是:
ReactTree(t)
=
Project(
PluginGraph(t),
SlotGraph(t),
RuntimeState(t)
)
这不是形式化定义,而是一个实用的阅读模型。
当某个 UI 组件没有出现时,应沿以下链路排查:
插件是否 Active
→ Registration 是否存在
→ Slot 是否已经声明
→ Owner 是否正在 renderSlot
→ scope 是否满足
→ key / select 是否匹配
→ Component 是否因 error boundary abdicate
而不是直接从 React Component Tree 开始查找。
一个设计判断
插件化 Agent UI 最核心的边界不在“是否使用 Slot”。
更重要的是三种所有权必须分开:
Runtime
拥有业务状态与 Projection
Slot Owner
拥有布局位置和 Composition Policy
Feature Plugin
拥有业务 renderer 与局部交互
React Component 处在最末端,只接受这些所有权共同形成的 Props。
这样增加 Tool、Plan、Workflow 或 SubAgent Feature 时,扩展主要表现为新的 Definition 和新的 Slot Contribution,而不是继续扩大中心 ChatView、AppFrame 或全局 Store。
下一篇将用一个最小实现把这个模型串起来:Server 维护 Session Event Log 和 Run,浏览器通过可恢复 Event Stream 建立 Client Runtime,Projection Engine 生成 View Node,Slot Registry 再根据插件 registration 选择 renderer。
参考资料
[1] DeepSeek Harness ui-layout registration: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-layout/src/client/index.ts
[2] DeepSeek Harness AppFrame: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-layout/src/client/AppFrame.tsx
[3] DeepSeek Harness UI Slots: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-slots/README.md
[4] DeepSeek Harness Slot Renderer Contract: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-slots/src/renderer.ts
[5] DeepSeek Harness ui-conversation apply: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-conversation/src/client/apply.ts
[6] DeepSeek Harness ui-tool apply: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-tool/src/client/apply.ts
[7] DeepSeek Harness ToolCallTree: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
[8] DeepSeek Harness Runtime SlotRegistry: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/slots.ts
[9] DeepSeek Harness React Slot Renderer: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/web-react/src/scoped-slots.tsx
[10] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md