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', 'Condition',
'Evaluator', 'Evaluator',
'Function', 'Function',
'Guardrails',
'Human In The Loop', 'Human In The Loop',
'Loop', 'Loop',
'Parallel', 'Parallel',
@@ -30,7 +31,6 @@ export const FOOTER_TOOLS = [
'GitHub', 'GitHub',
'Gmail', 'Gmail',
'Google Drive', 'Google Drive',
'Guardrails',
'HubSpot', 'HubSpot',
'HuggingFace', 'HuggingFace',
'Hunter', 'Hunter',

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { memo, useMemo } from 'react'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow' import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types' import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useExecutionStore } from '@/stores/execution/store' import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -9,7 +11,7 @@ interface WorkflowEdgeProps extends EdgeProps {
targetHandle?: string | null targetHandle?: string | null
} }
export const WorkflowEdge = ({ const WorkflowEdgeComponent = ({
id, id,
sourceX, sourceX,
sourceY, sourceY,
@@ -41,65 +43,64 @@ export const WorkflowEdge = ({
const isInsideLoop = data?.isInsideLoop ?? false const isInsideLoop = data?.isInsideLoop ?? false
const parentLoopId = data?.parentLoopId const parentLoopId = data?.parentLoopId
const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) // Combined store subscription to reduce subscription overhead
const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady) useShallow((state) => ({
diffAnalysis: state.diffAnalysis,
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
}))
)
const lastRunEdges = useExecutionStore((state) => state.lastRunEdges) 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 dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error' const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
// Check if this edge was traversed during last execution
const edgeRunStatus = lastRunEdges.get(id) const edgeRunStatus = lastRunEdges.get(id)
const getEdgeColor = () => { // Memoize diff status calculation to avoid recomputing on every render
if (edgeDiffStatus === 'deleted') return 'var(--text-error)' const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
if (isErrorEdge) return 'var(--text-error)' if (data?.isDeleted) return 'deleted'
if (edgeDiffStatus === 'new') return 'var(--brand-tertiary)' if (!diffAnalysis?.edge_diff || !isDiffReady) return null
// 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)'
}
const edgeStyle = { const actualSourceHandle = sourceHandle || 'source'
...(style ?? {}), const actualTargetHandle = targetHandle || 'target'
strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2, const edgeIdentifier = `${source}-${actualSourceHandle}-${target}-${actualTargetHandle}`
stroke: getEdgeColor(),
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined, if (isShowingDiff) {
opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1, 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 ( 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 { export function useCurrentWorkflow(): CurrentWorkflow {
// Get normal workflow state - optimized with shallow comparison // Get normal workflow state - optimized with shallow comparison
// This prevents re-renders when only subblock values change (not block structure)
const normalWorkflow = useWorkflowStore( const normalWorkflow = useWorkflowStore(
useShallow((state) => { useShallow((state) => ({
const workflow = state.getWorkflowState() blocks: state.blocks,
return { edges: state.edges,
blocks: workflow.blocks, loops: state.loops,
edges: workflow.edges, parallels: state.parallels,
loops: workflow.loops, lastSaved: state.lastSaved,
parallels: workflow.parallels, isDeployed: state.isDeployed,
lastSaved: workflow.lastSaved, deployedAt: state.deployedAt,
isDeployed: workflow.isDeployed, deploymentStatuses: state.deploymentStatuses,
deployedAt: workflow.deployedAt, needsRedeployment: state.needsRedeployment,
deploymentStatuses: workflow.deploymentStatuses, }))
needsRedeployment: workflow.needsRedeployment,
}
})
) )
// Get diff state - now including isDiffReady // Get diff state - optimized with shallow comparison
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore() 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 // Create the abstracted interface - optimized to prevent unnecessary re-renders
const currentWorkflow = useMemo((): CurrentWorkflow => { 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 }) { export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return ( 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> <ErrorBoundary>{children}</ErrorBoundary>
</main> </main>
) )

View File

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

View File

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

View File

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

View File

@@ -226,12 +226,12 @@ export function Sidebar() {
) )
const isLoading = workflowsLoading || sessionLoading 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(() => { useEffect(() => {
if (!workflowId || workflowsLoading || initialScrollDoneRef.current === workflowId) return if (!workflowId || workflowsLoading || initialScrollDoneRef.current) return
initialScrollDoneRef.current = workflowId initialScrollDoneRef.current = true
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflowId } }) new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflowId } })

View File

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

View File

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

View File

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

View File

@@ -102,11 +102,15 @@ const SModalContent = React.forwardRef<
SModalContent.displayName = 'SModalContent' SModalContent.displayName = 'SModalContent'
/** /**
* Sidebar container. * Sidebar container with scrollable content.
*/ */
const SModalSidebar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const SModalSidebar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ 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 * Fetch all organizations for the current user
* Note: Billing data is fetched separately via useSubscriptionData() to avoid duplicate calls
*/ */
async function fetchOrganizations() { async function fetchOrganizations() {
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([ const [orgsResponse, activeOrgResponse] = await Promise.all([
client.organization.list(), client.organization.list(),
client.organization.getFullOrganization(), client.organization.getFullOrganization(),
fetch('/api/billing?context=user').then((r) => r.json()),
]) ])
return { return {
organizations: orgsResponse.data || [], organizations: orgsResponse.data || [],
activeOrganization: activeOrgResponse.data, activeOrganization: activeOrgResponse.data,
billingData: billingResponse,
} }
} }

View File

@@ -251,10 +251,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
position, 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 }) => { updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => {