Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 298 additions & 12 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.0.0",
"jsdom": "^29.0.1",
"sass": "^1.93.2",
"typescript-eslint": "^8.29.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.29.0",
"vite": "^7.0.4",
"vitest": "^4.1.0"
}
Expand Down
136 changes: 136 additions & 0 deletions src/web-ui/src/flow_chat/components/RichTextInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React, { act, createRef, forwardRef, useImperativeHandle, useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRoot, type Root } from 'react-dom/client';
import RichTextInput from './RichTextInput';
import type { ContextItem } from '../../shared/types/context';

type HarnessHandle = {
setValue: (value: string) => void;
};

const emptyContexts: ContextItem[] = [];

let JSDOMCtor: (new (
html?: string,
options?: { pretendToBeVisual?: boolean }
) => { window: Window & typeof globalThis }) | null = null;

try {
const jsdom = await import('jsdom');
JSDOMCtor = jsdom.JSDOM as typeof JSDOMCtor;
} catch {
JSDOMCtor = null;
}

const ControlledHarness = forwardRef<HarnessHandle>(function ControlledHarness(_, ref) {
const [value, setValue] = useState('hello');

useImperativeHandle(ref, () => ({
setValue,
}), []);

return (
<RichTextInput
value={value}
onChange={(nextValue) => setValue(nextValue)}
contexts={emptyContexts}
onRemoveContext={() => {}}
/>
);
});

const describeWithJsdom = JSDOMCtor ? describe : describe.skip;

describeWithJsdom('RichTextInput external sync', () => {
let dom: { window: Window & typeof globalThis };
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
dom = new JSDOMCtor!('<!doctype html><html><body></body></html>', {
pretendToBeVisual: true,
});

const { window } = dom;
vi.stubGlobal('window', window);
vi.stubGlobal('document', window.document);
vi.stubGlobal('navigator', window.navigator);
vi.stubGlobal('Node', window.Node);
vi.stubGlobal('Text', window.Text);
vi.stubGlobal('Element', window.Element);
vi.stubGlobal('HTMLElement', window.HTMLElement);
vi.stubGlobal('HTMLDivElement', window.HTMLDivElement);
vi.stubGlobal('HTMLSpanElement', window.HTMLSpanElement);
vi.stubGlobal('DocumentFragment', window.DocumentFragment);
vi.stubGlobal('Range', window.Range);
vi.stubGlobal('Selection', window.Selection);
vi.stubGlobal('NodeFilter', window.NodeFilter);
vi.stubGlobal('Event', window.Event);
vi.stubGlobal('InputEvent', window.InputEvent);
vi.stubGlobal('getSelection', window.getSelection.bind(window));
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);

vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', () => {});
window.requestAnimationFrame = globalThis.requestAnimationFrame;
window.cancelAnimationFrame = globalThis.cancelAnimationFrame;

container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
dom.window.close();
vi.unstubAllGlobals();
});

async function renderHarness(ref: React.RefObject<HarnessHandle>) {
await act(async () => {
root.render(<ControlledHarness ref={ref} />);
});

const editor = container.querySelector('.rich-text-input');
expect(editor).toBeInstanceOf(HTMLDivElement);
return editor as HTMLDivElement;
}

it('keeps the existing DOM node when parent echoes local input', async () => {
const harnessRef = createRef<HarnessHandle>();
const editor = await renderHarness(harnessRef);

expect(editor.textContent).toBe('hello');
const originalTextNode = editor.firstChild;
expect(originalTextNode).toBeInstanceOf(Text);

await act(async () => {
(originalTextNode as Text).textContent = 'hello!';
editor.dispatchEvent(new window.Event('input', { bubbles: true }));
});

expect(editor.textContent).toBe('hello!');
expect(editor.firstChild).toBe(originalTextNode);
});

