From 53cc3df502322b46c671b978d03dfc57382f4921 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 10:50:48 -0700 Subject: [PATCH 1/3] fix(chat): re-measure the prompt editor when its width changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat input's textarea grows to its full content height under a mirror overlay, but it only re-measured on text change. A width change after typing (window resize, sidebar toggle, resource panel opening) left the textarea at a stale inline height while the overlay rewrapped taller. The spilled lines still painted and scrolled but had no textarea beneath them, so clicks landed on the scroller and never placed a caret. Re-measure on width change only — the measure writes the textarea's height, so reacting to height would feed itself. --- .../prompt-editor/prompt-editor.test.tsx | 211 ++++++++++++++++++ .../prompt-editor/prompt-editor.tsx | 35 ++- 2 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx new file mode 100644 index 00000000000..94b0ecdccbc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx @@ -0,0 +1,211 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) +vi.mock('@/blocks/integration-matcher', () => ({ + getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), +})) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown', + () => ({ PlusMenuDropdown: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown', + () => ({ SkillsMenuDropdown: () => null }) +) + +import { PromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor' +import { usePromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' + +/** + * jsdom performs no layout, so the autosize inputs are stubbed: `editorWidth` + * stands for the scroller's content width and `contentHeight` for the height + * the text wraps to at that width. Narrowing raises the content height, exactly + * as rewrapping does in a browser. + */ +let contentHeight = 240 +let editorWidth = 700 +let autosizeCalls = 0 + +/** + * Mirrors the real observer's contract closely enough to test the width guard: + * `observe` delivers an initial notification for the current size (browsers do), + * and {@link resizeTo} delivers subsequent ones. + */ +class FakeResizeObserver implements ResizeObserver { + private static instances: FakeResizeObserver[] = [] + private readonly callback: ResizeObserverCallback + private targets: Element[] = [] + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + FakeResizeObserver.instances.push(this) + } + + observe(target: Element) { + this.targets.push(target) + this.deliver() + } + + unobserve(target: Element) { + this.targets = this.targets.filter((t) => t !== target) + } + + disconnect() { + this.targets = [] + FakeResizeObserver.instances = FakeResizeObserver.instances.filter((i) => i !== this) + } + + deliver() { + const entries = this.targets.map( + (target) => ({ target, contentRect: { width: editorWidth } }) as ResizeObserverEntry + ) + if (entries.length > 0) this.callback(entries, this) + } + + static reset() { + FakeResizeObserver.instances = [] + } + + static observerCount() { + return FakeResizeObserver.instances.length + } + + /** Delivers a resize notification to every live observer, as a reflow would. */ + static deliverAll() { + for (const instance of [...FakeResizeObserver.instances]) instance.deliver() + } +} + +function mountEditor() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + function Probe() { + const editor = usePromptEditor({ workspaceId: 'ws-1', initialValue: 'a long prompt' }) + return + } + + act(() => root.render()) + + const textarea = container.querySelector('textarea') + if (!textarea) throw new Error('textarea did not render') + + return { + textarea, + unmount: () => { + act(() => root.unmount()) + container.remove() + }, + } +} + +/** Applies a new editor width and delivers the resulting resize notification. */ +function resizeTo(width: number, wrappedHeight: number) { + editorWidth = width + contentHeight = wrappedHeight + act(() => FakeResizeObserver.deliverAll()) +} + +describe('PromptEditor autosize', () => { + let originalScrollHeight: PropertyDescriptor | undefined + + beforeEach(() => { + contentHeight = 240 + editorWidth = 700 + autosizeCalls = 0 + FakeResizeObserver.reset() + + originalScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight') + Object.defineProperty(Element.prototype, 'scrollHeight', { + configurable: true, + get() { + if (!(this instanceof HTMLTextAreaElement)) return 0 + autosizeCalls++ + return contentHeight + }, + }) + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + }) + + afterEach(() => { + if (originalScrollHeight) { + Object.defineProperty(Element.prototype, 'scrollHeight', originalScrollHeight) + } + vi.unstubAllGlobals() + }) + + it('sizes the textarea to its content height on mount', () => { + const { textarea, unmount } = mountEditor() + + expect(textarea.style.height).toBe('240px') + unmount() + }) + + /** + * The regression: the textarea carries an inline pixel height, so without a + * width-driven re-measure a narrower editor paints rewrapped overlay text + * below the textarea's box — visible text with no hit target, which swallows + * clicks instead of placing the caret. + */ + it('re-measures when the editor width changes so no text falls outside the textarea', () => { + const { textarea, unmount } = mountEditor() + expect(textarea.style.height).toBe('240px') + + resizeTo(340, 500) + + expect(textarea.style.height).toBe('500px') + unmount() + }) + + it('re-measures again when the editor widens back', () => { + const { textarea, unmount } = mountEditor() + + resizeTo(340, 500) + resizeTo(700, 240) + + expect(textarea.style.height).toBe('240px') + unmount() + }) + + /** + * `autosize` writes the textarea's height, which grows the scroller and + * re-notifies this observer. Re-measuring on an unchanged width would make + * that a feedback loop. + */ + it('ignores resize notifications that do not change the width', () => { + const { textarea, unmount } = mountEditor() + const callsAfterMount = autosizeCalls + + contentHeight = 500 + act(() => FakeResizeObserver.deliverAll()) + + expect(textarea.style.height).toBe('240px') + expect(autosizeCalls).toBe(callsAfterMount) + unmount() + }) + + /** The observer's initial delivery reports the width the mount measure used. */ + it('does not re-measure on the observer’s first delivery', () => { + const { unmount } = mountEditor() + + expect(autosizeCalls).toBe(1) + unmount() + }) + + it('stops observing after unmount', () => { + const { unmount } = mountEditor() + expect(FakeResizeObserver.observerCount()).toBe(1) + + unmount() + + expect(FakeResizeObserver.observerCount()).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index db58cdcf461..15d19d6fde7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -88,7 +88,7 @@ export function PromptEditor({ * container, letting the browser clamp a bottom-pinned transcript upward by * the input's grown height on every multi-line edit. */ - useLayoutEffect(() => { + const autosize = useCallback(() => { const textarea = textareaRef.current if (!textarea) return const scroller = scrollerRef.current @@ -96,7 +96,38 @@ export function PromptEditor({ textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` if (scroller) scroller.style.height = '' - }, [value, textareaRef]) + }, [textareaRef]) + + useLayoutEffect(() => { + autosize() + }, [value, autosize]) + + /** + * Re-measure when the editor's width changes. The textarea carries an inline + * pixel height, so a width change (window resize, sidebar or side-panel + * toggle, chat column reflow) rewraps the text taller while the box stays at + * its old height. The mirror overlay paints the full text regardless, so the + * spilled lines render over the scroller with no textarea beneath them — + * visible, scrollable text that swallows clicks instead of placing the caret. + * + * Only width is compared: `autosize` writes the textarea's height, which grows + * the scroller until its cap and re-notifies this observer, so reacting to + * height would feed itself. The first delivery reports the width the + * mount-time measure already used, so it is recorded without re-measuring. + */ + useEffect(() => { + const scroller = scrollerRef.current + if (!scroller) return + let lastWidth: number | null = null + const observer = new ResizeObserver(([entry]) => { + const width = entry.contentRect.width + const previousWidth = lastWidth + lastWidth = width + if (previousWidth !== null && previousWidth !== width) autosize() + }) + observer.observe(scroller) + return () => observer.disconnect() + }, [autosize]) useEffect(() => { if (autoFocus && !readOnly) editor.focusAtEnd() From 43d4584d66341ce07f33460e0b075b49be7dbb54 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 10:56:57 -0700 Subject: [PATCH 2/3] fix(chat): measure the observer's first delivery like any other The width can change between the mount-time measure and observe(), so treating the first notification as confirmation of the mount width dropped that change and left the stale height in place. --- .../prompt-editor/prompt-editor.test.tsx | 49 +++++++++++++------ .../prompt-editor/prompt-editor.tsx | 9 ++-- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx index 94b0ecdccbc..773a8967666 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx @@ -33,9 +33,12 @@ let editorWidth = 700 let autosizeCalls = 0 /** - * Mirrors the real observer's contract closely enough to test the width guard: - * `observe` delivers an initial notification for the current size (browsers do), - * and {@link resizeTo} delivers subsequent ones. + * Mirrors the real observer's contract closely enough to test the width guard. + * `observe` only registers the target — real deliveries, including the initial + * one browsers send, are asynchronous, so every test drives them explicitly via + * {@link resizeTo} / {@link FakeResizeObserver.deliverAll}. Delivering inside + * `observe` would hide the window between the mount-time measure and the first + * notification, which is exactly where a width change can be missed. */ class FakeResizeObserver implements ResizeObserver { private static instances: FakeResizeObserver[] = [] @@ -49,7 +52,6 @@ class FakeResizeObserver implements ResizeObserver { observe(target: Element) { this.targets.push(target) - this.deliver() } unobserve(target: Element) { @@ -114,6 +116,14 @@ function resizeTo(width: number, wrappedHeight: number) { act(() => FakeResizeObserver.deliverAll()) } +/** + * Delivers the observer's initial notification at the mounted width, putting the + * editor in the steady state a test can then resize away from. + */ +function settle() { + act(() => FakeResizeObserver.deliverAll()) +} + describe('PromptEditor autosize', () => { let originalScrollHeight: PropertyDescriptor | undefined @@ -157,6 +167,7 @@ describe('PromptEditor autosize', () => { */ it('re-measures when the editor width changes so no text falls outside the textarea', () => { const { textarea, unmount } = mountEditor() + settle() expect(textarea.style.height).toBe('240px') resizeTo(340, 500) @@ -167,6 +178,7 @@ describe('PromptEditor autosize', () => { it('re-measures again when the editor widens back', () => { const { textarea, unmount } = mountEditor() + settle() resizeTo(340, 500) resizeTo(700, 240) @@ -175,6 +187,22 @@ describe('PromptEditor autosize', () => { unmount() }) + /** + * `observe` registers the target, but the first notification arrives a frame + * later. A sidebar or side-panel transition can change the width inside that + * window, so the first delivery must be measured like any other rather than + * trusted to confirm the width the mount-time measure used. + */ + it('re-measures on the first delivery when the width changed before it arrived', () => { + const { textarea, unmount } = mountEditor() + expect(textarea.style.height).toBe('240px') + + resizeTo(340, 500) + + expect(textarea.style.height).toBe('500px') + unmount() + }) + /** * `autosize` writes the textarea's height, which grows the scroller and * re-notifies this observer. Re-measuring on an unchanged width would make @@ -182,21 +210,14 @@ describe('PromptEditor autosize', () => { */ it('ignores resize notifications that do not change the width', () => { const { textarea, unmount } = mountEditor() - const callsAfterMount = autosizeCalls + settle() + const callsAfterSettle = autosizeCalls contentHeight = 500 act(() => FakeResizeObserver.deliverAll()) expect(textarea.style.height).toBe('240px') - expect(autosizeCalls).toBe(callsAfterMount) - unmount() - }) - - /** The observer's initial delivery reports the width the mount measure used. */ - it('does not re-measure on the observer’s first delivery', () => { - const { unmount } = mountEditor() - - expect(autosizeCalls).toBe(1) + expect(autosizeCalls).toBe(callsAfterSettle) unmount() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index 15d19d6fde7..4b6bb2a1975 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -112,8 +112,9 @@ export function PromptEditor({ * * Only width is compared: `autosize` writes the textarea's height, which grows * the scroller until its cap and re-notifies this observer, so reacting to - * height would feed itself. The first delivery reports the width the - * mount-time measure already used, so it is recorded without re-measuring. + * height would feed itself. The first delivery is measured like any other — + * the width can change between the mount-time measure and `observe()`, and + * re-measuring an unchanged width only writes the same height back. */ useEffect(() => { const scroller = scrollerRef.current @@ -121,9 +122,9 @@ export function PromptEditor({ let lastWidth: number | null = null const observer = new ResizeObserver(([entry]) => { const width = entry.contentRect.width - const previousWidth = lastWidth + if (width === lastWidth) return lastWidth = width - if (previousWidth !== null && previousWidth !== width) autosize() + autosize() }) observer.observe(scroller) return () => observer.disconnect() From 8130737efbf7d6e7dcc91dbc543c9dc0c3967acf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 11:05:56 -0700 Subject: [PATCH 3/3] chore(chat): trim duplicated comments on the prompt editor autosize The failure mode was documented in four places. Keeps one canonical explanation next to the guard and leaves only the per-test whys the test names do not already carry. --- .../prompt-editor/prompt-editor.test.tsx | 29 ++++--------------- .../prompt-editor/prompt-editor.tsx | 21 +++++++------- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx index 773a8967666..5b7fc95ee03 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx @@ -33,12 +33,10 @@ let editorWidth = 700 let autosizeCalls = 0 /** - * Mirrors the real observer's contract closely enough to test the width guard. - * `observe` only registers the target — real deliveries, including the initial - * one browsers send, are asynchronous, so every test drives them explicitly via - * {@link resizeTo} / {@link FakeResizeObserver.deliverAll}. Delivering inside + * `observe` only registers the target: real deliveries, including the initial + * one, are asynchronous, so tests drive them explicitly. Delivering inside * `observe` would hide the window between the mount-time measure and the first - * notification, which is exactly where a width change can be missed. + * notification — exactly where a width change can be missed. */ class FakeResizeObserver implements ResizeObserver { private static instances: FakeResizeObserver[] = [] @@ -78,7 +76,6 @@ class FakeResizeObserver implements ResizeObserver { return FakeResizeObserver.instances.length } - /** Delivers a resize notification to every live observer, as a reflow would. */ static deliverAll() { for (const instance of [...FakeResizeObserver.instances]) instance.deliver() } @@ -109,7 +106,6 @@ function mountEditor() { } } -/** Applies a new editor width and delivers the resulting resize notification. */ function resizeTo(width: number, wrappedHeight: number) { editorWidth = width contentHeight = wrappedHeight @@ -159,12 +155,6 @@ describe('PromptEditor autosize', () => { unmount() }) - /** - * The regression: the textarea carries an inline pixel height, so without a - * width-driven re-measure a narrower editor paints rewrapped overlay text - * below the textarea's box — visible text with no hit target, which swallows - * clicks instead of placing the caret. - */ it('re-measures when the editor width changes so no text falls outside the textarea', () => { const { textarea, unmount } = mountEditor() settle() @@ -187,12 +177,7 @@ describe('PromptEditor autosize', () => { unmount() }) - /** - * `observe` registers the target, but the first notification arrives a frame - * later. A sidebar or side-panel transition can change the width inside that - * window, so the first delivery must be measured like any other rather than - * trusted to confirm the width the mount-time measure used. - */ + /** Distinct from the case above: here the width moves before any delivery lands. */ it('re-measures on the first delivery when the width changed before it arrived', () => { const { textarea, unmount } = mountEditor() expect(textarea.style.height).toBe('240px') @@ -203,11 +188,7 @@ describe('PromptEditor autosize', () => { unmount() }) - /** - * `autosize` writes the textarea's height, which grows the scroller and - * re-notifies this observer. Re-measuring on an unchanged width would make - * that a feedback loop. - */ + /** The height `autosize` writes re-notifies this observer, so this guard breaks the loop. */ it('ignores resize notifications that do not change the width', () => { const { textarea, unmount } = mountEditor() settle() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index 4b6bb2a1975..af7013cb84b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -103,18 +103,17 @@ export function PromptEditor({ }, [value, autosize]) /** - * Re-measure when the editor's width changes. The textarea carries an inline - * pixel height, so a width change (window resize, sidebar or side-panel - * toggle, chat column reflow) rewraps the text taller while the box stays at - * its old height. The mirror overlay paints the full text regardless, so the - * spilled lines render over the scroller with no textarea beneath them — - * visible, scrollable text that swallows clicks instead of placing the caret. + * The textarea carries an inline pixel height, so a width change (window + * resize, sidebar toggle, chat column reflow) rewraps the text taller while + * the box stays at its old height. The mirror overlay paints the full text + * regardless, so the spilled lines render over the scroller with no textarea + * beneath them — visible, scrollable text that swallows clicks instead of + * placing the caret. * - * Only width is compared: `autosize` writes the textarea's height, which grows - * the scroller until its cap and re-notifies this observer, so reacting to - * height would feed itself. The first delivery is measured like any other — - * the width can change between the mount-time measure and `observe()`, and - * re-measuring an unchanged width only writes the same height back. + * Only width is compared: `autosize` writes the textarea's height, which + * re-notifies this observer, so reacting to height would feed itself. The + * first delivery is measured like any other — the width can change between + * the mount-time measure and `observe()`. */ useEffect(() => { const scroller = scrollerRef.current