Procházet zdrojové kódy

fix(ui-conversation): step across chips without a keyboard-selected state

isKeyboardSelectable() defaulted to true, so an arrow key at a chip edge
created a NodeSelection whose DOM projection collapses to an element
point; the plain-text binding's arrow/delete/insert handlers all bail on
non-Range selections, deadlocking arrows, typing, and Backspace at the
chip until a pointer click. False restores the placeholder semantics:
arrows cross the chip in one move and Backspace/Delete remove it whole.
Reproduced and verified with real-key Playwright probes (CDP raw
keydowns carry no engine default and cannot reproduce it); the
reference-composer e2e pins the gesture in the browser lane.
Yichen Jiang před 3 týdny
rodič
revize
c8b4ec73a0

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.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 .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md
-2026-08-20-web-composer-lexical-editor.md: 7c8ae00e028d8deaf4a24733d67451bd01998eb9
-2026-08-20-web-composer-lexical-editor.zh.md: 07c86a2917409adfdf1453702ea552505fe823d0
+2026-08-20-web-composer-lexical-editor.md: ddb04fd97a653dac9e1cbef83e432875b1e40280
+2026-08-20-web-composer-lexical-editor.zh.md: f4435ed0ec3f9b9acf6acd72b0d7a9c6bfc3f28a

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

@@ -58,3 +58,4 @@ The mirror/backdrop layers and their CSS coupling rules; the Safari soft-wrap re
 - 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.
+- `isKeyboardSelectable()` must be **false** on the chip. With the default `true`, an arrow at the chip edge creates a NodeSelection whose DOM projection collapses to an element point, and the plain-text binding's arrow/delete/insert handlers all bail on non-Range selections — arrows, typing, and Backspace deadlock at the chip until a pointer click. False restores the placeholder semantics: arrows step across in one move, Backspace/Delete remove the chip whole (browser-lane e2e pins the gesture; only a real key event reproduces it — CDP raw keydowns carry no engine default).

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

@@ -58,3 +58,4 @@ mirror/backdrop 层及其 CSS 耦合规则;Safari 软换行修复(2026-08-13
 - 历史恢复(`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,要么推进越过窗口。
+- chip 的 `isKeyboardSelectable()` 必须为 **false**。取默认值 `true` 时,方向键落在 chip 边缘会创建 NodeSelection,其 DOM 投影坍塌为 element point,而 plain-text binding 的方向键/删除/插入 handler 全都对非 Range selection 直接放弃——方向键、打字与退格在 chip 边死锁,直到鼠标点击才能解除。false 恢复占位符语义:方向键一步跨过,Backspace/Delete 整颗删除(浏览器 lane e2e 钉住该手势;只有真实按键事件能复现——CDP 裸 keydown 不携带引擎默认行为)。

+ 29 - 0
apps/web/tests/reference-composer.e2e.ts

@@ -200,6 +200,35 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     expect(tripwire.warnings).toEqual([])
   })
 
+  it('arrows step across a chip in one move and Backspace removes it whole', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-keyboard'))
+    const input = page.locator('[data-composer-input]').first()
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+
+    await input.fill('@reference')
+    await menu.getByRole('option', { name: /File · reference\.txt/ }).click()
+    await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
+
+    // First ArrowLeft crosses the trailing space; the second steps across the
+    // chip in one move — no keyboard-selected intermediate state — and typing
+    // continues normally on the far side.
+    await page.keyboard.press('ArrowLeft')
+    await page.keyboard.press('ArrowLeft')
+    await page.keyboard.type('pre')
+    await expect.poll(() => input.textContent()).toBe('prereference.txt ')
+    await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
+
+    // ArrowRight steps back across the chip; Backspace directly behind it
+    // removes the whole chip in one keystroke.
+    await page.keyboard.press('ArrowRight')
+    await page.keyboard.press('Backspace')
+    await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0)
+    await expect.poll(() => input.textContent()).toBe('pre ')
+
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
   it('renders the durable direct-message then recall order', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
     const group = page.getByRole('treeitem', { name: /Ungrouped/ })

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

@@ -133,9 +133,15 @@ export class ReferenceChipNode extends DecoratorNode<JSX.Element> {
     return true
   }
 
-  /** Arrow keys and Backspace address the chip as one unit. */
+  /**
+   * No keyboard-selected intermediate state: arrows step across the chip in
+   * one move and Backspace/Delete remove it whole (the placeholder semantics
+   * of the old textarea). `true` would put a NodeSelection between the
+   * keystroke and the caret — a state the plain-text binding's handlers all
+   * ignore, deadlocking arrows, typing, and deletion at the chip edge.
+   */
   override isKeyboardSelectable(): boolean {
-    return true
+    return false
   }
 
   /** Clipboard / persistence projection (native copy reads this). */

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

@@ -87,7 +87,7 @@ describe('ReferenceChipNode', () => {
       expect(chip.getTextContent()).toBe('@session:随意回复不调用工具')
       expect(chip.isInvalid()).toBe(false)
       expect(chip.isInline()).toBe(true)
-      expect(chip.isKeyboardSelectable()).toBe(true)
+      expect(chip.isKeyboardSelectable()).toBe(false)
       expect($isReferenceChipNode(chip)).toBe(true)
       expect($isReferenceChipNode($createTextNode('x'))).toBe(false)
     }, { discrete: true })