From c8b4ec73a072f930983bd7731d0ccf43657c037b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 22:48:56 +0800 Subject: [PATCH] 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. --- ...8-20-web-composer-lexical-editor.i18n.yaml | 4 +-- .../2026-08-20-web-composer-lexical-editor.md | 1 + ...26-08-20-web-composer-lexical-editor.zh.md | 1 + apps/web/tests/reference-composer.e2e.ts | 29 +++++++++++++++++++ .../src/client/input/editor/chip-node.tsx | 10 +++++-- .../tests/lexical-editor-core.client.spec.tsx | 2 +- 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml index 4ad81ad789..c27cfafcd9 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md index 7c8ae00e02..ddb04fd97a 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md +++ b/.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). diff --git a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md index 07c86a2917..f4435ed0ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md +++ b/.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 不携带引擎默认行为)。 diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 6f2d61bc7a..e802b44777 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/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/ }) diff --git a/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx b/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx index 02b32788f6..fa08a08575 100644 --- a/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx +++ b/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx @@ -133,9 +133,15 @@ export class ReferenceChipNode extends DecoratorNode { 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). */ diff --git a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx index f4b81638f0..312d96d971 100644 --- a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx +++ b/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 })