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

Merge pull request #4285 from deepseek-harness/fix/composer-draft-scroll-jump

fix(web): insert pasted composer text as line-break nodes
Chinesezjc 2 недель назад
Родитель
Сommit
0810a4403a

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.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/bug-fix/2026-09-15-composer-paste-line-breaks.md
+2026-09-15-composer-paste-line-breaks.md: 8411644c31b8b48dfd16ceb08815062bcb32f99f
+2026-09-15-composer-paste-line-breaks.zh.md: beb2f5ea7a75dd24392f5c28e99189f186a557e4

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.md

@@ -0,0 +1,29 @@
+# Agent Note: Composer paste inserts line breaks
+
+Status: implemented
+
+English | [中文](2026-09-15-composer-paste-line-breaks.zh.md)
+
+## Problem
+
+A pasted multi-line draft and a typed one hold the same text in different documents. The composer's paste path inserted clipboard text through `RangeSelection.insertText`, which keeps every newline inside one text node, while typing and the editor's own programmatic writes produce a line-break node per line.
+
+Inside a single text node the browser reports the collapsed caret's geometry as the block's. With a pasted draft scrolled to its end, deleting the trailing line made Lexical's collapsed-caret reveal — `scrollIntoViewIfNeeded`, reached from `$updateDOMSelection` — read a zero-height rect at the top of the block and write that offset into the composer's scrollport. The capped box then showed the head of the draft with the caret off screen, in a draft that still overflowed. The same document also gave Home and vertical caret movement block boundaries instead of line boundaries.
+
+## Decision
+
+[`DraftEditorRuntime.paste()`](../../../../packages/client/ui-conversation/src/client/input/editor/runtime.ts) inserts through `RangeSelection.insertRawText`, the path Lexical's own paste handler uses; its raw-text generator splits the input into text and line-break nodes. A pasted draft therefore carries the document a typed draft carries, and both project to the same draft text.
+
+## Alternatives considered
+
+**Keep `insertText` and correct the reveal in the composer view.** The composer's own `revealDraftSelection` never ran during the failing gesture: the write came from Lexical's selection reconciliation, which owns the caret reveal for a collapsed selection. Guarding that function would not have reached it, and suppressing Lexical's reveal would remove the caret tracking ordinary typing depends on.
+
+**Rewrite newlines inside the pasted text.** Normalizing the clipboard text before insertion leaves the document unchanged — the defect is the node structure the insertion produces, not the characters it receives.
+
+## Consequences
+
+Pasted newlines become line-break nodes instead of characters inside a text node. The clipboard projection, and with it the submitted prompt, the draft mirror, and trigger spans, is unchanged: `$composerLayout` projects a line-break node and a paragraph gap alike as one `\n`. Tab characters reach `TabNode` through the same raw-text path. The composer no longer relies on the browser reporting per-line geometry for a draft stored in one text node.
+
+## Testing
+
+[The composer scroll scenario](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) pins the behavior in a real browser: a pasted draft with a blank tail, scrolled to its end, holds `scrollTop === scrollMax` through an end-of-draft Backspace and through a selection delete of the trailing lines. Reverting `paste()` to `insertText` fails that case on `scrollTop === scrollMax` while the draft still overflows; the scenario's typed-draft cases and its committed geometry golden pass with either insertion path.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Composer 粘贴插入换行节点
+
+Status: implemented
+
+[English](2026-09-15-composer-paste-line-breaks.md) | 中文
+
+## 问题
+
+粘贴的多行草稿与逐行打字的草稿文本相同,但文档不同。Composer 的粘贴路径经 `RangeSelection.insertText` 插入剪贴板文本,会把每个换行都留在同一个文本节点内;而打字和编辑器自身的程序化写入会为每行产生一个换行节点。
+
+在单个文本节点内,浏览器把折叠光标的几何信息报告为整个块的几何信息。当粘贴草稿滚动到末尾时,删掉末尾那行会让 Lexical 自己的折叠光标 reveal(`scrollIntoViewIfNeeded`,由 `$updateDOMSelection` 到达)读到一个位于块顶部的零高度矩形,并把该偏移写进 composer 的滚动容器。于是这个带高度上限的框显示出草稿开头、光标落在屏幕外,而草稿本身仍然溢出。同一份文档还让 Home 键与纵向光标移动拿到块级边界,而不是行级边界。
+
+## 决策
+
+[`DraftEditorRuntime.paste()`](../../../../packages/client/ui-conversation/src/client/input/editor/runtime.ts) 改经 `RangeSelection.insertRawText` 插入,这也是 Lexical 自身粘贴处理器所用的路径;它的 raw-text 生成器把输入拆成文本节点与换行节点。因此粘贴草稿与打字草稿携带相同的文档,两者投影出相同的草稿文本。
+
+## 考虑过的替代方案
+
+**保留 `insertText`,在 composer 视图里修正 reveal。** 失败手势中 composer 自己的 `revealDraftSelection` 从未运行:那次写入来自 Lexical 的选区对账,折叠选区的光标 reveal 归它所有。给该函数加守卫无法触及它,而屏蔽 Lexical 的 reveal 又会去掉普通打字所依赖的光标跟随。
+
+**在粘贴文本内重写换行。** 插入前规范化剪贴板文本不会改变文档——缺陷在于插入产生的节点结构,而不在于它接收的字符。
+
+## 影响
+
+粘贴的换行成为换行节点,而不再是文本节点内的字符。剪贴板投影、并因此提交的 prompt、草稿镜像和触发词跨度都不变:`$composerLayout` 把换行节点与段落间隙同样投影为一个 `\n`。制表符经同一条 raw-text 路径落到 `TabNode`。composer 不再依赖浏览器为「整篇存于一个文本节点」的草稿报告逐行几何信息。
+
+## 测试
+
+[Composer 滚动场景](../../../../apps/web/tests/composer-draft-scroll.e2e.ts)在真实浏览器里固定该行为:尾部为空行的粘贴草稿滚动到末尾后,在草稿末尾按退格、以及选中末尾若干行后删除,都保持 `scrollTop === scrollMax`。把 `paste()` 退回 `insertText` 会让该用例在草稿仍然溢出时于 `scrollTop === scrollMax` 上失败;该场景中的打字草稿用例与已提交的几何 golden 在两种插入路径下都通过。

