DSH / Atlas
2026-09-14proposedarchitecture

Two-stage Composer and DraftEditor isolation

Composer 与 DraftEditor 隔离的两阶段重构

One Client needs to edit the same Session's draft and pending attachments in multiple views. A Lexical editor binds only one DOM root; multiple presentation locations need multiple editor instances, but must not own unrelated drafts or upload tasks, or make the Session Controller understand carets, composition, or DOM state. The current [SessionInputShell](../../../../packages/client/ui-conversation/src/client/input/

English

Problem

One Client needs to edit the same Session's draft and pending attachments in multiple views. A Lexical editor binds only one DOM root; multiple presentation locations need multiple editor instances, but must not own unrelated drafts or upload tasks, or make the Session Controller understand carets, composition, or DOM state.

The current SessionInputShell combines Lexical operations, draft projection, the submission state machine, and failure recovery. InputBar combines editor presentation, DOM bindings, attachment intake, and submission controls. Implementing multiple instances directly in these files would mix code extraction with behavior changes.

ConversationController already owns attachment entities and upload tasks centrally; the shell retains only ordered attachment IDs. Selecting a skill inserts ordinary /name text whose highlighting derives from a lexicon; atomic file and Session references use chips carrying source identity. A shared draft must not lose these references by synchronizing text alone, and does not require copying attachment entities.

This proposal details editor isolation for #3951, following Client Session and UI ownership. The Session activity view, residency states, and eviction policy are designed independently; #4138 is only a Host lifecycle reference. This proposal implements none of those features and does not repeat the Conversation component decomposition in #3984.

Proposal

Use two independent PRs. Stage one concentrates existing editor implementation into explicit locations for behavior changes; stage two changes behavior only. DraftEditor names the draft-editing area, while Composer names the complete writing area including attachments and submission controls. Keep input/, skeleton/, InputBar, InputHub, and SessionInputShell; directory moves and renames are not refactoring deliverables.

Stage one: five mechanical responsibility extractions

The ui-conversation paths below are relative to packages/client/ui-conversation/src/client/. Every new file must contain logic already executed today, not placeholder interfaces or future features.

Original locationExtraction destinationLocation for later behavior changes
Lexical creation, registration, projection, node operations, and cleanup in input/facade.tsinput/editor/runtime.tsOne editor's implementation and its creation, binding, and disposal
Text-area JSX in skeleton/InputBar.tsxinput/editor/DraftEditor.tsxOne editor's presentation, excluding the attachment rail and submission orchestration
Focus, selection reveal, wheel, keymap, and picker binding functions in InputBar.tsxinput/editor/view-binding.tsDOM interaction and editor bindings for one mounted view
Range, reference, and keyboard interface types in contract/input.tscontract/draft-editor.tsEditor-facing data and operation types; submission and shared state stay in the original file
Document drop effect implementation in ui-attachment/src/client/ComposerAttachments.tsxui-attachment/src/client/drop-events.tsDocument drag-and-drop registration, routing, and cleanup

The existing shell creates and delegates to the internal object in runtime.ts. That object retains the original editor, NodeKey map, projection, and Lexical registrations; it neither copies those states nor independently decides whether editing or submission is permitted. The shell retains SubmitMachine, draft revision, attachment IDs, notices, attempts, serialization, and success/failure recovery decisions.

Methods combining guards and node operations retain their guards at the original location. For example, beginCommand keeps its span/phase checks, node replacement, and machine dispatch in the same order; failure recovery preserves batch ordering, revision guards, restoration flags, and history cleanup timing. Editor updates still call the shell synchronously at the original publication point, without another Promise, effect, or notification turn.

DraftEditor.tsx extracts presentation without adding a DOM wrapper. All existing React hooks, refs, dependency arrays, and relative effect order remain in InputBar; effects delegate to ordinary functions at their original call sites. CSS files, class keys, React keys, placeholder order, and decorator order remain unchanged. The new component does not take over editor creation or hold another draft.

contract/draft-editor.ts receives TokenSpan, ReferenceInsert, ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, EditSelection, and Occurrence. Names and members remain unchanged, consumers import from the actual declaration owner, and existing public exports retain their names and visibility. ComposerKeyboard temporarily still depends on shared InputState; this is not an independent controlled-editor protocol.

