Skip to content

Commit 004e058

Browse files
authored
fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)
* fix(messages-input): fix cursor alignment and auto-resize with overlay * fixed remaining zustand warnings
1 parent 5157f0b commit 004e058

File tree

8 files changed

+148
-82
lines changed

8 files changed

+148
-82
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { isEqual } from 'lodash'
33
import { useReactFlow } from 'reactflow'
4+
import { useStoreWithEqualityFn } from 'zustand/traditional'
45
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
56
import { cn } from '@/lib/core/utils/cn'
67
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -102,7 +103,8 @@ export const ComboBox = memo(function ComboBox({
102103
[blockConfig?.subBlocks]
103104
)
104105
const canonicalModeOverrides = blockState?.data?.canonicalModes
105-
const dependencyValues = useSubBlockStore(
106+
const dependencyValues = useStoreWithEqualityFn(
107+
useSubBlockStore,
106108
useCallback(
107109
(state) => {
108110
if (dependsOnFields.length === 0 || !activeWorkflowId) return []

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { isEqual } from 'lodash'
3+
import { useStoreWithEqualityFn } from 'zustand/traditional'
34
import { Badge } from '@/components/emcn'
45
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
56
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -100,7 +101,8 @@ export const Dropdown = memo(function Dropdown({
100101
[blockConfig?.subBlocks]
101102
)
102103
const canonicalModeOverrides = blockState?.data?.canonicalModes
103-
const dependencyValues = useSubBlockStore(
104+
const dependencyValues = useStoreWithEqualityFn(
105+
useSubBlockStore,
104106
useCallback(
105107
(state) => {
106108
if (dependsOnFields.length === 0 || !activeWorkflowId) return []

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx

Lines changed: 123 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
1+
import {
2+
useCallback,
3+
useEffect,
4+
useImperativeHandle,
5+
useLayoutEffect,
6+
useMemo,
7+
useRef,
8+
useState,
9+
} from 'react'
210
import { isEqual } from 'lodash'
311
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
412
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
@@ -382,93 +390,138 @@ export function MessagesInput({
382390
textareaRefs.current[fieldId]?.focus()
383391
}, [])
384392

385-
const autoResizeTextarea = useCallback((fieldId: string) => {
393+
const syncOverlay = useCallback((fieldId: string) => {
386394
const textarea = textareaRefs.current[fieldId]
387-
if (!textarea) return
388395
const overlay = overlayRefs.current[fieldId]
396+
if (!textarea || !overlay) return
389397

390-
// If user has manually resized, respect their chosen height and only sync overlay.
391-
if (userResizedRef.current[fieldId]) {
392-
const currentHeight =
393-
textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX
394-
const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight)
395-
textarea.style.height = `${clampedHeight}px`
398+
overlay.style.width = `${textarea.clientWidth}px`
399+
overlay.scrollTop = textarea.scrollTop
400+
overlay.scrollLeft = textarea.scrollLeft
401+
}, [])
402+
403+
const autoResizeTextarea = useCallback(
404+
(fieldId: string) => {
405+
const textarea = textareaRefs.current[fieldId]
406+
const overlay = overlayRefs.current[fieldId]
407+
if (!textarea) return
408+
409+
if (!textarea.value.trim()) {
410+
userResizedRef.current[fieldId] = false
411+
}
412+
413+
if (userResizedRef.current[fieldId]) {
414+
if (overlay) {
415+
overlay.style.height = `${textarea.offsetHeight}px`
416+
}
417+
syncOverlay(fieldId)
418+
return
419+
}
420+
421+
textarea.style.height = 'auto'
422+
const scrollHeight = textarea.scrollHeight
423+
const height = Math.min(
424+
MAX_TEXTAREA_HEIGHT_PX,
425+
Math.max(MIN_TEXTAREA_HEIGHT_PX, scrollHeight)
426+
)
427+
428+
textarea.style.height = `${height}px`
396429
if (overlay) {
397-
overlay.style.height = `${clampedHeight}px`
430+
overlay.style.height = `${height}px`
398431
}
399-
return
400-
}
401432

402-
textarea.style.height = 'auto'
403-
const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
404-
const nextHeight = Math.min(
405-
MAX_TEXTAREA_HEIGHT_PX,
406-
Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
407-
)
408-
textarea.style.height = `${nextHeight}px`
433+
syncOverlay(fieldId)
434+
},
435+
[syncOverlay]
436+
)
409437

410-
if (overlay) {
411-
overlay.style.height = `${nextHeight}px`
412-
}
413-
}, [])
438+
const handleResizeStart = useCallback(
439+
(fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
440+
e.preventDefault()
441+
e.stopPropagation()
414442

415-
const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
416-
e.preventDefault()
417-
e.stopPropagation()
443+
const textarea = textareaRefs.current[fieldId]
444+
if (!textarea) return
418445

419-
const textarea = textareaRefs.current[fieldId]
420-
if (!textarea) return
446+
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
421447

422-
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
448+
isResizingRef.current = true
449+
resizeStateRef.current = {
450+
fieldId,
451+
startY: e.clientY,
452+
startHeight,
453+
}
423454

424-
isResizingRef.current = true
425-
resizeStateRef.current = {
426-
fieldId,
427-
startY: e.clientY,
428-
startHeight,
429-
}
455+
const handleMouseMove = (moveEvent: MouseEvent) => {
456+
if (!isResizingRef.current || !resizeStateRef.current) return
430457

431-
const handleMouseMove = (moveEvent: MouseEvent) => {
432-
if (!isResizingRef.current || !resizeStateRef.current) return
458+
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
459+
const deltaY = moveEvent.clientY - startY
460+
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
433461

434-
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
435-
const deltaY = moveEvent.clientY - startY
436-
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
462+
const activeTextarea = textareaRefs.current[activeFieldId]
463+
const overlay = overlayRefs.current[activeFieldId]
437464

438-
const activeTextarea = textareaRefs.current[activeFieldId]
439-
if (activeTextarea) {
440-
activeTextarea.style.height = `${nextHeight}px`
441-
}
465+
if (activeTextarea) {
466+
activeTextarea.style.height = `${nextHeight}px`
467+
}
442468

443-
const overlay = overlayRefs.current[activeFieldId]
444-
if (overlay) {
445-
overlay.style.height = `${nextHeight}px`
469+
if (overlay) {
470+
overlay.style.height = `${nextHeight}px`
471+
if (activeTextarea) {
472+
overlay.scrollTop = activeTextarea.scrollTop
473+
overlay.scrollLeft = activeTextarea.scrollLeft
474+
}
475+
}
446476
}
447-
}
448477

449-
const handleMouseUp = () => {
450-
if (resizeStateRef.current) {
451-
const { fieldId: activeFieldId } = resizeStateRef.current
452-
userResizedRef.current[activeFieldId] = true
453-
}
478+
const handleMouseUp = () => {
479+
if (resizeStateRef.current) {
480+
const { fieldId: activeFieldId } = resizeStateRef.current
481+
userResizedRef.current[activeFieldId] = true
482+
syncOverlay(activeFieldId)
483+
}
454484

455-
isResizingRef.current = false
456-
resizeStateRef.current = null
457-
document.removeEventListener('mousemove', handleMouseMove)
458-
document.removeEventListener('mouseup', handleMouseUp)
459-
}
485+
isResizingRef.current = false
486+
resizeStateRef.current = null
487+
document.removeEventListener('mousemove', handleMouseMove)
488+
document.removeEventListener('mouseup', handleMouseUp)
489+
}
460490

461-
document.addEventListener('mousemove', handleMouseMove)
462-
document.addEventListener('mouseup', handleMouseUp)
463-
}, [])
491+
document.addEventListener('mousemove', handleMouseMove)
492+
document.addEventListener('mouseup', handleMouseUp)
493+
},
494+
[syncOverlay]
495+
)
464496

465-
useEffect(() => {
497+
useLayoutEffect(() => {
466498
currentMessages.forEach((_, index) => {
467-
const fieldId = `message-${index}`
468-
autoResizeTextarea(fieldId)
499+
autoResizeTextarea(`message-${index}`)
469500
})
470501
}, [currentMessages, autoResizeTextarea])
471502

503+
useEffect(() => {
504+
const observers: ResizeObserver[] = []
505+
506+
for (let i = 0; i < currentMessages.length; i++) {
507+
const fieldId = `message-${i}`
508+
const textarea = textareaRefs.current[fieldId]
509+
const overlay = overlayRefs.current[fieldId]
510+
511+
if (textarea && overlay) {
512+
const observer = new ResizeObserver(() => {
513+
overlay.style.width = `${textarea.clientWidth}px`
514+
})
515+
observer.observe(textarea)
516+
observers.push(observer)
517+
}
518+
}
519+
520+
return () => {
521+
observers.forEach((observer) => observer.disconnect())
522+
}
523+
}, [currentMessages.length])
524+
472525
return (
473526
<div className='flex w-full flex-col gap-[10px]'>
474527
{currentMessages.map((message, index) => (
@@ -621,19 +674,15 @@ export function MessagesInput({
621674
</div>
622675

623676
{/* Content Input with overlay for variable highlighting */}
624-
<div className='relative w-full'>
677+
<div className='relative w-full overflow-hidden'>
625678
<textarea
626679
ref={(el) => {
627680
textareaRefs.current[fieldId] = el
628681
}}
629-
className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
630-
rows={3}
682+
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
631683
placeholder='Enter message content...'
632684
value={message.content}
633-
onChange={(e) => {
634-
fieldHandlers.onChange(e)
635-
autoResizeTextarea(fieldId)
636-
}}
685+
onChange={fieldHandlers.onChange}
637686
onKeyDown={(e) => {
638687
if (e.key === 'Tab' && !isPreview && !disabled) {
639688
e.preventDefault()
@@ -670,12 +719,13 @@ export function MessagesInput({
670719
ref={(el) => {
671720
overlayRefs.current[fieldId] = el
672721
}}
673-
className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
722+
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
674723
>
675724
{formatDisplayText(message.content, {
676725
accessiblePrefixes,
677726
highlightAll: !accessiblePrefixes,
678727
})}
728+
{message.content.endsWith('\n') && '\u200B'}
679729
</div>
680730

681731
{/* Env var dropdown for this message */}
@@ -705,7 +755,7 @@ export function MessagesInput({
705755

706756
{!isPreview && !disabled && (
707757
<div
708-
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
758+
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
709759
onMouseDown={(e) => handleResizeStart(fieldId, e)}
710760
onDragStart={(e) => {
711761
e.preventDefault()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useCallback, useMemo } from 'react'
44
import { isEqual } from 'lodash'
5+
import { useStoreWithEqualityFn } from 'zustand/traditional'
56
import {
67
buildCanonicalIndex,
78
isNonEmptyValue,
@@ -151,7 +152,7 @@ export function useDependsOnGate(
151152

152153
// Get values for all dependency fields (both all and any)
153154
// Use isEqual to prevent re-renders when dependency values haven't actually changed
154-
const dependencyValuesMap = useSubBlockStore(dependencySelector, isEqual)
155+
const dependencyValuesMap = useStoreWithEqualityFn(useSubBlockStore, dependencySelector, isEqual)
155156

156157
const depsSatisfied = useMemo(() => {
157158
// Check all fields (AND logic) - all must be satisfied

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
22
import { createLogger } from '@sim/logger'
33
import { isEqual } from 'lodash'
44
import { useShallow } from 'zustand/react/shallow'
5+
import { useStoreWithEqualityFn } from 'zustand/traditional'
56
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
67
import { getProviderFromModel } from '@/providers/utils'
78
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -58,7 +59,8 @@ export function useSubBlockValue<T = any>(
5859
const streamingValueRef = useRef<T | null>(null)
5960
const wasStreamingRef = useRef<boolean>(false)
6061

61-
const storeValue = useSubBlockStore(
62+
const storeValue = useStoreWithEqualityFn(
63+
useSubBlockStore,
6264
useCallback(
6365
(state) => {
6466
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
@@ -92,7 +94,8 @@ export function useSubBlockValue<T = any>(
9294

9395
// Always call this hook unconditionally - don't wrap it in a condition
9496
// Optimized: only re-render if model value actually changes
95-
const modelSubBlockValue = useSubBlockStore(
97+
const modelSubBlockValue = useStoreWithEqualityFn(
98+
useSubBlockStore,
9699
useCallback((state) => (blockId ? state.getValue(blockId, 'model') : null), [blockId]),
97100
(a, b) => a === b
98101
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { isEqual } from 'lodash'
55
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
66
import { useShallow } from 'zustand/react/shallow'
7+
import { useStoreWithEqualityFn } from 'zustand/traditional'
78
import { Button, Tooltip } from '@/components/emcn'
89
import {
910
buildCanonicalIndex,
@@ -99,7 +100,8 @@ export function Editor() {
99100
currentWorkflow.isSnapshotView
100101
)
101102

102-
const blockSubBlockValues = useSubBlockStore(
103+
const blockSubBlockValues = useStoreWithEqualityFn(
104+
useSubBlockStore,
103105
useCallback(
104106
(state) => {
105107
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { isEqual } from 'lodash'
44
import { useParams } from 'next/navigation'
55
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
6+
import { useStoreWithEqualityFn } from 'zustand/traditional'
67
import { Badge, Tooltip } from '@/components/emcn'
78
import { cn } from '@/lib/core/utils/cn'
89
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -526,7 +527,8 @@ const SubBlockRow = memo(function SubBlockRow({
526527
* Subscribe only to variables for this workflow to avoid re-renders from other workflows.
527528
* Uses isEqual for deep comparison since Object.fromEntries creates a new object each time.
528529
*/
529-
const workflowVariables = useVariablesStore(
530+
const workflowVariables = useStoreWithEqualityFn(
531+
useVariablesStore,
530532
useCallback(
531533
(state) => {
532534
if (!workflowId) return {}
@@ -729,7 +731,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
729731
const isStarterBlock = type === 'starter'
730732
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
731733

732-
const blockSubBlockValues = useSubBlockStore(
734+
const blockSubBlockValues = useStoreWithEqualityFn(
735+
useSubBlockStore,
733736
useCallback(
734737
(state) => {
735738
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES

0 commit comments

Comments
 (0)