The previous article advanced the data flow as far as ConversationViewNode. By this point, Runtime has completed history restoration, real-time Event merging, and business Projection, so React is now working with a stable set of View Nodes.
The remaining question is: who decides which component renders each Node?
An early Agent UI often centralizes those decisions in ChatView:
This structure is straightforward when there are few Features. As the number of Harness plugins grows, it gradually becomes a central dependency for every UI feature: adding a Workflow plugin means modifying ChatView, adding a new type of Tool Card means modifying ToolCall, and adding a new Details Panel means modifying Layout.
DeepSeek Harness currently addresses this with the Slot system. Its core idea can be summarized this way: the page structure is declared by Slot Owners, Feature Plugins register implementations with existing Slots, and the React Tree is a projection of the current Plugin Graph under the current Runtime State.
The Page Begins as a Slot Tree
The DSH Web Shell itself renders only one built-in Slot:
root
The ui-layout plugin registers AppFrame with root and declares four child slots in the same registration:
root
└── AppFrame
├── sidebar
├── conversation
├── details
└── shell.overlay
The registration relationships in the current source are: [1]
This code does four things at once:
贡献 AppFrame
声明 child slot
声明 AppFrame store
声明业务 inject face
Declaring a Slot is therefore not a matter of maintaining a separate static Schema. The declaration lives on the registration of the component that owns that layout position.
The Slot Owner Determines the Layout Geometry
AppFrame itself receives renderSlot() only through Props, then calls it from the location where it actually owns the layout: [2]
This establishes a clear ownership principle:
The component that declares a Slot owns the layout and rendering authority for that position.
A Sidebar Plugin can decide what appears inside the sidebar, but it cannot decide the Sidebar’s width in the three-column layout. The width belongs to AppFrame, so it is passed as owner props at the renderSlot() call site.
This avoids a common plugin-system problem: a Feature Plugin contributes content and then modifies the host layout through global selectors or DOM queries.
Layout Owner
负责位置、尺寸、出现位置
│
│ owner props
▼
Slot Registrant
负责这个位置里的业务内容
children Represent Both Declaration and Authorization
DSH’s Slot design has another strict constraint: a component may render only the child slots declared in its own registration. [3]
For example, AppFrame declares:
sidebar
conversation
details
shell.overlay
Only then does the Renderer inject the corresponding renderSlot() capability into AppFrame’s Props.
If a component holds an old renderSlot closure after its registration has been unloaded, the Renderer throws StaleAuthorizationError; if it tries to render a Slot it has not declared, the Renderer throws SlotOwnershipError. [4]
This ties the UI lifecycle to the plugin lifecycle:
Plugin Registration exists
↓
Child Slot Declaration exists
↓
renderSlot authorization exists
↓
Component can compose descendants
When a plugin is unloaded:
Registration removed
↓
Child Slot declaration removed
↓
Descendant contributions collapse
↓
retained renderSlot binding becomes stale
This is the same category of problem as the Cordis Effect lifecycle discussed in Unit 3, except that it operates on the UI Composition Graph.
conversation Declares Its Own Internal Structure
After the ui-conversation plugin registers with the conversation Slot, it goes on to declare its own child slots. [5]
These include:
The session body goes on to declare:
conversation.session
└── ConversationSession
└── conversation.view
Chat View ultimately owns the rendering position for Chat Nodes.
Thus, the page is not declared in full in an App.tsx; instead, it takes shape layer by layer as Plugin Registration proceeds:
Shell
↓
root
↓
ui-layout
↓
conversation
↓
ui-conversation
↓
conversation.chat.node
↓
ui-tool / ui-goal / workflow / ...
Each layer knows only the child seats it owns.
Tool UI Shows Nested Plugin Composition
The current ui-tool plugin does this via:
This registers ToolCallTree as the renderer for the tool-call Chat Node. [6]
There are two layers of dispatch:
ToolCallTree itself does not know the presentation component for each Tool. It simply assembles:
callId
toolName
block
cwd
openFile
inspect
into owner props, then invokes the keyed Slot according to toolName: [7]
Adding a new business Tool UI therefore requires only a registration:
ToolCallTree does not need to be modified.
Why slots.inject() Is Still Needed
Unit 3 already covered Cordis’s dynamic dependencies. UI Slots have the same timing problem: Plugin A may load first, but the Slot it needs to register may not be declared until Plugin B loads later.
If you call the following directly:
while conversation.chat.node does not yet exist, the registration should fail, because the system cannot determine the Slot’s kind, scope, and owner contract.
ctx.slots.inject() provides the semantics of “depending on a Slot Declaration.” [8]
Its reconcile logic can be reduced to:
This closely mirrors the activation model of Cordis’s ctx.inject(service, callback).
The difference is that the dependency changes from a Service to a Slot Declaration.
UI Plugin load order therefore does not need to be strictly arranged:
ui-tool 先加载
conversation.chat.node 尚未声明
↓
ui-tool 等待
↓
ui-conversation 声明 slot
↓
ToolCallTree registration 生效
When the Slot Owner is unloaded, the Tool Contribution also disappears automatically.
A Slot Is Not One Type
Different UI positions need different composition rules. DSH’s current Slot Core supports several primary kinds. [3][4]
single
One position selects a single current winner. This works well for:
root
conversation
details
list
Multiple contributions can coexist and render in order. This works well for:
shell.overlay
header.actions
input.dock
keyed
The Owner chooses a renderer by business key. Tool View uses:
key = toolName
Thus:
read → ReadToolView
bash → BashToolView
unknown → fallback
chain
Routing works in the opposite direction. The Owner does not specify a renderer key; each registration supplies select(ownerProps). Entries run in priority order, and the first entry that returns non-null is selected. [3][4]
This suits scenarios such as Approval, Question, and Composer takeover, where the question is “who is currently eligible to take over this position?”
Therefore, a Slot is more than a “React Component Registry.” It also defines a local Composition Policy.
What Props a Component Eventually Receives
This is where a plug-based UI is most likely to become tangled.
If every Plugin can freely pull data from ctx, Global Store, React Context, or a Service Locator, then Slot solves only component discovery, not dependency boundaries.
DSH currently divides component Props into four shares: [3]
Runtime Share
+ RenderSlot Share
+ Store Share
+ Business Inject Share
Plus the owner props passed by the Owner at the renderSlot() call site.
Runtime Share
The framework provides stable capabilities such as:
sessionId
useSession
useSessions
useWorkspaces
useProjection
RenderSlot Share
If a registration declares child slots, it receives the corresponding:
renderSlot
renderSlotChain
Store Share
If a registration declares a store, it receives:
useStore
actions
Business Inject Share
During the Plugin apply phase, it can capture a Cordis Service in a closure and return plain data and callbacks through inject.
For example:
openFile()
inspect()
stop()
selectWorkspace()
The business component itself never touches ctx.
Hooks Are Generated at the React Binding Layer
The Runtime layer maintains raw Observables:
It does not carry React Hooks. [4]
At web-react, the Renderer binds Observables to:
useSession
useStore
useProjection
use<Name>
In the current scoped-slots.tsx, standardKit() generates framework Props based on scope, session info, store, and children. [9]
When the component is actually rendered, the Props merge order is explicit: [9]
If contextual hooks are present, one additional layer of dynamic Hook Props is merged in.
The final Component Props can therefore be expressed as:
finalProps
= framework runtime props
+ child-slot capabilities
+ store props
+ plugin injected callbacks/data
+ slot-level injected values
+ owner props
Owner props come last because they represent data known at the current render occurrence.
Why Business Components Must Not Access ctx Directly
Current DSH Client constraints explicitly state that Cordis ctx exists only in the Plugin Apply and Inject Factory world; Feature .tsx Components do not read Context directly. [10]
The value of this constraint is that all component dependencies are visible directly in Props.
For example:
Testing then requires only mock data and callbacks.
If a component calls the following internally:
its real dependencies are hidden inside the Runtime Container, and the plugin UI quickly degenerates back into a Service Locator architecture.
DSH therefore deliberately separates Cordis from React:
Stores Also Have Explicit Boundaries
Plugin UI still needs shared interaction state, such as:
selected tool call
panel width
active tab
draft
DSH Slot Registration can declare a Store, but the current rules explicitly require that business state such as Session, Connection, and Frame not be placed in these Stores. [10]
State ownership therefore forms three layers:
This is more complex than putting all state into a single Zustand store, but the boundaries are more stable.
When Exactly Does React Mount?
Loading a plugin does not mean the component mounts immediately.
For a Component to actually appear, several conditions must hold at the same time:
Thus, at least three moments need to be distinguished:
1. Plugin activation
2. UI registration
3. React mount
A Plugin may already be Active, but it may have registered a Slot that the current page does not render; the Component will not mount yet.
Likewise, a Component Unmount does not necessarily mean the Plugin was unloaded. It may only mean that:
session switched
slot key changed
chain election changed
owner stopped rendering the slot
This separation is the foundation for understanding the plugin UI lifecycle.
The Complete Path from SessionEvent to Tool Card
Now the previous two articles and the Slot system can be connected:
Notice that Cordis does not directly “render React” anywhere in this chain.
Instead, Cordis manages Plugin lifetimes; the Slot Registry manages the UI Contribution Graph; Conversation Runtime manages Event Projection; web-react binds Observables and Slot Entries to React; and the final Component consumes only Props.
The React Tree Is a Runtime Projection of the Plugin Graph
Most traditional React applications reveal much of their component tree statically through JSX in the source.
A plugin-based Harness no longer satisfies that assumption.
The current React Tree depends on all of the following at once:
哪些 Plugin active
哪些 Slot declaration active
哪些 Contribution registered
当前 Session 是什么
当前 View Node 是什么
keyed/chain 路由选择了谁
Thus, a more accurate relationship is:
ReactTree(t)
=
Project(
PluginGraph(t),
SlotGraph(t),
RuntimeState(t)
)
This is not a formal definition, but a practical model for reading the system.
When a UI component does not appear, troubleshoot along this path:
rather than starting from the React Component Tree.
A Design Decision
The core boundary of a plugin-based Agent UI is not “whether to use Slots.”
What matters more is separating three kinds of ownership:
Runtime
拥有业务状态与 Projection
Slot Owner
拥有布局位置和 Composition Policy
Feature Plugin
拥有业务 renderer 与局部交互
The React Component sits at the very end and accepts only the Props formed by these ownership relationships.
Thus, when adding Tool, Plan, Workflow, or SubAgent Features, the extension mainly consists of a new Definition and a new Slot Contribution, rather than continued expansion of the central ChatView, AppFrame, or global Store.
The next article will connect this model with a minimal implementation: the Server maintains the Session Event Log and Run; the browser establishes a Client Runtime through a resumable Event Stream; the Projection Engine generates View Nodes; and the Slot Registry then selects a renderer based on plugin registrations.
References
[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