Stage-one invariants

  • InputHub still creates one shell and one editor per Session, with unchanged creation, reuse, and disposal timing and counts.
  • Lexical remains the draft authority; Undo/Redo, NodeKey identity, span checks, and revision rules remain unchanged, without a second document or store.
  • useInput, inputActions, Slots, events, inject declarations, and public APIs retain their names, payloads, and behavior; Host protocols and persistence formats do not change.
  • Attachment selection, upload timing, image previews, submission batches, success clearing, failure restoration, and notice rules remain unchanged.
  • Each original component still registers document drop listeners in the same effect; the single picker, duplicate drop, and single editor/root limitations remain.
  • Tests change only type imports that actually need updating; test filenames, assertions, recorded Sessions, and expected outputs remain unchanged, without snapshot refreshes.
  • No existing file moves, existing private-name changes, CSS changes, new packages, dependencies, renderer scopes, or general state framework.

Isolation actually achieved in stage one

SubjectResult
Editor implementationNode and projection operations belong to runtime, presentation to DraftEditor, and DOM bindings to view-binding
Submission and attachment orchestrationStill owned by the original shell and ConversationController, not DraftEditor
State across different SessionsRemains isolated under existing rules
Shared state within one SessionStill reuses the original shell, without duplicate attachments or uploads
Independent editors within one SessionNot implemented; views still share one Lexical editor
Independent selection, IME, Undo, and menu originsNot implemented; separating code does not change runtime ownership
Multi-view picker, focus, and document drop routingNot implemented; binding code has a separate location for modification

Stage two: behavior changes only

Stage two implements a shared draft and multiple editors directly in the locations above. It must not move existing files or directories, perform pure renames or helper/class/component extractions, reorder existing tests, or clean up formatting or comments. New types, implementations, and tests required by new behavior may be added, but copying old code into a new file and deleting its original does not evade this restriction.

If behavior implementation still needs structural preparation, complete stage one first: amend its PR before merge, or add a separate mechanical prerequisite PR after merge. The behavior PR uses that mechanical result as its base and cannot include the preparation.

Final state ownership

The shared Composer model evolves the responsibilities of the existing SessionInputShell without requiring another rename. The Session Controller continues to own only Session business state and does not import DraftEditor, Lexical, or the shared draft document.

StateFinal ownerMulti-view requirement
Draft text, semantic references, and content revisionSession-associated shared Composer modelPublish edits from either view to every view through one reactive source
Ordered attachment IDs, claims, submission attempts, and failure recoveryShared Composer modelSettle each submission once; operations in either view affect the same pending input
File, Blob URL, upload tasks, progress, and receiptsExisting attachment ownerDo not copy per view; unmounting one view does not cancel resources used by another
Lexical, DOM, NodeKey mappings, selection, and IME preeditEach DraftEditor instanceTwo independent editors/roots; unmounting one does not detach the other
Menu anchor, file dialog, and focusInitiating viewRoute by operation origin, not a single Session picker
Session history, running, and queueSession ControllerKeep reading existing sources instead of copying them into the draft model

The renderer still binds React hooks from bare observables, and business components read and write through existing standard props. The shared model accepts neither DOM, Lexical NodeKeys, nor composition intermediate state; DraftEditor receives neither Session/Context nor upload services, only draft data, presentation data, and editing/intent callbacks.

Shared content and synchronization requirements

Draft content must represent ordinary text, newlines, and atomic references with complete ReferenceInsert information independently of Lexical. Shared reference identity must not depend on one editor's NodeKey; each instance privately maps it to its own nodes. Skills remain ordinary /name text, with both views deriving highlights from the same text and lexicon, without an extra selected-skill list or changes to Host recognition.

Draft text is small, so synchronization may use complete semantic documents without requiring a collaborative-editing algorithm. The shared model accepts edits, assigns revisions, and publishes; editors distinguish local changes from external rendering to avoid feedback loops. Callbacks from stale revisions, prior model lifetimes, or unmounted views must not overwrite current content. Submission freezing, success clearing, failure restoration, and attachment changes must reach all views through the same shared source.

IME preedit belongs to the local instance, and updates from another view must not directly disrupt text under composition. Stage two must define and verify how another view's edits, submission clearing, and model release interact with composition. Undo/Redo must also operate on one logical draft, rather than letting two Lexical histories restore stale whole documents over each other; synchronization and history implementation are outside the mechanical stage.

Programmatic insertion, menu selection, file selection, and focus restoration need the initiating view's temporary identity. Closing that view must not redirect late UI actions into another view of the same Session. Document drop must select one explicit target and process the drop exactly once; origin routing and deduplication are stage-two behavior.

