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
Original file line number Diff line number Diff line change
@@ -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 <PromptEditor editor={editor} className='max-h-[200px]' />
}

act(() => root.render(<Probe />))

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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,46 @@ 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
if (scroller) scroller.style.height = `${scroller.offsetHeight}px`
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()
Expand Down
Loading