Kaynağa Gözat

feat(ui-conversation): lexical composer replaces the textarea stack

The editor (shell-owned, per-session) is the draft + chip truth; the
machine slims to the submit plane. Chips are atomic decorator nodes with
NodeKey identity; TokenSpan coordinates ride the detect projection (chip =
one U+FFFC), persistence and InputState.draft ride the clipboard
projection. The mirror/backdrop layers, Safari soft-wrap repair, manual
undo log, boundary occurrence deletion, and clipboard expansion all
retire; the producerless paste-attempt and set-invalid planes go with
them.
Yichen Jiang 1 ay önce
ebeveyn
işleme
b519cb87b0
25 değiştirilmiş dosya ile 1827 ekleme ve 2704 silme
  1. 61 110
      packages/client/ui-conversation/src/client/input/contract.ts
  2. 6 59
      packages/client/ui-conversation/src/client/input/decorations.ts
  3. 50 0
      packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx
  4. 41 0
      packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx
  5. 1 1
      packages/client/ui-conversation/src/client/input/editor/chip-node.tsx
  6. 69 0
      packages/client/ui-conversation/src/client/input/editor/claim-decor.ts
  7. 11 0
      packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css
  8. 149 0
      packages/client/ui-conversation/src/client/input/editor/keymap.ts
  9. 32 4
      packages/client/ui-conversation/src/client/input/editor/projection.ts
  10. 10 0
      packages/client/ui-conversation/src/client/input/editor/span-map.ts
  11. 138 0
      packages/client/ui-conversation/src/client/input/editor/text-ref.ts
  12. 318 138
      packages/client/ui-conversation/src/client/input/facade.ts
  13. 48 426
      packages/client/ui-conversation/src/client/input/machine.ts
  14. 31 156
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  15. 159 426
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  16. 28 16
      packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
  17. 194 376
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  18. 0 927
      packages/client/ui-conversation/tests/input-machine.client.spec.ts
  19. 32 23
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  20. 15 12
      packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts
  21. 22 13
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  22. 32 0
      packages/client/ui-conversation/tests/keydown-probe.client.spec.tsx
  23. 1 1
      packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
  24. 23 16
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  25. 356 0
      packages/client/ui-conversation/tests/submit-machine.client.spec.ts

+ 61 - 110
packages/client/ui-conversation/src/client/input/contract.ts

@@ -2,11 +2,13 @@
  * Frozen input-machine contract. Types
  * Frozen input-machine contract. Types
  * only. Three-tier visibility: business packages see InputState via the
  * only. Three-tier visibility: business packages see InputState via the
  * InputZone currency; the scoped input events carry the mutation verbs; the
  * InputZone currency; the scoped input events carry the mutation verbs; the
- * conversation wiring layer alone sees the full SessionInput. InputMachine
- * (machine.ts) is package-private and never exported.
+ * conversation wiring layer alone sees the full SessionInput. The draft text
+ * and its reference chips live in the shell's Lexical editor; the machine
+ * here is the submit plane (phase, claim, attempt) alone.
  */
  */
 import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { LexicalEditor } from 'lexical'
 import type {
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
   ReferenceInsert, SubmitOutcome, TokenSpan,
   ReferenceInsert, SubmitOutcome, TokenSpan,
@@ -19,19 +21,19 @@ export type DraftAttachmentId = Branded<'DraftAttachmentId'>
 
 
 /**
 /**
  * The scoped-event application verbs: the hub's bail listeners call these,
  * The scoped-event application verbs: the hub's bail listeners call these,
- * and the boolean answer IS the event's bail value (true ⟺ the machine
- * accepted after phase and span/bare-token guards).
+ * and the boolean answer IS the event's bail value (true ⟺ the editor
+ * applied the edit after phase and span guards).
  */
  */
 export interface InputTarget {
 export interface InputTarget {
   /** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */
   /** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */
   beginCommand(claim: CommandClaim, span: TokenSpan): boolean
   beginCommand(claim: CommandClaim, span: TokenSpan): boolean
-  /** Replace the trigger span with one reference occurrence (span-CAS'd). */
+  /** Replace the trigger span with one reference chip (span-CAS'd). */
   insertReference(ref: ReferenceInsert, span: TokenSpan): boolean
   insertReference(ref: ReferenceInsert, span: TokenSpan): boolean
 }
 }
 
 
 /** Per-session input facade owned by the conversation wiring layer. */
 /** Per-session input facade owned by the conversation wiring layer. */
 export interface SessionInput extends InputTarget {
 export interface SessionInput extends InputTarget {
-  /** Single write path for draft text (all mutation rides machine events). */
+  /** Replace the whole draft (persisted-draft seed and programmatic writes). */
   setDraft(text: string): void
   setDraft(text: string): void
   /** Append ordered browser-owned image ids; busy admission phases refuse. */
   /** Append ordered browser-owned image ids; busy admission phases refuse. */
   addImages(ids: readonly DraftAttachmentId[]): boolean
   addImages(ids: readonly DraftAttachmentId[]): boolean
@@ -66,12 +68,12 @@ export interface SessionInputResolver {
 
 
 /**
 /**
  * The public input action face provided to every session-scope slot
  * The public input action face provided to every session-scope slot
- * component: two stable-identity void callbacks, mirroring the
- * useStore+actions convention. Command-style handles (track/arbitrate/space/
- * undo/paste/…) stay InputBar-private and never ride this face.
+ * component: stable-identity void callbacks, mirroring the
+ * useStore+actions convention. Command-style handles (arbitrate/space/
+ * paste/…) stay InputBar-private and never ride this face.
  */
  */
 export interface InputActions {
 export interface InputActions {
-  /** Single public draft write path (full next draft; occurrence math via diff scan). */
+  /** Replace the whole draft (persisted-draft seed and programmatic writes). */
   setDraft(text: string): void
   setDraft(text: string): void
   /** Append ordered browser-owned image ids; busy admission phases refuse. */
   /** Append ordered browser-owned image ids; busy admission phases refuse. */
   addImages(ids: readonly DraftAttachmentId[]): boolean
   addImages(ids: readonly DraftAttachmentId[]): boolean
@@ -95,13 +97,15 @@ export interface InputNotice {
  * returns and event-handler semantics that must not enter the public provide
  * returns and event-handler semantics that must not enter the public provide
  * channel. Handed to the composer-bar entry through its own inject —
  * channel. Handed to the composer-bar entry through its own inject —
  * package-internal, never across a plugin boundary. The session shell
  * package-internal, never across a plugin boundary. The session shell
- * satisfies it structurally.
+ * 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 {
 export interface ComposerKeyboard {
   /** Live machine state for event-handler reads (render reads go through useInput). */
   /** Live machine state for event-handler reads (render reads go through useInput). */
   readonly snapshot: InputState
   readonly snapshot: InputState
-  /** Draft write with the DOM-observed edit shape (narrows occurrence math). */
-  setDraft(text: string, editRange?: EditRange): void
+  /** The shell-owned Lexical editor the composer binds its contenteditable to. */
+  readonly editor: LexicalEditor
   /** Submit with an explicit delivery mode resolved by the keyboard policy. */
   /** Submit with an explicit delivery mode resolved by the keyboard policy. */
   submit(mode: InputSubmitMode): void
   submit(mode: InputSubmitMode): void
   /**
   /**
@@ -110,14 +114,14 @@ export interface ComposerKeyboard {
    * button is the same operation applied to the whole queue).
    * button is the same operation applied to the whole queue).
    */
    */
   steerQueue(): void
   steerQueue(): void
-  undo(): void
-  redo(): void
-  /** Paste over the selection (sync components ride the same transaction). */
-  pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void
-  /** Caret/selection gestures the machine cannot observe end the paste attempt. */
-  invalidatePaste(): void
-  /** Feed a draft/caret change through trigger detection (guard derived from phase). */
-  track(draft: string, caret: number): 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). */
   /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
   arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
   arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
   /** Space adjudication; true = the input applied a claim — caller preventDefaults. */
   /** Space adjudication; true = the input applied a claim — caller preventDefaults. */
@@ -129,42 +133,33 @@ export interface ComposerKeyboard {
 /** 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 machine. */
+/** 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 draft character coordinates. */
+/** Half-open [start, end) range/selection in detect-projection coordinates. */
 export interface EditSelection {
 export interface EditSelection {
   readonly start: number
   readonly start: number
   readonly end: number
   readonly end: number
 }
 }
 
 
 /**
 /**
- * One edit applied to the previous draft: [start, end) in the PREVIOUS
- * draft's coordinates was replaced by insertedLength characters. Supplied by
- * the wiring layer when the DOM event exposes the edit shape; absent, the
- * machine recovers it with a prefix/suffix common-scan diff.
- */
-export interface EditRange extends EditSelection {
-  readonly insertedLength: number
-}
-
-/**
- * One reference occurrence backed by its complete inline display text in the
- * draft. Identity is occurrenceId — same-named
- * references stay independently addressable. label/appearance/clipboardText are the
- * owner's insert-time projections, cached so the chip survives owner loss
- * (invalid flips instead of dropping the occurrence).
+ * 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 {
 export interface Occurrence {
-  /** Machine-minted stable identity (monotonic per machine). */
+  /** Shell-assigned stable identity (monotonic per shell, keyed by NodeKey). */
   readonly occurrenceId: number
   readonly occurrenceId: number
   /** Owning source name (serializer routing key). */
   /** Owning source name (serializer routing key). */
   readonly source: string
   readonly source: string
   /** Owner-scoped reference id. */
   /** Owner-scoped reference id. */
   readonly ref: string
   readonly ref: string
-  /** Display-text offset in the draft. */
+  /** Offset in the clipboard-text projection. */
   readonly offset: number
   readonly offset: number
-  /** Display-text length; the occurrence occupies exactly [offset, offset+length). */
+  /** Length in the clipboard-text projection; the occurrence occupies exactly [offset, offset+length). */
   readonly length: number
   readonly length: number
   /** Inline display label (insert-time cache). */
   /** Inline display label (insert-time cache). */
   readonly label: string
   readonly label: string
@@ -176,53 +171,19 @@ export interface Occurrence {
   readonly invalid?: boolean
   readonly invalid?: boolean
 }
 }
 
 
-/** One sync-matched paste component; start/end are relative to the pasted text. */
-export interface PasteComponent extends EditSelection {
-  readonly reference: ReferenceInsert
-}
-
-/**
- * Live paste-match attempt published while async matching may still upgrade
- * pasted tokens (the clipboard round-trip). Any non-paste transaction,
- * submit start, invalidate-paste, or release ends it; a paste-upgrade keeps
- * it current (later tokens re-CAS against the advanced draftRev).
- */
-export interface PasteAttemptState {
-  /** Machine-minted attempt identity (paste-upgrade must match it). */
-  readonly attemptId: number
-  /** Pasted range in the draft as of the paste transaction. */
-  readonly insertedRange: EditSelection
-  /** Caller-supplied projection generation echoed back (the controller drops cross-generation results). */
-  readonly generation: number
-}
-
-/**
- * InputMachine construction knobs. The machine never reads an ambient clock:
- * `now` is the only time source, injected by the shell (tests inject a
- * fake). The default clock is constant, i.e. consecutive single-char typing
- * always coalesces until a non-typing transaction intervenes.
- */
-export interface InputMachineOptions {
-  /** Single-char typing undo-merge window in ms (default 1000). */
-  readonly mergeWindowMs?: number
-  /** Monotonic clock for typing-merge decisions (default: constant 0). */
-  readonly now?: () => number
-}
-
 /** 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). */
   readonly draft: string
   readonly draft: string
   /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */
   /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */
   readonly imageIds: readonly DraftAttachmentId[]
   readonly imageIds: readonly DraftAttachmentId[]
-  /** Monotonic draft revision (span CAS compares against this). */
+  /** Monotonic editor revision (span CAS compares against this). */
   readonly draftRev: number
   readonly draftRev: number
   readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
   readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
   /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
   /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
   readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean }
   readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean }
-  /** Reference occurrence table, sorted by offset. */
+  /** Reference occurrence view of the editor's chips, sorted by offset. */
   readonly occurrences: readonly Occurrence[]
   readonly occurrences: readonly Occurrence[]
-  /** Live paste-match attempt (absent when no paste is matchable). */
-  readonly paste?: PasteAttemptState
   /** Read-only transient inbox projection (`session/queue`, including pending steering). */
   /** Read-only transient inbox projection (`session/queue`, including pending steering). */
   readonly queue: readonly QueuedMessage[]
   readonly queue: readonly QueuedMessage[]
 }
 }
@@ -236,56 +197,46 @@ export interface InputState {
 export interface SubmitAttempt {
 export interface SubmitAttempt {
   readonly seq: number
   readonly seq: number
   readonly signal: AbortSignal
   readonly signal: AbortSignal
-  /** Draft at enter time; settlement clears it only after acceptance. */
+  /** Clipboard-projection draft at enter time; settlement clears it only after acceptance. */
   readonly draftSnapshot: string
   readonly draftSnapshot: string
   /** Default-message delivery intent retained while slash adjudication is pending. */
   /** Default-message delivery intent retained while slash adjudication is pending. */
   readonly mode: InputSubmitMode
   readonly mode: InputSubmitMode
 }
 }
 
 
 /**
 /**
- * InputMachine input events (the machine's single write path). Every draft
- * mutation is one transaction: draft edit, occurrence reconciliation, and
- * undo-log push are atomic inside dispatch(). Events carrying `at` stamp the
- * injected clock reading; only single-char typing coalescing reads it.
+ * Submit-machine input events (the machine's single write path). Text
+ * mutation lives in the editor; the machine only observes the draft through
+ * event payloads (claim integrity, enter snapshots, settlement decisions).
  */
  */
 export type InputEvent =
 export type InputEvent =
-  /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
-  | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
-  | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
-  /** Place one inline reference at the span and mint the occurrence (scoped insert-reference event payload). */
-  | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
-  /** Delete a settled command token; success is observable as a draftRev advance. */
-  | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard }
-  /** Owner-resolution result: exactly the listed occurrences are invalid (style bit; not a transaction). */
-  | { readonly type: 'set-invalid'; readonly invalidIds: readonly number[] }
-  | { readonly type: 'undo' }
-  | { readonly type: 'redo' }
-  /**
-   * Paste text replacing the selection, one transaction. Hot-snapshot sync
-   * matches ride in as components (chips minted inside the SAME transaction:
-   * one undo returns to pre-paste); a PasteMatchAttempt opens for the async
-   * remainder. Component ranges must be disjoint and inside the pasted text.
-   */
-  | { readonly type: 'paste-begin'; readonly text: string; readonly selection: EditSelection; readonly components?: readonly PasteComponent[]; readonly generation?: number }
-  /** Async match landed: upgrade one pasted token to a chip as an INDEPENDENT transaction (undo #1 → text, undo #2 → pre-paste). */
-  | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
-  /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
-  | { readonly type: 'invalidate-paste' }
-  | { readonly type: 'enter'; readonly mode: InputSubmitMode }
+  /** Clipboard projection changed: the claimed integrity watch runs (zero effects). */
+  | { readonly type: 'draft-changed'; readonly draft: string }
+  /** The editor applied a claim-token replacement: enter claimed. */
+  | { readonly type: 'claim'; readonly claim: CommandClaim }
+  /** Enter submission with the current clipboard projection. */
+  | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string }
   | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
   | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
   | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
   | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
-  | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
+  /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
+  | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string }
   /** Commit an image-only send whose empty draft did not need an attempt. */
   /** Commit an image-only send whose empty draft did not need an attempt. */
   | { readonly type: 'send-committed' }
   | { readonly type: 'send-committed' }
   | { readonly type: 'release' }
   | { readonly type: 'release' }
 
 
 /**
 /**
- * InputMachine output effects (executed by the SessionInput shell; the
- * machine stays pure). Draft/occurrence mutations carry no effect — the
- * shell publishes the state store after every dispatch.
+ * Submit-machine output effects (executed by the SessionInput shell; the
+ * machine stays pure).
  */
  */
 export type InputEffect =
 export type InputEffect =
   | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
   | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
   | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
   | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
   | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode }
   | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode }
   | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
   | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
+  /**
+   * Clear the committed draft in the editor and cut undo history. A string
+   * snapshot keeps a pure suffix typed during the Host round-trip (content
+   * appended after the sent snapshot survives; interleaved edits cannot be
+   * separated and clear whole); null clears unconditionally (image-only
+   * sends have no draft to retain).
+   */
+  | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null }

+ 6 - 59
packages/client/ui-conversation/src/client/input/decorations.ts

@@ -1,33 +1,11 @@
 /**
 /**
- * Draft decoration pure core (references render from occurrence ranges; the
- * claim token renders as a mirror-layer
- * highlight, the claim hint as ghost text). Zero React — the skeleton renders
- * the instructions; tests drive this directly.
+ * Plain-text reference scan (the plain-text-reference decision;
+ * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
+ * a `/name` or `@name` token whose name is on the trigger's lexicon, and
+ * syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's
+ * text-ref entity transform consumes these ranges; editing the text out of
+ * match shape simply drops the range next scan.
  */
  */
-import type { InputState } from './contract.ts'
-
-/** The claim-token highlight range (always draft-leading while the watch holds). */
-export interface TokenRange {
-  readonly start: number
-  readonly end: number
-}
-
-/** One structured inline-reference render instruction. */
-export interface ChipRender {
-  /** Stable render key (same-labeled chips stay independent). */
-  readonly occurrenceId: number
-  /** Display-text offset in the draft. */
-  readonly offset: number
-  /** Display-text length in the draft. */
-  readonly length: number
-  /** Exact inline text whose native glyph metrics determine layout. */
-  readonly text: string
-  readonly label: string
-  /** Optional domain glyph beside the label. */
-  readonly appearance?: 'session' | 'file' | 'folder'
-  /** Owner-resolution failure styling bit. */
-  readonly invalid: boolean
-}
 
 
 /**
 /**
  * One plain-text reference range (the plain-text-reference decision;
  * One plain-text reference range (the plain-text-reference decision;
@@ -98,34 +76,3 @@ export function scanTextRefs(
   }
   }
   return out.sort((left, right) => left.start - right.start)
   return out.sort((left, right) => left.start - right.start)
 }
 }
-
-/** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */
-const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
-
-/**
- * Derive the mirror-layer decorations from the input state.
- * @param state - published input state.
- * @param lexicon - optional per-trigger reference lexicons (plain-text-reference scan).
- * @returns token range, chip instructions, text-ref ranges, and the ghost hint.
- */
-export function deriveDecorations(
-  state: InputState, lexicon: ReadonlyMap<'/' | '@', readonly string[]> = EMPTY_LEXICON,
-): DraftDecorations {
-  const { draft, claim, phase, occurrences } = state
-  const claimActive = (phase === 'claimed' || phase === 'submitting')
-    && claim !== undefined && draft.startsWith(claim.token)
-  const token: TokenRange | null = claimActive ? { start: 0, end: claim.token.length } : null
-  const chips = occurrences.map(o => ({
-    occurrenceId: o.occurrenceId,
-    offset: o.offset,
-    length: o.length,
-    text: draft.slice(o.offset, o.offset + o.length),
-    label: o.label,
-    ...o.appearance === undefined ? {} : { appearance: o.appearance },
-    invalid: o.invalid === true,
-  }))
-  const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === ''
-    ? claim.hint
-    : null
-  return { token, chips, textRefs: scanTextRefs(draft, lexicon), hint }
-}

+ 50 - 0
packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx

@@ -0,0 +1,50 @@
+/**
+ * The composer's contenteditable host: binds one shell-owned Lexical editor
+ * to a resident div. Session-maybe by design — a null editor renders the
+ * same DOM inert (the no-session Workspace-trigger state), so switching
+ * between the two never swaps the element tree. Editability has ONE writer:
+ * this component reflects the `editable` prop onto the editor; nothing else
+ * calls setEditable.
+ */
+import { useLayoutEffect, useRef } from 'react'
+import type { HTMLAttributes, ReactNode } from 'react'
+import type { LexicalEditor } from 'lexical'
+
+/** Host props: the editor binding plus the div passthroughs the bar owns. */
+export interface ComposerContentEditableProps extends HTMLAttributes<HTMLDivElement> {
+  /** The shell-owned editor; null renders the same div unbound and inert. */
+  readonly editor: LexicalEditor | null
+  /** Whether the user may edit (readOnly/disabled states fold in here). */
+  readonly editable: boolean
+}
+
+/**
+ * Render the composer's editable surface.
+ * @param props - editor binding, editability, and div passthroughs.
+ * @returns the resident contenteditable div.
+ */
+export function ComposerContentEditable({ editor, editable, ...rest }: ComposerContentEditableProps): ReactNode {
+  const ref = useRef<HTMLDivElement | null>(null)
+  useLayoutEffect(() => {
+    const el = ref.current
+    if (editor === null || el === null) return
+    editor.setRootElement(el)
+    return () => { editor.setRootElement(null) }
+  }, [editor])
+  useLayoutEffect(() => {
+    if (editor !== null) editor.setEditable(editable)
+  }, [editor, editable])
+  return (
+    <div
+      ref={ref}
+      // Lexical's setRootElement never touches contenteditable; the binding
+      // renders it, and setEditable above keeps the editor's own gate in step.
+      contentEditable={editor !== null && editable}
+      suppressContentEditableWarning
+      role="textbox"
+      aria-multiline="true"
+      data-composer-input
+      {...rest}
+    />
+  )
+}

+ 41 - 0
packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx

@@ -0,0 +1,41 @@
+/**
+ * Decorator render loop: portals every decorator node's React face into its
+ * host element (what @lexical/react's composer does internally, scoped to
+ * this composer's needs). Chip DOM identity rides the NodeKey — text edits
+ * around a chip never remount its portal.
+ */
+import * as React from 'react'
+import { createPortal } from 'react-dom'
+import type { ReactNode } from 'react'
+import type { LexicalEditor, NodeKey } from 'lexical'
+
+/** Portal-loop props. */
+export interface DecoratorPortalsProps {
+  /** The bound editor; null (no-session) renders nothing. */
+  readonly editor: LexicalEditor | null
+}
+
+/**
+ * Render every decorator's React face into its editor host element.
+ * @param props - the editor to observe.
+ * @returns the live portal set.
+ */
+export function DecoratorPortals({ editor }: DecoratorPortalsProps): ReactNode {
+  const [decorators, setDecorators] = React.useState<Record<NodeKey, React.JSX.Element>>(
+    () => editor === null ? {} : editor.getDecorators<React.JSX.Element>(),
+  )
+  React.useLayoutEffect(() => {
+    if (editor === null) return
+    setDecorators(editor.getDecorators<React.JSX.Element>())
+    return editor.registerDecoratorListener<React.JSX.Element>((next) => { setDecorators(next) })
+  }, [editor])
+  if (editor === null) return null
+  return (
+    <>
+      {Object.entries(decorators).map(([key, jsx]) => {
+        const el = editor.getElementByKey(key)
+        return el === null ? null : createPortal(jsx, el, key)
+      })}
+    </>
+  )
+}

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

@@ -119,7 +119,7 @@ export class ReferenceChipNode extends DecoratorNode<JSX.Element> {
   override createDOM(_config: EditorConfig): HTMLElement {
   override createDOM(_config: EditorConfig): HTMLElement {
     const el = document.createElement('span')
     const el = document.createElement('span')
     el.setAttribute('data-composer-chip', this.__source)
     el.setAttribute('data-composer-chip', this.__source)
-    el.contentEditable = 'false'
+    el.setAttribute('contenteditable', 'false')
     return el
     return el
   }
   }
 
 

+ 69 - 0
packages/client/ui-conversation/src/client/input/editor/claim-decor.ts

@@ -0,0 +1,69 @@
+/**
+ * Claim-token highlight: while a command claim holds, the draft's leading
+ * token renders in the warn color. A TextNode transform keeps the token in
+ * its own styled node (splitting when typing merges text into it), and the
+ * shell nudges the first leaf dirty when the claim flips so entering and
+ * leaving claimed restyles without a text edit.
+ */
+import type { LexicalEditor, TextNode as TextNodeType } from 'lexical'
+import { $getRoot, $isElementNode, $isTextNode, TextNode } from 'lexical'
+
+/** Inline style carried by the claim-token node (the old backdrop's hlToken color). */
+const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)'
+
+/** The document's first text leaf, or null (empty document / leading chip). */
+function firstTextLeaf(): TextNodeType | null {
+  const block = $getRoot().getFirstChild()
+  if (!$isElementNode(block)) return null
+  const leaf = block.getFirstChild()
+  return $isTextNode(leaf) ? leaf : null
+}
+
+/**
+ * Register the claim-token styling transform.
+ * @param editor - the shell-owned editor.
+ * @param activeToken - live claim token accessor; null while unclaimed.
+ * @returns the unregister disposer.
+ */
+export function registerClaimDecoration(editor: LexicalEditor, activeToken: () => string | null): () => void {
+  return editor.registerNodeTransform(TextNode, (node) => {
+    const first = firstTextLeaf()
+    if (first === null || node.getKey() !== first.getKey()) {
+      // Off the token seat: clear a stale token style (a node can move here
+      // by paragraph merges).
+      if (node.getStyle() === TOKEN_STYLE && (first === null || node.getKey() !== first.getKey())) {
+        node.setStyle('')
+      }
+      return
+    }
+    const token = activeToken()
+    const text = node.getTextContent()
+    if (token === null || !text.startsWith(token)) {
+      if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
+      return
+    }
+    if (text.length > token.length) {
+      // Typing at the token boundary lands in the styled node; split the
+      // overflow back out so only the token itself carries the color.
+      const [tokenNode] = node.splitText(token.length)
+      if (tokenNode !== undefined && tokenNode.getStyle() !== TOKEN_STYLE) tokenNode.setStyle(TOKEN_STYLE)
+      return
+    }
+    if (node.getStyle() !== TOKEN_STYLE) node.setStyle(TOKEN_STYLE)
+  })
+}
+
+/**
+ * Nudge the token seat dirty so the transform restyles after a claim flip
+ * (claims change phase without a text edit; transforms only run on dirty
+ * nodes).
+ * @param editor - the shell-owned editor.
+ */
+export function refreshClaimDecoration(editor: LexicalEditor): void {
+  // Not discrete: a refresh can fire from inside an update listener, where a
+  // synchronous nested commit would recurse; the queued update lands on the
+  // next flush.
+  editor.update(() => {
+    firstTextLeaf()?.markDirty()
+  })
+}

+ 11 - 0
packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css

@@ -0,0 +1,11 @@
+/* Editor-internal decoration styles: nodes Lexical mounts inside the
+   contenteditable (chip hosts get their look from ReferenceChip.module.css;
+   this sheet covers text-level decorations). */
+
+/* Plain-text reference: chip family colors over the draft's own glyphs.
+   clone keeps rounded ends on soft-wrap fragments. */
+.textRef {
+  color: var(--dsw-alias-state-business-primary);
+  box-decoration-break: clone;
+  -webkit-box-decoration-break: clone;
+}

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

@@ -0,0 +1,149 @@
+/**
+ * Composer keymap over the Lexical command layer: menu arbitration
+ * (arrows/escape/enter), space adjudication, the Enter submit gesture, and
+ * paste routing. Registered at CRITICAL priority so it decides before
+ * @lexical/plain-text's own Enter/paste defaults; a handler returning false
+ * falls through to those defaults (Shift+Enter's line break, ordinary
+ * spaces, text paste the bar routes itself).
+ *
+ * IME guard: a composition-closing Enter/Space must not submit or adjudicate.
+ * KeyboardEvent.isComposing covers most engines; Safari delivers the closing
+ * keydown AFTER compositionend, so a root-element composition watch holds the
+ * guard for 10ms more (the old textarea's proven window); keyCode
+ * 229 is the legacy signal engines emit without isComposing.
+ */
+import type { LexicalEditor } from 'lexical'
+import {
+  COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND,
+  KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, PASTE_COMMAND,
+} from 'lexical'
+import { mergeRegister } from '@lexical/utils'
+import type { ArbitrateKey, ArbitrateOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+
+/** The bar-supplied behavior behind each intercepted gesture. */
+export interface ComposerKeymapHandlers {
+  /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
+  arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
+  /** Space adjudication; true = a claim was applied — the keystroke is consumed. */
+  space(): boolean
+  /** Dismiss the popupSelect shell (Escape layering: an open overlay closes first). */
+  dismissPopup(): void
+  /** Whether Enter may submit right now (locked/busy states refuse). */
+  canSubmit(): boolean
+  /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */
+  submit(accelerated: boolean): void
+  /** Pasted files (image intake). */
+  intakeFiles(files: readonly File[]): void
+  /** Pasted plain text (sanitized insertion through the shell). */
+  pasteText(text: string): void
+}
+
+/** Composition state a keydown can trust (see the module doc's Safari note). */
+function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean): boolean {
+  // keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
+  return event.isComposing || event.keyCode === 229 || recentlyComposing()
+}
+
+/**
+ * Register the composer keymap on one editor.
+ * @param editor - the shell-owned editor.
+ * @param handlers - bar-supplied behavior.
+ * @returns the unregister disposer.
+ */
+export function registerComposerKeymap(editor: LexicalEditor, handlers: ComposerKeymapHandlers): () => void {
+  // Composition watch: true through composition and for one tick after
+  // compositionend (Safari's late closing keydown). The listener rides the
+  // root element and re-arms on root swaps.
+  let composing = false
+  let composingUntil = 0
+  const onCompositionStart = (): void => {
+    composing = true
+  }
+  const onCompositionEnd = (): void => {
+    composing = false
+    composingUntil = Date.now() + 10
+  }
+  const recentlyComposing = (): boolean => composing || Date.now() < composingUntil
+
+  const arrow = (key: ArbitrateKey) => (event: KeyboardEvent | null): boolean => {
+    const inComposition = event !== null && isComposingEvent(event, recentlyComposing)
+    if (handlers.arbitrate(key, inComposition) === 'consumed') {
+      event?.preventDefault()
+      return true
+    }
+    return false
+  }
+
+  return mergeRegister(
+    editor.registerRootListener((root, prevRoot) => {
+      prevRoot?.removeEventListener('compositionstart', onCompositionStart)
+      prevRoot?.removeEventListener('compositionend', onCompositionEnd)
+      root?.addEventListener('compositionstart', onCompositionStart)
+      root?.addEventListener('compositionend', onCompositionEnd)
+    }),
+    editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => {
+      // Escape layering: an open overlay closes; claimed without an overlay
+      // does NOT release (backspacing the token is the only exit gesture).
+      handlers.dismissPopup()
+      const inComposition = event !== null && isComposingEvent(event, recentlyComposing)
+      if (handlers.arbitrate('escape', inComposition) === 'consumed') {
+        event?.preventDefault()
+        return true
+      }
+      return false
+    }, COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_SPACE_COMMAND, (event) => {
+      if (isComposingEvent(event, recentlyComposing)) return false
+      const consumed = handlers.space()
+      console.log('[probe] space() ->', consumed)
+      if (consumed) {
+        event.preventDefault() // claim token already carries the trailing separator
+        return true
+      }
+      return false
+    }, COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
+      // Shift+Enter is the native line break UNCONDITIONALLY — decided before
+      // the IME guard so a composition-closing Shift+Enter still breaks the line.
+      if (event?.shiftKey === true) return false
+      if (event !== null && isComposingEvent(event, recentlyComposing)) {
+        // The IME consumes this Enter (candidate pick); neither submit nor
+        // break the line. No preventDefault: the browser owns the gesture.
+        return true
+      }
+      // Menu-open Enter picks the highlight through arbitration; a
+      // no-highlight menu passes down to the submit gesture.
+      if (handlers.arbitrate('enter', false) !== 'pass') {
+        event?.preventDefault()
+        return true
+      }
+      event?.preventDefault()
+      if (event?.repeat === true) return true // held-down Enter must not machine-gun sends
+      if (!handlers.canSubmit()) return true
+      handlers.submit(event?.ctrlKey === true || event?.metaKey === true)
+      return true
+    }, COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(PASTE_COMMAND, (event) => {
+      // Duck-typed: the payload union includes InputEvent, and test engines
+      // deliver clipboardData on plain events.
+      const clipboardData = (event as ClipboardEvent).clipboardData ?? null
+      if (clipboardData === null) return false
+      const files = Array.from(clipboardData.items)
+        .filter(item => item.kind === 'file')
+        .map(item => item.getAsFile())
+        .filter((file): file is File => file !== null)
+      if (files.length > 0) handlers.intakeFiles(files)
+      const text = clipboardData.getData('text/plain')
+      if (text === '') {
+        if (files.length === 0) return false
+        event.preventDefault()
+        return true
+      }
+      event.preventDefault()
+      handlers.pasteText(text)
+      return true
+    }, COMMAND_PRIORITY_CRITICAL),
+  )
+}

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