+ 92 - 0
apps/web/tests/composer-draft-scroll.e2e.ts

@@ -73,6 +73,50 @@ async function typeDraft(page: Page, rows: readonly string[]): Promise<void> {
   }
 }
 
+/**
+ * Replace the draft with one pasted block through the composer's own paste
+ * path (the keymap's ClipboardEvent route).
+ * @param page - the page under test.
+ * @param rows - draft lines, joined with '\n' — one paste, not one keystroke each.
+ */
+async function pasteDraft(page: Page, rows: readonly string[]): Promise<void> {
+  await typeDraft(page, [])
+  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 }))
+  }, rows.join('\n'))
+}
+
+/** The composer's scrollport alone, for measurements taken after an edit removed the marked lines. */
+interface ScrollportMetrics {
+  /** True when the draft is taller than the capped box. */
+  overflows: boolean
+  /** The composer's one scroll offset. */
+  scrollTop: number
+  /** Furthest that offset can go. */
+  scrollMax: number
+}
+
+/**
+ * Measure the composer's scrollport without addressing the draft's marked lines.
+ * @param page - the page under test.
+ * @returns the offset, its maximum, and whether the draft overflows.
+ */
+function measureScrollport(page: Page): Promise<ScrollportMetrics> {
+  return page.evaluate(() => {
+    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 surface is not inside a draft scrollport')
+    return {
+      overflows: scroll.scrollHeight > scroll.clientHeight,
+      scrollTop: scroll.scrollTop,
+      scrollMax: scroll.scrollHeight - scroll.clientHeight,
+    }
+  })
+}
+
 /** 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. */