Existing text-draft restoration after refresh must remain, without implicitly promising persistence for structured references, File objects, or cross-browser collaboration. The Session activity view and LRU/timeout policy remain independent of this editing protocol.

Alternatives considered

Only rename input or relocate it to composer. This does not separate Lexical operations, view bindings, and submission decisions; behavior implementation would still need to extract old code from large files, so it is not a stage-one deliverable.

Bind one Lexical editor to two DOM roots. This conflicts with Lexical's single-root model; copying React presentation does not create two independently interactive editors.

Give each Composer an independent draft and attachments. This fails the same-Session shared-editing requirement and introduces conflicting attachment and submission ownership.

Implement a shared DraftDocument, Undo, or drop deduplication in the mechanical stage. This changes authority, lifecycle, or event-processing counts and cannot be reviewed as behavior-preserving preparation.

Acceptance criteria

Stage one completes the five extractions and required imports, JSDoc, and README updates; review compares original method bodies, branches, callback order, hooks, DOM, and cleanup. Existing editing, reference, claim, attachment, submission, failure-restoration, and unmount tests continue to pass; focused browser regressions run against built artifacts with unchanged expected output. Type and documentation checks cover relocated declarations and bilingual pairs. New dual-instance functionality is not a stage-one acceptance condition.

Stage two uses two genuinely mounted Composers for one Session to verify bidirectional text and chip synchronization, skill highlights, shared attachments and progress, submission clearing/failure restoration, IME/Undo, origin routing, and continued operation after either view unmounts. Its diff contains behavior implementation and corresponding tests only, without mechanical cleanup.

Risks

Even stateless JSX extraction can alter ref or effect timing; therefore hooks and refs retain their host, and DOM gains no wrapper. Lexical extraction can alter nested updates, projection caching, or history cleanup order; therefore retain original operation bodies and compare execution order instead of rewriting algorithms.

Stage one still cannot mount two editors for one Session and retains the existing picker/drop limitations. Confusing directory isolation with state isolation could cause roots to detach each other, duplicate attachment intake, or misroute focus; stage-two dual-instance behavior tests must close these gaps.

中文

问题

同一个 Client 需要在不同视图中编辑同一个 Session 的草稿和待发送附件。Lexical 的一个 editor 只能绑定一个 DOM root;多个呈现位置需要多个编辑实例,但不能各自拥有互不相干的草稿和上传任务,也不能让 Session Controller 理解光标、输入法或 DOM。

当前 SessionInputShell 同时包含 Lexical 操作、草稿投影、提交状态机和失败恢复。InputBar 同时包含编辑区呈现、DOM 绑定、附件入口和发送控件。直接在这两个文件中实现多实例会让代码提取与行为差异混在一起。

附件实体和上传任务已经由 ConversationController 集中管理,shell 只保留有序附件 IDs。skill(技能)选择插入普通 /name 文本,高亮由词表派生;文件和 Session 的原子引用则使用带来源身份的 chip。共享草稿不能只同步文字而丢失这些引用,也不需要复制附件实体。

本提案细化 #3951 的编辑器隔离,遵循 Client Session 与 UI 所有权。Session 活跃视图、驻留状态与回收策略独立设计;#4138 仅作为 Host 生命周期参考。本提案不实现这些功能,也不重复 #3984 的 Conversation 组件拆分。

提案

采用两个独立 PR(Pull Request)。第一阶段集中既有编辑实现,为行为改动准备明确位置;第二阶段只修改行为。DraftEditor 专指草稿编辑区,Composer 指包含附件和发送控件的完整编写区域。保留 input/、skeleton/、InputBar、InputHub 和 SessionInputShell,不以改目录或改名作为重构成果。

第一阶段:五处机械职责提取

以下 ui-conversation 路径相对 packages/client/ui-conversation/src/client/。每个新文件必须承载当前已经执行的逻辑,不建立占位接口或未来功能。

原位置提取位置后续行为修改的落点
input/facade.ts 的 Lexical 创建、注册、投影、节点操作和清理input/editor/runtime.ts单个 editor 的实现及其创建、绑定和释放
skeleton/InputBar.tsx 的文字区域 JSXinput/editor/DraftEditor.tsx单份编辑区的呈现,不包含附件栏和提交编排
InputBar.tsx 的 focus、selection reveal、wheel、keymap、picker 绑定函数input/editor/view-binding.ts单个挂载视图的 DOM 交互和编辑器绑定
contract/input.ts 的范围、引用、键盘接口类型contract/draft-editor.ts编辑器对外数据与操作类型;提交和共享状态仍留原文件
ui-attachment/src/client/ComposerAttachments.tsx 的 document drop effect 实现ui-attachment/src/client/drop-events.tsdocument 拖放监听的注册、路由和清理