@@ -130,6 +130,26 @@ export function $composerLayout(): ComposerLayout {
   }
   }
 }
 }
 
 
+/**
+ * Fold one clipboard-projection offset to its detect-projection twin.
+ * Offsets inside a chip's clipboard expansion snap to the chip's trailing
+ * edge; callers only pass boundaries that were once a document end (submit
+ * snapshots), which never split a chip.
+ * @param layout - the current walk product.
+ * @param clipboardOffset - offset into the clipboard projection.
+ * @returns the detect offset covering the same document position.
+ */
+export function detectOffsetOfClipboardOffset(layout: ComposerLayout, clipboardOffset: number): number {
+  for (const segment of layout.segments) {
+    const end = segment.clipboardStart + segment.clipboardLength
+    if (clipboardOffset > end) continue
+    if (clipboardOffset === end) return segment.detectStart + segment.detectLength
+    if (segment.kind === 'chip') return segment.detectStart + segment.detectLength
+    return segment.detectStart + (clipboardOffset - segment.clipboardStart)
+  }
+  return layout.detectLength
+}
+
 /** The published projection product consumed by the shell every update. */
 /** The published projection product consumed by the shell every update. */
 export interface EditorProjection {
 export interface EditorProjection {
   /** Trigger/TokenSpan coordinate text (chip = one U+FFFC). */
   /** Trigger/TokenSpan coordinate text (chip = one U+FFFC). */
@@ -138,6 +158,8 @@ export interface EditorProjection {
   readonly clipboardText: string
   readonly clipboardText: string
   /** InputState-compatible occurrence view (clipboardText coordinates). */
   /** InputState-compatible occurrence view (clipboardText coordinates). */
   readonly occurrences: readonly Occurrence[]
   readonly occurrences: readonly Occurrence[]
+  /** Range selection in detect coordinates (ordered); null while absent or non-range. */
+  readonly selection: { readonly start: number; readonly end: number } | null
   /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */
   /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */
   readonly caret: number | null
   readonly caret: number | null
 }
 }
@@ -190,13 +212,19 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti
     })
     })
   }
   }
   const selection = $getSelection()
   const selection = $getSelection()
-  const caret = $isRangeSelection(selection) && selection.isCollapsed()
-    ? $detectOffsetOfPoint(layout, selection.anchor)
-    : null
+  let range: { start: number; end: number } | null = null
+  if ($isRangeSelection(selection)) {
+    const anchor = $detectOffsetOfPoint(layout, selection.anchor)
+    const focus = $detectOffsetOfPoint(layout, selection.focus)
+    if (anchor !== null && focus !== null) {
+      range = { start: Math.min(anchor, focus), end: Math.max(anchor, focus) }
+    }
+  }
   return {
   return {
     detectText: layout.detectText,
     detectText: layout.detectText,
     clipboardText: layout.clipboardText,
     clipboardText: layout.clipboardText,
     occurrences,
     occurrences,
-    caret,
+    selection: range,
+    caret: range !== null && range.start === range.end ? range.start : null,
   }
   }
 }
 }

+ 10 - 0
packages/client/ui-conversation/src/client/input/editor/span-map.ts

@@ -79,6 +79,16 @@ function selectSpan(layout: ComposerLayout, span: DetectSpan): RangeSelection |
   return selection
   return selection
 }
 }
 
 
+/**
+ * Select one detect span (collapsed spans place the caret). Exposed for the
+ * shell's caret placement and tests; the replace helpers below build on it.
+ * @param span - detect span to select.
+ * @returns whether both endpoints mapped.
+ */
+export function $selectDetectSpan(span: DetectSpan): boolean {
+  return selectSpan($composerLayout(), span) !== null
+}
+
 /**
 /**
  * Replace one detect span with plain text (empty text deletes the span).
  * Replace one detect span with plain text (empty text deletes the span).
  * The caret lands after the insertion.
  * The caret lands after the insertion.

+ 138 - 0
packages/client/ui-conversation/src/client/input/editor/text-ref.ts

@@ -0,0 +1,138 @@
+/**
+ * Plain-text reference decoration (the plain-text-reference decision;
+ * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
+ * a `/name` or `@name` token whose name is on the trigger's lexicon, and
+ * syntax-recognizable `@dir/` folder tokens, render in the chip family
+ * colors. Pure derivation as before — the entity transform converts matching
+ * text into TextRefNode and back as edits move it in and out of match shape;
+ * no occurrence identity exists.
+ */
+import type { EditorConfig, LexicalEditor, NodeKey, SerializedTextNode, Spread } from 'lexical'
+import { TextNode } from 'lexical'
+import { registerLexicalTextEntity } from '@lexical/text'
+import { mergeRegister } from '@lexical/utils'
+import { $getRoot } from 'lexical'
+import { scanTextRefs } from '../decorations.ts'
+import css from './composer-editor.module.css'
+
+/** JSON form of one text-ref node. */
+export type SerializedTextRefNode = Spread<{
+  appearance?: 'folder'
+}, SerializedTextNode>
+
+/** One matched plain-text reference as a styled, fully editable text node. */
+export class TextRefNode extends TextNode {
+  /** Optional icon domain for syntax-recognizable plain references. */
+  __appearance: 'folder' | undefined
+
+  /** Lexical node registry type tag. */
+  static override getType(): string {
+    return 'composer-text-ref'
+  }
+
+  /**
+   * Clone with identity (Lexical writable-copy contract).
+   * @param node - node to clone.
+   * @returns a copy carrying the same NodeKey.
+   */
+  static override clone(node: TextRefNode): TextRefNode {
+    return new TextRefNode(node.__text, node.__appearance, node.__key)
+  }
+
+  /**
+   * Rebuild one text-ref from its JSON form.
+   * @param json - serialized node.
+   * @returns a fresh node.
+   */
+  static override importJSON(json: SerializedTextRefNode): TextRefNode {
+    const node = new TextRefNode(json.text, json.appearance)
+    node.setFormat(json.format)
+    node.setDetail(json.detail)
+    node.setMode(json.mode)
+    node.setStyle(json.style)
+    return node
+  }
+
+  /**
+   * @param text - the matched token text.
+   * @param appearance - optional icon domain (folder tokens).
+   * @param key - Lexical clone-path key; absent for fresh nodes.
+   */
+  constructor(text: string, appearance?: 'folder', key?: NodeKey) {
+    super(text, key)
+    this.__appearance = appearance
+  }
+
+  /** Serialize to the JSON node form. */
+  override exportJSON(): SerializedTextRefNode {
+    return {
+      ...super.exportJSON(),
+      type: 'composer-text-ref',
+      ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }),
+    }
+  }
+
+  /** Style the span the base TextNode mounts. */
+  override createDOM(config: EditorConfig): HTMLElement {
+    const el = super.createDOM(config)
+    el.classList.add(css.textRef ?? 'textRef')
+    el.setAttribute('data-composer-text-ref', '')
+    if (this.__appearance !== undefined) el.setAttribute('data-ref-appearance', this.__appearance)
+    return el
+  }
+
+  /** Entity nodes never merge with plain siblings (the transform owns their bounds). */
+  override isTextEntity(): true {
+    return true
+  }
+
+  /** Editing continues inside; the transform re-evaluates match shape per edit. */
+  override canInsertTextBefore(): boolean {
+    return true
+  }
+}
+
+/**
+ * Folder-shape probe for one matched token (the appearance bit).
+ * @param token - matched token text.
+ * @returns 'folder' for `@dir/` shapes; undefined otherwise.
+ */
+function appearanceOf(token: string): 'folder' | undefined {
+  return token.startsWith('@') && token.endsWith('/') ? 'folder' : undefined
+}
+
+/**
+ * Register the plain-text reference entity transform.
+ * @param editor - the shell-owned editor.
+ * @param lexiconOf - live per-trigger name-roll accessor (the controller's aggregated store).
+ * @returns the unregister disposer.
+ */
+export function registerTextRefDecoration(
+  editor: LexicalEditor,
+  lexiconOf: () => ReadonlyMap<'/' | '@', readonly string[]>,
+): () => void {
+  const getMatch = (text: string): { start: number; end: number } | null => {
+    const first = scanTextRefs(text, lexiconOf())[0]
+    return first === undefined ? null : { start: first.start, end: first.end }
+  }
+  return mergeRegister(
+    ...registerLexicalTextEntity(
+      editor,
+      getMatch,
+      TextRefNode,
+      node => new TextRefNode(node.getTextContent(), appearanceOf(node.getTextContent())),
+    ),
+  )
+}
+
+/**
+ * Force a re-scan of the whole document (transforms only visit dirty nodes;
+ * a lexicon roll change dirties nothing on its own). Queued, not discrete —
+ * the caller may sit inside an update listener.
+ * @param editor - the shell-owned editor.
+ */
+export function rescanTextRefs(editor: LexicalEditor): void {
+  editor.update(() => {
+    for (const node of $getRoot().getAllTextNodes()) node.markDirty()
+  })
+}

+ 318 - 138
packages/client/ui-conversation/src/client/input/facade.ts

@@ -1,23 +1,39 @@
 /**
 /**
- * SessionInput shell over the pure input machine: the sole machine caller
- * and effect executor. Owns the InputState store (machine state + the queue
- * overlay), the notice channel, and the submit transaction plumbing
+ * SessionInput shell: owns the per-session Lexical editor (text + chip
+ * truth) and the pure SubmitMachine (phase/claim/attempt), and choreographs
+ * everything between them — projections and InputState publication, the
+ * scoped-event application verbs, the submit transaction plumbing
  * (adjudicate via the session's InputTriggerController; claim.submit; default
  * (adjudicate via the session's InputTriggerController; claim.submit; default
- * sink). Package-private; the hub alone constructs it and wires the scoped
- * event listeners onto it.
+ * sink), the notice channel, and the draft persistence mirror.
+ * Package-private; the hub alone constructs it and wires the scoped event
+ * listeners onto it.
  */
  */
 import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { LexicalEditor, NodeKey } from 'lexical'
+import {
+  $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
+  CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG,
+} from 'lexical'
+import { registerPlainText } from '@lexical/plain-text'
+import { createEmptyHistoryState, registerHistory } from '@lexical/history'
+import { mergeRegister } from '@lexical/utils'
 import type {
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
   ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan,
   ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan,
 } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type {
 import type {
-  DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
-  PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
+  DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState,
+  QueuedMessage, SessionInput, SubmitAttempt,
 } from './contract.ts'
 } from './contract.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
-import { InputMachine, projectClipboard } from './machine.ts'
+import { SubmitMachine } from './machine.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 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 {
@@ -76,15 +92,29 @@ 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
+
 /**
 /**
  * The per-session input facade: scoped-event application verbs +
  * The per-session input facade: scoped-event application verbs +
- * setDraft/submit + the published InputState store.
+ * setDraft/submit + the published InputState store, over a shell-owned
+ * Lexical editor.
  */
  */
 export class SessionInputShell implements SessionInput {
 export class SessionInputShell implements SessionInput {
-  /** Published machine state + queue overlay (the InputZone currency source). */
+  /** Published editor projection + submit-plane state + queue overlay (the InputZone currency source). */
   readonly state: SnapshotStore<InputState>
   readonly state: SnapshotStore<InputState>
   /** 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. */
+  readonly editor: LexicalEditor
   /** 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) },
@@ -94,33 +124,119 @@ export class SessionInputShell implements SessionInput {
     submit: () => { this.submit('queue') },
     submit: () => { this.submit('queue') },
   }
   }
 
 
-  // Real wall clock: the typing-run merge window must actually expire in
-  // production (the machine's no-clock default is a constant for pure tests).
-  private readonly core = new InputMachine({ now: () => Date.now() })
+  private readonly core = new SubmitMachine()
+  private projection: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null }
+  private rev = 0
+  /** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
+  private readonly occurrenceIds = new Map<NodeKey, number>()
+  private occurrenceSeq = 0
+  private readonly unregister: () => void
   private noticeSeq = 0
   private noticeSeq = 0
   private lastMirroredDraft = ''
   private lastMirroredDraft = ''
   private imageIds: readonly DraftAttachmentId[] = []
   private imageIds: readonly DraftAttachmentId[] = []
   /** One image-only send at a time: Enter during the Host round-trip is a no-op. */
   /** One image-only send at a time: Enter during the Host round-trip is a no-op. */
   private imageSendInFlight = false
   private imageSendInFlight = false
   private disposed = false
   private disposed = false
-  /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */
+  /** Draft persistence mirror (chat store write; receives the clipboard projection). */
   private mirrorFn: ((text: string) => void) | undefined
   private mirrorFn: ((text: string) => void) | undefined
+  /** Live lexicon subscription disposer; undefined until the controller resolves. */
+  private lexiconOff: (() => void) | undefined
 
 
   constructor(private readonly deps: SessionInputDeps) {
   constructor(private readonly deps: SessionInputDeps) {
+    this.editor = createEditor({
+      namespace: 'dsh-composer',
+      nodes: [ReferenceChipNode, TextRefNode],
+      onError: (error) => { throw error },
+    })
+    this.unregister = mergeRegister(
+      registerPlainText(this.editor),
+      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.lexiconOff?.() },
+    )
     this.state = createSnapshotStore<InputState>(this.compose())
     this.state = createSnapshotStore<InputState>(this.compose())
     deps.queue?.subscribe(() => { this.publish() })
     deps.queue?.subscribe(() => { this.publish() })
   }
   }
 
 
+  // ---- editor plumbing ----
+
+  /**
+   * Run one editor edit whose result is observable on return. At the top
+   * level this is a discrete update. Inside this editor's own update —
+   * command handlers land here synchronously (space/enter picks, paste) —
+   * $-functions are already legal, and wrapping them in update() would DEFER
+   * them past the synchronous bail answer (and a nested discrete throws);
+   * the body runs directly and the outer update commits it.
+   * @param fn - the $-edit body.
+   */
+  private applyEdit(fn: () => void): void {
+    if (this.editor._updating) {
+      fn()
+      return
+    }
+    this.editor.update(fn, { discrete: true })
+  }
+
+
+  /**
+   * Subscribe the text-ref re-scan to the controller's lexicon once the
+   * controller resolves. The deps thunk cannot resolve at construction (the
+   * shell is created inside the sessions provide materialization), so the
+   * first interactive updates retry until it can.
+   */
+  private ensureLexiconSubscription(): void {
+    if (this.lexiconOff !== undefined) return
+    const controller = this.deps.inputTriggers?.()
+    if (controller === undefined) return
+    this.lexiconOff = controller.lexicon.subscribe(() => { rescanTextRefs(this.editor) })
+  }
+
+  /** Re-project, run the claim watch, publish, and feed trigger tracking after every editor commit. */
+  private onEditorUpdate(): void {
+    this.ensureLexiconSubscription()
+    this.rev += 1
+    this.projection = this.editor.getEditorState().read(() =>
+      $projectComposer(key => this.occurrenceIdOf(key)))
+    this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText }))
+    const caret = this.projection.caret
+    if (caret !== null) {
+      this.deps.inputTriggers?.()?.track(
+        this.projection.detectText, caret, { tier: guardOf(this.core.state.phase) }, this.rev,
+      )
+    }
+  }
+
+  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 ----
 
 
   /**
   /**
-   * Single draft write path (all mutation rides machine events).
+   * 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.
    * @param text - the full next draft.
-   * @param editRange - the DOM-observed edit shape, when the caller knows it
-   * (narrows the machine's occurrence math; absent → diff scan).
    */
    */
-  setDraft(text: string, editRange?: EditRange): void {
-    this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
+  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 })
   }
   }
 
 
   /** Append ordered image ids unless an admission transaction is locked. */
   /** Append ordered image ids unless an admission transaction is locked. */
@@ -158,46 +274,38 @@ export class SessionInputShell implements SessionInput {
   }
   }
 
 
   /**
   /**
-   * Clear the draft as a successful-send commit: no undo unit is recorded and
-   * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content
-   * (the command path gets the same discipline from submit-settled success).
+   * Clear the draft as a successful-send commit: the editor empties (no undo
+   * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent
+   * content (the command path gets the same discipline from submit-settled).
    * @param imageIds - admitted image ids to remove from this draft.
    * @param imageIds - admitted image ids to remove from this draft.
    */
    */
   commitSend(imageIds: readonly DraftAttachmentId[]): void {
   commitSend(imageIds: readonly DraftAttachmentId[]): void {
     const submitted = new Set(imageIds)
     const submitted = new Set(imageIds)
     this.imageIds = this.imageIds.filter(id => !submitted.has(id))
     this.imageIds = this.imageIds.filter(id => !submitted.has(id))
-    this.run(this.core.dispatch({ type: 'send-committed' }))
-  }
-
-  /** Undo the latest transaction (InputBar intercepts the platform chord). */
-  undo(): void {
-    this.run(this.core.dispatch({ type: 'undo' }))
-  }
-
-  /** Redo the latest undone transaction. */
-  redo(): void {
-    this.run(this.core.dispatch({ type: 'redo' }))
+    this.dispatchRun(({ type: 'send-committed' }))
   }
   }
 
 
   /**
   /**
-   * Paste text over the selection in one transaction, with any hot-snapshot
-   * sync matches componentized inside it.
+   * Insert pasted plain text over the current editor selection
+   * (placeholder-sanitized). The paste event's own default is suppressed by
+   * the caller; history groups the paste as one undoable step.
    * @param text - pasted plain text.
    * @param text - pasted plain text.
-   * @param selection - replaced selection in draft coordinates.
-   * @param components - sync-matched reference components (disjoint, inside `text`).
-   * @param generation - projection generation for late async-upgrade guards.
    */
    */
-  pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void {
-    this.run(this.core.dispatch({
-      type: 'paste-begin', text, selection,
-      ...(components !== undefined ? { components } : {}),
-      ...(generation !== undefined ? { generation } : {}),
-    }))
-  }
-
-  /** End the live paste-match attempt (caret/selection ops and Slash updates the machine cannot see). */
-  invalidatePaste(): void {
-    this.run(this.core.dispatch({ type: 'invalidate-paste' }))
+  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)
+    })
   }
   }
 
 
   /**
   /**
@@ -232,24 +340,14 @@ export class SessionInputShell implements SessionInput {
       this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft))
       this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft))
       return
       return
     }
     }
-    this.run(this.core.dispatch({ type: 'enter', mode }))
+    this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText }))
     const phase = this.snapshot.phase
     const phase = this.snapshot.phase
     if (phase === 'adjudicating' || phase === 'submitting') {
     if (phase === 'adjudicating' || phase === 'submitting') {
       this.deps.popup?.()?.dismiss()
       this.deps.popup?.()?.dismiss()
-      this.deps.inputTriggers?.()?.track(this.snapshot.draft, 0, { tier: 'frozen' }, this.snapshot.draftRev)
+      this.deps.inputTriggers?.()?.track(this.projection.detectText, 0, { tier: 'frozen' }, this.rev)
     }
     }
   }
   }
 
 
-  /**
-   * Feed a draft/caret change through trigger detection (guard derived from
-   * the machine phase).
-   * @param draft - live draft text.
-   * @param caret - caret position in draft coordinates.
-   */
-  track(draft: string, caret: number): void {
-    this.deps.inputTriggers?.()?.track(draft, caret, { tier: guardOf(this.snapshot.phase) }, this.snapshot.draftRev)
-  }
-
   /**
   /**
    * Keyboard arbitration while the menu is open.
    * Keyboard arbitration while the menu is open.
    * @param key - the intercepted key.
    * @param key - the intercepted key.
@@ -277,15 +375,9 @@ export class SessionInputShell implements SessionInput {
   space(): boolean {
   space(): boolean {
     const inputTriggers = this.deps.inputTriggers?.()
     const inputTriggers = this.deps.inputTriggers?.()
     if (inputTriggers === undefined) return false
     if (inputTriggers === undefined) return false
-    const consumed = inputTriggers.onSpace()
-    // Machine-driven draft replacement never passes through onChange, so
-    // re-track: the caret lands after the token, where detection sees
-    // whitespace and closes the menu.
-    if (consumed) {
-      const next = this.snapshot
-      inputTriggers.track(next.draft, next.draft.length, { tier: guardOf(next.phase) }, next.draftRev)
-    }
-    return consumed
+    return inputTriggers.onSpace()
+    // No re-track here: applying the claim/insert mutates the editor, and the
+    // update listener re-tracks at the settled caret on its own.
   }
   }
 
 
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   /** Dismiss the popupSelect shell (any interaction outside the box). */
@@ -294,9 +386,18 @@ export class SessionInputShell implements SessionInput {
   }
   }
 
 
   /**
   /**
-   * Hot plain-text reference lexicon source for the decoration scan
-   * (the plain-text-reference decision;
-   * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
+   * 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(): { start: number; end: number } {
+    if (this.projection.selection !== null) return this.projection.selection
+    const at = this.projection.detectText.length
+    return { start: at, end: at }
+  }
+
+  /**
+   * Hot plain-text reference lexicon source for the decoration scan:
    * delegates to the controller's aggregated store. Stable
    * delegates to the controller's aggregated store. Stable
    * identity per shell; without a pipeline the snapshot is the empty Map and
    * identity per shell; without a pipeline the snapshot is the empty Map and
    * subscribers never fire.
    * subscribers never fire.
@@ -306,28 +407,53 @@ export class SessionInputShell implements SessionInput {
     subscribe: fn => this.deps.inputTriggers?.()?.lexicon.subscribe(fn) ?? (() => {}),
     subscribe: fn => this.deps.inputTriggers?.()?.lexicon.subscribe(fn) ?? (() => {}),
   }
   }
 
 
+  // ---- scoped-event application verbs ----
+
   /**
   /**
-   * Apply one command claim (scoped begin-command event listener body).
+   * Apply one command claim (scoped begin-command event listener body): the
+   * editor replaces [0, span.end) with the claim token, then the machine
+   * enters claimed.
    * @param claim - the command claim from the pick path.
    * @param claim - the command claim from the pick path.
-   * @param span - pick-time span snapshot.
-   * @returns whether the machine accepted (phase + span CAS passed and the draft mutated).
+   * @param span - pick-time span snapshot (detect coordinates).
+   * @returns whether the edit applied (phase, span CAS, and leading guard passed).
    */
    */
   beginCommand(claim: CommandClaim, span: TokenSpan): boolean {
   beginCommand(claim: CommandClaim, span: TokenSpan): boolean {
-    const before = this.core.state.draftRev
-    this.run(this.core.dispatch({ type: 'begin-command', claim, span }))
-    return this.core.state.phase === 'claimed' && this.core.state.draftRev !== before
+    const phase = this.core.state.phase
+    if (phase !== 'plain' && phase !== 'claimed') return false
+    if (span.draftRev !== this.rev) return false
+    // Leading-trigger contract: only whitespace may precede the span; the
+    // whitespace prefix is dropped so the claimed watch (startsWith) holds.
+    if (this.projection.detectText.slice(0, span.start).trim() !== '') return false
+    let applied = false
+    this.applyEdit(() => {
+      applied = $replaceDetectSpanWithText({ start: 0, end: span.end }, claim.token)
+    })
+    if (!applied) return false
+    this.dispatchRun(({ type: 'claim', claim }))
+    return true
   }
   }
 
 
   /**
   /**
-   * Apply one reference insertion (scoped insert-reference event listener body).
+   * Apply one reference insertion (scoped insert-reference event listener
+   * body): the editor replaces the span with one chip node, followed by a
+   * separating space unless one is already next.
    * @param ref - the reference insertion from the pick path.
    * @param ref - the reference insertion from the pick path.
-   * @param span - pick-time span snapshot.
-   * @returns whether the machine accepted.
+   * @param span - pick-time span snapshot (detect coordinates).
+   * @returns whether the edit applied.
    */
    */
   insertReference(ref: ReferenceInsert, span: TokenSpan): boolean {
   insertReference(ref: ReferenceInsert, span: TokenSpan): boolean {
-    const before = this.core.state.draftRev
-    this.run(this.core.dispatch({ type: 'insert-ref', reference: ref, span }))
-    return this.core.state.draftRev !== before
+    const phase = this.core.state.phase
+    if (phase !== 'plain' && phase !== 'claimed') return false
+    if (span.draftRev !== this.rev) return false
+    const tail = this.projection.detectText.slice(span.end, span.end + 1)
+    let applied = false
+    this.applyEdit(() => {
+      const nodes = tail === ' '
+        ? [$createReferenceChipNode(ref)]
+        : [$createReferenceChipNode(ref), $createTextNode(' ')]
+      applied = $replaceDetectSpanWithNodes(span, nodes)
+    })
+    return applied
   }
   }
 
 
   /**
   /**
@@ -338,43 +464,40 @@ export class SessionInputShell implements SessionInput {
    * @returns whether the token was consumed.
    * @returns whether the token was consumed.
    */
    */
   consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
   consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
-    const snapshot = this.core.state
     if (guard.kind === 'span') {
     if (guard.kind === 'span') {
-      if (guard.span.draftRev !== snapshot.draftRev) return false
-      const draft = snapshot.draft
-      this.setDraft(draft.slice(0, guard.span.start) + draft.slice(guard.span.end))
-      return true
+      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
     }
     }
-    if (snapshot.draft.trim() !== guard.token) return false
+    if (guard.token === '' || this.projection.clipboardText.trim() !== guard.token) return false
     this.setDraft('')
     this.setDraft('')
     return true
     return true
   }
   }
 
 
   /**
   /**
    * Insert plain reference text over the pick-time span (scoped insert-text
    * Insert plain reference text over the pick-time span (scoped insert-text
-   * event listener body; plain-text-reference decision, web-input-machine
-   * note). Same CAS-then-splice shape as the
-   * consume-token span branch: the machine sees an ordinary draft-changed
-   * transaction (one undo step), no occurrence is minted — the chip look is
-   * a scan-derived decoration, never state.
+   * event listener body; the plain-text reference path). The editor gains
+   * ordinary characters — no chip node; the chip look is a scan-derived
+   * decoration, never state.
    * @param text - the plain reference text to splice in (e.g. `/name `).
    * @param text - the plain reference text to splice in (e.g. `/name `).
-   * @param span - pick-time span snapshot (draftRev CAS).
-   * @param keepCompleting - re-track at the caret after the splice so an open
-   * token (a directory pick's trailing slash) reopens the menu.
+   * @param span - pick-time span snapshot (detect coordinates).
+   * @param keepCompleting - contract passenger; completion re-opening is
+   * automatic here (the update listener re-tracks at the settled caret, so an
+   * open token — a directory pick's trailing slash — reopens the menu without
+   * an explicit re-track).
    * @returns whether the text was applied.
    * @returns whether the text was applied.
    */
    */
   insertText(text: string, span: TokenSpan, keepCompleting = false): boolean {
   insertText(text: string, span: TokenSpan, keepCompleting = false): boolean {
-    const snapshot = this.core.state
-    if (span.draftRev !== snapshot.draftRev) return false
-    const draft = snapshot.draft
-    this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end))
-    if (keepCompleting) {
-      // Machine-driven draft replacement never passes through onChange, so
-      // re-track at the caret inside the still-open token (see space()).
-      const next = this.snapshot
-      this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev)
-    }
-    return true
+    void keepCompleting
+    if (span.draftRev !== this.rev) return false
+    let applied = false
+    this.applyEdit(() => {
+      applied = $replaceDetectSpanWithText(span, text)
+    })
+    return applied
   }
   }
 
 
   /**
   /**
@@ -389,13 +512,15 @@ export class SessionInputShell implements SessionInput {
 
 
   // ---- wiring-layer extras (not on the frozen SessionInput face) ----
   // ---- wiring-layer extras (not on the frozen SessionInput face) ----
 
 
-  /** Teardown: abort any in-flight attempt and stop accepting async settlements. */
+  /** Teardown: abort any in-flight attempt, unbind the editor, and stop accepting async settlements. */
   dispose(): void {
   dispose(): void {
     this.disposed = true
     this.disposed = true
-    this.run(this.core.dispatch({ type: 'release' }))
+    this.dispatchRun(({ type: 'release' }))
+    this.unregister()
+    this.editor.setRootElement(null)
   }
   }
 
 
-  /** Read the live machine state (guard derivation reads here). */
+  /** Read the live input state (guard derivation reads here). */
   get snapshot(): InputState {
   get snapshot(): InputState {
     return this.state.getSnapshot()
     return this.state.getSnapshot()
   }
   }
