diff --git a/src/renderer/src/components/native-chat/native-chat-question-card-composition-loss.test.tsx b/src/renderer/src/components/native-chat/native-chat-question-card-composition-survival.test.tsx similarity index 59% rename from src/renderer/src/components/native-chat/native-chat-question-card-composition-loss.test.tsx rename to src/renderer/src/components/native-chat/native-chat-question-card-composition-survival.test.tsx index 72eba09bd12..e17b0bcd95e 100644 --- a/src/renderer/src/components/native-chat/native-chat-question-card-composition-loss.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-question-card-composition-survival.test.tsx @@ -2,27 +2,37 @@ /** A question card fully replaces the composer (`NativeChatView.tsx`, * `{questionActive ? null : }`) because the card supplies - * its own answer input. That swap unmounts the composer, so a Hangul preedit - * in flight when an AskUserQuestion card arrives is lost: the composer never - * receives `compositionend`, so the preedit is never committed to the draft, - * and the remounted textarea restores only the committed text via - * `defaultValue={draft}`. + * its own answer input. That swap unmounts the composer mid-composition, and + * the composer never receives `compositionend`. The preedit survives anyway, + * and this file pins the chain that carries it. + * + * A real IME fires `input` (inputType `insertCompositionText`) for every + * preedit mutation — see the observation-derived sequence in + * `tests/e2e/terminal-ime-observed-event-sequences.ts`. React dispatches + * `onChange` on those events with no composition gate (react-dom's + * `getTargetInstForInputOrChangeEvent` keys only on `input`/`change`), so + * `handleDraftChange` runs per keystroke and `setDraft` writes the preedit + * straight through to the draft cache. The remounted textarea then restores it + * via `defaultValue={draft}`. `compositionend` is a reconciliation fallback, + * not the commit path. + * + * An earlier revision of this file characterized the preedit as LOST. That was + * a simulation artifact: it assigned `textarea.value` directly, with no `input` + * event, which no IME does. Drive composition through the frames below or the + * loss reappears as an artifact rather than a defect. * * THIS OWNS NO REPORTED ROW. It is not a regression guard for #12118 or * STA-3219. Those reporters describe continuous flicker keyed to streaming * token counters and elapsed timers, and those drivers provably do not remount * the composer — `native-chat-composer-autogrow.test.tsx` holds node identity * across 120 such rerenders. An AskUserQuestion card arrives once per question, - * which does not match that cadence. This is filed as a hazard guard for a real - * preedit-loss path found while searching for those rows' owner. + * which does not match that cadence. * - * It characterizes CURRENT behavior, including the loss itself. Committing the - * preedit before the swap (or keeping the composer mounted) is a fix, not a - * regression — it will fail this file, and the expectations below should then - * be updated to the new contract rather than worked around. - * - * The card owning its own input is by design; losing the in-flight composition - * is not. Geometry is deliberately unasserted: happy-dom has no layout engine. */ + * Two residual facts are deliberately unasserted because no JS owns them: the + * OS aborts the composition when the field disappears, so the surviving text + * returns committed (a lone jamo comes back as a compatibility jamo the user + * cannot compose onto), and the remounted composer is unfocused because the + * card owned focus. Geometry too: happy-dom has no layout engine. */ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' @@ -112,7 +122,10 @@ vi.mock('./use-native-chat-session-options', () => ({ })) import NativeChatView from './NativeChatView' -import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' // Preload bridge surface the composer subscribes to on mount. beforeAll(() => { @@ -160,19 +173,29 @@ function setInteractivePrompt(prompt: string | null, rendered: ReturnType { - it('drops an active Hangul preedit but keeps the committed draft', () => { + it('keeps an active Hangul preedit across the swap', () => { const rendered = render(view()) const before = composerTextarea() expect(before).not.toBeNull() - // Committed draft is "abc"; the user is now composing Hangul onto the end, - // so "가" lives only in the browser-owned value, not in React state. + // Committed draft is "abc"; the user now composes Hangul onto the end, + // one jamo per frame. fireEvent.change(before!, { target: { value: 'abc' } }) fireEvent.compositionStart(before!) - before!.value = 'abc가' + composeFrame(before!, 'abcㄱ', 'ㄱ') + composeFrame(before!, 'abc가', '가') - // An AskUserQuestion card arrives mid-composition and takes the input region. + // An AskUserQuestion card arrives mid-composition and takes the input + // region. No compositionend ever reaches the composer. setInteractivePrompt(ASK_USER_QUESTION, rendered) expect(composerTextarea()).toBeNull() expect(before!.isConnected).toBe(false) @@ -182,11 +205,49 @@ describe('native chat question card vs an in-flight composition', () => { const after = composerTextarea() expect(after).not.toBeNull() - // The reporter-facing shape: a different node, committed text intact, and - // the preedit gone because no compositionend ever reached the composer. + // A different node — the swap does remount — carrying the whole in-flight + // text, preedit included. expect(after).not.toBe(before) - expect(after!.value).toBe('abc') - expect(after!.value).not.toContain('가') + expect(after!.value).toBe('abc가') + }) + + it('keeps the live conversion candidate of a multi-segment Japanese composition', () => { + const rendered = render(view()) + const before = composerTextarea() + expect(before).not.toBeNull() + + // Japanese stays composing far longer than Hangul: kana first, then + // per-segment conversion, all inside one composition. + fireEvent.compositionStart(before!) + composeFrame(before!, 'か', 'か') + composeFrame(before!, 'かんじ', 'かんじ') + composeFrame(before!, 'かんじへんかん', 'かんじへんかん') + composeFrame(before!, '漢字へんかん', '漢字へんかん') + // Second segment converted; the card lands on this candidate. + composeFrame(before!, '漢字変換', '漢字変換') + + setInteractivePrompt(ASK_USER_QUESTION, rendered) + setInteractivePrompt(null, rendered) + + const after = composerTextarea() + expect(after).not.toBeNull() + // The candidate the user was looking at, not the kana it started from. + expect(after!.value).toBe('漢字変換') + }) + + it('holds the preedit in the draft cache while the card owns the input region', () => { + const rendered = render(view()) + const before = composerTextarea() + expect(before).not.toBeNull() + + fireEvent.compositionStart(before!) + composeFrame(before!, '가', '가') + + // The mechanism, pinned directly: onChange fires on composition `input` + // frames, so the draft cache — not the unmounted DOM node — is what carries + // the preedit across the swap. Gating onChange on `isComposing` breaks here. + setInteractivePrompt(ASK_USER_QUESTION, rendered) + expect(readNativeChatDraftCache(PANE_KEY)).toBe('가') }) it('leaves an ordinary committed English draft unchanged through the same swap', () => {