前編では、データフローをConversationViewNodeまで進めた。この時点でRuntimeは履歴の復元、リアルタイムEventの統合、業務Projectionまで完了しており、Reactの向かう先は安定したView Nodeの集合になっている。
残る問題は、どのNodeをレンダリングするコンポーネントを、誰が決めるかだ。
初期のAgent UIでは、判定をChatViewにまとめてoucheることが多い。
Featureが少ない段階なら、この構成は単純で分かりやすい。しかしHarnessのPlugin数が増えると、次第にすべてのUI機能が依存する中心的な箇所になる。Workflow Pluginの追加にはChatViewの修正が必要で、Tool Cardの種類を増やすにはToolCallの修正が、新しいDetails Panelの追加にはまたLayoutの修正が必要になる。
DeepSeek Harnessは現在、この問題をSlotシステムで解決している。中核となる考え方は、こうまとめられる。ページ構造はSlot Ownerが宣言し、Feature Pluginは既存のSlotへ実装を登録する。React Treeは、現在のRuntime Stateにおける現在のPlugin Graphの投影である。
ページはまずSlot Treeである
DSH Web Shell自身は、内builtのSlotを1つだけ描画する。
root
ui-layout PluginはAppFrameをrootへ登録し、同じregistrationで4つのchild slotsを宣言する。
root
└── AppFrame
├── sidebar
├── conversation
├── details
└── shell.overlay
現在のソースにおける登録関係は次のとおりだ。[1]
このコードは同時に4つのことを行っている。
贡献 AppFrame
声明 child slot
声明 AppFrame store
声明业务 inject face
そのためSlotの宣言は、単独で管理される静的なSchemaではない。宣言は、そのレイアウト位置を所有するコンポーネントの登録時に行われる。
Slot Ownerが幾何構造を決める
AppFrame自体はProps経由でrenderSlot()を受け取るだけで、実際にレイアウトを所有する位置でそれを呼び出す。[2]
ここには明確な所有原則がある。
Slotを宣言したコンポーネントが、その位置のレイアウトとレンダリング権限を所有する。
Sidebar Pluginはsidebarの中身を決定できるが、Sidebarを3カラムレイアウトの幅にするかどうかは決められない。幅はAppFrameが所有しているため、renderSlot()の呼び出し地点からowner propsとして渡される。
これにより、Pluginシステムでよくある「Feature Pluginがコンテンツ-reverseで提供するとともに、global selectorやDOM queryでホストlayoutを改変する」という問題を避けられる。
Layout Owner
负责位置、尺寸、出现位置
│
│ owner props
▼
Slot Registrant
负责这个位置里的业务内容
childrenは宣言と認可を同時に表す
DSHのSlot設計には、もう一つ強い制約がある。コンポーネントは、自分のregistrationで宣言したchild slotsしか描画できない。[3]
AppFrameが次を宣言するとする。
sidebar
conversation
details
shell.overlay
そのためRendererは、AppFrameのPropsに対応spiritualrenderSlot()能力を注入する。
コンポーネントが古いrenderSlot closureを保持したままregistrationが解除された場合、RendererはStaleAuthorizationErrorを投げる。宣言していないSlotを描画しようとした場合はSlotOwnershipErrorだ。[4]
この設計はUIのlifecycleとPluginのlifecycleを結びつける。
Plugin Registration exists
↓
Child Slot Declaration exists
↓
renderSlot authorization exists
↓
Component can compose descendants
Pluginをunloadしたとき、次の順序で状態が崩れる。
Registration removed
↓
Child Slot declaration removed
↓
Descendant contributions collapse
↓
retained renderSlot binding becomes stale
第3ユニットで扱ったCordis Effectのlifecycleと同じ種類の問題だ。ただしここでは、作用先がUI Composition Graphになる。
conversationも自身の内部構造を宣言する
ui-conversation Pluginはconversation Slotに登録された後、さらに自身のchild slotsを宣言する。[5]
その一部は次のとおりだ。
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がネストしたPlugin構成を示す
ui-toolは現在、次の方法でToolCallTreeを登録している。
これによってToolCallTreeがtool-call Chat Nodeのrendererとして登録される。[6]
ここでは2層のdispatchがある。
ToolCallTree自身は、それぞれのTool用什么表示Component,彼此不知晓实际展示逻辑。它只是把: ToolCallTree自身は、各Toolの表示Componentを知っているわけではない。ただ、次の値をowner propsへまとめている。
callId
toolName
block
cwd
openFile
inspect
その後、toolNameに応じてkeyed Slotを呼び出す。[7]
新しい業務Tool UIを追加するには、次のように登録するだけでよい。
ToolCallTreeを修正する必要はない。
なぜまだslots.inject()が必要なのか
第3ユニットではCordisの動的依存関係をすでに扱った。UI Slotにも同じ時間軸の問題がある。Plugin Aが先にロードされる一方、Plugin Aが登録したいSlotはPlugin Bが後から宣言する、という状況も起こりうる。
もし直接次を実行すると、
その時点でconversation.chat.nodeが存在しなければ、登録は失敗する。システムがSlotのkind、scope、owner contractを確認できないからだ。
ctx.slots.inject()が提供するのは、「Slot Declarationへの依存」というsemanticsだ。[8]
そのreconcile logicは、次のようにまとめられる。
これはCordisのctx.inject(service, callback)が持つ依存有効化モデルにかなり近い。
違うのは、依存先がServiceからSlot Declarationに変わることだけだ。
そのためUI Pluginのロード順序を厳密に揃える必要はない。
ui-tool 先加载
conversation.chat.node 尚未声明
↓
ui-tool 等待
↓
ui-conversation 声明 slot
↓
ToolCallTree registration 生效
Slot Ownerをunloadすると、Tool Contributionも自動的に消える。
Slotは1種類に留まらない
UIの位置によって、必要な組み合わせルールも異なる。DSHの現在のSlot Coreは、主なkindをいくつかサポートしている。[3][4]
single
1つの位置で、現在のwinnerを1つだけ選ぶ。次の用途に向いている。
root
conversation
details
list
複数のcontributionを同時に保持し、順番に描画する。次の用途に向いている。
shell.overlay
header.actions
input.dock
keyed
Ownerがbusiness 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
ここが、Plugin化されたUIで最も混乱しやすい部分だ。
各Pluginがctx、Global Store、React Context、Service Locatorから自由にデータを取得できてしまうと、Slotはコンポーネントの選択を解決するだけで、依存の境界は解決しない。
DSHは現在、コンポーネントのPropsを次の4つのshareに分けている。[3]
Runtime Share
+ RenderSlot Share
+ Store Share
+ Business Inject Share
さらに、OwnerがrenderSlot()の呼び出し地点で渡すowner propsを加える。
Runtime Share
Frameworkが安定した能力を提供する。
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をclosureで捕捉し、inject経由で通常のデータとcallbackを返せる。
openFile()
inspect()
stop()
selectWorkspace()
業務Component自体はctxに触らない。
HookはReact Binding層で生成される
Runtime層が維持するのは、素のObservableだ。
React Hookは持たない。[4]
web-reactの段階に来て初めて、RendererがObservableを次の形にbindする。
useSession
useStore
useProjection
use<Name>
現在のscoped-slots.tsxでは、standardKit()がscope、session info、store、childrenに基づいてframework Propsを生成する。[9]
Componentを実際にrenderするとき、Propsのmerge relationshipは明確だ。[9]
contextual hooksがあれば、動的なHook Propsがもう1層mergeされる。
最終的なComponent Propsは、次のように表せる。
finalProps
= framework runtime props
+ child-slot capabilities
+ store props
+ plugin injected callbacks/data
+ slot-level injected values
+ owner props
Owner propsを最後に置くのは、それが実際の現在のrender位置で既知のデータを表すためだ。
なぜ業務コンポーネントはctxを直接取得できないのか
現在のDSH Clientの制約は、明確にこう定めている。Cordis ctxはPlugin ApplyとInject Factoryの世界にだけ存在し、Featureの.tsx ComponentはContextを直接読まない。[10]
この制約的价值は、Componentの依存関係をPropsだけで完全に把握できることだ。
テスト(fake dataとcallback),只需要传入假数据和callback。
Component内部で次のように呼んだら、
Componentの本当の依存関係がRuntime Containerの中に隠れてしまい、Plugin UIはすぐにService Locator architectureへ退行する。
だからDSHはCordisとReactを意図的に分離している。
Storeにも明確な境界がある
Plugin UIは、共有interaction stateをまだ必要とする。
selected tool call
panel width
active tab
draft
DSHのSlot RegistrationはStoreを宣言できる。ただし現在のルールでは、Session、Connection、Frameといった業務状態をこれらのStoreに入れてはならない、と明記されている。[10]
そのため状態の所有権は3層に分かれる。
すべての状態をZustandへ統一するより複雑だが、境界は安定する。
React Mountはいったいいつ起きるのか
Pluginがロードされたことは、ComponentがすぐMountされることではない。
Componentが実際に出現するには、複数の条件が同時に満たされる必要がある。
最低でも3つの時点を区別する必要がある。
1. Plugin activation
2. UI registration
3. React mount
PluginがすでにActiveでも、登録先のSlotが現在のページで描画されていなければ、ComponentはMountされない。
同様に、ComponentのUnmountがPluginのunloadを意味一定是限らない。次の可能性があるだけだ。
session switched
slot key changed
chain election changed
owner stopped rendering the slot
この分離が、Plugin UIのlifecycleを理解するための基礎になる。
SessionEventからTool Cardまでの完全な経路
ここまでで、前の2記事とSlot systemをつなげられる。
ただし、この経路でCordisが直接Reactを「描画する」わけではない。
CordisはPlugin lifecycleを管理する。Slot RegistryはUI Contribution Graphを管理する。Conversation RuntimeはEvent Projectionを管理する。web-reactはObservableとSlot EntryをReactへbindする。最後にComponentが消費するのはPropsだけだ。
React TreeはPlugin Graphの実行時の投影
従来のReactアプリケーションなら、ソース中のJSXからComponent Treeの大部分を静的に読み取れた。
Plugin化された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 Componentが出てこない場合は、次の経路を調べていく。
React Component Treeを起点に探すのではない。
設計上の判断
Plugin化されたAgent UIで最も核心的な境界は、「Slotを使うかどうか」ではない。
重要なのは、3種類の所有権を分離することだ。
Runtime
拥有业务状态与 Projection
Slot Owner
拥有布局位置和 Composition Policy
Feature Plugin
拥有业务 renderer 与局部交互
React Componentは一番末端にあり、この3種類の所有権が共同で形にするPropsだけを受け取る。
Tool、Plan、Workflow、SubAgentのFeatureを追加する場合、拡張は主に新しいDefinitionと新しいSlot Contributionとして現れる。中央のChatView、AppFrame、global Storeをますます拡大させる必要はない。
次の記事では、最小限の実装でこのモデルをつなぐ。ServerがSession Event LogとRunを維持し、Browserは復元可能なEvent Streamを通じてClient Runtimeを確立する。Projection EngineがView Nodeを生成し、Slot RegistryがPlugin 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