@@ -403,7 +528,7 @@ export class SessionInputShell implements SessionInput {
   /**
   /**
    * Bind the draft persistence mirror (chat store write). Adopt-on-bind: the
    * Bind the draft persistence mirror (chat store write). Adopt-on-bind: the
    * store draft may hold a persisted value from a previous mount; the caller
    * store draft may hold a persisted value from a previous mount; the caller
-   * seeds it via setDraft BEFORE binding, and afterwards every machine-adopted
+   * seeds it via setDraft BEFORE binding, and afterwards every editor-adopted
    * draft mirrors out.
    * draft mirrors out.
    * @param write - store draft write.
    * @param write - store draft write.
    * @returns the unbind disposer.
    * @returns the unbind disposer.
@@ -417,6 +542,21 @@ export class SessionInputShell implements SessionInput {
 
 
   // ---- effect executor ----
   // ---- effect executor ----
 
 
+  /** The claim token the decoration transform styles; null while unclaimed. */
+  private activeClaimToken(): string | null {
+    const core = this.core.state
+    return (core.phase === 'claimed' || core.phase === 'submitting') && core.claim !== undefined
+      ? core.claim.token
+      : null
+  }
+
+  /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
+  private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
+    const beforeToken = this.activeClaimToken()
+    this.run(this.core.dispatch(ev))
+    if (this.activeClaimToken() !== beforeToken) refreshClaimDecoration(this.editor)
+  }
+
   private run(effects: readonly InputEffect[]): void {
   private run(effects: readonly InputEffect[]): void {
     for (const fx of effects) this.execute(fx)
     for (const fx of effects) this.execute(fx)
     this.publish()
     this.publish()
@@ -441,21 +581,45 @@ export class SessionInputShell implements SessionInput {
         this.sinkSerialized(fx.attempt, fx.draft, fx.mode)
         this.sinkSerialized(fx.attempt, fx.draft, fx.mode)
         return
         return
       }
       }
-      default:
-        return // machine-internal effects (mirror rides publish)
+      case 'commit-draft': {
+        this.commitDraft(fx.retainSuffixOf)
+        return
+      }
     }
     }
   }
   }
 
 
   /**
   /**
-   * Prompt serialization before the sink: expand each
-   * inline reference range to its owner's model form via the session controller's
-   * codec routing. Owner missing / serialize failure / disposal blocks the
-   * send — notice + draft and chips retained, never a silent downgrade to
-   * the clipboard text. Chip-free drafts skip the async detour.
+   * Execute the commit-draft effect: clear the committed content (retaining
+   * a pure typed-during-flight suffix when the snapshot allows) and cut the
+   * undo history so sent content cannot resurrect.
+   */
+  private commitDraft(retainSuffixOf: string | null): void {
+    this.editor.update(() => {
+      const layout = $composerLayout()
+      const clip = layout.clipboardText
+      if (retainSuffixOf !== null && clip !== retainSuffixOf && clip.startsWith(retainSuffixOf)) {
+        $replaceDetectSpanWithText(
+          { start: 0, end: detectOffsetOfClipboardOffset(layout, retainSuffixOf.length) }, '',
+        )
+        return
+      }
+      const root = $getRoot()
+      root.clear()
+      root.selectEnd()
+    }, { discrete: true, tag: HISTORY_MERGE_TAG })
+    this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
+  }
+
+  /**
+   * Prompt serialization before the sink: expand each chip occurrence to its
+   * owner's model form via the session controller's codec routing. Owner
+   * missing / serialize failure / disposal blocks the send — notice + draft
+   * and chips retained, never a silent downgrade to the clipboard text.
+   * Chip-free drafts skip the async detour.
    */
    */
   private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void {
   private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void {
     const imageIds = [...this.imageIds]
     const imageIds = [...this.imageIds]
-    const occurrences = this.core.state.occurrences
+    const occurrences = this.projection.occurrences
     if (occurrences.length === 0) {
     if (occurrences.length === 0) {
       this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds)
       this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds)
       return
       return
@@ -472,8 +636,9 @@ export class SessionInputShell implements SessionInput {
     })).then(
     })).then(
       (parts) => {
       (parts) => {
         if (this.disposed) return
         if (this.disposed) return
-        // Splice model forms over their display ranges (offsets are draft-time;
-        // parts arrive offset-sorted since the table is).
+        // Splice model forms over their clipboard ranges (offsets are
+        // clipboard-projection; parts arrive offset-sorted since chips walk in
+        // document order).
         let out = ''
         let out = ''
         let cursor = 0
         let cursor = 0
         for (const part of parts) {
         for (const part of parts) {
@@ -487,7 +652,9 @@ export class SessionInputShell implements SessionInput {
         controller.abort()
         controller.abort()
         if (this.dead(attempt)) return
         if (this.dead(attempt)) return
         const message = error instanceof Error ? error.message : String(error)
         const message = error instanceof Error ? error.message : String(error)
-        this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message }))
+        this.dispatchRun(({
+          type: 'submit-settled', attempt, ok: false, draft: this.projection.clipboardText, message,
+        }))
       },
       },
     )
     )
   }
   }
@@ -505,19 +672,21 @@ export class SessionInputShell implements SessionInput {
           const submitted = new Set(imageIds)
           const submitted = new Set(imageIds)
           this.imageIds = this.imageIds.filter(id => !submitted.has(id))
           this.imageIds = this.imageIds.filter(id => !submitted.has(id))
         }
         }
-        this.run(this.core.dispatch({
+        this.dispatchRun(({
           type: 'submit-settled',
           type: 'submit-settled',
           attempt,
           attempt,
           ok: outcome.kind === 'success',
           ok: outcome.kind === 'success',
+          draft: this.projection.clipboardText,
           outcome,
           outcome,
         }))
         }))
       },
       },
       (error: unknown) => {
       (error: unknown) => {
         if (this.dead(attempt)) return
         if (this.dead(attempt)) return
-        this.run(this.core.dispatch({
+        this.dispatchRun(({
           type: 'submit-settled',
           type: 'submit-settled',
           attempt,
           attempt,
           ok: false,
           ok: false,
+          draft: this.projection.clipboardText,
           message: error instanceof Error ? error.message : String(error),
           message: error instanceof Error ? error.message : String(error),
         }))
         }))
       },
       },
@@ -529,18 +698,18 @@ export class SessionInputShell implements SessionInput {
     const inputTriggers = this.deps.inputTriggers?.()
     const inputTriggers = this.deps.inputTriggers?.()
     if (inputTriggers === undefined) {
     if (inputTriggers === undefined) {
       // No pipeline mounted: the '/' line is an ordinary message.
       // No pipeline mounted: the '/' line is an ordinary message.
-      this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
+      this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined }))
       return
       return
     }
     }
     inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then(
     inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then(
       (outcome: PickOutcome) => {
       (outcome: PickOutcome) => {
         if (this.dead(attempt)) return
         if (this.dead(attempt)) return
-        this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome }))
+        this.dispatchRun(({ type: 'adjudicated', attempt, outcome }))
       },
       },
       (error: unknown) => {
       (error: unknown) => {
         if (this.dead(attempt)) return
         if (this.dead(attempt)) return
         const message = error instanceof Error ? error.message : String(error)
         const message = error instanceof Error ? error.message : String(error)
-        this.run(this.core.dispatch({ type: 'adjudication-failed', attempt, message }))
+        this.dispatchRun(({ type: 'adjudication-failed', attempt, message }))
       },
       },
     )
     )
   }
   }
@@ -570,15 +739,19 @@ export class SessionInputShell implements SessionInput {
             this.imageIds = this.imageIds.filter(id => !submitted.has(id))
             this.imageIds = this.imageIds.filter(id => !submitted.has(id))
             this.deps.commandImages.release(imageIds)
             this.deps.commandImages.release(imageIds)
           }
           }
-          this.run(this.core.dispatch({
-            type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome,
+          this.dispatchRun(({
+            type: 'submit-settled', attempt, ok: outcome.kind === 'success',
+            draft: this.projection.clipboardText, outcome,
             ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}),
             ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}),
           }))
           }))
         },
         },
         (error: unknown) => {
         (error: unknown) => {
           if (this.dead(attempt)) return
           if (this.dead(attempt)) return
           const message = error instanceof Error ? error.message : String(error)
           const message = error instanceof Error ? error.message : String(error)
-          this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message }))
+          this.dispatchRun(({
+            type: 'submit-settled', attempt, ok: false,
+            draft: this.projection.clipboardText, message,
+          }))
         },
         },
       )
       )
   }
   }
@@ -590,16 +763,23 @@ export class SessionInputShell implements SessionInput {
 
 
   private compose(): InputState {
   private compose(): InputState {
     const core = this.core.state
     const core = this.core.state
-    return { ...core, imageIds: this.imageIds, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
+    return {
+      draft: this.projection.clipboardText,
+      imageIds: this.imageIds,
+      draftRev: this.rev,
+      phase: core.phase,
+      ...(core.claim !== undefined ? { claim: core.claim } : {}),
+      occurrences: this.projection.occurrences,
+      queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE,
+    }
   }
   }
 
 
   private publish(): void {
   private publish(): void {
     const next = this.compose()
     const next = this.compose()
     this.state.set(next)
     this.state.set(next)
-    const mirroredDraft = projectClipboard(next)
-    if (mirroredDraft !== this.lastMirroredDraft) {
-      this.lastMirroredDraft = mirroredDraft
-      this.mirrorFn?.(mirroredDraft)
+    if (next.draft !== this.lastMirroredDraft) {
+      this.lastMirroredDraft = next.draft
+      this.mirrorFn?.(next.draft)
     }
     }
   }
   }
 }
 }

+ 48 - 426
packages/client/ui-conversation/src/client/input/machine.ts

@@ -1,47 +1,19 @@
 /**
 /**
- * InputMachine: the pure per-session input state machine.
- * Events in, effects out; zero React / DOM / cordis / ambient
- * clock. Package-private — the SessionInput shell is the only caller and the
- * sole executor of the returned effects.
+ * SubmitMachine: the pure per-session submit-plane state machine.
+ * Events in, effects out; zero React / DOM / cordis. Package-private — the
+ * SessionInput shell is the only caller and the sole executor of the
+ * returned effects.
  *
  *
- * Draft truth: the draft string holds each reference's complete inline display
- * text; the occurrence table carries identity, range, and the owner's cached projections. Every
- * draft mutation is one transaction — draft edit, occurrence reconciliation,
- * and undo-log push are atomic inside dispatch() — and bumps draftRev, which
- * is what lets span CAS reduce to a revision-equality check: equal rev ⟹
- * identical draft ⟹ identical span content. Callers observe mutation success
- * as a draftRev advance (begin-command / insert-ref / consume-token /
- * paste-upgrade all answer their bail events this way).
+ * The machine owns phase, claim, and the in-flight SubmitAttempt; it never
+ * holds the draft. Text truth lives in the shell's Lexical editor, and every
+ * decision that needs the draft reads it from the event payload (claim
+ * integrity watch, enter snapshots, settlement suffix/re-entry decisions).
  */
  */
-import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import type { CommandClaim } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
-import type {
-  ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions,
-  InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
-} from './contract.ts'
+import type { InputEffect, InputEvent, InputState, SubmitAttempt } from './contract.ts'
 
 
-/** Legacy fixed-width object replacement character rejected from pasted text. */
-export const PLACEHOLDER = ''
-
-const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
-
-/**
- * Build the inline draft text whose leading marker is decorated as the
- * reference icon in the backdrop.
- * @param reference - reference insertion with its cached display projection.
- * @returns display text with one marker glyph followed by the complete label.
- */
-export function referenceDraftText(reference: Pick<ReferenceInsert, 'label'>): string {
-  return `@${reference.label}`
-}
-
-/** The machine never writes the queue; the wiring layer overlays the queue store's projection. */
-const EMPTY_QUEUE: InputState['queue'] = []
-
-/** Undo ring depth (bounded self-managed transaction log). */
-const LOG_LIMIT = 100
-
-/** Exhaustiveness backstop for the closed InputEvent / guard unions. */
+/** Exhaustiveness backstop for the closed InputEvent union. */
 function unreachable(value: never): never {
 function unreachable(value: never): never {
   throw new Error(`unreachable input event: ${JSON.stringify(value)}`)
   throw new Error(`unreachable input event: ${JSON.stringify(value)}`)
 }
 }
@@ -64,88 +36,33 @@ function argsAfter(draft: string, token: string): string {
   return ''
   return ''
 }
 }
 
 
-/**
- * Prefix/suffix common-scan recovering the edit range between two drafts
- * (used when the wiring layer cannot supply one from the DOM event).
- */
-function diffEdit(prev: string, next: string): EditRange {
-  let p = 0
-  const maxCommon = Math.min(prev.length, next.length)
-  while (p < maxCommon && prev[p] === next[p]) p += 1
-  let s = 0
-  const maxSuffix = maxCommon - p
-  while (s < maxSuffix && prev[prev.length - 1 - s] === next[next.length - 1 - s]) s += 1
-  return { start: p, end: prev.length - s, insertedLength: next.length - s - p }
-}
-
-/**
- * Expand the draft's reference ranges into their occurrences' clipboard text
- * for persistence and clipboard projection. Table order is offset order, so
- * one linear walk pairs ranges with entries.
- * @param state - published input state.
- * @returns the plain-text projection of the draft.
- */
-export function projectClipboard(state: Pick<InputState, 'draft' | 'occurrences'>): string {
-  const { draft, occurrences } = state
-  if (occurrences.length === 0) return draft
-  let out = ''
-  let cursor = 0
-  for (const o of occurrences) {
-    out += draft.slice(cursor, o.offset) + o.clipboardText
-    cursor = o.offset + o.length
-  }
-  return out + draft.slice(cursor)
-}
-
-/** One undo unit: snapshots taken before the transaction applied. */
-interface Transaction {
-  readonly draftBefore: string
-  readonly occurrencesBefore: readonly Occurrence[]
-  /** Pre-edit selection when the triggering event carried one (shell caret restore on undo). */
-  readonly selectionBefore?: EditSelection
+/** The submit-plane slice of the published InputState. */
+export interface SubmitSnapshot {
+  readonly phase: InputState['phase']
+  readonly claim?: InputState['claim']
 }
 }
 
 
 /**
 /**
- * Pure input machine, one instance per session (per-session isolation is by
+ * Pure submit machine, one instance per session (per-session isolation is by
  * construction). The machine constructs one AbortController per SubmitAttempt
  * construction). The machine constructs one AbortController per SubmitAttempt
  * at enter time and aborts it itself on release; the shell never aborts, it
  * at enter time and aborts it itself on release; the shell never aborts, it
  * only observes attempt.signal on its adjudicate/submit promises. Stale
  * only observes attempt.signal on its adjudicate/submit promises. Stale
  * attempts (any adjudicated / adjudication-failed / submit-settled whose seq
  * attempts (any adjudicated / adjudication-failed / submit-settled whose seq
  * is not the in-flight one) are dropped: same state, zero effects.
  * is not the in-flight one) are dropped: same state, zero effects.
  */
  */
-export class InputMachine {
-  private draft = ''
-  private draftRev = 0
+export class SubmitMachine {
   private phase: InputState['phase'] = 'plain'
   private phase: InputState['phase'] = 'plain'
   private claim: CommandClaim | undefined
   private claim: CommandClaim | undefined
-  private occurrences: readonly Occurrence[] = []
-  private occurrenceSeq = 0
   private seq = 0
   private seq = 0
   private inflight: {
   private inflight: {
     readonly attempt: SubmitAttempt
     readonly attempt: SubmitAttempt
     readonly controller: AbortController
     readonly controller: AbortController
   } | undefined
   } | undefined
-  private log: Transaction[] = []
-  private redoStack: Transaction[] = []
-  /** Open single-char typing run: the next contiguous char within the window coalesces. */
-  private typingRun: { readonly end: number; readonly at: number } | undefined
-  private paste: PasteAttemptState | undefined
-  private pasteSeq = 0
-  private readonly mergeWindowMs: number
-  private readonly now: () => number
 
 
-  constructor(options: InputMachineOptions = {}) {
-    this.mergeWindowMs = options.mergeWindowMs ?? 1000
-    this.now = options.now ?? (() => 0)
-  }
-
-  /** Read-only snapshot of the machine state (queue always empty at this tier). */
-  get state(): InputState {
+  /** Read-only snapshot of the submit-plane state. */
+  get state(): SubmitSnapshot {
     const c = this.claim
     const c = this.claim
     return {
     return {
-      draft: this.draft,
-      imageIds: [],
-      draftRev: this.draftRev,
       phase: this.phase,
       phase: this.phase,
       ...(c
       ...(c
         ? {
         ? {
@@ -156,33 +73,19 @@ export class InputMachine {
           },
           },
         }
         }
         : {}),
         : {}),
-      occurrences: this.occurrences,
-      ...(this.paste !== undefined ? { paste: this.paste } : {}),
-      queue: EMPTY_QUEUE,
     }
     }
   }
   }
 
 
   /**
   /**
    * Feed one event through the machine.
    * Feed one event through the machine.
-   * @param ev - Input event; the single write path for all input state.
+   * @param ev - Input event; the single write path for all submit-plane state.
    * @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events.
    * @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events.
    */
    */
   dispatch(ev: InputEvent): readonly InputEffect[] {
   dispatch(ev: InputEvent): readonly InputEffect[] {
     switch (ev.type) {
     switch (ev.type) {
-      case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange)
-      case 'begin-command': return this.onBeginCommand(ev.claim, ev.span)
-      case 'insert-ref': return this.onInsertRef(ev.reference, ev.span)
-      case 'consume-token': return this.onConsumeToken(ev.guard)
-      case 'set-invalid': return this.onSetInvalid(ev.invalidIds)
-      case 'undo': return this.onUndo()
-      case 'redo': return this.onRedo()
-      case 'paste-begin': return this.onPasteBegin(ev.text, ev.selection, ev.components, ev.generation)
-      case 'paste-upgrade': return this.onPasteUpgrade(ev.attemptId, ev.span, ev.reference)
-      case 'invalidate-paste': {
-        this.paste = undefined
-        return []
-      }
-      case 'enter': return this.onEnter(ev.mode)
+      case 'draft-changed': return this.onDraftChanged(ev.draft)
+      case 'claim': return this.onClaim(ev.claim)
+      case 'enter': return this.onEnter(ev.mode, ev.draft)
       case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
       case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
       case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
       case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
       case 'submit-settled': return this.onSubmitSettled(ev)
       case 'submit-settled': return this.onSubmitSettled(ev)
@@ -192,313 +95,51 @@ export class InputMachine {
     }
     }
   }
   }
 
 
-  // ---- transaction plumbing ----
-
-  /** Adopt a new draft: bump the revision (the span-CAS invalidation point). */
-  private adopt(draft: string): void {
-    this.draft = draft
-    this.draftRev += 1
-  }
-
-  /** Push one undo unit (before-state), trim the ring, and cut the redo chain. */
-  private pushTxn(selectionBefore?: EditSelection): void {
-    this.log.push({
-      draftBefore: this.draft,
-      occurrencesBefore: this.occurrences,
-      ...(selectionBefore !== undefined ? { selectionBefore } : {}),
-    })
-    if (this.log.length > LOG_LIMIT) this.log.shift()
-    this.redoStack = []
-  }
-
-  /**
-   * Reconcile the occurrence table with one edit (old-draft coordinates):
-   * entries past the range shift by the length delta; an edit that intersects
-   * a reference range removes its structured occurrence and leaves the edited
-   * characters as ordinary draft text.
-   */
-  private reconcile(range: EditRange): void {
-    const delta = range.insertedLength - (range.end - range.start)
-    const kept: Occurrence[] = []
-    for (const o of this.occurrences) {
-      if (o.offset + o.length <= range.start) kept.push(o)
-      else if (o.offset >= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta })
-    }
-    this.occurrences = kept
-  }
-
-  /** Claimed integrity watch: any mutation that breaks the token prefix releases the claim. */
-  private watchClaim(): void {
-    if (this.phase === 'claimed' && this.claim !== undefined && !this.draft.startsWith(this.claim.token)) {
+  /** Claimed integrity watch: any draft that breaks the token prefix releases the claim. */
+  private onDraftChanged(draft: string): InputEffect[] {
+    if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) {
       this.phase = 'plain'
       this.phase = 'plain'
       this.claim = undefined
       this.claim = undefined
     }
     }
-  }
-
-  /** Mint one occurrence at a draft offset. */
-  private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence {
-    this.occurrenceSeq += 1
-    return {
-      occurrenceId: this.occurrenceSeq,
-      source: reference.source,
-      ref: reference.ref,
-      offset,
-      length,
-      label: reference.label,
-      ...reference.appearance === undefined ? {} : { appearance: reference.appearance },
-      clipboardText: reference.clipboardText,
-    }
-  }
-
-  /** Splice minted entries into the offset-sorted table. */
-  private withMinted(minted: readonly Occurrence[]): void {
-    if (minted.length === 0) return
-    this.occurrences = [...this.occurrences, ...minted].sort((a, b) => a.offset - b.offset)
-  }
-
-  // ---- draft transactions ----
-
-  private onDraftChanged(draft: string, editRange?: EditRange): InputEffect[] {
-    if (draft === this.draft) return []
-    const range = editRange ?? diffEdit(this.draft, draft)
-    // Single-char typing coalesces into the open run while contiguous and
-    // inside the merge window; anything else opens its own transaction.
-    const typing = range.start === range.end && range.insertedLength === 1
-    const at = this.now()
-    const run = this.typingRun
-    const merges = typing && run !== undefined && run.end === range.start && at - run.at <= this.mergeWindowMs
-    if (!merges) this.pushTxn({ start: range.start, end: range.end })
-    this.typingRun = typing ? { end: range.start + 1, at } : undefined
-    this.reconcile(range)
-    this.adopt(draft)
-    this.watchClaim()
-    this.paste = undefined
     return []
     return []
   }
   }
 
 
-  /** Span CAS: revision equality (content identity follows) plus bounds sanity. */
-  private casOk(span: TokenSpan): boolean {
-    return span.draftRev === this.draftRev
-      && span.start >= 0 && span.start <= span.end && span.end <= this.draft.length
-  }
-
-  private onBeginCommand(claim: CommandClaim, span: TokenSpan): InputEffect[] {
+  /** The editor applied a claim-token replacement: enter claimed (busy phases refuse). */
+  private onClaim(claim: CommandClaim): InputEffect[] {
     if (this.phase !== 'plain' && this.phase !== 'claimed') return []
     if (this.phase !== 'plain' && this.phase !== 'claimed') return []
-    // Leading-trigger contract: only whitespace may precede the span; the
-    // whitespace prefix is dropped so the claimed watch (startsWith) holds.
-    if (!this.casOk(span) || this.draft.slice(0, span.start).trim() !== '') return []
-    this.pushTxn()
-    this.typingRun = undefined
-    this.reconcile({ start: 0, end: span.end, insertedLength: claim.token.length })
-    this.adopt(claim.token + this.draft.slice(span.end))
     this.claim = claim
     this.claim = claim
     this.phase = 'claimed'
     this.phase = 'claimed'
-    this.paste = undefined
-    return []
-  }
-
-  private onInsertRef(reference: ReferenceInsert, span: TokenSpan): InputEffect[] {
-    if (this.phase !== 'plain' && this.phase !== 'claimed') return []
-    if (!this.casOk(span)) return []
-    this.replaceSpanWithChip(reference, span)
-    this.paste = undefined
-    return []
-  }
-
-  /**
-   * Shared reference-insertion transaction: replace [span) with one inline
-   * occurrence (insert-ref and paste-upgrade both land here). A separating
-   * space follows the reference unless one is already next.
-   * @returns the inserted length (display text plus optional gap).
-   */
-  private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number {
-    this.pushTxn()
-    this.typingRun = undefined
-    const tail = this.draft.slice(span.end)
-    const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : ''
-    const displayText = referenceDraftText(reference)
-    const inserted = displayText + gap
-    this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length })
-    this.withMinted([this.mint(reference, span.start, displayText.length)])
-    this.adopt(this.draft.slice(0, span.start) + inserted + tail)
-    this.watchClaim()
-    return inserted.length
-  }
-
-  /**
-   * Guarded token deletion after business success (popup settle / menu-pick
-   * execute). No effect signals success: the caller reads the draftRev
-   * advance off the published state (same currency as the other bail verbs).
-   */
-  private onConsumeToken(guard: ConsumeTokenGuard): InputEffect[] {
-    if (this.phase !== 'plain' && this.phase !== 'claimed') return []
-    switch (guard.kind) {
-      case 'span': {
-        const span = guard.span
-        if (!this.casOk(span) || span.start === span.end) return []
-        this.pushTxn()
-        this.typingRun = undefined
-        this.reconcile({ start: span.start, end: span.end, insertedLength: 0 })
-        this.adopt(this.draft.slice(0, span.start) + this.draft.slice(span.end))
-        this.watchClaim()
-        this.paste = undefined
-        return []
-      }
-      case 'bare-token': {
-        if (guard.token === '' || this.draft.trim() !== guard.token) return []
-        this.pushTxn()
-        this.typingRun = undefined
-        this.occurrences = []
-        this.adopt('')
-        this.watchClaim()
-        this.paste = undefined
-        return []
-      }
-      default: return unreachable(guard)
-    }
-  }
-
-  /**
-   * Owner-resolution style bits: exactly the listed occurrences render
-   * invalid. Not a transaction — the draft, revision, and undo log are
-   * untouched (invalidation never deletes or rewrites chips).
-   */
-  private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
-    const ids = new Set(invalidIds)
-    if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return []
-    this.occurrences = this.occurrences.map((o) => {
-      const invalid = ids.has(o.occurrenceId)
-      if ((o.invalid === true) === invalid) return o
-      const { invalid: _drop, ...rest } = o
-      return invalid ? { ...rest, invalid: true } : rest
-    })
-    return []
-  }
-
-  // ---- undo / redo ----
-
-  private onUndo(): InputEffect[] {
-    const entry = this.log.pop()
-    if (entry === undefined) return []
-    this.redoStack.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences })
-    this.occurrences = entry.occurrencesBefore
-    this.adopt(entry.draftBefore)
-    this.watchClaim()
-    this.typingRun = undefined
-    this.paste = undefined
-    return []
-  }
-
-  private onRedo(): InputEffect[] {
-    const entry = this.redoStack.pop()
-    if (entry === undefined) return []
-    // Manual log push: pushTxn would cut the redo chain being walked.
-    this.log.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences })
-    if (this.log.length > LOG_LIMIT) this.log.shift()
-    this.occurrences = entry.occurrencesBefore
-    this.adopt(entry.draftBefore)
-    this.watchClaim()
-    this.typingRun = undefined
-    this.paste = undefined
-    return []
-  }
-
-  // ---- paste plane ----
-
-  /**
-   * Paste as one transaction: the text (reference-placeholder-sanitized) replaces the
-   * selection; hot-snapshot sync matches componentize inside the SAME
-   * transaction (one undo returns to pre-paste); a match attempt opens for
-   * the async remainder while the phase still accepts reference mutations.
-   */
-  private onPasteBegin(
-    rawText: string, selection: EditSelection,
-    components: readonly PasteComponent[] = [], generation = 0,
-  ): InputEffect[] {
-    const { start, end } = selection
-    if (start < 0 || start > end || end > this.draft.length) return []
-    const text = rawText.replace(REFERENCE_PLACEHOLDER_RE, '')
-    this.pushTxn(selection)
-    this.typingRun = undefined
-    // Componentize: replace each matched token range (paste-text coordinates,
-    // disjoint by contract) with inline display text while assembling the insert.
-    const sorted = [...components].sort((a, b) => a.start - b.start)
-    const minted: Occurrence[] = []
-    let inserted = ''
-    let cursor = 0
-    for (const c of sorted) {
-      inserted += text.slice(cursor, c.start)
-      const displayText = referenceDraftText(c.reference)
-      minted.push(this.mint(c.reference, start + inserted.length, displayText.length))
-      inserted += displayText
-      cursor = c.end
-    }
-    inserted += text.slice(cursor)
-    this.reconcile({ start, end, insertedLength: inserted.length })
-    this.withMinted(minted)
-    this.adopt(this.draft.slice(0, start) + inserted + this.draft.slice(end))
-    this.watchClaim()
-    if (this.phase === 'plain' || this.phase === 'claimed') {
-      this.pasteSeq += 1
-      this.paste = {
-        attemptId: this.pasteSeq,
-        insertedRange: { start, end: start + inserted.length },
-        generation,
-      }
-    } else {
-      this.paste = undefined
-    }
-    return []
-  }
-
-  /**
-   * Async match landed: upgrade one pasted token to a chip as an INDEPENDENT
-   * transaction (undo #1 → the token text, undo #2 → pre-paste). The attempt
-   * stays current — later tokens re-CAS against the advanced draftRev.
-   */
-  private onPasteUpgrade(attemptId: number, span: TokenSpan, reference: ReferenceInsert): InputEffect[] {
-    const attempt = this.paste
-    if (attempt === undefined || attempt.attemptId !== attemptId) return []
-    if (this.phase !== 'plain' && this.phase !== 'claimed') return []
-    if (!this.casOk(span) || span.start === span.end) return []
-    const insertedLength = this.replaceSpanWithChip(reference, span)
-    this.paste = {
-      ...attempt,
-      insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) },
-    }
     return []
     return []
   }
   }
 
 
   // ---- submit plane ----
   // ---- submit plane ----
 
 
   /** Mint the next SubmitAttempt and take the in-flight slot. */
   /** Mint the next SubmitAttempt and take the in-flight slot. */
-  private beginAttempt(mode: InputSubmitMode): SubmitAttempt {
+  private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt {
     const controller = new AbortController()
     const controller = new AbortController()
     this.seq += 1
     this.seq += 1
-    const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode }
+    const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode }
     this.inflight = { attempt, controller }
     this.inflight = { attempt, controller }
     return attempt
     return attempt
   }
   }
 
 
-  private onEnter(mode: InputSubmitMode): InputEffect[] {
+  private onEnter(mode: InputSubmitMode, draft: string): InputEffect[] {
     if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
     if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
     if (this.phase === 'claimed' && this.claim !== undefined) {
     if (this.phase === 'claimed' && this.claim !== undefined) {
-      const attempt = this.beginAttempt(mode)
+      const attempt = this.beginAttempt(mode, draft)
       this.phase = 'submitting'
       this.phase = 'submitting'
-      this.paste = undefined
-      return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
+      return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
     }
     }
-    const trimmed = this.draft.trim()
+    const trimmed = draft.trim()
     if (trimmed === '') return []
     if (trimmed === '') return []
-    this.paste = undefined
     if (trimmed.startsWith('/')) {
     if (trimmed.startsWith('/')) {
-      const attempt = this.beginAttempt(mode)
+      const attempt = this.beginAttempt(mode, draft)
       this.phase = 'adjudicating'
       this.phase = 'adjudicating'
-      return [{ type: 'adjudicate', attempt, draft: this.draft }]
+      return [{ type: 'adjudicate', attempt, draft }]
     }
     }
-    const attempt = this.beginAttempt(mode)
+    const attempt = this.beginAttempt(mode, draft)
     this.phase = 'submitting'
     this.phase = 'submitting'
-    return [{ type: 'default-sink', attempt, draft: this.draft, mode }]
+    return [{ type: 'default-sink', attempt, draft, mode }]
   }
   }
 
 
   private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
   private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -514,7 +155,7 @@ export class InputMachine {
         args: argsAfter(attempt.draftSnapshot, outcome.claim.token),
         args: argsAfter(attempt.draftSnapshot, outcome.claim.token),
       }]
       }]
     }
     }
