Browse Source

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

imccyu 1 month ago
parent
commit
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 { FileCard } from '../FileCard.tsx'
 import { ImageLightbox } from '../ImageLightbox.tsx'
 import { ImageLightbox } from '../ImageLightbox.tsx'
 import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts'
 import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts'
+import { installDocumentDropEvents } from './drop-events.ts'
 import css from './ComposerAttachments.module.css'
 import css from './ComposerAttachments.module.css'
 
 
 /** Rail item retaining its browser-owned attachment for callbacks. */
 /** Rail item retaining its browser-owned attachment for callbacks. */
@@ -29,54 +30,7 @@ export function ComposerAttachments({
   }, [attachments, preview])
   }, [attachments, preview])
 
 
   useEffect(() => {
   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])
   }, [canAcceptDrop, onAddFiles])
 
 
   const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
   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 { Context } from '@deepseek-ai/cordis'
 import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 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 { QueueRow } from './queue.ts'
 import type { InputSubmitMode } from './composer-submission.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. */
 /** Attachment payload passed to a claimed command submission. */
 export type SubmitAttachment =
 export type SubmitAttachment =
   | {
   | {
@@ -59,15 +52,6 @@ export interface CommandClaim {
   submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise<SubmitOutcome>
   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. */
 /** Result of trigger-source adjudication. */
 export type PickOutcome =
 export type PickOutcome =
   | { readonly claim: CommandClaim }
   | { readonly claim: CommandClaim }
@@ -76,12 +60,6 @@ export type PickOutcome =
   | 'handled'
   | 'handled'
   | undefined
   | 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. */
 /** Scoped request to enter command mode. */
 export interface BeginCommandRequest {
 export interface BeginCommandRequest {
   readonly claim: CommandClaim
   readonly claim: CommandClaim
@@ -255,91 +233,12 @@ export interface InputNotice {
   readonly seq: number
   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. */
 /** One independently addressable row projected from the transient queue snapshot. */
 export type QueuedMessage = QueueRow
 export type QueuedMessage = QueueRow
 
 
 /** Guard union of the scoped consume-token event, checked by the shell. */
 /** Guard union of the scoped consume-token event, checked by the shell. */
 export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
 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). */
 /** Published input state (the currency; per-session). */
 export interface InputState {
 export interface InputState {
   /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */
   /** 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 { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type { ComposerBlock } from './composer-blocks.ts'
 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 { createConversationStore } from '../stores.ts'
 import type { BusyEnterBehavior } from './composer-submission.ts'
 import type { BusyEnterBehavior } from './composer-submission.ts'
 import type { ConversationSnapshot } from './snapshot.ts'
 import type { ConversationSnapshot } from './snapshot.ts'

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

@@ -62,11 +62,11 @@ export type {
   UseConversationViews,
   UseConversationViews,
 } from './contract/slots.ts'
 } from './contract/slots.ts'
 export type {
 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'
 } from './contract/input.ts'
+export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts'
 export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'
 export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'
 
 
 declare module '@deepseek-ai/cordis' {
 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,
   EditorConfig, LexicalNode, NodeKey, SerializedLexicalNode, Spread,
 } from 'lexical'
 } from 'lexical'
 import { DecoratorNode } 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'
 import { ReferenceChip } from './ReferenceChip.tsx'
 
 
 /** JSON form of one chip (Lexical node serialization contract). */
 /** 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,
   KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND,
 } from 'lexical'
 } from 'lexical'
 import { mergeRegister } from '@lexical/utils'
 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. */
 /** The bar-supplied behavior behind each intercepted gesture. */
 export interface ComposerKeymapHandlers {
 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 {
 import {
   $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode,
   $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode,
 } from 'lexical'
 } from 'lexical'
-import type { Occurrence } from '../../contract/input.ts'
+import type { Occurrence } from '../../contract/draft-editor.ts'
 import { $isReferenceChipNode } from './chip-node.tsx'
 import { $isReferenceChipNode } from './chip-node.tsx'
 
 
 /** The detect-projection stand-in for one chip (object replacement character). */
 /** 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,
   CLICK_COMMAND, COMMAND_PRIORITY_LOW,
 } from 'lexical'
 } from 'lexical'
 import type { LexicalEditor } 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 { $isReferenceChipNode } from './chip-node.tsx'
 import { TextRefNode } from './text-ref.ts'
 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 {
 import {
   createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
   createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
 } 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 {
 import type {
-  ArbitrateKey, ArbitrateOutcome, CommandClaim, ComposerKeyboard, ConsumeTokenRequest, DraftAttachmentId,
+  CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
-  Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
-  SubmitOutcome, TokenSpan,
+  QueuedMessage, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
 } from '../contract/input.ts'
 } 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 type { InputSubmitMode } from '../contract/composer-submission.ts'
 import { SubmitMachine } from './machine.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 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). */
 /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
 export interface PopupDismissFace {
 export interface PopupDismissFace {
@@ -103,17 +93,6 @@ const EMPTY_QUEUE: readonly QueuedMessage[] = []
 /** No-pipeline lexicon: zero text-ref decorations. */
 /** No-pipeline lexicon: zero text-ref decorations. */
 const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
 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. */
 /** Editor and attachment snapshot owned by one detached default send. */
 interface DetachedDraft {
 interface DetachedDraft {
   readonly draft: string
   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. */
   /** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */
   readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
   readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
   /** The shell-owned editor (text + chip truth); the composer binds its contenteditable to it. */
   /** 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). */
   /** The public provide-channel action face (one stable identity per session). */
   readonly actions: InputActions = {
   readonly actions: InputActions = {
     setDraft: (text) => { this.setDraft(text) },
     setDraft: (text) => { this.setDraft(text) },
@@ -143,11 +124,11 @@ export class SessionInputShell implements SessionInput {
   }
   }
 
 
   private readonly core = new SubmitMachine()
   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
   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 readonly unregister: () => void
   private noticeSeq = 0
   private noticeSeq = 0
   private lastMirroredDraft = ''
   private lastMirroredDraft = ''
@@ -157,8 +138,6 @@ export class SessionInputShell implements SessionInput {
   private mirrorFn: ((text: string) => void) | undefined
   private mirrorFn: ((text: string) => void) | undefined
   /** The mounted composer's file-picker opener (scoped pick-files event target). */
   /** The mounted composer's file-picker opener (scoped pick-files event target). */
   private filePicker: Parameters<ComposerKeyboard['bindFilePicker']>[0] | undefined
   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. */
   /** Default sends retained until admission settles or scope disposal releases their attachments. */
   private readonly detachedDrafts = new Map<number, DetachedDraft>()
   private readonly detachedDrafts = new Map<number, DetachedDraft>()
   /** Failed default sends waiting to be restored together in submission order. */
   /** 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) {
   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())
     this.state = createSnapshotStore<InputState>(this.compose())
     deps.queue?.subscribe(() => { this.publish() })
     deps.queue?.subscribe(() => { this.publish() })
   }
   }
 
 
   // ---- editor plumbing ----
   // ---- 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. */
   /** Re-project, run the claim watch, publish, and feed trigger tracking after every editor commit. */
   private onEditorUpdate(): void {
   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
     // Selection-only commits advance neither the revision nor the published
     // state: menus still track the caret below, while draftRev moves only
     // state: menus still track the caret below, while draftRev moves only
     // with content so a snapshot-built span (apply.ts) stays CAS-valid across
     // 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 ----
   // ---- SessionInput face ----
 
 
   /**
   /**
@@ -272,18 +200,7 @@ export class SessionInputShell implements SessionInput {
    * @param text - the full next draft.
    * @param text - the full next draft.
    */
    */
   setDraft(text: string): void {
   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. */
   /** Append ordered attachment ids unless an admission transaction is locked. */
@@ -341,20 +258,7 @@ export class SessionInputShell implements SessionInput {
    * @param text - pasted plain text.
    * @param text - pasted plain text.
    */
    */
   paste(text: string): void {
   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.
    * @returns the ordered [start, end) span in detect coordinates.
    */
    */
   caretSpan(): { start: number; end: number } {
   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
     // Leading-trigger contract: only whitespace may precede the span; the
     // whitespace prefix is dropped so the claimed watch (startsWith) holds.
     // whitespace prefix is dropped so the claimed watch (startsWith) holds.
     if (this.projection.detectText.slice(0, span.start).trim() !== '') return false
     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
     if (!applied) return false
     this.dispatchRun(({ type: 'claim', claim }))
     this.dispatchRun(({ type: 'claim', claim }))
     return true
     return true
@@ -501,14 +400,7 @@ export class SessionInputShell implements SessionInput {
     if (phase !== 'plain' && phase !== 'claimed') return false
     if (phase !== 'plain' && phase !== 'claimed') return false
     if (span.draftRev !== this.rev) return false
     if (span.draftRev !== this.rev) return false
     const tail = this.projection.detectText.slice(span.end, span.end + 1)
     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 {
   consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
     if (guard.kind === 'span') {
     if (guard.kind === 'span') {
       if (guard.span.draftRev !== this.rev || guard.span.start === guard.span.end) return false
       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
     if (guard.token === '' || this.projection.clipboardText.trim() !== guard.token) return false
     this.setDraft('')
     this.setDraft('')
@@ -548,11 +436,7 @@ export class SessionInputShell implements SessionInput {
   insertText(text: string, span: TokenSpan, keepCompleting = false): boolean {
   insertText(text: string, span: TokenSpan, keepCompleting = false): boolean {
     void keepCompleting
     void keepCompleting
     if (span.draftRev !== this.rev) return false
     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.disposed = true
     this.dispatchRun(({ type: 'release' }))
     this.dispatchRun(({ type: 'release' }))
     this.unregister()
     this.unregister()
-    this.editor.setRootElement(null)
     this.detachedDrafts.clear()
     this.detachedDrafts.clear()
     this.failedDetached.clear()
     this.failedDetached.clear()
     this.attachmentFlights.clear()
     this.attachmentFlights.clear()
@@ -656,7 +539,7 @@ export class SessionInputShell implements SessionInput {
   private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
   private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
     const beforeToken = this.activeClaimToken()
     const beforeToken = this.activeClaimToken()
     this.run(this.core.dispatch(ev))
     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 {
   private run(effects: readonly InputEffect[]): void {
@@ -696,20 +579,13 @@ export class SessionInputShell implements SessionInput {
    * undo history so sent content cannot resurrect.
    * undo history so sent content cannot resurrect.
    */
    */
   private commitDraft(retainSuffixOf: string | null): void {
   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)) {
       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
     this.restoringFailures = true
     try {
     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
       this.failedRestoreRev = this.rev
     } finally {
     } finally {
       this.restoringFailures = false
       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 type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import { queueReadFaceOf } from './queue-store.ts'
 import { queueReadFaceOf } from './queue-store.ts'
 import type {
 import type {
-  ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
+  DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
   SessionInputResolver, SessionInput, SubmitOutcome,
   SessionInputResolver, SessionInput, SubmitOutcome,
 } from '../contract/input.ts'
 } from '../contract/input.ts'
+import type { ComposerKeyboard } from '../contract/draft-editor.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import type { PopupDismissFace } from './facade.ts'
 import type { PopupDismissFace } from './facade.ts'
 import { SessionInputShell } 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 { 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 clsx from 'clsx'
 import {
 import {
   IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
   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.
 // api-remotes import already places it in every client program.
 import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
 import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ComposerBarProps } from '../contract/slots.ts'
 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 { resolveSubmitMode } from '../input/submission-policy.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
 import { ContextMeter } from './ContextMeter.tsx'
 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
   // session switches that land the caret off screen). The live DOM selection
   // is the ruler; no mirror layer exists to consult.
   // is the ruler; no mirror layer exists to consult.
   const revealSelection = (): void => {
   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
   // 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.
   // caret (restored at the draft's end) off screen.
   useEffect(() => {
   useEffect(() => {
     if (locked || editor === null) return
     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])
   }, [locked, sessionId, editor])
 
 
   // A persisted draft arrives AFTER the unlock effect: ConversationSession
   // 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.
   // a short draft never traps the gesture and a long draft stays scrollable.
   // Hero mounts have no host and keep native wheel scrolling.
   // Hero mounts have no host and keep native wheel scrolling.
   useEffect(() => {
   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
   // Intake pre-check: an addition that would break a projected image limit is
@@ -270,48 +241,12 @@ export const InputBar = memo(function InputBar({
 
 
   useEffect(() => {
   useEffect(() => {
     if (keyboard === undefined) return
     if (keyboard === undefined) return
-    return keyboard.bindFilePicker({
-      available: () => gate.current.canAcceptDrop && fileInputRef.current !== null,
-      open: () => { fileInputRef.current?.click() },
-    })
+    return installDraftFilePicker(keyboard, gate, fileInputRef)
   }, [keyboard])
   }, [keyboard])
 
 
   useEffect(() => {
   useEffect(() => {
     if (editor === null || keyboard === undefined) return
     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])
   }, [editor, keyboard])
 
 
   // Button presses steal focus from the editor; suppress at mousedown so
   // 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
   // restores the previous selection, so no reveal is needed: the caret has
   // not moved, and the next keystroke gets the browser's native one.
   // not moved, and the next keystroke gets the browser's native one.
   const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
   const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
-    e.preventDefault()
-    editor?.getRootElement()?.focus({ preventScroll: true })
+    keepDraftFocus(e, editor)
   }
   }
 
 
   const onToggleCommandMenu = (): void => {
   const onToggleCommandMenu = (): void => {
@@ -446,32 +380,21 @@ export const InputBar = memo(function InputBar({
             thing that scrolls. Chips are decorator portals inside the same
             thing that scrolls. Chips are decorator portals inside the same
             surface, so wrapping, caret geometry, and scrolling are the
             surface, so wrapping, caret geometry, and scrolling are the
             browser's own. */}
             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.row}>
           <div className={css.tools}>
           <div className={css.tools}>
             <Tooltip label={t('input.commands')} side="top" delayMs={500}>
             <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,
   $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $getSelection,
   $isTextNode, $setSelection,
   $isTextNode, $setSelection,
 } from 'lexical'
 } from 'lexical'
-import type { ReferenceInsert } from '../src/client/contract/input.ts'
+import type { ReferenceInsert } from '../src/client/contract/draft-editor.ts'
 import {
 import {
   $createReferenceChipNode, $isReferenceChipNode, ReferenceChipNode,
   $createReferenceChipNode, $isReferenceChipNode, ReferenceChipNode,
 } from '../src/client/input/editor/chip-node.tsx'
 } 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',
     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}',
     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',
     key: 'conversation.composer.bar',
@@ -425,7 +425,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.composer.dock',
@@ -480,7 +480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.hero.agentPreset',
@@ -510,7 +510,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.hero.brand.mark',
@@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     occupants: [],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.hero.workspace',
@@ -570,7 +570,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.hero.workspace.directoryFlow',
@@ -641,7 +641,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.input.dock',
@@ -703,7 +703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.input.left',
@@ -756,7 +756,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     occupants: [],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.input.model',
@@ -794,7 +794,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.input.overlay',
@@ -851,7 +851,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.input.permission',
@@ -889,7 +889,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.input.plan',
@@ -927,7 +927,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.input.right',
@@ -980,7 +980,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     occupants: [],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.message.images',
@@ -1058,7 +1058,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.session.header',
@@ -1094,7 +1094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.session.header.actions',
@@ -1155,7 +1155,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.session.header.corner',
@@ -1193,7 +1193,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.session.header.lineage',
@@ -1233,7 +1233,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'conversation.session.header.utilities',
@@ -1291,7 +1291,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'none',
     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}',
     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',
     key: 'conversation.trajectory.images',
@@ -1393,7 +1393,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'none',
     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}',
     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',
     key: 'main',
@@ -1462,7 +1462,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'shadows-shipped-ui',
     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}',
     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',
     key: 'rightbar',