mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user