On the server side, an Agent is a natural fit for expressing execution through Events: the start of a Turn, model output, Tool Calls, Tool Results, Approval, Compaction, and changes to Run status can all become facts that arrive in order.
What the browser ultimately needs, however, is not a list of Events.
A chat interface needs:
Message Row
Tool Card
Approval Panel
Plan Block
SubAgent Node
Running Indicator
Composer State
Web Runtime therefore necessarily involves a state transition:
Event
↓
Projection
↓
View State
↓
UI
Where this Projection lives will directly determine whether the system can correctly handle Replay, Streaming, Reconnect, and plugin extensions.
The Problems with Handling Events Directly in React
The easiest structure to implement is to have components subscribe to the Event Stream:
In the short term, this code is straightforward. But adding history restoration introduces a second path:
The system now has two different ways of constructing state:
History Replay
→ buildMessages()
Live Event
→ reduce()
If the rules of the two paths aren't exactly the same, the same Session produces a different UI on first open and during continuous operation.
Tool Call is a typical example. The history may already contain both:
tool/call
...
tool/result
The live state, by contrast, goes through:
tool/call
→ running card
→ tool/result
→ completed card
If the history-building function reads the final result directly while the live reducer maintains intermediate state, the two implementations can easily drift apart as their rules evolve.
Therefore, a more robust principle is:
Replay and Live Append should go through the same Projection Engine.
A Projection Is a Repeatable State Fold
The simplest Projection can look like this:
History restoration:
initialState
↓ event 0
state 1
↓ event 1
state 2
↓ event 2
...
↓ event N
View State
Live execution simply continues from this state:
View State at N
↓ event N+1
View State at N+1
This gives the system an important invariant:
fold(history + live)
=
fold(history) continued with live tail
This equivalence lets Replay, Reconnect, and live Streaming share the same semantics.
Messages Aren't the Only Projection
Agent UIs tend to flatten everything into messages[]. This reintroduces the problem from the second installment: Agent execution contains plenty of state that isn't well suited to being represented as Messages.
For example:
turn/start
step/start
assistant/chunk
tool/call
tool/result
approval/requested
turn/end
Multiple Projections can be derived from these Events simultaneously:
The Server and Client can have different Projections, but they should be based on an explicit source of truth and consistent sequence boundaries.
DeepSeek Harness already demonstrates this in a Server Session: the SessionEvent Log is the source of truth, while deriveMessages() projects only the history visible to the model. On the Browser side, a Conversation Projection turns Events into Nodes suited to the UI. [1][2]
Why a Client Runtime Should Exist
React components are well suited to managing component lifecycles and local interaction state, but not to owning network reconnection, the Event Window, Gap Repair, or business Projections.
This state has several characteristics:
- Multiple components on the page need to share it.
- State updates come not only from React events, but also from network activity and history reads.
- The UI may unmount while the Session still needs to keep receiving Events.
- The state must support a full Replay.
- Updates may occur far more frequently than the UI needs to rerender.
Accordingly, DSH currently defines its browser-side data object layer as React-free:
ConnectionController
↓
SessionManager
↓
Session
↓
Conversation Assembler
React observes this layer only through subscribe() / getSnapshot(). [3]
This differs from the traditional frontend pattern of “putting state in a Store.” DSH's constraint document explicitly requires business state such as Session, Frame, and Connection to remain in the object layer; the Slot Store carries only shared UI state such as selection, draft, and panel width. [3]
This boundary is worth preserving:
Business Runtime State
→ Client Runtime object
Shared UI Interaction State
→ UI Store
Component-local State
→ React state
The three kinds of state have different sources and don't need to be unified in a single global Store.
A Session Must Maintain a Contiguous Event Window
A DSH Client Session currently maintains the following internally:
Its goal isn't to store the Server's entire Session, but to maintain a contiguous window of what the browser has currently loaded. [4]
On first open:
Session.open()
↓
history(maxMessages = 50)
↓
installWindow()
↓
Conversation.replaceWindow()
If live Events arrive while history is being read, they first enter liveBuffer. Once the history window has been installed, they're appended in seq order. [4]
The key isn't the Buffer itself, but the requirement that the window remain contiguous.
Suppose the history ends like this:
seq = 120
The Client then receives:
seq = 123
Appending it directly would permanently lose 121 and 122. DSH currently rejects a window with such a gap; instead, it puts 123 in the Buffer and triggers a tail page repull:
120
↓
123 arrives
↓
gap detected
↓
123 → liveBuffer
↓
reload tail history
↓
121, 122, 123...
↓
installWindow + dedup
The seq value therefore serves three purposes at once: ordering, deduplication, and Gap Detection. [4]
Reconnect Is Fundamentally Re-establishing the Baseline
After a network reconnection, the client can't assume that nothing changed while it was disconnected.
DSH's SessionManager.handleConnected() performs the following:
refresh session list
refresh relevant subagent catalogs
resync every resident Session
Session.resync() increments the generation, clears the old window, and calls open() again. [5][6]
openGeneration handles a typical concurrency problem:
If the old request could still write state after returning, it would overwrite the new generation with results obtained from the old connection.
Therefore, doOpen(generation) checks after every await:
This generation token is a common and effective way to isolate race conditions in the Client Runtime. [4]
Projections Also Need an Incremental Path
If every Event triggered a rescan of the entire Session Window:
new event
↓
scan event 0..N
↓
rebuild every node
Long sessions would incur significant overhead.
DSH's ConversationNodeAssembler currently supports three paths:
replaceWindow()
用于 open / resync / gap repair
prepend()
用于加载更早历史
append()
用于实时尾部 Event
append() doesn't rescan the existing Context. It processes only the current Event and updates the corresponding Context and View Builder. [7]
Thus, the Client Projection's computational model can be expressed as:
低频路径:完整重建
replaceWindow(history)
高频路径:增量折叠
append(event)
Both paths must produce equivalent business outcomes, even though their performance strategies can differ.
ConversationNodeDefinition Moves the Business Fold Out of the Runtime
If the Runtime contains a giant switch:
Every new UI Feature then requires modifying the core Projection Engine.
DSH currently uses ConversationNodeDefinition<State> to register these business rules. [8]
A Definition mainly provides:
It represents an independent Event → State → ViewNode state machine.
For example, the conceptual model for a Tool Call could be:
The Runtime is responsible for:
Event 顺序
Context identity
Turn / Step Location
调用 Definition
缓存 State
发布 View Snapshot
A Feature Plugin is responsible for:
哪些 Event 属于自己
如何更新业务 State
最终产生什么 View Node
This gives Projection itself a plugin boundary.
Why Generate View Nodes Before Entering React
ConversationViewNode is the final layer of business representation before React:
Chat targets further carry Location, Anchor Sequence, and Visibility. [8]
This layer matters because it separates business semantics from React components:
SessionEvent
↓
Business State
↓
ConversationViewNode
↓
UI Renderer
↓
React Component
That makes it possible to test the same Projection Engine without React.
A business Feature can first check whether it correctly produces:
ToolCallNode {
id,
status,
call,
result,
subCalls
}
Then it can separately test how the Node is displayed.
Streaming Fold and React Publication Need Not Be Synchronized
LLM Streaming produces a large number of Chunks. If every Chunk forces the entire React Tree to render synchronously, the Projection layer may be correct, but UI performance will still suffer.
DSH currently adds publication() to ConversationNodeDefinition, with possible results:
none
animation-frame
immediate
The Assembler uses the fastest publication cadence required by the current transaction. [7][8]
This creates two distinct rates:
Event Fold Frequency
可能每个 chunk 都执行
React Publication Frequency
可以按 animation frame 合并
The Client Runtime therefore takes on an often-overlooked responsibility: business state must be computed promptly, while UI notifications can be batched.
If these two are tied directly to React setState(), they are difficult to control independently.
Observable Is a Narrow Interface Between the Runtime and React
Once the Client Runtime has the full state, React doesn't need to know about the Event Stream, History API, or Reconnect mechanism.
It only needs:
The React Binding layer then calls useSyncExternalStore(). [3]
This keeps dependencies one-way:
Connection / Session / Projection
│
│ Observable Snapshot
▼
React Binding
│
▼
Component
Business components don't listen to the Socket directly or run Projections themselves.
A More Complete Data Flow
Connecting the layers covered in this article gives us the following:
This pipeline supports all of the following:
首次打开
历史分页
实时 Streaming
网络重连
Gap Repair
插件重新注册后的 Projection rebuild
A Design Decision
A stable state model for Agent UI should meet two conditions.
First, Replay and Live use the same Projection rules. Otherwise, the restored UI will eventually diverge from the live runtime state.
Second, Projection belongs to the Client Runtime, not the component tree. React should observe the Projection result rather than own the source of truth or recovery algorithms.
Thus, the right relationship for an Agent Web UI is:
Event 是事实变化
Projection 是业务解释
View State 是 UI 输入
React 是渲染器
The next article tackles the final question: after producing a ConversationViewNode, who decides which component should render it, and how Features such as Tool, Plan, SubAgent, and Workflow can be added to the UI without modifying the central ChatView.
References
[1] DeepSeek Harness Session Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[2] DeepSeek Harness Session Surface: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/surface.ts
[3] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[4] DeepSeek Harness Client Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/session.ts
[5] DeepSeek Harness ConnectionController: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/src/client/connection.ts
[6] DeepSeek Harness SessionManager: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/manager.ts
[7] DeepSeek Harness ConversationNodeAssembler: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/conversation-assembler.ts
[8] DeepSeek Harness Conversation contracts: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/contract/conversation.ts