앞 글에서는 데이터 흐름을 ConversationViewNode까지 확장했다. 이제 Runtime은 히스토리 복원, 실시간 Event 병합, 업무 Projection을 마친 상태이며, React가 마주하는 것은 안정된 View Node들의 집합이다.
남은 문제는 이 Node들을 어떤 컴포넌트가 렌더링할지를 누가 결정하느냐이다.
초기의 Agent UI는 판단 로직을 ChatView에 집중하는 경우가 많다.
Feature가 적을 때는 이 구조가 직관적이다. 하지만 Harness 플러그인이 늘어날수록 모든 UI 기능의 중앙 의존점이 된다. Workflow 플러그인을 추가하려면 ChatView를 수정해야 하고, Tool Card 종류를 추가하려면 ToolCall을 수정해야 하며, 새로운 Details Panel을 추가하려면 다시 Layout을 수정해야 한다.
DeepSeek Harness은 현재 슬롯 시스템으로 이 문제를 해결한다. 핵심 아이디어를 한 문장으로 요약하면 다음과 같다. 페이지 구조는 슬롯 소유자가 선언하고, 피처 플러그인은 기존 슬롯에 구현을 등록하며, React Tree는 현재 Plugin Graph를 현재 Runtime State에 투영한 결과다.
페이지는 먼저 슬롯 트리로 시작한다
DSH Web Shell 자체는 내장 슬롯 하나만 렌더링한다.
root
ui-layout 플러그인은 AppFrame을 root에 등록하고, 같은 등록에서 네 개의 자식 슬롯을 선언한다.
root
└── AppFrame
├── sidebar
├── conversation
├── details
└── shell.overlay
현재 소스의 등록 관계는 다음과 같다. [1]
이 코드는 네 가지 일을 동시에 처리한다.
기여 AppFrame
선언 child slot
선언 AppFrame store
선언 업무 inject face
따라서 슬롯 선언은 독립적으로 관리되는 정적 Schema가 아니다. 해당 레이아웃 위치를 소유한 컴포넌트를 등록할 때 선언이 이루어진다.
슬롯 소유자가 기하 구조를 결정한다
AppFrame은 Props를 통해 renderSlot()만 전달받고, 실제 레이아웃을 소유한 위치에서 이를 호출한다. [2]
여기에는 명확한 소유권 원칙이 생긴다.
슬롯을 선언한 컴포넌트는 해당 위치의 레이아웃과 렌더링 권한을 소유한다.
Sidebar Plugin은 sidebar 내부에 표시할 내용을 정할 수 있지만, Sidebar가 3열 레이아웃에서 차지하는 너비는 정할 수 없다. 너비는 AppFrame에 속하므로 owner props로 renderSlot() 호출 지점에서 전달된다.
이렇게 하면 피처 플러그인이 콘텐츠를 기여하는 동시에 전역 selector나 DOM query를 통해 호스트 레이아웃까지 수정하는, 플러그인 시스템에서 흔히 발생하는 문제 한 종류를 피할 수 있다.
관계는 다음과 같이 바뀐다.
Layout Owner
负责位置、尺寸、出现位置
│
│ owner props
▼
Slot Registrant
负责这个位置里的业务内容
children는 선언과 동시에 권한 부여도 나타낸다
DSH의 슬롯 설계에는 더 강한 제약이 하나 더 있다. 컴포넌트는 자신의 등록에 선언된 자식 슬롯만 렌더링할 수 있다. [3]
예를 들어 AppFrame은 다음을 선언한다.
sidebar
conversation
details
shell.overlay
그래서 Renderer는 AppFrame의 Props에 대응하는 renderSlot() 능력을 주입한다.
컴포넌트가 오래된 renderSlot 클로저를 보유한 상태에서 해당 등록이 제거되었다면 Renderer는 StaleAuthorizationError를 던진다. 선언하지 않은 슬롯을 렌더링하려 한다면 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 슬롯에 등록된 뒤에는 다시 자신의 자식 슬롯을 선언한다. [5]
그 안에는 다음이 포함된다.
Session body도 다시 선언한다.
conversation.session
└── ConversationSession
└── conversation.view
Chat View는 최종적으로 Chat Node를 렌더링할 위치까지 소유한다.
따라서 페이지는 하나의 App.tsx 안에 완전히 선언되는 대신, 플러그인 등록이 단계적으로 쌓이며 형성된다.
Shell
↓
root
↓
ui-layout
↓
conversation
↓
ui-conversation
↓
conversation.chat.node
↓
ui-tool / ui-goal / workflow / ...
각 계층은 자신이 소유한 자식 위치만 알고 있다.
Tool UI는 중첩된 플러그인 조합을 보여준다
현재 ui-tool은 다음과 같이 동작한다.
ToolCallTree를 tool-call Chat Node의 renderer로 등록하는 방식이다. [6]
여기에는 두 단계의 디스패치가 있다.
ToolCallTree 자체는 각 Tool의 표시용 컴포넌트를 알지 못한다. 다음 값들만 owner props로 조합한 뒤, toolName을 기준으로 keyed 슬롯을 호출한다. [7]
callId
toolName
block
cwd
openFile
inspect
따라서 새로운 업무 Tool UI를 추가하려면 다음만 등록하면 된다.
ToolCallTree를 수정할 필요가 없다.
왜 slots.inject()가 아직 필요한가
세 번째 단원에서 Cordis의 동적 의존성을 이미 다뤘다. UI 슬롯에도 동일한 시간 순서 문제가 있다. Plugin A가 먼저 로드되더라도, A가 등록하려는 슬롯은 나중에 로드되는 Plugin B가 선언할 수 있다.
다음 코드를 그대로 실행하면 다음과 같다.
그런데 conversation.chat.node가 아직 존재하지 않는다면 등록은 실패해야 한다. 시스템이 이 슬롯의 kind, scope, owner contract를 확인할 수 없기 때문이다.
ctx.slots.inject()는 “슬롯 선언에 의존한다”는 의미를 제공한다. [8]
reconcile 로직을 압축하면 다음과 같다.
이 방식은 Cordis의 ctx.inject(service, callback) 의존성 활성화 모델과 매우 유사하다.
차이는 의존 대상이 Service에서 Slot Declaration으로 바뀌었다는 점뿐이다.
따라서 UI Plugin의 로드 순서를 엄격히 정할 필요가 없다.
ui-tool 先加载
conversation.chat.node 尚未声明
↓
ui-tool 等待
↓
ui-conversation 声明 slot
↓
ToolCallTree registration 生效
슬롯 소유자를 언마운트하면 Tool 기여 항목도 자동으로 사라진다.
슬롯은 단일 유형이 아니다
서로 다른 UI 자리에는 서로 다른 조합 규칙이 필요하다. DSH의 현재 Slot Core은 몇 가지 주요 kind를 지원한다. [3][4]
single
한 자리에서 현재 선택된 구현 하나만 고른다. 다음에 적합하다.
root
conversation
details
list
여러 기여 항목이 동시에 존재하며 순서대로 렌더링된다. 다음에 적합하다.
shell.overlay
header.actions
input.dock
keyed
Owner가 업무 key를 기준으로 renderer를 선택한다. Tool View는 다음과 같이 key를 사용한다.
key = toolName
따라서 다음과 같이 라우팅된다.
read → ReadToolView
bash → BashToolView
unknown → fallback
chain
라우팅 방향이 반대다. Owner가 renderer key를 지정하지 않고, 각 등록이 스스로 select(ownerProps)를 제공한다. priority 순으로 실행되며, 처음으로 null이 아닌 값을 반환한 entry가 선택된다. [3][4]
현재 누가 이 자리를 차지할 자격이 있는지 판단하는 Approval, Question, Composer takeover 같은 상황에 적합하다.
따라서 슬롯은 “React Component Registry”일 뿐 아니라 국소적인 Composition Policy도 정의한다.
컴포넌트가 최종적으로 받는 Props
플러그인 기반 UI에서 가장 쉽게 혼란스러워지는 지점이다.
각 Plugin이 ctx, Global Store, React Context, Service Locator에서 데이터를 자유롭게 가져온다면, 슬롯은 컴포넌트 탐색 문제만 해결했을 뿐 의존성 경계는 해결하지 못한다.
DSH는 현재 컴포넌트 Props를 네 가지 공유 영역으로 나눈다. [3]
Runtime Share
+ RenderSlot Share
+ Store Share
+ Business Inject Share
여기에 Owner가 renderSlot() 호출 지점에서 전달한 owner props가 추가된다.
Runtime 공유 영역
프레임워크가 다음과 같은 안정적인 기능을 제공한다.
sessionId
useSession
useSessions
useWorkspaces
useProjection
RenderSlot 공유 영역
등록에서 자식 슬롯을 선언했다면 다음을 얻는다.
renderSlot
renderSlotChain
Store 공유 영역
등록에서 store를 선언했다면 다음을 얻는다.
useStore
actions
Business Inject 공유 영역
Plugin apply 단계에서 Cordis Service를 클로저로 캡처할 수 있으며, inject를 통해 일반 데이터와 callback를 반환할 수 있다.
예를 들면 다음과 같다.
openFile()
inspect()
stop()
selectWorkspace()
업무 컴포넌트 자체는 ctx를 직접 다루지 않는다.
훅은 React Binding 계층에서 생성된다
Runtime 계층이 유지하는 것은 순수한 Observable이다.
여기에는 React Hook이 들어 있지 않다. [4]
web-react까지 오면 Renderer가 Observable을 다음과 같이 React에 바인딩한다.
useSession
useStore
useProjection
use<Name>
현재 scoped-slots.tsx에서 standardKit()은 scope, session info, store, children을 바탕으로 프레임워크 Props를 생성한다. [9]
컴포넌트를 실제로 렌더링할 때 Props의 병합 관계는 매우 명확하다. [9]
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가 마지막에 덮어쓰는 이유는 현재 실제 렌더링 지점에서 알려진 데이터를 나타내기 때문이다.
왜 업무 컴포넌트가 ctx를 직접 가져갈 수 없는가
현재 DSH Client 제약은 명확히 규정한다. Cordis ctx는 Plugin Apply와 Inject Factory의 영역에만 존재하며, Feature .tsx Component는 Context를 직접 읽지 않는다. [10]
이 제약의 가치는 컴포넌트의 의존성을 Props만으로 완전히 파악할 수 있다는 데 있다.
예를 들면 다음과 같다.
테스트에서는 가짜 데이터와 callback만 전달하면 된다.
컴포넌트 내부에서 다음과 같이 호출한다면,
컴포넌트의 실제 의존성이 Runtime Container 안에 숨겨지고, 플러그인 UI도 이내 Service Locator 구조로 퇴행한다.
따라서 DSH는 Cordis와 React를 의도적으로 분리한다.
Store에도 명확한 경계가 있다
Plugin UI에도 공유해야 하는 인터랙션 상태가 여전히 필요하다. 예를 들면 다음과 같다.
selected tool call
panel width
active tab
draft
DSH Slot Registration은 Store를 선언할 수 있지만, 현재 규칙은 Session, Connection, Frame 같은 업무 상태를 이 Store에 넣지 말라고 명시한다. [10]
따라서 상태 소유권은 세 층으로 나뉜다.
모든 상태를 Zustand로 통일하는 것보다 복잡하지만, 경계는 더 안정적이다.
React 마운트는 정확히 언제 발생하는가
플러그인이 로드되었다고 해서 컴포넌트가 바로 마운트되는 것은 아니다.
컴포넌트가 실제로 나타나려면 여러 조건이 동시에 충족되어야 한다.
따라서 최소 세 시점을 구분해야 한다.
1. Plugin activation
2. UI registration
3. React mount
Plugin은 이미 Active일 수 있지만, 현재 페이지가 렌더링하지 않는 슬롯을 등록했을 수도 있다. 이 경우 Component는 지금 마운트되지 않는다.
마찬가지로 Component의 언마운트가 Plugin의 언마운트를 뜻하지 않을 수도 있다. 그저 다음과 같은 변화일 수 있다.
session switched
slot key changed
chain election changed
owner stopped rendering the slot
이런 분리는 플러그인 UI 수명 주기를 이해하는 데 기초가 된다.
SessionEvent에서 Tool Card까지의 전체 경로
이제 앞의 두 글과 슬롯 시스템을 연결할 수 있다.
이 경로에서 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 컴포넌트가 나타나지 않을 때는 다음 경로를 따라 점검한다.
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 기여 항목으로 나타난다. 중앙의 ChatView, AppFrame 또는 전역 Store를 계속 확장할 필요가 없다.
다음 글에서는 최소한의 구현으로 이 모델을 연결해 보겠다. Server는 Session Event Log과 Run을 유지하고, 브라우저는 복구 가능한 Event Stream을 통해 Client Runtime을 구축하며, Projection Engine은 View Node를 생성하고, Slot Registry는 플러그인 등록에 따라 renderer를 선택한다.
참고자료
[1] DeepSeek Harness ui-layout 등록: 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 슬롯: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-slots/README.md
[4] DeepSeek Harness 슬롯 렌더러 계약: 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 규칙: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md