it('replaces the DOM node when value changes externally', async () => {
const harnessRef = createRef<HarnessHandle>();
const editor = await renderHarness(harnessRef);

const originalTextNode = editor.firstChild;
expect(originalTextNode).toBeInstanceOf(Text);

await act(async () => {
harnessRef.current?.setValue('server rewrite');
});

expect(editor.textContent).toBe('server rewrite');
expect(editor.firstChild).not.toBe(originalTextNode);
});
});
23 changes: 10 additions & 13 deletions src/web-ui/src/flow_chat/components/RichTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import React, { useRef, useEffect, useCallback, useState } from 'react';
import type { ContextItem } from '../../shared/types/context';
import { getRichTextExternalSyncAction } from './richTextInputSync';
import './RichTextInput.scss';

/** @ mention state */
Expand Down Expand Up @@ -121,7 +122,6 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
const isComposingRef = useRef(false);
const lastContextIdsRef = useRef<Set<string>>(new Set());
const mentionStateRef = useRef<MentionState>({ isActive: false, query: '', startOffset: 0 });
const isLocalChangeRef = useRef(false);

// Create tag element with pill style
const createTagElement = useCallback((context: ContextItem): HTMLSpanElement => {
Expand Down Expand Up @@ -395,7 +395,6 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
);
const visibleContexts = contexts.filter(context => visibleContextIds.has(context.id));

isLocalChangeRef.current = true;
onChange(textContent, visibleContexts);

// Ensure detection runs after DOM updates
Expand Down Expand Up @@ -599,14 +598,9 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
}, [insertTagAtCursor, insertTagReplacingMention, openMention, onMentionStateChange, internalRef]);

// Initialize and sync value changes from external sources.
// Skip syncing when the change originated from local user input
// to avoid resetting the cursor position.
// This editor is effectively controlled by comparing the parent's value
// with the current DOM content, rather than tracking a "skip next sync" flag.
useEffect(() => {
if (isLocalChangeRef.current) {
isLocalChangeRef.current = false;
return;
}

const editor = internalRef.current;
if (!editor) return;

Expand All @@ -620,15 +614,18 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
}

const currentContent = extractTextContent();
const syncAction = getRichTextExternalSyncAction(value, currentContent);

// If value is empty, clear editor content
if (!value && currentContent !== '') {
if (syncAction === 'noop') {
return;
}

if (syncAction === 'clear') {
editor.textContent = '';
return;
}

// External updates require syncing
if (value && value !== currentContent) {
if (syncAction === 'replace') {
editor.textContent = value;

// Restore cursor to the end
Expand Down
20 changes: 20 additions & 0 deletions src/web-ui/src/flow_chat/components/richTextInputSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { getRichTextExternalSyncAction } from './richTextInputSync';

describe('getRichTextExternalSyncAction', () => {
it('does nothing when parent value already matches DOM content', () => {
expect(getRichTextExternalSyncAction('hello', 'hello')).toBe('noop');
});

it('clears the DOM when parent value becomes empty', () => {
expect(getRichTextExternalSyncAction('', 'hello')).toBe('clear');
});

it('replaces the DOM when parent value diverges from current content', () => {
expect(getRichTextExternalSyncAction('server rewrite', 'hello')).toBe('replace');
});

it('does nothing when both parent value and DOM are empty', () => {
expect(getRichTextExternalSyncAction('', '')).toBe('noop');
});
});
16 changes: 16 additions & 0 deletions src/web-ui/src/flow_chat/components/richTextInputSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type RichTextExternalSyncAction = 'noop' | 'clear' | 'replace';

export function getRichTextExternalSyncAction(
value: string,
currentContent: string
): RichTextExternalSyncAction {
if (value === currentContent) {
return 'noop';
}

if (!value) {
return currentContent ? 'clear' : 'noop';
}

return 'replace';
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useFontPreference } from '../hooks/useFontPreference';
import { FontSizeLevel, UI_FONT_SIZE_PRESETS } from '../types';
import './FontPreferencePanel.scss';

const UI_LEVELS: FontSizeLevel[] = ['compact', 'small', 'default', 'medium', 'large'];
const UI_LEVELS: Array<Exclude<FontSizeLevel, 'custom'>> = ['compact', 'small', 'default', 'medium', 'large'];
const FLOW_CHAT_PX_OPTIONS = [12, 13, 14, 15, 16, 17, 18, 19, 20];

export function FontPreferencePanel() {
Expand Down
Loading