Просмотр исходного кода

Merge pull request #4191 from deepseek-harness/worktree-draft-editor-isolation

refactor(web): isolate draft editor runtime and view bindings
imccyu 1 неделя назад
Родитель
Сommit
22fed0f1eb
27 измененных файлов с 1039 добавлено и 485 удалено
  1. 6 0
      .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.i18n.yaml
  2. 116 0
      .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md
  3. 116 0
      .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md
  4. 2 2
      packages/client/ui-attachment/README.i18n.yaml
  5. 1 0
      packages/client/ui-attachment/README.md
  6. 1 0
      packages/client/ui-attachment/README.zh.md
  7. 2 48
      packages/client/ui-attachment/src/client/ComposerAttachments.tsx
  8. 66 0
      packages/client/ui-attachment/src/client/drop-events.ts
  9. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  10. 2 0
      packages/client/ui-conversation/README.md
  11. 2 0
      packages/client/ui-conversation/README.zh.md
  12. 105 0
      packages/client/ui-conversation/src/client/contract/draft-editor.ts
  13. 1 102
      packages/client/ui-conversation/src/client/contract/input.ts
  14. 2 3
      packages/client/ui-conversation/src/client/contract/slots.ts
  15. 4 4
      packages/client/ui-conversation/src/client/index.ts
  16. 63 0
      packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx
  17. 1 1
      packages/client/ui-conversation/src/client/input/editor/chip-node.tsx
  18. 1 1
      packages/client/ui-conversation/src/client/input/editor/keymap.ts
  19. 1 1
      packages/client/ui-conversation/src/client/input/editor/projection.ts
  20. 1 1
      packages/client/ui-conversation/src/client/input/editor/reference-activation.ts
  21. 299 0
      packages/client/ui-conversation/src/client/input/editor/runtime.ts
  22. 155 0
      packages/client/ui-conversation/src/client/input/editor/view-binding.ts
  23. 38 192
      packages/client/ui-conversation/src/client/input/facade.ts
  24. 2 1
      packages/client/ui-conversation/src/client/input/hub.ts
  25. 27 104
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  26. 1 1
      packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
  27. 22 22
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

+ 6 - 0
.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md
+2026-09-14-composer-model-and-draft-editor.md: 3f66ab2c73da0a8f66bf43a821cbe5f85363867a
+2026-09-14-composer-model-and-draft-editor.zh.md: aa040b0fa68961d4d5884d73791c4986b8bfab50

+ 116 - 0
.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md

@@ -0,0 +1,116 @@
+# Agent Note: Two-stage Composer and DraftEditor isolation
+
+Status: proposed
+
+English | [中文](2026-09-14-composer-model-and-draft-editor.zh.md)
+
+## 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](../../../../packages/client/ui-conversation/src/client/input/facade.ts) combines Lexical operations, draft projection, the submission state machine, and failure recovery. [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx) 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](../../../../packages/client/ui-conversation/src/client/service.ts) 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](https://github.com/deepseek-ai/deepseek-harness/pull/3951), following [Client Session and UI ownership](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.md). The Session activity view, residency states, and eviction policy are designed independently; [#4138](https://github.com/deepseek-ai/deepseek-harness/pull/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 location | Extraction destination | Location for later behavior changes |
+|---|---|---|
+| Lexical creation, registration, projection, node operations, and cleanup in `input/facade.ts` | `input/editor/runtime.ts` | One editor's implementation and its creation, binding, and disposal |
+| Text-area JSX in `skeleton/InputBar.tsx` | `input/editor/DraftEditor.tsx` | One editor's presentation, excluding the attachment rail and submission orchestration |
+| Focus, selection reveal, wheel, keymap, and picker binding functions in `InputBar.tsx` | `input/editor/view-binding.ts` | DOM interaction and editor bindings for one mounted view |
+| Range, reference, and keyboard interface types in `contract/input.ts` | `contract/draft-editor.ts` | Editor-facing data and operation types; submission and shared state stay in the original file |
+| Document drop effect implementation in `ui-attachment/src/client/ComposerAttachments.tsx` | `ui-attachment/src/client/drop-events.ts` | Document 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
+
+| Subject | Result |
+|---|---|
+| Editor implementation | Node and projection operations belong to runtime, presentation to DraftEditor, and DOM bindings to view-binding |
+| Submission and attachment orchestration | Still owned by the original shell and ConversationController, not DraftEditor |
+| State across different Sessions | Remains isolated under existing rules |
+| Shared state within one Session | Still reuses the original shell, without duplicate attachments or uploads |
+| Independent editors within one Session | Not implemented; views still share one Lexical editor |
+| Independent selection, IME, Undo, and menu origins | Not implemented; separating code does not change runtime ownership |
+| Multi-view picker, focus, and document drop routing | Not 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.
+
+| State | Final owner | Multi-view requirement |
+|---|---|---|
+| Draft text, semantic references, and content revision | Session-associated shared Composer model | Publish edits from either view to every view through one reactive source |
+| Ordered attachment IDs, claims, submission attempts, and failure recovery | Shared Composer model | Settle each submission once; operations in either view affect the same pending input |
+| File, Blob URL, upload tasks, progress, and receipts | Existing attachment owner | Do not copy per view; unmounting one view does not cancel resources used by another |
+| Lexical, DOM, NodeKey mappings, selection, and IME preedit | Each DraftEditor instance | Two independent editors/roots; unmounting one does not detach the other |
+| Menu anchor, file dialog, and focus | Initiating view | Route by operation origin, not a single Session picker |
+| Session history, running, and queue | Session Controller | Keep 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.

+ 116 - 0
.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md

