Răsfoiți Sursa

docs+build(ui-conversation): lexical composer collateral

The client-bundle preset pins the production/development exports condition
(lexical's node-condition file selects its flavor with a top-level await a
CJS bundle cannot carry); the composer carries an explicit aria-label (a
div's data-placeholder does not name it the way a textarea placeholder
did); READMEs, the composer.bar slot doc, and the Agent Note record the
editor architecture; web e2e drives the contenteditable surface.
Yichen Jiang 1 lună în urmă
părinte
comite
f6fb66a318
53 a modificat fișierele cu 364 adăugiri și 337 ștergeri
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml
  2. 57 0
      .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md
  3. 57 0
      .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md
  4. 1 1
      apps/web/tests/agent-preset-selection.e2e.ts
  5. 2 2
      apps/web/tests/approval-composer.e2e.ts
  6. 1 1
      apps/web/tests/chat-continuous-conversation.e2e.ts
  7. 1 1
      apps/web/tests/chat-long-interactions.e2e.ts
  8. 4 4
      apps/web/tests/chat-scroll-contract.e2e.ts
  9. 1 1
      apps/web/tests/code-mode-round.e2e.ts
  10. 23 10
      apps/web/tests/command-image-envelope.snapshot.ts
  11. 2 2
      apps/web/tests/complex-history.perf.ts
  12. 105 247
      apps/web/tests/composer-draft-scroll.e2e.ts
  13. 1 1
      apps/web/tests/cordis-tool-round.e2e.ts
  14. 1 1
      apps/web/tests/default-model.e2e.ts
  15. 1 1
      apps/web/tests/details-session-lifecycle.e2e.ts
  16. 2 2
      apps/web/tests/feedback-command.e2e.ts
  17. 1 1
      apps/web/tests/goal-command-presentation.e2e.ts
  18. 1 1
      apps/web/tests/goal-multi-turn-actions.e2e.ts
  19. 21 3
      apps/web/tests/image-display.snapshot.ts
  20. 4 4
      apps/web/tests/lifecycle-chrome.e2e.ts
  21. 3 3
      apps/web/tests/live-interactions.e2e.ts
  22. 2 2
      apps/web/tests/navigation-panes.e2e.ts
  23. 1 1
      apps/web/tests/permission-policy-context.e2e.ts
  24. 1 1
      apps/web/tests/plan-control-row.e2e.ts
  25. 2 2
      apps/web/tests/plan-review.e2e.ts
  26. 2 2
      apps/web/tests/question-composer.e2e.ts
  27. 2 2
      apps/web/tests/queue-actions.e2e.ts
  28. 10 7
      apps/web/tests/reference-composer.e2e.ts
  29. 1 1
      apps/web/tests/replay-round-trip.e2e.ts
  30. 1 1
      apps/web/tests/schedule-after.e2e.ts
  31. 2 2
      apps/web/tests/seeded-history.e2e.ts
  32. 1 1
      apps/web/tests/skill-invocation-policy.e2e.ts
  33. 1 1
      apps/web/tests/skill-user-invoke.e2e.ts
  34. 2 2
      apps/web/tests/smoke-real.e2e.ts
  35. 1 1
      apps/web/tests/startup-auto-selection.e2e.ts
  36. 4 4
      apps/web/tests/steering.e2e.ts
  37. 3 3
      apps/web/tests/subagent-conversation.e2e.ts
  38. 1 1
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  39. 3 3
      apps/web/tests/support.ts
  40. 1 1
      apps/web/tests/trajectory-virtualization.e2e.ts
  41. 1 1
      apps/web/tests/turn-tail-actions.e2e.ts
  42. 1 1
      apps/web/tests/web-search-round.e2e.ts
  43. 1 1
      apps/web/tests/workflow-run.e2e.ts
  44. 12 0
      packages/client/tsdown.client.ts
  45. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  46. 0 0
      packages/client/ui-conversation/README.md
  47. 0 0
      packages/client/ui-conversation/README.zh.md
  48. 2 2
      packages/client/ui-conversation/src/client/contract/slots.ts
  49. 1 0
      packages/client/ui-conversation/src/client/input/facade.ts
  50. 3 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  51. 2 2
      packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
  52. 1 1
      packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
  53. 2 2
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md
+2026-08-20-web-composer-lexical-editor.md: 90665028627cf328b6708d0a7ae3ebf641f80c17
+2026-08-20-web-composer-lexical-editor.zh.md: e17802522d55a5225a8b0de2b2f6941a33a5ca69

+ 57 - 0
.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md

