Skip to content

Commit d2964af

Browse files
authored
fix(chat): re-measure the prompt editor when its width changes (#6380)
* fix(chat): re-measure the prompt editor when its width changes 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. * 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. * 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.
1 parent a7080d5 commit d2964af

2 files changed

Lines changed: 246 additions & 2 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
9+
vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) }))
10+
vi.mock('@/blocks/integration-matcher', () => ({
11+
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
12+
}))
13+
vi.mock(
14+
'@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown',
15+
() => ({ PlusMenuDropdown: () => null })
16+
)
17+
vi.mock(
18+
'@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown',
19+
() => ({ SkillsMenuDropdown: () => null })
20+
)
21+
22+
import { PromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor'
23+
import { usePromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor'
24+
25+
/**
26+
* jsdom performs no layout, so the autosize inputs are stubbed: `editorWidth`
27+
* stands for the scroller's content width and `contentHeight` for the height
28+
* the text wraps to at that width. Narrowing raises the content height, exactly
29+
* as rewrapping does in a browser.
30+
*/
31+
let contentHeight = 240
32+
let editorWidth = 700
33+
let autosizeCalls = 0
34+
35+
/**
36+
* `observe` only registers the target: real deliveries, including the initial
37+
* one, are asynchronous, so tests drive them explicitly. Delivering inside
38+
* `observe` would hide the window between the mount-time measure and the first
39+
* notification — exactly where a width change can be missed.
40+
*/
41+
class FakeResizeObserver implements ResizeObserver {
42+
private static instances: FakeResizeObserver[] = []
43+
private readonly callback: ResizeObserverCallback
44+
private targets: Element[] = []
45+
46+
constructor(callback: ResizeObserverCallback) {
47+
this.callback = callback
48+
FakeResizeObserver.instances.push(this)
49+
}
50+
51+
observe(target: Element) {
52+
this.targets.push(target)
53+
}
54+
55+
unobserve(target: Element) {
56+
this.targets = this.targets.filter((t) => t !== target)
57+
}
58+
59+
disconnect() {
60+
this.targets = []
61+
FakeResizeObserver.instances = FakeResizeObserver.instances.filter((i) => i !== this)
62+
}
63+
64+
deliver() {
65+
const entries = this.targets.map(
66+
(target) => ({ target, contentRect: { width: editorWidth } }) as ResizeObserverEntry
67+
)
68+
if (entries.length > 0) this.callback(entries, this)
69+
}
70+
71+
static reset() {
72+
FakeResizeObserver.instances = []
73+
}
74+
75+
static observerCount() {
76+
return FakeResizeObserver.instances.length
77+
}
78+
79+
static deliverAll() {
80+
for (const instance of [...FakeResizeObserver.instances]) instance.deliver()
81+
}
82+
}
83+
84+
function mountEditor() {
85+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
86+
const container = document.createElement('div')
87+
document.body.appendChild(container)
88+
const root: Root = createRoot(container)
89+
90+
function Probe() {
91+
const editor = usePromptEditor({ workspaceId: 'ws-1', initialValue: 'a long prompt' })
92+
return <PromptEditor editor={editor} className='max-h-[200px]' />
93+
}
94+
95+
act(() => root.render(<Probe />))
96+
97+
const textarea = container.querySelector('textarea')
98+
if (!textarea) throw new Error('textarea did not render')
99+
100+
return {
101+
textarea,
102+
unmount: () => {
103+
act(() => root.unmount())
104+
container.remove()
105+
},
106+
}
107+
}
108+
109+
function resizeTo(width: number, wrappedHeight: number) {
110+
editorWidth = width
111+
contentHeight = wrappedHeight
112+
act(() => FakeResizeObserver.deliverAll())
113+
}
114+
115+
/**
116+
* Delivers the observer's initial notification at the mounted width, putting the
117+
* editor in the steady state a test can then resize away from.
118+
*/
119+
function settle() {
120+
act(() => FakeResizeObserver.deliverAll())
121+
}
122+
123+
describe('PromptEditor autosize', () => {
124+
let originalScrollHeight: PropertyDescriptor | undefined
125+
126+
beforeEach(() => {
127+
contentHeight = 240
128+
editorWidth = 700
129+
autosizeCalls = 0
130+
FakeResizeObserver.reset()
131+
132+
originalScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight')
133+
Object.defineProperty(Element.prototype, 'scrollHeight', {
134+
configurable: true,
135+
get() {
136+
if (!(this instanceof HTMLTextAreaElement)) return 0
137+
autosizeCalls++
138+
return contentHeight
139+
},
140+
})
141+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
142+
})
143+
144+
afterEach(() => {
145+
if (originalScrollHeight) {
146+
Object.defineProperty(Element.prototype, 'scrollHeight', originalScrollHeight)
147+
}
148+
vi.unstubAllGlobals()
149+
})
150+
151+
it('sizes the textarea to its content height on mount', () => {
152+
const { textarea, unmount } = mountEditor()
153+
154+
expect(textarea.style.height).toBe('240px')
155+
unmount()
156+
})
157+
158+
it('re-measures when the editor width changes so no text falls outside the textarea', () => {
159+
const { textarea, unmount } = mountEditor()
160+
settle()
161+
expect(textarea.style.height).toBe('240px')
162+
163+
resizeTo(340, 500)
164+
165+
expect(textarea.style.height).toBe('500px')
166+
unmount()
167+
})
168+
169+
it('re-measures again when the editor widens back', () => {
170+
const { textarea, unmount } = mountEditor()
171+
settle()
172+
173+
resizeTo(340, 500)
174+
resizeTo(700, 240)
175+
176+
expect(textarea.style.height).toBe('240px')
177+
unmount()
178+
})
179+
180+
/** Distinct from the case above: here the width moves before any delivery lands. */
181+
it('re-measures on the first delivery when the width changed before it arrived', () => {
182+
const { textarea, unmount } = mountEditor()
183+
expect(textarea.style.height).toBe('240px')
184+
185+
resizeTo(340, 500)
186+
187+
expect(textarea.style.height).toBe('500px')
188+
unmount()
189+
})
190+
191+
/** The height `autosize` writes re-notifies this observer, so this guard breaks the loop. */
192+
it('ignores resize notifications that do not change the width', () => {
193+
const { textarea, unmount } = mountEditor()
194+
settle()
195+
const callsAfterSettle = autosizeCalls
196+
197+
contentHeight = 500
198+
act(() => FakeResizeObserver.deliverAll())
199+
200+
expect(textarea.style.height).toBe('240px')
201+
expect(autosizeCalls).toBe(callsAfterSettle)
202+
unmount()
203+
})
204+
205+
it('stops observing after unmount', () => {
206+
const { unmount } = mountEditor()
207+
expect(FakeResizeObserver.observerCount()).toBe(1)
208+
209+
unmount()
210+
211+
expect(FakeResizeObserver.observerCount()).toBe(0)
212+
})
213+
})

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,46 @@ export function PromptEditor({
8888
* container, letting the browser clamp a bottom-pinned transcript upward by
8989
* the input's grown height on every multi-line edit.
9090
*/
91-
useLayoutEffect(() => {
91+
const autosize = useCallback(() => {
9292
const textarea = textareaRef.current
9393
if (!textarea) return
9494
const scroller = scrollerRef.current
9595
if (scroller) scroller.style.height = `${scroller.offsetHeight}px`
9696
textarea.style.height = 'auto'
9797
textarea.style.height = `${textarea.scrollHeight}px`
9898
if (scroller) scroller.style.height = ''
99-
}, [value, textareaRef])
99+
}, [textareaRef])
100+
101+
useLayoutEffect(() => {
102+
autosize()
103+
}, [value, autosize])
104+
105+
/**
106+
* The textarea carries an inline pixel height, so a width change (window
107+
* resize, sidebar toggle, chat column reflow) rewraps the text taller while
108+
* the box stays at its old height. The mirror overlay paints the full text
109+
* regardless, so the spilled lines render over the scroller with no textarea
110+
* beneath them — visible, scrollable text that swallows clicks instead of
111+
* placing the caret.
112+
*
113+
* Only width is compared: `autosize` writes the textarea's height, which
114+
* re-notifies this observer, so reacting to height would feed itself. The
115+
* first delivery is measured like any other — the width can change between
116+
* the mount-time measure and `observe()`.
117+
*/
118+
useEffect(() => {
119+
const scroller = scrollerRef.current
120+
if (!scroller) return
121+
let lastWidth: number | null = null
122+
const observer = new ResizeObserver(([entry]) => {
123+
const width = entry.contentRect.width
124+
if (width === lastWidth) return
125+
lastWidth = width
126+
autosize()
127+
})
128+
observer.observe(scroller)
129+
return () => observer.disconnect()
130+
}, [autosize])
100131

101132
useEffect(() => {
102133
if (autoFocus && !readOnly) editor.focusAtEnd()

0 commit comments

Comments
 (0)