Skip to content

Commit 1557324

Browse files
committed
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.
1 parent 77649d3 commit 1557324

2 files changed

Lines changed: 244 additions & 2 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+
* Mirrors the real observer's contract closely enough to test the width guard:
37+
* `observe` delivers an initial notification for the current size (browsers do),
38+
* and {@link resizeTo} delivers subsequent ones.
39+
*/
40+
class FakeResizeObserver implements ResizeObserver {
41+
private static instances: FakeResizeObserver[] = []
42+
private readonly callback: ResizeObserverCallback
43+
private targets: Element[] = []
44+
45+
constructor(callback: ResizeObserverCallback) {
46+
this.callback = callback
47+
FakeResizeObserver.instances.push(this)
48+
}
49+
50+
observe(target: Element) {
51+
this.targets.push(target)
52+
this.deliver()
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+
/** Delivers a resize notification to every live observer, as a reflow would. */
80+
static deliverAll() {
81+
for (const instance of [...FakeResizeObserver.instances]) instance.deliver()
82+
}
83+
}
84+
85+
function mountEditor() {
86+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
87+
const container = document.createElement('div')
88+
document.body.appendChild(container)
89+
const root: Root = createRoot(container)
90+
91+
function Probe() {
92+
const editor = usePromptEditor({ workspaceId: 'ws-1', initialValue: 'a long prompt' })
93+
return <PromptEditor editor={editor} className='max-h-[200px]' />
94+
}
95+
96+
act(() => root.render(<Probe />))
97+
98+
const textarea = container.querySelector('textarea')
99+
if (!textarea) throw new Error('textarea did not render')
100+
101+
return {
102+
textarea,
103+
unmount: () => {
104+
act(() => root.unmount())
105+
container.remove()
106+
},
107+
}
108+
}
109+
110+
/** Applies a new editor width and delivers the resulting resize notification. */
111+
function resizeTo(width: number, wrappedHeight: number) {
112+
editorWidth = width
113+
contentHeight = wrappedHeight
114+
act(() => FakeResizeObserver.deliverAll())
115+
}
116+
117+
describe('PromptEditor autosize', () => {
118+
let originalScrollHeight: PropertyDescriptor | undefined
119+
120+
beforeEach(() => {
121+
contentHeight = 240
122+
editorWidth = 700
123+
autosizeCalls = 0
124+
FakeResizeObserver.reset()
125+
126+
originalScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight')
127+
Object.defineProperty(Element.prototype, 'scrollHeight', {
128+
configurable: true,
129+
get() {
130+
if (!(this instanceof HTMLTextAreaElement)) return 0
131+
autosizeCalls++
132+
return contentHeight
133+
},
134+
})
135+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
136+
})
137+
138+
afterEach(() => {
139+
if (originalScrollHeight) {
140+
Object.defineProperty(Element.prototype, 'scrollHeight', originalScrollHeight)
141+
}
142+
vi.unstubAllGlobals()
143+
})
144+
145+
it('sizes the textarea to its content height on mount', () => {
146+
const { textarea, unmount } = mountEditor()
147+
148+
expect(textarea.style.height).toBe('240px')
149+
unmount()
150+
})
151+
152+
/**
153+
* The regression: the textarea carries an inline pixel height, so without a
154+
* width-driven re-measure a narrower editor paints rewrapped overlay text
155+
* below the textarea's box — visible text with no hit target, which swallows
156+
* clicks instead of placing the caret.
157+
*/
158+
it('re-measures when the editor width changes so no text falls outside the textarea', () => {
159+
const { textarea, unmount } = mountEditor()
160+
expect(textarea.style.height).toBe('240px')
161+
162+
resizeTo(340, 500)
163+
164+
expect(textarea.style.height).toBe('500px')
165+
unmount()
166+
})
167+
168+
it('re-measures again when the editor widens back', () => {
169+
const { textarea, unmount } = mountEditor()
170+
171+
resizeTo(340, 500)
172+
resizeTo(700, 240)
173+
174+
expect(textarea.style.height).toBe('240px')
175+
unmount()
176+
})
177+
178+
/**
179+
* `autosize` writes the textarea's height, which grows the scroller and
180+
* re-notifies this observer. Re-measuring on an unchanged width would make
181+
* that a feedback loop.
182+
*/
183+
it('ignores resize notifications that do not change the width', () => {
184+
const { textarea, unmount } = mountEditor()
185+
const callsAfterMount = autosizeCalls
186+
187+
contentHeight = 500
188+
act(() => FakeResizeObserver.deliverAll())
189+
190+
expect(textarea.style.height).toBe('240px')
191+
expect(autosizeCalls).toBe(callsAfterMount)
192+
unmount()
193+
})
194+
195+
/** The observer's initial delivery reports the width the mount measure used. */
196+
it('does not re-measure on the observer’s first delivery', () => {
197+
const { unmount } = mountEditor()
198+
199+
expect(autosizeCalls).toBe(1)
200+
unmount()
201+
})
202+
203+
it('stops observing after unmount', () => {
204+
const { unmount } = mountEditor()
205+
expect(FakeResizeObserver.observerCount()).toBe(1)
206+
207+
unmount()
208+
209+
expect(FakeResizeObserver.observerCount()).toBe(0)
210+
})
211+
})

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+
* Re-measure when the editor's width changes. The textarea carries an inline
107+
* pixel height, so a width change (window resize, sidebar or side-panel
108+
* toggle, chat column reflow) rewraps the text taller while the box stays at
109+
* its old height. The mirror overlay paints the full text regardless, so the
110+
* spilled lines render over the scroller with no textarea beneath them —
111+
* visible, scrollable text that swallows clicks instead of placing the caret.
112+
*
113+
* Only width is compared: `autosize` writes the textarea's height, which grows
114+
* the scroller until its cap and re-notifies this observer, so reacting to
115+
* height would feed itself. The first delivery reports the width the
116+
* mount-time measure already used, so it is recorded without re-measuring.
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+
const previousWidth = lastWidth
125+
lastWidth = width
126+
if (previousWidth !== null && previousWidth !== width) 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)