mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
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:
@@ -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
|
||||
|
||||
+2
-2
@@ -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 (
|
||||
<>
|
||||
|
||||
+71
-60
@@ -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 }
|
||||
}
|
||||
+7
-10
@@ -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))
|
||||
|
||||
+19
-18
@@ -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,
|
||||
|
||||
+5
-7
@@ -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()
|
||||
|
||||
+5
-7
@@ -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
-10
@@ -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
|
||||
|
||||
+9
-11
@@ -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'>
|
||||
|
||||
+27
-32
@@ -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 }>
|
||||
|
||||
+6
-12
@@ -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(() => {
|
||||
|
||||
+2
-7
@@ -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
|
||||
|
||||
+8
-10
@@ -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(() => {
|
||||
|
||||
+8
-10
@@ -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) {
|
||||
|
||||
+9
-7
@@ -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
|
||||
|
||||
+13
-16
@@ -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)
|
||||
|
||||
+5
-7
@@ -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)
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+16
-14
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user