diff --git a/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx b/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx index a25e60aa58..bcec785d65 100644 --- a/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx +++ b/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx @@ -7952,3 +7952,82 @@ describe('IME composition handling', () => { expect(onChange.mock.calls[0][0].detail.value).toBe('가나'); }); }); + +describe('tokens takes precedence over value', () => { + test('empty tokens array still activates token mode, ignoring value', () => { + const { wrapper } = renderTokenMode({ props: { value: 'hello' } }); + expect(wrapper.findContentEditableElement()).not.toBeNull(); + expect(getValue(wrapper)).toBe(''); + }); + + test('content is derived from tokens, not value, when both are provided', () => { + const { wrapper } = renderTokenMode({ + props: { tokens: [{ type: 'text', value: 'from-tokens' }], value: 'from-value' }, + }); + expect(getValue(wrapper)).toBe('from-tokens'); + }); + + test('onAction fires with tokens-derived value, not the value prop', () => { + const onAction = jest.fn(); + const { wrapper } = renderTokenMode({ + props: { + onAction, + tokens: [{ type: 'text', value: 'from-tokens' }], + value: 'from-value', + }, + }); + wrapper.findActionButton()!.click(); + expect(onAction).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ value: 'from-tokens' }), + }) + ); + }); + + test('updating the value prop does not trigger the token-mode height effect', () => { + const rafSpy = jest.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0); + try { + const tokens: PromptInputProps['tokens'] = [{ type: 'text', value: 'hello' }]; + const { rerender } = renderTokenMode({ props: { tokens, value: 'initial' } }); + const callsAfterMount = rafSpy.mock.calls.length; + expect(callsAfterMount).toBeGreaterThan(0); + + rerender( + + ); + + expect(rafSpy.mock.calls.length).toBe(callsAfterMount); + } finally { + rafSpy.mockRestore(); + } + }); + + test('updating tokens still triggers the token-mode height effect', () => { + const rafSpy = jest.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0); + try { + const { rerender } = renderTokenMode({ props: { tokens: [{ type: 'text', value: 'hello' }] } }); + const callsAfterMount = rafSpy.mock.calls.length; + + rerender( + + ); + + expect(rafSpy.mock.calls.length).toBeGreaterThan(callsAfterMount); + } finally { + rafSpy.mockRestore(); + } + }); +}); diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index 5eec9deefd..2e4ec2a0ee 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -116,7 +116,9 @@ const InternalPromptInput = React.forwardRef( ), }; - const isTokenMode = !!tokens && supportsTokenMode; + // Empty tokens array is valid token-mode state; only fall back to + // textarea mode when the prop is absent. + const isTokenMode = tokens !== undefined && supportsTokenMode; if (isDevelopment) { if ((menus || tokens) && !supportsTokenMode) { @@ -173,13 +175,20 @@ const InternalPromptInput = React.forwardRef( } }); + // Height is adjusted per-mode. `value` must not be a dependency in token + // mode — it's unused there, and re-running the effect on `value` changes + // resets the contentEditable caret to offset 0. useEffect(() => { if (isTokenMode) { requestAnimationFrame(() => adjustInputHeight()); - } else { + } + }, [isTokenMode, tokens, adjustInputHeight, isCompactMode, placeholder]); + + useEffect(() => { + if (!isTokenMode) { adjustInputHeight(); } - }, [isTokenMode, tokens, adjustInputHeight, value, isCompactMode, placeholder]); + }, [isTokenMode, value, adjustInputHeight, isCompactMode, placeholder]); const plainTextValue = isTokenMode ? tokensToText