improvement(sidebar): auto-scroll (#2312)

* fix(sidebar): re-render auto-scroll

* improvement: sidebar-scrolling listener
This commit is contained in:
Emir Karabeg
2025-12-11 12:54:14 -08:00
committed by GitHub
parent 39d5d797ec
commit 193a15aca4
4 changed files with 96 additions and 140 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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<string | null>(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(

View File

@@ -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<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(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<HTMLInputElement>(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<string | null>(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<HTMLElement>) => {
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<HTMLInputElement>) => {
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([
{