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