From 92b23cb7924ad41481f39edf192f261394dd9821 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 29 Sep 2026 17:11:02 +0800 Subject: [PATCH] fix(web): stop claim token decoration from looping on boundary input Typing at the end of the highlighted command token lands in the styled node. The decoration split the overflow back out, but splitText copied the token style onto it, so Lexical's normalization merged the halves again and the transform looped until Lexical aborted the update. The DOM kept the native insertion while the editor state rolled back, which left the composer desynchronized for every later keystroke. Clear the overflow style in the same transform pass. Refs #4490 --- .../src/client/input/editor/claim-decor.ts | 9 +++++-- .../tests/input-scenarios.client.spec.tsx | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts index 7e28273305..e0ed9d3e57 100644 --- a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts +++ b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts @@ -43,9 +43,14 @@ export function registerClaimDecoration(editor: LexicalEditor, activeToken: () = } if (text.length > token.length) { // Typing at the token boundary lands in the styled node; split the - // overflow back out so only the token itself carries the color. - const [tokenNode] = node.splitText(token.length) + // overflow back out so only the token itself carries the color. The + // split copies the token style onto the overflow; it is cleared in this + // pass because Lexical's normalization merges same-style neighbours + // before the overflow's own transform runs, which would rejoin the two + // halves and loop until Lexical aborts the update. + const [tokenNode, overflow] = node.splitText(token.length) if (tokenNode !== undefined && tokenNode.getStyle() !== TOKEN_STYLE) tokenNode.setStyle(TOKEN_STYLE) + overflow?.setStyle('') return } if (node.getStyle() !== TOKEN_STYLE) node.setStyle(TOKEN_STYLE) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index c4df0217e8..00b7cb2971 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -12,6 +12,7 @@ import './control-row-dom.ts' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { $getRoot, $isElementNode, $isTextNode } from 'lexical' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -340,6 +341,31 @@ describe('scenario H: backspace breaks the token', () => { expect(b.shell.snapshot.phase).toBe('plain') }) + it('keeps text typed at the end of the claimed token when the browser inserts it natively', async () => { + const b = await bench() + b.type('/goal') + await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) }) + fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 }) + expect(b.shell.snapshot.phase).toBe('claimed') + b.type('/goal 发布') + // A caret at the token/argument boundary sits at the end of the styled + // token node; native insertion writes there and Lexical syncs the DOM + // text with setTextContent plus a caret select. + act(() => { + b.shell.editor.update(() => { + const paragraph = $getRoot().getFirstChild() + const token = $isElementNode(paragraph) ? paragraph.getFirstChild() : null + if (!$isTextNode(token)) throw new Error('missing token node') + token.setTextContent('/goal 立即') + token.select(8, 8) + }, { discrete: true }) + }) + expect(b.shell.snapshot.draft).toBe('/goal 立即发布') + expect(b.shell.snapshot.phase).toBe('claimed') + act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) + expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe('/goal ') + }) + it('claim releases automatically; the enter after that goes through adjudication again', async () => { const b = await bench() b.type('/goal')