From 193a15aca4e204011bb4182d538b05d2de8ba717 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:54:14 -0800 Subject: [PATCH] improvement(sidebar): auto-scroll (#2312) * fix(sidebar): re-render auto-scroll * improvement: sidebar-scrolling listener --- .../components/search-modal/search-modal.tsx | 7 + .../components/folder-item/folder-item.tsx | 13 +- .../workflow-list/workflow-list.tsx | 26 +-- .../w/components/sidebar/sidebar.tsx | 190 +++++++----------- 4 files changed, 96 insertions(+), 140 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 4ff3270dfc..d641ad7d57 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -10,6 +10,7 @@ import { useBrandConfig } from '@/lib/branding/branding' import { cn } from '@/lib/core/utils/cn' import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils' +import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { getAllBlocks } from '@/blocks' interface SearchModalProps { @@ -430,6 +431,12 @@ export function SearchModal({ window.open(item.href, '_blank', 'noopener,noreferrer') } else { router.push(item.href) + // Scroll to the workflow in the sidebar after navigation + if (item.type === 'workflow') { + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: item.id } }) + ) + } } } break diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index da2ef55ea1..c1a87b63e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -14,6 +14,7 @@ import { useItemDrag, useItemRename, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks' import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' import { useCreateWorkflow } from '@/hooks/queries/workflows' @@ -87,6 +88,10 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { if (result.id) { router.push(`/workspace/${workspaceId}/w/${result.id}`) + // Scroll to the newly created workflow + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } }) + ) } } catch (error) { // Error already handled by mutation's onError callback @@ -100,11 +105,17 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { */ const handleCreateFolderInFolder = useCallback(async () => { try { - await createFolderMutation.mutateAsync({ + const result = await createFolderMutation.mutateAsync({ workspaceId, name: 'New Folder', parentId: folder.id, }) + if (result.id) { + // Scroll to the newly created folder + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } }) + ) + } } catch (error) { logger.error('Failed to create folder:', error) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index b2c70f7044..9f3a1b4427 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import clsx from 'clsx' import { useParams, usePathname } from 'next/navigation' import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item' @@ -144,11 +144,8 @@ export function WorkflowList({ [pathname, workspaceId] ) - // Track last scrolled workflow to avoid redundant scroll checks - const lastScrolledWorkflowRef = useRef(null) - /** - * Auto-expand folders, select active workflow, and scroll into view if needed. + * Auto-expand folders and select active workflow. */ useEffect(() => { if (!workflowId || isLoading || foldersLoading) return @@ -164,25 +161,6 @@ export function WorkflowList({ if (!selectedWorkflows.has(workflowId)) { selectOnly(workflowId) } - - // Skip scroll check if already handled for this workflow - if (lastScrolledWorkflowRef.current === workflowId) return - lastScrolledWorkflowRef.current = workflowId - - // Scroll after render only if element is completely off-screen - requestAnimationFrame(() => { - const element = document.querySelector(`[data-item-id="${workflowId}"]`) - const container = scrollContainerRef.current - if (!element || !container) return - - const { top: elTop, bottom: elBottom } = element.getBoundingClientRect() - const { top: ctTop, bottom: ctBottom } = container.getBoundingClientRect() - - // Only scroll if completely above or below the visible area - if (elBottom <= ctTop || elTop >= ctBottom) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }) }, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded]) const renderWorkflowItem = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 8549437792..dbe51643b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -34,9 +34,13 @@ import { useSearchModalStore } from '@/stores/search-modal/store' import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store' const logger = createLogger('Sidebar') -// Feature flag: Billing usage indicator visibility (matches legacy sidebar behavior) + +/** Feature flag for billing usage indicator visibility */ const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) +/** Event name for sidebar scroll operations - centralized for consistency */ +export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item' + /** * Sidebar component with resizable width that persists across page refreshes. * @@ -60,63 +64,79 @@ export function Sidebar() { const fileInputRef = useRef(null) const scrollContainerRef = useRef(null) - // Session data const { data: sessionData, isPending: sessionLoading } = useSession() - // Sidebar state - use store's hydration tracking to prevent SSR mismatch + /** + * Sidebar state from store with hydration tracking to prevent SSR mismatch. + * Uses default (expanded) state until hydrated. + */ const hasHydrated = useSidebarStore((state) => state._hasHydrated) const isCollapsedStore = useSidebarStore((state) => state.isCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth) - - // Use default (expanded) state until hydrated to prevent hydration mismatch const isCollapsed = hasHydrated ? isCollapsedStore : false - - // Determine if we're on a workflow page (only workflow pages allow collapse and resize) const isOnWorkflowPage = !!workflowId - // Import state const [isImporting, setIsImporting] = useState(false) - - // Workspace import input ref const workspaceFileInputRef = useRef(null) - // Workspace import hook const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } = useImportWorkspace() + const { handleExportWorkspace: exportWorkspace } = useExportWorkspace() - // Workspace export hook - const { isExporting: isExportingWorkspace, handleExportWorkspace: exportWorkspace } = - useExportWorkspace() - - // Workspace popover state const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false) - - // Footer navigation modal state const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false) - // Listen for external events to open help modal + /** Listens for external events to open help modal */ useEffect(() => { const handleOpenHelpModal = () => setIsHelpModalOpen(true) window.addEventListener('open-help-modal', handleOpenHelpModal) return () => window.removeEventListener('open-help-modal', handleOpenHelpModal) }, []) - // Global search modal state + /** Listens for scroll events and scrolls items into view if off-screen */ + useEffect(() => { + const handleScrollToItem = (e: CustomEvent<{ itemId: string }>) => { + const { itemId } = e.detail + if (!itemId) return + + const tryScroll = (retriesLeft: number) => { + requestAnimationFrame(() => { + const element = document.querySelector(`[data-item-id="${itemId}"]`) + const container = scrollContainerRef.current + + if (!element || !container) { + if (retriesLeft > 0) tryScroll(retriesLeft - 1) + return + } + + const { top: elTop, bottom: elBottom } = element.getBoundingClientRect() + const { top: ctTop, bottom: ctBottom } = container.getBoundingClientRect() + + if (elBottom <= ctTop || elTop >= ctBottom) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }) + } + + tryScroll(10) + } + window.addEventListener(SIDEBAR_SCROLL_EVENT, handleScrollToItem as EventListener) + return () => + window.removeEventListener(SIDEBAR_SCROLL_EVENT, handleScrollToItem as EventListener) + }, []) + const { isOpen: isSearchModalOpen, setOpen: setIsSearchModalOpen, open: openSearchModal, } = useSearchModalStore() - // Workspace management hook const { workspaces, activeWorkspace, isWorkspacesLoading, - fetchWorkspaces, - isWorkspaceValid, switchWorkspace, handleCreateWorkspace, isCreatingWorkspace, @@ -127,10 +147,8 @@ export function Sidebar() { sessionUserId: sessionData?.user?.id, }) - // Sidebar resize hook const { handleMouseDown } = useSidebarResize() - // Workflow operations hook const { regularWorkflows, workflowsLoading, @@ -138,17 +156,14 @@ export function Sidebar() { handleCreateWorkflow: createWorkflow, } = useWorkflowOperations({ workspaceId }) - // Folder operations hook const { isCreatingFolder, handleCreateFolder: createFolder } = useFolderOperations({ workspaceId, }) - // Duplicate workspace hook const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({ getWorkspaceId: () => workspaceId, }) - // Prepare data for search modal const searchModalWorkflows = useMemo( () => regularWorkflows.map((workflow) => ({ @@ -172,7 +187,6 @@ export function Sidebar() { [workspaces, workspaceId] ) - // Footer navigation items const footerNavigationItems = useMemo( () => [ { @@ -209,116 +223,85 @@ export function Sidebar() { [workspaceId] ) - // Combined loading state const isLoading = workflowsLoading || sessionLoading + const initialScrollDoneRef = useRef(null) - /** - * Scrolls a newly created element into view if completely off-screen. - * Uses requestAnimationFrame to sync with render, then scrolls. - */ - const scrollToElement = useCallback((elementId: string) => { + /** Scrolls to active workflow on initial load or workspace switch */ + useEffect(() => { + if (!workflowId || workflowsLoading || initialScrollDoneRef.current === workflowId) return + initialScrollDoneRef.current = workflowId requestAnimationFrame(() => { - const element = document.querySelector(`[data-item-id="${elementId}"]`) - const container = scrollContainerRef.current - if (!element || !container) return - - const { top: elTop, bottom: elBottom } = element.getBoundingClientRect() - const { top: ctTop, bottom: ctBottom } = container.getBoundingClientRect() - - // Only scroll if element is completely off-screen - if (elBottom <= ctTop || elTop >= ctBottom) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflowId } }) + ) }) - }, []) + }, [workflowId, workflowsLoading]) - /** - * Force sidebar to minimum width and ensure it's expanded when not on a workflow page - */ + /** Forces sidebar to minimum width and ensures it's expanded when not on a workflow page */ useEffect(() => { if (!isOnWorkflowPage) { - // Ensure sidebar is always expanded on non-workflow pages if (isCollapsed) { setIsCollapsed(false) } - // Force sidebar to minimum width setSidebarWidth(MIN_SIDEBAR_WIDTH) } }, [isOnWorkflowPage, isCollapsed, setIsCollapsed, setSidebarWidth]) - /** - * Handle create workflow - creates workflow and scrolls to it - */ + /** Creates a workflow and scrolls to it */ const handleCreateWorkflow = useCallback(async () => { const workflowId = await createWorkflow() if (workflowId) { - scrollToElement(workflowId) + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflowId } }) + ) } - }, [createWorkflow, scrollToElement]) + }, [createWorkflow]) - /** - * Handle create folder - creates folder and scrolls to it - */ + /** Creates a folder and scrolls to it */ const handleCreateFolder = useCallback(async () => { const folderId = await createFolder() if (folderId) { - scrollToElement(folderId) + window.dispatchEvent(new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: folderId } })) } - }, [createFolder, scrollToElement]) + }, [createFolder]) - /** - * Handle import workflow button click - triggers file input - */ + /** Triggers file input for workflow import */ const handleImportWorkflow = useCallback(() => { - if (fileInputRef.current) { - fileInputRef.current.click() - } + fileInputRef.current?.click() }, []) - /** - * Handle workspace switch from popover menu - */ + /** Handles workspace switch from popover menu */ const handleWorkspaceSwitch = useCallback( async (workspace: { id: string; name: string; ownerId: string; role?: string }) => { if (workspace.id === workspaceId) { setIsWorkspaceMenuOpen(false) return } - await switchWorkspace(workspace) setIsWorkspaceMenuOpen(false) }, [workspaceId, switchWorkspace] ) - /** - * Handle sidebar collapse toggle - */ + /** Toggles sidebar collapse state */ const handleToggleCollapse = useCallback(() => { setIsCollapsed(!isCollapsed) }, [isCollapsed, setIsCollapsed]) - /** - * Handle click on sidebar elements to revert to active workflow selection - */ + /** Reverts to active workflow selection when clicking sidebar background */ const handleSidebarClick = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement - // Revert to active workflow selection if clicking on sidebar background, header, or search area - // But not on interactive elements like buttons or links if (target.tagName === 'BUTTON' || target.closest('button, [role="button"], a')) { return } - const { selectOnly, clearSelection } = useFolderStore.getState() workflowId ? selectOnly(workflowId) : clearSelection() }, [workflowId] ) - /** - * Handle workspace rename - */ + /** Renames a workspace */ const handleRenameWorkspace = useCallback( async (workspaceIdToRename: string, newName: string) => { await updateWorkspaceName(workspaceIdToRename, newName) @@ -326,9 +309,7 @@ export function Sidebar() { [updateWorkspaceName] ) - /** - * Handle workspace delete - */ + /** Deletes a workspace */ const handleDeleteWorkspace = useCallback( async (workspaceIdToDelete: string) => { const workspaceToDelete = workspaces.find((w) => w.id === workspaceIdToDelete) @@ -339,9 +320,7 @@ export function Sidebar() { [workspaces, confirmDeleteWorkspace] ) - /** - * Handle workspace duplicate - */ + /** Duplicates a workspace */ const handleDuplicateWorkspace = useCallback( async (_workspaceIdToDuplicate: string, workspaceName: string) => { await duplicateWorkspace(workspaceName) @@ -349,9 +328,7 @@ export function Sidebar() { [duplicateWorkspace] ) - /** - * Handle workspace export - */ + /** Exports a workspace */ const handleExportWorkspace = useCallback( async (workspaceIdToExport: string, workspaceName: string) => { await exportWorkspace(workspaceIdToExport, workspaceName) @@ -359,18 +336,12 @@ export function Sidebar() { [exportWorkspace] ) - /** - * Handle workspace import button click - */ + /** Triggers file input for workspace import */ const handleImportWorkspace = useCallback(() => { - if (workspaceFileInputRef.current) { - workspaceFileInputRef.current.click() - } + workspaceFileInputRef.current?.click() }, []) - /** - * Handle workspace import file change - */ + /** Handles workspace import file selection */ const handleWorkspaceFileChange = useCallback( async (event: React.ChangeEvent) => { const files = event.target.files @@ -379,7 +350,6 @@ export function Sidebar() { const zipFile = files[0] await importWorkspace(zipFile) - // Reset file input if (event.target) { event.target.value = '' } @@ -387,12 +357,7 @@ export function Sidebar() { [importWorkspace] ) - /** - * Resolve a workspace id from either params or the current URL path. - * - * This mirrors existing behavior but is wrapped in a helper to keep command - * handlers small and focused. - */ + /** Resolves workspace ID from params or URL path */ const resolveWorkspaceIdFromPath = useCallback((): string | undefined => { if (workspaceId) return workspaceId if (typeof window === 'undefined') return undefined @@ -404,12 +369,7 @@ export function Sidebar() { return parts[idx + 1] }, [workspaceId]) - /** - * Register global sidebar commands using the central commands registry. - * - * Only commands declared in the registry can be registered here. The - * registry owns ids and shortcut strings; this component supplies handlers. - */ + /** Registers global sidebar commands with the central commands registry */ useRegisterGlobalCommands(() => createCommands([ {