@@ -288,6 +332,54 @@ describe('web e2e: composer draft scrolling', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('deleting a pasted draft\'s trailing lines keeps the box at the end', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste-delete'))
+    // A pasted block is where a draft's structure and a typed one diverge: a
+    // paste that keeps its newlines inside one text node leaves the browser
+    // revealing the caret from a rect that spans the whole block, which jumps
+    // the capped box back to the draft's head on the next edit at its end.
+    await pasteDraft(page, [...DRAFT_ROWS, '', '', ''])
+    await expect.poll(async () => (await measureScrollport(page)).overflows, { timeout: 10_000 }).toBe(true)
+    await surface(page).hover()
+    await page.mouse.wheel(0, 4000)
+    await expect.poll(async () => {
+      const m = await measureScrollport(page)
+      return m.scrollTop === m.scrollMax
+    }, { timeout: 10_000 }).toBe(true)
+    // The caret at the draft's end, then the trailing lines removed one at a
+    // time; the box must stay at the end, where the caret is. Select-all plus
+    // ArrowRight lands the caret on the document end; the collapse has to be
+    // observable before Backspace, or the whole selection is what gets deleted.
+    const input = surface(page)
+    await input.click()
+    await page.keyboard.press('ControlOrMeta+KeyA')
+    await page.keyboard.press('ArrowRight')
+    await expect.poll(
+      async () => page.evaluate(() => window.getSelection()?.isCollapsed ?? false),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('Backspace')
+    await expect.poll(async () => (await measureScrollport(page)).overflows, { timeout: 10_000 }).toBe(true)
+    await expect.poll(async () => {
+      const m = await measureScrollport(page)
+      return m.scrollTop === m.scrollMax
+    }, { timeout: 10_000 }).toBe(true)
+    // The box is at its furthest offset rather than back at the draft's head.
+    const afterBackspace = await measureScrollport(page)
+    expect(afterBackspace.scrollMax).toBeGreaterThan(0)
+    expect(afterBackspace.scrollTop).toBe(afterBackspace.scrollMax)
+    // The same failure through the selection gesture: select the trailing
+    // lines and delete them at once.
+    await page.keyboard.press('Shift+ArrowUp')
+    await page.keyboard.press('Shift+ArrowUp')
+    await page.keyboard.press('Delete')
+    await expect.poll(async () => {
+      const m = await measureScrollport(page)
+      return m.overflows && m.scrollTop === m.scrollMax
+    }, { timeout: 10_000 }).toBe(true)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('matches the committed composer scroll geometry golden', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
     // Restore the pristine draft (the edit cases appended to it) and return to

+ 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: 3c2697487ffe5b79bc8209eb9be953cc70977d79
-README.zh.md: 137357eb133a42debbce3232ca7b0858a26ee8c1
+README.md: afafb1d9fd579a0d5fdd6ed004ed081ff8c38483
+README.zh.md: ad688a674e70d2a447cdedeaec9a2621a644d47d

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -52,7 +52,7 @@ The shell reads the persisted View preference before rendering when a Session fi
 
 Active transcripts expose content-width drag handles in their uncovered side gutters. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)).
 
-The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, pasted text enters through the raw-text path so its newlines become the same line-break nodes a typed draft carries, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -52,7 +52,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,粘贴文本经 raw-text 路径进入,其换行成为打字草稿所携带的同种换行节点,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 

+ 9 - 5
packages/client/ui-conversation/src/client/input/editor/runtime.ts

@@ -162,9 +162,13 @@ export class DraftEditorRuntime {
 
   /**
    * Insert pasted plain text over the current editor selection
-   * (placeholder-sanitized). The paste event's own default is suppressed by
-   * the caller; PASTE_TAG makes the paste its own history boundary, so one
-   * undo never removes both the paste and typing inside the merge window.
+   * (placeholder-sanitized). The raw-text path is what splits pasted newlines
+   * into line-break nodes — the document a typed draft already has; a text node
+   * holding them instead leaves the browser reporting the whole block as the
+   * collapsed caret's geometry, which removes the scrollport's offset on the
+   * next edit. The paste event's own default is suppressed by the caller;
+   * PASTE_TAG makes the paste its own history boundary, so one undo never
+   * removes both the paste and typing inside the merge window.
    * @param text - pasted plain text.
    */
   paste(text: string): void {
@@ -173,14 +177,14 @@ export class DraftEditorRuntime {
     this.applyEdit(() => {
       const selection = $getSelection()
       if ($isRangeSelection(selection)) {
-        selection.insertText(clean)
+        selection.insertRawText(clean)
         return
       }
       // No selection yet (never-focused surface): land at the document end,
       // growing the first paragraph when the tree is empty.
       const root = $getRoot()
       if (root.getChildrenSize() === 0) root.append($createParagraphNode())
-      root.selectEnd().insertText(clean)
+      root.selectEnd().insertRawText(clean)
     }, PASTE_TAG)
   }