improvement: workflow loading, sidebar scrolling (#2322)

* improvement: workflow loading, sidebar scrolling

* further optimizations

* remove redundant perms calls

* remove redundant api calls

* use displayNodes local state to make dragging smooth even in larger workflows

* improvement(logs): trace span output styling

* fix(s-modal): sidebar overflow scrolling

* fix(footer): guardrails link

* improvement(loading): spinner

* refactor(training-modal): changed file name

* improvement(spinner): optimize spinner in background

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Emir Karabeg
2025-12-15 19:21:21 -08:00
committed by GitHub
parent f0dc8e81d9
commit 0e6a1315d0
18 changed files with 672 additions and 740 deletions

View File

@@ -4,6 +4,7 @@ export const FOOTER_BLOCKS = [
'Condition',
'Evaluator',
'Function',
'Guardrails',
'Human In The Loop',
'Loop',
'Parallel',
@@ -30,7 +31,6 @@ export const FOOTER_TOOLS = [
'GitHub',
'Gmail',
'Google Drive',
'Guardrails',
'HubSpot',
'HuggingFace',
'Hunter',

View File

@@ -14,7 +14,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<ProviderModelsLoader />
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className='flex h-screen w-full'>
<div className='flex h-screen w-full bg-[var(--bg)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />

View File

@@ -398,7 +398,7 @@ function InputOutputSection({
}, [data])
return (
<div className='flex flex-col gap-[8px]'>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => onToggle(sectionKey)}
@@ -436,7 +436,7 @@ function InputOutputSection({
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] min-h-0 overflow-hidden rounded-[6px] border-0'
className='!bg-[var(--surface-3)] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
/>
)}
@@ -477,7 +477,7 @@ function NestedBlockItem({
const isChildrenExpanded = expandedChildren.has(spanId)
return (
<div className='flex flex-col gap-[8px]'>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<ExpandableRowHeader
name={span.name}
duration={span.duration || 0}
@@ -502,7 +502,7 @@ function NestedBlockItem({
{/* Nested children */}
{hasChildren && isChildrenExpanded && (
<div className='mt-[2px] flex flex-col gap-[10px] border-[var(--border)] border-l-2 pl-[10px]'>
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l-2 pl-[10px]'>
{span.children!.map((child, childIndex) => (
<NestedBlockItem
key={child.id || `${spanId}-child-${childIndex}`}
@@ -617,7 +617,7 @@ function TraceSpanItem({
return (
<>
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<ExpandableRowHeader
name={span.name}
duration={duration}
@@ -642,7 +642,7 @@ function TraceSpanItem({
{/* For workflow blocks, keep children nested within the card (not as separate cards) */}
{!isFirstSpan && isWorkflowBlock && inlineChildren.length > 0 && isCardExpanded && (
<div className='mt-[2px] flex flex-col gap-[10px] border-[var(--border)] border-l-2 pl-[10px]'>
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l-2 pl-[10px]'>
{inlineChildren.map((childSpan, index) => (
<NestedBlockItem
key={childSpan.id || `${spanId}-nested-${index}`}
@@ -662,7 +662,7 @@ function TraceSpanItem({
{/* For non-workflow blocks, render inline children/tool calls */}
{!isFirstSpan && !isWorkflowBlock && isCardExpanded && (
<div className='mt-[2px] flex flex-col gap-[10px] border-[var(--border)] border-l-2 pl-[10px]'>
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l-2 pl-[10px]'>
{[...toolCallSpans, ...inlineChildren].map((childSpan, index) => {
const childId = childSpan.id || `${spanId}-inline-${index}`
const childIsError = childSpan.status === 'error'
@@ -677,7 +677,10 @@ function TraceSpanItem({
)
return (
<div key={`inline-${childId}`} className='flex flex-col gap-[8px]'>
<div
key={`inline-${childId}`}
className='flex min-w-0 flex-col gap-[8px] overflow-hidden'
>
<ExpandableRowHeader
name={childSpan.name}
duration={childSpan.duration || 0}
@@ -727,7 +730,7 @@ function TraceSpanItem({
{/* Nested children */}
{showChildrenInProgressBar && hasNestedChildren && isNestedExpanded && (
<div className='mt-[2px] flex flex-col gap-[10px] border-[var(--border)] border-l-2 pl-[10px]'>
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l-2 pl-[10px]'>
{childSpan.children!.map((nestedChild, nestedIndex) => (
<NestedBlockItem
key={nestedChild.id || `${childId}-nested-${nestedIndex}`}
@@ -809,9 +812,9 @@ export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
}
return (
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<div className='flex w-full min-w-0 flex-col gap-[6px] overflow-hidden rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Trace Span</span>
<div className='flex flex-col gap-[8px]'>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
{normalizedSpans.map((span, index) => (
<TraceSpanItem
key={span.id || index}

View File

@@ -1,5 +1,7 @@
import { memo, useMemo } from 'react'
import { X } from 'lucide-react'
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -9,7 +11,7 @@ interface WorkflowEdgeProps extends EdgeProps {
targetHandle?: string | null
}
export const WorkflowEdge = ({
const WorkflowEdgeComponent = ({
id,
sourceX,
sourceY,
@@ -41,65 +43,64 @@ export const WorkflowEdge = ({
const isInsideLoop = data?.isInsideLoop ?? false
const parentLoopId = data?.parentLoopId
const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis)
const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady)
// Combined store subscription to reduce subscription overhead
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
useShallow((state) => ({
diffAnalysis: state.diffAnalysis,
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
}))
)
const lastRunEdges = useExecutionStore((state) => state.lastRunEdges)
const generateEdgeIdentity = (
sourceId: string,
targetId: string,
sourceHandle?: string | null,
targetHandle?: string | null
): string => {
const actualSourceHandle = sourceHandle || 'source'
const actualTargetHandle = targetHandle || 'target'
return `${sourceId}-${actualSourceHandle}-${targetId}-${actualTargetHandle}`
}
const edgeIdentifier = generateEdgeIdentity(source, target, sourceHandle, targetHandle)
let edgeDiffStatus: EdgeDiffStatus = null
if (data?.isDeleted) {
edgeDiffStatus = 'deleted'
} else if (diffAnalysis?.edge_diff && edgeIdentifier && isDiffReady) {
if (isShowingDiff) {
if (diffAnalysis.edge_diff.new_edges.includes(edgeIdentifier)) {
edgeDiffStatus = 'new'
} else if (diffAnalysis.edge_diff.unchanged_edges.includes(edgeIdentifier)) {
edgeDiffStatus = 'unchanged'
}
} else {
if (diffAnalysis.edge_diff.deleted_edges.includes(edgeIdentifier)) {
edgeDiffStatus = 'deleted'
}
}
}
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
// Check if this edge was traversed during last execution
const edgeRunStatus = lastRunEdges.get(id)
const getEdgeColor = () => {
if (edgeDiffStatus === 'deleted') return 'var(--text-error)'
if (isErrorEdge) return 'var(--text-error)'
if (edgeDiffStatus === 'new') return 'var(--brand-tertiary)'
// Show run path status if edge was traversed
if (edgeRunStatus === 'success') return 'var(--border-success)'
if (edgeRunStatus === 'error') return 'var(--text-error)'
return 'var(--surface-12)'
}
// Memoize diff status calculation to avoid recomputing on every render
const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
if (data?.isDeleted) return 'deleted'
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
const edgeStyle = {
...(style ?? {}),
strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2,
stroke: getEdgeColor(),
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1,
}
const actualSourceHandle = sourceHandle || 'source'
const actualTargetHandle = targetHandle || 'target'
const edgeIdentifier = `${source}-${actualSourceHandle}-${target}-${actualTargetHandle}`
if (isShowingDiff) {
if (diffAnalysis.edge_diff.new_edges.includes(edgeIdentifier)) return 'new'
if (diffAnalysis.edge_diff.unchanged_edges.includes(edgeIdentifier)) return 'unchanged'
} else {
if (diffAnalysis.edge_diff.deleted_edges.includes(edgeIdentifier)) return 'deleted'
}
return null
}, [
data?.isDeleted,
diffAnalysis,
isDiffReady,
isShowingDiff,
source,
target,
sourceHandle,
targetHandle,
])
// Memoize edge style to prevent object recreation
const edgeStyle = useMemo(() => {
let color = 'var(--surface-12)'
if (edgeDiffStatus === 'deleted') color = 'var(--text-error)'
else if (isErrorEdge) color = 'var(--text-error)'
else if (edgeDiffStatus === 'new') color = 'var(--brand-tertiary)'
else if (edgeRunStatus === 'success') color = 'var(--border-success)'
else if (edgeRunStatus === 'error') color = 'var(--text-error)'
return {
...(style ?? {}),
strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2,
stroke: color,
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1,
}
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
return (
<>
@@ -148,3 +149,5 @@ export const WorkflowEdge = ({
</>
)
}
export const WorkflowEdge = memo(WorkflowEdgeComponent)

View File

@@ -43,26 +43,29 @@ export interface CurrentWorkflow {
*/
export function useCurrentWorkflow(): CurrentWorkflow {
// Get normal workflow state - optimized with shallow comparison
// This prevents re-renders when only subblock values change (not block structure)
const normalWorkflow = useWorkflowStore(
useShallow((state) => {
const workflow = state.getWorkflowState()
return {
blocks: workflow.blocks,
edges: workflow.edges,
loops: workflow.loops,
parallels: workflow.parallels,
lastSaved: workflow.lastSaved,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
deploymentStatuses: workflow.deploymentStatuses,
needsRedeployment: workflow.needsRedeployment,
}
})
useShallow((state) => ({
blocks: state.blocks,
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
lastSaved: state.lastSaved,
isDeployed: state.isDeployed,
deployedAt: state.deployedAt,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
}))
)
// Get diff state - now including isDiffReady
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
// Get diff state - optimized with shallow comparison
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore(
useShallow((state) => ({
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
baselineWorkflow: state.baselineWorkflow,
}))
)
// Create the abstracted interface - optimized to prevent unnecessary re-renders
const currentWorkflow = useMemo((): CurrentWorkflow => {

View File

@@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return (
<main className='flex h-full flex-1 flex-col overflow-hidden bg-muted/40'>
<main className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
)

View File

@@ -38,11 +38,10 @@ export function TeamManagement() {
const { data: organizationsData } = useOrganizations()
const activeOrganization = organizationsData?.activeOrganization
const billingData = organizationsData?.billingData?.data
const hasTeamPlan = billingData?.isTeam ?? false
const hasEnterprisePlan = billingData?.isEnterprise ?? false
const { data: userSubscriptionData } = useSubscriptionData()
const hasTeamPlan = userSubscriptionData?.data?.isTeam ?? false
const hasEnterprisePlan = userSubscriptionData?.data?.isEnterprise ?? false
const {
data: organization,

View File

@@ -316,16 +316,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
queryKey: organizationKeys.lists(),
queryFn: async () => {
const { client } = await import('@/lib/auth/auth-client')
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([
const [orgsResponse, activeOrgResponse] = await Promise.all([
client.organization.list(),
client.organization.getFullOrganization(),
fetch('/api/billing?context=user').then((r) => r.json()),
])
return {
organizations: orgsResponse.data || [],
activeOrganization: activeOrgResponse.data,
billingData: billingResponse,
}
},
staleTime: 30 * 1000,

View File

@@ -144,6 +144,12 @@ export function WorkspaceHeader({
const contextMenuRef = useRef<HTMLDivElement | null>(null)
const capturedWorkspaceRef = useRef<{ id: string; name: string } | null>(null)
// Client-only rendering for Popover to prevent Radix ID hydration mismatch
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
/**
* Focus the inline list rename input when it becomes active
*/
@@ -269,104 +275,121 @@ export function WorkspaceHeader({
<Badge className='cursor-pointer' onClick={() => setIsInviteModalOpen(true)}>
Invite
</Badge>
{/* Workspace Switcher Popover */}
<Popover
open={isWorkspaceMenuOpen}
onOpenChange={(open) => {
// Don't close if context menu is opening
if (!open && isContextMenuOpen) {
return
}
setIsWorkspaceMenuOpen(open)
}}
>
<PopoverTrigger asChild>
<Button
variant='ghost-secondary'
type='button'
aria-label='Switch workspace'
className='group !p-[3px] -m-[3px]'
>
<ChevronDown
className={`h-[8px] w-[12px] transition-transform duration-100 ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align='end'
side='bottom'
sideOffset={8}
style={{ maxWidth: '160px', minWidth: '160px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
{/* Workspace Switcher Popover - only render after mount to avoid Radix ID hydration mismatch */}
{isMounted ? (
<Popover
open={isWorkspaceMenuOpen}
onOpenChange={(open) => {
// Don't close if context menu is opening
if (!open && isContextMenuOpen) {
return
}
setIsWorkspaceMenuOpen(open)
}}
>
{isWorkspacesLoading ? (
<PopoverItem disabled>
<span>Loading workspaces...</span>
</PopoverItem>
) : (
<>
<div className='relative flex items-center justify-between'>
<PopoverSection>Workspaces</PopoverSection>
<div className='flex items-center gap-[6px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Import workspace'
className='!p-[3px]'
onClick={(e) => {
e.stopPropagation()
onImportWorkspace()
}}
disabled={isImportingWorkspace}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>
{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}
</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Create workspace'
className='!p-[3px]'
onClick={async (e) => {
e.stopPropagation()
await onCreateWorkspace()
setIsWorkspaceMenuOpen(false)
}}
disabled={isCreatingWorkspace}
>
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}</p>
</Tooltip.Content>
</Tooltip.Root>
<PopoverTrigger asChild>
<Button
variant='ghost-secondary'
type='button'
aria-label='Switch workspace'
className='group !p-[3px] -m-[3px]'
>
<ChevronDown
className={`h-[8px] w-[12px] transition-transform duration-100 ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align='end'
side='bottom'
sideOffset={8}
style={{ maxWidth: '160px', minWidth: '160px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{isWorkspacesLoading ? (
<PopoverItem disabled>
<span>Loading workspaces...</span>
</PopoverItem>
) : (
<>
<div className='relative flex items-center justify-between'>
<PopoverSection>Workspaces</PopoverSection>
<div className='flex items-center gap-[6px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Import workspace'
className='!p-[3px]'
onClick={(e) => {
e.stopPropagation()
onImportWorkspace()
}}
disabled={isImportingWorkspace}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>
{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}
</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Create workspace'
className='!p-[3px]'
onClick={async (e) => {
e.stopPropagation()
await onCreateWorkspace()
setIsWorkspaceMenuOpen(false)
}}
disabled={isCreatingWorkspace}
>
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>
{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
<div className='max-h-[200px] overflow-y-auto'>
{workspaces.map((workspace, index) => (
<div key={workspace.id} className={index > 0 ? 'mt-[2px]' : ''}>
{editingWorkspaceId === workspace.id ? (
<div className='flex h-[25px] items-center gap-[8px] rounded-[6px] bg-[var(--surface-9)] px-[6px]'>
<input
ref={listRenameInputRef}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault()
<div className='max-h-[200px] overflow-y-auto'>
{workspaces.map((workspace, index) => (
<div key={workspace.id} className={index > 0 ? 'mt-[2px]' : ''}>
{editingWorkspaceId === workspace.id ? (
<div className='flex h-[25px] items-center gap-[8px] rounded-[6px] bg-[var(--surface-9)] px-[6px]'>
<input
ref={listRenameInputRef}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault()
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, editingName.trim())
setEditingWorkspaceId(null)
} finally {
setIsListRenaming(false)
}
} else if (e.key === 'Escape') {
e.preventDefault()
setEditingWorkspaceId(null)
}
}}
onBlur={async () => {
if (!editingWorkspaceId) return
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, editingName.trim())
@@ -374,50 +397,47 @@ export function WorkspaceHeader({
} finally {
setIsListRenaming(false)
}
} else if (e.key === 'Escape') {
}}
className='w-full border-0 bg-transparent p-0 font-base text-[12px] text-[var(--text-primary)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
disabled={isListRenaming}
onClick={(e) => {
e.preventDefault()
setEditingWorkspaceId(null)
}
}}
onBlur={async () => {
if (!editingWorkspaceId) return
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, editingName.trim())
setEditingWorkspaceId(null)
} finally {
setIsListRenaming(false)
}
}}
className='w-full border-0 bg-transparent p-0 font-base text-[12px] text-[var(--text-primary)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
disabled={isListRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
</div>
) : (
<PopoverItem
active={workspace.id === workspaceId}
onClick={() => onWorkspaceSwitch(workspace)}
onContextMenu={(e) => handleContextMenu(e, workspace)}
>
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>
</PopoverItem>
)}
</div>
))}
</div>
</>
)}
</PopoverContent>
</Popover>
e.stopPropagation()
}}
/>
</div>
) : (
<PopoverItem
active={workspace.id === workspaceId}
onClick={() => onWorkspaceSwitch(workspace)}
onContextMenu={(e) => handleContextMenu(e, workspace)}
>
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>
</PopoverItem>
)}
</div>
))}
</div>
</>
)}
</PopoverContent>
</Popover>
) : (
<Button
variant='ghost-secondary'
type='button'
aria-label='Switch workspace'
className='group !p-[3px] -m-[3px]'
disabled
>
<ChevronDown className='h-[8px] w-[12px]' />
</Button>
)}
{/* Sidebar Collapse Toggle */}
{showCollapseButton && (
<Button

View File

@@ -226,12 +226,12 @@ export function Sidebar() {
)
const isLoading = workflowsLoading || sessionLoading
const initialScrollDoneRef = useRef<string | null>(null)
const initialScrollDoneRef = useRef(false)
/** Scrolls to active workflow on initial load or workspace switch */
/** Scrolls to active workflow on initial page load only */
useEffect(() => {
if (!workflowId || workflowsLoading || initialScrollDoneRef.current === workflowId) return
initialScrollDoneRef.current = workflowId
if (!workflowId || workflowsLoading || initialScrollDoneRef.current) return
initialScrollDoneRef.current = true
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflowId } })

View File

@@ -1,9 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { Panel, Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -55,14 +55,14 @@ export default function WorkflowsPage() {
// Always show loading state until redirect happens
// There should always be a default workflow, so we never show "no workflows found"
return (
<main className='flex h-full flex-1 flex-col overflow-hidden bg-muted/40'>
<div className='flex h-full items-center justify-center'>
<div className='text-center'>
<div className='mx-auto mb-4'>
<Loader2 className='h-5 w-5 animate-spin text-muted-foreground' />
</div>
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
<div className='workflow-container flex h-full items-center justify-center bg-[var(--bg)]'>
<div className='h-[18px] w-[18px] animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-transparent' />
</div>
<Panel />
</div>
</main>
<Terminal />
</div>
)
}

View File

@@ -1,7 +1,6 @@
'use client'
import { useEffect } from 'react'
import { Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
@@ -118,9 +117,7 @@ export default function WorkspacePage() {
if (isPending) {
return (
<div className='flex h-screen w-full items-center justify-center'>
<div className='flex flex-col items-center justify-center text-center align-middle'>
<Loader2 className='h-5 w-5 animate-spin text-muted-foreground' />
</div>
<div className='h-[18px] w-[18px] animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-transparent' />
</div>
)
}

View File

@@ -160,17 +160,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
initializedRef.current = true
setIsConnecting(true)
const initializeSocket = async () => {
const initializeSocket = () => {
try {
// Generate initial token for socket authentication
const token = await generateSocketToken()
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || 'http://localhost:3002'
logger.info('Attempting to connect to Socket.IO server', {
url: socketUrl,
userId: user?.id || 'no-user',
hasToken: !!token,
timestamp: new Date().toISOString(),
})

View File

@@ -102,11 +102,15 @@ const SModalContent = React.forwardRef<
SModalContent.displayName = 'SModalContent'
/**
* Sidebar container.
* Sidebar container with scrollable content.
*/
const SModalSidebar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex w-[166px] flex-col py-[12px]', className)} {...props} />
<div
ref={ref}
className={cn('flex min-h-0 w-[166px] flex-col overflow-y-auto py-[12px]', className)}
{...props}
/>
)
)

View File

@@ -21,18 +21,17 @@ export const organizationKeys = {
/**
* Fetch all organizations for the current user
* Note: Billing data is fetched separately via useSubscriptionData() to avoid duplicate calls
*/
async function fetchOrganizations() {
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([
const [orgsResponse, activeOrgResponse] = await Promise.all([
client.organization.list(),
client.organization.getFullOrganization(),
fetch('/api/billing?context=user').then((r) => r.json()),
])
return {
organizations: orgsResponse.data || [],
activeOrganization: activeOrgResponse.data,
billingData: billingResponse,
}
}

View File

@@ -251,10 +251,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
position,
},
},
edges: [...state.edges],
}))
get().updateLastSaved()
// No sync for position updates to avoid excessive syncing during drag
},
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => {