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..5b7fc95ee03
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx
@@ -0,0 +1,213 @@
+/**
+ * @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
+
+/**
+ * `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 — exactly where a width change can be missed.
+ */
+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)
+ }
+
+ 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
+ }
+
+ 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()
+ },
+ }
+}
+
+function resizeTo(width: number, wrappedHeight: number) {
+ editorWidth = width
+ contentHeight = wrappedHeight
+ 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
+
+ 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()
+ })
+
+ 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)
+
+ expect(textarea.style.height).toBe('500px')
+ unmount()
+ })
+
+ it('re-measures again when the editor widens back', () => {
+ const { textarea, unmount } = mountEditor()
+ settle()
+
+ resizeTo(340, 500)
+ resizeTo(700, 240)
+
+ expect(textarea.style.height).toBe('240px')
+ unmount()
+ })
+
+ /** 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')
+
+ resizeTo(340, 500)
+
+ expect(textarea.style.height).toBe('500px')
+ unmount()
+ })
+
+ /** 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()
+ const callsAfterSettle = autosizeCalls
+
+ contentHeight = 500
+ act(() => FakeResizeObserver.deliverAll())
+
+ expect(textarea.style.height).toBe('240px')
+ expect(autosizeCalls).toBe(callsAfterSettle)
+ 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..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
@@ -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])
+
+ /**
+ * 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
+ * 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
+ if (!scroller) return
+ let lastWidth: number | null = null
+ const observer = new ResizeObserver(([entry]) => {
+ const width = entry.contentRect.width
+ if (width === lastWidth) return
+ lastWidth = width
+ autosize()
+ })
+ observer.observe(scroller)
+ return () => observer.disconnect()
+ }, [autosize])
useEffect(() => {
if (autoFocus && !readOnly) editor.focusAtEnd()