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.
This commit is contained in:
Yichen Jiang committed 2026-08-20 22:48:56 +08:00
1 parent 315fc9b16e
commit c8b4ec73a0
6 files changed
+42 -5

No files matched your search

@@ -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
@@ -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).
@@ -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
View File
@@ -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/ })
@@ -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). */
@@ -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 })