runtime.ts 内部对象由现有 shell 创建并委托调用。它保管原 editor、NodeKey 映射、投影和 Lexical 注册;不复制这些状态,也不独立决定能否编辑或提交。shell 继续持有 SubmitMachine、draft revision、附件 IDs、通知、attempt、序列化以及成功/失败恢复决策。

同时涉及判断和节点操作的方法在原位置保留判断。例如 beginCommand 的 span/phase 检查、节点替换、machine dispatch 的顺序不变;失败恢复的批次排序、revision 保护、恢复标志和 history 清理时点不变。editor 更新仍在原同步位置回调 shell 发布状态,不增加 Promise、effect 或通知轮次。

DraftEditor.tsx 是无额外 DOM 包装的呈现提取。所有既有 React 钩子、refs、依赖数组和 effect 相对顺序仍留在 InputBar;effect 只在原调用位置委托普通函数。CSS 文件、class keys、React keys、placeholder 与 decorator 顺序均不变。新组件不接管 editor 创建或持有另一份草稿。

contract/draft-editor.ts 移入 TokenSpan、ReferenceInsert、ArbitrateKey、ArbitrateOutcome、ComposerKeyboard、EditSelection 和 Occurrence。名称和成员不变,所有消费方从实际声明处导入;既有公开出口保持原名称和可见集合。ComposerKeyboard 仍暂时依赖共享 InputState,这不是独立受控编辑协议。

第一阶段不变项

  • InputHub 仍按 Session 创建一个 shell 和一个 editor,创建、复用、dispose(资源释放)的时点及次数不变。
  • 草稿真值仍在 Lexical,Undo/Redo、NodeKey 身份、span 检查和 revision 规则不变;不增加第二份文档或存储。
  • useInput、inputActions、Slot、事件、inject 及公开 API 的名称、载荷和行为不变;不改 Host 协议和持久化格式。
  • 附件选择、上传时机、图片预览、提交批次、成功清空、失败恢复及通知规则不变。
  • document drop 仍在每个原组件的同一 effect 中注册;单 picker、重复 drop、单 editor/root 的限制原样保留。
  • 测试只修改实际需要的类型导入;不改测试文件名、断言、录制 Session 或预期输出,不刷新快照。
  • 不搬已有文件,不改已有私有名字,不改 CSS,不增包、依赖、renderer scope 或通用状态框架。

第一阶段实际隔离程度

内容完成后的状态
编辑器实现节点和投影操作归 runtime,呈现归 DraftEditor,DOM 绑定归 view-binding
提交与附件编排仍由原 shell 和 ConversationController 管理,不落入 DraftEditor
不同 Session 的状态继续按原规则隔离
同 Session 的共享状态继续复用原 shell;没有双份附件或上传任务
同 Session 的独立编辑实例未实现,仍共享一个 Lexical editor
独立 selection、IME、Undo 和菜单来源未实现;代码位置分开不代表运行时归属已改变
picker、focus、document drop 的多视图路由未实现;绑定代码已有单独修改位置

第二阶段:只调整行为

第二阶段直接在上述位置实现共享草稿和多编辑实例。禁止移动已有文件或目录、纯改名、纯提取 helper/class/component、重排已有测试,以及格式或注释清理。新增行为需要的新类型、实现和测试可以增加,但不得复制旧代码到新文件后删除原文来规避约束。

如果行为实现仍需要结构准备,必须先补第一阶段:未合入时修改第一阶段 PR;已合入时单独增加机械前置 PR。行为 PR 以该机械结果为 base,不能夹带机械准备。

最终状态归属

共享 Composer 模型是现有 SessionInputShell 的职责演进,不要求再次改名。Session Controller 继续只管理会话业务,不 import DraftEditor、Lexical 或共享草稿文档。

