From e3c384d0983f355d32557d78828c49a354212c53 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 26 Mar 2026 01:26:10 +0530 Subject: [PATCH 1/2] feat: fix rerenders --- .../home/components/user-input/user-input.tsx | 832 +++++++++++------- 1 file changed, 497 insertions(+), 335 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index a69cc08477..c76713ad33 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -31,7 +31,7 @@ type WindowWithSpeech = Window & { webkitSpeechRecognition?: SpeechRecognitionStatic } -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ArrowUp, Loader2, Mic, Paperclip, X } from 'lucide-react' import { useParams } from 'next/navigation' import { @@ -81,6 +81,7 @@ import { import type { ChatContext } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useAnimatedPlaceholder } from '../../hooks' +import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' const TEXTAREA_BASE_CLASSES = cn( 'm-0 box-border h-auto min-h-[24px] w-full resize-none', @@ -149,6 +150,401 @@ function mapResourceToContext(resource: MothershipResource): ChatContext { } } +function AnimatedPlaceholderEffect({ + textareaRef, + isInitialView, +}: { + textareaRef: React.RefObject + isInitialView: boolean +}) { + const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) + const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.placeholder = placeholder + } + }, [placeholder, textareaRef]) + + return null +} + +interface PlusMenuHandle { + open: () => void +} + +type AvailableResourceGroup = ReturnType[number] + +const PlusMenuDropdown = React.memo( + React.forwardRef< + PlusMenuHandle, + { + availableResources: AvailableResourceGroup[] + onResourceSelect: (resource: MothershipResource) => void + onFileSelect: () => void + onClose: () => void + textareaRef: React.RefObject + pendingCursorRef: React.MutableRefObject + } + >(function PlusMenuDropdown( + { availableResources, onResourceSelect, onFileSelect, onClose, textareaRef, pendingCursorRef }, + ref + ) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + + React.useImperativeHandle( + ref, + () => ({ + open: () => { + setOpen(true) + setSearch('') + setActiveIndex(0) + }, + }), + [] + ) + + const filteredItems = useMemo(() => { + const q = search.toLowerCase().trim() + if (!q) return null + return availableResources.flatMap(({ type, items }) => + items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) + ) + }, [search, availableResources]) + + const handleSelect = useCallback( + (resource: MothershipResource) => { + onResourceSelect(resource) + setOpen(false) + setSearch('') + setActiveIndex(0) + }, + [onResourceSelect] + ) + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const items = filteredItems + if (!items) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (items.length > 0 && items[activeIndex]) { + const { type, item } = items[activeIndex] + handleSelect({ type, id: item.id, title: item.name }) + } + } + }, + [filteredItems, activeIndex, handleSelect] + ) + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + setSearch('') + setActiveIndex(0) + onClose() + } + }, + [onClose] + ) + + const handleCloseAutoFocus = useCallback( + (e: Event) => { + e.preventDefault() + const textarea = textareaRef.current + if (!textarea) return + if (pendingCursorRef.current !== null) { + textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current) + pendingCursorRef.current = null + } + textarea.focus() + }, + [textareaRef, pendingCursorRef] + ) + + return ( + + + + + + { + setSearch(e.target.value) + setActiveIndex(0) + }} + onKeyDown={handleSearchKeyDown} + /> +
+ {filteredItems ? ( + filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + return ( + setActiveIndex(index)} + onClick={() => { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + {config.label} + + + ) + }) + ) : ( +
+ No results +
+ ) + ) : ( + <> + { + setOpen(false) + onFileSelect() + }} + > + + Attachments + + + + + Workspace + + + {availableResources.map(({ type, items }) => { + if (items.length === 0) return null + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + {type === 'workflow' ? ( +
+ ) : ( + + )} + {config.label} + + + {items.map((item) => ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + ))} + + + ) + })} + + + + )} +
+ + + ) + }) +) + +const AttachedFilesList = React.memo(function AttachedFilesList({ + attachedFiles, + onFileClick, + onRemoveFile, +}: { + attachedFiles: AttachedFile[] + onFileClick: (file: AttachedFile) => void + onRemoveFile: (id: string) => void +}) { + if (attachedFiles.length === 0) return null + + return ( +
+ {attachedFiles.map((file) => { + const isImage = file.type.startsWith('image/') + return ( + + +
onFileClick(file)} + > + {isImage && file.previewUrl ? ( + {file.name} + ) : ( +
+ {(() => { + const Icon = getDocumentIcon(file.type, file.name) + return + })()} + + {file.name.split('.').pop()} + +
+ )} + {file.uploading && ( +
+ +
+ )} + {!file.uploading && ( + + )} +
+
+ +

{file.name}

+
+
+ ) + })} +
+ ) +}) + +const SendButton = React.memo(function SendButton({ + isSending, + canSubmit, + onSubmit, + onStopGeneration, +}: { + isSending: boolean + canSubmit: boolean + onSubmit: () => void + onStopGeneration: () => void +}) { + if (isSending) { + return ( + + ) + } + return ( + + ) +}) + +const MicButton = React.memo(function MicButton({ + isListening, + onToggle, +}: { + isListening: boolean + onToggle: () => void +}) { + return ( + + ) +}) + +const DropOverlay = React.memo(function DropOverlay() { + return ( +
+
+ Drop files +
+ {DROP_OVERLAY_ICONS.map((Icon, i) => ( + + ))} +
+
+
+ ) +}) + export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' interface UserInputProps { @@ -181,10 +577,8 @@ export function UserInput({ const { workspaceId } = useParams<{ workspaceId: string }>() const { data: session } = useSession() const [value, setValue] = useState(defaultValue) - const [plusMenuOpen, setPlusMenuOpen] = useState(false) - const [plusMenuSearch, setPlusMenuSearch] = useState('') - const [plusMenuActiveIndex, setPlusMenuActiveIndex] = useState(0) const overlayRef = useRef(null) + const plusMenuRef = useRef(null) const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue) if (defaultValue && defaultValue !== prevDefaultValue) { @@ -206,9 +600,6 @@ export function UserInput({ if (editValue) onEditValueConsumed?.() }, [editValue, onEditValueConsumed]) - const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) - const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' - const files = useFileAttachments({ userId: userId || session?.user?.id, workspaceId, @@ -240,14 +631,6 @@ export function UserInput({ const availableResources = useAvailableResources(workspaceId, existingResourceKeys) - const filteredPlusMenuItems = useMemo(() => { - const q = plusMenuSearch.toLowerCase().trim() - if (!q) return null - return availableResources.flatMap(({ type, items }) => - items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) - ) - }, [plusMenuSearch, availableResources]) - const mentionMenu = useMentionMenu({ message: value, selectedContexts: contextManagement.selectedContexts, @@ -270,6 +653,11 @@ export function UserInput({ const prefixRef = useRef('') const valueRef = useRef(value) + const filesRef = useRef(files) + filesRef.current = files + const contextRef = useRef(contextManagement) + contextRef.current = contextManagement + useEffect(() => { return () => { recognitionRef.current?.abort() @@ -300,13 +688,14 @@ export function UserInput({ (resource: MothershipResource) => { const textarea = textareaRef.current if (textarea) { - const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? value.length + const currentValue = valueRef.current + const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length atInsertPosRef.current = null - const needsSpaceBefore = insertAt > 0 && !/\s/.test(value.charAt(insertAt - 1)) + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} ` - const before = value.slice(0, insertAt) - const after = value.slice(insertAt) + const before = currentValue.slice(0, insertAt) + const after = currentValue.slice(insertAt) const newPos = before.length + insertText.length pendingCursorRef.current = newPos setValue(`${before}${insertText}${after}`) @@ -314,47 +703,35 @@ export function UserInput({ const context = mapResourceToContext(resource) handleContextAdd(context) - setPlusMenuOpen(false) }, - [textareaRef, value, handleContextAdd] + [textareaRef, handleContextAdd] ) - const handlePlusMenuSearchKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const items = filteredPlusMenuItems - if (!items) return - if (e.key === 'ArrowDown') { - e.preventDefault() - setPlusMenuActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setPlusMenuActiveIndex((prev) => Math.max(prev - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (items.length > 0 && items[plusMenuActiveIndex]) { - const { type, item } = items[plusMenuActiveIndex] - handleResourceSelect({ type, id: item.id, title: item.name }) - setPlusMenuOpen(false) - setPlusMenuSearch('') - setPlusMenuActiveIndex(0) - } - } - }, - [filteredPlusMenuItems, plusMenuActiveIndex, handleResourceSelect] - ) + const handlePlusMenuClose = useCallback(() => { + atInsertPosRef.current = null + }, []) - const handleContainerDragOver = useCallback( - (e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/x-sim-resource')) { - e.preventDefault() - e.stopPropagation() - e.dataTransfer.dropEffect = 'copy' - return - } - files.handleDragOver(e) - }, - [files] - ) + const handleFileSelectStable = useCallback(() => { + filesRef.current.handleFileSelect() + }, []) + + const handleFileClick = useCallback((file: AttachedFile) => { + filesRef.current.handleFileClick(file) + }, []) + + const handleRemoveFile = useCallback((id: string) => { + filesRef.current.removeFile(id) + }, []) + + const handleContainerDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('application/x-sim-resource')) { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + return + } + filesRef.current.handleDragOver(e) + }, []) const handleContainerDrop = useCallback( (e: React.DragEvent) => { @@ -370,11 +747,23 @@ export function UserInput({ } return } - files.handleDrop(e) + filesRef.current.handleDrop(e) }, - [handleResourceSelect, files] + [handleResourceSelect] ) + const handleDragEnter = useCallback((e: React.DragEvent) => { + filesRef.current.handleDragEnter(e) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + filesRef.current.handleDragLeave(e) + }, []) + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + filesRef.current.handleFileChange(e) + }, []) + useEffect(() => { if (wasSendingRef.current && !isSending) { textareaRef.current?.focus() @@ -468,14 +857,18 @@ export function UserInput({ return } - prefixRef.current = value + prefixRef.current = valueRef.current if (startRecognition()) { setIsListening(true) } - }, [isListening, value, startRecognition]) + }, [isListening, startRecognition]) const handleSubmit = useCallback(() => { - const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles + const currentFiles = filesRef.current + const currentContext = contextRef.current + const currentValue = valueRef.current + + const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles .filter((f) => !f.uploading && f.key) .map((f) => ({ id: f.id, @@ -486,19 +879,19 @@ export function UserInput({ })) onSubmit( - value, + currentValue, fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined, - contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined + currentContext.selectedContexts.length > 0 ? currentContext.selectedContexts : undefined ) setValue('') restartRecognition('') - files.clearAttachedFiles() - contextManagement.clearContexts() + currentFiles.clearAttachedFiles() + currentContext.clearContexts() if (textareaRef.current) { textareaRef.current.style.height = 'auto' } - }, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition]) + }, [onSubmit, restartRecognition, textareaRef]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -588,9 +981,7 @@ export function UserInput({ const adjusted = `${before}${after}` setValue(adjusted) atInsertPosRef.current = caret - 1 - setPlusMenuOpen(true) - setPlusMenuSearch('') - setPlusMenuActiveIndex(0) + plusMenuRef.current?.open() restartRecognition(adjusted) return } @@ -627,7 +1018,13 @@ export function UserInput({ [isInitialView] ) - const renderOverlayContent = useCallback(() => { + const handleScroll = useCallback((e: React.UIEvent) => { + if (overlayRef.current) { + overlayRef.current.scrollTop = e.currentTarget.scrollTop + } + }, []) + + const overlayContent = useMemo(() => { const contexts = contextManagement.selectedContexts if (!value) { @@ -732,67 +1129,18 @@ export function UserInput({ 'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]', isInitialView && 'shadow-sm' )} - onDragEnter={files.handleDragEnter} - onDragLeave={files.handleDragLeave} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} onDragOver={handleContainerDragOver} onDrop={handleContainerDrop} > - {/* Attached files */} - {files.attachedFiles.length > 0 && ( -
- {files.attachedFiles.map((file) => { - const isImage = file.type.startsWith('image/') - return ( - - -
files.handleFileClick(file)} - > - {isImage && file.previewUrl ? ( - {file.name} - ) : ( -
- {(() => { - const Icon = getDocumentIcon(file.type, file.name) - return - })()} - - {file.name.split('.').pop()} - -
- )} - {file.uploading && ( -
- -
- )} - {!file.uploading && ( - - )} -
-
- -

{file.name}

-
-
- ) - })} -
- )} + + + {/* Textarea with overlay for highlighting */}
@@ -802,7 +1150,7 @@ export function UserInput({ className={cn(OVERLAY_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')} aria-hidden='true' > - {renderOverlayContent()} + {overlayContent}