diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 5fe644d129c..0e8f9b62a6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -215,16 +215,13 @@ function TextEditor({ onSaveStatusChange?.(saveStatus) }, [saveStatus, onSaveStatusChange]) - useEffect(() => { - if (saveRef) { - saveRef.current = saveImmediately - } - return () => { - if (saveRef) { - saveRef.current = null - } - } - }, [saveRef, saveImmediately]) + if (saveRef) saveRef.current = saveImmediately + useEffect( + () => () => { + if (saveRef) saveRef.current = null + }, + [saveRef] + ) useEffect(() => { if (!isResizing) return diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 9ceeb35ac5f..6ba4f70e76e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -160,8 +160,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor ]) const handleOpenWorkflow = useCallback(() => { - router.push(`/workspace/${workspaceId}/w/${workflowId}`) - }, [router, workspaceId, workflowId]) + window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') + }, [workspaceId, workflowId]) return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index b95d761ee6f..d0cf8336cea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback, useEffect, useState } from 'react' +import { forwardRef, memo, useCallback, useState } from 'react' import { cn } from '@/lib/core/utils/cn' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -31,68 +31,79 @@ interface MothershipViewProps { className?: string } -export const MothershipView = memo(function MothershipView({ - workspaceId, - chatId, - resources, - activeResourceId, - onSelectResource, - onAddResource, - onRemoveResource, - onReorderResources, - onCollapse, - isCollapsed, - className, -}: MothershipViewProps) { - const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null +export const MothershipView = memo( + forwardRef(function MothershipView( + { + workspaceId, + chatId, + resources, + activeResourceId, + onSelectResource, + onAddResource, + onRemoveResource, + onReorderResources, + onCollapse, + isCollapsed, + className, + }: MothershipViewProps, + ref + ) { + const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null - const [previewMode, setPreviewMode] = useState('preview') - const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), []) + const [previewMode, setPreviewMode] = useState('preview') + const [prevActiveId, setPrevActiveId] = useState(active?.id) + const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), []) - useEffect(() => { - setPreviewMode('preview') - }, [active?.id]) + // Reset preview mode to default when the active resource changes (guarded render-phase update) + if (active?.id !== prevActiveId) { + setPrevActiveId(active?.id) + setPreviewMode('preview') + } - const isActivePreviewable = - active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) + const isActivePreviewable = + active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) - return ( -
-
- : null} - previewMode={isActivePreviewable ? previewMode : undefined} - onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined} - /> -
- {active ? ( - - ) : ( -
- Click "+" above to add a resource -
- )} + return ( +
+
+ : null + } + previewMode={isActivePreviewable ? previewMode : undefined} + onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined} + /> +
+ {active ? ( + + ) : ( +
+ Click "+" above to add a resource +
+ )} +
-
- ) -}) + ) + }) +) 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 1c106b90642..fffd762b9e2 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 @@ -202,9 +202,7 @@ export function UserInput({ } useEffect(() => { - if (editValue) { - onEditValueConsumed?.() - } + if (editValue) onEditValueConsumed?.() }, [editValue, onEditValueConsumed]) const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index b238e60ca82..1f9a3e73ee9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -26,7 +26,7 @@ import { UserMessageContent, } from './components' import { PendingTagIndicator } from './components/message-content/components/special-tags' -import { useAutoScroll, useChat } from './hooks' +import { useAutoScroll, useChat, useMothershipResize } from './hooks' import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types' const logger = createLogger('Home') @@ -138,26 +138,41 @@ export function Home({ chatId }: HomeProps = {}) { useChatHistory(chatId) const { mutate: markRead } = useMarkTaskRead(workspaceId) + const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize() + const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false) const [skipResourceTransition, setSkipResourceTransition] = useState(false) const isResourceCollapsedRef = useRef(isResourceCollapsed) isResourceCollapsedRef.current = isResourceCollapsed - const collapseResource = useCallback(() => setIsResourceCollapsed(true), []) - const expandResource = useCallback(() => { - setIsResourceCollapsed(false) + const collapseResource = useCallback(() => { + clearWidth() + setIsResourceCollapsed(true) + }, [clearWidth]) + const animatingInTimerRef = useRef | null>(null) + const startAnimatingIn = useCallback(() => { + if (animatingInTimerRef.current) clearTimeout(animatingInTimerRef.current) setIsResourceAnimatingIn(true) + animatingInTimerRef.current = setTimeout(() => { + setIsResourceAnimatingIn(false) + animatingInTimerRef.current = null + }, 400) }, []) + const expandResource = useCallback(() => { + setIsResourceCollapsed(false) + startAnimatingIn() + }, [startAnimatingIn]) + const handleResourceEvent = useCallback(() => { if (isResourceCollapsedRef.current) { const { isCollapsed, toggleCollapsed } = useSidebarStore.getState() if (!isCollapsed) toggleCollapsed() setIsResourceCollapsed(false) - setIsResourceAnimatingIn(true) + startAnimatingIn() } - }, []) + }, [startAnimatingIn]) const { messages, @@ -178,8 +193,15 @@ export function Home({ chatId }: HomeProps = {}) { } = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent }) const [editingInputValue, setEditingInputValue] = useState('') + const [prevChatId, setPrevChatId] = useState(chatId) const clearEditingValue = useCallback(() => setEditingInputValue(''), []) + // Clear editing value when navigating to a different chat (guarded render-phase update) + if (chatId !== prevChatId) { + setPrevChatId(chatId) + setEditingInputValue('') + } + const handleEditQueuedMessage = useCallback( (id: string) => { const msg = editQueuedMessage(id) @@ -190,10 +212,6 @@ export function Home({ chatId }: HomeProps = {}) { [editQueuedMessage] ) - useEffect(() => { - setEditingInputValue('') - }, [chatId]) - useEffect(() => { wasSendingRef.current = false if (resolvedChatId) markRead(resolvedChatId) @@ -207,23 +225,12 @@ export function Home({ chatId }: HomeProps = {}) { }, [isSending, resolvedChatId, markRead]) useEffect(() => { - if (!isResourceAnimatingIn) return - const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400) - return () => clearTimeout(timer) - }, [isResourceAnimatingIn]) - - useEffect(() => { - if (resources.length > 0 && isResourceCollapsedRef.current) { - setSkipResourceTransition(true) - setIsResourceCollapsed(false) - } - }, [resources]) - - useEffect(() => { - if (!skipResourceTransition) return + if (!(resources.length > 0 && isResourceCollapsedRef.current)) return + setIsResourceCollapsed(false) + setSkipResourceTransition(true) const id = requestAnimationFrame(() => setSkipResourceTransition(false)) return () => cancelAnimationFrame(id) - }, [skipResourceTransition]) + }, [resources]) const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { @@ -359,7 +366,7 @@ export function Home({ chatId }: HomeProps = {}) { return (
-
+
+ {/* Resize handle — zero-width flex child whose absolute child straddles the border */} + {!isResourceCollapsed && ( +
+
+
+ )} + { + if (resources.length === 0) return null + if (activeResourceId && resources.some((r) => r.id === activeResourceId)) + return activeResourceId + return resources[resources.length - 1].id + }, [resources, activeResourceId]) + + const activeResourceIdRef = useRef(effectiveActiveResourceId) + activeResourceIdRef.current = effectiveActiveResourceId const [messageQueue, setMessageQueue] = useState([]) const messageQueueRef = useRef([]) - useEffect(() => { - messageQueueRef.current = messageQueue - }, [messageQueue]) + messageQueueRef.current = messageQueue const sendMessageRef = useRef(async () => {}) const processSSEStreamRef = useRef< @@ -482,19 +490,6 @@ export function useChat( } }, [chatHistory, workspaceId, queryClient]) - useEffect(() => { - if (resources.length === 0) { - if (activeResourceId !== null) { - setActiveResourceId(null) - } - return - } - - if (!activeResourceId || !resources.some((resource) => resource.id === activeResourceId)) { - setActiveResourceId(resources[resources.length - 1].id) - } - }, [activeResourceId, resources]) - const processSSEStream = useCallback( async ( reader: ReadableStreamDefaultReader, @@ -871,9 +866,7 @@ export function useChat( }, [workspaceId, queryClient, addResource, removeResource] ) - useLayoutEffect(() => { - processSSEStreamRef.current = processSSEStream - }) + processSSEStreamRef.current = processSSEStream const persistPartialResponse = useCallback(async () => { const chatId = chatIdRef.current @@ -962,9 +955,7 @@ export function useChat( }, [invalidateChatQueries] ) - useLayoutEffect(() => { - finalizeRef.current = finalize - }) + finalizeRef.current = finalize const sendMessage = useCallback( async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { @@ -1100,9 +1091,7 @@ export function useChat( }, [workspaceId, queryClient, processSSEStream, finalize] ) - useLayoutEffect(() => { - sendMessageRef.current = sendMessage - }) + sendMessageRef.current = sendMessage const stopGeneration = useCallback(async () => { if (sendingRef.current && !chatIdRef.current) { @@ -1240,7 +1229,7 @@ export function useChat( sendMessage, stopGeneration, resources, - activeResourceId, + activeResourceId: effectiveActiveResourceId, setActiveResourceId, addResource, removeResource, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts new file mode 100644 index 00000000000..7a6c1e0fd93 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef } from 'react' +import { MOTHERSHIP_WIDTH } from '@/stores/constants' + +/** + * Hook for managing resize of the MothershipView resource panel. + * + * Uses imperative DOM manipulation (zero React re-renders during drag) with + * Pointer Events + setPointerCapture for unified mouse/touch/stylus support. + * Attach `mothershipRef` to the MothershipView root div and bind + * `handleResizePointerDown` to the drag handle's onPointerDown. + * Call `clearWidth` when the panel collapses so the CSS class retakes control. + */ +export function useMothershipResize() { + const mothershipRef = useRef(null) + // Stored so the useEffect cleanup can tear down listeners if the component unmounts mid-drag + const cleanupRef = useRef<(() => void) | null>(null) + + const handleResizePointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault() + + const el = mothershipRef.current + if (!el) return + + const handle = e.currentTarget as HTMLElement + handle.setPointerCapture(e.pointerId) + + // Pin to current rendered width so drag starts from the visual position + el.style.width = `${el.getBoundingClientRect().width}px` + + // Disable CSS transition to prevent animation lag during drag + const prevTransition = el.style.transition + el.style.transition = 'none' + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + + // AbortController removes all listeners at once on cleanup/cancel/unmount + const ac = new AbortController() + const { signal } = ac + + const cleanup = () => { + ac.abort() + el.style.transition = prevTransition + document.body.style.cursor = '' + document.body.style.userSelect = '' + cleanupRef.current = null + } + cleanupRef.current = cleanup + + handle.addEventListener( + 'pointermove', + (moveEvent: PointerEvent) => { + const newWidth = window.innerWidth - moveEvent.clientX + const maxWidth = window.innerWidth * MOTHERSHIP_WIDTH.MAX_PERCENTAGE + el.style.width = `${Math.min(Math.max(newWidth, MOTHERSHIP_WIDTH.MIN), maxWidth)}px` + }, + { signal } + ) + + handle.addEventListener( + 'pointerup', + (upEvent: PointerEvent) => { + handle.releasePointerCapture(upEvent.pointerId) + cleanup() + }, + { signal } + ) + + // Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.) + // Without this, body cursor/userSelect and transition would be permanently stuck + handle.addEventListener('pointercancel', cleanup, { signal }) + }, []) + + // Tear down any active drag if the component unmounts mid-drag + useEffect(() => { + return () => { + cleanupRef.current?.() + } + }, []) + + // Re-clamp panel width when the viewport is resized (inline px width can exceed max after narrowing) + useEffect(() => { + const handleWindowResize = () => { + const el = mothershipRef.current + if (!el || !el.style.width) return + const maxWidth = window.innerWidth * MOTHERSHIP_WIDTH.MAX_PERCENTAGE + const current = el.getBoundingClientRect().width + if (current > maxWidth) { + el.style.width = `${maxWidth}px` + } + } + window.addEventListener('resize', handleWindowResize) + return () => window.removeEventListener('resize', handleWindowResize) + }, []) + + /** Remove inline width so the collapse CSS class retakes control */ + const clearWidth = useCallback(() => { + mothershipRef.current?.style.removeProperty('width') + }, []) + + return { mothershipRef, handleResizePointerDown, clearWidth } +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx index 71de87b35fa..25281533fcd 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx @@ -169,16 +169,13 @@ export function ChunkEditor({ const saveFunction = isCreateMode ? handleSave : saveImmediately - useEffect(() => { - if (saveRef) { - saveRef.current = saveFunction - } - return () => { - if (saveRef) { - saveRef.current = null - } - } - }, [saveRef, saveFunction]) + if (saveRef) saveRef.current = saveFunction + useEffect( + () => () => { + if (saveRef) saveRef.current = null + }, + [saveRef] + ) const tokenStrings = useMemo(() => { if (!tokenizerOn || !editedContent) return [] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 20f224eea09..dbdf92ba327 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -274,9 +274,7 @@ export function KnowledgeBase({ const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id) const hasSyncingConnectors = connectors.some((c) => c.status === 'syncing') const hasSyncingConnectorsRef = useRef(hasSyncingConnectors) - useEffect(() => { - hasSyncingConnectorsRef.current = hasSyncingConnectors - }, [hasSyncingConnectors]) + hasSyncingConnectorsRef.current = hasSyncingConnectors const { documents, @@ -752,11 +750,9 @@ export function KnowledgeBase({ const prevKnowledgeBaseIdRef = useRef(id) const isNavigatingToNewKB = prevKnowledgeBaseIdRef.current !== id - useEffect(() => { - if (knowledgeBase && knowledgeBase.id === id) { - prevKnowledgeBaseIdRef.current = id - } - }, [knowledgeBase, id]) + if (knowledgeBase && knowledgeBase.id === id) { + prevKnowledgeBaseIdRef.current = id + } const isInitialLoad = isLoadingKnowledgeBase && !knowledgeBase const isFetchingNewKB = isNavigatingToNewKB && isFetchingDocuments diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index 2821215788e..686952a2fe5 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -220,10 +220,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { return result }, [rawExecutions]) - - useEffect(() => { - prevExecutionsRef.current = executions - }, [executions]) + prevExecutionsRef.current = executions const lastExecutionByWorkflow = useMemo(() => { const map = new Map() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 9aa2607113e..7a41e992c72 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Info, Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' @@ -64,13 +64,19 @@ export function ApiKeys() { const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [searchTerm, setSearchTerm] = useState('') - const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false) const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace' const createButtonDisabled = isLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys) const scrollContainerRef = useRef(null) + const scrollToBottom = useCallback(() => { + scrollContainerRef.current?.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: 'smooth', + }) + }, []) + const filteredWorkspaceKeys = useMemo(() => { if (!searchTerm.trim()) { return workspaceKeys.map((key, index) => ({ key, originalIndex: index })) @@ -111,16 +117,6 @@ export function ApiKeys() { } } - useEffect(() => { - if (shouldScrollToBottom && scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: scrollContainerRef.current.scrollHeight, - behavior: 'smooth', - }) - setShouldScrollToBottom(false) - } - }, [shouldScrollToBottom]) - const formatLastUsed = (dateString?: string) => { if (!dateString) return 'Never' return formatDate(new Date(dateString)) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index de1c96842f6..15ff5a19079 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -316,6 +316,9 @@ export function CredentialsManager() { // --- Detail view state --- const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [prevSelectedCredentialId, setPrevSelectedCredentialId] = useState< + string | null | undefined + >(undefined) const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('') const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('') const [copyIdSuccess, setCopyIdSuccess] = useState(false) @@ -347,6 +350,19 @@ export function CredentialsManager() { [envCredentials, selectedCredentialId] ) + if (selectedCredential?.id !== prevSelectedCredentialId) { + setPrevSelectedCredentialId(selectedCredential?.id ?? null) + if (!selectedCredential) { + setSelectedDescriptionDraft('') + setSelectedDisplayNameDraft('') + setDetailsError(null) + } else { + setDetailsError(null) + setSelectedDescriptionDraft(selectedCredential.description || '') + setSelectedDisplayNameDraft(selectedCredential.displayName) + } + } + // --- Detail view hooks --- const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers( selectedCredential?.id @@ -458,12 +474,10 @@ export function CredentialsManager() { return personalInvalid || workspaceInvalid }, [envVars, newWorkspaceRows]) - // --- Effects --- - useEffect(() => { - hasChangesRef.current = hasChanges - shouldBlockNavRef.current = hasChanges || isDetailsDirty - }, [hasChanges, isDetailsDirty]) + hasChangesRef.current = hasChanges + shouldBlockNavRef.current = hasChanges || isDetailsDirty + // --- Effects --- useEffect(() => { if (hasSavedRef.current) return @@ -549,19 +563,6 @@ export function CredentialsManager() { } }, []) - // --- Detail view: sync drafts when credential changes --- - useEffect(() => { - if (!selectedCredential) { - setSelectedDescriptionDraft('') - setSelectedDisplayNameDraft('') - return - } - - setDetailsError(null) - setSelectedDescriptionDraft(selectedCredential.description || '') - setSelectedDisplayNameDraft(selectedCredential.displayName) - }, [selectedCredential]) - // --- Pending credential create request --- const applyPendingCredentialCreateRequest = useCallback( (request: PendingCredentialCreateRequest) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 45e3889d720..c7d63ab87e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -68,6 +68,12 @@ export function General() { const [name, setName] = useState(profile?.name || '') const [isEditingName, setIsEditingName] = useState(false) const inputRef = useRef(null) + const [prevProfileName, setPrevProfileName] = useState(profile?.name) + + if (profile?.name && profile.name !== prevProfileName) { + setPrevProfileName(profile.name) + setName(profile.name) + } const [showResetPasswordModal, setShowResetPasswordModal] = useState(false) const resetPassword = useResetPassword() @@ -76,12 +82,6 @@ export function General() { const snapToGridValue = settings?.snapToGridSize ?? 0 - useEffect(() => { - if (profile?.name) { - setName(profile.name) - } - }, [profile?.name]) - const { previewUrl: profilePictureUrl, fileInputRef: profilePictureInputRef, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx index c4f17704ff5..d1b2d119a11 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx @@ -97,16 +97,14 @@ const PlanModeSection: React.FC = ({ const [isResizing, setIsResizing] = React.useState(false) const [isEditing, setIsEditing] = React.useState(false) const [editedContent, setEditedContent] = React.useState(content) + const [prevContent, setPrevContent] = React.useState(content) + if (!isEditing && content !== prevContent) { + setPrevContent(content) + setEditedContent(content) + } const resizeStartRef = React.useRef({ y: 0, startHeight: 0 }) const textareaRef = React.useRef(null) - // Update edited content when content prop changes - React.useEffect(() => { - if (!isEditing) { - setEditedContent(content) - } - }, [content, isEditing]) - const handleResizeStart = React.useCallback( (e: React.MouseEvent) => { e.preventDefault() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx index 8c865f1c21e..7355e69cb6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useEffect, useState } from 'react' +import { memo, useState } from 'react' import { Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react' import { Button } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -47,13 +47,11 @@ export const TodoList = memo(function TodoList({ className, }: TodoListProps) { const [isCollapsed, setIsCollapsed] = useState(collapsed) - - /** - * Sync collapsed prop with internal state - */ - useEffect(() => { + const [prevCollapsed, setPrevCollapsed] = useState(collapsed) + if (collapsed !== prevCollapsed) { + setPrevCollapsed(collapsed) setIsCollapsed(collapsed) - }, [collapsed]) + } if (!todos || todos.length === 0) { return null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 819d0ef5d04..23eec222625 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { escapeRegex, filterOutContext, @@ -22,15 +22,6 @@ interface UseContextManagementProps { */ export function useContextManagement({ message, initialContexts }: UseContextManagementProps) { const [selectedContexts, setSelectedContexts] = useState(initialContexts ?? []) - const initializedRef = useRef(false) - - // Initialize with initial contexts when they're first provided (for edit mode) - useEffect(() => { - if (initialContexts && initialContexts.length > 0 && !initializedRef.current) { - setSelectedContexts(initialContexts) - initializedRef.current = true - } - }, [initialContexts]) /** * Adds a context to the selected contexts list, avoiding duplicates diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index f45d36959a8..cd46688c516 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Button, @@ -49,7 +49,10 @@ export function GeneralDeploy({ onLoadDeploymentComplete, }: GeneralDeployProps) { const [selectedVersion, setSelectedVersion] = useState(null) - const [previewMode, setPreviewMode] = useState('active') + const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false) + // Derived — no useEffect needed + const previewMode: PreviewMode = + selectedVersion !== null && !showActiveDespiteSelection ? 'selected' : 'active' const [showLoadDialog, setShowLoadDialog] = useState(false) const [showPromoteDialog, setShowPromoteDialog] = useState(false) const [showExpandedPreview, setShowExpandedPreview] = useState(false) @@ -64,16 +67,9 @@ export function GeneralDeploy({ const revertMutation = useRevertToVersion() - useEffect(() => { - if (selectedVersion !== null) { - setPreviewMode('selected') - } else { - setPreviewMode('active') - } - }, [selectedVersion]) - const handleSelectVersion = useCallback((version: number | null) => { setSelectedVersion(version) + setShowActiveDespiteSelection(false) }, []) const handleLoadDeployment = useCallback((version: number) => { @@ -164,7 +160,9 @@ export function GeneralDeploy({ > setPreviewMode(val as PreviewMode)} + onValueChange={(val) => + setShowActiveDespiteSelection((val as PreviewMode) === 'active') + } > Live diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index fb5b18c5df1..712fe07d50e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -227,12 +227,39 @@ export function DeployModal({ getApiKeyLabel, ]) + const selectedStreamingOutputsRef = useRef(selectedStreamingOutputs) + selectedStreamingOutputsRef.current = selectedStreamingOutputs + useEffect(() => { if (open && workflowId) { setActiveTab('general') setDeployError(null) setDeployWarnings([]) setChatSuccess(false) + + const currentOutputs = selectedStreamingOutputsRef.current + if (currentOutputs.length > 0) { + const blocks = Object.values(useWorkflowStore.getState().blocks) + const validOutputs = currentOutputs.filter((outputId) => { + if (startsWithUuid(outputId)) { + const underscoreIndex = outputId.indexOf('_') + if (underscoreIndex === -1) return false + const blockId = outputId.substring(0, underscoreIndex) + return blocks.some((b) => b.id === blockId) + } + const parts = outputId.split('.') + if (parts.length >= 2) { + const blockName = parts[0] + return blocks.some( + (b) => b.name?.toLowerCase().replace(/\s+/g, '') === blockName.toLowerCase() + ) + } + return true + }) + if (validOutputs.length !== currentOutputs.length) { + setSelectedStreamingOutputs(validOutputs) + } + } } return () => { if (chatSuccessTimeoutRef.current) { @@ -241,38 +268,6 @@ export function DeployModal({ } }, [open, workflowId]) - useEffect(() => { - if (!open || selectedStreamingOutputs.length === 0) return - - const blocks = Object.values(useWorkflowStore.getState().blocks) - - const validOutputs = selectedStreamingOutputs.filter((outputId) => { - if (startsWithUuid(outputId)) { - const underscoreIndex = outputId.indexOf('_') - if (underscoreIndex === -1) return false - - const blockId = outputId.substring(0, underscoreIndex) - const block = blocks.find((b) => b.id === blockId) - return !!block - } - - const parts = outputId.split('.') - if (parts.length >= 2) { - const blockName = parts[0] - const block = blocks.find( - (b) => b.name?.toLowerCase().replace(/\s+/g, '') === blockName.toLowerCase() - ) - return !!block - } - - return true - }) - - if (validOutputs.length !== selectedStreamingOutputs.length) { - setSelectedStreamingOutputs(validOutputs) - } - }, [open, selectedStreamingOutputs, setSelectedStreamingOutputs]) - useEffect(() => { const handleOpenDeployModal = (event: Event) => { const customEvent = event as CustomEvent<{ tab?: TabView }> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index f20da325f1f..d28bf328837 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -120,7 +120,6 @@ export const ComboBox = memo(function ComboBox({ ) // State management - const [storeInitialized, setStoreInitialized] = useState(false) const [fetchedOptions, setFetchedOptions] = useState>([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) @@ -280,27 +279,22 @@ export const ComboBox = memo(function ComboBox({ }, [value, evaluatedOptions]) const [inputValue, setInputValue] = useState(displayValue) - - useEffect(() => { + const [prevDisplayValue, setPrevDisplayValue] = useState(displayValue) + if (displayValue !== prevDisplayValue) { + setPrevDisplayValue(displayValue) setInputValue(displayValue) - }, [displayValue]) - - // Mark store as initialized on first render - useEffect(() => { - setStoreInitialized(true) - }, []) + } - // Set default value once store is initialized and permissions are loaded + // Set default value once permissions are loaded useEffect(() => { if (isPermissionLoading) return - if (!storeInitialized) return if (defaultOptionValue === undefined) return // Only set default when no value exists (initial block add) if (value === null || value === undefined) { setStoreValue(defaultOptionValue) } - }, [storeInitialized, value, defaultOptionValue, setStoreValue, isPermissionLoading]) + }, [value, defaultOptionValue, setStoreValue, isPermissionLoading]) // Clear fetched options and hydrated option when dependencies change useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index fb52e3086fd..8427ba71e71 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -124,7 +124,6 @@ export const Dropdown = memo(function Dropdown({ isEqual ) - const [storeInitialized, setStoreInitialized] = useState(false) const [fetchedOptions, setFetchedOptions] = useState>([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) @@ -242,17 +241,13 @@ export const Dropdown = memo(function Dropdown({ }, [defaultValue, comboboxOptions, multiSelect]) useEffect(() => { - setStoreInitialized(true) - }, []) - - useEffect(() => { - if (multiSelect || !storeInitialized || defaultOptionValue === undefined) { + if (multiSelect || defaultOptionValue === undefined) { return } if (storeValue === null || storeValue === undefined || storeValue === '') { setStoreValue(defaultOptionValue) } - }, [storeInitialized, storeValue, defaultOptionValue, setStoreValue, multiSelect]) + }, [storeValue, defaultOptionValue, setStoreValue, multiSelect]) /** * Normalizes variable references in JSON strings by wrapping them in quotes diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx index 78157cf361f..406441880f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx @@ -122,11 +122,9 @@ export function LongInput({ isStreaming: wandHook.isStreaming, }) - useEffect(() => { - persistSubBlockValueRef.current = (value: string) => { - setSubBlockValue(value) - } - }, [setSubBlockValue]) + persistSubBlockValueRef.current = (value: string) => { + setSubBlockValue(value) + } // Check if wand is actually enabled const isWandEnabled = config.wandConfig?.enabled ?? false @@ -193,12 +191,12 @@ export function LongInput({ // Sync local content with base value when not streaming useEffect(() => { if (!wandHook.isStreaming) { - const baseValueString = baseValue?.toString() ?? '' - if (baseValueString !== localContent) { - setLocalContent(baseValueString) - } + setLocalContent((prev) => { + const baseValueString = baseValue?.toString() ?? '' + return baseValueString !== prev ? baseValueString : prev + }) } - }, [baseValue, wandHook.isStreaming]) // Removed localContent to prevent infinite loop + }, [baseValue, wandHook.isStreaming]) // Update height when rows prop changes useLayoutEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx index e2d2dd19160..6e7577cd8c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx @@ -109,11 +109,9 @@ export const ShortInput = memo(function ShortInput({ isStreaming: wandHook.isStreaming, }) - useEffect(() => { - persistSubBlockValueRef.current = (value: string) => { - setSubBlockValue(value) - } - }, [setSubBlockValue]) + persistSubBlockValueRef.current = (value: string) => { + setSubBlockValue(value) + } const isWandEnabled = config.wandConfig?.enabled ?? false @@ -214,12 +212,12 @@ export const ShortInput = memo(function ShortInput({ useEffect(() => { if (!wandHook.isStreaming) { - const baseValueString = baseValue?.toString() ?? '' - if (baseValueString !== localContent) { - setLocalContent(baseValueString) - } + setLocalContent((prev) => { + const baseValueString = baseValue?.toString() ?? '' + return baseValueString !== prev ? baseValueString : prev + }) } - }, [baseValue, wandHook.isStreaming, localContent]) + }, [baseValue, wandHook.isStreaming]) const handleScroll = useCallback((e: React.UIEvent) => { if (overlayRef.current) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index d215f5576ca..991aafe4e92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -310,6 +310,14 @@ export const Toolbar = memo( // Search state const [isSearchActive, setIsSearchActive] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [prevIsActive, setPrevIsActive] = useState(isActive) + if (isActive !== prevIsActive) { + setPrevIsActive(isActive) + if (!isActive) { + setIsSearchActive(false) + setSearchQuery('') + } + } // Toggle animation state const [isToggling, setIsToggling] = useState(false) @@ -350,14 +358,8 @@ export const Toolbar = memo( const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD /** - * Clear search when tab becomes inactive + * Filter items based on search query */ - useEffect(() => { - if (!isActive) { - setIsSearchActive(false) - setSearchQuery('') - } - }, [isActive]) /** * Filter items based on search query diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index fb2db8b2163..735fd1ac9fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -604,11 +604,13 @@ export const Terminal = memo(function Terminal() { const [autoSelectEnabled, setAutoSelectEnabled] = useState(true) const [mainOptionsOpen, setMainOptionsOpen] = useState(false) - const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false) + const [isTrainingEnvEnabled] = useState(() => + isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED')) + ) const showTrainingControls = useShowTrainingControls() const { isTraining, toggleModal: toggleTrainingModal, stopTraining } = useCopilotTrainingStore() - const [isPlaygroundEnabled, setIsPlaygroundEnabled] = useState(false) + const [isPlaygroundEnabled] = useState(() => isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND'))) const { handleMouseDown } = useTerminalResize() const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize() @@ -709,21 +711,21 @@ export const Terminal = memo(function Terminal() { }, [outputData]) // Keep refs in sync for keyboard handler - useEffect(() => { - selectedEntryRef.current = selectedEntry - navigableEntriesRef.current = navigableEntries - showInputRef.current = showInput - hasInputDataRef.current = hasInputData - isExpandedRef.current = isExpanded - }, [selectedEntry, navigableEntries, showInput, hasInputData, isExpanded]) + selectedEntryRef.current = selectedEntry + navigableEntriesRef.current = navigableEntries + showInputRef.current = showInput + hasInputDataRef.current = hasInputData + isExpandedRef.current = isExpanded /** * Reset entry tracking when switching workflows to ensure auto-open * works correctly for each workflow independently. */ - useEffect(() => { + const prevActiveWorkflowIdRef = useRef(activeWorkflowId) + if (prevActiveWorkflowIdRef.current !== activeWorkflowId) { + prevActiveWorkflowIdRef.current = activeWorkflowId hasInitializedEntriesRef.current = false - }, [activeWorkflowId]) + } /** * Auto-open the terminal on new entries when "Open on run" is enabled. @@ -961,11 +963,6 @@ export const Terminal = memo(function Terminal() { return unsub }, []) - useEffect(() => { - setIsTrainingEnvEnabled(isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))) - setIsPlaygroundEnabled(isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND'))) - }, []) - useEffect(() => { if (!selectedEntry) { setShowInput(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx index e39cbbb9cb8..ebd5e9a1a36 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx @@ -29,6 +29,11 @@ export function WandPromptBar({ }: WandPromptBarProps) { const promptBarRef = useRef(null) const [isExiting, setIsExiting] = useState(false) + const [prevIsVisible, setPrevIsVisible] = useState(isVisible) + if (isVisible !== prevIsVisible) { + setPrevIsVisible(isVisible) + if (isVisible) setIsExiting(false) + } // Handle the fade-out animation const handleCancel = () => { @@ -66,13 +71,6 @@ export function WandPromptBar({ } }, [isVisible, isStreaming, isLoading, isExiting, onCancel]) - // Reset the exit state when visibility changes - useEffect(() => { - if (isVisible) { - setIsExiting(false) - } - }, [isVisible]) - if (!isVisible && !isStreaming && !isExiting) { return null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts index 620c75e37e7..717bb6e89b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' const AUTO_SCROLL_GRACE_MS = 120 @@ -38,6 +38,13 @@ export function useScrollManagement( ) { const scrollAreaRef = useRef(null) const [userHasScrolledAway, setUserHasScrolledAway] = useState(false) + const [prevIsSendingMessage, setPrevIsSendingMessage] = useState(isSendingMessage) + if (prevIsSendingMessage !== isSendingMessage) { + setPrevIsSendingMessage(isSendingMessage) + if (!isSendingMessage) { + setUserHasScrolledAway(false) + } + } const programmaticUntilRef = useRef(0) const lastScrollTopRef = useRef(0) @@ -138,12 +145,6 @@ export function useScrollManagement( } }, [messages, userHasScrolledAway, scrollToBottom]) - useEffect(() => { - if (!isSendingMessage) { - setUserHasScrolledAway(false) - } - }, [isSendingMessage]) - useEffect(() => { if (!isSendingMessage || userHasScrolledAway) return @@ -167,7 +168,7 @@ export function useScrollManagement( // overflow-anchor: none during streaming prevents the browser from // fighting our programmatic scrollToBottom calls (Chromium/Firefox only; // Safari does not support this property). - useEffect(() => { + useLayoutEffect(() => { const container = scrollAreaRef.current if (!container) return diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 3a7e91870a8..461de618c53 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -336,9 +336,7 @@ const WorkflowContent = React.memo( const isAutoConnectEnabled = useAutoConnect() const autoConnectRef = useRef(isAutoConnectEnabled) - useEffect(() => { - autoConnectRef.current = isAutoConnectEnabled - }, [isAutoConnectEnabled]) + autoConnectRef.current = isAutoConnectEnabled // Panel open states for context menu const isVariablesOpen = useVariablesStore((state) => state.isOpen) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 67e0066c4ed..f2f6c40a0c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -277,16 +277,20 @@ function ConnectionsSection({ onResizeMouseDown, onToggleCollapsed, }: ConnectionsSectionProps) { - const [expandedBlocks, setExpandedBlocks] = useState>(() => new Set()) + /** Stable string of connection IDs to prevent guard from running on every render */ + const connectionIds = useMemo(() => connections.map((c) => c.blockId).join(','), [connections]) + + const [expandedBlocks, setExpandedBlocks] = useState>( + () => new Set(connectionIds.split(',').filter(Boolean)) + ) const [expandedVariables, setExpandedVariables] = useState(true) const [expandedEnvVars, setExpandedEnvVars] = useState(true) - /** Stable string of connection IDs to prevent effect from running on every render */ - const connectionIds = useMemo(() => connections.map((c) => c.blockId).join(','), [connections]) - - useEffect(() => { + const [prevConnectionIds, setPrevConnectionIds] = useState(connectionIds) + if (connectionIds !== prevConnectionIds) { + setPrevConnectionIds(connectionIds) setExpandedBlocks(new Set(connectionIds.split(',').filter(Boolean))) - }, [connectionIds]) + } const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index 816e31526b2..169d643a398 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { ArrowLeft } from 'lucide-react' import { Button, Tooltip } from '@/components/emcn' import { redactApiKeys } from '@/lib/core/security/redaction' @@ -161,6 +161,11 @@ export function Preview({ }) const [workflowStack, setWorkflowStack] = useState([]) + const [prevRootState, setPrevRootState] = useState(rootWorkflowState) + if (rootWorkflowState !== prevRootState) { + setPrevRootState(rootWorkflowState) + setWorkflowStack([]) + } const rootBlockExecutions = useMemo(() => { if (providedBlockExecutions) return providedBlockExecutions @@ -227,10 +232,6 @@ export function Preview({ setPinnedBlockId(null) }, []) - useEffect(() => { - setWorkflowStack([]) - }, [rootWorkflowState]) - const isNested = workflowStack.length > 0 const currentWorkflowName = isNested ? workflowStack[workflowStack.length - 1].workflowName : null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index c08b59b6331..077516f2fd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -175,24 +175,26 @@ export function SearchModal({ ] ) + const [search, setSearch] = useState('') + const [prevOpen, setPrevOpen] = useState(open) + if (open !== prevOpen) { + setPrevOpen(open) + if (open) setSearch('') + } + useEffect(() => { - if (open) { - setSearch('') - if (inputRef.current) { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - )?.set - if (nativeInputValueSetter) { - nativeInputValueSetter.call(inputRef.current, '') - inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) - } - inputRef.current.focus() - } + if (!open || !inputRef.current) return + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + if (nativeInputValueSetter) { + nativeInputValueSetter.call(inputRef.current, '') + inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) } + inputRef.current.focus() }, [open]) - const [search, setSearch] = useState('') const deferredSearch = useDeferredValue(search) const handleSearchChange = useCallback((value: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index 85f92fcf598..581ca8ad907 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -124,13 +124,6 @@ export function useDragDrop(options: UseDragDropOptions = {}) { } }, [hoverFolderId, isDragging, expandedFolders, setExpanded]) - useEffect(() => { - if (!isDragging) { - setHoverFolderId(null) - setDropIndicator(null) - } - }, [isDragging]) - const calculateDropPosition = useCallback( (e: React.DragEvent, element: HTMLElement): 'before' | 'after' => { const rect = element.getBoundingClientRect() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-item-rename.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-item-rename.ts index bb9c1456253..44004a43884 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-item-rename.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-item-rename.ts @@ -42,13 +42,6 @@ export function useItemRename({ initialName, onSave, itemType, itemId }: UseItem const [isRenaming, setIsRenaming] = useState(false) const inputRef = useRef(null) - /** - * Update edit value when initial name changes - */ - useEffect(() => { - setEditValue(initialName) - }, [initialName]) - /** * Focus and select input when entering edit mode */ diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index 16598fc76c5..d0aac1f8272 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -32,6 +32,7 @@ export function useAutosave({ }: UseAutosaveOptions): UseAutosaveReturn { const [saveStatus, setSaveStatus] = useState('idle') const timerRef = useRef>(undefined) + const idleTimerRef = useRef>(undefined) const savingRef = useRef(false) const onSaveRef = useRef(onSave) onSaveRef.current = onSave @@ -59,6 +60,8 @@ export function useAutosave({ const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed) setTimeout(() => { setSaveStatus(nextStatus) + clearTimeout(idleTimerRef.current) + idleTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) savingRef.current = false if (nextStatus !== 'error' && contentRef.current !== savedContentRef.current) { save() @@ -74,16 +77,10 @@ export function useAutosave({ return () => clearTimeout(timerRef.current) }, [content, enabled, isDirty, delay, save]) - useEffect(() => { - if (saveStatus === 'saved' || saveStatus === 'error') { - const t = setTimeout(() => setSaveStatus('idle'), 2000) - return () => clearTimeout(t) - } - }, [saveStatus]) - useEffect(() => { return () => { clearTimeout(timerRef.current) + clearTimeout(idleTimerRef.current) if (contentRef.current !== savedContentRef.current && !savingRef.current) { onSaveRef.current().catch(() => {}) } diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index 98b40148329..1d534a48463 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -119,22 +119,22 @@ export function useWebhookManagement({ const queryEnabled = useWebhookUrl && !isPreview && Boolean(workflowId && blockId) + // Reset sync flag when blockId changes or query becomes disabled (render-phase guard) + const prevBlockIdRef = useRef(blockId) + if (blockId !== prevBlockIdRef.current) { + prevBlockIdRef.current = blockId + syncedRef.current = false + } + if (!queryEnabled) { + syncedRef.current = false + } + const { data: webhook, isLoading: queryLoading } = useWebhookQuery( workflowId, blockId, queryEnabled ) - useEffect(() => { - syncedRef.current = false - }, [blockId]) - - useEffect(() => { - if (!queryEnabled) { - syncedRef.current = false - } - }, [queryEnabled]) - useEffect(() => { if (!queryEnabled || syncedRef.current) return if (webhook === undefined) return diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index 97d2ca898cf..88f82974e0b 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -64,5 +64,12 @@ export const OUTPUT_PANEL_WIDTH = { MIN: 280, } as const +/** Home chat resource panel (MothershipView) width constraints */ +export const MOTHERSHIP_WIDTH = { + MIN: 280, + /** Maximum is 65% of viewport, enforced dynamically */ + MAX_PERCENTAGE: 0.65, +} as const + /** Terminal block column width - minimum width for the logs column */ export const TERMINAL_BLOCK_COLUMN_WIDTH = 240 as const