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

refactor(web): isolate draft editor implementation without behavior changes

imccyu 1 неделя назад
Родитель
Сommit
b5abe6cf29

+ 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)
+  }
+}

+ 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',