状态最终 owner多视图要求
草稿正文、语义引用、内容 revisionSession 关联的共享 Composer 模型任一处编辑后,通过同一个响应式来源发布给所有视图
有序附件 IDs、认领、提交 attempt 和失败恢复共享 Composer 模型每个提交只结算一次,任一视图的操作作用于同一批输入
File、Blob URL、上传任务、进度和凭证既有附件管理 owner不随视图复制;卸载一个视图不取消其他视图所用资源
Lexical、DOM、NodeKey 映射、selection、IME preedit各 DraftEditor 实例两个独立 editor/root;卸载一处不解绑另一处
菜单锚点、文件对话框和焦点发起操作的视图按操作来源路由,不以 Session 唯一 picker 代替来源
Session 历史、running、queueSession Controller继续读取现有来源,不复制进草稿模型

React 钩子仍由 renderer 从裸 observable 绑定,业务组件通过现有标准 props 读写。共享模型不接收 DOM、Lexical NodeKey 或输入法中间态;DraftEditor 不接收 Session/Context 或上传服务,只接收草稿数据、显示数据与编辑/意图回调。

共享内容和同步要求

草稿内容必须能独立于 Lexical 表示普通文本、换行和带完整 ReferenceInsert 信息的原子引用。引用的共享身份不能依赖某个 editor 的 NodeKey;各实例私有映射到自己的节点。skill 保持普通 /name 文本,两处从同一文本和词表派生高亮,不引入额外的已选 skill 列表或改变 Host 识别规则。

草稿文本量小,可以使用完整语义文档同步,不要求协同编辑算法。共享模型负责接受编辑、分配 revision 和发布,编辑器区分本地产生的变化与外部呈现,避免回声循环。旧 revision、旧模型生命周期和已卸载视图的回调不能覆盖新内容。提交冻结、成功清空、失败恢复和附件变化必须通过同一共享来源到达所有视图。

IME preedit 属于本地实例,远端视图更新不能直接破坏正在组合的文本。来自另一视图的修改、发送清空和模型释放如何与组合态相遇,必须在第二阶段定义并验证。Undo/Redo 也必须作用于同一份逻辑草稿,不能让两份 Lexical history 互相恢复陈旧整篇文档;具体同步与历史实现不属于机械阶段。

程序化插入、菜单选择、文件选择器和焦点恢复要携带发起视图的临时身份。视图关闭后不能把迟到的 UI 操作转发给另一个同 Session 视图。document drop 必须明确一次拖放选哪个目标并保证仅处理一次;来源路由和去重均是第二阶段行为。

刷新后的既有文字草稿恢复应保留,但不默认新增结构化引用、File 或跨浏览器协作的持久化承诺。Session 活跃视图及 LRU/时限策略不与这份编辑协议绑定。

考虑过的替代方案

仅把 input 改名或平移到 composer。 不能分离 Lexical 操作、视图绑定和提交决策,后续行为实现仍需从大文件中提取旧代码,因此不作为第一阶段成果。

让一个 Lexical editor 同时挂两个 DOM root。 与 Lexical 的单 root 模型冲突,不能用 React 复制呈现来获得两个可独立交互的编辑器。

每个 Composer 独立草稿和附件。 不满足同一 Session 共享编辑的要求,还会引入附件和提交所有权分歧。

机械阶段直接实现共享 DraftDocument、Undo 或 drop 去重。 改变真值、生命周期或事件处理次数,无法作为行为不变的前置改动审查。

验收标准

第一阶段必须完成五处提取及必要导入、JSDoc 和 README 更新;逐项核对原方法体、分支、回调次序、Hooks、DOM 和清理。现有编辑、引用、认领、附件、提交、失败恢复和卸载测试继续通过;用构建产物运行针对性浏览器回归,预期输出不变。类型和文档检查覆盖移动后的声明与双语配对。不以新双实例功能作为第一阶段验收条件。

第二阶段必须用同一 Session 的两个真实挂载 Composer 验证双向文字和 chip 同步、skill 高亮、共享附件和进度、发送清空/失败恢复、IME/Undo、来源路由,以及任一视图卸载后另一处继续工作。其差异必须仅包含行为实现及相应测试,不包含机械整理。

风险

无状态 JSX 提取仍可能改变 ref 或 effect 时序;因此 Hook 和 ref 的宿主保持不变,DOM 不增加包装。Lexical 提取可能改变嵌套 update、projection 缓存或 history 清理顺序;因此保留原操作体并对照执行次序,而非重写算法。

第一阶段仍不能同时挂载同 Session 的两个编辑器,且保留原 picker/drop 限制。后续若误把目录隔离当成状态隔离,会造成 root 相互解绑、重复附件接收或错误焦点路由;这些限制必须由第二阶段的双实例行为测试关闭。