feat(sidebar-controls): added ability to expand on hover (#343)

This commit is contained in:
Emir Karabeg
2025-05-09 22:49:54 -07:00
committed by Emir Karabeg
parent a92ee8bf46
commit d79cad4c52
11 changed files with 301 additions and 72 deletions

View File

@@ -26,13 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
validation.workflow.deployedState as any
)
}
logger.info(`[${requestId}] Retrieved status for workflow: ${id}`, {
isDeployed: validation.workflow.isDeployed,
isPublished: validation.workflow.isPublished,
needsRedeployment,
})
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt,

View File

@@ -140,6 +140,7 @@
/* Custom Animations */
@layer utilities {
/* Animation containment to avoid layout shifts */
.animation-container {
contain: paint layout style;
@@ -150,9 +151,11 @@
0% {
box-shadow: 0 0 0 0 hsl(var(--border));
}
50% {
box-shadow: 0 0 0 8px hsl(var(--border));
}
100% {
box-shadow: 0 0 0 0 hsl(var(--border));
}
@@ -195,6 +198,7 @@
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
@@ -202,12 +206,11 @@
@keyframes orbit {
0% {
transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px))
rotate(calc(var(--angle) * -1deg));
transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg));
}
100% {
transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px))
rotate(calc((var(--angle) * -1deg) - 360deg));
transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg));
}
}
@@ -215,14 +218,17 @@
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-100% - var(--gap)));
}
@@ -234,30 +240,27 @@
.streaming-effect::after {
content: '';
@apply pointer-events-none absolute top-0 left-0 h-full w-full;
background: linear-gradient(
90deg,
rgba(128, 128, 128, 0) 0%,
rgba(128, 128, 128, 0.1) 50%,
rgba(128, 128, 128, 0) 100%
);
@apply pointer-events-none absolute left-0 top-0 h-full w-full;
background: linear-gradient(90deg,
rgba(128, 128, 128, 0) 0%,
rgba(128, 128, 128, 0.1) 50%,
rgba(128, 128, 128, 0) 100%);
animation: code-shimmer 1.5s infinite;
z-index: 10;
}
.dark .streaming-effect::after {
background: linear-gradient(
90deg,
rgba(180, 180, 180, 0) 0%,
rgba(180, 180, 180, 0.1) 50%,
rgba(180, 180, 180, 0) 100%
);
background: linear-gradient(90deg,
rgba(180, 180, 180, 0) 0%,
rgba(180, 180, 180, 0.1) 50%,
rgba(180, 180, 180, 0) 100%);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
@@ -270,8 +273,10 @@
/* Dark mode error badge styling */
.dark .error-badge {
background-color: hsl(0, 70%, 20%) !important; /* Darker red background for dark mode */
color: hsl(0, 0%, 100%) !important; /* Pure white text for better contrast */
background-color: hsl(0, 70%, 20%) !important;
/* Darker red background for dark mode */
color: hsl(0, 0%, 100%) !important;
/* Pure white text for better contrast */
}
/* Input Overrides */
@@ -293,10 +298,12 @@ input[type='search']::-ms-clear {
/* Code Prompt Bar Placeholder Animation */
@keyframes placeholder-pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 0.8;
}
@@ -319,3 +326,9 @@ input[type='search']::-ms-clear {
.font-geist-mono {
font-family: var(--font-geist-mono);
}
/* Sidebar overlay styles */
.main-content-overlay {
z-index: 40;
/* Higher z-index to appear above content */
}

View File

@@ -76,7 +76,6 @@ const RUN_COUNT_OPTIONS = [1, 5, 10, 25, 50, 100]
export function ControlBar() {
const router = useRouter()
const { data: session } = useSession()
const { isCollapsed: isSidebarCollapsed } = useSidebarStore()
// Store hooks
const {

View File

@@ -14,8 +14,13 @@ import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs'
export function Toolbar() {
const [activeTab, setActiveTab] = useState<BlockCategory>('blocks')
const [searchQuery, setSearchQuery] = useState('')
const [isCollapsed, setIsCollapsed] = useState(false)
const { isCollapsed: isSidebarCollapsed } = useSidebarStore()
const { mode, isExpanded } = useSidebarStore()
// In hover mode, act as if sidebar is always collapsed for layout purposes
const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
// State to track if toolbar is open - independent of sidebar state
const [isToolbarOpen, setIsToolbarOpen] = useState(true)
const blocks = useMemo(() => {
const filteredBlocks = !searchQuery.trim() ? getBlocksByCategory(activeTab) : getAllBlocks()
@@ -31,13 +36,14 @@ export function Toolbar() {
})
}, [searchQuery, activeTab])
if (isCollapsed) {
// Show toolbar button when it's closed, regardless of sidebar state
if (!isToolbarOpen) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsCollapsed(false)}
className={`fixed transition-left duration-200 ${isSidebarCollapsed ? 'left-20' : 'left-64'} bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg bg-background text-muted-foreground hover:text-foreground hover:bg-accent border`}
onClick={() => setIsToolbarOpen(true)}
className={`fixed transition-all duration-200 ${isSidebarCollapsed ? 'left-20' : 'left-64'} bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg bg-background text-muted-foreground hover:text-foreground hover:bg-accent border`}
>
<PanelRight className="h-5 w-5" />
<span className="sr-only">Open Toolbar</span>
@@ -50,7 +56,7 @@ export function Toolbar() {
return (
<div
className={`fixed transition-left duration-200 ${isSidebarCollapsed ? 'left-14' : 'left-60'} top-16 z-10 h-[calc(100vh-4rem)] w-60 border-r bg-background sm:block`}
className={`fixed transition-all duration-200 ${isSidebarCollapsed ? 'left-14' : 'left-60'} top-16 z-10 h-[calc(100vh-4rem)] w-60 border-r bg-background sm:block`}
>
<div className="flex flex-col h-full">
<div className="px-4 pt-4 pb-1 sticky top-0 bg-background z-20">
@@ -89,7 +95,7 @@ export function Toolbar() {
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsCollapsed(true)}
onClick={() => setIsToolbarOpen(false)}
className="absolute right-4 bottom-[18px] flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent"
>
<PanelLeftClose className="h-5 w-5" />

View File

@@ -48,7 +48,10 @@ function WorkflowContent() {
// State
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [isInitialized, setIsInitialized] = useState(false)
const { isCollapsed: isSidebarCollapsed } = useSidebarStore()
const { mode, isExpanded } = useSidebarStore()
// In hover mode, act as if sidebar is always collapsed for layout purposes
const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
// Hooks
const params = useParams()
@@ -478,8 +481,8 @@ function WorkflowContent() {
<div className="flex flex-col h-screen w-full overflow-hidden">
<div className={`transition-all duration-200 ${isSidebarCollapsed ? 'ml-14' : 'ml-60'}`}>
<ControlBar />
<Toolbar />
</div>
<Toolbar />
<div
className={`flex-1 relative w-full h-full transition-all duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>

View File

@@ -0,0 +1,102 @@
'use client'
import { useState } from 'react'
import { PanelRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { SidebarMode, useSidebarStore } from '@/stores/sidebar/store'
// This component ONLY controls sidebar state, not toolbar state
export function SidebarControl() {
const { mode, setMode, toggleExpanded, isExpanded } = useSidebarStore()
const [open, setOpen] = useState(false)
const handleModeChange = (value: SidebarMode) => {
// When selecting expanded mode, ensure it's expanded
if (value === 'expanded' && !isExpanded) {
toggleExpanded()
}
// Set the new mode
setMode(value)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="flex h-8 w-8 p-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 cursor-pointer"
>
<PanelRight className="h-[18px] w-[18px] text-muted-foreground" />
<span className="sr-only text-sm">Sidebar control</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-44 p-0 shadow-md border overflow-hidden rounded-lg bg-background"
side="top"
align="start"
sideOffset={5}
>
<div className="border-b py-[10px] px-4">
<h4 className="text-xs font-[480] text-muted-foreground">Sidebar control</h4>
</div>
<div className="px-2 pt-1 pb-2">
<div className="flex flex-col gap-[1px]">
<button
className={cn(
'w-full text-left py-1.5 px-2 text-xs rounded hover:bg-accent/50 text-muted-foreground font-medium'
)}
onClick={() => handleModeChange('expanded')}
>
<span className="flex items-center">
<span
className={cn(
'h-1 w-1 rounded-full mr-1.5',
mode === 'expanded' ? 'bg-muted-foreground' : 'bg-transparent'
)}
></span>
Expanded
</span>
</button>
<button
className={cn(
'w-full text-left py-1.5 px-2 text-xs rounded hover:bg-accent/50 text-muted-foreground font-medium'
)}
onClick={() => handleModeChange('collapsed')}
>
<span className="flex items-center">
<span
className={cn(
'h-1 w-1 rounded-full mr-1.5',
mode === 'collapsed' ? 'bg-muted-foreground' : 'bg-transparent'
)}
></span>
Collapsed
</span>
</button>
<button
className={cn(
'w-full text-left py-1.5 px-2 text-xs rounded hover:bg-accent/50 text-muted-foreground font-medium'
)}
onClick={() => handleModeChange('hover')}
>
<span className="flex items-center">
<span
className={cn(
'h-1 w-1 rounded-full mr-1.5',
mode === 'hover' ? 'bg-muted-foreground' : 'bg-transparent'
)}
></span>
Expand on hover
</span>
</button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -39,6 +39,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { cn } from '@/lib/utils'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface Workspace {
@@ -51,6 +52,7 @@ interface Workspace {
interface WorkspaceHeaderProps {
onCreateWorkflow: () => void
isCollapsed?: boolean
onDropdownOpenChange?: (isOpen: boolean) => void
}
// New WorkspaceModal component
@@ -225,8 +227,16 @@ function WorkspaceEditModal({
)
}
export function WorkspaceHeader({ onCreateWorkflow, isCollapsed }: WorkspaceHeaderProps) {
const [isOpen, setIsOpen] = useState(false)
export function WorkspaceHeader({
onCreateWorkflow,
isCollapsed,
onDropdownOpenChange,
}: WorkspaceHeaderProps) {
// Get sidebar store state to check current mode
const { mode, workspaceDropdownOpen, setAnyModalOpen } = useSidebarStore()
// Keep local isOpen state in sync with the store (for internal component use)
const [isOpen, setIsOpen] = useState(workspaceDropdownOpen)
const { data: sessionData, isPending } = useSession()
const [plan, setPlan] = useState('Free Plan')
// Use client-side loading instead of isPending to avoid hydration mismatch
@@ -419,6 +429,32 @@ export function WorkspaceHeader({ onCreateWorkflow, isCollapsed }: WorkspaceHead
// Determine URL for workspace links
const workspaceUrl = activeWorkspace ? `/w/${activeWorkspace.id}` : '/w'
// Notify parent component when dropdown opens/closes
const handleDropdownOpenChange = (open: boolean) => {
setIsOpen(open)
// Inform the parent component about the dropdown state change
if (onDropdownOpenChange) {
onDropdownOpenChange(open)
}
}
// Special handling for click interactions in hover mode
const handleTriggerClick = (e: React.MouseEvent) => {
// When in hover mode, explicitly prevent bubbling for the trigger
if (mode === 'hover') {
e.stopPropagation()
e.preventDefault()
// Toggle dropdown state
handleDropdownOpenChange(!isOpen)
}
}
// Handle modal open/close state
useEffect(() => {
// Update the modal state in the store
setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting)
}, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen])
return (
<div className="py-2 px-2">
{/* Workspace Modal */}
@@ -436,9 +472,15 @@ export function WorkspaceHeader({ onCreateWorkflow, isCollapsed }: WorkspaceHead
workspace={editingWorkspace}
/>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu open={isOpen} onOpenChange={handleDropdownOpenChange}>
<div
className={`group relative rounded-md cursor-pointer ${isCollapsed ? 'flex justify-center' : ''}`}
onClick={(e) => {
// In hover mode, prevent clicks on the container from collapsing the sidebar
if (mode === 'hover') {
e.stopPropagation()
}
}}
>
{/* Hover background with consistent padding - only when not collapsed */}
{!isCollapsed && <div className="absolute inset-0 rounded-md group-hover:bg-accent/50" />}
@@ -456,7 +498,10 @@ export function WorkspaceHeader({ onCreateWorkflow, isCollapsed }: WorkspaceHead
) : (
<div className="relative">
<DropdownMenuTrigger asChild>
<div className="flex items-center px-2 py-[6px] relative z-10 w-full">
<div
className="flex items-center px-2 py-[6px] relative z-10 w-full"
onClick={handleTriggerClick}
>
<div className="flex items-center gap-2 overflow-hidden cursor-pointer">
<Link
href={workspaceUrl}

View File

@@ -28,6 +28,7 @@ import { useRegistryLoading } from '../../hooks/use-registry-loading'
import { HelpModal } from './components/help-modal/help-modal'
import { NavSection } from './components/nav-section/nav-section'
import { SettingsModal } from './components/settings-modal/settings-modal'
import { SidebarControl } from './components/sidebar-control/sidebar-control'
import { WorkflowList } from './components/workflow-list/workflow-list'
import { WorkspaceHeader } from './components/workspace-header/workspace-header'
@@ -46,7 +47,18 @@ export function Sidebar() {
const pathname = usePathname()
const [showSettings, setShowSettings] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const { isCollapsed, toggleCollapsed } = useSidebarStore()
const {
mode,
isExpanded,
toggleExpanded,
setMode,
workspaceDropdownOpen,
setWorkspaceDropdownOpen,
isAnyModalOpen,
setAnyModalOpen,
} = useSidebarStore()
const [isHovered, setIsHovered] = useState(false)
const [explicitMouseEnter, setExplicitMouseEnter] = useState(false)
// Track when active workspace changes to ensure we refresh the UI
useEffect(() => {
@@ -56,6 +68,18 @@ export function Sidebar() {
}
}, [activeWorkspaceId])
// Update modal state in the store when settings or help modals open/close
useEffect(() => {
setAnyModalOpen(showSettings || showHelp)
}, [showSettings, showHelp, setAnyModalOpen])
// Reset explicit mouse enter state when modal state changes
useEffect(() => {
if (isAnyModalOpen) {
setExplicitMouseEnter(false)
}
}, [isAnyModalOpen])
// Separate regular workflows from temporary marketplace workflows
const { regularWorkflows, tempWorkflows } = useMemo(() => {
const regular: WorkflowMetadata[] = []
@@ -129,16 +153,49 @@ export function Sidebar() {
}
}
// Calculate sidebar visibility states
// When in hover mode, sidebar is collapsed until hovered or workspace dropdown is open
// When in expanded/collapsed mode, sidebar follows isExpanded state
const isCollapsed =
mode === 'collapsed' ||
(mode === 'hover' &&
((!isHovered && !workspaceDropdownOpen) || isAnyModalOpen || !explicitMouseEnter))
// Only show overlay effect when in hover mode and actually being hovered or dropdown is open
const showOverlay =
mode === 'hover' &&
((isHovered && !isAnyModalOpen && explicitMouseEnter) || workspaceDropdownOpen)
return (
<aside
className={clsx(
'fixed inset-y-0 left-0 z-10 flex flex-col border-r bg-background sm:flex transition-width duration-200',
isCollapsed ? 'w-14' : 'w-60'
'fixed inset-y-0 left-0 z-10 flex flex-col border-r bg-background sm:flex transition-all duration-200',
isCollapsed ? 'w-14' : 'w-60',
showOverlay ? 'shadow-lg' : '',
mode === 'hover' ? 'main-content-overlay' : ''
)}
onMouseEnter={() => {
if (mode === 'hover' && !isAnyModalOpen) {
setIsHovered(true)
setExplicitMouseEnter(true)
}
}}
onMouseLeave={() => {
if (mode === 'hover') {
setIsHovered(false)
}
}}
style={{
// When in hover mode and expanded, position above content without pushing it
position: showOverlay ? 'fixed' : 'fixed',
}}
>
{/* Workspace Header - Fixed at top */}
<div className="flex-shrink-0">
<WorkspaceHeader onCreateWorkflow={handleCreateWorkflow} isCollapsed={isCollapsed} />
<WorkspaceHeader
onCreateWorkflow={handleCreateWorkflow}
isCollapsed={isCollapsed}
onDropdownOpenChange={setWorkspaceDropdownOpen}
/>
</div>
{/* Main navigation - Fixed at top below header */}
@@ -236,35 +293,23 @@ export function Sidebar() {
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={toggleCollapsed}
className="flex items-center justify-center rounded-md text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer w-8 h-8 mx-auto"
>
<ChevronRight className="h-[18px] w-[18px]" />
</div>
<SidebarControl />
</TooltipTrigger>
<TooltipContent side="right">Expand</TooltipContent>
</Tooltip>
</div>
) : (
<div className="flex justify-between">
{/* Help button on left */}
{/* Sidebar control on left */}
<SidebarControl />
{/* Help button on right */}
<div
onClick={() => setShowHelp(true)}
className="flex items-center justify-center rounded-md px-1 py-1 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer"
className="flex items-center justify-center rounded-md w-8 h-8 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer"
>
<HelpCircle className="h-[18px] w-[18px]" />
<span className="sr-only">Help</span>
</div>
{/* Collapse/Expand button on right */}
<div
onClick={toggleCollapsed}
className="flex items-center justify-center rounded-md px-1 py-1 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer"
>
<ChevronLeft className="h-[18px] w-[18px]" />
<span className="sr-only">Collapse</span>
</div>
</div>
)}
</div>

View File

@@ -62,7 +62,9 @@ export default function Logs() {
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const { isCollapsed: isSidebarCollapsed } = useSidebarStore()
const { mode, isExpanded } = useSidebarStore()
const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
// Group logs by executionId to identify the last log in each group
const executionGroups = useMemo(() => {

View File

@@ -83,8 +83,6 @@ async function getTeamSeats(userId: string): Promise<number> {
*/
export async function checkUsageStatus(userId: string): Promise<UsageData> {
try {
logger.info('Starting usage status check for user', { userId })
// In development, always return permissive limits
if (!isProd) {
// Get actual usage from the database for display purposes

View File

@@ -1,21 +1,43 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type SidebarMode = 'expanded' | 'collapsed' | 'hover'
interface SidebarState {
isCollapsed: boolean
toggleCollapsed: () => void
setCollapsed: (collapsed: boolean) => void
mode: SidebarMode
isExpanded: boolean
// Track workspace dropdown state
workspaceDropdownOpen: boolean
// Track if any modal is open
isAnyModalOpen: boolean
setMode: (mode: SidebarMode) => void
toggleExpanded: () => void
// Control workspace dropdown state
setWorkspaceDropdownOpen: (isOpen: boolean) => void
// Control modal state
setAnyModalOpen: (isOpen: boolean) => void
// Force sidebar expanded state without triggering loops
forceExpanded: (expanded: boolean) => void
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
isCollapsed: false,
toggleCollapsed: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
setCollapsed: (collapsed) => set({ isCollapsed: collapsed }),
mode: 'expanded', // Default to expanded mode
isExpanded: true, // Default to expanded state
workspaceDropdownOpen: false, // Track if workspace dropdown is open
isAnyModalOpen: false, // Track if any modal is open
setMode: (mode) => set({ mode }),
toggleExpanded: () => set((state) => ({ isExpanded: !state.isExpanded })),
// Only update dropdown state without changing isExpanded
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
// Update modal state
setAnyModalOpen: (isOpen) => set({ isAnyModalOpen: isOpen }),
// Separate function to control expanded state
forceExpanded: (expanded) => set({ isExpanded: expanded }),
}),
{
name: 'sidebar-state',
}
)
)
)