-    // 'handled' (source dealt internally), {insert} (no enter-time span
+    // 'handled' (source dealt internally), {insert}/{text} (no enter-time span
     // semantics), or a miss: all land plain; only the miss flows to the sink.
     // semantics), or a miss: all land plain; only the miss flows to the sink.
     if (outcome === undefined) {
     if (outcome === undefined) {
       this.phase = 'submitting'
       this.phase = 'submitting'
@@ -545,30 +186,19 @@ export class InputMachine {
     if (ev.ok) {
     if (ev.ok) {
       this.phase = 'plain'
       this.phase = 'plain'
       this.claim = undefined
       this.claim = undefined
-      this.occurrences = []
-      // Text appended after the sent snapshot during the Host round-trip
-      // survives the commit; edits interleaved with committed content cannot
-      // be separated from it, so only a pure suffix is retained.
-      const snapshot = flight.attempt.draftSnapshot
-      this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot)
-        ? this.draft.slice(snapshot.length)
-        : '')
-      // Committed content is gone for good: undo must not resurrect a sent draft.
-      this.log = []
-      this.redoStack = []
-      this.typingRun = undefined
-      this.paste = undefined
-      return ev.outcome?.text !== undefined
-        ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }]
-        : []
+      const effects: InputEffect[] = [{ type: 'commit-draft', retainSuffixOf: flight.attempt.draftSnapshot }]
+      if (ev.outcome?.text !== undefined) {
+        effects.push({ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text })
+      }
+      return effects
     }
     }
     const text = ev.message ?? ev.outcome?.text
     const text = ev.message ?? ev.outcome?.text
     // Keep the same command claim only while the live draft still equals the
     // Keep the same command claim only while the live draft still equals the
     // enter-time draft; user input typed during flight wins.
     // enter-time draft; user input typed during flight wins.
     // Claimed re-entry additionally requires the watch to hold — an
     // Claimed re-entry additionally requires the watch to hold — an
     // enter-path snapshot may carry leading whitespace the token never had.
     // enter-path snapshot may carry leading whitespace the token never had.
-    if (this.draft === flight.attempt.draftSnapshot
-      && this.claim !== undefined && this.draft.startsWith(this.claim.token)) {
+    if (ev.draft === flight.attempt.draftSnapshot
+      && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) {
       this.phase = 'claimed'
       this.phase = 'claimed'
       return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
       return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
     }
     }
@@ -577,17 +207,11 @@ export class InputMachine {
     return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
     return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
   }
   }
 
 
-  /** Cut undo state after an accepted image-only send. */
+  /** Clear the draft after an accepted image-only send (no suffix retention: there was no draft). */
   private onSendCommitted(): InputEffect[] {
   private onSendCommitted(): InputEffect[] {
     if (this.phase !== 'plain') return []
     if (this.phase !== 'plain') return []
     this.claim = undefined
     this.claim = undefined
-    this.occurrences = []
-    this.adopt('')
-    this.log = []
-    this.redoStack = []
-    this.typingRun = undefined
-    this.paste = undefined
-    return []
+    return [{ type: 'commit-draft', retainSuffixOf: null }]
   }
   }
 
 
   private onRelease(): InputEffect[] {
   private onRelease(): InputEffect[] {
@@ -597,8 +221,6 @@ export class InputMachine {
     }
     }
     this.phase = 'plain'
     this.phase = 'plain'
     this.claim = undefined
     this.claim = undefined
-    this.typingRun = undefined
-    this.paste = undefined
     return []
     return []
   }
   }
 }
 }

+ 31 - 156
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -116,57 +116,20 @@
   height: 0;
   height: 0;
 }
 }
 
 
-/* The draft's scrollport, and the ONLY scrolling box in the composer: the
-   caret is the textarea's and every visible glyph is the backdrop's, so the two
-   layers stay together only by riding one offset the browser applies to both at
-   once. Scrolling one box and assigning the offset to the other cannot hold —
-   a wheel gesture is composited off the main thread, so the assignment lands
-   frames late and the words visibly trail the caret. The 14-line cap lives here
+/* The draft's scrollport, and the ONLY scrolling box in the composer. The
+   contenteditable grows with its content inside; the 14-line cap lives here
    because this is the box the cap describes. */
    because this is the box the cap describes. */
 .scroll {
 .scroll {
   max-height: var(--dsh-composer-text-max-height);
   max-height: var(--dsh-composer-text-max-height);
   overflow-y: auto;
   overflow-y: auto;
 }
 }
 
 
-/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft
-   height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are
-   as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea
-   MUST share font, line-height, padding and wrapping rules or heights diverge. */
+/* Auto-grow anchor: the contenteditable is in normal flow and sets the
+   draft's height; the placeholder rides it absolutely. */
 .grow {
 .grow {
   position: relative;
   position: relative;
 }
 }
 
 
-/* Decoration backdrop: same metrics as the transparent-text textarea. It owns
-   every visible glyph plus the range colors and ghost hint; the textarea above
-   retains the native selection and caret. */
-.backdrop {
-  position: absolute;
-  inset: 0;
-  overflow: hidden;
-  color: var(--dsw-alias-label-primary);
-  pointer-events: none;
-}
-
-.backdropDisabled,
-.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) {
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.hlToken {
-  background-color: transparent;
-  color: var(--dsw-alias-state-warn-label);
-}
-
-.hlSegment {
-  border-radius: 4px;
-  background-color: transparent;
-  color: transparent;
-}
-
-.hint {
-  color: var(--dsw-alias-label-caption);
-}
-
 /* Machine pending dot (adjudicating / submitting). */
 /* Machine pending dot (adjudicating / submitting). */
 .pending {
 .pending {
   width: 8px;
   width: 8px;
@@ -181,70 +144,48 @@
   to { opacity: 1; }
   to { opacity: 1; }
 }
 }
 
 
+/* The contenteditable draft surface (grows with its content; .scroll caps
+   and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
 .input {
 .input {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  resize: none;
-  /* Never a scroller of its own: it is as tall as the draft, so it has no
-     scrollable overflow to hold an offset that could differ from the glyphs'.
-     The browser still reveals the caret — the scroll-into-view walks up to
-     .scroll and moves both layers together. */
-  overflow: hidden;
-  border: none;
-  outline: none;
-  background: transparent;
-  color: transparent;
-  -webkit-text-fill-color: transparent;
-  /* Business blue, not brand-primary: that token resolves to ink in this sheet. */
-  caret-color: var(--dsw-alias-state-business-primary);
-}
-
-.input,
-.mirror,
-.backdrop {
-  /* Textareas default to content-box (unlike buttons/inputs): without this the
-     width:100% textarea gains its padding OUTSIDE the card and text runs past
-     the right padding — and wraps 28px later than the mirror/backdrop layers. */
   box-sizing: border-box;
   box-sizing: border-box;
-  /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
-     metrics or the highlight ranges drift off the glyphs. */
   padding: 4px 12px 0 16px;
   padding: 4px 12px 0 16px;
   font-family: var(--dsw-font-family);
   font-family: var(--dsw-font-family);
   font-size: inherit;
   font-size: inherit;
-  /* Three consumers, not two: the mirror sizes the stack, the layers must break
-     lines identically, and the caret reveal parses this value to step one line
-     down for a caret that sits after a newline. That parse needs a length, so a
-     theme resolving this to `normal` would make the reveal a silent no-op. */
   line-height: inherit;
   line-height: inherit;
   white-space: pre-wrap;
   white-space: pre-wrap;
   word-break: break-word;
   word-break: break-word;
   overflow-wrap: anywhere;
   overflow-wrap: anywhere;
-  /* These three MUST wrap at one width: the mirror decides the box height
-     the other two are laid out in, and a glyph layer that breaks lines
-     elsewhere than the textarea puts the words under the wrong caret. They do
-     so by construction now that all three sit INSIDE .scroll — a scrollbar
-     that consumes layout space narrows the scrollport, which is their shared
-     containing block, so it costs all three the same width on every engine.
-     A textarea that scrolls itself would break this, and no property fixes
-     it: WebKit reserves gutter space for an overflow-y:auto textarea and not
-     for the overflow:hidden layers beside it, leaving them 8px apart
-     (768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
+  outline: none;
+  color: var(--dsw-alias-label-primary);
+  /* Business blue, not brand-primary: that token resolves to ink in this sheet. */
+  caret-color: var(--dsw-alias-state-business-primary);
+}
+
+/* Lexical paragraphs are <p> blocks: strip the UA margins so the surface
+   keeps the textarea's line rhythm. */
+.input p {
+  margin: 0;
+}
+
+/* Claim ghost hint as generated content after the last paragraph: the bar
+   sets --dsh-composer-hint (a quoted string) while the claim's args are
+   blank; without the variable the declaration is invalid and nothing shows. */
+.input p:last-child::after {
+  content: var(--dsh-composer-hint);
+  color: var(--dsw-alias-label-caption);
 }
 }
 
 
 /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
 /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
-.input::placeholder {
+.placeholder {
+  position: absolute;
+  inset: 4px 12px auto 16px;
   color: var(--dsw-alias-label-caption);
   color: var(--dsw-alias-label-caption);
-  -webkit-text-fill-color: var(--dsw-alias-label-caption);
+  pointer-events: none;
   user-select: none;
   user-select: none;
 }
 }
 
 
-/* The backdrop owns disabled draft color; the textarea remains caret-only so
-   its marker glyphs cannot cover the reference icons beneath it. */
-.input:disabled {
-  color: transparent;
-  -webkit-text-fill-color: transparent;
+.inputDisabled {
+  color: var(--dsw-alias-label-tertiary);
   cursor: not-allowed;
   cursor: not-allowed;
 }
 }
 
 
@@ -252,14 +193,9 @@
   cursor: pointer;
   cursor: pointer;
 }
 }
 
 
-.mirror {
-  visibility: hidden;
-  pointer-events: none;
-}
-
 /* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
 /* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
    line + 4pt); the docked composer collapses to the content height. */
    line + 4pt); the docked composer collapses to the content height. */
-.hero .mirror {
+.hero .input {
   min-height: 52px;
   min-height: 52px;
 }
 }
 
 
@@ -410,64 +346,3 @@
   font-size: 12px;
   font-size: 12px;
   cursor: pointer;
   cursor: pointer;
 }
 }
-
-/* Plain-text reference highlight: a pure range mark over the
-   draft's own glyphs — advance untouched, so the two layers cannot drift.
-   Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
-.textRef {
-  background-color: transparent;
-  color: var(--dsw-alias-state-business-primary);
-  box-decoration-break: clone;
-  -webkit-box-decoration-break: clone;
-}
-.textRef:after {
-  display: none;
-}
-
-.textRefTrigger {
-  position: relative;
-}
-
-.textRefTriggerGlyph {
-  color: transparent;
-}
-
-.textRefIcon {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-}
-
-/* Structured references use the same inline-backdrop technique as /skill:
-   their complete display text remains in the textarea, so wrapping and caret
-   geometry come from the browser's native glyph metrics. The leading marker
-   reserves the icon's advance while the backdrop paints the domain glyph. */
-.chip {
-  position: relative;
-  color: var(--dsw-alias-state-business-primary);
-  background: transparent;
-  -webkit-box-decoration-break: clone;
-  box-decoration-break: clone;
-}
-
-.chipTrigger {
-  position: relative;
-}
-
-.chipTriggerGlyph {
-  color: transparent;
-}
-
-.chipIcon {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-}
-
-.chipInvalid {
-  text-decoration: line-through;
-  opacity: 0.7;
-  color: var(--dsw-alias-state-error-primary);
-}

+ 159 - 426
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -4,10 +4,17 @@
  * through this entry's own inject, whose hooks compartment binds
  * through this entry's own inject, whose hooks compartment binds
  * useNotices/useLexicon; layout-phase inputs (variant, placeholder,
  * useNotices/useLexicon; layout-phase inputs (variant, placeholder,
  * region-slot content) ride the owner props. Session facts
  * region-slot content) ride the owner props. Session facts
- * (running/removed/promptError) are self-selected via useSession. */
+ * (running/removed/promptError) are self-selected via useSession.
+ *
+ * The text surface is the shell-owned Lexical editor bound here through
+ * ComposerContentEditable; chips render as decorator portals, and the
+ * keymap registers submit/menu/paste gestures on the editor command layer.
+ * The no-session state renders the SAME div inert as the Workspace-picker
+ * trigger instead of a parallel tree.
+ */
 
 
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import clsx from 'clsx'
 import {
 import {
   IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
   IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
@@ -22,18 +29,14 @@ 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 { deriveDecorations } from '../input/decorations.ts'
-import type { DraftDecorations } from '../input/decorations.ts'
+import { ComposerContentEditable } from '../input/editor/ComposerContentEditable.tsx'
+import { DecoratorPortals } from '../input/editor/DecoratorPortals.tsx'
+import { registerComposerKeymap } from '../input/editor/keymap.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
-import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
 import { ContextMeter } from './ContextMeter.tsx'
 import { ContextMeter } from './ContextMeter.tsx'
 import { PermissionSelect } from './PermissionSelect.tsx'
 import { PermissionSelect } from './PermissionSelect.tsx'
-import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts'
 import css from './InputBar.module.css'
 import css from './InputBar.module.css'
 
 
-/** Decoration product of the no-session state (no machine, empty draft). */
-const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
-
 export type InputBarProps = ComposerBarProps
 export type InputBarProps = ComposerBarProps
 
 
 export function InputBar({
 export function InputBar({
@@ -46,13 +49,13 @@ export function InputBar({
 }: InputBarProps) {
 }: InputBarProps) {
   const input = useInput(s => s)
   const input = useInput(s => s)
   const notice = useNotices(s => s)
   const notice = useNotices(s => s)
-  const lexicon = useLexicon(s => s)
+  void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms
   const commandMenuOpen = useMenuLauncher(source => source === 'command')
   const commandMenuOpen = useMenuLauncher(source => source === 'command')
   const promptError = useSession(s => s.promptError) ?? null
   const promptError = useSession(s => s.promptError) ?? null
   const running = useSession(s => s.running) ?? false
   const running = useSession(s => s.running) ?? false
   const subagent = useSession(s => s.subagent) ?? null
   const subagent = useSession(s => s.subagent) ?? null
   const removed = useSession(s => s.removed) ?? false
   const removed = useSession(s => s.removed) ?? false
-  // Plan mode swaps the textarea placeholder (the projection is the folded
+  // Plan mode swaps the composer placeholder (the projection is the folded
   // host value; owner-prop placeholders — hero, session-unavailable — win).
   // host value; owner-prop placeholders — hero, session-unavailable — win).
   const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
   const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
   // Absent (undefined: no frame yet) and cleared (null) both mean no goal.
   // Absent (undefined: no frame yet) and cleared (null) both mean no goal.
@@ -61,6 +64,7 @@ export function InputBar({
   // current; the bar renders the same DOM inert instead of a parallel tree.
   // current; the bar renders the same DOM inert instead of a parallel tree.
   const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
   const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
   const draft = input?.draft ?? ''
   const draft = input?.draft ?? ''
+  const editor = keyboard?.editor ?? null
   const attachments = useMemo(
   const attachments = useMemo(
     () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
     () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
     [draftImages, input?.imageIds],
     [draftImages, input?.imageIds],
@@ -95,23 +99,8 @@ export function InputBar({
   useEffect(() => {
   useEffect(() => {
     if (notice?.level === 'error') showToast(notice.text)
     if (notice?.level === 'error') showToast(notice.text)
   }, [notice, showToast])
   }, [notice, showToast])
-  const inputRef = useRef<HTMLTextAreaElement | null>(null)
   const cardRef = useRef<HTMLDivElement | null>(null)
   const cardRef = useRef<HTMLDivElement | null>(null)
   const scrollRef = useRef<HTMLDivElement | null>(null)
   const scrollRef = useRef<HTMLDivElement | null>(null)
-  const mirrorRef = useRef<HTMLDivElement | null>(null)
-  const safari = useMemo(() => isSafariBrowser(navigator), [])
-  const safariNativeShrinkRef = useRef(false)
-  // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
-  // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
-  const composingRef = useRef(false)
-  const onCompositionStart = (): void => {
-    composingRef.current = true
-  }
-  const onCompositionEnd = (): void => {
-    setTimeout(() => {
-      composingRef.current = false
-    }, 10)
-  }
 
 
   // The Access seat's data: the host-computed permissions projection
   // The Access seat's data: the host-computed permissions projection
   // (undefined = capability absent → the chip renders nothing).
   // (undefined = capability absent → the chip renders nothing).
@@ -133,12 +122,13 @@ export function InputBar({
   // be disabled do lock it — there is no session to choose a model for.
   // be disabled do lock it — there is no session to choose a model for.
   const modelSeatLocked = removed || inert || !live
   const modelSeatLocked = removed || inert || !live
   const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
   const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
-  // The no-workspace textarea remains the resident DOM node but acts as the
+  // The no-workspace surface remains the resident DOM node but acts as the
   // existing picker trigger. Message controls stay locked until a Session
   // existing picker trigger. Message controls stay locked until a Session
   // exists; the trigger itself is read-only rather than disabled so pointer
   // exists; the trigger itself is read-only rather than disabled so pointer
   // and keyboard users can reach the recovery action.
   // and keyboard users can reach the recovery action.
   const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined
   const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined
-  const textareaDisabled = removed || (locked && !workspaceTrigger)
+  const editorDisabled = removed || (locked && !workspaceTrigger)
+  const editable = live && !locked && !machineBusy
   const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
   const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
     && input.queue.some(row => row.placement === 'queued')
     && input.queue.some(row => row.placement === 'queued')
 
 
@@ -149,77 +139,43 @@ export function InputBar({
     }
     }
   }, [attachments, input?.imageIds, inputActions])
   }, [attachments, input?.imageIds, inputActions])
 
 
-  // A native Safari edit that shortens the draft may leave the previous
-  // soft-wrap layout behind after the mirror shrinks. The native-change signal
-  // keeps ordinary typing and programmatic draft updates from reading layout;
-  // the helper then repairs only measured overflow before paint while
-  // preserving native editing state. See
-  // .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md.
-  useLayoutEffect(() => {
-    const nativeShrink = safariNativeShrinkRef.current
-    safariNativeShrinkRef.current = false
-    if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current)
-  }, [draft, safari])
-  // Scroll the draft scrollport the minimum that brings `caret` into view — the
-  // browser's own behavior for typing, performed for the paths where it does
-  // not act.
-  //
-  // The mirror is the caret's ruler: it renders the same draft at the same
-  // metrics and the same wrap width in the same stack (that is what makes it
-  // the height authority), so a Range collapsed at the caret's index reports
-  // where the caret is without a caret API.
-  const revealCaret = (caret: number): void => {
+  // Scroll the draft scrollport the minimum that brings the selection focus
+  // into view — the browser's own behavior for typing, performed for the
+  // paths where it does not act (programmatic focus with preventScroll, and
+  // session switches that land the caret off screen). The live DOM selection
+  // is the ruler; no mirror layer exists to consult.
+  const revealSelection = (): void => {
     const scrollEl = scrollRef.current
     const scrollEl = scrollRef.current
-    const mirrorEl = mirrorRef.current
-    const text = mirrorEl?.firstChild
-    if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return
-    // A box that cannot scroll has nothing to reveal: the draft fits, so every
-    // caret is already in view and the assignment below would clamp to itself.
-    if (scrollEl.scrollHeight <= scrollEl.clientHeight) return
-    const at = Math.min(caret, text.data.length)
-    // A caret straight after a newline sits on a line with nothing on it to
-    // measure — the shape a trailing-newline draft ends in — and the engines
-    // disagree there: chromium returns NO client rects at all (an all-zero box,
-    // which would scroll the wrong way), firefox reports the line above, WebKit
-    // the right one. Measure the newline itself instead, which is the line the
-    // caret just left, and step one line down; that they all agree on.
-    const afterNewline = at > 0 && text.data[at - 1] === '\n'
-    const range = document.createRange()
-    range.setStart(text, afterNewline ? at - 1 : at)
-    if (afterNewline) range.setEnd(text, at)
-    else range.collapse(true)
-    const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0
-    const rect = range.getBoundingClientRect()
+    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()
     const box = scrollEl.getBoundingClientRect()
-    if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom
-    else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line
-  }
-
-  // Reveal the focus end of the current selection. Today's entry paths leave a
-  // collapsed selection, but honoring direction keeps a future range-preserving
-  // path from revealing its anchor instead of its focus.
-  const revealSelectionFocus = (el: HTMLTextAreaElement): void => {
-    // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
-    const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd
-    // oxlint-disable-next-line typescript/no-unnecessary-condition
-    revealCaret(caret ?? el.value.length)
+    if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom
+    else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top
   }
   }
 
 
   // Unlock (mount / session switch) returns focus to the box, and owns the
   // Unlock (mount / session switch) returns focus to the box, and owns the
-  // reveal that comes with it. `preventScroll` because this focus is ours, not
-  // a gesture: the textarea is as tall as the draft, so the browser's reveal
-  // would walk up to the conversation scrollport and move the transcript under
-  // a user who only switched session. That leaves the caret to us — the DOM is
-  // reused across sessions, so switching to a longer draft keeps the previous
-  // offset while the value swap puts the caret at the new draft's end, which is
-  // off screen (measured on all three engines: offset 0 with the caret 940px
-  // down). Suppress the walk, then reveal in our own box.
+  // reveal that comes with it. Lexical's focus() suppresses the browser's
+  // scroll walk (preventScroll inside), so the reveal in our own scrollport
+  // is ours to perform — switching to a longer draft otherwise leaves the
+  // caret (restored at the draft's end) off screen.
   useEffect(() => {
   useEffect(() => {
-    const el = inputRef.current
-    if (locked || el === null) return
-    el.focus({ preventScroll: true })
-    revealSelectionFocus(el)
-  }, [locked, sessionId])
+    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() })
+  }, [locked, sessionId, editor])
 
 
   // A persisted draft arrives AFTER the unlock effect: ConversationSession
   // A persisted draft arrives AFTER the unlock effect: ConversationSession
   // adopts it in its own mount effect, and a parent's mount effect runs after
   // adopts it in its own mount effect, and a parent's mount effect runs after
@@ -228,25 +184,10 @@ export function InputBar({
   // not focus: send-clear, failed-send restore, and first-character transitions
   // not focus: send-clear, failed-send restore, and first-character transitions
   // must not steal focus from another control the user moved to.
   // must not steal focus from another control the user moved to.
   useEffect(() => {
   useEffect(() => {
-    const el = inputRef.current
-    if (locked || draft === '' || el === null) return
-    revealSelectionFocus(el)
+    if (locked || draft === '') return
+    revealSelection()
   }, [draft !== ''])
   }, [draft !== ''])
 
 
-  // Caret restore after an edit the composer performs itself. The machine owns
-  // the draft and the undo log, so paste and cut suppress the native edit and
-  // write the value through the machine — and a
-  // programmatic selection change reveals nothing: measured in chromium and
-  // WebKit, pasting a long block leaves the view where it was while the caret
-  // sits at the end of the draft. Native typing gets its reveal from the
-  // browser; these two have to ask for it, so they share one restore.
-  const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
-    requestAnimationFrame(() => {
-      el.setSelectionRange(caret, caret)
-      revealCaret(caret)
-    })
-  }
-
   // Wheel chaining on the draft scrollport, one lifetime (it is never
   // Wheel chaining on the draft scrollport, one lifetime (it is never
   // unmounted — the inert state renders the same element disabled). While the
   // unmounted — the inert state renders the same element disabled). While the
   // capped box can still move in this direction, keep the native scroll; only
   // capped box can still move in this direction, keep the native scroll; only
@@ -269,169 +210,6 @@ export function InputBar({
     return () => { el.removeEventListener('wheel', onWheel) }
     return () => { el.removeEventListener('wheel', onWheel) }
   }, [])
   }, [])
 
 
-  // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
-  /* oxlint-disable typescript/no-unnecessary-condition */
-  const selectionOf = (el: HTMLTextAreaElement) => ({
-    start: el.selectionStart ?? 0,
-    end: el.selectionEnd ?? el.selectionStart ?? 0,
-  })
-  /* oxlint-enable typescript/no-unnecessary-condition */
-
-  const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
-    if (workspaceTrigger) {
-      if (e.key === 'Enter' || e.key === ' ') {
-        e.preventDefault()
-        onRequestWorkspace()
-      }
-      return
-    }
-    // Absent machine without a Workspace recovery action stays disabled; the
-    // guard narrows the faces for the paths below.
-    if (input === undefined || keyboard === undefined || inputActions === undefined) return
-    // Shift+Enter is the native newline UNCONDITIONALLY — decided before the
-    // IME guard so a composition-closing Shift+Enter still breaks the line.
-    if (e.key === 'Enter' && e.shiftKey) return
-    // keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
-    // oxlint-disable-next-line typescript/no-deprecated
-    const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
-    if (!composing && !machineBusy && !locked
-      && (e.key === 'Backspace' || e.key === 'Delete')) {
-      const selection = selectionOf(e.currentTarget)
-      if (selection.start === selection.end) {
-        const occurrence = input.occurrences.find(o => e.key === 'Backspace'
-          ? o.offset + o.length === selection.start
-          : o.offset === selection.start)
-        if (occurrence !== undefined) {
-          e.preventDefault()
-          const start = occurrence.offset
-          const end = occurrence.offset + occurrence.length
-          keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
-          restoreCaret(e.currentTarget, start)
-          keyboard.track(keyboard.snapshot.draft, start)
-          return
-        }
-      }
-    }
-    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
-      if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
-      return
-    }
-    if (e.key === 'Escape') {
-      // Escape layering: an open overlay closes; claimed without an overlay
-      // does NOT release (backspacing the token is the only exit gesture).
-      keyboard.dismissPopup()
-      if (keyboard.arbitrate('escape', composing) === 'consumed') e.preventDefault()
-      return
-    }
-    if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
-      // The machine owns the undo/redo log (chip transactions have semantics
-      // the browser stack cannot represent); never let the native stack run.
-      e.preventDefault()
-      if (machineBusy || locked) return
-      const redo = e.key === 'y' || e.shiftKey
-      if (redo) keyboard.redo()
-      else keyboard.undo()
-      return
-    }
-    if (e.key === ' ') {
-      if (composing) return
-      if (keyboard.space()) e.preventDefault() // claim token already carries the trailing separator
-      return
-    }
-    if (e.key !== 'Enter') return
-    if (composing) return
-    // Menu-open Enter picks the highlight through arbitration; a no-highlight
-    // menu passes down to the machine's own adjudication.
-    const arbitrated = keyboard.arbitrate('enter', composing)
-    if (arbitrated !== 'pass') {
-      e.preventDefault()
-      return
-    }
-    e.preventDefault()
-    if (e.repeat) return // held-down Enter must not machine-gun sends
-    if (locked || machineBusy) return
-    const accelerated = e.ctrlKey || e.metaKey
-    // 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 (the dock's per-row
-    // steer button applied to the whole queue). Steering needs the same
-    // window as the per-row button: a running ordinary session.
-    if (accelerated && canSteerQueue) {
-      keyboard.steerQueue()
-      return
-    }
-    keyboard.submit(resolveSubmitMode(
-      running,
-      accelerated ? 'accelerated' : 'enter',
-      subagent === null,
-    ))
-  }
-
-  const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
-    if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft
-    if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
-    const next = e.target.value
-    safariNativeShrinkRef.current = safari && next.length < draft.length
-    keyboard.setDraft(next)
-    // selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
-    // oxlint-disable-next-line typescript/no-unnecessary-condition
-    keyboard.track(next, e.target.selectionStart ?? next.length)
-  }
-
-  const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
-    if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut
-    const el = e.currentTarget
-    const { start, end } = selectionOf(el)
-    if (start === end) return
-    const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start)
-    if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine
-    e.preventDefault()
-    const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start)
-    const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end)
-    // Expand structured ranges to their owner clipboard projections.
-    let text = ''
-    let cursor = copyStart
-    for (const o of touched) {
-      text += draft.slice(cursor, o.offset) + o.clipboardText
-      cursor = o.offset + o.length
-    }
-    text += draft.slice(cursor, copyEnd)
-    e.clipboardData.setData('text/plain', text)
-    if (cut && !machineBusy && !locked) {
-      keyboard.setDraft(
-        draft.slice(0, copyStart) + draft.slice(copyEnd),
-        { start: copyStart, end: copyEnd, insertedLength: 0 },
-      )
-      restoreCaret(el, copyStart)
-    }
-  }
-
-  const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
-    if (keyboard === undefined) return // absent machine: no draft can accept a paste
-    if (machineBusy || locked) return
-    const files = Array.from(e.clipboardData.items)
-      .filter(item => item.kind === 'file')
-      .map(item => item.getAsFile())
-      .filter((file): file is File => file !== null)
-    if (files.length > 0) intakeImages(files)
-    const text = e.clipboardData.getData('text/plain')
-    if (text === '') {
-      if (files.length > 0) e.preventDefault()
-      return
-    }
-    e.preventDefault()
-    const el = e.currentTarget
-    const sel = selectionOf(el)
-    // Sync components stay empty at this layer: hot-snapshot matching needs
-    // the Slash roster, which lives behind keyboard.track — the paste attempt
-    // opens in the machine and the controller upgrades tokens as matches
-    // land (paste-upgrade). The DOM layer only starts the transaction.
-    keyboard.pasteBegin(text, sel)
-    const caret = sel.start + text.length
-    restoreCaret(el, caret)
-    keyboard.track(keyboard.snapshot.draft, caret)
-  }
-
   // Intake pre-check (DeepSeek Chat semantics): an addition that would break
   // Intake pre-check (DeepSeek Chat semantics): an addition that would break
   // a projected limit is refused as a whole batch, announced immediately, and
   // a projected limit is refused as a whole batch, announced immediately, and
   // never enters the rail — no more submit-time failure rolling the rail
   // never enters the rail — no more submit-time failure rolling the rail