@@ -0,0 +1,57 @@
+# Agent Note: Web composer as a Lexical editor (chips as atomic nodes)
+
+Status: implemented
+
+English | [中文](2026-08-20-web-composer-lexical-editor.zh.md)
+
+> Scope: the composer's text surface (ui-conversation input/editor), the SubmitMachine that remains of InputMachine, and the projection contract feeding the untouched ui-input-trigger pipeline. Supersedes the draft/occurrence half of the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); its submit-plane, slot, and trigger-pipeline halves stay current.
+
+## Problem
+
+The textarea composer painted text in three coupled layers (hidden auto-grow mirror, decoration backdrop, transparent-text textarea) and held the draft twice (the textarea string and the machine's occurrence table). Both couplings produced structural bugs: reconciling occurrences against a string diff guessed the edit position, and a greedy scan sliding into a reference silently degraded it before the serialization guard could run (#2813); scan-derived decorations carried no identity, so typing ahead of one rebuilt its DOM every keystroke (#2793). The layer trick also taxed every chip style — nothing could change glyph advance, so no background, padding, radius, or label truncation.
+
+## Decision
+
+One Lexical editor per session shell replaces the three layers and the draft half of the machine.
+
+- **Ownership**: `SessionInputShell` creates the editor outside React (`createEditor` + `registerPlainText` + `registerHistory`) and keeps it for the session's lifetime; React binds a resident contenteditable to it (`ComposerContentEditable`, ~40 lines) and portals decorators (`DecoratorPortals`). `@lexical/react` is deliberately not used: its composer owns editor creation inside React, which conflicts with per-session shell ownership, and it drags an unused dependency tree.
+- **Chips are atomic `DecoratorNode`s** (`ReferenceChipNode`) carrying the owner's insert-time projections. NodeKey is the occurrence identity; `getTextContent()` answers the clipboard projection, so native copy/cut and the draft mirror need no expansion code.
+- **One tree, three projections**: the detect projection (chip = one U+FFFC) feeds `detectTrigger` and TokenSpan coordinates, restoring the opaque-reference invariant #2769 broke; the clipboard projection (chip = clipboardText) feeds `InputState.draft`, persistence, and submit-plane decisions; the model form is produced per chip at submit through the owner codec. `span-map.ts` is the single place numeric spans map back to Lexical points.
+- **The machine slims to the submit plane** (phase/claim/attempt); it never holds the draft — events carry the clipboard projection (`enter`, `submit-settled`), and the claimed integrity watch runs on `draft-changed`. Draft clearing became the `commit-draft` effect the shell executes in the editor (suffix retention included), followed by `CLEAR_HISTORY_COMMAND`.
+- **Contract stability**: `TokenSpan {start, end, draftRev}`, `ReferenceInsert`, `CommandClaim`, the four `slash/input-*` bail events, every trigger source, the controller, and MenuView are unchanged. `draftRev` is now the editor update counter.
+- **Claim tokens stay literal text** with a transform-styled leading leaf (backspacing the token remains the exit gesture); plain-text references ride `registerLexicalTextEntity` (`TextRefNode`); the ghost hint is a CSS `--dsh-composer-hint` variable rendered as generated content.
+
+## Retired with the rewrite
+
+The mirror/backdrop layers and their CSS coupling rules; the Safari soft-wrap repair (2026-08-13 note's workaround — the surface has no mirror to disagree with); mirror-Range caret measurement; the machine's undo ring and typing-merge clock (Lexical history, 1000ms merge delay preserved); manual boundary Backspace/Delete occurrence deletion (atomic nodes); manual copy/cut expansion; `EditRange`/`diffEdit`/`reconcile`. The paste-attempt plane (`paste-begin` components, `paste-upgrade`, `invalidate-paste`) and the `set-invalid` event had **no producers anywhere in the tree** and were deleted rather than ported; `Occurrence.invalid` stays on the node and the projection for the day a producer exists.
+
+## Deliberate behavior changes
+
+- Claimed command args now reach the source in clipboard form (references as canonical text, not display labels) — the parseable form.
+- `InputState.draft` is the clipboard projection (was display text). Cross-package readers consume phase/queue-level fields; the occurrence table had zero external readers.
+- Chip deletion follows the engine's native decorator gesture; jsdom lacks `Selection.modify`, so the keyboard path is asserted in the browser lane only.
+- Folder text-refs keep the `@` glyph beside the folder appearance attribute instead of overpainting the trigger character.
+- The composer's accessible name is an explicit `aria-label` mirroring the placeholder (a div's `data-placeholder` does not name it the way a textarea's placeholder did) — caught by the reference-composer aria golden.
+
+## Alternatives considered
+
+- **Patch the textarea** (record beforeinput selections to narrow the diff, #2813's proposed fix): shrinks the guessing window but keeps two truths and the style tax; every future decoration pays it again.
+- **Hand-rolled contenteditable layer**: rejected by the dependencies-over-hand-rolling policy — IME, selection, and engine quirks are exactly what Lexical already owns.
+- **Deleting the machine entirely** (editor state as the only machine): the submit plane (attempt CAS, anti-backwash, abort) is text-independent and battle-tested; rewriting it buys risk, not simplicity.
+- **`@lexical/react`**: its composer creates the editor inside React, conflicting with per-session shell ownership, and pulls an unused dependency tree; the two bindings it would replace total ~80 lines.
+
+## Consequences
+
+- Bugs #2813 and #2793 are structurally unexpressible: no edit-position inference exists, and chip DOM identity rides NodeKey.
+- Chips are real DOM (icon, capsule, `max-width` truncation, invalid strike-through) and enter the accessibility tree; the old backdrop was `aria-hidden`.
+- The editor and its history survive session switches on the shell; unit tests drive the document headlessly, while true keyboard gestures (chip deletion, IME) belong to the browser lane.
+- The ui-conversation client bundle carries lexical (+~70KB gzip); no other package imports a Lexical value, so no module-table row exists.
+- The submit plane, trigger pipeline, and slash/input-* contracts are byte-compatible for every source plugin.
+
+## Traps
+
+- `editor.update` **defers** its fn when called from inside the same editor's update (command handlers land there synchronously); a nested discrete throws. `applyEdit` runs the `$`-body directly when `editor._updating` (legal in command handlers — the pattern Lexical itself uses for setEditable) and discretely at top level. A bail answer computed through a wrapped nested update reads stale state.
+- Lexical's chord/space detection reads `event.keyCode` (undo `z`=90, space=32); synthetic tests must set it.
+- A history restore (`UNDO_COMMAND`) commits on the next flush, not synchronously inside the dispatch.
+- The client bundle needs the `production`/`development` exports condition pinned (tsdown preset `inputOptions.resolve.conditionNames`): lexical's `node` condition file selects its flavor with a top-level await a CJS bundle cannot carry.
+- `registerHistory`'s merge delay reads `Date.now` at call time; fake-timer tests must install the mock before shell construction or advance past the window.

+ 57 - 0
.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md

@@ -0,0 +1,57 @@
+# Agent Note:Web 输入框改为 Lexical 编辑器(chip 为原子节点)
+
+Status: implemented
+
+[English](2026-08-20-web-composer-lexical-editor.md) | 中文
+
+> 范围:输入框文本表面(ui-conversation input/editor)、InputMachine 瘦身后余下的 SubmitMachine,以及喂给原封不动的 ui-input-trigger 管线的投影契约。取代[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md) 中 draft/occurrence 的那一半;其提交面、slot 与 trigger 管线部分仍然有效。
+
+## 问题
+
+textarea 输入框用三个耦合层绘制文本(隐藏自增高 mirror、装饰 backdrop、文字透明的 textarea),且草稿存在两份(textarea 字符串与状态机的 occurrence 表)。两处耦合各自产生结构性 bug:occurrence 对齐依赖字符串 diff 猜测编辑位置,贪心扫描滑进引用内部会在序列化守卫运行前把它静默降级(#2813);扫描推导的装饰没有身份,在其前方打字每一击都重建它的 DOM(#2793)。层叠戏法还对每种 chip 样式征税——任何改变字形 advance 的样式都不可用,于是没有背景、内边距、圆角,也无法截断标签。
+
+## 决策
+
+每个会话壳持有一个 Lexical 编辑器,取代三层结构与状态机的草稿半边。
+
+- **所有权**:`SessionInputShell` 在 React 之外创建编辑器(`createEditor` + `registerPlainText` + `registerHistory`)并持有它到会话结束;React 侧把常驻 contenteditable 绑上去(`ComposerContentEditable`,约 40 行)并 portal 渲染 decorator(`DecoratorPortals`)。刻意不用 `@lexical/react`:其 composer 在 React 内部创建编辑器,与 per-session 壳所有权冲突,还会拖入用不到的依赖树。
+- **chip 是原子 `DecoratorNode`**(`ReferenceChipNode`),携带所有者插入时的投影。NodeKey 即 occurrence 身份;`getTextContent()` 回答剪贴板投影,因此原生复制/剪切与草稿镜像不再需要展开代码。
+- **一棵树,三个投影**:检测投影(chip = 1 个 U+FFFC)供 `detectTrigger` 与 TokenSpan 坐标使用,恢复了 #2769 打破的不透明引用不变量;剪贴板投影(chip = clipboardText)供 `InputState.draft`、持久化与提交面决策使用;模型形式在提交时逐 chip 经所有者 codec 产出。`span-map.ts` 是数字 span 映射回 Lexical point 的唯一场所。
+- **状态机瘦身为提交面**(phase/claim/attempt);它不再持有草稿——事件携带剪贴板投影(`enter`、`submit-settled`),claimed 完整性监视跑在 `draft-changed` 上。清空草稿变成 shell 在编辑器里执行的 `commit-draft` 效果(含后缀保留),随后 `CLEAR_HISTORY_COMMAND`。
+- **契约稳定**:`TokenSpan {start, end, draftRev}`、`ReferenceInsert`、`CommandClaim`、四个 `slash/input-*` bail 事件、所有 trigger source、controller 与 MenuView 一律未改。`draftRev` 现在是编辑器 update 计数。
+- **claim token 保持字面文本**,前缀叶子由 transform 上色(退格删 token 仍是退出手势);纯文本引用走 `registerLexicalTextEntity`(`TextRefNode`);ghost hint 是 CSS 变量 `--dsh-composer-hint` 生成内容。
+
+## 随重构退役
+
+mirror/backdrop 层及其 CSS 耦合规则;Safari 软换行修复(2026-08-13 note 的 workaround——表面已无可与之分歧的 mirror);mirror-Range 光标测量;状态机的 undo 环与打字合并时钟(Lexical history,保留 1000ms 合并窗口);手写的边界 Backspace/Delete 整段删除(原子节点原生);手写复制/剪切展开;`EditRange`/`diffEdit`/`reconcile`。粘贴尝试面(`paste-begin` components、`paste-upgrade`、`invalidate-paste`)与 `set-invalid` 事件**全仓没有任何生产者**,直接删除而非移植;`Occurrence.invalid` 保留在节点与投影上,待未来出现生产者。
+
+## 刻意的行为变化
+
+- 已认领命令的 args 现以剪贴板形式到达 source(引用为规范文本而非展示标签)——可解析的那种形式。
+- `InputState.draft` 是剪贴板投影(原为展示文本)。跨包读方只消费 phase/queue 级字段;occurrence 表的外部读方为零。
+- chip 删除遵循引擎的原生 decorator 手势;jsdom 缺 `Selection.modify`,键盘路径只在浏览器 lane 断言。
+- 文件夹纯文本引用保留 `@` 字形并携带 appearance 属性,不再覆盖 trigger 字符。
+- 输入框的可访问名称改为显式 `aria-label` 镜像 placeholder(div 的 `data-placeholder` 不像 textarea 的 placeholder 那样参与命名)——由 reference-composer 的 aria golden 逮出。
+
+## 曾考虑的替代方案
+
+- **给 textarea 打补丁**(记录 beforeinput 时的 selection 收窄 diff,即 #2813 提议的修法):缩小猜测窗口但保留双事实源与样式税;未来每个装饰都要再交一次。
+- **自研 contenteditable 薄层**:被「依赖优先于手搓」政策否决——IME、selection 与引擎怪癖正是 Lexical 已经解决的本职。
+- **彻底删除状态机**(编辑器状态为唯一状态机):提交面(attempt CAS、防倒灌、abort)与文本表示无关且久经考验;重写只买来风险。
+- **`@lexical/react`**:其 composer 在 React 内创建编辑器,与 per-session 壳所有权冲突,还拉入用不到的依赖树;它能替代的两个绑定总共约 80 行。
+
+## 后果
+
+- #2813 与 #2793 在结构上不可表达:不存在编辑位置推断,chip DOM 身份随 NodeKey。
+- chip 是真实 DOM(图标、胶囊、`max-width` 截断、失效删除线)并进入可访问性树;旧 backdrop 是 `aria-hidden` 的。
+- 编辑器及其历史随壳跨会话切换存活;单元测试无头驱动文档,真实键盘手势(删 chip、IME)归浏览器 lane。
+- ui-conversation 的 client bundle 携带 lexical(gzip 约 +70KB);无其他包 import Lexical 值,故无模块表行。
+- 提交面、trigger 管线与 slash/input-* 契约对每个 source 插件字节兼容。
+
+## 坑
+
+- 在同一编辑器的 update 内再调 `editor.update` 会**推迟**其 fn(command handler 同步落到这里);嵌套 discrete 直接抛错。`applyEdit` 在 `editor._updating` 时直接执行 `$` 函数体(command handler 内合法——Lexical 自己对 setEditable 用同款分叉),顶层则 discrete。经包裹嵌套 update 计算的 bail 答案读到的是旧状态。
+- Lexical 的组合键/空格检测读 `event.keyCode`(undo `z`=90、空格=32);合成事件测试必须设置它。
+- 历史恢复(`UNDO_COMMAND`)在下一次 flush 才提交,不在 dispatch 内同步生效。
+- client bundle 需钉住 `production`/`development` exports 条件(tsdown preset 的 `inputOptions.resolve.conditionNames`):lexical 的 `node` 条件文件用顶层 await 选择口味,CJS bundle 载不动。
+- `registerHistory` 的合并延时在调用时捕获 `Date.now`;fake-timer 测试要么在 shell 构造前装好 mock,要么推进越过窗口。

+ 1 - 1
apps/web/tests/agent-preset-selection.e2e.ts

@@ -235,7 +235,7 @@ describe('web e2e: agent-preset selection', () => {
     // Continues the previous case: the chip has already applied `minimal` to
     // the blank session, and this one reads the menu that switch left behind.
     onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
-    const composer = page.locator('textarea:enabled').last()
+    const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
 
     // `minimal` mounts neither the compaction group nor plan mode nor local
     // skill discovery, so the catalog the composer warmed under the

+ 2 - 2
apps/web/tests/approval-composer.e2e.ts

@@ -74,7 +74,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
     if (MODE !== 'record') {
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
 
     // The composer's own text cap, measured on the live draft scrollport before
@@ -171,7 +171,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
     expect(written).toContain(TOKENS.slice(0, 64))
     await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1)
     expect(await page.locator('[data-approval-key]').count()).toBe(0)
-    await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 300_000)

+ 1 - 1
apps/web/tests/chat-continuous-conversation.e2e.ts

@@ -216,7 +216,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
 
   it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
-    const composer = page.locator('textarea:enabled').last()
+    const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
     await composer.waitFor({ timeout: 15_000 })
     let sessionId: SessionId | undefined
 

+ 1 - 1
apps/web/tests/chat-long-interactions.e2e.ts

@@ -272,7 +272,7 @@ describe('web e2e: long Chat interaction contract', () => {
       .toBe(`${FIXTURE.title} (1)`)
     await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
     const settled = scaffold.whenTurnSettled(60_000)
-    const composer = page.locator('textarea:enabled').last()
+    const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
     await composer.fill(CONTINUE_PROMPT)
     await page.getByRole('button', { name: 'Send message', exact: true }).click()
     await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)

+ 4 - 4
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -496,7 +496,7 @@ describe('web e2e: long Chat scroll contract', () => {
 
       const settled = world.scaffold.whenTurnSettled(60_000)
       try {
-        const composer = world.page.locator('textarea:enabled').last()
+        const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
         await composer.fill(LIVE_TEXT_PROMPT)
         await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
         await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
@@ -559,7 +559,7 @@ describe('web e2e: long Chat scroll contract', () => {
       const settled = world.scaffold.whenTurnSettled(60_000)
       let released = false
       try {
-        const composer = world.page.locator('textarea:enabled').last()
+        const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
         await composer.fill(LIVE_TOOL_PROMPT)
         await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
         await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
@@ -700,7 +700,7 @@ describe('web e2e: long Chat scroll contract', () => {
         RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
       )
       await expectBottom(world.page)
-      const composer = world.page.locator('textarea:enabled').last()
+      const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
       const longDraft = Array.from(
         { length: 18 },
         (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
@@ -789,7 +789,7 @@ describe('web e2e: long Chat scroll contract', () => {
       const settled = world.scaffold.whenTurnSettled(60_000)
       let released = false
       try {
-        const composer = world.page.locator('textarea:enabled').last()
+        const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
         await composer.fill(LIVE_FLING_PROMPT)
         await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
         await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)

+ 1 - 1
apps/web/tests/code-mode-round.e2e.ts

@@ -63,7 +63,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
       // Drift guard: the committed fixture must carry exactly the drive prompt.
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled()
     await input.fill(PROMPT)

+ 23 - 10
apps/web/tests/command-image-envelope.snapshot.ts

@@ -13,17 +13,30 @@ import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
 
 installAssembledBootEnv()
 
-/** Open a fresh fixture session and return its composer textarea. */
-async function freshComposer(): Promise<HTMLTextAreaElement> {
+/** Open a fresh fixture session and return its composer surface. */
+async function freshComposer(): Promise<HTMLElement> {
   const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
   const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
   if (start === null) throw new Error('fixture Workspace new-session action missing')
   fireEvent.click(start)
-  return await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) as HTMLTextAreaElement
+  return await waitFor(() => {
+    const surface = document.querySelector<HTMLElement>(
+      '[data-composer-input][data-placeholder="Describe what you want to build"]',
+    )
+    if (surface === null) throw new Error('composer surface missing')
+    return surface
+  }, { timeout: 10_000 })
+}
+
+/** Type through the clipboard: jsdom carries no editable beforeinput, and the paste command inserts at the caret. */
+function pasteText(surface: HTMLElement, text: string): void {
+  fireEvent.paste(surface, {
+    clipboardData: { items: [], getData: () => text },
+  })
 }
 
 /** Paste one tiny PNG into the composer and wait for its rail thumbnail. */
-async function pasteImage(textarea: HTMLTextAreaElement, name: string): Promise<void> {
+async function pasteImage(textarea: HTMLElement, name: string): Promise<void> {
   const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' })
   fireEvent.paste(textarea, {
     clipboardData: {
@@ -44,7 +57,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   await pasteImage(textarea, 'ref.png')
 
   // /echo is a leadingInput fixture command without `input.images`.
-  fireEvent.change(textarea, { target: { value: '/echo hello' } })
+  pasteText(textarea, '/echo hello')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
   // The refusal rides the same transient error banner as other composer
@@ -59,7 +72,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   expect([...document.querySelectorAll('[role="status"]')]
     .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false)
   // The whole envelope is retained: draft text and the rail thumbnail.
-  expect(textarea.value).toBe('/echo hello')
+  await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') })
   const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
   expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
 })
@@ -71,11 +84,11 @@ it('consumes images through a declaring command and clears the composer on succe
 
   // /goal declares `input.images` in the fixture catalog; the claim submit
   // serializes the pasted bytes and the fixture executor admits them.
-  fireEvent.change(textarea, { target: { value: '/goal rebuild the cathedral' } })
+  pasteText(textarea, '/goal rebuild the cathedral')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
   await waitFor(() => {
-    expect(textarea.value).toBe('')
+    expect(textarea.textContent).toBe('')
     expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
   }, { timeout: 5_000 })
 })
@@ -85,11 +98,11 @@ it('submits a bare /plan with an image as an image-only plan request', async ()
   const textarea = await freshComposer()
   await pasteImage(textarea, 'plan-task.png')
 
-  fireEvent.change(textarea, { target: { value: '/plan' } })
+  pasteText(textarea, '/plan')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
   await waitFor(() => {
-    expect(textarea.value).toBe('')
+    expect(textarea.textContent).toBe('')
     expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
   }, { timeout: 5_000 })
   expect([...document.querySelectorAll('[role="alert"]')]

+ 2 - 2
apps/web/tests/complex-history.perf.ts

@@ -936,7 +936,7 @@ async function continueConversation(
     readonly checkpointInterval?: number
   },
 ): Promise<ConversationReport> {
-  const composer = world.page.locator('textarea:enabled').last()
+  const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
   await composer.waitFor({ timeout: 15_000 })
   const retainedBefore = await retainedBrowserState(cdp, world.page)
   const checkpoints: RetainedCheckpoint[] = [{ turns: options.startingTurns, state: retainedBefore }]
@@ -1064,7 +1064,7 @@ async function measurePostSoakUserRender(
   if (spec.toolResultMarker !== undefined) {
     throw new Error('post-soak render probe must remain a text-only turn')
   }
-  const composer = world.page.locator('textarea:enabled').last()
+  const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
   const composerFill = await measure(cdp, async () => {
     await composer.fill(spec.prompt)
     await expect.poll(() => composer.inputValue()).toBe(spec.prompt)

+ 105 - 247
apps/web/tests/composer-draft-scroll.e2e.ts

@@ -1,31 +1,19 @@
-// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
-// GLYPHS AND ITS CARET AS ONE.
+// Web e2e scenario: a composer draft longer than the 14-line cap scrolls,
+// reveals the caret, and holds no second scroll offset.
 //
-// The composer paints its text in two stacked layers (see
-// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
-// `<textarea>` carries the value, the selection and the caret but renders its
-// own glyphs `color: transparent`, and every visible character is painted by the
-// `[data-input-backdrop]` div underneath it, which also carries the claim-token
-// highlight, the chips and the ghost hint.
-//
-// Two layers can only stay together by moving together. They do: both sit
-// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
-// tall as the whole draft — so one offset, applied by the browser, moves the
-// caret and the words in the same frame. Scrolling the textarea and assigning
-// its offset to the backdrop looks equivalent and is not: a wheel gesture is
-// composited off the main thread, so the assignment lands frames late and the
-// caret visibly flies ahead of the text it belongs to.
-//
-// That failure is what the same-task measurement below pins. Every metric here
-// is read through the caret's own coordinate frame — where the textarea puts
-// line n — against where the backdrop paints line n, because that difference is
-// the defect a user sees, and it is the one number a mirror between two boxes
-// cannot hold at zero.
+// The composer is ONE contenteditable surface (see
+// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css):
+// the Lexical editor's root carries the glyphs, the selection and the caret
+// together, grows with its content, and `[data-input-scroll]` — the
+// composer's single scrolling box — caps it at 14 lines. With one surface
+// there is no second text layer whose offset could drift from the caret's;
+// what remains to pin is the cap, the wheel gesture, and the caret reveals
+// (typing at a scrolled end, pasting a long block, a trailing-newline end).
 //
 // Only a real engine can show any of this. Scrolling is layout: jsdom reports
 // `scrollHeight === clientHeight` for every element and never scrolls one, so
 // the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can
-// only assert that one scrollport contains both layers.
+// only assert that the scrollport holds the surface.
 //
 // Zero model calls: a fresh workspace's blank session already carries a live
 // composer, and the scenario only types into it. A stray stream would fail loud
@@ -43,37 +31,49 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
 /**
- * Committed golden of the composer's two-layer scroll geometry. The change
- * alters no accessible name, so the aria goldens the other scenarios commit are
- * byte-identical with and without it; this records the relations instead, which
- * makes a shift in the cap or in the layer coupling a reviewable diff rather
- * than an assertion someone has to reconstruct.
+ * Committed golden of the composer's scroll geometry. The change alters no
+ * accessible name, so the aria goldens the other scenarios commit are
+ * byte-identical with and without it; this records the relations instead,
+ * which makes a shift in the cap or in the reveal behavior a reviewable diff
+ * rather than an assertion someone has to reconstruct.
  */
 const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
 const MODE = webSnapshotMode()
 
-/** Marks the first and last line so a Range can find them in the backdrop's text. */
+/** Marks the first and last line so the measurement can find their line boxes. */
 const FIRST_MARKER = 'FIRST-LINE-MARKER'
 const LAST_MARKER = 'LAST-LINE-MARKER'
 /** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
 const DRAFT_LINES = 40
-const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
+const DRAFT_ROWS = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
   if (index === 0) return FIRST_MARKER
   if (index === DRAFT_LINES - 1) return LAST_MARKER
   return `draft line ${String(index + 1).padStart(2, '0')}`
-}).join('\n')
+})
+
+/** The live composer surface. */
+function surface(page: Page): ReturnType<Page['locator']> {
+  return page.locator('[data-composer-input][contenteditable="true"]').first()
+}
 
 /**
- * A draft ending in a newline, where the two layers reserve their
- * final line box on different terms. A textarea keeps one for the caret after a
- * final newline; `white-space: pre-wrap` collapses a text node's trailing
- * newline and generates none. The hidden auto-grow mirror carries the newline
- * and so decides the height for both, which is why the backdrop needs no
- * padding of its own — but only a draft with a trailing newline can show it.
+ * Replace the draft through real gestures: select-all, delete, then insert
+ * the rows with soft line breaks (the composer's Enter submits).
+ * @param page - the page under test.
+ * @param rows - draft lines; a trailing empty row leaves a trailing newline.
  */
-const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
+async function typeDraft(page: Page, rows: readonly string[]): Promise<void> {
+  const input = surface(page)
+  await input.click()
+  await page.keyboard.press('ControlOrMeta+KeyA')
+  await page.keyboard.press('Delete')
+  for (const [index, row] of rows.entries()) {
+    if (index > 0) await page.keyboard.press('Shift+Enter')
+    if (row !== '') await page.keyboard.insertText(row)
+  }
+}
 
-/** The composer's text layers as the browser lays them out. */
+/** The composer's scroll surface as the browser lays it out. */
 interface ComposerMetrics {
   /** True when the draft is taller than the capped box — the situation under test. */
   overflows: boolean
@@ -81,105 +81,55 @@ interface ComposerMetrics {
   clientHeight: number
   /** Whole lines that fit in the visible box, at the composer's own line-height. */
   visibleLines: number
-  /** The composer's one scroll offset, which the caret and the glyphs both follow. */
+  /** The composer's one scroll offset. */
   scrollTop: number
   /** Furthest that offset can go. */
   scrollMax: number
   /**
-   * Scrollable overflow the textarea holds on its own — 0, or a second offset
-   * exists that nothing keeps equal to this one.
-   */
-  inputScrollable: number
-  /**
-   * Distance between where the caret sits for a draft line and where the
-   * backdrop paints that line, in pixels. A fixed value (the difference between
-   * a line box's top and its glyph box's) is alignment; a value that CHANGES
-   * with the scroll offset is the defect — the words trailing the caret.
-   */
-  caretGlyphGap: number
-  /**
-   * How much that gap moves when the offset changes inside a single task: 0
-   * here, because one box carries both layers. Assigning one box's offset to
-   * another cannot be 0 — a scroll event is dispatched after the task that
-   * moved the box, so between the two there is a frame with the caret at the
-   * new offset and the glyphs at the old one.
-   */
-  gapShiftOnScroll: number
-  /**
-   * Top of the LAST draft line relative to the visible box's top, in pixels: at
-   * most `clientHeight` when that line is on screen.
+   * Scrollable overflow the editable surface holds on its own — 0, or a
+   * second offset exists beside the scrollport's.
    */
+  surfaceScrollable: number
+  /** Top of the LAST draft line relative to the visible box's top: at most `clientHeight` when on screen. */
   lastLineOffset: number
   /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
   firstLineOffset: number
-  /** Content width the textarea wraps at. */
-  inputWrapWidth: number
-  /** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
-  backdropWrapWidth: number
-  /** Content width the hidden auto-grow mirror wraps at — it decides the box's height. */
-  mirrorWrapWidth: number
 }
 
 /**
- * Measure the composer's layers in the page, in the caret's coordinate frame.
+ * Measure the composer surface in the page.
  * @param page - the page under test.
- * @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit.
+ * @returns the offset, the cap, and where the draft's first and last lines sit.
  */
 function measureComposer(page: Page): Promise<ComposerMetrics> {
   return page.evaluate(({ first, last }) => {
-    const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
-    if (input === null) throw new Error('no live composer textarea in the DOM')
+    const input = document.querySelector<HTMLElement>('[data-composer-input][contenteditable="true"]')
+    if (input === null) throw new Error('no live composer surface in the DOM')
     const scroll = input.closest<HTMLElement>('[data-input-scroll]')
-    if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
-    const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
-    if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
-    // The hidden auto-grow mirror: the textarea's next sibling, and the layer
-    // that decides the box's height, so its wrap width matters as much as the
-    // two that carry glyphs.
-    const mirror = input.nextElementSibling
-    if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
-    // The draft carries no chips or claim token, so the decoration walk emits it
-    // as a single text node, which is what the Range below needs.
-    const text = backdrop.firstChild
-    if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
+    if (scroll === null) throw new Error('the composer surface is not inside a draft scrollport')
     const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
-    /** Where the backdrop paints the line holding `marker`, in viewport coordinates. */
+    /** Where the surface paints the line holding `marker`, in viewport coordinates. */
     const glyphTop = (marker: string): number => {
-      const at = text.data.indexOf(marker)
-      if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
-      const range = document.createRange()
-      range.setStart(text, at)
-      range.setEnd(text, at + marker.length)
-      return range.getBoundingClientRect().top
+      const walker = document.createTreeWalker(input, NodeFilter.SHOW_TEXT)
+      for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
+        const text = node as Text
+        const at = text.data.indexOf(marker)
+        if (at < 0) continue
+        const range = document.createRange()
+        range.setStart(text, at)
+        range.setEnd(text, at + marker.length)
+        return range.getBoundingClientRect().top
+      }
+      throw new Error(`marker ${marker} missing from the composer text`)
     }
-    const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop)
-    // Where the CARET sits on the draft's first line: the textarea lays its own
-    // (transparent) glyphs out from its border box, shifted by any offset it
-    // holds itself. Reading the caret's frame this way rather than the
-    // scrollport's is what makes the gap the user-visible quantity — it stays
-    // honest if the textarea ever starts scrolling on its own again.
-    const gap = (): number =>
-      Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first))
-    // The same-task probe: move the offset and re-read the gap before the task
-    // ends, which is before any scroll event could have run a listener.
-    const before = gap()
-    const restore = scroll.scrollTop
-    scroll.scrollTop = restore === 0 ? 120 : 0
-    const gapShiftOnScroll = Math.abs(gap() - before)
-    scroll.scrollTop = restore
     const box = scroll.getBoundingClientRect()
     return {
-      inputWrapWidth: input.clientWidth,
-      backdropWrapWidth: backdrop.clientWidth,
-      mirrorWrapWidth: mirror.clientWidth,
       overflows: scroll.scrollHeight > scroll.clientHeight,
       clientHeight: scroll.clientHeight,
       visibleLines: Math.floor(scroll.clientHeight / lineHeight),
       scrollTop: scroll.scrollTop,
       scrollMax: scroll.scrollHeight - scroll.clientHeight,
-      inputScrollable: input.scrollHeight - input.clientHeight,
-      caretGlyphGap: before,
-      gapShiftOnScroll,
+      surfaceScrollable: input.scrollHeight - input.clientHeight,
       lastLineOffset: glyphTop(last) - box.top,
       firstLineOffset: glyphTop(first) - box.top,
     }
@@ -192,9 +142,9 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
  * Absolute glyph coordinates are deliberately absent: they depend on font
  * metrics and would make the fixture fail on a machine that measures text
  * differently — a golden that needs re-recording per platform documents the
- * platform, not the behavior. What is recorded is the cap, the caret-to-glyph
- * relation, and which lines are on screen, each a comparison that survives any
- * layout keeping the coupling.
+ * platform, not the behavior. What is recorded is the cap, the single-offset
+ * invariant, and which lines are on screen, each a comparison that survives
+ * any layout keeping the behavior.
  * @param top - metrics with the draft scrolled to its start.
  * @param bottom - metrics with the draft scrolled to its end.
  * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
@@ -205,32 +155,26 @@ function renderGeometry(
   top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
 ): string {
   return [
-    '# Composer draft scrolling (14-line cap, two text layers, one scrollport)',
+    '# Composer draft scrolling (14-line cap, one editable surface, one scrollport)',
     '',
     '## At the start of the draft',
     '',
     `- draft overflows the capped box: ${String(top.overflows)}`,
     `- visible lines: ${String(top.visibleLines)}`,
-    `- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`,
-    `- all three layers wrap at one width: ${String(
-      top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
-    )}`,
+    `- the surface holds no scroll offset of its own: ${String(top.surfaceScrollable === 0)}`,
     `- scroll offset: ${String(top.scrollTop)}px`,
-    `- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`,
     `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
     `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
     '',
     '## Scrolled to the end of the draft',
     '',
     `- offset moved: ${String(bottom.scrollTop > 0)}`,
-    `- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`,
-    `- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`,
+    `- the surface holds no scroll offset of its own: ${String(bottom.surfaceScrollable === 0)}`,
     `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
     `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
     '',
     '## Draft ending in a newline, scrolled to the end',
     '',
-    `- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`,
     `- the draft's own last line is on screen: ${String(
       trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
     )}`,
@@ -238,7 +182,6 @@ function renderGeometry(
     '## Right after pasting a long block at the end',
     '',
     `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
-    `- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`,
     `- the pasted block's last line is on screen: ${String(
       pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
     )}`,
@@ -259,7 +202,7 @@ describe('web e2e: composer draft scrolling', () => {
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
-    await page.locator('textarea:enabled').first().fill(DRAFT)
+    await typeDraft(page, DRAFT_ROWS)
   }, 180_000)
 
   afterAll(async () => {
@@ -267,7 +210,7 @@ describe('web e2e: composer draft scrolling', () => {
     await scaffold?.close()
   })
 
-  it('caps the draft box and keeps both text layers at the start', async () => {
+  it('caps the draft box at 14 lines with a single scroll offset', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
     // Vacuity guard: without an overflowing draft there is nothing to scroll and
     // every assertion below holds trivially.
@@ -275,7 +218,7 @@ describe('web e2e: composer draft scrolling', () => {
     // Typing the draft left the caret — and the box — at its end, so reach the
     // start by the same gesture a user would, and leave it there for the wheel
     // case below.
-    await page.locator('textarea:enabled').first().hover()
+    await surface(page).hover()
     await page.mouse.wheel(0, -2000)
     await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
     const metrics = await measureComposer(page)
@@ -283,9 +226,9 @@ describe('web e2e: composer draft scrolling', () => {
     // 14 x 24px lines). The count, not the pixels: it is the figma constant and
     // survives a device-pixel-ratio change.
     expect(metrics.visibleLines).toBe(14)
-    // One scrolling box: the textarea is as tall as the draft, so there is no
-    // second offset for the caret to hold while the glyphs hold another.
-    expect(metrics.inputScrollable).toBe(0)
+    // One scrolling box: the surface grows with the draft, so it holds no
+    // second offset beside the scrollport's.
+    expect(metrics.surfaceScrollable).toBe(0)
     expect(metrics.scrollTop).toBe(0)
     expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
     expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
@@ -293,130 +236,50 @@ describe('web e2e: composer draft scrolling', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('lays out all three text layers at one wrap width', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
-    // A layer that breaks lines somewhere else puts the words under the wrong
-    // caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
-    // draft. All three share a containing block — the scrollport — so a
-    // scrollbar that consumes layout space costs them the same width; with
-    // only the textarea scrolling, WebKit reserves gutter space for it alone
-    // (768 against 776) while chromium and firefox do not.
-    const metrics = await measureComposer(page)
-    expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
-    // The mirror decides the box height, so it belongs in the same equality —
-    // were it alone to wrap wider, the box would be measured too short and
-    // clip content before the 14-line cap, with every other assertion green.
-    expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth)
-    expect(tripwire.pageErrors).toEqual([])
-  }, 60_000)
-
-  it('the glyphs cannot lag the caret: one task moves both', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag'))
-    // The reported symptom, isolated. A scroll offset changes and the caret's
-    // distance to its own glyphs is re-read before the task ends — before any
-    // `scroll` listener could have run. With the layers on one scrollport the
-    // browser moves both, so the distance is unchanged; with the glyph layer
-    // catching up in a listener it is off by the whole delta until a later
-    // frame, which is a caret flying away from its text mid-gesture.
-    const metrics = await measureComposer(page)
-    expect(metrics.gapShiftOnScroll).toBe(0)
-    expect(tripwire.pageErrors).toEqual([])
-  }, 60_000)
-
-  it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
+  it('a wheel gesture over a long draft moves the draft', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
-    const input = page.locator('textarea:enabled').first()
-    await input.hover()
-    const resting = (await measureComposer(page)).caretGlyphGap
-    // One delta past the whole draft: the box clamps at its own end, and the
-    // wheel-chaining handler leaves it native because the box is not yet at its
-    // edge when the gesture starts (the chaining itself is owned by the unit spec).
-    await page.mouse.wheel(0, 2000)
+    await surface(page).hover()
+    await page.mouse.wheel(0, 240)
     await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
       .toBeGreaterThan(0)
-    const metrics = await measureComposer(page)
-    // The caret is still on its own glyphs after the gesture.
-    expect(metrics.caretGlyphGap).toBe(resting)
-    // The reported symptom, stated as what the user sees: the end of the draft
-    // is on screen and its beginning is not.
-    expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
-    expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
-    expect(metrics.firstLineOffset).toBeLessThan(0)
+    const scrolled = await measureComposer(page)
+    expect(scrolled.firstLineOffset).toBeLessThan(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it('typing at the end of a scrolled draft brings the caret back into view', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
-    // The other way the box moves, and the one that depends on the browser: the
-    // textarea holds no scroll offset of its own, so revealing the caret after
-    // an edit is a scroll-into-view that has to walk up to the scrollport.
-    // Scroll away from the caret first, so the edit has somewhere to bring it
-    // back from.
-    const input = page.locator('textarea:enabled').first()
-    await input.press('End')
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-typing'))
+    const input = surface(page)
+    // Put the caret at the very end, scroll the view away from it, then type:
+    // the browser's own caret reveal must bring the end back on screen.
+    await input.click()
+    await page.keyboard.press('ControlOrMeta+End')
     await input.hover()
     await page.mouse.wheel(0, -2000)
     await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
-    await input.pressSequentially(' tail')
-    const metrics = await measureComposer(page)
-    expect(metrics.scrollTop).toBeGreaterThan(0)
-    expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
-    expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
-    expect(tripwire.pageErrors).toEqual([])
-  }, 60_000)
-
-  it('pasting a long block scrolls to the caret it leaves at the end', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
-    // The composer suppresses the native paste — the machine owns the draft and
-    // the undo log — and restores the caret programmatically, which reveals
-    // nothing on its own: in chromium and WebKit the view stays put while the
-    // caret sits at the end of the pasted block, so the restore scrolls it
-    // into view; this case pins it.
-    const input = page.locator('textarea:enabled').first()
-    await input.fill('one short line')
-    await input.press('End')
-    // A real `paste` event carrying real clipboard data, dispatched at the
-    // textarea: the same event a Cmd-V delivers, and it runs the same handler.
-    await input.evaluate((el, text) => {
-      const data = new DataTransfer()
-      data.setData('text/plain', text)
-      el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
-      // The engines disagree when the draft ends in a newline: the caret
-      // lands on a line with nothing on it, where chromium reports no client
-      // rects at all for the collapsed position.
-    }, `\n${DRAFT}\n`)
-    await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
-    // The restore lands one frame after the machine commits the draft, so the
-    // box overflows before it moves; waiting on the offset is waiting for the
-    // behavior itself, and its absence fails this poll.
-    await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
-    const metrics = await measureComposer(page)
-    // The caret is at the end of what was pasted, so the draft's last line is
-    // what has to be on screen.
-    expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
-    expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
-    expect(metrics.gapShiftOnScroll).toBe(0)
+    await page.keyboard.insertText(' typed-at-end')
+    await expect.poll(async () => {
+      const m = await measureComposer(page)
+      return m.lastLineOffset >= 0 && m.lastLineOffset < m.clientHeight
+    }, { timeout: 10_000 }).toBe(true)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
-    // The layers reserve a final line box on different terms, so the trailing-newline case is
-    // the one that separates a height every layer agrees on from a box measured
-    // one line short of the caret's own last position.
-    const input = page.locator('textarea:enabled').first()
-    await input.fill(DRAFT_TRAILING_NEWLINE)
+    // A trailing soft break reserves a final empty line box; the end of the
+    // draft is below the last glyph line, and scrolling to the end must show it.
+    await typeDraft(page, [...DRAFT_ROWS, ''])
     await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
-    await input.hover()
+    await surface(page).hover()
     await page.mouse.wheel(0, 4000)
     await expect.poll(async () => {
       const m = await measureComposer(page)
       return m.scrollTop === m.scrollMax
     }, { timeout: 10_000 }).toBe(true)
     const bottom = await measureComposer(page)
-    // At the very bottom the glyphs are level with the caret, and the draft's
-    // own last line — the one before the empty final line — is on screen.
-    expect(bottom.gapShiftOnScroll).toBe(0)
+    // At the very bottom the draft's own last line — the one before the empty
+    // final line — is on screen.
     expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
     expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
     expect(tripwire.pageErrors).toEqual([])
@@ -424,21 +287,20 @@ describe('web e2e: composer draft scrolling', () => {
 
   it('matches the committed composer scroll geometry golden', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
-    const input = page.locator('textarea:enabled').first()
-    // Restore the pristine draft (the edit case appended to it) and return to
+    // Restore the pristine draft (the edit cases appended to it) and return to
     // its start, both through ordinary gestures.
-    await input.fill(DRAFT)
-    await input.hover()
+    await typeDraft(page, DRAFT_ROWS)
+    await surface(page).hover()
     await page.mouse.wheel(0, -2000)
     await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
     const top = await measureComposer(page)
-    await input.hover()
+    await surface(page).hover()
     await page.mouse.wheel(0, 2000)
     await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
       .toBeGreaterThan(0)
     const bottom = await measureComposer(page)
-    await input.fill(DRAFT_TRAILING_NEWLINE)
-    await input.hover()
+    await typeDraft(page, [...DRAFT_ROWS, ''])
+    await surface(page).hover()
     await page.mouse.wheel(0, 4000)
     await expect.poll(async () => {
       const m = await measureComposer(page)
@@ -447,16 +309,12 @@ describe('web e2e: composer draft scrolling', () => {
     const trailingNewline = await measureComposer(page)
     // The paste path, measured the way a user meets it: a short draft, the
     // caret at its end, one long block pasted in.
-    await input.fill('one short line')
-    await input.press('End')
-    await input.evaluate((el, text) => {
+    await typeDraft(page, ['one short line'])
+    await surface(page).evaluate((el, text) => {
       const data = new DataTransfer()
       data.setData('text/plain', text)
       el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
-      // The ordinary case, without a trailing newline, so the collapsed branch
-      // of the reveal keeps a real engine under it; the case above owns the
-      // after-newline branch.
-    }, `\n${DRAFT}`)
+    }, `\n${DRAFT_ROWS.join('\n')}`)
     await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
     await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
     const pasted = await measureComposer(page)

+ 1 - 1
apps/web/tests/cordis-tool-round.e2e.ts

@@ -94,7 +94,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
     if (MODE !== 'record') {
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STOP_PROMPT])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const runTurnSettled = scaffold.whenTurnSettled()
     await input.fill(PROMPT)

+ 1 - 1
apps/web/tests/default-model.e2e.ts

@@ -130,7 +130,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
 
   it('goes inert when the route the default names stops being served', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
-    const box = page.locator('textarea[data-input-phase], textarea').first()
+    const box = page.locator('[data-composer-input]').first()
     await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
 
     // What removing the provider on the Models page leaves behind: the saved

+ 1 - 1
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -91,7 +91,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
   it('starts and reloads closed, then stays closed across Session ownership changes', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle'))
     const settled = scaffold.whenTurnSettled()
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.fill(PROMPT)
     await input.press('Enter')
     await settled

+ 2 - 2
apps/web/tests/feedback-command.e2e.ts

@@ -62,7 +62,7 @@ describe('web e2e: /feedback command acknowledgement', () => {
       // Drift guard: the committed fixture must carry exactly the drive prompt.
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     // Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot
     // miss the turn/end that settles the recorded turn.
@@ -81,7 +81,7 @@ describe('web e2e: /feedback command acknowledgement', () => {
     // command row does not render while a fresh session is still blank) and
     // the replayed reply is on screen.
     await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.fill('/feedback the diff view is unreadable')
     await input.press('Enter')
     // The command plane settles without a model turn: the ack row names the

+ 1 - 1
apps/web/tests/goal-command-presentation.e2e.ts

@@ -48,7 +48,7 @@ describe('web e2e: /goal human transcript presentation', () => {
     await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
       timeout: 15_000,
     }).toBe(1)
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.fill('/goal')
     await input.press('Enter')
     await expect.poll(() => input.inputValue()).toBe('/goal ')

+ 1 - 1
apps/web/tests/goal-multi-turn-actions.e2e.ts

@@ -124,7 +124,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
 
   /** Submit the Goal command after arming the two-turn barrier. */
   async function runGoal(timeoutMs: number): Promise<SessionId> {
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = whenTurnsSettled(scaffold!, 2, timeoutMs)
     await input.fill(COMMAND)

+ 21 - 3
apps/web/tests/image-display.snapshot.ts

@@ -88,7 +88,13 @@ it('accepts pasted images into the composer rail in order and removes them', asy
 
   // Image-only send arming is pinned at package level (input-bar.spec.tsx);
   // this assembled lane pins the intake chain over the built graph.
-  const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+  const textarea = await waitFor(() => {
+    const surface = document.querySelector<HTMLElement>(
+      '[data-composer-input][data-placeholder="Describe what you want to build"]',
+    )
+    if (surface === null) throw new Error('composer surface missing')
+    return surface
+  }, { timeout: 10_000 })
   const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
   fireEvent.paste(textarea, {
     clipboardData: {
@@ -157,7 +163,13 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
   if (start === null) throw new Error('fixture Workspace new-session action missing')
   fireEvent.click(start)
-  const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+  const textarea = await waitFor(() => {
+    const surface = document.querySelector<HTMLElement>(
+      '[data-composer-input][data-placeholder="Describe what you want to build"]',
+    )
+    if (surface === null) throw new Error('composer surface missing')
+    return surface
+  }, { timeout: 10_000 })
 
   // A file drag anywhere over the page raises the full-viewport overlay whose
   // desc line carries the projected limits — copy that can only render after
@@ -206,7 +218,13 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
   if (start === null) throw new Error('fixture Workspace new-session action missing')
   fireEvent.click(start)
 
-  const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+  const textarea = await waitFor(() => {
+    const surface = document.querySelector<HTMLElement>(
+      '[data-composer-input][data-placeholder="Describe what you want to build"]',
+    )
+    if (surface === null) throw new Error('composer surface missing')
+    return surface
+  }, { timeout: 10_000 })
   const image = new File([new Uint8Array([137, 80, 78, 71])], 'too-wide.png', { type: 'image/png' })
   fireEvent.paste(textarea, {
     clipboardData: {

+ 4 - 4
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -73,9 +73,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     expect(snapshot).not.toContain('text: Skills')
     expect(snapshot).not.toContain('text: Subagents')
     const launchedBox = await menu.boundingBox()
-    await page.locator('textarea').first().press('Escape')
+    await page.locator('[data-composer-input]').first().press('Escape')
     await expect.poll(() => menu.count()).toBe(0)
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.fill('/')
     await menu.waitFor({ timeout: 10_000 })
     const typedBox = await menu.boundingBox()
@@ -103,7 +103,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
       await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
-      const input = activePage.locator('textarea').first()
+      const input = activePage.locator('[data-composer-input]').first()
       await activePage.getByRole('button', { name: 'Commands' }).click()
       const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
       await menu.waitFor({ timeout: 10_000 })
@@ -161,7 +161,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     // The blank frame renders the hero, not the resident composer: the
     // headline plus the guidance placeholder are the empty state's anchors.
     await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     if (MODE !== 'record') {
       // Golden of the hero's stable waiting state (captured before any send;

+ 3 - 3
apps/web/tests/live-interactions.e2e.ts

@@ -106,7 +106,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
    * can act mid-turn (the cancel scenario's whole point).
    */
   async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> {
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold!.whenTurnSettled(timeoutMs)
     await input.fill(PROMPT)
@@ -146,7 +146,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // Composer recovered; no streaming node lingers. The host settled first
     // (awaited above), but the abort frame reaches the browser over SSE — the
     // frozen-partial swap is eventually consistent, so poll rather than count.
-    await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
     // Golden of the aborted end-state: the prompt bubble plus the frozen
     // partial ('partial' is the hang entry's replayed prefix) and no more.
@@ -166,7 +166,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
     // AUTH is outside llm-retry's retryable set: no retry record.
     expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
-    await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
     const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
     await errorStatus.waitFor({ timeout: 10_000 })

+ 2 - 2
apps/web/tests/navigation-panes.e2e.ts

@@ -162,7 +162,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record'))
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
     for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) {
@@ -346,7 +346,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await ensureSeedOpen(observer)
 
     try {
-      const input = page.locator('textarea').first()
+      const input = page.locator('[data-composer-input]').first()
       const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 })
       await input.fill('/export')
       await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 })

+ 1 - 1
apps/web/tests/permission-policy-context.e2e.ts

@@ -94,7 +94,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
     }
 
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
     for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
       await input.fill(`/permission ${preset}`)

+ 1 - 1
apps/web/tests/plan-control-row.e2e.ts

@@ -77,7 +77,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
 
   it('keeps the plan chip and model trigger disjoint and exits plan mode by click', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-narrow-viewport'))
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     await input.fill('/plan ')
     await input.press('Enter')

+ 2 - 2
apps/web/tests/plan-review.e2e.ts

@@ -68,7 +68,7 @@ describe('web e2e: plan review takeover round trip', () => {
     if (MODE !== 'record') {
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
     await input.fill(LINE)
@@ -108,7 +108,7 @@ describe('web e2e: plan review takeover round trip', () => {
     // Card gone; regular input restored.
     expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
     expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
-    await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])

+ 2 - 2
apps/web/tests/question-composer.e2e.ts

@@ -64,7 +64,7 @@ describe('web e2e: resident question composer round trip', () => {
     if (MODE !== 'record') {
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
     await input.fill(PROMPT)
@@ -163,7 +163,7 @@ describe('web e2e: resident question composer round trip', () => {
     // Composer gone; regular input restored.
     expect(await page.locator('[data-question-key]').count()).toBe(0)
     expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
-    await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     // Golden of the answered transcript: the ask_user_question round trip
     // rendered as history (question tool row + DONE), composer takeover gone.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)

+ 2 - 2
apps/web/tests/queue-actions.e2e.ts

@@ -86,7 +86,7 @@ describe('web e2e: queue row actions', () => {
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
 
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     const firstSettled = scaffold.whenTurnSettled()
     await input.fill(ACTIVE_PROMPT)
     await input.press('Enter')
@@ -196,7 +196,7 @@ describe('web e2e: queue row actions', () => {
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
 
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     const settled = scaffold.whenTurnSettled()
     await input.fill('/goal Keep the composer context panels aligned')
     await input.press('Enter')

+ 10 - 7
apps/web/tests/reference-composer.e2e.ts

@@ -136,7 +136,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
   it('groups both sources and projects files and sessions as structured inline icon labels', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-composer'))
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
 
     await input.fill('@')
@@ -152,17 +152,20 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
     await input.fill('@reference')
     await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click()
-    const fileReference = page.locator('[data-reference-appearance="file"]')
-    await expect.poll(() => fileReference.textContent()).toBe('@reference.txt')
+    // The pick lands an atomic chip: a real DOM capsule carrying the domain
+    // icon and the label (the canonical reference text lives on the node and
+    // expands on submit; the surface text is the label plus the separator).
+    const fileReference = page.locator('[data-composer-chip]').last()
+    await expect.poll(() => fileReference.textContent()).toBe('reference.txt')
     await expect.poll(() => fileReference.locator('svg').count()).toBe(1)
-    await expect.poll(() => input.inputValue()).toBe('@reference.txt ')
+    await expect.poll(() => input.textContent()).toBe('reference.txt ')
 
     await input.fill('@Research')
     await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click()
-    const sessionReference = page.locator('[data-reference-appearance="session"]')
-    await expect.poll(() => sessionReference.textContent()).toBe('@Research notes')
+    const sessionReference = page.locator('[data-composer-chip]').last()
+    await expect.poll(() => sessionReference.textContent()).toBe('Research notes')
     await expect.poll(() => sessionReference.locator('svg').count()).toBe(1)
-    await expect.poll(() => input.inputValue()).toBe('@Research notes ')
+    await expect.poll(() => input.textContent()).toBe('Research notes ')
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 1 - 1
apps/web/tests/replay-round-trip.e2e.ts

@@ -66,7 +66,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
       // Drift guard: the committed fixture must carry exactly the drive prompt.
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     // Arm the host-side settled barrier BEFORE the send click.
     const settled = scaffold.whenTurnSettled()

+ 1 - 1
apps/web/tests/schedule-after.e2e.ts

@@ -357,7 +357,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
     const atSession = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
     await atSession.waitFor({ timeout: 15_000 })
     await atSession.click()
-    const composer = page.locator('textarea:enabled').last()
+    const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
     await composer.fill(AT_USER_PROMPT)
     const settled = scaffold.whenTurnSettled(60_000)
     await page.getByRole('button', { name: 'Send message', exact: true }).click()

+ 2 - 2
apps/web/tests/seeded-history.e2e.ts

@@ -216,7 +216,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
 
   it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record'))
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled()
     await input.fill(PROMPT)
@@ -491,7 +491,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     const previousDshHome = process.env.DSH_HOME
     process.env.DSH_HOME = scaffold.harnessHome
     try {
-      const input = page.locator('textarea').first()
+      const input = page.locator('[data-composer-input]').first()
       await input.fill('/feedback the diff view is unreadable')
       await input.press('Enter')
       const row = page.locator('[data-variant="others"]').filter({

+ 1 - 1
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -95,7 +95,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
 
   it('renders every user-invocable skill and marks the user-only entry', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.fill('/policy')
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
     await expect.poll(

+ 1 - 1
apps/web/tests/skill-user-invoke.e2e.ts

@@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
 
   it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
-    const composer = page.locator('textarea:enabled').last()
+    const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
     await composer.waitFor({ timeout: 15_000 })
 
     // The menu lists the user-only skill (its only entry point) before enter.

+ 2 - 2
apps/web/tests/smoke-real.e2e.ts

@@ -539,7 +539,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
     // step mounts and the page is immediately interactive.
     // Fresh world: connect a Workspace so the composer starts live.
     await connectFreshWorkspace(page, sessionsDir)
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const productTitle = await page.title()
     await screen(page, '02-empty-state')
@@ -591,7 +591,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
 
   it('bash differential rendering: tool row click leaves the default details column closed', async () => {
     onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
     await input.press('Enter')
     // Wait for the tool ROW, not response text (the reply echoes any marker).

+ 1 - 1
apps/web/tests/startup-auto-selection.e2e.ts

@@ -153,7 +153,7 @@ describe('web e2e: startup auto-selection', () => {
     await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
     expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
     expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
-    expect(await page.locator('textarea').first().isVisible()).toBe(true)
+    expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true)
 
     releaseHistory()
     await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')

+ 4 - 4
apps/web/tests/steering.e2e.ts

@@ -96,7 +96,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
       // both the opening prompt and the later same-turn steer.
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
     await input.fill(PROMPT)
@@ -200,7 +200,7 @@ describe('web e2e: composer shortcut steers directly', () => {
   it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering'))
     expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER])
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled(30_000)
     await input.fill(PROMPT)
@@ -263,7 +263,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
     await dialog.getByRole('button', { name: 'Steer' }).waitFor({ timeout: 10_000 })
     await page.keyboard.press('Escape')
 
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     const settled = scaffold.whenTurnSettled(30_000)
     await input.fill(PROMPT)
     await input.press('Enter')
@@ -323,7 +323,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
 
   it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all'))
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled(30_000)
 

+ 3 - 3
apps/web/tests/subagent-conversation.e2e.ts

@@ -94,7 +94,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     const parent = scaffold.ctx.agents.roots()[0]
     if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
     const parentSettled = scaffold.whenTurnSettled()
-    const parentInput = page.locator('textarea:enabled').first()
+    const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first()
     await parentInput.fill(PARENT_PROMPT)
     await parentInput.press('Enter')
     expect(await parentSettled).toBe(parent.id)
@@ -468,7 +468,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
     await page.getByRole('button', { name: '3 subagents' }).click()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
-    await page.locator('textarea:enabled').first().waitFor()
+    await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor()
     expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
 
     const forkResponse = page.waitForResponse(response =>
@@ -485,7 +485,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
     await page.getByRole('button', { name: '3 subagents' }).click()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
-    const input = page.locator('textarea:enabled').first()
+    const input = page.locator('[data-composer-input][contenteditable="true"]').first()
     await input.waitFor()
     const promptResponse = page.waitForResponse(response =>
       new URL(response.url()).pathname === '/api/subagent.prompt')

+ 1 - 1
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -154,7 +154,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     // One prompted parent turn makes the parent non-blank so the session
     // header (and its subagent catalog action) renders.
     const parentSettled = scaffold.whenTurnSettled()
-    const parentInput = page.locator('textarea:enabled').first()
+    const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first()
     await parentInput.fill('Ask a research subagent to explain event sourcing.')
     await parentInput.press('Enter')
     expect(await parentSettled).toBe(parent.id)

+ 3 - 3
apps/web/tests/support.ts

@@ -59,7 +59,7 @@ export function probeFreePort(): Promise<number> {
  * until the live composer unlocks. A fresh world has no Workspace, so the boot
  * lands in the Workspace-trigger view state (startup auto-selection has nothing to
  * select); every scenario that types into the composer must connect one
- * first. With nothing to list, activating the textarea raises the dialog directly —
+ * first. With nothing to list, activating the composer surface raises the dialog directly —
  * adding a workspace is the picker's only entry. The directory is staged here
  * and adopted through the path editor, which is idempotent across the repeated
  * connects a scenario may make; creating a folder from inside the dialog (the
@@ -83,7 +83,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
   await dialog.getByRole('button', { name: 'Open', exact: true }).click()
   // The pick connected the workspace: the blank session's live composer
   // replaces the locked placeholder and enables.
-  await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
+  await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]')
     .waitFor({ timeout: 15_000 })
 }
 
@@ -106,7 +106,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
   await pathInput.fill(join(root, name))
   await pathInput.press('Enter')
   await dialog.getByRole('button', { name: '打开', exact: true }).click()
-  await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
+  await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容"]')
     .waitFor({ timeout: 15_000 })
 }
 

+ 1 - 1
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -321,7 +321,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
         host.scrollTo = trackedScrollTo as typeof host.scrollTo
       })
       const settled = scaffold.whenTurnSettled()
-      const input = page.locator('textarea').first()
+      const input = page.locator('[data-composer-input]').first()
       await input.fill('Stream one deterministic response while Trajectory remains visible.')
       await input.press('Enter')
       await settled

+ 1 - 1
apps/web/tests/turn-tail-actions.e2e.ts

@@ -83,7 +83,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
 
   /** Send the recorded prompt with the settled barrier pre-armed (returned wrapped so the caller can act mid-turn). */
   async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> {
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold!.whenTurnSettled(timeoutMs)
     await input.fill(PROMPT)

+ 1 - 1
apps/web/tests/web-search-round.e2e.ts

@@ -184,7 +184,7 @@ describe('web e2e: shipped default web search', () => {
     if (MODE !== 'record') {
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
     }
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled()
     await input.fill(PROMPT)

+ 1 - 1
apps/web/tests/workflow-run.e2e.ts

@@ -71,7 +71,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
   it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live'))
     const settled = waitForParentSettlement()
-    const input = page.locator('textarea').first()
+    const input = page.locator('[data-composer-input]').first()
     await input.fill(prompt)
     await input.press('Enter')
 

+ 12 - 0
packages/client/tsdown.client.ts

@@ -460,6 +460,18 @@ function clientConfig(id: string, entry: string): UserConfig {
       // everything else is bundled.
       alwaysBundle: (specifier: string) => !isRequested(specifier),
     },
+    // Dual-mode libraries (lexical's exports carry development/production/
+    // node conditions; the node file picks its flavor with a top-level await
+    // a CJS bundle cannot carry) resolve their static flavor matching the
+    // NODE_ENV the defines below bake in.
+    inputOptions: {
+      resolve: {
+        conditionNames: [
+          (process.env.NODE_ENV ?? 'production') === 'development' ? 'development' : 'production',
+          'browser', 'import', 'module', 'default',
+        ],
+      },
+    },
     // Browser bundles inline node-idiom deps (zustand/immer read
     // process.env.NODE_ENV; zustand's esm build also probes
     // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags

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

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

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
packages/client/ui-conversation/README.md


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
packages/client/ui-conversation/README.zh.md


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

@@ -222,10 +222,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     /**
      * The default composer body: a single slot rendered as the composer
      * chain's fallback (a real entry, not a chain rider, so a
-     * takeover election hides rather than unmounts it and the textarea DOM
+     * takeover election hides rather than unmounts it and the composer DOM
      * survives). Session-maybe: the bar stays mounted across the
      * no-session/session transition — the no-workspace hero renders the SAME
-     * textarea DOM as a read-only Workspace-picker trigger instead of a
+     * composer surface as a read-only Workspace-picker trigger instead of a
      * parallel inert tree — with the machine hooks absent until a session is
      * current. InputBar registers
      * here from this package's apply; its machine state arrives through the

+ 1 - 0
packages/client/ui-conversation/src/client/input/facade.ts

@@ -389,6 +389,7 @@ export class SessionInputShell implements SessionInput {
    * The live selection as a detect-coordinate span (menu-launcher synthetic
    * hits replace it on pick); an absent selection answers a collapsed span at
    * the document end.
+   * @returns the ordered [start, end) span in detect coordinates.
    */
   caretSpan(): { start: number; end: number } {
     if (this.projection.selection !== null) return this.projection.selection

+ 3 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -415,7 +415,9 @@ export function InputBar({
               data-phase={input?.phase ?? 'inert'}
               aria-disabled={editorDisabled || undefined}
               data-placeholder={placeholderText}
-              aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : undefined}
+              // The placeholder was the textarea's accessible name; a div's
+              // data attribute is not, so the label restores it.
+              aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText}
               aria-haspopup={workspaceTrigger ? 'menu' : undefined}
               aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
               tabIndex={workspaceTrigger ? 0 : undefined}

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

@@ -208,8 +208,8 @@ describe('prompt rejection through the assembled composer', () => {
     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)
+    const conversation = runtime.ctx.get('conversation') as { input: unknown }
+    const shell = (conversation.input as InputHub).shell(SID)
     act(() => { shell.setDraft('do not lose this') })
     fireEvent.keyDown(composer, { key: 'Enter' })
     await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })

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

@@ -148,7 +148,7 @@ describe('ReferenceChipNode', () => {
         'getChildren' in block ? (block as ParagraphNode).getChildren() : []).find($isReferenceChipNode)
       expect(chip).toBeDefined()
       if (chip === undefined) return
-      const el = chip.createDOM()
+      const el = chip.createDOM({ namespace: 'core-spec', theme: {} } as never)
       expect(el.getAttribute('data-composer-chip')).toBe('session-reference')
       expect(el.getAttribute('contenteditable')).toBe('false')
       expect(chip.updateDOM()).toBe(false)

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

@@ -344,8 +344,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     key: 'conversation.composer.bar',
     kind: 'single',
     scope: 'session-maybe',
-    summary: 'The default composer body: a single slot rendered as the composer chain\'s fallback (a real entry, not a chain rider, so a takeover election hides rather than unmounts it and the textarea DOM survives).',
-    doc: 'The default composer body: a single slot rendered as the composer\nchain\'s fallback (a real entry, not a chain rider, so a\ntakeover election hides rather than unmounts it and the textarea DOM\nsurvives). Session-maybe: the bar stays mounted across the\nno-session/session transition — the no-workspace hero renders the SAME\ntextarea DOM as a read-only Workspace-picker trigger instead of a\nparallel inert tree — with the machine hooks absent until a session is\ncurrent. InputBar registers\nhere from this package\'s apply; its machine state arrives through the\nstandard provide channel (useInput + inputActions), the keyboard\ncommand face through its own inject.',
+    summary: 'The default composer body: a single slot rendered as the composer chain\'s fallback (a real entry, not a chain rider, so a takeover election hides rather than unmounts it and the composer DOM survives).',
+    doc: 'The default composer body: a single slot rendered as the composer\nchain\'s fallback (a real entry, not a chain rider, so a\ntakeover election hides rather than unmounts it and the composer DOM\nsurvives). Session-maybe: the bar stays mounted across the\nno-session/session transition — the no-workspace hero renders the SAME\ncomposer surface as a read-only Workspace-picker trigger instead of a\nparallel inert tree — with the machine hooks absent until a session is\ncurrent. InputBar registers\nhere from this package\'s apply; its machine state arrives through the\nstandard provide channel (useInput + inputActions), the keyboard\ncommand face through its own inject.',
     registerOptions: [],
     ownerProps: [
       '/**\n * Owner share of the composer-bar slot: ConversationRoot\'s layout-phase\n * inputs plus the input-region child-slot content it renders (the region\n * slots stay declared/rendered by the conversation entry; the bar hosts the\n * results as chrome).\n */\nexport interface ComposerBarOwnerProps {\n  /** Hero = empty-state centered card; composer = resident bottom bar. */\n  variant: \'hero\' | \'composer\'\n  /**\n   * A block another plugin raised for this session: the bar refuses input and\n   * shows the blocker\'s reason as the placeholder, but — unlike `disabled` —\n   * keeps the model seat live. Every block this contract has is one the user\n   * clears by choosing a model, so locking that seat too would leave the\n   * composer telling them to do the one thing it prevents.\n   */\n  blocked?: { readonly reason: string }\n  /**\n   * Inert no-workspace state: the bar locks message actions while preserving\n   * its normal DOM so the Workspace pick transitions in place.\n   */\n  disabled?: boolean\n  /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */\n  workspacePickerOpen?: boolean\n  /** Open the existing Workspace picker from the inert textarea. */ /* …truncated — full shape in source */',

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff