feat(home): resizable chat/resource panel divider (#3648)

* feat(home): resizable chat/resource panel divider

* fix(home): address PR review comments

- Remove aria-hidden from resize handle outer div so separator role is visible to AT
- Add viewport-resize re-clamping in useMothershipResize to prevent panel exceeding max % after browser window narrows
- Change default MothershipView width from 60% to 50%

* refactor(home): eradicate useEffect anti-patterns per you-might-not-need-an-effect

- use-chat: remove messageQueue→ref sync Effect; inline assignment like other refs
- use-chat: replace activeResourceId selection Effect with useMemo (derived value, avoids
  extra re-render cycle; activeResourceIdRef now tracks effective value for API payloads)
- use-chat: replace 3x useLayoutEffect ref-sync (processSSEStream, finalize, sendMessage)
  with direct render-phase assignment — consistent with existing resourcesRef pattern
- user-input: fold onEditValueConsumed callback into existing render-phase guard; remove Effect
- home: move isResourceAnimatingIn 400ms timer into expandResource/handleResourceEvent event
  handlers where setIsResourceAnimatingIn(true) is called; remove reactive Effect watcher

* fix(home): revert default width to 60%, reduce max resize to 63%

* improvement(react): replace useEffect anti-patterns with better React primitives

* improvement(react): replace useEffect anti-patterns with better React primitives

* improvement(home): use pointer events for resize handle (touch + mouse support)

* fix(autosave): store idle-reset timer ref to prevent status corruption on rapid saves

* fix(home): move onEditValueConsumed call out of render phase into useEffect

* fix(home): add pointercancel handler; fix(settings): sync name on profile refetch

* fix(home): restore cleanupRef assignment dropped during AbortController refactor
This commit is contained in:
Waleed
2026-03-18 02:57:44 -07:00
committed by GitHub
parent b84f30e9e7
commit 8a4c161ec4
36 changed files with 454 additions and 391 deletions
@@ -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
@@ -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 (
<>
@@ -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<HTMLDivElement, MothershipViewProps>(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<PreviewMode>('preview')
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const [prevActiveId, setPrevActiveId] = useState<string | null | undefined>(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 (
<div
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-300 ease-out',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[60%] border-l',
className
)}
>
<div className='flex min-h-0 flex-1 flex-col'>
<ResourceTabs
workspaceId={workspaceId}
chatId={chatId}
resources={resources}
activeId={active?.id ?? null}
onSelect={onSelectResource}
onAddResource={onAddResource}
onRemoveResource={onRemoveResource}
onReorderResources={onReorderResources}
onCollapse={onCollapse}
actions={active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null}
previewMode={isActivePreviewable ? previewMode : undefined}
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
/>
<div className='min-h-0 flex-1 overflow-hidden'>
{active ? (
<ResourceContent
workspaceId={workspaceId}
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
/>
) : (
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
Click "+" above to add a resource
</div>
)}
return (
<div
ref={ref}
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-300 ease-out',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[60%] border-l',
className
)}
>
<div className='flex min-h-0 flex-1 flex-col'>
<ResourceTabs
workspaceId={workspaceId}
chatId={chatId}
resources={resources}
activeId={active?.id ?? null}
onSelect={onSelectResource}
onAddResource={onAddResource}
onRemoveResource={onRemoveResource}
onReorderResources={onReorderResources}
onCollapse={onCollapse}
actions={
active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null
}
previewMode={isActivePreviewable ? previewMode : undefined}
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
/>
<div className='min-h-0 flex-1 overflow-hidden'>
{active ? (
<ResourceContent
workspaceId={workspaceId}
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
/>
) : (
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
Click "+" above to add a resource
</div>
)}
</div>
</div>
</div>
</div>
)
})
)
})
)
@@ -202,9 +202,7 @@ export function UserInput({
}
useEffect(() => {
if (editValue) {
onEditValueConsumed?.()
}
if (editValue) onEditValueConsumed?.()
}, [editValue, onEditValueConsumed])
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
@@ -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 collapseResource = useCallback(() => {
clearWidth()
setIsResourceCollapsed(true)
}, [clearWidth])
const animatingInTimerRef = useRef<ReturnType<typeof setTimeout> | 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)
setIsResourceAnimatingIn(true)
}, [])
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 (
<div className='relative flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
@@ -458,7 +465,21 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
</div>
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
{!isResourceCollapsed && (
<div className='relative z-20 w-0 flex-none'>
<div
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize'
role='separator'
aria-orientation='vertical'
aria-label='Resize resource panel'
onPointerDown={handleResizePointerDown}
/>
</div>
)}
<MothershipView
ref={mothershipRef}
workspaceId={workspaceId}
chatId={resolvedChatId}
resources={resources}
@@ -2,4 +2,5 @@ export { useAnimatedPlaceholder } from './use-animated-placeholder'
export { useAutoScroll } from './use-auto-scroll'
export type { UseChatReturn } from './use-chat'
export { useChat } from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
export { useStreamingReveal } from './use-streaming-reveal'
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
@@ -269,14 +269,22 @@ export function useChat(
onResourceEventRef.current = options?.onResourceEvent
const resourcesRef = useRef(resources)
resourcesRef.current = resources
const activeResourceIdRef = useRef(activeResourceId)
activeResourceIdRef.current = activeResourceId
// Derive the effective active resource ID — auto-selects the last resource when the stored ID is
// absent or no longer in the list, avoiding a separate Effect-based state correction loop.
const effectiveActiveResourceId = useMemo(() => {
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<QueuedMessage[]>([])
const messageQueueRef = useRef<QueuedMessage[]>([])
useEffect(() => {
messageQueueRef.current = messageQueue
}, [messageQueue])
messageQueueRef.current = messageQueue
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(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<Uint8Array>,
@@ -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,
@@ -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<HTMLDivElement | null>(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 }
}
@@ -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 []
@@ -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<string>(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
@@ -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<string, number>()
@@ -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<ApiKey | null>(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<HTMLDivElement>(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))
@@ -316,6 +316,9 @@ export function CredentialsManager() {
// --- Detail view state ---
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(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) => {
@@ -68,6 +68,12 @@ export function General() {
const [name, setName] = useState(profile?.name || '')
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(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,
@@ -97,16 +97,14 @@ const PlanModeSection: React.FC<PlanModeSectionProps> = ({
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<HTMLTextAreaElement>(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()
@@ -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
@@ -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<ChatContext[]>(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
@@ -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<number | null>(null)
const [previewMode, setPreviewMode] = useState<PreviewMode>('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({
>
<ButtonGroup
value={previewMode}
onValueChange={(val) => setPreviewMode(val as PreviewMode)}
onValueChange={(val) =>
setShowActiveDespiteSelection((val as PreviewMode) === 'active')
}
>
<ButtonGroupItem value='active'>Live</ButtonGroupItem>
<ButtonGroupItem value='selected' className='truncate'>
@@ -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 }>
@@ -120,7 +120,6 @@ export const ComboBox = memo(function ComboBox({
)
// State management
const [storeInitialized, setStoreInitialized] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(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(() => {
@@ -124,7 +124,6 @@ export const Dropdown = memo(function Dropdown({
isEqual
)
const [storeInitialized, setStoreInitialized] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(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
@@ -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(() => {
@@ -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<HTMLInputElement>) => {
if (overlayRef.current) {
@@ -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
@@ -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)
@@ -29,6 +29,11 @@ export function WandPromptBar({
}: WandPromptBarProps) {
const promptBarRef = useRef<HTMLDivElement>(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
}
@@ -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<HTMLDivElement>(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
@@ -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)
@@ -277,16 +277,20 @@ function ConnectionsSection({
onResizeMouseDown,
onToggleCollapsed,
}: ConnectionsSectionProps) {
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(() => 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<Set<string>>(
() => 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
@@ -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<WorkflowStackEntry[]>([])
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
@@ -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) => {
@@ -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()
@@ -42,13 +42,6 @@ export function useItemRename({ initialName, onSave, itemType, itemId }: UseItem
const [isRenaming, setIsRenaming] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
/**
* Update edit value when initial name changes
*/
useEffect(() => {
setEditValue(initialName)
}, [initialName])
/**
* Focus and select input when entering edit mode
*/
+4 -7
View File
@@ -32,6 +32,7 @@ export function useAutosave({
}: UseAutosaveOptions): UseAutosaveReturn {
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(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(() => {})
}
+10 -10
View File
@@ -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
+7
View File
@@ -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