@@ -466,25 +244,67 @@ export function InputBar({
 
 
   const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
   const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
 
 
-  const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
-    // Any caret/selection gesture ends a live paste attempt (the machine
-    // cannot observe DOM selection). Cheap no-op when none is live.
-    if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
-    void e
-  }
+  // The keymap handlers read live bar state through this ref so the editor
+  // registration survives re-renders without re-arming per keystroke.
+  const gate = useRef({
+    locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages,
+  })
+  gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages }
+
+  useEffect(() => {
+    if (editor === null || keyboard === undefined) return
+    return registerComposerKeymap(editor, {
+      arbitrate: (key, composing) => keyboard.arbitrate(key, composing),
+      space: () => {
+        if (gate.current.machineBusy || gate.current.locked) return false
+        return keyboard.space()
+      },
+      dismissPopup: () => { keyboard.dismissPopup() },
+      canSubmit: () => !gate.current.locked && !gate.current.machineBusy,
+      submit: (accelerated) => {
+        const g = gate.current
+        // Empty-draft accelerated Enter acts on the queue instead of the
+        // (empty) draft: the machine rejects empty drafts, so the gesture
+        // steers every still-pending queued message into the running turn.
+        if (accelerated && g.canSteerQueue) {
+          keyboard.steerQueue()
+          return
+        }
+        keyboard.submit(g.resolveSubmitMode(
+          g.running,
+          accelerated ? 'accelerated' : 'enter',
+          g.subagent === null,
+        ))
+      },
+      intakeFiles: (files) => { gate.current.intakeImages(files) },
+      pasteText: (text) => {
+        if (gate.current.machineBusy || gate.current.locked) return
+        keyboard.paste(text)
+      },
+    })
+  }, [editor, keyboard])
 
 
-  // Button presses steal focus from the textarea; suppress at mousedown so
-  // typing continues seamlessly. `preventScroll` for the same reason as the
-  // unlock effect, and with no reveal of its own: the caret has not moved, and
-  // the next keystroke gets the browser's native one.
+  // Button presses steal focus from the editor; suppress at mousedown so
+  // typing continues seamlessly. Lexical's focus() carries preventScroll and
+  // restores the previous selection, so no reveal is needed: the caret has
+  // not moved, and the next keystroke gets the browser's native one.
   const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
   const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
     e.preventDefault()
     e.preventDefault()
-    inputRef.current?.focus({ preventScroll: true })
+    editor?.getRootElement()?.focus({ preventScroll: true })
   }
   }
 
 
   const onToggleCommandMenu = (): void => {
   const onToggleCommandMenu = (): void => {
-    const el = inputRef.current
-    if (el !== null) toggleCommandMenu?.(selectionOf(el))
+    if (keyboard !== undefined) toggleCommandMenu?.(keyboard.caretSpan())
+  }
+
+  // The no-session Workspace trigger: the resident editable div acts as the
+  // picker trigger for keyboard users (no editor is bound in this state).
+  const onWorkspaceKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
+    if (!workspaceTrigger) return
+    if (e.key === 'Enter' || e.key === ' ') {
+      e.preventDefault()
+      onRequestWorkspace()
+    }
   }
   }
 
 
   // Ordinary sessions retain their primary Send/Stop toggle. A continuable
   // Ordinary sessions retain their primary Send/Stop toggle. A continuable
@@ -510,102 +330,36 @@ export function InputBar({
     ? null
     ? null
     : <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
     : <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
 
 
-  // Mirror-layer decorations: a visible backdrop with transparent textarea
-  // text. Claim tokens and references retain the draft's own glyph metrics,
-  // so their decoration cannot drift from wrapping, selection, or the caret.
-  const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
-  const backdrop: ReactNode[] = []
-  {
-    // Segment boundaries: the token range end, every structured-reference
-    // offset, and every text-ref range — merged in draft order (the sources never
-    // overlap: structured references own their ranges, text-refs own plain tokens, the
-    // claim token only leads).
-    let cursor = 0
-    const pushPlain = (upTo: number): void => {
-      if (upTo > cursor) backdrop.push(draft.slice(cursor, upTo))
-      cursor = upTo
-    }
-    if (deco.token !== null) {
-      backdrop.push(
-        <mark key="token" className={css.hlToken} data-decoration="token">
-          {draft.slice(deco.token.start, deco.token.end)}
-        </mark>,
-      )
-      cursor = deco.token.end
-    }
-    type Boundary =
-      | { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] }
-      | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number]; ordinal: number }
-    const boundaries: Boundary[] = [
-      ...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })),
-      ...deco.textRefs.map((ref, ordinal) => ({ at: ref.start, kind: 'text-ref' as const, ref, ordinal })),
-    ].sort((a, b) => a.at - b.at)
-    for (const b of boundaries) {
-      if (b.at < cursor) continue // claim-token overlap: the leading mark wins
-      pushPlain(b.at)
-      if (b.kind === 'chip') {
-        const chip = b.chip
-        backdrop.push(
-          <span
-            key={`chip-${chip.occurrenceId}`}
-            className={clsx(css.chip, chip.invalid && css.chipInvalid)}
-            data-decoration="chip"
-            data-reference-appearance={chip.appearance}
-            data-occurrence={chip.occurrenceId}
-            data-invalid={chip.invalid || undefined}
-            title={chip.label}
-          >
-            {chip.appearance === undefined
-              ? chip.text[0]
-              : (
-                <span className={css.chipTrigger}>
-                  <span className={css.chipTriggerGlyph}>{chip.text[0]}</span>
-                  <ReferenceIcon kind={chip.appearance} size={16} className={css.chipIcon} />
-                </span>
-              )}
-            <span>{chip.text.slice(1)}</span>
-          </span>,
-        )
-        cursor = chip.offset + chip.length
-      } else {
-        // Plain-range highlight: the glyphs stay the
-        // textarea's (advance untouched); the mark paints the chip look.
-        // The key is the draft-order ordinal: a fresh scan derives these
-        // ranges every render, so none of them carries identity past its
-        // position, and a draft-offset key would unmount the mark and its
-        // icon for every character typed ahead of it. Structured references
-        // key by occurrenceId, the identity their occurrence table owns.
-        const text = draft.slice(b.ref.start, b.ref.end)
-        backdrop.push(
-          <mark key={`ref-${b.ordinal}`} className={css.textRef} data-decoration="text-ref">
-            {b.ref.appearance === 'folder'
-              ? (
-                <>
-                  <span className={css.textRefTrigger}>
-                    <span className={css.textRefTriggerGlyph}>{text[0]}</span>
-                    <ReferenceIcon kind="folder" size={16} className={css.textRefIcon} />
-                  </span>
-                  {text.slice(1)}
-                </>
-              )
-              : text}
-          </mark>,
-        )
-        cursor = b.ref.end
-      }
-    }
-    pushPlain(draft.length)
-    if (deco.hint !== null) {
-      // Claim tokens have the `/name ` format (trailing space); trim to the bare name.
-      const commandName = input?.claim?.token.slice(1).trim() ?? ''
-      const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
-      // Dynamic lookup by claimed command name: unknown commands miss the
-      // dictionary and keep the machine's own hint, so the call is wide.
-      const translated = (t as Translate)(hintKey)
-      const displayHint = translated !== hintKey ? translated : deco.hint
-      backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
-    }
-  }
+  // Claim ghost hint: rendered by CSS as generated content after the last
+  // paragraph while the claim's args are blank (a hint implies a single-line
+  // token draft). The translated per-command hint wins over the claim's own.
+  const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting')
+    && input.claim !== undefined && draft.startsWith(input.claim.token)
+  const rawHint = claimActive && input.claim?.hint !== undefined
+    && draft.slice(input.claim.token.length).trim() === ''
+    ? input.claim.hint
+    : null
+  const hint = ((): string | null => {
+    if (rawHint === null) return null
+    // Claim tokens have the `/name ` format (trailing space); trim to the bare name.
+    const commandName = input?.claim?.token.slice(1).trim() ?? ''
+    const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
+    // Dynamic lookup by claimed command name: unknown commands miss the
+    // dictionary and keep the machine's own hint, so the call is wide.
+    const translated = (t as Translate)(hintKey)
+    return translated !== hintKey ? translated : rawHint
+  })()
+
+  const placeholderText = placeholder ?? (parentOffline
+    ? t('placeholder.parentOffline')
+    : disabled
+      ? t('placeholder.unavailable')
+      // The steer hint deliberately outranks the plan placeholder:
+      // while it shows, the whole-queue gesture is genuinely available
+      // (the gate never consults plan mode), so the actionable hint wins.
+      : canSteerQueue
+        ? t('placeholder.steerQueue')
+        : planActive ? t('placeholder.plan') : t('placeholder.default'))
 
 
   return (
   return (
     <div className={clsx(css.root, variant === 'hero' && css.hero)}>
     <div className={clsx(css.root, variant === 'hero' && css.hero)}>
@@ -623,7 +377,7 @@ export function InputBar({
           {notice.text}
           {notice.text}
         </div>
         </div>
       )}
       )}
-      {/* Trigger clicks land on the card, not the textarea: the toolbar row's
+      {/* Trigger clicks land on the card, not the editor: the toolbar row's
           disabled controls swallow clicks otherwise (the CSS state disarms
           disabled controls swallow clicks otherwise (the CSS state disarms
           their pointer events), so the WHOLE capsule is the pick target.
           their pointer events), so the WHOLE capsule is the pick target.
           pointerdown stops here so the Menu's outside-close cannot race the
           pointerdown stops here so the Menu's outside-close cannot race the
@@ -647,54 +401,33 @@ export function InputBar({
             size: imageSizeText(imageLimits.maxImageBytes),
             size: imageSizeText(imageLimits.maxImageBytes),
           },
           },
         })}
         })}
-        {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
-            stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
-            absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
-            lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the
-            glyphs to the backdrop, so they can only stay together by moving together: one scroll
-            offset the browser applies to both layers at once, never a JS mirror between two boxes,
-            which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
+        {/* One scrollport, one text surface: the contenteditable grows with
+            its content and .scroll — capped at 14 lines in CSS — is the only
+            thing that scrolls. Chips are decorator portals inside the same
+            surface, so wrapping, caret geometry, and scrolling are the
+            browser's own. */}
         <div ref={scrollRef} className={css.scroll} data-input-scroll>
         <div ref={scrollRef} className={css.scroll} data-input-scroll>
           <div className={css.grow}>
           <div className={css.grow}>
-            <div
-              aria-hidden
-              className={clsx(css.backdrop, textareaDisabled && css.backdropDisabled)}
-              data-input-backdrop
-              data-disabled={textareaDisabled || undefined}
-            >
-              {backdrop}
-            </div>
-            <textarea
-              ref={inputRef}
-              className={css.input}
-              value={draft}
-              disabled={textareaDisabled}
-              readOnly={machineBusy || workspaceTrigger}
+            <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}
               aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : undefined}
               aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : undefined}
               aria-haspopup={workspaceTrigger ? 'menu' : undefined}
               aria-haspopup={workspaceTrigger ? 'menu' : undefined}
               aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
               aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
-              data-phase={input?.phase ?? 'inert'}
-              placeholder={placeholder ?? (parentOffline
-                ? t('placeholder.parentOffline')
-                : disabled
-                  ? t('placeholder.unavailable')
-                  // The steer hint deliberately outranks the plan placeholder:
-                  // while it shows, the whole-queue gesture is genuinely available
-                  // (the gate never consults plan mode), so the actionable hint wins.
-                  : canSteerQueue
-                    ? t('placeholder.steerQueue')
-                    : planActive ? t('placeholder.plan') : t('placeholder.default'))}
-              rows={2}
-              onChange={onChange}
-              onKeyDown={onKeyDown}
-              onSelect={onSelect}
-              onCopy={(e) => { onCopyOrCut(e, false) }}
-              onCut={(e) => { onCopyOrCut(e, true) }}
-              onPaste={onPaste}
-              onCompositionStart={onCompositionStart}
-              onCompositionEnd={onCompositionEnd}
+              tabIndex={workspaceTrigger ? 0 : undefined}
+              onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
+              style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
             />
             />
-            <div ref={mirrorRef} aria-hidden className={css.mirror} data-input-mirror>{`${draft}\n`}</div>
+            {empty && !claimActive && (
+              <div aria-hidden className={css.placeholder} data-composer-placeholder>
+                {placeholderText}
+              </div>
+            )}
+            <DecoratorPortals editor={workspaceTrigger ? null : editor} />
           </div>
           </div>
         </div>
         </div>
         <div className={css.row}>
         <div className={css.row}>

+ 28 - 16
packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx

@@ -1,14 +1,22 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 /** Conversation assembly acceptance independent of Tool presentation. */
 /** Conversation assembly acceptance independent of Tool presentation. */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
+import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react'
 import { useState } from 'react'
 import { useState } from 'react'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import { InputHub } from '../src/client/input/hub.ts'
 import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 usePinnedBrowserLanguages('zh-CN')
 usePinnedBrowserLanguages('zh-CN')
 
 
 const SID = 's1' as SessionId
 const SID = 's1' as SessionId
@@ -90,10 +98,10 @@ describe('resident composer', () => {
     await runtime.mount({ inject: [...inject], apply })
     await runtime.mount({ inject: [...inject], apply })
     runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
     runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
     const view = runtime.renderRoot()
     const view = runtime.renderRoot()
-    const textarea = view.container.querySelector('textarea')
+    const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')
     expect(textarea).not.toBeNull()
     expect(textarea).not.toBeNull()
-    expect(textarea!.disabled).toBe(false)
-    expect(textarea!.readOnly).toBe(true)
+    expect(textarea!.getAttribute('aria-disabled')).not.toBe('true')
+    expect(textarea!.getAttribute('contenteditable')).not.toBe('true')
     expect(textarea!.getAttribute('aria-haspopup')).toBe('menu')
     expect(textarea!.getAttribute('aria-haspopup')).toBe('menu')
     expect(view.getByTestId('workspace-probe').textContent).toBe('false:0')
     expect(view.getByTestId('workspace-probe').textContent).toBe('false:0')
     fireEvent.click(textarea!)
     fireEvent.click(textarea!)
@@ -127,11 +135,11 @@ describe('resident composer', () => {
     const root = view.container.querySelector('[data-phase="hero"]')!
     const root = view.container.querySelector('[data-phase="hero"]')!
     const scrollBody = view.container.querySelector('[data-conversation-scroll]')!
     const scrollBody = view.container.querySelector('[data-conversation-scroll]')!
     const composerSeat = view.container.querySelector('[data-composer-seat]')!
     const composerSeat = view.container.querySelector('[data-composer-seat]')!
-    const textarea = view.container.querySelector('textarea')!
+    const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
     const workspaceChip = view.getByRole('button', { name: '选择工作区' })
     const workspaceChip = view.getByRole('button', { name: '选择工作区' })
     const workspaceProbe = view.getByTestId('workspace-probe')
     const workspaceProbe = view.getByTestId('workspace-probe')
-    expect(textarea.disabled).toBe(false)
-    expect(textarea.readOnly).toBe(true)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    expect(textarea.getAttribute('contenteditable')).not.toBe('true')
 
 
     fireEvent.click(workspaceChip)
     fireEvent.click(workspaceChip)
     fireEvent.click(workspaceProbe)
     fireEvent.click(workspaceProbe)
@@ -146,12 +154,12 @@ describe('resident composer', () => {
     expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
     expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
     expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
     expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
     expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat)
     expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat)
-    expect(view.container.querySelector('textarea')).toBe(textarea)
+    expect(view.container.querySelector<HTMLDivElement>('[data-composer-input]')).toBe(textarea)
     expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip)
     expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip)
     expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe)
     expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe)
     expect(workspaceProbe.textContent).toBe('true:1')
     expect(workspaceProbe.textContent).toBe('true:1')
-    expect(textarea.disabled).toBe(false)
-    expect(textarea.readOnly).toBe(false)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    expect(textarea.getAttribute('contenteditable')).toBe('true')
     await runtime.dispose()
     await runtime.dispose()
   })
   })
 
 
@@ -161,15 +169,15 @@ describe('resident composer', () => {
       draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
       draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
     })
     })
     const view = runtime.renderRoot()
     const view = runtime.renderRoot()
-    const hero = view.container.querySelector('textarea')
+    const hero = view.container.querySelector<HTMLDivElement>('[data-composer-input]')
     expect(hero).not.toBeNull()
     expect(hero).not.toBeNull()
-    expect(hero!.disabled).toBe(false)
+    expect(hero!.getAttribute('aria-disabled')).not.toBe('true')
 
 
     await runtime.sessions.updateSnapshot(SID, (draft) => {
     await runtime.sessions.updateSnapshot(SID, (draft) => {
       draft.blank = false
       draft.blank = false
       draft.composerPhase = 'active'
       draft.composerPhase = 'active'
     })
     })
-    expect(view.container.querySelector('textarea')).toBe(hero)
+    expect(view.container.querySelector<HTMLDivElement>('[data-composer-input]')).toBe(hero)
     await runtime.dispose()
     await runtime.dispose()
   })
   })
 })
 })
@@ -197,8 +205,12 @@ describe('prompt rejection through the assembled composer', () => {
     await runtime.mount({ inject: [...inject], apply })
     await runtime.mount({ inject: [...inject], apply })
     const view = runtime.renderRoot()
     const view = runtime.renderRoot()
 
 
-    const composer = view.container.querySelector('textarea')!
-    fireEvent.change(composer, { target: { value: 'do not lose this' } })
+    const composer = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
+    // Write through the assembled input resolver (contenteditable change
+    // events carry no value; the resolver is the public draft write path).
+    const conversation = runtime.ctx.get('conversation') as { input: InputHub }
+    const shell = conversation.input.shell(SID)
+    act(() => { shell.setDraft('do not lose this') })
     fireEvent.keyDown(composer, { key: 'Enter' })
     fireEvent.keyDown(composer, { key: 'Enter' })
     await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
     await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
 
 
@@ -211,7 +223,7 @@ describe('prompt rejection through the assembled composer', () => {
     const alert = await view.findByRole('alert')
     const alert = await view.findByRole('alert')
     expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
     expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
     await waitFor(() => {
     await waitFor(() => {
-      expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
+      expect(shell.snapshot.draft).toBe('do not lose this')
     })
     })
     await runtime.dispose()
     await runtime.dispose()
   })
   })

+ 194 - 376
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -1,11 +1,16 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
-// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
-// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
-// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
-// decoration backdrop, error banners, status strips, and the focus-keeping mousedown.
+// InputBar behavior over the editor + submit-machine wiring: Enter-send
+// semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering,
+// repeat suppression), running semantics (input stays free; continuable
+// children keep Send beside Stop), the machine pending lock, chip decorators,
+// error banners, status strips, and the focus-keeping mousedown. Keyboard
+// gestures dispatch real KeyboardEvents at the contenteditable (the Lexical
+// root listener routes them through the keymap commands); draft writes drive
+// the shell (jsdom's beforeinput lacks the ranges Lexical needs).
 
 
 import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { $getRoot, $isTextNode } from 'lexical'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import {
 import {
   createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
   createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
@@ -15,6 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import { SessionInputShell } from '../src/client/input/facade.ts'
 import { SessionInputShell } from '../src/client/input/facade.ts'
+import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
 import type {
 import type {
   ComposerAttachment, ComposerAttachmentsOwnerProps,
   ComposerAttachment, ComposerAttachmentsOwnerProps,
 } from '../src/client/contract/slots.ts'
 } from '../src/client/contract/slots.ts'
@@ -32,11 +38,6 @@ afterEach(cleanup)
 const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
 const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
 Range.prototype.getBoundingClientRect = ZERO_RECT
 Range.prototype.getBoundingClientRect = ZERO_RECT
 
 
-// Read through the descriptor so the native method is never referenced unbound;
-// the reveal case below wraps it to record what it was asked to measure.
-const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
-  .value as (this: Range, node: Node, offset: number) => void
-
 const SCTX = {} as ClientContext
 const SCTX = {} as ClientContext
 const SID = 's1' as SessionId
 const SID = 's1' as SessionId
 
 
@@ -134,6 +135,7 @@ function bench(over?: BenchOptions) {
     ...(lex !== undefined
     ...(lex !== undefined
       ? {
       ? {
         inputTriggers: (() => ({
         inputTriggers: (() => ({
+          track: () => {},
           lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
           lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
         })) as unknown as NonNullable<ShellDeps['inputTriggers']>,
         })) as unknown as NonNullable<ShellDeps['inputTriggers']>,
       }
       }
@@ -201,7 +203,7 @@ function bench(over?: BenchOptions) {
     ...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
     ...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
   }
   }
   const view = render(<InputBar {...props} />)
   const view = render(<InputBar {...props} />)
-  const textarea = view.container.querySelector('textarea')!
+  const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
   const primaryStops = over?.running === true && over.subagent === undefined
   const primaryStops = over?.running === true && over.subagent === undefined
   const button = view.container.querySelector<HTMLButtonElement>(
   const button = view.container.querySelector<HTMLButtonElement>(
     `button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
     `button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
@@ -211,6 +213,8 @@ function bench(over?: BenchOptions) {
     view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
     view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
     menuLauncher,
     menuLauncher,
     steerQueue: over?.steerQueue,
     steerQueue: over?.steerQueue,
+    get placeholder() { return placeholderOf(view.container) },
+    get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' },
   }
   }
 }
 }
 
 
@@ -222,8 +226,23 @@ function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]):
   throw new Error('attachment slot was not rendered')
   throw new Error('attachment slot was not rendered')
 }
 }
 
 
+/** The state's placeholder copy (the textarea.placeholder equivalent; the visible layer renders it only while empty). */
+function placeholderOf(container: HTMLElement): string {
+  return container.querySelector('[data-composer-input]')?.getAttribute('data-placeholder') ?? ''
+}
+
+/** Whether the composer surface accepts edits right now (setEditable's DOM face). */
+function editableOf(input: HTMLElement): boolean {
+  return input.getAttribute('contenteditable') === 'true'
+}
+
+/** Write the draft through the shell inside act (the seed path; caret lands at the end). */
+function writeDraft(shell: SessionInputShell, text: string): void {
+  act(() => { shell.setDraft(text) })
+}
+
 describe('image draft rail', () => {
 describe('image draft rail', () => {
-  it('collects clipboard files while preserving text from a mixed paste', () => {
+  it('collects clipboard files while preserving text from a mixed paste', async () => {
     const addImages = vi.fn(() => null)
     const addImages = vi.fn(() => null)
     const { textarea, shell } = bench({ addImages })
     const { textarea, shell } = bench({ addImages })
     const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
     const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
@@ -237,7 +256,8 @@ describe('image draft rail', () => {
       },
       },
     })
     })
     expect(addImages).toHaveBeenCalledWith([image])
     expect(addImages).toHaveBeenCalledWith([image])
-    expect(shell.snapshot.draft).toBe('同时粘贴的文字')
+    // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away.
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') })
   })
   })
 
 
   it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
   it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
