The previous article already pushed the data flow to ConversationViewNode. At this point, Runtime has completed historical recovery, real-time Event merging, and business Projection—React is facing a stable set of View Nodes.
The remaining question is: which component renders these Nodes?
An early Agent UI often centralizes the logic in 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 />
}
This structure is straightforward when features are few. As the Harness plugin count grows, it gradually becomes the central dependency for all UI features: adding a Workflow plugin requires modifying ChatView, adding a Tool Card requires modifying ToolCall, and adding a new Details Panel requires modifying Layout.
DeepSeek Harness currently solves this with a Slot system. Its core idea can be summarized as: the page structure is declared by the Slot Owner, Feature Plugins register implementations into existing Slots, and the React Tree is a projection of the current Plugin Graph under the current Runtime State.
The Page Is First a Slot Tree
DSH Web Shell only renders one built-in Slot:
root
The ui-layout plugin registers AppFrame to root and declares four child slots in the same registration:
root
└── AppFrame
├── sidebar
├── conversation
├── details
└── shell.overlay
The current source registration is: [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)
This code simultaneously does four things:
Contribute AppFrame
Declare child slots
Declare AppFrame store
Declare business inject face
The Slot declaration is therefore not a separately maintained static schema. The declaration happens during the registration of the component that owns that layout position.
Slot Owner Determines Geometry
AppFrame itself only receives renderSlot() via Props, then calls it at positions it actually owns: [2]
<div className={sidebarCol}>
{renderSlot('sidebar', {
collapsed: sidebarCollapsed,
width: cols.sidebar,
})}
</div>
<CenterColumn>
{renderSlot('conversation', {})}
</CenterColumn>
<DetailsColumn>
{renderSlot('details', {})}
</DetailsColumn>
This establishes a clear ownership principle:
The component that declares a Slot owns the layout and rendering rights for that position.
A Sidebar Plugin can decide what appears inside the sidebar, but cannot decide the sidebar's width in the three-column layout. Width belongs to AppFrame, so it comes in as owner props from the renderSlot() call site.
This avoids a common problem in plugin systems: Feature Plugin contributes content while also modifying the host layout through global selectors or DOM queries.
The relationship becomes:
Layout Owner
Owns position, size, appearance
│
│ owner props
▼
Slot Registrant
Owns business content at this position
children Represents Both Declaration and Authorization
DSH's Slot design has another strong constraint: a component can only render child slots declared in its own registration. [3]
For example, AppFrame declares:
sidebar
conversation
details
shell.overlay
Therefore the Renderer injects the corresponding renderSlot() capability into AppFrame's Props.
If a component holds an old renderSlot closure but its registration has been unloaded, the Renderer throws a StaleAuthorizationError; if a component tries to render a Slot it hasn't declared, it throws a SlotOwnershipError. [4]
This design connects UI lifecycle with plugin lifecycle:
Plugin Registration exists
↓
Child Slot Declaration exists
↓
renderSlot authorization exists
↓
Component can compose descendants
When the plugin unloads:
Registration removed
↓
Child Slot declaration removed
↓
Descendant contributions collapse
↓
retained renderSlot binding becomes stale
This is the same class of problem as the Cordis Effect lifecycle discussed in Unit Three, but here it operates on the UI Composition Graph.
conversation Re-declares Its Internal Structure
After the ui-conversation plugin registers to the conversation Slot, it continues declaring its own child slots. [5]
Including:
conversation
└── ConversationRoot
├── conversation.session
├── conversation.session.header
├── conversation.composer
├── conversation.composer.bar
├── conversation.input.overlay
├── conversation.input.dock
└── ...
Session body continues declaring:
conversation.session
└── ConversationSession
└── conversation.view
Chat View ultimately owns the rendering position for Chat Nodes.
So the page isn't fully declared in one App.tsx, but forms incrementally with Plugin Registrations:
Shell
↓
root
↓
ui-layout
↓
conversation
↓
ui-conversation
↓
conversation.chat.node
↓
ui-tool / ui-goal / workflow / ...
Each layer only knows its own child seats.
Tool UI Shows Nested Plugin Composition
ui-tool currently registers ToolCallTree as the renderer for tool-call Chat Nodes via:
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)
)
[6]
There are two levels of dispatch:
Conversation Node
kind = tool-call
↓
conversation.chat.node
↓
ToolCallTree
↓
tool.call.toolview
↓
ReadToolView / BashToolView / WebToolView / ...
ToolCallTree itself doesn't know how to display each Tool type. It only assembles:
callId
toolName
block
cwd
openFile
inspect
Into owner props, then calls the keyed Slot by toolName: [7]
renderSlot('tool.call.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard ... />,
})
So adding a new business Tool UI only requires registration:
ctx.slots.inject('tool.call.toolview', () =>
ctx.slots.register({
name: 'tool.call.toolview',
key: 'my-tool',
}, MyToolView)
)
No modification to ToolCallTree needed.
Why slots.inject() Is Still Needed
Unit Three already discussed Cordis's dynamic dependencies. UI Slots have the same timing problem: Plugin A may load first, but the Slot it wants to register is only declared by Plugin B later.
If you execute directly:
ctx.slots.register({
name: 'conversation.chat.node',
...
})
And conversation.chat.node doesn't exist yet, the registration should fail because the system can't confirm this Slot's kind, scope, and owner contract.
ctx.slots.inject() provides the "depend on Slot Declaration" semantics. [8]
Its reconcile logic can be condensed to:
observe declaration epoch
↓
slot absent
→ contribution inactive
slot declared
→ run callback
→ register contribution
slot declaration removed
→ dispose contribution
slot declared again
→ run callback again
This is very close to Cordis's ctx.inject(service, callback) dependency activation model.
The difference is the dependency object changed from Service to Slot Declaration.
Therefore UI Plugin load order doesn't need to be strictly arranged:
ui-tool loads first
conversation.chat.node not yet declared
↓
ui-tool waits
↓
uiconversation declares slot
↓
ToolCallTree registration takes effect
When Slot Owner unloads, Tool Contribution also automatically disappears.
Slot Is Not a Single Type
Different UI positions need different composition rules. DSH's Slot Core currently supports several main kinds. [3][4]
single
One position selects only the current winner. Suitable for:
root
conversation
details
list
Multiple contributions exist simultaneously and render in order. Suitable for:
shell.overlay
header.actions
input.dock
keyed
Owner selects renderer based on business key. Tool View uses:
key = toolName
Thus:
read → ReadToolView
bash → BashToolView
unknown → fallback
chain
Routing direction is reversed. Owner doesn't specify renderer key; each registration provides select(ownerProps); executes by priority, first entry returning non-null is selected. [3][4]
This fits scenarios like Approval, Question, Composer takeover—"who currently has qualification to take over this position."
Therefore Slot is not just a "React Component Registry." It also defines local Composition Policy.
What Props Does a Component Ultimately Receive
This is where plugin-based UI最容易变得混乱的地方 (most easily becomes chaotic).
If each Plugin can freely fetch data from ctx, Global Store, React Context, Service Locator, then Slot only solves component discovery, not dependency boundaries.
DSH currently splits Component Props into four shares: [3]
Runtime Share
+ RenderSlot Share
+ Store Share
+ Business Inject Share
Plus Owner props passed at the renderSlot() call site.
Runtime Share
Framework provides stable capabilities, for example:
sessionId
useSession
useSessions
useWorkspaces
useProjection
RenderSlot Share
If registration declares child slots, get corresponding:
renderSlot
renderSlotChain
Store Share
If registration declares store, get:
useStore
actions
Business Inject Share
Plugin apply phase can closure-capture Cordis Service and return plain data and callbacks via inject.
For example:
openFile()
inspect()
stop()
selectWorkspace()
Business components themselves don't touch ctx.
Hooks Are Generated at the React Binding Layer
Runtime layer maintains bare Observables:
interface HostObservable<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
It doesn't carry React Hooks. [4]
Only at web-react does the Renderer bind Observable 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 actually rendering component, Props merge relationship is very clear: [9]
<Comp
{...kit}
{...injected}
{...slotInjected.props}
{...ownerProps}
/>
If contextual hooks exist, another layer of dynamic Hook Props is merged.
Therefore Component Props can ultimately be expressed as:
finalProps
= framework runtime props
+ child-slot capabilities
+ store props
+ plugin injected callbacks/data
+ slot-level injected values
+ owner props
Owner props override last because they represent data known for the current actual render occurrence.
Why Business Components Can't Directly Grab ctx
Current DSH Client rules explicitly state: Cordis ctx only exists in Plugin Apply and Inject Factory world; Feature .tsx Components don't directly read Context. [10]
The value of this rule is that component dependencies can be fully seen from Props.
For example:
function ReadToolView({ block, cwd, openFile }) {
...
}
Testing only needs to pass mock data and callbacks.
If the component internally calls:
const ctx = useCordis()
const session = ctx.sessions.current()
const fs = ctx.fs
Then the component's real dependencies hide in Runtime Container, and plugin UI quickly regresses to Service Locator architecture.
So DSH deliberately separates Cordis from React:
Cordis Plugin World
│
│ inject factory
▼
Plain Props / Observable Sources
│
│ web-react binding
▼
React Component World
Store Also Has Clear Boundaries
Plugin UI still needs shared interaction state, for example:
selected tool call
panel width
active tab
draft
DSH Slot Registration can declare Store, but current rules explicitly require: business state like Session, Connection, Frame should not go into these Stores. [10]
Therefore state ownership forms three layers:
Client Runtime Object Layer
Session / Connection / Event / Projection
Plugin Store
Cross-component shared UI interaction state
React Local State
Short-lifecycle state inside component
This is more complex than "unified Zustand for all state," but boundaries are more stable.
When Does React Mount Actually Happen
Plugin loading doesn't equal immediate Component Mount.
A Component actually appears when multiple conditions are met simultaneously:
Plugin Fiber active
↓
Registration exists
↓
Parent Slot declaration exists
↓
Slot Owner mounted
↓
Owner calls renderSlot()
↓
kind/key/chain selector selects that entry
↓
Session scope condition met
↓
React mounts Component
Therefore at least three time points should be distinguished:
1. Plugin activation
2. UI registration
3. React mount
A Plugin can be Active but registered to a Slot not rendered on the current page; Component won't Mount at that point.
Similarly, a Component Unmount doesn't necessarily mean Plugin was unloaded; it could be:
session switched
slot key changed
chain election changed
owner stopped rendering the slot
This separation is fundamental to understanding plugin UI lifecycle.
Complete Chain from SessionEvent to Tool Card
Now we can connect the previous two articles with the Slot system:
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
Note Cordis doesn't directly "render React" in this chain.
Cordis manages Plugin lifecycle; Slot Registry manages UI Contribution Graph; Conversation Runtime manages Event Projection; web-react binds Observable and Slot Entry to React; finally Component only consumes Props.
React Tree Is Runtime Projection of Plugin Graph
Traditional React applications usually allow most component trees to be statically seen from JSX in source code.
Plugin-based Harness no longer satisfies this.
Current React Tree simultaneously depends on:
Which Plugins are active
Which Slot declarations are active
Which Contributions are registered
What the current Session is
What the current View Node is
Which entry keyed/chain routing selected
Therefore the more accurate relationship is:
ReactTree(t)
=
Project(
PluginGraph(t),
SlotGraph(t),
RuntimeState(t)
)
This isn't a formal definition, but a practical reading model.
When some UI component doesn't appear, troubleshoot along this path:
Plugin Active
→ Registration exists
→ Slot declared
→ Owner calling renderSlot
→ scope satisfied
→ key / select matched
→ Component abdicated due to error boundary
Instead of starting directly from React Component Tree.
A Design Judgment
The core boundary in plugin-based Agent UI isn't "whether to use Slot."
More importantly, three ownerships must be separated:
Runtime
Owns business state and Projection
Slot Owner
Owns layout position and Composition Policy
Feature Plugin
Owns business renderer and local interaction
React Component sits at the very end, only accepting Props formed by these ownerships combined.
This way, adding Tool, Plan, Workflow, or SubAgent features mainly manifests as new Definitions and new Slot Contributions, rather than continuing to expand central ChatView, AppFrame, or global Store.
The next article will run through this model with a minimal implementation: Server maintains Session Event Log and Run, browser establishes Client Runtime through recoverable Event Stream, Projection Engine generates View Node, Slot Registry selects renderer based on plugin registration.
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