diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 605cda4df8..fccd7600ee 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -33,11 +33,26 @@ opacity: 0; } +html[data-sidebar-collapsed] .sidebar-container span, +html[data-sidebar-collapsed] .sidebar-container .text-small { + opacity: 0; +} + .sidebar-container .sidebar-collapse-hide { transition: opacity 60ms ease; } -.sidebar-container[data-collapsed] .sidebar-collapse-hide { +.sidebar-container[data-collapsed] .sidebar-collapse-hide, +html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide { + opacity: 0; +} + +html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove { + display: none; +} + +html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { + width: 0; opacity: 0; } diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index dc3fb76201..c4d5db0301 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -114,6 +114,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) if (isCollapsed) { document.documentElement.style.setProperty('--sidebar-width', '51px'); + document.documentElement.setAttribute('data-sidebar-collapsed', ''); } else { var width = state && state.sidebarWidth; var maxSidebarWidth = window.innerWidth * 0.3; diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index c91d9465e2..64ef65819f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -16,6 +16,7 @@ import { import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks' import type { ChatContext } from '@/stores/panel' +import { useSidebarStore } from '@/stores/sidebar/store' import { MessageContent, MothershipView, @@ -166,6 +167,9 @@ export function Home({ chatId }: HomeProps = {}) { const handleResourceEvent = useCallback(() => { if (isResourceCollapsedRef.current) { + /** Auto-collapse sidebar to give resource panel maximum width for immersive experience */ + const { isCollapsed, toggleCollapsed } = useSidebarStore.getState() + if (!isCollapsed) toggleCollapsed() setIsResourceCollapsed(false) setIsResourceAnimatingIn(true) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx new file mode 100644 index 0000000000..ff61e2ed52 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -0,0 +1,121 @@ +import { Folder } from 'lucide-react' +import Link from 'next/link' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import type { FolderTreeNode } from '@/stores/folders/types' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +interface CollapsedSidebarMenuProps { + icon: React.ReactNode + hover: ReturnType + onClick?: () => void + ariaLabel?: string + children: React.ReactNode + className?: string +} + +export function CollapsedSidebarMenu({ + icon, + hover, + onClick, + ariaLabel, + children, + className, +}: CollapsedSidebarMenuProps) { + return ( +
+ { + if (open) hover.open() + else hover.close() + }} + modal={false} + > +
+ + + +
+ + {children} + +
+
+ ) +} + +export function CollapsedFolderItems({ + nodes, + workflowsByFolder, + workspaceId, +}: { + nodes: FolderTreeNode[] + workflowsByFolder: Record + workspaceId: string +}) { + return ( + <> + {nodes.map((folder) => { + const folderWorkflows = workflowsByFolder[folder.id] || [] + const hasChildren = folder.children.length > 0 || folderWorkflows.length > 0 + + if (!hasChildren) { + return ( + + + {folder.name} + + ) + } + + return ( + + + + {folder.name} + + + + {folderWorkflows.map((workflow) => ( + + +
+ {workflow.name} + + + ))} + + + ) + })} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts index a5a1fbb087..f122ee5de6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts @@ -1,3 +1,7 @@ +export { + CollapsedFolderItems, + CollapsedSidebarMenu, +} from './collapsed-sidebar-menu/collapsed-sidebar-menu' export { HelpModal } from './help-modal/help-modal' export { NavItemContextMenu } from './nav-item-context-menu' export { SearchModal } from './search-modal/search-modal' 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 86faf7e204..cddb472a6d 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 @@ -13,6 +13,10 @@ import { useSidebarDragContextValue, useWorkflowSelection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { + compareByOrder, + groupWorkflowsByFolder, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import { useFolders } from '@/hooks/queries/folders' import { useFolderStore } from '@/stores/folders/store' import type { FolderTreeNode } from '@/stores/folders/types' @@ -22,17 +26,6 @@ const TREE_SPACING = { INDENT_PER_LEVEL: 20, } as const -function compareByOrder( - a: T, - b: T -): number { - if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder - const timeA = a.createdAt?.getTime() ?? 0 - const timeB = b.createdAt?.getTime() ?? 0 - if (timeA !== timeB) return timeA - timeB - return a.id.localeCompare(b.id) -} - interface WorkflowListProps { workspaceId: string workflowId: string | undefined @@ -129,21 +122,10 @@ export const WorkflowList = memo(function WorkflowList({ return activeWorkflow?.folderId || null }, [workflowId, regularWorkflows, isLoading, foldersLoading]) - const workflowsByFolder = useMemo(() => { - const grouped = regularWorkflows.reduce( - (acc, workflow) => { - const folderId = workflow.folderId || 'root' - if (!acc[folderId]) acc[folderId] = [] - acc[folderId].push(workflow) - return acc - }, - {} as Record - ) - for (const folderId of Object.keys(grouped)) { - grouped[folderId].sort(compareByOrder) - } - return grouped - }, [regularWorkflows]) + const workflowsByFolder = useMemo( + () => groupWorkflowsByFolder(regularWorkflows), + [regularWorkflows] + ) const orderedWorkflowIds = useMemo(() => { const ids: string[] = [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts index 5c23e7afe0..bdffa08fdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts @@ -4,6 +4,7 @@ export { type DropIndicator, useDragDrop } from './use-drag-drop' export { useFolderExpand } from './use-folder-expand' export { useFolderOperations } from './use-folder-operations' export { useFolderSelection } from './use-folder-selection' +export { useHoverMenu } from './use-hover-menu' export { useItemDrag } from './use-item-drag' export { useItemRename } from './use-item-rename' export { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts new file mode 100644 index 0000000000..e48fc4c25d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const CLOSE_DELAY_MS = 150 + +const preventAutoFocus = (e: Event) => e.preventDefault() + +/** + * Manages hover-triggered dropdown menu state. + * Provides handlers for trigger and content mouse events with a delay + * to prevent flickering when moving between trigger and content. + */ +export function useHoverMenu() { + const [isOpen, setIsOpen] = useState(false) + const closeTimerRef = useRef | null>(null) + + const cancelClose = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current) + closeTimerRef.current = null + } + }, []) + + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current) + } + } + }, []) + + const scheduleClose = useCallback(() => { + cancelClose() + closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS) + }, [cancelClose]) + + const open = useCallback(() => { + cancelClose() + setIsOpen(true) + }, [cancelClose]) + + const close = useCallback(() => { + cancelClose() + setIsOpen(false) + }, [cancelClose]) + + const triggerProps = useMemo( + () => ({ onMouseEnter: open, onMouseLeave: scheduleClose }) as const, + [open, scheduleClose] + ) + + const contentProps = useMemo( + () => + ({ + onMouseEnter: cancelClose, + onMouseLeave: scheduleClose, + onCloseAutoFocus: preventAutoFocus, + }) as const, + [cancelClose, scheduleClose] + ) + + return { isOpen, open, close, triggerProps, contentProps } +} 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 9469033299..bded9e2b07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { MoreHorizontal } from 'lucide-react' import Link from 'next/link' @@ -38,6 +38,8 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { + CollapsedFolderItems, + CollapsedSidebarMenu, HelpModal, NavItemContextMenu, SearchModal, @@ -50,17 +52,20 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/ import { useContextMenu, useFolderOperations, + useHoverMenu, useSidebarResize, useTaskSelection, useWorkflowOperations, useWorkspaceManagement, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { groupWorkflowsByFolder } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import { useDuplicateWorkspace, useExportWorkspace, useImportWorkflow, useImportWorkspace, } from '@/app/workspace/[workspaceId]/w/hooks' +import { useFolders } from '@/hooks/queries/folders' import { useDeleteTask, useDeleteTasks, useRenameTask, useTasks } from '@/hooks/queries/tasks' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -74,7 +79,7 @@ const logger = createLogger('Sidebar') function SidebarItemSkeleton() { return ( -
+
) @@ -265,6 +270,12 @@ export const Sidebar = memo(function Sidebar() { const [showCollapsedContent, setShowCollapsedContent] = useState(isCollapsed) + useLayoutEffect(() => { + if (!isCollapsed) { + document.documentElement.removeAttribute('data-sidebar-collapsed') + } + }, [isCollapsed]) + useEffect(() => { if (isCollapsed) { const timer = setTimeout(() => setShowCollapsedContent(true), 200) @@ -356,6 +367,20 @@ export const Sidebar = memo(function Sidebar() { workspaceId, }) + useFolders(workspaceId) + const folders = useFolderStore((s) => s.folders) + const getFolderTree = useFolderStore((s) => s.getFolderTree) + + const folderTree = useMemo( + () => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []), + [isCollapsed, workspaceId, folders, getFolderTree] + ) + + const workflowsByFolder = useMemo( + () => (isCollapsed ? groupWorkflowsByFolder(regularWorkflows) : {}), + [isCollapsed, regularWorkflows] + ) + const [activeNavItemHref, setActiveNavItemHref] = useState(null) const { isOpen: isNavContextMenuOpen, @@ -632,6 +657,8 @@ export const Sidebar = memo(function Sidebar() { const [visibleTaskCount, setVisibleTaskCount] = useState(5) const [renamingTaskId, setRenamingTaskId] = useState(null) const [renameValue, setRenameValue] = useState('') + const tasksHover = useHoverMenu() + const workflowsHover = useHoverMenu() const renameInputRef = useRef(null) const renameCanceledRef = useRef(false) @@ -960,7 +987,7 @@ export const Sidebar = memo(function Sidebar() { type='button' onClick={toggleCollapsed} className={cn( - 'ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-[8px] transition-all duration-200 hover:bg-[var(--surface-active)]', + 'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-[8px] transition-all duration-200 hover:bg-[var(--surface-active)]', isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100' )} aria-label='Collapse sidebar' @@ -1023,13 +1050,11 @@ export const Sidebar = memo(function Sidebar() { {/* Workspace */}
-
-
- Workspace + {!isCollapsed && ( +
+
Workspace
-
+ )}
{workspaceNavItems.map((item) => ( {/* Tasks */}
-
-
-
- All tasks + {isCollapsed ? ( + + } + hover={tasksHover} + onClick={() => router.push(`/workspace/${workspaceId}/home`)} + ariaLabel='Tasks' + > + {tasksLoading ? ( + + + Loading... + + ) : ( + tasks.map((task) => ( + + + + {task.name} + + + )) + )} + + ) : ( +
+
+
+
+ All tasks +
+
+ + + + + +

New task

+
+
+
+
- {!isCollapsed && ( -
- - - - - -

New task

-
-
-
- )} + + See more + + )} + + )} +
-
-
- {tasksLoading ? ( - + )} +
+ + {/* Workflows */} + {isCollapsed ? ( + + } + hover={workflowsHover} + onClick={handleCreateWorkflow} + ariaLabel='Workflows' + className='mt-[14px]' + > + {workflowsLoading && regularWorkflows.length === 0 ? ( + + + Loading... + + ) : regularWorkflows.length === 0 ? ( + No workflows yet ) : ( <> - {tasks.slice(0, visibleTaskCount).map((task) => { - const isCurrentRoute = task.id !== 'new' && pathname === task.href - const isRenaming = renamingTaskId === task.id - const isSelected = task.id !== 'new' && selectedTasks.has(task.id) - - if (!isCollapsed && isRenaming) { - return ( + + {(workflowsByFolder.root || []).map((workflow) => ( + +
- - setRenameValue(e.target.value)} - onKeyDown={handleRenameKeyDown} - onBlur={handleSaveTaskRename} - className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none' - /> -
- ) - } - - return ( - - ) - })} - {tasks.length > visibleTaskCount && ( - - )} + className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]' + style={{ + backgroundColor: workflow.color, + borderColor: `${workflow.color}60`, + backgroundClip: 'padding-box', + }} + /> + {workflow.name} + +
+ ))} )} -
-
- - {/* Workflows */} - {!isCollapsed && ( -
+ + ) : ( +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts new file mode 100644 index 0000000000..ecabf89b45 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts @@ -0,0 +1,30 @@ +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +export function compareByOrder( + a: T, + b: T +): number { + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder + const timeA = a.createdAt?.getTime() ?? 0 + const timeB = b.createdAt?.getTime() ?? 0 + if (timeA !== timeB) return timeA - timeB + return a.id.localeCompare(b.id) +} + +export function groupWorkflowsByFolder( + workflows: WorkflowMetadata[] +): Record { + const grouped = workflows.reduce( + (acc, workflow) => { + const folderId = workflow.folderId || 'root' + if (!acc[folderId]) acc[folderId] = [] + acc[folderId].push(workflow) + return acc + }, + {} as Record + ) + for (const key of Object.keys(grouped)) { + grouped[key].sort(compareByOrder) + } + return grouped +}