@@ -404,14 +424,14 @@ describe('image draft rail', () => {
 
 
 describe('Enter semantics', () => {
 describe('Enter semantics', () => {
   it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
   it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
-    const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
-    expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
+    const { placeholder } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
+    expect(placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
   })
   })
 
 
   it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
   it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
-    expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
-    expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
-    expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
+    expect(bench({ running: true }).placeholder).toBe('给智能体发消息')
+    expect(bench({ queue: [row('q-1')] }).placeholder).toBe('给智能体发消息')
+    expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('给智能体发消息')
     expect(bench({
     expect(bench({
       running: true,
       running: true,
       queue: [row('q-1')],
       queue: [row('q-1')],
@@ -419,26 +439,26 @@ describe('Enter semantics', () => {
         address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
         address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
         parentAvailable: true,
         parentAvailable: true,
       },
       },
-    }).textarea.placeholder).toBe('给智能体发消息')
+    }).placeholder).toBe('给智能体发消息')
     expect(bench({
     expect(bench({
       running: true,
       running: true,
       queue: [row('q-1')],
       queue: [row('q-1')],
       placeholder: '上层指定提示',
       placeholder: '上层指定提示',
-    }).textarea.placeholder).toBe('上层指定提示')
+    }).placeholder).toBe('上层指定提示')
     // The command menu owns Enter while open: neither the hint nor the
     // The command menu owns Enter while open: neither the hint nor the
     // gesture may claim the chord.
     // gesture may claim the chord.
     expect(bench({
     expect(bench({
       running: true,
       running: true,
       queue: [row('q-1')],
       queue: [row('q-1')],
       commandMenuOpen: true,
       commandMenuOpen: true,
-    }).textarea.placeholder).toBe('给智能体发消息')
+    }).placeholder).toBe('给智能体发消息')
     // The steer hint intentionally outranks the plan placeholder: while it
     // The steer hint intentionally outranks the plan placeholder: while it
     // shows, the whole-queue gesture is genuinely available in plan mode.
     // shows, the whole-queue gesture is genuinely available in plan mode.
     expect(bench({
     expect(bench({
       running: true,
       running: true,
       queue: [row('q-1')],
       queue: [row('q-1')],
       plan: { active: true, pending: false },
       plan: { active: true, pending: false },
-    }).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
+    }).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
   })
   })
 
 
   it('an open command menu withholds the whole-queue steering gesture', () => {
   it('an open command menu withholds the whole-queue steering gesture', () => {
@@ -570,11 +590,26 @@ describe('Enter semantics', () => {
 
 
   it('platform undo/redo chords route to the machine, never the browser stack', () => {
   it('platform undo/redo chords route to the machine, never the browser stack', () => {
     const { textarea, shell } = bench({ draft: '' })
     const { textarea, shell } = bench({ draft: '' })
-    fireEvent.change(textarea, { target: { value: 'first' } })
-    fireEvent.change(textarea, { target: { value: 'first second' } })
-    fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
-    expect(shell.snapshot.draft).not.toBe('first second')
-    fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
+    vi.useFakeTimers()
+    onTestFinished(() => { vi.useRealTimers() })
+    writeDraft(shell, 'first')
+    vi.advanceTimersByTime(1100) // beyond the history merge window: a separate undo step
+    act(() => {
+      shell.editor.update(() => {
+        const first = $getRoot().getAllTextNodes()[0]
+        if ($isTextNode(first)) first.select(5, 5).insertText(' second')
+      }, { discrete: true })
+    })
+    expect(shell.snapshot.draft).toBe('first second')
+    // Lexical's chord detection reads keyCode (90 = z); a history restore
+    // commits on the next flush (setEditorState defers inside the command
+    // update).
+    const flush = (): void => { act(() => { shell.editor.update(() => {}, { discrete: true }) }) }
+    fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true })
+    flush()
+    expect(shell.snapshot.draft).toBe('first')
+    fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true, shiftKey: true })
+    flush()
     expect(shell.snapshot.draft).toBe('first second')
     expect(shell.snapshot.draft).toBe('first second')
   })
   })
 
 
@@ -602,9 +637,9 @@ describe('Enter semantics', () => {
 
 
 describe('running and lock semantics', () => {
 describe('running and lock semantics', () => {
   it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
   it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
-    const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
-    expect(textarea.disabled).toBe(false)
-    fireEvent.change(textarea, { target: { value: '排队消息2' } })
+    const { textarea, button, stop, sink, shell } = bench({ running: true, draft: '排队消息' })
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    writeDraft(shell, '排队消息2')
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
     expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
     expect(button.getAttribute('aria-label')).toBe('停止生成')
     expect(button.getAttribute('aria-label')).toBe('停止生成')
@@ -643,7 +678,7 @@ describe('running and lock semantics', () => {
     })
     })
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(interruptButton).not.toBeNull()
     expect(interruptButton).not.toBeNull()
-    expect(textarea.disabled).toBe(false)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
     fireEvent.click(button)
     fireEvent.click(button)
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     fireEvent.click(interruptButton!)
     fireEvent.click(interruptButton!)
@@ -663,8 +698,8 @@ describe('running and lock semantics', () => {
         parentAvailable: false,
         parentAvailable: false,
       },
       },
     })
     })
-    expect(textarea.disabled).toBe(true)
-    expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
+    expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(button.disabled).toBe(true)
     expect(button.disabled).toBe(true)
@@ -711,8 +746,8 @@ describe('running and lock semantics', () => {
 
 
   it('disabled (session removed) locks the textarea and chrome', () => {
   it('disabled (session removed) locks the textarea and chrome', () => {
     const { textarea, view } = bench({ disabled: true })
     const { textarea, view } = bench({ disabled: true })
-    expect(textarea.disabled).toBe(true)
-    expect(textarea.placeholder).toBe('会话不可用')
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
+    expect(placeholderOf(view.container)).toBe('会话不可用')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
   })
   })
 
 
@@ -724,21 +759,22 @@ describe('running and lock semantics', () => {
     expect(empty.button.disabled).toBe(true)
     expect(empty.button.disabled).toBe(true)
   })
   })
 
 
-  it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
+  it('unlock refocuses the surface; mousedown on the button keeps focus', () => {
     const first = bench({ disabled: true, draft: 'x' })
     const first = bench({ disabled: true, draft: 'x' })
+    const textarea = first.view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
+    const focused: (boolean | undefined)[] = []
+    textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
     act(() => { first.session.set(snapshotOf({ removed: false })) })
     act(() => { first.session.set(snapshotOf({ removed: false })) })
-    const textarea = first.view.container.querySelector('textarea')!
-    expect(document.activeElement).toBe(textarea)
-    textarea.blur()
+    expect(focused).toEqual([true])
     fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
     fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
-    expect(document.activeElement).toBe(textarea)
+    expect(focused).toEqual([true, true])
   })
   })
 
 
-  it('typing forwards through the machine (draft state echoes back)', () => {
-    const { textarea, wiring } = bench()
-    fireEvent.change(textarea, { target: { value: 'typed' } })
+  it('a programmatic draft write echoes back through the state and the editor DOM', () => {
+    const { textarea, wiring, shell } = bench()
+    writeDraft(shell, 'typed')
     expect(wiring.state.getSnapshot().draft).toBe('typed')
     expect(wiring.state.getSnapshot().draft).toBe('typed')
-    expect((textarea).value).toBe('typed')
+    expect(textarea.textContent).toBe('typed')
   })
   })
 
 
   it('wheel over a non-overflowing draft forwards to the conversation host', () => {
   it('wheel over a non-overflowing draft forwards to the conversation host', () => {
@@ -792,270 +828,45 @@ describe('running and lock semantics', () => {
     }
     }
   })
   })
 
 
-  it('the caret layer and the glyph layer ride one scrollport', () => {
-    const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
+  it('one editable surface rides the scrollport (no mirror or backdrop layers)', () => {
+    const { view, textarea } = bench({ draft: 'line\n'.repeat(40).trimEnd() })
     const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
     const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
-    // The caret is the textarea's and every visible glyph is the backdrop's, so
-    // one box has to carry both or an offset can exist in one and not the other.
-    // jsdom has no layout and loads no stylesheet — which box scrolls is the
-    // browser scenario's to assert; what is checkable here is that the
-    // scrollport element holds both layers.
     expect(scroll.contains(textarea)).toBe(true)
     expect(scroll.contains(textarea)).toBe(true)
-    expect(scroll.contains(backdrop)).toBe(true)
-    // The glyph layer carries the draft and nothing else — no height padding
-    // to a second box's scroll extent.
-    expect(backdrop.textContent).toBe('line\n'.repeat(40))
-  })
-
-  it('repairs Safari native overflow after the mirror shrinks the draft', () => {
-    const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
-    const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
-      'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
-    )
-    onTestFinished(() => {
-      vendor.mockRestore()
-      userAgent.mockRestore()
-    })
-    const { textarea } = bench({ draft: 'two wrapped lines' })
-    const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
-    let inputRepaired = false
-    let scrollportRepaired = false
-    const inputLayouts: string[] = []
-    const scrollportLayouts: string[] = []
-    Object.defineProperty(textarea, 'clientHeight', {
-      configurable: true,
-      get: () => textarea.style.height === '29px' ? 29 : 28,
-    })
-    Object.defineProperty(textarea, 'scrollHeight', {
-      configurable: true,
-      get: () => inputRepaired ? 28 : 52,
-    })
-    Object.defineProperty(textarea, 'offsetHeight', {
-      configurable: true,
-      get: () => {
-        inputLayouts.push(textarea.style.height)
-        if (textarea.style.height === '') inputRepaired = true
-        return textarea.clientHeight
-      },
-    })
-    Object.defineProperty(scrollport, 'clientHeight', {
-      configurable: true,
-      get: () => {
-        if (scrollport.style.height === '53px') return 53
-        if (inputRepaired && !scrollportRepaired) return 52
-        return 28
-      },
-    })
-    Object.defineProperty(scrollport, 'offsetHeight', {
-      configurable: true,
-      get: () => {
-        scrollportLayouts.push(scrollport.style.height)
-        if (scrollport.style.height === '') scrollportRepaired = true
-        return scrollport.clientHeight
-      },
-    })
-    textarea.setSelectionRange(5, 5)
-
-    fireEvent.change(textarea, { target: { value: 'one line' } })
-
-    expect(inputLayouts).toEqual(['29px', ''])
-    expect(scrollportLayouts).toEqual(['53px', ''])
-    expect(textarea.style.height).toBe('')
-    expect(scrollport.style.height).toBe('')
-    expect(textarea.scrollHeight).toBe(textarea.clientHeight)
-    expect(scrollport.clientHeight).toBe(28)
-  })
-
-  it('does not force the Safari recovery for another iOS browser', () => {
-    const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
-    const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
-      'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
-    )
-    onTestFinished(() => {
-      vendor.mockRestore()
-      userAgent.mockRestore()
-    })
-    const { textarea } = bench({ draft: 'two wrapped lines' })
-    const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
-    Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 })
-    Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 })
-    Object.defineProperty(textarea, 'offsetHeight', {
-      configurable: true,
-      get: () => { throw new Error('non-Safari browser must not force textarea layout') },
-    })
-    Object.defineProperty(scrollport, 'offsetHeight', {
-      configurable: true,
-      get: () => { throw new Error('non-Safari browser must not force scrollport layout') },
-    })
-
-    fireEvent.change(textarea, { target: { value: 'one line' } })
-
-    expect(scrollport.style.height).toBe('')
-  })
-
-  it('does not read Safari layout while a native edit grows the draft', () => {
-    const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
-    const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
-      'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
-    )
-    onTestFinished(() => {
-      vendor.mockRestore()
-      userAgent.mockRestore()
-    })
-    const { textarea, shell } = bench({ draft: 'one line' })
-    Object.defineProperty(textarea, 'clientHeight', {
-      configurable: true,
-      get: () => { throw new Error('growing Safari input must not read layout') },
-    })
-    Object.defineProperty(textarea, 'scrollHeight', {
-      configurable: true,
-      get: () => { throw new Error('growing Safari input must not read layout') },
-    })
-
-    fireEvent.change(textarea, { target: { value: 'one line grows' } })
-
-    expect(shell.snapshot.draft).toBe('one line grows')
-  })
-
-  it('an edit the composer performs itself scrolls the caret back into view', async () => {
-    // Paste and cut suppress the native edit, so no engine reveals the caret
-    // for them. jsdom has no layout: the rects are stubbed,
-    // and what is asserted is the arithmetic — minimal scroll, in both
-    // directions, and nothing at all for a caret already inside the box.
-    const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
-    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
-    expect(mirror.firstChild).toBeInstanceOf(Text)
-    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
-    // jsdom reports scrollHeight === clientHeight for every element, which is
-    // the composer's own "nothing to reveal" case; a scrollable box is what
-    // puts the reveal on the table at all.
-    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
-    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
-    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
-    onTestFinished(() => {
-      Range.prototype.getBoundingClientRect = ZERO_RECT
-      Range.prototype.setStart = NATIVE_SET_START
-    })
-    // Which layer the caret is measured against, and at which index: the stub
-    // records `setStart` so a helper that measured the backdrop instead, or
-    // always collapsed at 0, fails here rather than only in the browser lane.
-    let measured: { node: Node; offset: number } | null = null
-    Range.prototype.setStart = function setStart(node: Node, offset: number): void {
-      measured = { node, offset }
-      NATIVE_SET_START.call(this, node, offset)
-    }
-    const caretAt = (top: number): void => {
-      Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
-    }
-    const settle = async (): Promise<void> => {
-      await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
-    }
-    // Pasted text lands below the fold: scroll down by exactly the overshoot.
-    caretAt(500)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } })
-    await settle()
-    expect(scroll.scrollTop).toBe(88) // 524 - 436
-    // Measured on the mirror's own text, at the index the paste left the caret
-    // (an empty draft's selection start, 0, plus the pasted length).
-    expect(measured!.node).toBe(mirror.firstChild)
-    expect(measured!.offset).toBe('pasted'.length)
-    // A caret already inside the box does not move it.
-    caretAt(200)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } })
-    await settle()
-    expect(scroll.scrollTop).toBe(88)
-    // Above the fold (a cut can leave it there): scroll back up.
-    caretAt(60)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } })
-    await settle()
-    expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
-    // A caret straight after a newline has nothing on its line to measure, so
-    // the newline it just left is measured instead and one line is added.
-    // chromium reports no client rects at all for the collapsed position.
-    mirror.style.lineHeight = '24px'
-    caretAt(500)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } })
-    await settle()
-    // The four pastes accumulate at the draft's head, so the caret is at the
-    // end of what they inserted — and the measured index is the newline before it.
-    expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
-    expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
-  })
-
-  it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
-    // The composer DOM is reused across sessions, so the previous session's
-    // offset survives while the value swap puts the caret at the new draft's
-    // end. `preventScroll` keeps the browser from revealing it through the
-    // conversation scrollport, which leaves the reveal to the effect itself.
-    const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
-    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
-    onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
-    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
-    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
-    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
-    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
-    Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
-    // The draft ends in a newline, so the reveal takes the after-newline path
-    // and needs a resolvable line-height (jsdom computes `normal`).
-    mirror.style.lineHeight = '24px'
-    // Which index the effect reveals at, not merely that it scrolled: a
-    // revealCaret(0) would land the same offset without this.
-    onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
-    let measured: { node: Node; offset: number } | null = null
-    Range.prototype.setStart = function setStart(node: Node, offset: number): void {
-      measured = { node, offset }
-      NATIVE_SET_START.call(this, node, offset)
-    }
+    // The three-layer stack is gone: the editable surface owns glyphs, caret,
+    // and height at once.
+    expect(view.container.querySelector('[data-input-backdrop]')).toBeNull()
+    expect(view.container.querySelector('[data-input-mirror]')).toBeNull()
+    // DOM textContent joins paragraphs without separators; the projection
+    // carries the newlines.
+    expect(textarea.textContent).toBe('line'.repeat(40))
+    expect(bench({ draft: 'a\nb' }).shell.snapshot.draft).toBe('a\nb')
+  })
+
+  it('a session switch refocuses the editable surface with preventScroll', () => {
+    const { view, textarea, props } = bench({ draft: 'line one' })
     const focused: (boolean | undefined)[] = []
     const focused: (boolean | undefined)[] = []
     textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
     textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
-    textarea.setSelectionRange(textarea.value.length, textarea.value.length)
     act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
     act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
     expect(focused).toEqual([true])
     expect(focused).toEqual([true])
-    expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
-    // The draft ends in a newline, so the rule measures that newline: the
-    // caret's own index is the mirror text's length minus its sentinel.
-    expect(measured!.node).toBe(mirror.firstChild)
-    expect(measured!.offset).toBe(textarea.value.length - 1)
-  })
-
-  it('a persisted draft adopted after mount gets its caret revealed too', () => {
-    // ConversationSession seeds the stored draft in its own mount effect, which
-    // runs after this component's: the first reveal measures an empty mirror,
-    // so the draft's arrival has to run it again without reclaiming focus.
-    const { view, textarea, shell } = bench()
-    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
-    // The restored draft ends in a newline, so the reveal takes the
-    // after-newline path and needs a resolvable line-height (jsdom says `normal`).
-    mirror.style.lineHeight = '24px'
-    onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
-    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
-    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
-    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
-    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
-    Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
+  })
+
+  it('a persisted draft adopted after mount does not steal focus from another control', () => {
+    const { shell } = bench()
     const other = document.createElement('input')
     const other = document.createElement('input')
     document.body.appendChild(other)
     document.body.appendChild(other)
     onTestFinished(() => { other.remove() })
     onTestFinished(() => { other.remove() })
     other.focus()
     other.focus()
-    expect(scroll.scrollTop).toBe(0)
     act(() => { shell.setDraft('restored\n'.repeat(40)) })
     act(() => { shell.setDraft('restored\n'.repeat(40)) })
     expect(document.activeElement).toBe(other)
     expect(document.activeElement).toBe(other)
-    // The caret the machine left at the draft's end, revealed once the draft exists.
-    expect(textarea.selectionStart).toBe(textarea.value.length)
-    expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
+    expect(shell.snapshot.draft).toBe('restored\n'.repeat(40))
   })
   })
 
 
   it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
   it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
-    const { textarea } = bench({ disabled: true })
-    expect(textarea.placeholder).toBe('会话不可用')
+    expect(bench({ disabled: true }).placeholder).toBe('会话不可用')
     const live = bench()
     const live = bench()
-    expect(live.textarea.placeholder).toBe('给智能体发消息')
+    expect(live.placeholder).toBe('给智能体发消息')
     const custom = bench({ placeholder: 'Custom placeholder' })
     const custom = bench({ placeholder: 'Custom placeholder' })
-    expect(custom.textarea.placeholder).toBe('Custom placeholder')
+    expect(custom.placeholder).toBe('Custom placeholder')
   })
   })
 
 
   it('the inert textarea opens the Workspace picker by pointer or keyboard', () => {
   it('the inert textarea opens the Workspace picker by pointer or keyboard', () => {
@@ -1066,8 +877,8 @@ describe('running and lock semantics', () => {
       onRequestWorkspace,
       onRequestWorkspace,
       placeholder: '选择一个工作区开始',
       placeholder: '选择一个工作区开始',
     })
     })
-    expect(textarea.disabled).toBe(false)
-    expect(textarea.readOnly).toBe(true)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    expect(editableOf(textarea)).toBe(false)
     expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
     expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
     expect(textarea.getAttribute('aria-expanded')).toBe('false')
     expect(textarea.getAttribute('aria-expanded')).toBe('false')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
@@ -1094,16 +905,16 @@ describe('running and lock semantics', () => {
 
 
   it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
   it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
     const active = bench({ plan: { active: true, pending: false } })
     const active = bench({ plan: { active: true, pending: false } })
-    expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
+    expect(active.placeholder).toBe('描述你的任务以生成计划')
     // /plan just ran: pending entry already reads as the plan target.
     // /plan just ran: pending entry already reads as the plan target.
     const entering = bench({ plan: { active: false, pending: true } })
     const entering = bench({ plan: { active: false, pending: true } })
-    expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
+    expect(entering.placeholder).toBe('描述你的任务以生成计划')
     // Pending exit: target is default again.
     // Pending exit: target is default again.
     const leaving = bench({ plan: { active: true, pending: true } })
     const leaving = bench({ plan: { active: true, pending: true } })
-    expect(leaving.textarea.placeholder).toBe('给智能体发消息')
+    expect(leaving.placeholder).toBe('给智能体发消息')
     // Owner placeholder outranks the plan swap.
     // Owner placeholder outranks the plan swap.
     const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
     const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
-    expect(custom.textarea.placeholder).toBe('Custom placeholder')
+    expect(custom.placeholder).toBe('Custom placeholder')
   })
   })
 })
 })
 
 
@@ -1123,34 +934,40 @@ describe('machine pending lock', () => {
       shell.submit()
       shell.submit()
     })
     })
     expect(shell.snapshot.phase).toBe('submitting')
     expect(shell.snapshot.phase).toBe('submitting')
-    const textarea = view.container.querySelector('textarea')!
-    expect(textarea.readOnly).toBe(true)
+    const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
+    expect(editableOf(textarea)).toBe(false)
     expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
     expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
   })
   })
 })
 })
 
 
 describe('decorations', () => {
 describe('decorations', () => {
-  it('claimed token renders the mirror highlight and the blank-args hint', () => {
+  /** The claim-token styled leaf (the transform's inline warn color). */
+  function tokenSpanOf(container: HTMLElement): HTMLElement | null {
+    return container.querySelector('[data-lexical-text][style*="warn-label"]')
+  }
+
+  it('claimed token styles the leading leaf and sets the blank-args hint variable', () => {
     // Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
     // Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
-    const { view, shell } = bench({ t: makeTranslate({}) })
+    const { view, shell, textarea } = bench({ t: makeTranslate({}) })
     act(() => {
     act(() => {
       shell.setDraft('/goal ')
       shell.setDraft('/goal ')
       shell.beginCommand(
       shell.beginCommand(
         { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
         { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
       )
       )
+      shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh
     })
     })
-    const token = view.container.querySelector('[data-decoration="token"]')
-    expect(token?.textContent).toBe('/goal ')
-    expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
-    // Args typed: the hint disappears, the token highlight stays.
+    expect(tokenSpanOf(view.container)?.textContent).toBe('/goal ')
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('目标内容'))
+    // Args typed: the hint disappears, the token style stays.
     act(() => { shell.setDraft('/goal 发布') })
     act(() => { shell.setDraft('/goal 发布') })
-    expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
-    expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
+    act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
+    expect(tokenSpanOf(view.container)).not.toBeNull()
   })
   })
 
 
   it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
   it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
-    const { view, shell } = bench()
+    const { shell, textarea } = bench()
     act(() => {
     act(() => {
       shell.setDraft('/goal ')
       shell.setDraft('/goal ')
       shell.beginCommand(
       shell.beginCommand(
@@ -1158,10 +975,10 @@ describe('decorations', () => {
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
       )
       )
     })
     })
-    expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
   })
   })
 
 
-  it('an inserted reference decorates its complete inline display range', () => {
+  it('an inserted reference renders a real chip capsule with its icon and label', () => {
     const { view, shell } = bench()
     const { view, shell } = bench()
     const reference = {
     const reference = {
       source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
       source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
@@ -1173,115 +990,115 @@ describe('decorations', () => {
         { start: 3, end: 6, draftRev: shell.snapshot.draftRev },
         { start: 3, end: 6, draftRev: shell.snapshot.draftRev },
       )
       )
     })
     })
-    const chip = view.container.querySelector('[data-decoration="chip"]')
-    expect(chip?.textContent).toBe('@会话一')
-    expect(chip?.getAttribute('data-reference-appearance')).toBe('session')
+    const chip = view.container.querySelector('[data-composer-chip]')
+    expect(chip?.textContent).toBe('会话一')
     expect(chip?.querySelector('svg')).not.toBeNull()
     expect(chip?.querySelector('svg')).not.toBeNull()
+    expect(chip?.getAttribute('contenteditable')).toBe('false')
     expect(shell.snapshot.occurrences).toHaveLength(1)
     expect(shell.snapshot.occurrences).toHaveLength(1)
-    expect(shell.snapshot.draft).toBe('参考 @会话一 内容')
-    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 4 })
+    // The draft IS the clipboard projection; the label lives in the chip DOM.
+    expect(shell.snapshot.draft).toBe('参考 @w1 内容')
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 3 })
   })
   })
 
 
