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