@@ -0,0 +1,116 @@
+# Agent Note: Composer 与 DraftEditor 隔离的两阶段重构
+
+Status: proposed
+
+[English](2026-09-14-composer-model-and-draft-editor.md) | 中文
+
+## 问题
+
+同一个 Client 需要在不同视图中编辑同一个 Session 的草稿和待发送附件。Lexical 的一个 editor 只能绑定一个 DOM root;多个呈现位置需要多个编辑实例,但不能各自拥有互不相干的草稿和上传任务,也不能让 Session Controller 理解光标、输入法或 DOM。
+
+当前 [SessionInputShell](../../../../packages/client/ui-conversation/src/client/input/facade.ts) 同时包含 Lexical 操作、草稿投影、提交状态机和失败恢复。[InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx) 同时包含编辑区呈现、DOM 绑定、附件入口和发送控件。直接在这两个文件中实现多实例会让代码提取与行为差异混在一起。
+
+附件实体和上传任务已经由 [ConversationController](../../../../packages/client/ui-conversation/src/client/service.ts) 集中管理,shell 只保留有序附件 IDs。skill(技能)选择插入普通 `/name` 文本,高亮由词表派生;文件和 Session 的原子引用则使用带来源身份的 chip。共享草稿不能只同步文字而丢失这些引用,也不需要复制附件实体。
+
+本提案细化 [#3951](https://github.com/deepseek-ai/deepseek-harness/pull/3951) 的编辑器隔离,遵循 [Client Session 与 UI 所有权](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md)。Session 活跃视图、驻留状态与回收策略独立设计;[#4138](https://github.com/deepseek-ai/deepseek-harness/pull/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` 的文字区域 JSX | `input/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.ts` | document 拖放监听的注册、路由和清理 |
+
+`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 | 多视图要求 |
+|---|---|---|
+| 草稿正文、语义引用、内容 revision | Session 关联的共享 Composer 模型 | 任一处编辑后,通过同一个响应式来源发布给所有视图 |
+| 有序附件 IDs、认领、提交 attempt 和失败恢复 | 共享 Composer 模型 | 每个提交只结算一次,任一视图的操作作用于同一批输入 |
+| File、Blob URL、上传任务、进度和凭证 | 既有附件管理 owner | 不随视图复制;卸载一个视图不取消其他视图所用资源 |
+| Lexical、DOM、NodeKey 映射、selection、IME preedit | 各 DraftEditor 实例 | 两个独立 editor/root;卸载一处不解绑另一处 |
+| 菜单锚点、文件对话框和焦点 | 发起操作的视图 | 按操作来源路由,不以 Session 唯一 picker 代替来源 |
+| Session 历史、running、queue | Session 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 相互解绑、重复附件接收或错误焦点路由;这些限制必须由第二阶段的双实例行为测试关闭。

+ 2 - 2
packages/client/ui-attachment/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
-README.md: c2b429b5ea2f190ab113697aa97eb35c7adcaa29
-README.zh.md: e2d6b3d45eaee7d652c33f223cfb8f061c69c72a
+README.md: 3c69b387914c631f50d783c4a35089ab8db1668f
+README.zh.md: c21b2acdbeb7ced6e9a8733a861c5324121ad194

+ 1 - 0
packages/client/ui-attachment/README.md

@@ -52,6 +52,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima
 | File | Role |
 |---|---|
 | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Ordered image/file rail + drop overlay assembly |
+| [`src/client/drop-events.ts`](src/client/drop-events.ts) | Document drag-and-drop listeners installed by each mounted attachment view's effect |
 | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Horizontal attachment overflow, wheel translation, edge arrows |
 | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly |
 | [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly |

+ 1 - 0
packages/client/ui-attachment/README.zh.md

@@ -52,6 +52,7 @@ Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行
 | 文件 | 职责 |
 |---|---|
 | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 有序图片/文件栏+拖放遮罩的组装 |
+| [`src/client/drop-events.ts`](src/client/drop-events.ts) | 每个已挂载附件视图的 effect 安装的 document 拖放监听 |
 | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 附件横向溢出、滚轮转换、边缘箭头 |
 | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 |
 | [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL |

+ 2 - 48
packages/client/ui-attachment/src/client/ComposerAttachments.tsx

@@ -9,6 +9,7 @@ import { DropOverlay } from '../DropOverlay.tsx'
 import { FileCard } from '../FileCard.tsx'
 import { ImageLightbox } from '../ImageLightbox.tsx'
 import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts'
+import { installDocumentDropEvents } from './drop-events.ts'
 import css from './ComposerAttachments.module.css'
 
 /** Rail item retaining its browser-owned attachment for callbacks. */
@@ -29,54 +30,7 @@ export function ComposerAttachments({
   }, [attachments, preview])
 
   useEffect(() => {
-    const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
-      const dataTransfer = event.dataTransfer
-      if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
-      return dataTransfer
-    }
-    const reset = (): void => {
-      dragDepth.current = 0
-      setDragActive(false)
-    }
-    const onDragEnter = (event: globalThis.DragEvent): void => {
-      if (fileTransfer(event) === null) return
-      event.preventDefault()
-      dragDepth.current += 1
-      setDragActive(true)
-    }
-    const onDragOver = (event: globalThis.DragEvent): void => {
-      const dataTransfer = fileTransfer(event)
-      if (dataTransfer === null) return
-      event.preventDefault()
-      dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
-    }
-    const onDragLeave = (event: globalThis.DragEvent): void => {
-      if (fileTransfer(event) === null) return
-      dragDepth.current = Math.max(0, dragDepth.current - 1)
-      if (dragDepth.current === 0) setDragActive(false)
-      const leftViewport = event.clientX <= 0 || event.clientY <= 0
-        || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
-      if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset()
-    }
-    const onDrop = (event: globalThis.DragEvent): void => {
-      const dataTransfer = fileTransfer(event)
-      if (dataTransfer === null) return
-      event.preventDefault()
-      reset()
-      if (canAcceptDrop) onAddFiles([...dataTransfer.files])
-    }
-    document.addEventListener('dragenter', onDragEnter)
-    document.addEventListener('dragover', onDragOver)
-    document.addEventListener('dragleave', onDragLeave)
-    document.addEventListener('drop', onDrop)
-    window.addEventListener('dragend', reset)
-    return () => {
-      document.removeEventListener('dragenter', onDragEnter)
-      document.removeEventListener('dragover', onDragOver)
-      document.removeEventListener('dragleave', onDragLeave)
-      document.removeEventListener('drop', onDrop)
-      window.removeEventListener('dragend', reset)
-    }
+    return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive)
   }, [canAcceptDrop, onAddFiles])
 
   const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({

+ 66 - 0
packages/client/ui-attachment/src/client/drop-events.ts

@@ -0,0 +1,66 @@
+/** Document drag-and-drop listeners owned by one mounted attachment view. */
+import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+
+/**
+ * Install one attachment view's file-drop listeners.
+ * @param canAcceptDrop - whether this view accepts the dropped files.
+ * @param onAddFiles - attachment intake callback.
+ * @param dragDepth - the view's retained nested-drag counter.
+ * @param setDragActive - publish whether a file drag is active.
+ * @returns cleanup for exactly these listeners.
+ */
+export function installDocumentDropEvents(
+  canAcceptDrop: ComposerAttachmentsProps['canAcceptDrop'],
+  onAddFiles: ComposerAttachmentsProps['onAddFiles'],
+  dragDepth: { current: number },
+  setDragActive: (active: boolean) => void,
+): () => void {
+  const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
+    const dataTransfer = event.dataTransfer
+    if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
+    return dataTransfer
+  }
+  const reset = (): void => {
+    dragDepth.current = 0
+    setDragActive(false)
+  }
+  const onDragEnter = (event: globalThis.DragEvent): void => {
+    if (fileTransfer(event) === null) return
+    event.preventDefault()
+    dragDepth.current += 1
+    setDragActive(true)
+  }
+  const onDragOver = (event: globalThis.DragEvent): void => {
+    const dataTransfer = fileTransfer(event)
+    if (dataTransfer === null) return
+    event.preventDefault()
+    dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
+  }
+  const onDragLeave = (event: globalThis.DragEvent): void => {
+    if (fileTransfer(event) === null) return
+    dragDepth.current = Math.max(0, dragDepth.current - 1)
+    if (dragDepth.current === 0) setDragActive(false)
+    const leftViewport = event.clientX <= 0 || event.clientY <= 0
+      || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
+    if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset()
+  }
+  const onDrop = (event: globalThis.DragEvent): void => {
+    const dataTransfer = fileTransfer(event)
+    if (dataTransfer === null) return
+    event.preventDefault()
+    reset()
+    if (canAcceptDrop) onAddFiles([...dataTransfer.files])
+  }
+  document.addEventListener('dragenter', onDragEnter)
+  document.addEventListener('dragover', onDragOver)
+  document.addEventListener('dragleave', onDragLeave)
+  document.addEventListener('drop', onDrop)
+  window.addEventListener('dragend', reset)
+  return () => {
+    document.removeEventListener('dragenter', onDragEnter)
+    document.removeEventListener('dragover', onDragOver)
+    document.removeEventListener('dragleave', onDragLeave)
+    document.removeEventListener('drop', onDrop)
+    window.removeEventListener('dragend', reset)
+  }
+}

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 8d68dfdb61943c7c45b38d9244134edecc86a593
-README.zh.md: b1d548ba8fc9276a6864548802a45796e74c0c7a
+README.md: 5b94862d830d27ae02e60832294c7595537b47c0
+README.zh.md: 6d7bb72ba582f90356f4bc08a78b2cdfdf211db7

+ 2 - 0
packages/client/ui-conversation/README.md

@@ -38,6 +38,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re
 
 The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
 
+`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.
+
 Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty.
 
 Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.

+ 2 - 0
packages/client/ui-conversation/README.zh.md

@@ -38,6 +38,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 
 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
 
+`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。
+
 已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
 
 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。

+ 105 - 0
packages/client/ui-conversation/src/client/contract/draft-editor.ts

@@ -0,0 +1,105 @@
+/** Editor-facing ranges, reference projections, and the composer keyboard interface. */
+import type { LexicalEditor } from 'lexical'
+import type { InputState } from './input.ts'
+import type { InputSubmitMode } from './composer-submission.ts'
+
+/** Pick-time draft span guarded by the input revision. */
+export interface TokenSpan {
+  readonly start: number
+  readonly end: number
+  readonly draftRev: number
+}
+
+/** Structured reference inserted by an input-trigger source. */
+export interface ReferenceInsert {
+  readonly source: string
+  readonly ref: string
+  readonly label: string
+  readonly appearance?: 'session' | 'file' | 'folder'
+  readonly clipboardText: string
+}
+
+/** Keyboard keys intercepted by an open trigger menu. */
+export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab'
+
+/** Trigger-menu keyboard routing result. */
+export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'
+
+/**
+ * The InputBar-exclusive keyboard/DOM command face: synchronous
+ * returns and event-handler semantics that must not enter the public provide
+ * channel. Handed to the composer-bar entry through its own inject —
+ * package-internal, never across a plugin boundary. The session shell
+ * satisfies it structurally. Text editing itself rides the shell's Lexical
+ * editor (exposed here for the contenteditable binding); the members below
+ * are the submit-plane and trigger-pipeline verbs the editor does not own.
+ */
+export interface ComposerKeyboard {
+  /** Live machine state for event-handler reads (render reads go through useInput). */
+  readonly snapshot: InputState
+  /** The shell-owned Lexical editor the composer binds its contenteditable to. */
+  readonly editor: LexicalEditor
+  /** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */
+  submit(mode: InputSubmitMode): void
+  /**
+   * Steer every still-pending queued message into the running turn (the
+   * empty-draft accelerated-Enter gesture; the queue dock's per-row steer
+   * button is the same operation applied to the whole queue).
+   */
+  steerQueue(): void
+  /** Insert pasted plain text over the current editor selection (reference-placeholder-sanitized). */
+  paste(text: string): void
+  /**
+   * The live selection as a detect-coordinate span (menu-launcher synthetic
+   * hits replace it on pick); an absent selection answers a collapsed span at
+   * the document end.
+   */
+  caretSpan(): EditSelection
+  /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
+  arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
+  /** Space adjudication; true = the input applied a claim — caller preventDefaults. */
+  space(): boolean
+  /** Dismiss the popupSelect shell (any interaction outside the box). */
+  dismissPopup(): void
+  /**
+   * Bind the mounted composer's file action and live intake availability.
+   * @param picker - availability query and native file-dialog opener.
+   * @returns the unbind disposer.
+   */
+  bindFilePicker(picker: { available(): boolean; open(): void }): () => void
+}
+
+/** Half-open [start, end) range/selection in detect-projection coordinates. */
+export interface EditSelection {
+  readonly start: number
+  readonly end: number
+}
+
+/**
+ * One reference occurrence projected from the editor's chip nodes, in
+ * clipboard-text coordinates. Identity is occurrenceId — a stable per-shell
+ * assignment per chip NodeKey, so same-named references stay independently
+ * addressable and survive undo. label/appearance/clipboardText are the
+ * owner's insert-time projections cached on the node (invalid flips instead
+ * of dropping the occurrence).
+ */
+export interface Occurrence {
+  /** Shell-assigned stable identity (monotonic per shell, keyed by NodeKey). */
+  readonly occurrenceId: number
+  /** Owning source name (serializer routing key). */
+  readonly source: string
+  /** Owner-scoped reference id. */
+  readonly ref: string
+  /** Offset in the clipboard-text projection. */
+  readonly offset: number
+  /** Length in the clipboard-text projection; the occurrence occupies exactly [offset, offset+length). */
+  readonly length: number
+  /** Inline display label (insert-time cache). */
+  readonly label: string
+  /** Optional domain glyph (insert-time cache). */
+  readonly appearance?: ReferenceInsert['appearance']
+  /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */
+  readonly clipboardText: string
+  /** Owner-resolution failure flag: the chip renders the failure treatment. */
+  readonly invalid?: boolean
+}

+ 1 - 102
packages/client/ui-conversation/src/client/contract/input.ts

@@ -9,17 +9,10 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { LexicalEditor } from 'lexical'
+import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
 import type { QueueRow } from './queue.ts'
 import type { InputSubmitMode } from './composer-submission.ts'
 
-/** Pick-time draft span guarded by the input revision. */
-export interface TokenSpan {
-  readonly start: number
-  readonly end: number
-  readonly draftRev: number
-}
-
 /** Attachment payload passed to a claimed command submission. */
 export type SubmitAttachment =
   | {
@@ -59,15 +52,6 @@ export interface CommandClaim {
   submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise<SubmitOutcome>
 }
 
-/** Structured reference inserted by an input-trigger source. */
-export interface ReferenceInsert {
-  readonly source: string
-  readonly ref: string
-  readonly label: string
-  readonly appearance?: 'session' | 'file' | 'folder'
-  readonly clipboardText: string
-}
-
 /** Result of trigger-source adjudication. */
 export type PickOutcome =
   | { readonly claim: CommandClaim }
@@ -76,12 +60,6 @@ export type PickOutcome =
   | 'handled'
   | undefined
 
-/** Keyboard keys intercepted by an open trigger menu. */
-export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab'
-
-/** Trigger-menu keyboard routing result. */
-export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'
-
 /** Scoped request to enter command mode. */
 export interface BeginCommandRequest {
   readonly claim: CommandClaim
@@ -255,91 +233,12 @@ export interface InputNotice {
   readonly seq: number
 }
 
-/**
- * The InputBar-exclusive keyboard/DOM command face: synchronous
- * returns and event-handler semantics that must not enter the public provide
- * channel. Handed to the composer-bar entry through its own inject —
- * package-internal, never across a plugin boundary. The session shell
- * satisfies it structurally. Text editing itself rides the shell's Lexical
- * editor (exposed here for the contenteditable binding); the members below
- * are the submit-plane and trigger-pipeline verbs the editor does not own.
- */
-export interface ComposerKeyboard {
-  /** Live machine state for event-handler reads (render reads go through useInput). */
-  readonly snapshot: InputState
-  /** The shell-owned Lexical editor the composer binds its contenteditable to. */
-  readonly editor: LexicalEditor
-  /** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */
-  submit(mode: InputSubmitMode): void
-  /**
-   * Steer every still-pending queued message into the running turn (the
-   * empty-draft accelerated-Enter gesture; the queue dock's per-row steer
-   * button is the same operation applied to the whole queue).
-   */
-  steerQueue(): void
-  /** Insert pasted plain text over the current editor selection (reference-placeholder-sanitized). */
-  paste(text: string): void
-  /**
-   * The live selection as a detect-coordinate span (menu-launcher synthetic
-   * hits replace it on pick); an absent selection answers a collapsed span at
-   * the document end.
-   */
-  caretSpan(): EditSelection
-  /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
-  arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
-  /** Space adjudication; true = the input applied a claim — caller preventDefaults. */
-  space(): boolean
-  /** Dismiss the popupSelect shell (any interaction outside the box). */
-  dismissPopup(): void
-  /**
-   * Bind the mounted composer's file action and live intake availability.
-   * @param picker - availability query and native file-dialog opener.
-   * @returns the unbind disposer.
-   */
-  bindFilePicker(picker: { available(): boolean; open(): void }): () => void
-}
-
 /** One independently addressable row projected from the transient queue snapshot. */
 export type QueuedMessage = QueueRow
 
 /** Guard union of the scoped consume-token event, checked by the shell. */
 export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
 
-/** Half-open [start, end) range/selection in detect-projection coordinates. */
-export interface EditSelection {
-  readonly start: number
-  readonly end: number
-}
-
-/**
- * One reference occurrence projected from the editor's chip nodes, in
- * clipboard-text coordinates. Identity is occurrenceId — a stable per-shell
- * assignment per chip NodeKey, so same-named references stay independently
- * addressable and survive undo. label/appearance/clipboardText are the
- * owner's insert-time projections cached on the node (invalid flips instead
- * of dropping the occurrence).
- */
-export interface Occurrence {
-  /** Shell-assigned stable identity (monotonic per shell, keyed by NodeKey). */
-  readonly occurrenceId: number
-  /** Owning source name (serializer routing key). */
-  readonly source: string
-  /** Owner-scoped reference id. */
-  readonly ref: string
-  /** Offset in the clipboard-text projection. */
-  readonly offset: number
-  /** Length in the clipboard-text projection; the occurrence occupies exactly [offset, offset+length). */
-  readonly length: number
-  /** Inline display label (insert-time cache). */
-  readonly label: string
-  /** Optional domain glyph (insert-time cache). */
-  readonly appearance?: ReferenceInsert['appearance']
-  /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */
-  readonly clipboardText: string
-  /** Owner-resolution failure flag: the chip renders the failure treatment. */
-  readonly invalid?: boolean
-}
-
 /** Published input state (the currency; per-session). */
 export interface InputState {
   /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */

+ 2 - 3
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -15,9 +15,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type { ComposerBlock } from './composer-blocks.ts'
-import type {
-  ComposerKeyboard, DraftAttachmentId, EditSelection, InputActions, InputNotice, InputState,
-} from './input.ts'
+import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts'
+import type { ComposerKeyboard, EditSelection } from './draft-editor.ts'
 import type { createConversationStore } from '../stores.ts'
 import type { BusyEnterBehavior } from './composer-submission.ts'
 import type { ConversationSnapshot } from './snapshot.ts'

+ 4 - 4
packages/client/ui-conversation/src/client/index.ts

@@ -62,11 +62,11 @@ export type {
   UseConversationViews,
 } from './contract/slots.ts'
 export type {
-  ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,
-  DraftAttachmentId, InputActions, InputState, InsertReferenceRequest, InsertTextRequest,
-  PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitAttachment,
-  SubmitOutcome, TokenSpan,
+  BeginCommandRequest, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions,
+  InputState, InsertReferenceRequest, InsertTextRequest, PickOutcome, SessionInput,
+  SessionInputResolver, SubmitAttachment, SubmitOutcome,
 } from './contract/input.ts'
+export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts'
 export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'
 
 declare module '@deepseek-ai/cordis' {

+ 63 - 0
packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx

@@ -0,0 +1,63 @@
+/** Stateless text-area presentation over the InputBar's borrowed editor. */
+import type { CSSProperties, KeyboardEventHandler, ReactNode, RefObject } from 'react'
+import type { LexicalEditor } from 'lexical'
+import clsx from 'clsx'
+import type { InputState } from '../../contract/input.ts'
+import { ComposerContentEditable } from './ComposerContentEditable.tsx'
+import { DecoratorPortals } from './DecoratorPortals.tsx'
+
+/** Text-area values and the scrollport reference retained by InputBar. */
+export interface DraftEditorProps {
+  readonly classNames: Readonly<Record<string, string>>
+  readonly editor: LexicalEditor | null
+  readonly scrollRef: RefObject<HTMLDivElement>
+  readonly editable: boolean
+  readonly editorDisabled: boolean
+  readonly phase: InputState['phase'] | 'inert'
+  readonly placeholderText: string
+  readonly ariaLabel: string
+  readonly workspaceTrigger: boolean
+  readonly workspacePickerOpen: boolean
+  readonly onWorkspaceKeyDown: KeyboardEventHandler<HTMLDivElement>
+  readonly hint: string | null
+  readonly showPlaceholder: boolean
+}
+
+/**
+ * Render the existing scrollport, editable surface, placeholder, and chip portals.
+ * @param props - borrowed editor and presentation values; this component owns no Hooks.
+ * @returns the existing text-area DOM without an additional wrapper.
+ */
+export function DraftEditor({
+  classNames: css, editor, scrollRef, editable, editorDisabled, phase, placeholderText, ariaLabel,
+  workspaceTrigger, workspacePickerOpen, onWorkspaceKeyDown, hint, showPlaceholder,
+}: DraftEditorProps): ReactNode {
+  return (
+    <div ref={scrollRef} className={css.scroll} data-input-scroll>
+      <div className={css.grow}>
+        <ComposerContentEditable
+          editor={workspaceTrigger ? null : editor}
+          editable={editable}
+          className={clsx(css.input, editorDisabled && css.inputDisabled)}
+          data-phase={phase}
+          aria-disabled={editorDisabled || undefined}
+          data-placeholder={placeholderText}
+          // The placeholder was the textarea's accessible name; a div's
+          // data attribute is not, so the label restores it.
+          aria-label={ariaLabel}
+          aria-haspopup={workspaceTrigger ? 'menu' : undefined}
+          aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
+          tabIndex={workspaceTrigger ? 0 : undefined}
+          onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
+          style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
+        />
+        {showPlaceholder && (
+          <div aria-hidden className={css.placeholder} data-composer-placeholder>
+            {placeholderText}
+          </div>
+        )}
+        <DecoratorPortals editor={workspaceTrigger ? null : editor} />
+      </div>
+    </div>
+  )
+}

+ 1 - 1
packages/client/ui-conversation/src/client/input/editor/chip-node.tsx

@@ -12,7 +12,7 @@ import type {
   EditorConfig, LexicalNode, NodeKey, SerializedLexicalNode, Spread,
 } from 'lexical'
 import { DecoratorNode } from 'lexical'
-import type { ReferenceInsert } from '../../contract/input.ts'
+import type { ReferenceInsert } from '../../contract/draft-editor.ts'
 import { ReferenceChip } from './ReferenceChip.tsx'
 
 /** JSON form of one chip (Lexical node serialization contract). */

+ 1 - 1
packages/client/ui-conversation/src/client/input/editor/keymap.ts

@@ -20,7 +20,7 @@ import {
   KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND,
 } from 'lexical'
 import { mergeRegister } from '@lexical/utils'
-import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/input.ts'
+import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/draft-editor.ts'
 
 /** The bar-supplied behavior behind each intercepted gesture. */
 export interface ComposerKeymapHandlers {

+ 1 - 1
packages/client/ui-conversation/src/client/input/editor/projection.ts

@@ -11,7 +11,7 @@ import type { ElementNode, LexicalNode, NodeKey, Point } from 'lexical'
 import {
   $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode,
 } from 'lexical'
-import type { Occurrence } from '../../contract/input.ts'
+import type { Occurrence } from '../../contract/draft-editor.ts'
 import { $isReferenceChipNode } from './chip-node.tsx'
 
 /** The detect-projection stand-in for one chip (object replacement character). */

+ 1 - 1
packages/client/ui-conversation/src/client/input/editor/reference-activation.ts

@@ -4,7 +4,7 @@ import {
   CLICK_COMMAND, COMMAND_PRIORITY_LOW,
 } from 'lexical'
 import type { LexicalEditor } from 'lexical'
-import type { ReferenceInsert } from '../../contract/input.ts'
+import type { ReferenceInsert } from '../../contract/draft-editor.ts'
 import { $isReferenceChipNode } from './chip-node.tsx'
 import { TextRefNode } from './text-ref.ts'
 

+ 299 - 0
packages/client/ui-conversation/src/client/input/editor/runtime.ts

@@ -0,0 +1,299 @@
+/** The Composer model's private Lexical editor, projections, and node operations. */
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { LexicalEditor, NodeKey } from 'lexical'
+import {
+  $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
+  CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG,
+} from 'lexical'
+import { registerPlainText } from '@lexical/plain-text'
+import { createEmptyHistoryState, registerHistory } from '@lexical/history'
+import { mergeRegister } from '@lexical/utils'
+import type { Occurrence, ReferenceInsert } from '../../contract/draft-editor.ts'
+import { registerReferenceActivation } from './reference-activation.ts'
+import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx'
+import { refreshClaimDecoration, registerClaimDecoration } from './claim-decor.ts'
+import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './text-ref.ts'
+import type { EditorProjection } from './projection.ts'
+import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './projection.ts'
+import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './span-map.ts'
+import type { DetectSpan } from './span-map.ts'
+
+type Lexicon = ReadonlyMap<'/' | '@', readonly string[]>
+
+/** Model callbacks read at the same editor registration and update points. */
+interface DraftEditorRuntimeDeps {
+  readonly onUpdate: () => void
+  readonly openReference: (source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>) => boolean
+  readonly activeClaimToken: () => string | null
+  readonly lexicon: () => Lexicon
+  readonly resolveLexicon: () => ObservableSnapshot<Lexicon> | undefined
+}
+
+/**
+ * Detect-projection and legacy reference placeholders stripped from every
+ * external text entering the document (paste, persisted-draft seed): a chip
+ * is the only legitimate source of U+FFFC in the detect projection, so a
+ * literal one in text would forge chip positions.
+ */
+const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
+
+/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
+const HISTORY_MERGE_DELAY_MS = 1000
+
+/** One model-owned editor; registration and disposal remain with its model. */
+export class DraftEditorRuntime {
+  /** The editor bound by the Composer's contenteditable host. */
+  readonly editor: LexicalEditor
+  private projected: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null }
+  /** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
+  private readonly occurrenceIds = new Map<NodeKey, number>()
+  private occurrenceSeq = 0
+  /** Live lexicon subscription disposer; undefined until the controller resolves. */
+  private lexiconOff: (() => void) | undefined
+
+  /** @param deps - model callbacks used by editor listeners and transforms. */
+  constructor(private readonly deps: DraftEditorRuntimeDeps) {
+    this.editor = createEditor({
+      namespace: 'dsh-composer',
+      nodes: [ReferenceChipNode, TextRefNode],
+      onError: (error) => { throw error },
+    })
+  }
+
+  /**
+   * Install editor behavior after the model holds this runtime.
+   * @returns unregister callback that also detaches the editor root.
+   */
+  register(): () => void {
+    const unregister = mergeRegister(
+      registerPlainText(this.editor),
+      registerReferenceActivation(this.editor, (source, reference) =>
+        this.deps.openReference(source, reference)),
+      registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
+      this.editor.registerUpdateListener(() => { this.deps.onUpdate() }),
+      registerClaimDecoration(this.editor, () => this.deps.activeClaimToken()),
+      registerTextRefDecoration(this.editor, () => this.deps.lexicon(), () => this.deps.activeClaimToken()),
+      () => { this.lexiconOff?.() },
+    )
+    return () => {
+      unregister()
+      this.editor.setRootElement(null)
+    }
+  }
+
+  /** The latest committed editor projection. */
+  get projection(): EditorProjection {
+    return this.projected
+  }
+
+  /**
+   * Run one editor edit whose result is observable on return. At the top
+   * level this is a discrete update. Inside this editor's own update —
+   * command handlers land here synchronously (space/enter picks, paste) —
+   * $-functions are already legal, and wrapping them in update() would DEFER
+   * them past the synchronous bail answer (and a nested discrete throws);
+   * the body runs directly and the outer update commits it.
+   * @param fn - the $-edit body.
+   */
+  private applyEdit(fn: () => void, tag?: string): void {
+    if (this.editor._updating) {
+      // Nested application joins the enclosing update (the PASTE_COMMAND
+      // dispatch path always lands here), so the tag attaches to that update.
+      if (tag !== undefined) $addUpdateTag(tag)
+      fn()
+      return
+    }
+    this.editor.update(fn, { discrete: true, ...(tag === undefined ? {} : { tag }) })
+  }
+
+  /**
+   * Subscribe the text-ref re-scan to the controller's lexicon once the
+   * controller resolves. The deps thunk cannot resolve at construction (the
+   * shell is created inside the sessions provide materialization), so the
+   * first interactive updates retry until it can.
+   */
+  private ensureLexiconSubscription(): void {
+    if (this.lexiconOff !== undefined) return
+    const lexicon = this.deps.resolveLexicon()
+    if (lexicon === undefined) return
+    this.lexiconOff = lexicon.subscribe(() => { rescanTextRefs(this.editor) })
+  }
+
+  /**
+   * Re-project inside the existing editor update callback.
+   * @returns the projection preceding this read.
+   */
+  refreshProjection(): EditorProjection {
+    this.ensureLexiconSubscription()
+    const prev = this.projected
+    this.projected = this.editor.getEditorState().read(() =>
+      $projectComposer(key => this.occurrenceIdOf(key)))
+    return prev
+  }
+
+  private occurrenceIdOf(key: NodeKey): number {
+    const existing = this.occurrenceIds.get(key)
+    if (existing !== undefined) return existing
+    this.occurrenceSeq += 1
+    this.occurrenceIds.set(key, this.occurrenceSeq)
+    return this.occurrenceSeq
+  }
+
+  /**
+   * Replace the whole draft (persisted-draft seed and programmatic writes).
+   * Placeholder-sanitized; newlines split paragraphs; the caret lands at the
+   * end. Merged into history so a seed is not an undoable step of its own.
+   * @param text - the full next draft.
+   */
+  setDraft(text: string): void {
+    const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
+    if (clean === this.projection.clipboardText) return
+    this.editor.update(() => {
+      const root = $getRoot()
+      root.clear()
+      for (const line of clean.split('\n')) {
+        const paragraph = $createParagraphNode()
+        if (line !== '') paragraph.append($createTextNode(line))
+        root.append(paragraph)
+      }
+      root.selectEnd()
+    }, { discrete: true, tag: HISTORY_MERGE_TAG })
+  }
+
+  /**
+   * Insert pasted plain text over the current editor selection
+   * (placeholder-sanitized). The paste event's own default is suppressed by
+   * the caller; PASTE_TAG makes the paste its own history boundary, so one
+   * undo never removes both the paste and typing inside the merge window.
+   * @param text - pasted plain text.
+   */
+  paste(text: string): void {
+    const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
+    if (clean === '') return
+    this.applyEdit(() => {
+      const selection = $getSelection()
+      if ($isRangeSelection(selection)) {
+        selection.insertText(clean)
+        return
+      }
+      // No selection yet (never-focused surface): land at the document end,
+      // growing the first paragraph when the tree is empty.
+      const root = $getRoot()
+      if (root.getChildrenSize() === 0) root.append($createParagraphNode())
+      root.selectEnd().insertText(clean)
+    }, PASTE_TAG)
+  }
+
+  /**
+   * The live selection as a detect-coordinate span (menu-launcher synthetic
+   * hits replace it on pick); an absent selection answers a collapsed span at
+   * the document end.
+   * @returns the ordered [start, end) span in detect coordinates.
+   */
+  caretSpan(): { start: number; end: number } {
+    if (this.projection.selection !== null) return this.projection.selection
+    const at = this.projection.detectText.length
+    return { start: at, end: at }
+  }
+
+  /**
+   * Replace a mapped span without applying the model's phase or revision guards.
+   * @param span - detect-coordinate range.
+   * @param text - inserted text.
+   * @returns whether the range mapped and the edit applied.
+   */
+  replaceText(span: DetectSpan, text: string): boolean {
+    let applied = false
+    this.applyEdit(() => {
+      applied = $replaceDetectSpanWithText(span, text)
+    })
+    return applied
+  }
+
+  /**
+   * Insert a reference chip with the existing trailing-space rule.
+   * @param span - detect-coordinate range.
+   * @param ref - reference fields.
+   * @param tail - the character following the range before editing.
+   * @returns whether the range mapped and the edit applied.
+   */
+  insertReference(span: DetectSpan, ref: ReferenceInsert, tail: string): boolean {
+    let applied = false
+    this.applyEdit(() => {
+      const nodes = tail === ' '
+        ? [$createReferenceChipNode(ref)]
+        : [$createReferenceChipNode(ref), $createTextNode(' ')]
+      applied = $replaceDetectSpanWithNodes(span, nodes)
+    })
+    return applied
+  }
+
+  /** Refresh claim-token decoration after the model's claim changes. */
+  refreshClaimDecoration(): void {
+    refreshClaimDecoration(this.editor)
+  }
+
+  /**
+   * Clear committed content using the model's suffix decision inside the editor update.
+   * @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root.
+   */
+  clearCommittedDraft(prefixLength: (clipboardText: string) => number | null): void {
+    this.editor.update(() => {
+      const layout = $composerLayout()
+      const length = prefixLength(layout.clipboardText)
+      if (length !== null) {
+        $replaceDetectSpanWithText(
+          { start: 0, end: detectOffsetOfClipboardOffset(layout, length) }, '',
+        )
+        return
+      }
+      const root = $getRoot()
+      root.clear()
+      root.selectEnd()
+    }, { discrete: true, tag: HISTORY_MERGE_TAG })
+  }
+
+  /**
+   * Rebuild one model-selected failure snapshot, creating fresh reference nodes.
+   * @param draft - clipboard text.
+   * @param occurrences - reference occurrences in clipboard order.
+   */
+  restoreDraft(draft: string, occurrences: readonly Occurrence[]): void {
+    this.editor.update(() => {
+      const root = $getRoot()
+      root.clear()
+      let paragraph = $createParagraphNode()
+      root.append(paragraph)
+      const appendText = (text: string): void => {
+        const lines = text.split('\n')
+        for (let i = 0; i < lines.length; i += 1) {
+          const line = lines[i]
+          if (line !== '') paragraph.append($createTextNode(line))
+          if (i < lines.length - 1) {
+            paragraph = $createParagraphNode()
+            root.append(paragraph)
+          }
+        }
+      }
+      let cursor = 0
+      for (const occurrence of occurrences) {
+        appendText(draft.slice(cursor, occurrence.offset))
+        paragraph.append(new ReferenceChipNode({
+          source: occurrence.source,
+          ref: occurrence.ref,
+          label: occurrence.label,
+          ...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }),
+          clipboardText: occurrence.clipboardText,
+        }, occurrence.invalid === true))
+        cursor = occurrence.offset + occurrence.length
+      }
+      appendText(draft.slice(cursor))
+      root.selectEnd()
+    }, { discrete: true, tag: HISTORY_MERGE_TAG })
+  }
+
+  /** Cut the editor's undo history after a committed clear or restoration. */
+  clearHistory(): void {
+    this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
+  }
+}

+ 155 - 0
packages/client/ui-conversation/src/client/input/editor/view-binding.ts

@@ -0,0 +1,155 @@
+/** DOM and keymap bindings installed by the InputBar's existing effects. */
+import type { MouseEvent, MutableRefObject, RefObject } from 'react'
+import type { LexicalEditor } from 'lexical'
+import type { ComposerKeyboard } from '../../contract/draft-editor.ts'
+import type { ComposerBarProps } from '../../contract/slots.ts'
+import type { BusyEnterBehavior } from '../../contract/composer-submission.ts'
+import { resolveSubmitMode } from '../submission-policy.ts'
+import { registerComposerKeymap } from './keymap.ts'
+
+interface DraftViewGate {
+  locked: boolean
+  machineBusy: boolean
+  canSteerQueue: boolean
+  running: boolean
+  steeringAvailable: boolean
+  busyEnter: BusyEnterBehavior
+  intakeFiles: (files: readonly File[]) => void
+  uploadsPending: boolean
+  showToast: (text: string) => void
+  t: ComposerBarProps['t']
+  canAcceptDrop: boolean
+}
+
+/**
+ * Reveal the DOM selection within the draft's own scrollport.
+ * @param scrollRef - the InputBar-owned scrollport reference.
+ */
+export function revealDraftSelection(scrollRef: RefObject<HTMLDivElement>): void {
+  const scrollEl = scrollRef.current
+  if (scrollEl === null || scrollEl.scrollHeight <= scrollEl.clientHeight) return
+  const selection = window.getSelection()
+  if (selection === null || selection.rangeCount === 0) return
+  const range = selection.getRangeAt(0)
+  let rect = range.getBoundingClientRect()
+  if (rect.height === 0 && rect.width === 0) {
+    // A collapsed caret at an empty line reports a zero rect in some
+    // engines; the anchor's element box is the line the caret sits on.
+    const anchor = selection.anchorNode
+    const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement
+    if (el === undefined || el === null) return
+    rect = el.getBoundingClientRect()
+  }
+  const box = scrollEl.getBoundingClientRect()
+  if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom
+  else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top
+}
+
+/**
+ * Focus the borrowed editor and reveal its restored selection.
+ * @param editor - the Session-owned editor.
+ * @param revealSelection - reveal the selection after Lexical restores it.
+ */
+export function focusDraftEditor(editor: LexicalEditor, revealSelection: () => void): void {
+  // Lexical's focus() restores the editor selection but never calls the DOM
+  // focus itself; preventScroll keeps the conversation scrollport still.
+  editor.getRootElement()?.focus({ preventScroll: true })
+  editor.focus(() => { revealSelection() })
+}
+
+/**
+ * Forward wheel movement at the draft's edge to its conversation scrollport.
+ * @param scrollRef - the InputBar-owned scrollport reference.
+ * @returns the listener cleanup, or undefined when the element is absent.
+ */
+export function installDraftWheel(scrollRef: RefObject<HTMLDivElement>): (() => void) | undefined {
+  const el = scrollRef.current
+  if (el === null) return
+  const onWheel = (e: WheelEvent): void => {
+    const host = el.closest('[data-conversation-scroll]')
+    if (!(host instanceof HTMLElement) || e.deltaY === 0) return
+    const atTop = el.scrollTop <= 0
+    const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
+    if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return
+    e.preventDefault()
+    host.scrollTop += e.deltaY
+  }
+  el.addEventListener('wheel', onWheel, { passive: false })
+  return () => { el.removeEventListener('wheel', onWheel) }
+}
+
+/**
+ * Bind this view's file dialog through the existing keyboard face.
+ * @param keyboard - the Session-owned composer operations.
+ * @param gate - live intake availability retained by InputBar.
+ * @param fileInputRef - the view's native file input.
+ * @returns the picker unbind disposer.
+ */
+export function installDraftFilePicker(
+  keyboard: ComposerKeyboard,
+  gate: MutableRefObject<Pick<DraftViewGate, 'canAcceptDrop'>>,
+  fileInputRef: RefObject<HTMLInputElement>,
+): () => void {
+  return keyboard.bindFilePicker({
+    available: () => gate.current.canAcceptDrop && fileInputRef.current !== null,
+    open: () => { fileInputRef.current?.click() },
+  })
+}
+
+/**
+ * Bind editor gestures to the view's live guards and Session operations.
+ * @param editor - the borrowed Session-owned editor.
+ * @param keyboard - the existing composer keyboard operations.
+ * @param gate - live view values read by the installed handlers.
+ * @returns the keymap disposer.
+ */
+export function installDraftKeymap(
+  editor: LexicalEditor,
+  keyboard: ComposerKeyboard,
+  gate: MutableRefObject<DraftViewGate>,
+): () => void {
+  return registerComposerKeymap(editor, {
+    arbitrate: (key, composing) => keyboard.arbitrate(key, composing),
+    space: () => {
+      if (gate.current.machineBusy || gate.current.locked) return false
+      return keyboard.space()
+    },
+    dismissPopup: () => { keyboard.dismissPopup() },
+    canSubmit: () => !gate.current.locked && !gate.current.machineBusy,
+    submit: (accelerated) => {
+      const g = gate.current
+      // Empty-draft accelerated Enter acts on the queue instead of the
+      // (empty) draft: the machine rejects empty drafts, so the gesture
+      // steers every still-pending queued message into the running turn.
+      if (accelerated && g.canSteerQueue) {
+        keyboard.steerQueue()
+        return
+      }
+      if (g.uploadsPending) {
+        g.showToast(g.t('file.stillUploading'))
+        return
+      }
+      keyboard.submit(resolveSubmitMode(
+        g.busyEnter,
+        g.running,
+        accelerated ? 'accelerated' : 'enter',
+        g.steeringAvailable,
+      ))
+    },
+    intakeFiles: (files) => { gate.current.intakeFiles(files) },
+    pasteText: (text) => {
+      if (gate.current.machineBusy || gate.current.locked) return
+      keyboard.paste(text)
+    },
+  })
+}
+
+/**
+ * Keep a toolbar press from moving focus away from the draft.
+ * @param event - the toolbar button's mouse event.
+ * @param editor - the borrowed editor, absent in the inert view.
+ */
+export function keepDraftFocus(event: MouseEvent<HTMLButtonElement>, editor: LexicalEditor | null): void {
+  event.preventDefault()
+  editor?.getRootElement()?.focus({ preventScroll: true })
+}

+ 38 - 192
packages/client/ui-conversation/src/client/input/facade.ts

@@ -12,29 +12,19 @@ import type { Context } from '@deepseek-ai/cordis'
 import {
   createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
-import type { LexicalEditor, NodeKey } from 'lexical'
-import {
-  $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
-  CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG,
-} from 'lexical'
-import { registerPlainText } from '@lexical/plain-text'
-import { createEmptyHistoryState, registerHistory } from '@lexical/history'
-import { mergeRegister } from '@lexical/utils'
+import type { LexicalEditor } from 'lexical'
 import type {
-  ArbitrateKey, ArbitrateOutcome, CommandClaim, ComposerKeyboard, ConsumeTokenRequest, DraftAttachmentId,
+  CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
-  Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
-  SubmitOutcome, TokenSpan,
+  QueuedMessage, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
 } from '../contract/input.ts'
+import type {
+  ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
+} from '../contract/draft-editor.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import { SubmitMachine } from './machine.ts'
-import { registerReferenceActivation } from './editor/reference-activation.ts'
-import { ReferenceChipNode, $createReferenceChipNode } from './editor/chip-node.tsx'
-import { refreshClaimDecoration, registerClaimDecoration } from './editor/claim-decor.ts'
-import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './editor/text-ref.ts'
+import { DraftEditorRuntime } from './editor/runtime.ts'
 import type { EditorProjection } from './editor/projection.ts'
-import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './editor/projection.ts'
-import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './editor/span-map.ts'
 
 /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
 export interface PopupDismissFace {
@@ -103,17 +93,6 @@ const EMPTY_QUEUE: readonly QueuedMessage[] = []
 /** No-pipeline lexicon: zero text-ref decorations. */
 const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
 
-/**
- * Detect-projection and legacy reference placeholders stripped from every
- * external text entering the document (paste, persisted-draft seed): a chip
- * is the only legitimate source of U+FFFC in the detect projection, so a
- * literal one in text would forge chip positions.
- */
-const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
-
-/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
-const HISTORY_MERGE_DELAY_MS = 1000
-
 /** Editor and attachment snapshot owned by one detached default send. */
 interface DetachedDraft {
   readonly draft: string
@@ -132,7 +111,9 @@ export class SessionInputShell implements SessionInput {
   /** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */
   readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
   /** The shell-owned editor (text + chip truth); the composer binds its contenteditable to it. */
-  readonly editor: LexicalEditor
+  get editor(): LexicalEditor {
+    return this.draftEditor.editor
+  }
   /** The public provide-channel action face (one stable identity per session). */
   readonly actions: InputActions = {
     setDraft: (text) => { this.setDraft(text) },
@@ -143,11 +124,11 @@ export class SessionInputShell implements SessionInput {
   }
 
   private readonly core = new SubmitMachine()
-  private projection: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null }
+  private readonly draftEditor: DraftEditorRuntime
+  private get projection(): EditorProjection {
+    return this.draftEditor.projection
+  }
   private rev = 0
-  /** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
-  private readonly occurrenceIds = new Map<NodeKey, number>()
-  private occurrenceSeq = 0
   private readonly unregister: () => void
   private noticeSeq = 0
   private lastMirroredDraft = ''
@@ -157,8 +138,6 @@ export class SessionInputShell implements SessionInput {
   private mirrorFn: ((text: string) => void) | undefined
   /** The mounted composer's file-picker opener (scoped pick-files event target). */
   private filePicker: Parameters<ComposerKeyboard['bindFilePicker']>[0] | undefined
-  /** Live lexicon subscription disposer; undefined until the controller resolves. */
-  private lexiconOff: (() => void) | undefined
   /** Default sends retained until admission settles or scope disposal releases their attachments. */
   private readonly detachedDrafts = new Map<number, DetachedDraft>()
   /** Failed default sends waiting to be restored together in submission order. */
@@ -174,67 +153,24 @@ export class SessionInputShell implements SessionInput {
   }>()
 
   constructor(private readonly deps: SessionInputDeps) {
-    this.editor = createEditor({
-      namespace: 'dsh-composer',
-      nodes: [ReferenceChipNode, TextRefNode],
-      onError: (error) => { throw error },
+    this.draftEditor = new DraftEditorRuntime({
+      onUpdate: () => { this.onEditorUpdate() },
+      openReference: (source, reference) =>
+        this.deps.inputTriggers?.()?.openReference(source, reference) ?? false,
+      activeClaimToken: () => this.activeClaimToken(),
+      lexicon: () => this.lexicon.getSnapshot(),
+      resolveLexicon: () => this.deps.inputTriggers?.()?.lexicon,
     })
-    this.unregister = mergeRegister(
-      registerPlainText(this.editor),
-      registerReferenceActivation(this.editor, (source, reference) =>
-        this.deps.inputTriggers?.()?.openReference(source, reference) ?? false),
-      registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
-      this.editor.registerUpdateListener(() => { this.onEditorUpdate() }),
-      registerClaimDecoration(this.editor, () => this.activeClaimToken()),
-      registerTextRefDecoration(this.editor, () => this.lexicon.getSnapshot(), () => this.activeClaimToken()),
-      () => { this.lexiconOff?.() },
-    )
+    this.unregister = this.draftEditor.register()
     this.state = createSnapshotStore<InputState>(this.compose())
     deps.queue?.subscribe(() => { this.publish() })
   }
 
   // ---- editor plumbing ----
 
-  /**
-   * Run one editor edit whose result is observable on return. At the top
-   * level this is a discrete update. Inside this editor's own update —
-   * command handlers land here synchronously (space/enter picks, paste) —
-   * $-functions are already legal, and wrapping them in update() would DEFER
-   * them past the synchronous bail answer (and a nested discrete throws);
-   * the body runs directly and the outer update commits it.
-   * @param fn - the $-edit body.
-   */
-  private applyEdit(fn: () => void, tag?: string): void {
-    if (this.editor._updating) {
-      // Nested application joins the enclosing update (the PASTE_COMMAND
-      // dispatch path always lands here), so the tag attaches to that update.
-      if (tag !== undefined) $addUpdateTag(tag)
-      fn()
-      return
-    }
-    this.editor.update(fn, { discrete: true, ...(tag === undefined ? {} : { tag }) })
-  }
-
-
-  /**
-   * Subscribe the text-ref re-scan to the controller's lexicon once the
-   * controller resolves. The deps thunk cannot resolve at construction (the
-   * shell is created inside the sessions provide materialization), so the
-   * first interactive updates retry until it can.
-   */
-  private ensureLexiconSubscription(): void {
-    if (this.lexiconOff !== undefined) return
-    const controller = this.deps.inputTriggers?.()
-    if (controller === undefined) return
-    this.lexiconOff = controller.lexicon.subscribe(() => { rescanTextRefs(this.editor) })
-  }
-
   /** Re-project, run the claim watch, publish, and feed trigger tracking after every editor commit. */
   private onEditorUpdate(): void {
-    this.ensureLexiconSubscription()
-    const prev = this.projection
-    this.projection = this.editor.getEditorState().read(() =>
-      $projectComposer(key => this.occurrenceIdOf(key)))
+    const prev = this.draftEditor.refreshProjection()
     // Selection-only commits advance neither the revision nor the published
     // state: menus still track the caret below, while draftRev moves only
     // with content so a snapshot-built span (apply.ts) stays CAS-valid across
@@ -255,14 +191,6 @@ export class SessionInputShell implements SessionInput {
     }
   }
 
-  private occurrenceIdOf(key: NodeKey): number {
-    const existing = this.occurrenceIds.get(key)
-    if (existing !== undefined) return existing
-    this.occurrenceSeq += 1
-    this.occurrenceIds.set(key, this.occurrenceSeq)
-    return this.occurrenceSeq
-  }
-
   // ---- SessionInput face ----
 
   /**
@@ -272,18 +200,7 @@ export class SessionInputShell implements SessionInput {
    * @param text - the full next draft.
    */
   setDraft(text: string): void {
-    const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
-    if (clean === this.projection.clipboardText) return
-    this.editor.update(() => {
-      const root = $getRoot()
-      root.clear()
-      for (const line of clean.split('\n')) {
-        const paragraph = $createParagraphNode()
-        if (line !== '') paragraph.append($createTextNode(line))
-        root.append(paragraph)
-      }
-      root.selectEnd()
-    }, { discrete: true, tag: HISTORY_MERGE_TAG })
+    this.draftEditor.setDraft(text)
   }
 
   /** Append ordered attachment ids unless an admission transaction is locked. */
@@ -341,20 +258,7 @@ export class SessionInputShell implements SessionInput {
    * @param text - pasted plain text.
    */
   paste(text: string): void {
-    const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
-    if (clean === '') return
-    this.applyEdit(() => {
-      const selection = $getSelection()
-      if ($isRangeSelection(selection)) {
-        selection.insertText(clean)
-        return
-      }
-      // No selection yet (never-focused surface): land at the document end,
-      // growing the first paragraph when the tree is empty.
-      const root = $getRoot()
-      if (root.getChildrenSize() === 0) root.append($createParagraphNode())
-      root.selectEnd().insertText(clean)
-    }, PASTE_TAG)
+    this.draftEditor.paste(text)
   }
 
   /**
@@ -446,9 +350,7 @@ export class SessionInputShell implements SessionInput {
    * @returns the ordered [start, end) span in detect coordinates.
    */
   caretSpan(): { start: number; end: number } {
-    if (this.projection.selection !== null) return this.projection.selection
-    const at = this.projection.detectText.length
-    return { start: at, end: at }
+    return this.draftEditor.caretSpan()
   }
 
   /**
@@ -479,10 +381,7 @@ export class SessionInputShell implements SessionInput {
     // Leading-trigger contract: only whitespace may precede the span; the
     // whitespace prefix is dropped so the claimed watch (startsWith) holds.
     if (this.projection.detectText.slice(0, span.start).trim() !== '') return false
-    let applied = false as boolean
-    this.applyEdit(() => {
-      applied = $replaceDetectSpanWithText({ start: 0, end: span.end }, claim.token)
-    })
+    const applied = this.draftEditor.replaceText({ start: 0, end: span.end }, claim.token)
     if (!applied) return false
     this.dispatchRun(({ type: 'claim', claim }))
     return true
@@ -501,14 +400,7 @@ export class SessionInputShell implements SessionInput {
     if (phase !== 'plain' && phase !== 'claimed') return false
     if (span.draftRev !== this.rev) return false
     const tail = this.projection.detectText.slice(span.end, span.end + 1)
-    let applied = false
-    this.applyEdit(() => {
-      const nodes = tail === ' '
-        ? [$createReferenceChipNode(ref)]
-        : [$createReferenceChipNode(ref), $createTextNode(' ')]
-      applied = $replaceDetectSpanWithNodes(span, nodes)
-    })
-    return applied
+    return this.draftEditor.insertReference(span, ref, tail)
   }
 
   /**
@@ -521,11 +413,7 @@ export class SessionInputShell implements SessionInput {
   consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
     if (guard.kind === 'span') {
       if (guard.span.draftRev !== this.rev || guard.span.start === guard.span.end) return false
-      let applied = false
-      this.applyEdit(() => {
-        applied = $replaceDetectSpanWithText(guard.span, '')
-      })
-      return applied
+      return this.draftEditor.replaceText(guard.span, '')
     }
     if (guard.token === '' || this.projection.clipboardText.trim() !== guard.token) return false
     this.setDraft('')
@@ -548,11 +436,7 @@ export class SessionInputShell implements SessionInput {
   insertText(text: string, span: TokenSpan, keepCompleting = false): boolean {
     void keepCompleting
     if (span.draftRev !== this.rev) return false
-    let applied = false
-    this.applyEdit(() => {
-      applied = $replaceDetectSpanWithText(span, text)
-    })
-    return applied
+    return this.draftEditor.replaceText(span, text)
   }
 
   /**
@@ -585,7 +469,6 @@ export class SessionInputShell implements SessionInput {
     this.disposed = true
     this.dispatchRun(({ type: 'release' }))
     this.unregister()
-    this.editor.setRootElement(null)
     this.detachedDrafts.clear()
     this.failedDetached.clear()
     this.attachmentFlights.clear()
@@ -656,7 +539,7 @@ export class SessionInputShell implements SessionInput {
   private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
     const beforeToken = this.activeClaimToken()
     this.run(this.core.dispatch(ev))
-    if (this.activeClaimToken() !== beforeToken) refreshClaimDecoration(this.editor)
+    if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration()
   }
 
   private run(effects: readonly InputEffect[]): void {
@@ -696,20 +579,13 @@ export class SessionInputShell implements SessionInput {
    * undo history so sent content cannot resurrect.
    */
   private commitDraft(retainSuffixOf: string | null): void {
-    this.editor.update(() => {
-      const layout = $composerLayout()
-      const clip = layout.clipboardText
+    this.draftEditor.clearCommittedDraft((clip) => {
       if (retainSuffixOf !== null && clip !== retainSuffixOf && clip.startsWith(retainSuffixOf)) {
-        $replaceDetectSpanWithText(
-          { start: 0, end: detectOffsetOfClipboardOffset(layout, retainSuffixOf.length) }, '',
-        )
-        return
+        return retainSuffixOf.length
       }
-      const root = $getRoot()
-      root.clear()
-      root.selectEnd()
-    }, { discrete: true, tag: HISTORY_MERGE_TAG })
-    this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
+      return null
+    })
+    this.draftEditor.clearHistory()
   }
 
   /**
@@ -819,38 +695,8 @@ export class SessionInputShell implements SessionInput {
     }
     this.restoringFailures = true
     try {
-      this.editor.update(() => {
-        const root = $getRoot()
-        root.clear()
-        let paragraph = $createParagraphNode()
-        root.append(paragraph)
-        const appendText = (text: string): void => {
-          const lines = text.split('\n')
-          for (let i = 0; i < lines.length; i += 1) {
-            const line = lines[i]
-            if (line !== '') paragraph.append($createTextNode(line))
-            if (i < lines.length - 1) {
-              paragraph = $createParagraphNode()
-              root.append(paragraph)
-            }
-          }
-        }
-        let cursor = 0
-        for (const occurrence of occurrences) {
-          appendText(draft.slice(cursor, occurrence.offset))
-          paragraph.append(new ReferenceChipNode({
-            source: occurrence.source,
-            ref: occurrence.ref,
-            label: occurrence.label,
-            ...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }),
-            clipboardText: occurrence.clipboardText,
-          }, occurrence.invalid === true))
-          cursor = occurrence.offset + occurrence.length
-        }
-        appendText(draft.slice(cursor))
-        root.selectEnd()
-      }, { discrete: true, tag: HISTORY_MERGE_TAG })
-      this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
+      this.draftEditor.restoreDraft(draft, occurrences)
+      this.draftEditor.clearHistory()
       this.failedRestoreRev = this.rev
     } finally {
       this.restoringFailures = false

+ 2 - 1
packages/client/ui-conversation/src/client/input/hub.ts

@@ -15,9 +15,10 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import { queueReadFaceOf } from './queue-store.ts'
 import type {
-  ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
+  DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
   SessionInputResolver, SessionInput, SubmitOutcome,
 } from '../contract/input.ts'
+import type { ComposerKeyboard } from '../contract/draft-editor.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import type { PopupDismissFace } from './facade.ts'
 import { SessionInputShell } from './facade.ts'

+ 27 - 104
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -14,7 +14,7 @@
  */
 
 import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent } from 'react'
+import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react'
 import clsx from 'clsx'
 import {
   IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
@@ -29,9 +29,11 @@ import type {} from '@deepseek-ai/dsh-goal/client'
 // api-remotes import already places it in every client program.
 import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ComposerBarProps } from '../contract/slots.ts'
-import { ComposerContentEditable } from '../input/editor/ComposerContentEditable.tsx'
-import { DecoratorPortals } from '../input/editor/DecoratorPortals.tsx'
-import { registerComposerKeymap } from '../input/editor/keymap.ts'
+import { DraftEditor } from '../input/editor/DraftEditor.tsx'
+import {
+  focusDraftEditor, installDraftFilePicker, installDraftKeymap, installDraftWheel,
+  keepDraftFocus, revealDraftSelection,
+} from '../input/editor/view-binding.ts'
 import { resolveSubmitMode } from '../input/submission-policy.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
 import { ContextMeter } from './ContextMeter.tsx'
@@ -152,23 +154,7 @@ export const InputBar = memo(function InputBar({
   // session switches that land the caret off screen). The live DOM selection
   // is the ruler; no mirror layer exists to consult.
   const revealSelection = (): void => {
-    const scrollEl = scrollRef.current
-    if (scrollEl === null || scrollEl.scrollHeight <= scrollEl.clientHeight) return
-    const selection = window.getSelection()
-    if (selection === null || selection.rangeCount === 0) return
-    const range = selection.getRangeAt(0)
-    let rect = range.getBoundingClientRect()
-    if (rect.height === 0 && rect.width === 0) {
-      // A collapsed caret at an empty line reports a zero rect in some
-      // engines; the anchor's element box is the line the caret sits on.
-      const anchor = selection.anchorNode
-      const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement
-      if (el === undefined || el === null) return
-      rect = el.getBoundingClientRect()
-    }
-    const box = scrollEl.getBoundingClientRect()
-    if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom
-    else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top
+    revealDraftSelection(scrollRef)
   }
 
   // Unlock (mount / session switch) returns focus to the box, and owns the
@@ -178,10 +164,7 @@ export const InputBar = memo(function InputBar({
   // caret (restored at the draft's end) off screen.
   useEffect(() => {
     if (locked || editor === null) return
-    // Lexical's focus() restores the editor selection but never calls the DOM
-    // focus itself; preventScroll keeps the conversation scrollport still.
-    editor.getRootElement()?.focus({ preventScroll: true })
-    editor.focus(() => { revealSelection() })
+    focusDraftEditor(editor, revealSelection)
   }, [locked, sessionId, editor])
 
   // A persisted draft arrives AFTER the unlock effect: ConversationSession
@@ -202,19 +185,7 @@ export const InputBar = memo(function InputBar({
   // a short draft never traps the gesture and a long draft stays scrollable.
   // Hero mounts have no host and keep native wheel scrolling.
   useEffect(() => {
-    const el = scrollRef.current
-    if (el === null) return
-    const onWheel = (e: WheelEvent): void => {
-      const host = el.closest('[data-conversation-scroll]')
-      if (!(host instanceof HTMLElement) || e.deltaY === 0) return
-      const atTop = el.scrollTop <= 0
-      const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
-      if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return
-      e.preventDefault()
-      host.scrollTop += e.deltaY
-    }
-    el.addEventListener('wheel', onWheel, { passive: false })
-    return () => { el.removeEventListener('wheel', onWheel) }
+    return installDraftWheel(scrollRef)
   }, [])
 
   // Intake pre-check: an addition that would break a projected image limit is
@@ -270,48 +241,12 @@ export const InputBar = memo(function InputBar({
 
   useEffect(() => {
     if (keyboard === undefined) return
-    return keyboard.bindFilePicker({
-      available: () => gate.current.canAcceptDrop && fileInputRef.current !== null,
-      open: () => { fileInputRef.current?.click() },
-    })
+    return installDraftFilePicker(keyboard, gate, fileInputRef)
   }, [keyboard])
 
   useEffect(() => {
     if (editor === null || keyboard === undefined) return
-    return registerComposerKeymap(editor, {
-      arbitrate: (key, composing) => keyboard.arbitrate(key, composing),
-      space: () => {
-        if (gate.current.machineBusy || gate.current.locked) return false
-        return keyboard.space()
-      },
-      dismissPopup: () => { keyboard.dismissPopup() },
-      canSubmit: () => !gate.current.locked && !gate.current.machineBusy,
-      submit: (accelerated) => {
-        const g = gate.current
-        // Empty-draft accelerated Enter acts on the queue instead of the
-        // (empty) draft: the machine rejects empty drafts, so the gesture
-        // steers every still-pending queued message into the running turn.
-        if (accelerated && g.canSteerQueue) {
-          keyboard.steerQueue()
-          return
-        }
-        if (g.uploadsPending) {
-          g.showToast(g.t('file.stillUploading'))
-          return
-        }
-        keyboard.submit(resolveSubmitMode(
-          g.busyEnter,
-          g.running,
-          accelerated ? 'accelerated' : 'enter',
-          g.steeringAvailable,
-        ))
-      },
-      intakeFiles: (files) => { gate.current.intakeFiles(files) },
-      pasteText: (text) => {
-        if (gate.current.machineBusy || gate.current.locked) return
-        keyboard.paste(text)
-      },
-    })
+    return installDraftKeymap(editor, keyboard, gate)
   }, [editor, keyboard])
 
   // Button presses steal focus from the editor; suppress at mousedown so
@@ -319,8 +254,7 @@ export const InputBar = memo(function InputBar({
   // restores the previous selection, so no reveal is needed: the caret has
   // not moved, and the next keystroke gets the browser's native one.
   const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
-    e.preventDefault()
-    editor?.getRootElement()?.focus({ preventScroll: true })
+    keepDraftFocus(e, editor)
   }
 
   const onToggleCommandMenu = (): void => {
@@ -446,32 +380,21 @@ export const InputBar = memo(function InputBar({
             thing that scrolls. Chips are decorator portals inside the same
             surface, so wrapping, caret geometry, and scrolling are the
             browser's own. */}
-        <div ref={scrollRef} className={css.scroll} data-input-scroll>
-          <div className={css.grow}>
-            <ComposerContentEditable
-              editor={workspaceTrigger ? null : editor}
-              editable={editable}
-              className={clsx(css.input, editorDisabled && css.inputDisabled)}
-              data-phase={input?.phase ?? 'inert'}
-              aria-disabled={editorDisabled || undefined}
-              data-placeholder={placeholderText}
-              // The placeholder was the textarea's accessible name; a div's
-              // data attribute is not, so the label restores it.
-              aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText}
-              aria-haspopup={workspaceTrigger ? 'menu' : undefined}
-              aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
-              tabIndex={workspaceTrigger ? 0 : undefined}
-              onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
-              style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
-            />
-            {draft === '' && attachments.length === 0 && !claimActive && (
-              <div aria-hidden className={css.placeholder} data-composer-placeholder>
-                {placeholderText}
-              </div>
-            )}
-            <DecoratorPortals editor={workspaceTrigger ? null : editor} />
-          </div>
-        </div>
+        <DraftEditor
+          classNames={css}
+          editor={editor}
+          scrollRef={scrollRef}
+          editable={editable}
+          editorDisabled={editorDisabled}
+          phase={input?.phase ?? 'inert'}
+          placeholderText={placeholderText}
+          ariaLabel={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText}
+          workspaceTrigger={workspaceTrigger}
+          workspacePickerOpen={workspacePickerOpen}
+          onWorkspaceKeyDown={onWorkspaceKeyDown}
+          hint={hint}
+          showPlaceholder={draft === '' && attachments.length === 0 && !claimActive}
+        />
         <div className={css.row}>
           <div className={css.tools}>
             <Tooltip label={t('input.commands')} side="top" delayMs={500}>

+ 1 - 1
packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx

@@ -12,7 +12,7 @@ import {
   $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $getSelection,
   $isTextNode, $setSelection,
 } from 'lexical'
-import type { ReferenceInsert } from '../src/client/contract/input.ts'
+import type { ReferenceInsert } from '../src/client/contract/draft-editor.ts'
 import {
   $createReferenceChipNode, $isReferenceChipNode, ReferenceChipNode,
 } from '../src/client/input/editor/chip-node.tsx'

+ 22 - 22
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -387,7 +387,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n      { name: \'conversation.composer\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:158',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157',
   },
   {
     key: 'conversation.composer.bar',
@@ -425,7 +425,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n      { name: \'conversation.composer.bar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175',
   },
   {
     key: 'conversation.composer.dock',
@@ -480,7 +480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n      { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:170',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -510,7 +510,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n      { name: \'conversation.hero.agentPreset\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:164',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
   },
   {
     key: 'conversation.hero.workspace',
@@ -570,7 +570,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n      { name: \'conversation.hero.workspace\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:160',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -641,7 +641,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n      { name: \'conversation.input.attachments\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177',
   },
   {
     key: 'conversation.input.dock',
@@ -703,7 +703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n      { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:166',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165',
   },
   {
     key: 'conversation.input.left',
@@ -756,7 +756,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n      { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171',
   },
   {
     key: 'conversation.input.model',
@@ -794,7 +794,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n      { name: \'conversation.input.model\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187',
   },
   {
     key: 'conversation.input.overlay',
@@ -851,7 +851,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167',
   },
   {
     key: 'conversation.input.permission',
@@ -889,7 +889,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n      { name: \'conversation.input.permission\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185',
   },
   {
     key: 'conversation.input.plan',
@@ -927,7 +927,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n      { name: \'conversation.input.plan\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183',
   },
   {
     key: 'conversation.input.right',
@@ -980,7 +980,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n      { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
   },
   {
     key: 'conversation.message.images',
@@ -1058,7 +1058,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n      { name: \'conversation.session\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:122',
   },
   {
     key: 'conversation.session.header',
@@ -1094,7 +1094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n      { name: \'conversation.session.header\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:124',
   },
   {
     key: 'conversation.session.header.actions',
@@ -1155,7 +1155,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n      { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:132',
   },
   {
     key: 'conversation.session.header.corner',
@@ -1193,7 +1193,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n      { name: \'conversation.session.header.corner\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149',
   },
   {
     key: 'conversation.session.header.lineage',
@@ -1233,7 +1233,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n      { name: \'conversation.session.header.lineage\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:126',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1291,7 +1291,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n      { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:138',
   },
   {
     key: 'conversation.trajectory.images',
@@ -1393,7 +1393,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n      { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:156',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155',
   },
   {
     key: 'main',
@@ -1462,7 +1462,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n      { name: \'main.conversation\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120',
   },
   {
     key: 'rightbar',