-  it('keeps the textarea glyph layer transparent when a structured reference becomes disabled', () => {
+  it('keeps the chip decorator mounted when the session becomes disabled', () => {
     const { view, shell, session, textarea } = bench()
     const { view, shell, session, textarea } = bench()
     act(() => {
     act(() => {
       shell.setDraft('@w1')
       shell.setDraft('@w1')
       shell.insertReference({
       shell.insertReference({
         source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
         source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
       }, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
       }, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
-      session.set(snapshotOf({ removed: true }))
     })
     })
-    const backdrop = view.container.querySelector('[data-input-backdrop]')
-    expect(textarea.disabled).toBe(true)
-    expect(backdrop?.getAttribute('data-disabled')).toBe('true')
-    expect(backdrop?.querySelector('[data-decoration="chip"] svg')).not.toBeNull()
+    const chip = view.container.querySelector('[data-composer-chip]')
+    expect(chip?.querySelector('svg')).not.toBeNull()
+    act(() => { session.set(snapshotOf({ removed: true })) })
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
+    expect(view.container.querySelector('[data-composer-chip]')).toBe(chip)
   })
   })
 
 
-  it('Backspace and Delete remove a reference as one range at its boundaries', () => {
-    const reference = {
-      source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
-    }
-    const backspace = bench()
+  it('a chip leaves the document as one unit (keyboard deletion is asserted in the browser lane)', () => {
+    // jsdom lacks the non-standard Selection.modify Lexical's character
+    // deletion crosses nodes with, so the Backspace/Delete gesture itself is
+    // an e2e assertion; the structural unit — one span covering the chip
+    // removes the whole occurrence — is checkable here.
+    const { shell } = bench()
     act(() => {
     act(() => {
-      backspace.shell.setDraft('前 @w1 后')
-      backspace.shell.insertReference(
-        reference,
-        { start: 2, end: 5, draftRev: backspace.shell.snapshot.draftRev },
-      )
+      shell.setDraft('前 @w1 后')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
     })
     })
-    backspace.textarea.setSelectionRange(6, 6)
-    fireEvent.keyDown(backspace.textarea, { key: 'Backspace' })
-    expect(backspace.shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
-
-    const forwardDelete = bench()
+    expect(shell.snapshot.occurrences).toHaveLength(1)
     act(() => {
     act(() => {
-      forwardDelete.shell.setDraft('前 @w1 后')
-      forwardDelete.shell.insertReference(
-        reference,
-        { start: 2, end: 5, draftRev: forwardDelete.shell.snapshot.draftRev },
-      )
+      shell.editor.update(() => {
+        expect($replaceDetectSpanWithText({ start: 2, end: 3 }, '')).toBe(true)
+      }, { discrete: true })
     })
     })
-    forwardDelete.textarea.setSelectionRange(2, 2)
-    fireEvent.keyDown(forwardDelete.textarea, { key: 'Delete' })
-    expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
+    expect(shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
   })
   })
 
 
-  it('copy and cut expand a partial reference selection to its structured range', () => {
+  it('copy and cut expand a selected chip to its clipboard projection natively', async () => {
     const { shell, textarea } = bench()
     const { shell, textarea } = bench()
     act(() => {
     act(() => {
       shell.setDraft('前 @w1 后')
       shell.setDraft('前 @w1 后')
       shell.insertReference({
       shell.insertReference({
         source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
         source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
       }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
       }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
+      // Select the chip plus its flanking spaces: detect [1, 4).
+      shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
     })
     })
     const setData = vi.fn()
     const setData = vi.fn()
-    textarea.setSelectionRange(3, 4)
-    fireEvent.copy(textarea, { clipboardData: { setData } })
-    expect(setData).toHaveBeenCalledWith('text/plain', '@w1')
-    expect(shell.snapshot.draft).toBe('前 @会话一 后')
-
-    textarea.setSelectionRange(3, 4)
-    fireEvent.cut(textarea, { clipboardData: { setData } })
-    expect(setData).toHaveBeenLastCalledWith('text/plain', '@w1')
-    expect(shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
+    fireEvent.copy(textarea, { clipboardData: { setData, getData: () => '' } })
+    expect(setData).toHaveBeenCalledWith('text/plain', ' @w1 ')
+    expect(shell.snapshot.draft).toBe('前 @w1 后')
+
+    act(() => {
+      shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
+    })
+    fireEvent.cut(textarea, { clipboardData: { setData, getData: () => '' } })
+    await vi.waitFor(() => {
+      expect(shell.snapshot).toMatchObject({ draft: '前后', occurrences: [] })
+    })
+    expect(setData).toHaveBeenLastCalledWith('text/plain', ' @w1 ')
   })
   })
 
 
-  it('a lexicon-matched plain token renders the text-ref mark', () => {
+  it('a lexicon-matched plain token renders the text-ref node', () => {
     const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
     const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
     const { view, shell } = bench({ lexicon })
     const { view, shell } = bench({ lexicon })
     act(() => { shell.setDraft('use /fixture-demo now') })
     act(() => { shell.setDraft('use /fixture-demo now') })
-    const mark = view.container.querySelector('[data-decoration="text-ref"]')
+    const mark = view.container.querySelector('[data-composer-text-ref]')
     expect(mark?.textContent).toBe('/fixture-demo')
     expect(mark?.textContent).toBe('/fixture-demo')
     // Editing the token out of match shape drops the decoration.
     // Editing the token out of match shape drops the decoration.
     act(() => { shell.setDraft('use /fixture-dem now') })
     act(() => { shell.setDraft('use /fixture-dem now') })
-    expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
+    expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
   })
   })
 
 
-  it('a directory completion renders a folder glyph without changing its plain text', () => {
+  it('a directory completion carries the folder appearance without changing its plain text', () => {
     const { view, shell } = bench()
     const { view, shell } = bench()
     act(() => { shell.setDraft('see @src/components/') })
     act(() => { shell.setDraft('see @src/components/') })
-    const mark = view.container.querySelector('[data-decoration="text-ref"]')
+    const mark = view.container.querySelector('[data-composer-text-ref]')
     expect(mark?.textContent).toBe('@src/components/')
     expect(mark?.textContent).toBe('@src/components/')
-    expect(mark?.querySelector('svg')).not.toBeNull()
+    expect(mark?.getAttribute('data-ref-appearance')).toBe('folder')
     expect(shell.snapshot.draft).toBe('see @src/components/')
     expect(shell.snapshot.draft).toBe('see @src/components/')
   })
   })
 
 
-  it('a plain-text reference keeps its nodes while earlier text shifts its offset', () => {
-    const { view, textarea, shell } = bench()
+  it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {
+    const { view, shell } = bench()
     act(() => { shell.setDraft('see @src/components/ here') })
     act(() => { shell.setDraft('see @src/components/ here') })
-    const backdrop = view.container.querySelector('[data-input-backdrop]')!
-    const mark = backdrop.querySelector('[data-decoration="text-ref"]')!
-    const icon = mark.querySelector('svg')!
-    act(() => { fireEvent.change(textarea, { target: { value: 'X see @src/components/ here' } }) })
-    // Node identity, not text: an offset-derived key remounts the mark and its
-    // icon on every keystroke landing ahead of the range.
-    expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBe(mark)
-    expect(icon.isConnected).toBe(true)
+    const mark = view.container.querySelector('[data-composer-text-ref]')!
+    // Type ahead through the node API (the transaction path typing takes).
+    act(() => {
+      shell.editor.update(() => {
+        const first = $getRoot().getAllTextNodes()[0]
+        if ($isTextNode(first)) first.spliceText(0, 0, 'X ')
+      }, { discrete: true })
+    })
+    // Node identity, not text: the entity node survives edits ahead of it.
+    expect(view.container.querySelector('[data-composer-text-ref]')).toBe(mark)
     expect(mark.textContent).toBe('@src/components/')
     expect(mark.textContent).toBe('@src/components/')
+    expect(shell.snapshot.draft).toBe('X see @src/components/ here')
     // A token edited out of match shape still loses its decoration.
     // A token edited out of match shape still loses its decoration.
-    act(() => { fireEvent.change(textarea, { target: { value: 'X see X@src/components/ here' } }) })
-    expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBeNull()
-    expect(shell.snapshot.draft).toBe('X see X@src/components/ here')
+    act(() => { shell.setDraft('X see X@src/components/ here') })
+    expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
   })
   })
 })
 })
 
 
@@ -1364,8 +1181,9 @@ describe('command launcher chrome and control seats', () => {
     expect(view.getByLabelText('命令')).toBeTruthy()
     expect(view.getByLabelText('命令')).toBeTruthy()
     // Capability absent (no projection value): the chip renders nothing.
     // Capability absent (no projection value): the chip renders nothing.
     expect(view.queryByLabelText(/^访问模式/)).toBeNull()
     expect(view.queryByLabelText(/^访问模式/)).toBeNull()
-    // Every seat dispatched, nothing rendered.
-    expect(slotCalls.map(c => c.key)).toEqual([
+    // Every seat dispatched, nothing rendered (render passes may repeat; the
+    // seat set is the contract).
+    expect([...new Set(slotCalls.map(c => c.key))]).toEqual([
       'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
       'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
     ])
     ])
     expect(view.queryByLabelText('Plan mode')).toBeNull()
     expect(view.queryByLabelText('Plan mode')).toBeNull()
@@ -1374,8 +1192,8 @@ describe('command launcher chrome and control seats', () => {
 
 
   it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
   it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
     const toggleCommandMenu = vi.fn()
     const toggleCommandMenu = vi.fn()
-    const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
-    textarea.setSelectionRange(2, 7)
+    const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
+    act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
     const launcher = view.getByLabelText('命令')
     const launcher = view.getByLabelText('命令')
     expect(launcher.getAttribute('aria-expanded')).toBe('false')
     expect(launcher.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(launcher)
     fireEvent.click(launcher)

+ 0 - 927
packages/client/ui-conversation/tests/input-machine.client.spec.ts

@@ -1,927 +0,0 @@
-/**
- * InputMachine unit account: the submit
- * plane (adjudication, span CAS, drift
- * guard, anti-backwash), plus the occurrence table (shift / whole-chip
- * deletion / same-name independence), the self-managed undo log (typing
- * coalescing, paste two-stage undo, redo chain), consume-token guards, the
- * paste attempt lifecycle, projectClipboard, and the decoration projection.
- * Pure event sequences — no React, no DOM, no ambient clock.
- */
-import { describe, expect, it } from 'vitest'
-import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
-import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
-import {
-  InputMachine, PLACEHOLDER, projectClipboard, referenceDraftText,
-} from '../src/client/input/machine.ts'
-import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts'
-
-const LEGACY_PLACEHOLDER = PLACEHOLDER
-
-function claimOf(name: string, hint?: string): CommandClaim {
-  return {
-    token: `/${name} `,
-    ...(hint !== undefined ? { hint } : {}),
-    submit: async () => ({ kind: 'success' }),
-  }
-}
-
-function refOf(name: string, source = 'skill'): ReferenceInsert {
-  return { source, ref: name, label: name, clipboardText: `/${name}` }
-}
-
-function spanOf(m: InputMachine, start: number, end: number): TokenSpan {
-  return { start, end, draftRev: m.state.draftRev }
-}
-
-function effectAt<T extends InputEffect['type']>(
-  effects: readonly InputEffect[], index: number, type: T,
-): Extract<InputEffect, { type: T }> {
-  const e = effects[index]
-  expect(e?.type).toBe(type)
-  return e as Extract<InputEffect, { type: T }>
-}
-
-/** Drive plain → adjudicating and hand back the minted attempt. */
-function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
-  m.dispatch({ type: 'draft-changed', draft })
-  const fx = m.dispatch({ type: 'enter', mode })
-  return effectAt(fx, 0, 'adjudicate').attempt
-}
-
-/** Drive plain → claimed → submitting and hand back attempt + claim. */
-function enterSubmitting(m: InputMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
-  const claim = claimOf(name)
-  m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
-  m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
-  m.dispatch({ type: 'draft-changed', draft: claim.token + args })
-  const fx = m.dispatch({ type: 'enter', mode: 'queue' })
-  return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
-}
-
-function staleAttempt(): SubmitAttempt {
-  return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
-}
-
-describe('input-machine: plain × enter', () => {
-  it('empty and whitespace-only drafts produce nothing', () => {
-    const m = new InputMachine()
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    m.dispatch({ type: 'draft-changed', draft: '  \n ' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-  })
-
-  it('non-command text falls to the default sink', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'hello world' })
-    const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' })
-    expect(effect.attempt.draftSnapshot).toBe('hello world')
-    expect(m.state.phase).toBe('submitting')
-  })
-
-  it('retains an explicit steer mode on the default sink effect', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'steer now' })
-    expect(effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink'))
-      .toMatchObject({ draft: 'steer now', mode: 'steer' })
-  })
-
-  it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/goal x' })
-    const fx = m.dispatch({ type: 'enter', mode: 'queue' })
-    const eff = effectAt(fx, 0, 'adjudicate')
-    expect(eff.draft).toBe('/goal x')
-    expect(eff.attempt.draftSnapshot).toBe('/goal x')
-    expect(eff.attempt.signal.aborted).toBe(false)
-    expect(m.state.phase).toBe('adjudicating')
-  })
-
-  it('leading is judged after trim including newlines', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
-  })
-
-  it('a non-whitespace prefix before "/" is not leading — default sink', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
-    expect(effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink'))
-      .toMatchObject({ draft: '第一行\n/goal x', mode: 'queue' })
-  })
-})
-
-describe('input-machine: adjudication outcomes', () => {
-  it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/goal x\ny')
-    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
-    const eff = effectAt(fx, 0, 'begin-submit')
-    expect(eff.args).toBe('x\ny')
-    expect(eff.attempt.seq).toBe(attempt.seq)
-    expect(m.state.phase).toBe('submitting')
-    expect(m.state.claim).toEqual({ token: '/goal ' })
-  })
-
-  it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
-    const a = new InputMachine()
-    const attemptA = enterAdjudicating(a, '/goal')
-    expect(effectAt(a.dispatch({ type: 'adjudicated', attempt: attemptA, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('')
-
-    const b = new InputMachine()
-    const attemptB = enterAdjudicating(b, '\n\n/goal x')
-    expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
-  })
-
-  it('undefined outcome falls back to the default sink', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
-    expect(effectAt(
-      m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }),
-      0,
-      'default-sink',
-    )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' })
-    expect(m.state.phase).toBe('submitting')
-  })
-
-  it("'handled' lands plain with zero effects (popup shell path)", () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/model')
-    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.draft).toBe('/model')
-  })
-
-  it('adjudication failure notices and keeps the draft — no silent downgrade', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/goal x')
-    expect(m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' }))
-      .toEqual([{ type: 'notice', level: 'error', text: 'warmup failed' }])
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.draft).toBe('/goal x')
-  })
-
-  it('enter is a no-op while adjudicating (pending lock)', () => {
-    const m = new InputMachine()
-    enterAdjudicating(m, '/goal x')
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    expect(m.state.phase).toBe('adjudicating')
-  })
-
-  it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
-    const m = new InputMachine()
-    enterAdjudicating(m, '/goal x')
-    expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: { claim: claimOf('goal') } })).toEqual([])
-    expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
-    expect(m.state.phase).toBe('adjudicating')
-  })
-
-  it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/goal x')
-    m.dispatch({ type: 'release' })
-    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-  })
-})
-
-describe('input-machine: begin-command CAS', () => {
-  it('valid span replaces it with the token and enters claimed; success = draftRev advance', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    const before = m.state.draftRev
-    const fx = m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
-    expect(fx).toEqual([])
-    expect(m.state.draftRev).toBeGreaterThan(before)
-    expect(m.state.draft).toBe('/goal ')
-    expect(m.state.phase).toBe('claimed')
-    expect(m.state.claim).toEqual({ token: '/goal ', hint: 'objective' })
-  })
-
-  it('a leading-whitespace prefix is dropped so the startsWith watch holds', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '\n\n/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })
-    expect(m.state.draft).toBe('/goal ')
-    m.dispatch({ type: 'draft-changed', draft: '/goal x' })
-    expect(m.state.phase).toBe('claimed')
-  })
-
-  it('a stale draftRev no-ops the whole action — no state change, no revision bump', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    const span = spanOf(m, 0, 3)
-    m.dispatch({ type: 'draft-changed', draft: '/goX' })
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span })).toEqual([])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: '/goX', draftRev: rev })
-  })
-
-  it('a non-whitespace prefix before the span no-ops (leading-trigger contract)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'x /go' })
-    expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-  })
-
-  it('claimed overwrites in place — no stack', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })
-    expect(m.state.draft).toBe('/model ')
-    expect(m.state.claim?.token).toBe('/model ')
-    expect(m.state.phase).toBe('claimed')
-  })
-
-  it('submitting rejects begin-command (lock)', () => {
-    const m = new InputMachine()
-    enterSubmitting(m, 'goal', 'x')
-    expect(m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })).toEqual([])
-    expect(m.state.claim?.token).toBe('/goal ')
-    expect(m.state.phase).toBe('submitting')
-  })
-
-  it('undo reverts the claim transaction and the watch releases the claim', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'undo' })
-    expect(m.state).toMatchObject({ draft: '/go', phase: 'plain' })
-    expect(m.state.claim).toBeUndefined()
-  })
-})
-
-describe('input-machine: insert-ref and the occurrence table', () => {
-  it('valid span becomes one inline display range + one occurrence with cached projections', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
-    const reference = { ...refOf('worker-1', 'reference'), appearance: 'session' as const }
-    const fx = m.dispatch({
-      type: 'insert-ref',
-      reference,
-      span: spanOf(m, 4, 8),
-    })
-    expect(fx).toEqual([])
-    const displayText = referenceDraftText(reference)
-    expect(m.state.draft).toBe(`see ${displayText} now`)
-    expect(m.state.occurrences).toEqual([{
-      occurrenceId: 1, source: 'reference', ref: 'worker-1', offset: 4,
-      length: displayText.length,
-      label: 'worker-1', appearance: 'session', clipboardText: '/worker-1',
-    }])
-    expect(m.state.phase).toBe('plain')
-  })
-
-  it('same-named references stay independent: distinct occurrenceIds, one deletion leaves the other', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
-    const displayText = referenceDraftText(refOf('alpha'))
-    const secondDraft = `${displayText} and /alp`
-    const secondStart = secondDraft.lastIndexOf('/alp')
-    m.dispatch({
-      type: 'draft-changed',
-      draft: secondDraft,
-      editRange: { start: displayText.length, end: displayText.length + 1, insertedLength: ' and /alp'.length },
-    })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, secondStart, secondStart + 4) })
-    expect(m.state.draft).toBe(`${displayText} and ${displayText} `)
-    expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
-    // Delete the first reference range whole; the second survives with its own identity.
-    m.dispatch({
-      type: 'draft-changed',
-      draft: ` and ${displayText} `,
-      editRange: { start: 0, end: displayText.length, insertedLength: 0 },
-    })
-    expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
-  })
-
-  it('claimed stays claimed across an inline insert (inline "@" during command args)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
-    expect(m.state.draft).toBe(`/goal ask ${referenceDraftText(refOf('worker-1'))} `)
-    expect(m.state.phase).toBe('claimed')
-    expect(m.state.occurrences).toHaveLength(1)
-  })
-
-  it('a stale draftRev no-ops: no draft change, no occurrence', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'see @wor' })
-    const span = spanOf(m, 4, 8)
-    m.dispatch({ type: 'draft-changed', draft: 'see @work' })
-    expect(m.dispatch({ type: 'insert-ref', reference: refOf('w'), span })).toEqual([])
-    expect(m.state.occurrences).toEqual([])
-  })
-})
-
-describe('input-machine: occurrence reconciliation on draft edits', () => {
-  /** Machine with one reference range at offset 4 inside `see @worker-1 now`. */
-  function withChip(): InputMachine {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
-    return m
-  }
-
-  it('an edit before the reference shifts the offset by the length delta (explicit editRange)', () => {
-    const m = withChip()
-    m.dispatch({ type: 'draft-changed', draft: `I ${m.state.draft}`, editRange: { start: 0, end: 0, insertedLength: 2 } })
-    expect(m.state.occurrences[0]?.offset).toBe(6)
-    m.dispatch({ type: 'draft-changed', draft: m.state.draft.slice(2), editRange: { start: 0, end: 2, insertedLength: 0 } })
-    expect(m.state.occurrences[0]?.offset).toBe(4)
-  })
-
-  it('an edit after the reference leaves the offset alone', () => {
-    const m = withChip()
-    const oldDraft = m.state.draft
-    const start = oldDraft.indexOf('now')
-    m.dispatch({
-      type: 'draft-changed',
-      draft: oldDraft.replace('now', 'later'),
-      editRange: { start, end: start + 3, insertedLength: 5 },
-    })
-    expect(m.state.occurrences[0]?.offset).toBe(4)
-  })
-
-  it('a deletion covering the reference removes the whole occurrence', () => {
-    const m = withChip()
-    const occurrence = m.state.occurrences[0]!
-    m.dispatch({
-      type: 'draft-changed',
-      draft: m.state.draft.slice(0, occurrence.offset) + m.state.draft.slice(occurrence.offset + occurrence.length),
-      editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 0 },
-    })
-    expect(m.state.occurrences).toEqual([])
-    expect(m.state.draft).toBe('see  now')
-  })
-
-  it('a replacement spanning the reference removes the occurrence and keeps the replacement text', () => {
-    const m = withChip()
-    const occurrence = m.state.occurrences[0]!
-    m.dispatch({
-      type: 'draft-changed',
-      draft: 'see all of it now',
-      editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 9 },
-    })
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => {
-    const m = withChip()
-    m.dispatch({ type: 'draft-changed', draft: m.state.draft.replace('see ', 'see there ') })
-    expect(m.state.occurrences[0]?.offset).toBe(10)
-  })
-
-  it('without editRange the diff scan detects reference deletion', () => {
-    const m = withChip()
-    m.dispatch({ type: 'draft-changed', draft: 'see now' })
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('an identical draft is a no-op: no revision bump, no undo entry', () => {
-    const m = withChip()
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'draft-changed', draft: m.state.draft })).toEqual([])
-    expect(m.state.draftRev).toBe(rev)
-  })
-})
-
-describe('input-machine: consume-token guards', () => {
-  it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/model rest' })
-    const before = m.state.draftRev
-    m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
-    expect(m.state.draftRev).toBeGreaterThan(before)
-    expect(m.state.draft).toBe('rest')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('/model rest')
-  })
-
-  it('span guard: a stale draftRev refuses — no deletion, no revision bump', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/model' })
-    const span = spanOf(m, 0, 6)
-    m.dispatch({ type: 'draft-changed', draft: '/model x' })
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'consume-token', guard: { kind: 'span', span } })).toEqual([])
-    expect(m.state).toMatchObject({ draft: '/model x', draftRev: rev })
-  })
-
-  it('bare-token guard: trimmed equality clears the draft; mismatch refuses', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '  /model \n' })
-    m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })
-    expect(m.state.draft).toBe('')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('  /model \n')
-
-    m.dispatch({ type: 'draft-changed', draft: '/model extra' })
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })).toEqual([])
-    expect(m.state).toMatchObject({ draft: '/model extra', draftRev: rev })
-  })
-
-  it('a chip elsewhere in the draft shifts across a span consume', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
-    m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
-    expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
-    expect(m.state.occurrences[0]?.offset).toBe(0)
-  })
-})
-
-describe('input-machine: undo / redo', () => {
-  it('the default constant clock coalesces contiguous single-char typing into one transaction', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-    m.dispatch({ type: 'redo' })
-    expect(m.state.draft).toBe('abc')
-  })
-
-  it('the merge window splits typing runs: within merges, beyond opens a new transaction', () => {
-    let t = 0
-    const m = new InputMachine({ mergeWindowMs: 1000, now: () => t })
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    t = 900
-    m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
-    t = 2500 // beyond the window from the previous char
-    m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('ab')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('non-contiguous or multi-char edits never merge into a typing run', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'ba', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'baXY', editRange: { start: 2, end: 2, insertedLength: 2 } })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('ba')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('a')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('a new transaction cuts the redo chain', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'undo' })
-    m.dispatch({ type: 'draft-changed', draft: 'z', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    expect(m.dispatch({ type: 'redo' })).toEqual([])
-    expect(m.state.draft).toBe('z')
-  })
-
-  it('undo on an empty log and redo on an empty chain are no-ops', () => {
-    const m = new InputMachine()
-    expect(m.dispatch({ type: 'undo' })).toEqual([])
-    expect(m.dispatch({ type: 'redo' })).toEqual([])
-  })
-
-  it('the log ring caps at 100 transactions', () => {
-    let t = 0
-    const m = new InputMachine({ mergeWindowMs: 0, now: () => (t += 10) })
-    let draft = ''
-    for (let i = 0; i < 110; i += 1) {
-      draft += 'x'
-      m.dispatch({ type: 'draft-changed', draft, editRange: { start: i, end: i, insertedLength: 1 } })
-    }
-    for (let i = 0; i < 100; i += 1) m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('x'.repeat(10))
-    expect(m.dispatch({ type: 'undo' })).toEqual([])
-    expect(m.state.draft).toBe('x'.repeat(10))
-  })
-
-  it('undo restores the occurrence table with the draft (chip resurrection)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '@wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) })
-    m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: m.state.draft.length, insertedLength: 0 } })
-    expect(m.state.occurrences).toEqual([])
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
-    expect(m.state.occurrences).toHaveLength(1)
-  })
-
-  it('a committed submit clears the log: undo cannot resurrect sent content', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    m.dispatch({ type: 'submit-settled', attempt, ok: true })
-    expect(m.state.draft).toBe('')
-    expect(m.dispatch({ type: 'undo' })).toEqual([])
-    expect(m.state.draft).toBe('')
-  })
-
-  it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'hello' })
-    const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    m.dispatch({ type: 'draft-changed', draft: 'hello world' })
-    m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true })
-    expect(m.state.draft).toBe(' world')
-
-    const n = new InputMachine()
-    n.dispatch({ type: 'draft-changed', draft: 'hello' })
-    const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    n.dispatch({ type: 'draft-changed', draft: 'hXello' })
-    n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true })
-    expect(n.state.draft).toBe('')
-  })
-})
-
-describe('input-machine: paste plane', () => {
-  it('paste replaces the selection as one transaction and opens a match attempt', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'abc' })
-    m.dispatch({ type: 'paste-begin', text: 'XY', selection: { start: 1, end: 2 }, generation: 7 })
-    expect(m.state.draft).toBe('aXYc')
-    expect(m.state.paste).toEqual({ attemptId: 1, insertedRange: { start: 1, end: 3 }, generation: 7 })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('abc')
-  })
-
-  it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: `x${LEGACY_PLACEHOLDER}y`, selection: { start: 0, end: 0 } })
-    expect(m.state.draft).toBe('xy')
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('sync hot-snapshot components mint inside the SAME transaction: one undo returns to pre-paste', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'hi ' })
-    m.dispatch({
-      type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 },
-      components: [{ start: 0, end: 6, reference: refOf('alpha') }],
-    })
-    expect(m.state.draft).toBe(`hi ${referenceDraftText(refOf('alpha'))} x`)
-    expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })])
-    expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: m.state.draft.length })
-    m.dispatch({ type: 'undo' })
-    expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] })
-  })
-
-  it('async upgrade is an INDEPENDENT transaction: undo #1 → token text, undo #2 → pre-paste', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } })
-    expect(m.state.paste?.attemptId).toBe(1)
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
-    expect(m.state.draft).toBe(`${referenceDraftText(refOf('alpha'))} rest`)
-    expect(m.state.occurrences).toHaveLength(1)
-    m.dispatch({ type: 'undo' })
-    expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('the attempt survives upgrades: successive tokens re-CAS against the advanced revision', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
-    const alpha = referenceDraftText(refOf('alpha'))
-    expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: alpha.length + 6 })
-    const betaStart = m.state.draft.indexOf('/beta')
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, betaStart, betaStart + 5), reference: refOf('beta') })
-    expect(m.state.draft).toBe(`${alpha} ${referenceDraftText(refOf('beta'))} `)
-    expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
-    expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: m.state.draft.length })
-  })
-
-  it('a stale span CAS drops one upgrade without ending the attempt', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
-    const preSpan = spanOf(m, 7, 12)
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
-    expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: preSpan, reference: refOf('beta') })).toEqual([])
-    expect(m.state.occurrences).toHaveLength(1)
-    expect(m.state.paste).toBeDefined()
-  })
-
-  it('any new input transaction ends the attempt; late upgrades drop whole', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
-    m.dispatch({ type: 'draft-changed', draft: '/alpha!', editRange: { start: 6, end: 6, insertedLength: 1 } })
-    expect(m.state.paste).toBeUndefined()
-    expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('invalidate-paste (caret/selection/slash activity) and submit start end the attempt', () => {
-    const a = new InputMachine()
-    a.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
-    a.dispatch({ type: 'invalidate-paste' })
-    expect(a.state.paste).toBeUndefined()
-
-    const b = new InputMachine()
-    b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
-    b.dispatch({ type: 'enter', mode: 'queue' })
-    expect(b.state.paste).toBeUndefined()
-  })
-
-  it('a mismatched attemptId is dropped (superseded paste)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
-    m.dispatch({ type: 'paste-begin', text: ' /beta', selection: { start: 6, end: 6 } })
-    expect(m.state.paste?.attemptId).toBe(2)
-    expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
-    expect(m.state.occurrences).toEqual([])
-  })
-})
-
-describe('input-machine: set-invalid styling bits', () => {
-  it('flags exactly the listed occurrences without a transaction', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
-    const alpha = referenceDraftText(refOf('alpha'))
-    m.dispatch({
-      type: 'draft-changed',
-      draft: `${alpha} /bet`,
-      editRange: { start: alpha.length + 1, end: alpha.length + 1, insertedLength: 5 },
-    })
-    m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, alpha.length + 1, alpha.length + 5) })
-    const rev = m.state.draftRev
-    m.dispatch({ type: 'set-invalid', invalidIds: [1] })
-    expect(m.state.draftRev).toBe(rev)
-    expect(m.state.occurrences.map(o => o.invalid === true)).toEqual([true, false])
-    // Recovery: the same source/ref resolving again clears the bit.
-    m.dispatch({ type: 'set-invalid', invalidIds: [] })
-    expect(m.state.occurrences.every(o => o.invalid === undefined)).toBe(true)
-  })
-
-  it('a no-change call keeps the table reference (no spurious publish)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
-    const table = m.state.occurrences
-    expect(m.dispatch({ type: 'set-invalid', invalidIds: [] })).toEqual([])
-    expect(m.state.occurrences).toBe(table)
-  })
-})
-
-describe('input-machine: projectClipboard', () => {
-  it('expands each reference range to its occurrence clipboardText in draft order', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'use /alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
-    const alpha = referenceDraftText(refOf('alpha'))
-    const secondDraft = `use ${alpha} then /bet`
-    const secondStart = secondDraft.lastIndexOf('/bet')
-    m.dispatch({
-      type: 'draft-changed',
-      draft: secondDraft,
-      editRange: { start: 4 + alpha.length + 1, end: 4 + alpha.length + 1, insertedLength: 'then /bet'.length },
-    })
-    m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, secondStart, secondStart + 4) })
-    expect(m.state.draft).toBe(`use ${alpha} then ${referenceDraftText(refOf('beta'))} `)
-    expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
-  })
-
-  it('is the identity on a chip-free draft', () => {
-    expect(projectClipboard({ draft: 'plain text', occurrences: [] })).toBe('plain text')
-  })
-})
-
-describe('decorations: scanTextRefs', () => {
-  const LEX: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
-    ['/', ['commit-helper', 'fixture-demo']],
-    ['@', ['worker-1']],
-  ])
-
-  it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
-    expect(scanTextRefs('/commit-helper then @worker-1 ok', LEX)).toEqual([
-      { start: 0, end: 14, trigger: '/' },
-      { start: 20, end: 29, trigger: '@' },
-    ])
-  })
-
-  it('a cold (empty) lexicon scans nothing', () => {
-    expect(scanTextRefs('/commit-helper', new Map())).toEqual([])
-  })
-
-  it('recognizes directory paths independently of the dynamic lexicon', () => {
-    expect(scanTextRefs('open @src/components/ or @"docs/design notes/', new Map())).toEqual([
-      { start: 5, end: 21, trigger: '@', appearance: 'folder' },
-      { start: 25, end: 45, trigger: '@', appearance: 'folder' },
-    ])
-  })
-
-  it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
-    expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([])
-  })
-
-  it('word boundary: a trigger glued to text never matches', () => {
-    expect(scanTextRefs('x/commit-helper', LEX)).toEqual([])
-    expect(scanTextRefs('a@worker-1', LEX)).toEqual([])
-  })
-
-  it('tokens never cross a newline; a token straight after one matches', () => {
-    expect(scanTextRefs('line\n/commit-helper', LEX)).toEqual([
-      { start: 5, end: 19, trigger: '/' },
-    ])
-  })
-
-  it('deriveDecorations threads the lexicon through as textRefs', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'use /commit-helper now' })
-    expect(deriveDecorations(m.state, LEX).textRefs).toEqual([
-      { start: 4, end: 18, trigger: '/' },
-    ])
-  })
-})
-
-describe('input-machine: decorations', () => {
-  it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    const reference = { ...refOf('alpha'), appearance: 'file' as const }
-    m.dispatch({
-      type: 'insert-ref',
-      reference,
-      span: spanOf(m, 0, 4),
-    })
-    m.dispatch({ type: 'set-invalid', invalidIds: [1] })
-    expect(deriveDecorations(m.state)).toEqual({
-      token: null,
-      chips: [{
-        occurrenceId: 1,
-        offset: 0,
-        length: referenceDraftText(reference).length,
-        text: referenceDraftText(reference),
-        label: 'alpha',
-        appearance: 'file',
-        invalid: true,
-      }],
-      textRefs: [],
-      hint: null,
-    })
-  })
-
-  it('claim token range and ghost hint show while claimed with blank args; args clear the hint', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
-    expect(deriveDecorations(m.state)).toEqual({
-      token: { start: 0, end: 6 },
-      chips: [],
-      textRefs: [],
-      hint: 'objective',
-    })
-    m.dispatch({ type: 'draft-changed', draft: '/goal x' })
-    expect(deriveDecorations(m.state)).toMatchObject({ token: { start: 0, end: 6 }, hint: null })
-  })
-
-  it('the token range persists through submitting; a hintless claim never ghosts', () => {
-    const m = new InputMachine()
-    enterSubmitting(m, 'goal', '')
-    expect(deriveDecorations(m.state)).toEqual({ token: { start: 0, end: 6 }, chips: [], textRefs: [], hint: null })
-  })
-})
-
-describe('input-machine: claimed lifecycle', () => {
-  it('breaking startsWith(token) auto-releases back to plain', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'draft-changed', draft: '/goal make' })
-    expect(m.state.phase).toBe('claimed')
-    m.dispatch({ type: 'draft-changed', draft: '/goa make' })
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.claim).toBeUndefined()
-    expect(m.state.draft).toBe('/goa make')
-  })
-
-  it('explicit release returns to plain when nothing is in flight', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    expect(m.dispatch({ type: 'release' })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.claim).toBeUndefined()
-  })
-
-  it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
-    const m = new InputMachine()
-    const { attempt, claim } = enterSubmitting(m, 'goal', 'line1\nline2')
-    expect(attempt.draftSnapshot).toBe('/goal line1\nline2')
-    m.dispatch({ type: 'submit-settled', attempt, ok: true })
-    expect(m.state.draft).toBe('')
-    expect(claim.token).toBe('/goal ')
-  })
-})
-
-describe('input-machine: submitting transaction', () => {
-  it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
-    const m = new InputMachine()
-    enterSubmitting(m, 'goal', 'x')
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
-    expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
-  })
-
-  it('commit clears draft and occurrences, releases the claim, and relays the outcome text', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '@wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) })
-    const refLength = referenceDraftText(refOf('worker-1')).length
-    m.dispatch({
-      type: 'draft-changed',
-      draft: `${referenceDraftText(refOf('worker-1'))}/go`,
-      editRange: { start: refLength + 1, end: refLength + 1, insertedLength: 3 },
-    })
-    m.dispatch({
-      type: 'draft-changed',
-      draft: '/go',
-      editRange: { start: 0, end: refLength + 1, insertedLength: 0 },
-    })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'draft-changed', draft: '/goal go' })
-    const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
-    expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
-    expect(m.state.claim).toBeUndefined()
-  })
-
-  it('rollback with an undeviated draft keeps the snapshot and re-enters claimed (same claim)', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
-    expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
-    expect(m.state).toMatchObject({ phase: 'claimed', draft: '/goal x' })
-    expect(m.state.claim?.token).toBe('/goal ')
-  })
-
-  it('rollback with a deviated draft only notices — the newer input wins', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    m.dispatch({ type: 'draft-changed', draft: 'fresh typing' })
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
-    expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: 'fresh typing' })
-    expect(m.state.claim).toBeUndefined()
-  })
-
-  it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
-    // '\n\n/goal x' round-trips through adjudication; the whitespace prefix
-    // would instantly break the claimed watch, so rollback lands plain.
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '\n\n/goal x')
-    m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
-    expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: '\n\n/goal x' })
-  })
-
-  it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
-    const m = new InputMachine()
-    const { attempt: first } = enterSubmitting(m, 'goal', 'x')
-    m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
-    const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
-    expect(second.seq).not.toBe(first.seq)
-    expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
-    expect(m.state.phase).toBe('submitting')
-    m.dispatch({ type: 'submit-settled', attempt: second, ok: true })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('release mid-flight aborts the attempt and later settles are dropped', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    expect(m.dispatch({ type: 'release' })).toEqual([])
-    expect(attempt.signal.aborted).toBe(true)
-    expect(m.state.phase).toBe('plain')
-    expect(m.dispatch({ type: 'submit-settled', attempt, ok: true })).toEqual([])
-    expect(m.state.draft).toBe('/goal x')
-  })
-})
-
-describe('input-machine: per-session isolation', () => {
-  it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
-    const a = new InputMachine()
-    const b = new InputMachine()
-    const { attempt } = enterSubmitting(a, 'goal', 'from A')
-    // B stays fully live while A holds its lock.
-    b.dispatch({ type: 'draft-changed', draft: '/mo' })
-    b.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(b, 0, 3) })
-    expect(b.state.phase).toBe('claimed')
-    expect(a.state.phase).toBe('submitting')
-    // A's commit falls back to A alone.
-    a.dispatch({ type: 'submit-settled', attempt, ok: true })
-    expect(a.state).toMatchObject({ phase: 'plain', draft: '' })
-    expect(b.state).toMatchObject({ phase: 'claimed', draft: '/model ' })
-  })
-})

+ 32 - 23
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -21,6 +21,13 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 import { zh } from '../src/client/locales.ts'
 
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 afterEach(cleanup)
 afterEach(cleanup)
 
 
 const SCTX = {} as ClientContext
 const SCTX = {} as ClientContext
@@ -86,7 +93,7 @@ function bench(over?: {
   const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } })
   const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } })
   const wiring = shell
   const wiring = shell
   const view = mountBar(shell, over)
   const view = mountBar(shell, over)
-  const textarea = view.container.querySelector('textarea')!
+  const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
   const claim = (token = '/goal ', hint = '目标', images?: true) => {
   const claim = (token = '/goal ', hint = '目标', images?: true) => {
     act(() => {
     act(() => {
       shell.setDraft(token)
       shell.setDraft(token)
@@ -106,7 +113,7 @@ function bench(over?: {
 describe('matrix row: plain', () => {
 describe('matrix row: plain', () => {
   it('enter falls to the default sink; no claim on the currency; edits free', async () => {
   it('enter falls to the default sink; no claim on the currency; edits free', async () => {
     const { textarea, shell, sink } = bench()
     const { textarea, shell, sink } = bench()
-    fireEvent.change(textarea, { target: { value: '普通消息' } })
+    act(() => { shell.setDraft('普通消息') })
     expect(shell.snapshot.claim).toBeUndefined()
     expect(shell.snapshot.claim).toBeUndefined()
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal))
     expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal))
@@ -120,37 +127,39 @@ describe('matrix row: claimed', () => {
   it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
   it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
     const { view, textarea, shell, claim } = bench()
     const { view, textarea, shell, claim } = bench()
     claim()
     claim()
+    act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
     expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
     expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
-    expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
+    expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ')
     // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
     // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
-    expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
-    expect((textarea).readOnly).toBe(false)
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
+    expect(textarea.getAttribute('contenteditable')).toBe('true')
     // Free editing beyond the token: hint drops, claim holds.
     // Free editing beyond the token: hint drops, claim holds.
-    fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
+    act(() => { shell.setDraft('/goal 发布版本') })
     expect(shell.snapshot.phase).toBe('claimed')
     expect(shell.snapshot.phase).toBe('claimed')
-    expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
   })
   })
 
 
   it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
   it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
     const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
     const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
-    const { view, textarea, sink, claim } = bench({ submit })
+    const { view, textarea, shell, sink, claim } = bench({ submit })
     claim()
     claim()
-    fireEvent.change(textarea, { target: { value: '/goal 发布' } })
+    act(() => { shell.setDraft('/goal 发布') })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).not.toHaveBeenCalled()
     expect(sink).not.toHaveBeenCalled()
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX, []) })
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX, []) })
     // Commit: draft cleared, notice surfaced, back to plain.
     // Commit: draft cleared, notice surfaced, back to plain.
-    await vi.waitFor(() => { expect((textarea).value).toBe('') })
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
     expect(view.getByText('完成')).toBeTruthy()
     expect(view.getByText('完成')).toBeTruthy()
   })
   })
 
 
   it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
   it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
-    const { view, textarea, shell, claim } = bench()
+    const { view, shell, claim } = bench()
     claim()
     claim()
-    fireEvent.change(textarea, { target: { value: '/goa 发布' } }) // token broken
+    act(() => { shell.setDraft('/goa 发布') }) // token broken
     expect(shell.snapshot.phase).toBe('plain')
     expect(shell.snapshot.phase).toBe('plain')
     expect(shell.snapshot.claim).toBeUndefined()
     expect(shell.snapshot.claim).toBeUndefined()
-    expect(view.container.querySelector('[data-decoration="token"]')).toBeNull()
+    act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
+    expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')).toBeNull()
   })
   })
 })
 })
 
 
@@ -169,7 +178,7 @@ describe('matrix row: claimed with images', () => {
     expect(sink).not.toHaveBeenCalled()
     expect(sink).not.toHaveBeenCalled()
     expect(view.getByText('/goal images-unsupported')).toBeTruthy()
     expect(view.getByText('/goal images-unsupported')).toBeTruthy()
     expect(shell.snapshot.imageIds).toEqual([img])
     expect(shell.snapshot.imageIds).toEqual([img])
-    expect((textarea).value).toBe('/goal ')
+    expect(shell.snapshot.draft).toBe('/goal ')
   })
   })
 
 
   it('an accepting claim serializes and forwards the images; success consumes and clears', async () => {
   it('an accepting claim serializes and forwards the images; success consumes and clears', async () => {
@@ -183,7 +192,7 @@ describe('matrix row: claimed with images', () => {
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) })
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) })
     expect(serialize).toHaveBeenCalledWith([img])
     expect(serialize).toHaveBeenCalledWith([img])
-    await vi.waitFor(() => { expect((textarea).value).toBe('') })
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
     expect(release).toHaveBeenCalledWith([img])
     expect(release).toHaveBeenCalledWith([img])
     expect(shell.snapshot.imageIds).toEqual([])
     expect(shell.snapshot.imageIds).toEqual([])
     expect(shell.snapshot.phase).toBe('plain')
     expect(shell.snapshot.phase).toBe('plain')
@@ -199,7 +208,7 @@ describe('matrix row: claimed with images', () => {
     expect(shell.snapshot.phase).toBe('claimed')
     expect(shell.snapshot.phase).toBe('claimed')
     expect(shell.snapshot.imageIds).toEqual([img])
     expect(shell.snapshot.imageIds).toEqual([img])
     expect(release).not.toHaveBeenCalled()
     expect(release).not.toHaveBeenCalled()
-    expect((textarea).value).toBe('/goal ')
+    expect(shell.snapshot.draft).toBe('/goal ')
   })
   })
 
 
   it('a serialize rejection blocks the transaction: notice, no submit call, images kept', async () => {
   it('a serialize rejection blocks the transaction: notice, no submit call, images kept', async () => {
@@ -253,7 +262,7 @@ describe('matrix row: submitting', () => {
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(shell.snapshot.phase).toBe('submitting')
     expect(shell.snapshot.phase).toBe('submitting')
     expect(shell.snapshot.claim).toBeDefined()
     expect(shell.snapshot.claim).toBeDefined()
-    expect((textarea).readOnly).toBe(true)
+    expect(textarea.getAttribute('contenteditable')).toBe('false')
     // Enter is dead inside the lock (submit dispatch is microtask-deferred).
     // Enter is dead inside the lock (submit dispatch is microtask-deferred).
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
@@ -271,7 +280,7 @@ describe('matrix row: submitting', () => {
     await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
     await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
     act(() => { rejectSubmit(new Error('执行失败')) })
     act(() => { rejectSubmit(new Error('执行失败')) })
     await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
     await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
-    expect((first.textarea).value).toBe('/goal ')
+    expect(first.shell.snapshot.draft).toBe('/goal ')
     expect(first.view.getByText('执行失败')).toBeTruthy()
     expect(first.view.getByText('执行失败')).toBeTruthy()
     cleanup()
     cleanup()
     // Drift: typing during flight wins; no restore, plain, notice only.
     // Drift: typing during flight wins; no restore, plain, notice only.
@@ -283,7 +292,7 @@ describe('matrix row: submitting', () => {
     act(() => { second.shell.setDraft('用户飞行中打的新稿') })
     act(() => { second.shell.setDraft('用户飞行中打的新稿') })
     act(() => { rejectSubmit(new Error('晚到失败')) })
     act(() => { rejectSubmit(new Error('晚到失败')) })
     await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
     await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
-    expect((second.textarea).value).toBe('用户飞行中打的新稿')
+    expect(second.shell.snapshot.draft).toBe('用户飞行中打的新稿')
     expect(second.view.getByText('晚到失败')).toBeTruthy()
     expect(second.view.getByText('晚到失败')).toBeTruthy()
   })
   })
 })
 })
@@ -291,15 +300,15 @@ describe('matrix row: submitting', () => {
 describe('matrix row: locked (session disabled)', () => {
 describe('matrix row: locked (session disabled)', () => {
   it('disables the textarea and chrome; the machine currency is untouched', () => {
   it('disables the textarea and chrome; the machine currency is untouched', () => {
     const { view, textarea, shell } = bench({ disabled: true })
     const { view, textarea, shell } = bench({ disabled: true })
-    expect((textarea).disabled).toBe(true)
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect(shell.snapshot.phase).toBe('plain')
     expect(shell.snapshot.phase).toBe('plain')
   })
   })
 
 
   it('running does NOT lock: typing and enter-queue stay live', () => {
   it('running does NOT lock: typing and enter-queue stay live', () => {
-    const { textarea, sink } = bench({ running: true })
-    expect((textarea).disabled).toBe(false)
-    fireEvent.change(textarea, { target: { value: '排队' } })
+    const { textarea, shell, sink } = bench({ running: true })
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    act(() => { shell.setDraft('排队') })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal))
     expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal))
   })
   })

+ 15 - 12
packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts

@@ -53,7 +53,9 @@ describe('reference submission', () => {
       end: 4,
       end: 4,
       draftRev: first.snapshot.draftRev,
       draftRev: first.snapshot.draftRev,
     })).toBe(true)
     })).toBe(true)
