mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
@@ -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',
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user