fix(sidebar): collapsed sidebar shows single icons with hover dropdown menus (#3588)

* fix(sidebar): collapsed sidebar shows single icons with hover dropdown menus

* fix(sidebar): truncate long names in collapsed dropdown menus

* fix(sidebar): address PR review — extract components, fix reactive subscription

* fix(sidebar): support touch/keyboard for collapsed menus, document auto-collapse

* fix(sidebar): remove dead CSS selector for sidebar-collapse-remove

* fix(sidebar): add aria-label to collapsed menu trigger buttons

* fix(sidebar): use useLayoutEffect for attribute removal, remove dead branch
This commit is contained in:
Waleed
2026-03-14 15:21:41 -07:00
committed by GitHub
parent cbc9f4248c
commit 952915abfc
10 changed files with 438 additions and 122 deletions

View File

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

View File

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

View File

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

View File

@@ -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<typeof useHoverMenu>
onClick?: () => void
ariaLabel?: string
children: React.ReactNode
className?: string
}
export function CollapsedSidebarMenu({
icon,
hover,
onClick,
ariaLabel,
children,
className,
}: CollapsedSidebarMenuProps) {
return (
<div className={cn('flex flex-col px-[8px]', className)}>
<DropdownMenu
open={hover.isOpen}
onOpenChange={(open) => {
if (open) hover.open()
else hover.close()
}}
modal={false}
>
<div {...hover.triggerProps}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label={ariaLabel}
className='mx-[2px] flex h-[30px] items-center rounded-[8px] px-[8px] hover:bg-[var(--surface-active)]'
onClick={onClick}
>
{icon}
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
{children}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function CollapsedFolderItems({
nodes,
workflowsByFolder,
workspaceId,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record<string, WorkflowMetadata[]>
workspaceId: string
}) {
return (
<>
{nodes.map((folder) => {
const folderWorkflows = workflowsByFolder[folder.id] || []
const hasChildren = folder.children.length > 0 || folderWorkflows.length > 0
if (!hasChildren) {
return (
<DropdownMenuItem key={folder.id} disabled>
<Folder className='h-[14px] w-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger>
<Folder className='h-[14px] w-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<CollapsedFolderItems
nodes={folder.children}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
/>
{folderWorkflows.map((workflow) => (
<DropdownMenuItem key={workflow.id} asChild>
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate'>{workflow.name}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</>
)
}

View File

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

View File

@@ -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<T extends { sortOrder: number; createdAt?: Date; id: string }>(
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<string, WorkflowMetadata[]>
)
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[] = []

View File

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

View File

@@ -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<ReturnType<typeof setTimeout> | 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 }
}

View File

@@ -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 (
<div className='mx-[2px] flex h-[30px] items-center px-[8px]'>
<div className='sidebar-collapse-hide mx-[2px] flex h-[30px] items-center px-[8px]'>
<Skeleton className='h-[24px] w-full rounded-[4px]' />
</div>
)
@@ -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<string | null>(null)
const {
isOpen: isNavContextMenuOpen,
@@ -632,6 +657,8 @@ export const Sidebar = memo(function Sidebar() {
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const tasksHover = useHoverMenu()
const workflowsHover = useHoverMenu()
const renameInputRef = useRef<HTMLInputElement>(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 */}
<div className='mt-[14px] flex flex-shrink-0 flex-col pb-[8px]'>
<div className='px-[16px] pb-[6px]'>
<div
className={`font-base text-[var(--text-icon)] text-small${isCollapsed ? ' opacity-0' : ''}`}
>
Workspace
{!isCollapsed && (
<div className='sidebar-collapse-remove px-[16px] pb-[6px]'>
<div className='font-base text-[var(--text-icon)] text-small'>Workspace</div>
</div>
</div>
)}
<div className='flex flex-col gap-[2px] px-[8px]'>
{workspaceNavItems.map((item) => (
<SidebarNavItem
@@ -1053,99 +1078,170 @@ export const Sidebar = memo(function Sidebar() {
>
{/* Tasks */}
<div className='flex flex-shrink-0 flex-col'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div
className={cn(
'font-base text-[var(--text-icon)] text-small',
isCollapsed && 'opacity-0'
)}
>
All tasks
</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>New task</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
}
hover={tasksHover}
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
ariaLabel='Tasks'
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<DropdownMenuItem key={task.id} asChild>
<Link href={task.href}>
<Blimp className='h-[16px] w-[16px]' />
<span>{task.name}</span>
</Link>
</DropdownMenuItem>
))
)}
</div>
</div>
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{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)
</CollapsedSidebarMenu>
) : (
<div className='sidebar-collapse-remove'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>
All tasks
</div>
<div className='flex items-center justify-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>New task</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{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 (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => 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'
if (isRenaming) {
return (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => 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'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
showCollapsedContent={showCollapsedContent}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
showCollapsedContent={showCollapsedContent}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
{/* Workflows */}
{!isCollapsed && (
<div className='workflows-section relative mt-[14px] flex flex-col'>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}}
/>
}
hover={workflowsHover}
onClick={handleCreateWorkflow}
ariaLabel='Workflows'
className='mt-[14px]'
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<DropdownMenuItem key={workflow.id} asChild>
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate'>{workflow.name}</span>
</Link>
</DropdownMenuItem>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='sidebar-collapse-remove workflows-section relative mt-[14px] flex flex-col'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>

View File

@@ -0,0 +1,30 @@
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
export function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
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<string, WorkflowMetadata[]> {
const grouped = workflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
)
for (const key of Object.keys(grouped)) {
grouped[key].sort(compareByOrder)
}
return grouped
}