-    expect(first.snapshot.draft).toBe('@Research notes ')
+    // InputState.draft IS the clipboard projection now (chips expand to their
+    // canonical text); the display label lives in the chip's decorator DOM.
+    expect(first.snapshot.draft).toBe(`${spacedMention} `)
     expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
     expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
 
 
     const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
     const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
@@ -82,6 +84,7 @@ describe('reference submission', () => {
     const inputTriggers = {
     const inputTriggers = {
       serializeReference,
       serializeReference,
       track: vi.fn(),
       track: vi.fn(),
+      lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
     } as unknown as InputTriggerController
     } as unknown as InputTriggerController
     const shell = new SessionInputShell({
     const shell = new SessionInputShell({
       actx: {} as ClientContext,
       actx: {} as ClientContext,
@@ -91,8 +94,8 @@ describe('reference submission', () => {
     })
     })
     chip(shell)
     chip(shell)
     expect(shell.snapshot).toMatchObject({
     expect(shell.snapshot).toMatchObject({
-      draft: '@Research ',
-      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
+      draft: `${mention} `,
+      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: mention.length }],
     })
     })
 
 
     shell.submit('queue')
     shell.submit('queue')
@@ -102,8 +105,8 @@ describe('reference submission', () => {
     })
     })
     expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
     expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
     expect(shell.snapshot).toMatchObject({
     expect(shell.snapshot).toMatchObject({
-      draft: '@Research ',
-      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
+      draft: `${mention} `,
+      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: mention.length }],
     })
     })
     expect(shell.notices.getSnapshot()).toMatchObject({
     expect(shell.notices.getSnapshot()).toMatchObject({
       level: 'error',
       level: 'error',
@@ -124,6 +127,7 @@ describe('reference submission', () => {
     const inputTriggers = {
     const inputTriggers = {
       serializeReference: () => Promise.reject(new Error('reference codec unavailable')),
       serializeReference: () => Promise.reject(new Error('reference codec unavailable')),
       track: vi.fn(),
       track: vi.fn(),
+      lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
     } as unknown as InputTriggerController
     } as unknown as InputTriggerController
     const shell = new SessionInputShell({
     const shell = new SessionInputShell({
       actx: {} as ClientContext,
       actx: {} as ClientContext,
@@ -137,7 +141,7 @@ describe('reference submission', () => {
       expect(shell.snapshot.phase).toBe('plain')
       expect(shell.snapshot.phase).toBe('plain')
     })
     })
     expect(sink).not.toHaveBeenCalled()
     expect(sink).not.toHaveBeenCalled()
-    expect(shell.snapshot.draft).toBe('@Research ')
+    expect(shell.snapshot.draft).toBe(`${mention} `)
     expect(shell.snapshot.occurrences).toHaveLength(1)
     expect(shell.snapshot.occurrences).toHaveLength(1)
     expect(shell.notices.getSnapshot()).toMatchObject({
     expect(shell.notices.getSnapshot()).toMatchObject({
       level: 'error',
       level: 'error',
@@ -219,11 +223,12 @@ describe('submit transaction hardening', () => {
     expect(shell.notices.getSnapshot()).toBeNull()
     expect(shell.notices.getSnapshot()).toBeNull()
   })
   })
 
 
-  it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => {
+  it('re-tracks at the caret when an insert-text splice lands (directory descent reopens the menu)', () => {
     const track = vi.fn()
     const track = vi.fn()
+    const lexicon = { getSnapshot: () => new Map(), subscribe: () => () => {} }
     const shell = new SessionInputShell({
     const shell = new SessionInputShell({
       actx: {} as ClientContext,
       actx: {} as ClientContext,
-      inputTriggers: () => ({ track } as unknown as InputTriggerController),
+      inputTriggers: () => ({ track, lexicon } as unknown as InputTriggerController),
       defaultSink: vi.fn(),
       defaultSink: vi.fn(),
       commandImages,
       commandImages,
     })
     })
@@ -231,10 +236,8 @@ describe('submit transaction hardening', () => {
     const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true)
     const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true)
     expect(applied).toBe(true)
     expect(applied).toBe(true)
     expect(shell.snapshot.draft).toBe('@src/')
     expect(shell.snapshot.draft).toBe('@src/')
+    // Every editor commit re-tracks at the settled caret (the continue flag
+    // is a contract passenger now): a trailing '/' keeps the menu open.
     expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
     expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
-
-    track.mockClear()
-    shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev })
-    expect(track).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 22 - 13
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -30,6 +30,13 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
 
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 afterEach(cleanup)
 afterEach(cleanup)
 
 
 /** Directory row driving kind derivation (input? = leadingInput, else execute). */
 /** Directory row driving kind derivation (input? = leadingInput, else execute). */
@@ -181,9 +188,9 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
     variant: 'composer',
     variant: 'composer',
   }
   }
   const view = render(<InputBar {...barProps} />)
   const view = render(<InputBar {...barProps} />)
-  const textarea = view.container.querySelector('textarea')!
+  const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
   const type = (text: string): void => {
   const type = (text: string): void => {
-    fireEvent.change(textarea, { target: { value: text } })
+    act(() => { shell.setDraft(text) })
   }
   }
   return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release }
   return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release }
 }
 }
@@ -209,17 +216,18 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => {
     // Pointer pick (menu path executes through the bound target inside the pipeline).
     // Pointer pick (menu path executes through the bound target inside the pipeline).
     act(() => { b.controller.pick('command', 0) })
     act(() => { b.controller.pick('command', 0) })
     expect(b.shell.snapshot.phase).toBe('claimed')
     expect(b.shell.snapshot.phase).toBe('claimed')
-    expect(b.textarea.value).toBe('/goal ')
-    expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
+    expect(b.shell.snapshot.draft).toBe('/goal ')
+    act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
+    expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ')
     // The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior).
     // The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior).
-    expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
+    expect(b.textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
     // Continue typing args; hint drops; claim holds.
     // Continue typing args; hint drops; claim holds.
     b.type('/goal 发布 v1')
     b.type('/goal 发布 v1')
     expect(b.shell.snapshot.phase).toBe('claimed')
     expect(b.shell.snapshot.phase).toBe('claimed')
     // Enter: submitting → command execute → commit clears.
     // Enter: submitting → command execute → commit clears.
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1', []) })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1', []) })
-    await vi.waitFor(() => { expect(b.textarea.value).toBe('') })
+    await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') })
     expect(b.shell.snapshot.phase).toBe('plain')
     expect(b.shell.snapshot.phase).toBe('plain')
     expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy()
     expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy()
     expect(b.sink).not.toHaveBeenCalled()
     expect(b.sink).not.toHaveBeenCalled()
@@ -235,7 +243,7 @@ describe('scenario C: pasted /goal xxx + enter (menu never opened)', () => {
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布', []) })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布', []) })
     await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
     await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
-    expect(b.textarea.value).toBe('')
+    expect(b.shell.snapshot.draft).toBe('')
     expect(b.sink).not.toHaveBeenCalled()
     expect(b.sink).not.toHaveBeenCalled()
   })
   })
 })
 })
@@ -278,7 +286,7 @@ describe('scenario: images ride an accepting command through the real pipeline',
     // The envelope the controller forwarded to matchEnter carried the count.
     // The envelope the controller forwarded to matchEnter carried the count.
     expect(b.envelopes).toEqual([{ images: 1 }])
     expect(b.envelopes).toEqual([{ images: 1 }])
     expect(b.serialize).toHaveBeenCalledWith(['img-1'])
     expect(b.serialize).toHaveBeenCalledWith(['img-1'])
-    await vi.waitFor(() => { expect(b.textarea.value).toBe('') })
+    await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') })
     expect(b.release).toHaveBeenCalledWith(['img-1'])
     expect(b.release).toHaveBeenCalledWith(['img-1'])
     expect(b.shell.snapshot.imageIds).toEqual([])
     expect(b.shell.snapshot.imageIds).toEqual([])
     expect(b.sink).not.toHaveBeenCalled()
     expect(b.sink).not.toHaveBeenCalled()
@@ -301,12 +309,12 @@ describe('scenario H: backspace breaks the token', () => {
     b.type('/goal')
     b.type('/goal')
     await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
     await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
     // Space adjudication claims (space column, leadingInput).
     // Space adjudication claims (space column, leadingInput).
-    fireEvent.keyDown(b.textarea, { key: ' ' })
+    fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 })
     expect(b.shell.snapshot.phase).toBe('claimed')
     expect(b.shell.snapshot.phase).toBe('claimed')
     // Backspace into the token: watch break → plain, visuals gone.
     // Backspace into the token: watch break → plain, visuals gone.
     b.type('/goa ')
     b.type('/goa ')
     expect(b.shell.snapshot.phase).toBe('plain')
     expect(b.shell.snapshot.phase).toBe('plain')
-    expect(b.view.container.querySelector('[data-decoration="token"]')).toBeNull()
+    expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')).toBeNull()
   })
   })
 })
 })
 
 
@@ -328,13 +336,14 @@ describe('scenario: reference decoration lights up when the lexicon settles', ()
     })
     })
     // Typed before the catalog settled: a plain token, no decoration.
     // Typed before the catalog settled: a plain token, no decoration.
     b.type('/deploy now')
     b.type('/deploy now')
-    expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
+    expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull()
     // The catalog settles (ui-skill's settle path fires the same notification).
     // The catalog settles (ui-skill's settle path fires the same notification).
     act(() => {
     act(() => {
       roll = ['deploy']
       roll = ['deploy']
       notify?.()
       notify?.()
     })
     })
-    const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
+    act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) // flush the queued re-scan
+    const mark = b.view.container.querySelector('[data-composer-text-ref]')
     expect(mark?.textContent).toBe('/deploy')
     expect(mark?.textContent).toBe('/deploy')
   })
   })
 })
 })
@@ -362,7 +371,7 @@ describe('scenario I: unknown /xyz + enter', () => {
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.view.getByText('目录预热失败')).toBeTruthy() })
     await vi.waitFor(() => { expect(b.view.getByText('目录预热失败')).toBeTruthy() })
     // Never a silent downgrade: draft retained, sink untouched.
     // Never a silent downgrade: draft retained, sink untouched.
-    expect(b.textarea.value).toBe('/plan 上线')
+    expect(b.shell.snapshot.draft).toBe('/plan 上线')
     expect(b.sink).not.toHaveBeenCalled()
     expect(b.sink).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 32 - 0
packages/client/ui-conversation/tests/keydown-probe.client.spec.tsx

@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+/** Probe: does a synthetic keydown at the contenteditable reach the keymap commands? */
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent } from '@testing-library/react'
+import { createEditor } from 'lexical'
+import { registerPlainText } from '@lexical/plain-text'
+import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts'
+
+describe('keydown probe', () => {
+  it('routes Enter to the keymap submit handler', () => {
+    const editor = createEditor({ namespace: 'probe', onError: (e) => { throw e } })
+    const root = document.createElement('div')
+    root.contentEditable = 'true'
+    document.body.appendChild(root)
+    editor.setRootElement(root)
+    registerPlainText(editor)
+    const submit = vi.fn()
+    registerComposerKeymap(editor, {
+      arbitrate: () => 'pass',
+      space: () => false,
+      dismissPopup: () => {},
+      canSubmit: () => true,
+      submit,
+      intakeFiles: () => {},
+      pasteText: () => {},
+    })
+    fireEvent.keyDown(root, { key: 'Enter' })
+    expect(submit).toHaveBeenCalledWith(false)
+    fireEvent.keyDown(root, { key: 'Enter', metaKey: true })
+    expect(submit).toHaveBeenCalledWith(true)
+  })
+})

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

@@ -150,7 +150,7 @@ describe('ReferenceChipNode', () => {
       if (chip === undefined) return
       if (chip === undefined) return
       const el = chip.createDOM()
       const el = chip.createDOM()
       expect(el.getAttribute('data-composer-chip')).toBe('session-reference')
       expect(el.getAttribute('data-composer-chip')).toBe('session-reference')
-      expect(el.contentEditable).toBe('false')
+      expect(el.getAttribute('contenteditable')).toBe('false')
       expect(chip.updateDOM()).toBe(false)
       expect(chip.updateDOM()).toBe(false)
     })
     })
   })
   })

+ 23 - 16
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -30,6 +30,13 @@ import type {
 } from '../src/client/contract/slots.ts'
 } from '../src/client/contract/slots.ts'
 import type { ViewTab } from '../src/client/contract/views.ts'
 import type { ViewTab } from '../src/client/contract/views.ts'
 
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 /** Machine-backed wiring over a sink spy. */
 /** Machine-backed wiring over a sink spy. */
 function fakeWiring() {
 function fakeWiring() {
   const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
   const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
@@ -252,7 +259,7 @@ function mount(
   }
   }
   const view = render(<ConversationRoot {...props} />)
   const view = render(<ConversationRoot {...props} />)
   return {
   return {
-    view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
+    view, chat, sink, wiring, retargetWorkspace, session, slotCalls, seatOwners, open,
     pickerOwner: () => pickerOwner,
     pickerOwner: () => pickerOwner,
     rerender: () => { view.rerender(<ConversationRoot {...props} />) },
     rerender: () => { view.rerender(<ConversationRoot {...props} />) },
   }
   }
@@ -281,11 +288,11 @@ describe('ConversationRoot resident composer', () => {
     const b = mount(conversationSnapshot(), undefined, undefined, {
     const b = mount(conversationSnapshot(), undefined, undefined, {
       composerBlock: { reason: 'select a model first' },
       composerBlock: { reason: 'select a model first' },
     })
     })
-    const box = b.view.getByRole('textbox') as HTMLTextAreaElement
-    // One disabled textarea with the blocker's placeholder, never a second
+    const box = b.view.getByRole('textbox')
+    // One disabled surface with the blocker's placeholder, never a second
     // tree: the DOM survives the block being raised and cleared.
     // tree: the DOM survives the block being raised and cleared.
-    expect(box.disabled).toBe(true)
-    expect(box.placeholder).toBe('select a model first')
+    expect(box.getAttribute('aria-disabled')).toBe('true')
+    expect(box.getAttribute('data-placeholder')).toBe('select a model first')
     fireEvent.keyDown(box, { key: 'Enter' })
     fireEvent.keyDown(box, { key: 'Enter' })
     expect(b.sink).not.toHaveBeenCalled()
     expect(b.sink).not.toHaveBeenCalled()
 
 
@@ -304,11 +311,11 @@ describe('ConversationRoot resident composer', () => {
       summaryBlank: true,
       summaryBlank: true,
       composerBlock: { reason: 'select a model first' },
       composerBlock: { reason: 'select a model first' },
     })
     })
-    const box = b.view.getByRole('textbox') as HTMLTextAreaElement
-    expect(box.disabled).toBe(false)
-    expect(box.readOnly).toBe(true)
+    const box = b.view.getByRole('textbox')
+    expect(box.getAttribute('aria-disabled')).not.toBe('true')
+    expect(box.getAttribute('contenteditable')).not.toBe('true')
     expect(box.getAttribute('aria-haspopup')).toBe('menu')
     expect(box.getAttribute('aria-haspopup')).toBe('menu')
-    expect(box.placeholder).not.toBe('select a model first')
+    expect(box.getAttribute('data-placeholder')).not.toBe('select a model first')
     const modelSeat = b.seatOwners.filter(call => call.key === 'conversation.input.model').at(-1)?.owner
     const modelSeat = b.seatOwners.filter(call => call.key === 'conversation.input.model').at(-1)?.owner
     expect(modelSeat).toEqual({ locked: true })
     expect(modelSeat).toEqual({ locked: true })
   })
   })
@@ -316,8 +323,8 @@ describe('ConversationRoot resident composer', () => {
   it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
   it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
     const b = mount(conversationSnapshot())
     const b = mount(conversationSnapshot())
     const box = b.view.getByRole('textbox')
     const box = b.view.getByRole('textbox')
-    expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
-    fireEvent.change(box, { target: { value: 'ordinary revised' } })
+    expect(b.wiring.snapshot.draft).toBe('ordinary draft')
+    act(() => { b.wiring.setDraft('ordinary revised') })
     expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
     expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
     fireEvent.keyDown(box, { key: 'Enter' })
     fireEvent.keyDown(box, { key: 'Enter' })
     expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal))
     expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal))
@@ -338,7 +345,7 @@ describe('ConversationRoot resident composer', () => {
     const host = b.view.container.querySelector('[data-conversation-scroll]')
     const host = b.view.container.querySelector('[data-conversation-scroll]')
     const seat = b.view.container.querySelector('[data-composer-seat]')
     const seat = b.view.container.querySelector('[data-composer-seat]')
     const header = b.view.container.querySelector('header')
     const header = b.view.container.querySelector('header')
-    const textarea = b.view.container.querySelector('textarea')
+    const textarea = b.view.container.querySelector<HTMLDivElement>('[data-composer-input]')
     expect(host).not.toBeNull()
     expect(host).not.toBeNull()
     expect(seat).not.toBeNull()
     expect(seat).not.toBeNull()
     expect(header).not.toBeNull()
     expect(header).not.toBeNull()
@@ -381,7 +388,7 @@ describe('ConversationRoot resident composer', () => {
     // for blank sessions): hero typing reaches the chat store.
     // for blank sessions): hero typing reaches the chat store.
     const box = b.view.getByRole('textbox')
     const box = b.view.getByRole('textbox')
     expect(host?.contains(box)).toBe(true)
     expect(host?.contains(box)).toBe(true)
-    fireEvent.change(box, { target: { value: 'draft in hero' } })
+    act(() => { b.wiring.setDraft('draft in hero') })
     expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
     expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
     // Picker: open through the chip; a pick switches to the other
     // Picker: open through the chip; a pick switches to the other
     // workspace's blank session (draft carry is apply-layer wiring).
     // workspace's blank session (draft carry is apply-layer wiring).
@@ -429,15 +436,15 @@ describe('ConversationRoot resident composer', () => {
   it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
   it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
     const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
     const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
     const before = b.view.getByRole('textbox')
     const before = b.view.getByRole('textbox')
-    fireEvent.change(before, { target: { value: 'kept across flip' } })
+    act(() => { b.wiring.setDraft('kept across flip') })
     // First message landed: content exists, phase leaves blank. Composer
     // First message landed: content exists, phase leaves blank. Composer
     // already sat in the resident scrollport during hero, so the textarea
     // already sat in the resident scrollport during hero, so the textarea
     // node and InputHub draft both survive.
     // node and InputHub draft both survive.
     b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
     b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
     b.rerender()
     b.rerender()
-    const after = b.view.getByRole('textbox') as HTMLTextAreaElement
+    const after = b.view.getByRole('textbox')
     expect(after).toBe(before)
     expect(after).toBe(before)
-    expect(after.value).toBe('kept across flip')
+    expect(b.wiring.snapshot.draft).toBe('kept across flip')
     expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
     expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
     expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
     expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
     expect(b.view.queryByText('探索未至之境')).toBeNull()
     expect(b.view.queryByText('探索未至之境')).toBeNull()

+ 356 - 0
packages/client/ui-conversation/tests/submit-machine.client.spec.ts

@@ -0,0 +1,356 @@
+/**
+ * SubmitMachine behavior: enter routing, adjudication outcomes, the claimed
+ * lifecycle and its integrity watch, settlement (commit-draft and claim
+ * re-entry decisions), anti-backwash, and per-session isolation. Text-edit
+ * semantics live in the editor (lexical-editor-core spec) — the machine only
+ * observes drafts through event payloads.
+ */
+import { describe, expect, it } from 'vitest'
+import type { CommandClaim } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
+import { SubmitMachine } from '../src/client/input/machine.ts'
+import { scanTextRefs } from '../src/client/input/decorations.ts'
+
+function claimOf(name: string, hint?: string): CommandClaim {
+  return {
+    token: `/${name} `,
+    ...(hint !== undefined ? { hint } : {}),
+    submit: async () => ({ kind: 'success' }),
+  }
+}
+
+function effectAt<T extends InputEffect['type']>(
+  effects: readonly InputEffect[], index: number, type: T,
+): Extract<InputEffect, { type: T }> {
+  const e = effects[index]
+  expect(e?.type).toBe(type)
+  return e as Extract<InputEffect, { type: T }>
+}
+
+/** Drive plain → adjudicating and hand back the minted attempt. */
+function enterAdjudicating(m: SubmitMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
+  const fx = m.dispatch({ type: 'enter', mode, draft })
+  return effectAt(fx, 0, 'adjudicate').attempt
+}
+
+/** Drive plain → claimed → submitting and hand back attempt + claim. */
+function enterSubmitting(m: SubmitMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
+  const claim = claimOf(name)
+  m.dispatch({ type: 'claim', claim })
+  const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: claim.token + args })
+  return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
+}
+
+function staleAttempt(): SubmitAttempt {
+  return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
+}
+
+describe('submit-machine: plain × enter', () => {
+  it('empty and whitespace-only drafts produce nothing', () => {
+    const m = new SubmitMachine()
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '' })).toEqual([])
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '  \n ' })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('non-command text falls to the default sink with the draft and mode', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
+    const sink = effectAt(fx, 0, 'default-sink')
+    expect(sink.draft).toBe('hello')
+    expect(sink.mode).toBe('queue')
+    expect(sink.attempt.draftSnapshot).toBe('hello')
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it('retains an explicit steer mode on the default sink effect', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'steer', draft: 'go' })
+    expect(effectAt(fx, 0, 'default-sink').mode).toBe('steer')
+  })
+
+  it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal write tests' })
+    const adjudicate = effectAt(fx, 0, 'adjudicate')
+    expect(adjudicate.draft).toBe('/goal write tests')
+    expect(adjudicate.attempt.draftSnapshot).toBe('/goal write tests')
+    expect(adjudicate.attempt.signal.aborted).toBe(false)
+    expect(m.state.phase).toBe('adjudicating')
+  })
+
+  it('leading is judged after trim including newlines', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n /goal x' })
+    expect(effectAt(fx, 0, 'adjudicate').draft).toBe(' \n /goal x')
+  })
+
+  it('a non-whitespace prefix before "/" is not leading — default sink', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'see /goal' })
+    expect(effectAt(fx, 0, 'default-sink').draft).toBe('see /goal')
+  })
+})
+
+describe('submit-machine: adjudication outcomes', () => {
+  it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal write x\nand y')
+    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
+    const begin = effectAt(fx, 0, 'begin-submit')
+    expect(begin.args).toBe('write x\nand y')
+    expect(m.state.phase).toBe('submitting')
+    expect(m.state.claim?.token).toBe('/goal ')
+  })
+
+  it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal')
+    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
+    expect(effectAt(fx, 0, 'begin-submit').args).toBe('')
+
+    const m2 = new SubmitMachine()
+    const attempt2 = enterAdjudicating(m2, '  /goal args')
+    const fx2 = m2.dispatch({ type: 'adjudicated', attempt: attempt2, outcome: { claim: claimOf('goal') } })
+    expect(effectAt(fx2, 0, 'begin-submit').args).toBe('args')
+  })
+
+  it('undefined outcome falls back to the default sink with the snapshot', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
+    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })
+    const sink = effectAt(fx, 0, 'default-sink')
+    expect(sink.draft).toBe('/unknown thing')
+    expect(sink.mode).toBe('steer')
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it("'handled' lands plain with zero effects (popup shell path)", () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/model')
+    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('adjudication failure notices and keeps plain — no silent downgrade', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal x')
+    const fx = m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' })
+    expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'warmup failed' })
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('enter is a no-op while adjudicating (pending lock)', () => {
+    const m = new SubmitMachine()
+    enterAdjudicating(m, '/goal x')
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
+    expect(m.state.phase).toBe('adjudicating')
+  })
+
+  it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
+    const m = new SubmitMachine()
+    enterAdjudicating(m, '/goal x')
+    expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: undefined })).toEqual([])
+    expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
+    expect(m.state.phase).toBe('adjudicating')
+  })
+
+  it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal x')
+    m.dispatch({ type: 'release' })
+    expect(attempt.signal.aborted).toBe(true)
+    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+})
+
+describe('submit-machine: claimed lifecycle', () => {
+  it('the claim event enters claimed and snapshots hint and images bits', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), images: true } })
+    expect(m.state.phase).toBe('claimed')
+    expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', images: true })
+  })
+
+  it('claimed overwrites in place — no stack', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    m.dispatch({ type: 'claim', claim: claimOf('plan') })
+    expect(m.state.claim?.token).toBe('/plan ')
+    expect(m.state.phase).toBe('claimed')
+  })
+
+  it('submitting rejects the claim event (lock)', () => {
+    const m = new SubmitMachine()
+    enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'claim', claim: claimOf('plan') })
+    expect(m.state.claim?.token).toBe('/goal ')
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it('breaking startsWith(token) auto-releases back to plain', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    m.dispatch({ type: 'draft-changed', draft: '/goal args fine' })
+    expect(m.state.phase).toBe('claimed')
+    m.dispatch({ type: 'draft-changed', draft: '/goa' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('explicit release returns to plain when nothing is in flight', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    m.dispatch({ type: 'release' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal line one\nline two' })
+    expect(effectAt(fx, 0, 'begin-submit').args).toBe('line one\nline two')
+  })
+})
+
+describe('submit-machine: submitting transaction', () => {
+  it('enter and claim are locked while submitting; draft-changed is recorded without leaving submitting', () => {
+    const m = new SubmitMachine()
+    enterSubmitting(m, 'goal', 'x')
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
+    m.dispatch({ type: 'draft-changed', draft: 'typed during flight' })
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it('commit emits commit-draft with the snapshot, releases the claim, and relays the outcome text', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    const fx = m.dispatch({
+      type: 'submit-settled', attempt, ok: true, draft: '/goal x',
+      outcome: { kind: 'success', text: 'goal saved' },
+    })
+    expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBe('/goal x')
+    expect(effectAt(fx, 1, 'notice')).toMatchObject({ level: 'info', text: 'goal saved' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('an error-kind outcome text relays as an error notice on success=false settles', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    const fx = m.dispatch({
+      type: 'submit-settled', attempt, ok: false, draft: 'deviated',
+      outcome: { kind: 'error', text: 'rejected' },
+    })
+    expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'rejected' })
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('rollback with an undeviated draft keeps the claim and re-enters claimed', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '/goal x', message: 'transport' })
+    expect(m.state.phase).toBe('claimed')
+    expect(m.state.claim?.token).toBe('/goal ')
+  })
+
+  it('rollback with a deviated draft only notices — the newer input wins', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: 'rewritten', message: 'transport' })
+    expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'transport' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '  /goal x')
+    m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
+    m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '  /goal x', message: 'nope' })
+    // The snapshot carries leading whitespace the token never had: plain, claim cleared.
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
+    const m = new SubmitMachine()
+    const { attempt: first } = enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'submit-settled', attempt: first, ok: false, draft: '/goal x', message: 'try again' })
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })
+    const second = effectAt(fx, 0, 'begin-submit').attempt
+    expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true, draft: '/goal x' })).toEqual([])
+    expect(m.state.phase).toBe('submitting')
+    m.dispatch({ type: 'submit-settled', attempt: second, ok: true, draft: '/goal x' })
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('release mid-flight aborts the attempt and later settles are dropped', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'release' })
+    expect(attempt.signal.aborted).toBe(true)
+    expect(m.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '' })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('send-committed clears unconditionally (image-only sends have no draft to retain)', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'send-committed' })
+    expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBeNull()
+    const busy = new SubmitMachine()
+    enterSubmitting(busy, 'goal', 'x')
+    expect(busy.dispatch({ type: 'send-committed' })).toEqual([])
+  })
+})
+
+describe('submit-machine: per-session isolation', () => {
+  it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
+    const a = new SubmitMachine()
+    const b = new SubmitMachine()
+    const { attempt } = enterSubmitting(a, 'goal', 'x')
+    const fx = b.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
+    expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello')
+    a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x' })
+    expect(a.state.phase).toBe('plain')
+    expect(b.state.phase).toBe('submitting')
+  })
+})
+
+describe('decorations: scanTextRefs', () => {
+  const lexicon: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
+    ['/', ['commit-helper', 'goal'] as readonly string[]],
+    ['@', ['research'] as readonly string[]],
+  ])
+
+  it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
+    const out = scanTextRefs('/goal then @research and /commit-helper', lexicon)
+    expect(out.map(r => [r.start, r.end, r.trigger])).toEqual([
+      [0, 5, '/'], [11, 20, '@'], [25, 39, '/'],
+    ])
+  })
+
+  it('a cold (empty) lexicon scans nothing lexicon-based', () => {
+    expect(scanTextRefs('/goal x', new Map())).toEqual([])
+  })
+
+  it('recognizes directory paths independently of the dynamic lexicon', () => {
+    const out = scanTextRefs('see @src/x/ now', new Map())
+    expect(out).toEqual([{ start: 4, end: 11, trigger: '@', appearance: 'folder' }])
+  })
+
+  it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
+    expect(scanTextRefs('/research @goal', lexicon)).toEqual([])
+  })
+
+  it('word boundary: a trigger glued to text never matches', () => {
+    expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([])
+  })
+
+  it('tokens never cross a newline; a token straight after one matches', () => {
+    const out = scanTextRefs('a\n/goal', lexicon)
+    expect(out).toEqual([{ start: 2, end: 7, trigger: '/' }])
+  })
+})