From 90971fb975b55dfb4bc223bbaf81f81cd19aaecf Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Sat, 4 Apr 2026 21:51:23 +0530 Subject: [PATCH 1/6] chore: fix conflicts --- .claude/settings.local.json | 16 + apps/sim/app/_styles/globals.css | 35 +- .../w/[workflowId]/components/index.ts | 2 + .../w/[workflowId]/components/panel/panel.tsx | 892 +++++++----------- .../components/terminal/terminal.tsx | 266 ++++-- .../[workflowId]/components/terminal/utils.ts | 2 +- .../components/workflow-actions/index.ts | 1 + .../workflow-actions/workflow-actions.tsx | 334 +++++++ .../workflow-controls/workflow-controls.tsx | 50 +- .../workflow-edge/workflow-edge.tsx | 42 +- .../components/workflow-toolbar/index.ts | 1 + .../workflow-toolbar/workflow-history.tsx | 134 +++ .../workflow-toolbar/workflow-toolbar.tsx | 98 ++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 49 +- .../app/workspace/[workspaceId]/w/page.tsx | 3 +- .../emcn/components/button/button.tsx | 2 + apps/sim/stores/panel/store.ts | 4 + apps/sim/stores/panel/types.ts | 6 +- apps/sim/stores/workflow-history/index.ts | 2 + apps/sim/stores/workflow-history/store.ts | 228 +++++ apps/sim/stores/workflow-history/types.ts | 65 ++ 21 files changed, 1600 insertions(+), 632 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx create mode 100644 apps/sim/stores/workflow-history/index.ts create mode 100644 apps/sim/stores/workflow-history/store.ts create mode 100644 apps/sim/stores/workflow-history/types.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..63c81149923 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(docker compose:*)", + "Bash(docker system df:*)", + "Bash(docker image:*)", + "Bash(docker builder:*)", + "Bash(bun run:*)", + "Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/simstudio\" bunx drizzle-kit migrate --config=./drizzle.config.ts)", + "Bash(docker exec sim-db-1:*)", + "Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/simstudio\" bun run db:push)", + "Bash(POSTGRES_PORT=5433 docker compose:*)", + "Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5433/simstudio\" bun run db:migrate)" + ] + } +} diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 254534f2891..eaebf8a70e1 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -677,17 +677,26 @@ input[type="search"]::-ms-clear { * Panel tab visibility and styling to prevent hydration flash */ html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="toolbar"], - html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="editor"] { + html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="editor"], + html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="logs"] { display: none !important; } html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="copilot"], - html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="editor"] { + html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="editor"], + html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="logs"] { display: none !important; } html[data-panel-active-tab="editor"] .panel-container [data-tab-content="copilot"], - html[data-panel-active-tab="editor"] .panel-container [data-tab-content="toolbar"] { + html[data-panel-active-tab="editor"] .panel-container [data-tab-content="toolbar"], + html[data-panel-active-tab="editor"] .panel-container [data-tab-content="logs"] { + display: none !important; + } + + html[data-panel-active-tab="logs"] .panel-container [data-tab-content="copilot"], + html[data-panel-active-tab="logs"] .panel-container [data-tab-content="toolbar"], + html[data-panel-active-tab="logs"] .panel-container [data-tab-content="editor"] { display: none !important; } @@ -696,7 +705,8 @@ input[type="search"]::-ms-clear { color: var(--text-primary) !important; } html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="toolbar"], - html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="editor"] { + html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="editor"], + html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="logs"] { background-color: transparent !important; color: var(--text-tertiary) !important; } @@ -706,7 +716,8 @@ input[type="search"]::-ms-clear { color: var(--text-primary) !important; } html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="copilot"], - html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="editor"] { + html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="editor"], + html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="logs"] { background-color: transparent !important; color: var(--text-tertiary) !important; } @@ -716,7 +727,19 @@ input[type="search"]::-ms-clear { color: var(--text-primary) !important; } html[data-panel-active-tab="editor"] .panel-container [data-tab-button="copilot"], - html[data-panel-active-tab="editor"] .panel-container [data-tab-button="toolbar"] { + html[data-panel-active-tab="editor"] .panel-container [data-tab-button="toolbar"], + html[data-panel-active-tab="editor"] .panel-container [data-tab-button="logs"] { + background-color: transparent !important; + color: var(--text-tertiary) !important; + } + + html[data-panel-active-tab="logs"] .panel-container [data-tab-button="logs"] { + background-color: var(--border-1) !important; + color: var(--text-primary) !important; + } + html[data-panel-active-tab="logs"] .panel-container [data-tab-button="copilot"], + html[data-panel-active-tab="logs"] .panel-container [data-tab-button="toolbar"], + html[data-panel-active-tab="logs"] .panel-container [data-tab-button="editor"] { background-color: transparent !important; color: var(--text-tertiary) !important; } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts index 7c32706966d..c2b9652c708 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts @@ -9,6 +9,8 @@ export { Panel } from './panel/panel' export { SubflowNodeComponent } from './subflows/subflow-node' export { Terminal } from './terminal/terminal' export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar' +export { WorkflowActions } from './workflow-actions' export { WorkflowBlock } from './workflow-block/workflow-block' export { WorkflowControls } from './workflow-controls' export { WorkflowEdge } from './workflow-edge/workflow-edge' +export { WorkflowToolbar } from './workflow-toolbar' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4c5ecbbc570..72764ca96d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -2,47 +2,28 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { History, Plus, Square } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' +import { Bot, History, Pencil, Plus, TerminalSquare, Wrench } from 'lucide-react' +import { useParams } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' import { - BubbleChatClose, - BubbleChatPreview, Button, - Copy, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Layout, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - MoreHorizontal, - Play, Popover, PopoverContent, PopoverItem, PopoverScrollArea, PopoverSection, PopoverTrigger, + Tooltip, Trash, } from '@/components/emcn' -import { Lock, Unlock, Upload } from '@/components/emcn/icons' -import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' -import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks' import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { - Deploy, Editor, Toolbar, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components' @@ -50,32 +31,25 @@ import { usePanelResize, useUsageLimits, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' +import { Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal' import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables' -import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout' -import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' -import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' -import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' -import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { useChatStore } from '@/stores/chat/store' -import { useNotificationStore } from '@/stores/notifications/store' import type { ChatContext, PanelTab } from '@/stores/panel' import { usePanelStore } from '@/stores/panel' -import { useVariablesModalStore } from '@/stores/variables/modal' -import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils' -import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Panel') + /** - * Panel component with resizable width and tab navigation that persists across page refreshes. + * Floating panel sidebar with tab navigation that persists across page refreshes. + * + * Renders as a floating overlay on the right side of the canvas with rounded corners + * and a collapsible toggle. Tabs: Copilot, Toolbar, Editor, Logs. * * Uses a CSS-based approach to prevent hydration mismatches and flash on load: * 1. Width is controlled by CSS variable (--panel-width) @@ -84,12 +58,7 @@ const logger = createLogger('Panel') * 4. React takes over visibility control after hydration completes * 5. Store updates CSS variable when width changes * - * This ensures server and client render identical HTML, preventing hydration errors and visual flash. - * - * Note: All tabs are kept mounted but hidden to preserve component state during tab switches. - * This prevents unnecessary remounting which would trigger data reloads and reset state. - * - * @returns Panel on the right side of the workflow + * @returns Floating panel on the right side of the canvas */ interface PanelProps { /** Override workspaceId when rendered outside a workspace route (e.g. sandbox mode) */ @@ -97,17 +66,25 @@ interface PanelProps { } export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: PanelProps = {}) { - const router = useRouter() const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) const panelRef = useRef(null) - const fileInputRef = useRef(null) - const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( + const { + activeTab, + setActiveTab, + panelWidth, + isPanelOpen, + setIsPanelOpen, + _hasHydrated, + setHasHydrated, + } = usePanelStore( useShallow((state) => ({ activeTab: state.activeTab, setActiveTab: state.setActiveTab, panelWidth: state.panelWidth, + isPanelOpen: state.isPanelOpen, + setIsPanelOpen: state.setIsPanelOpen, _hasHydrated: state._hasHydrated, setHasHydrated: state.setHasHydrated, })) @@ -117,19 +94,8 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel } | null>(null) const { data: session } = useSession() - // State - const [isMenuOpen, setIsMenuOpen] = useState(false) - const [isAutoLayouting, setIsAutoLayouting] = useState(false) - const [isExporting, setIsExporting] = useState(false) - const [isDuplicating, setIsDuplicating] = useState(false) - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) - // Hooks - const userPermissions = useUserPermissionsContext() const { config: permissionConfig } = usePermissionConfig() - const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) - const duplicateWorkflowMutation = useDuplicateWorkflowMutation() - const { data: workflows = {} } = useWorkflowMap(workspaceId) const { activeWorkflowId, hydration } = useWorkflowRegistry( useShallow((state) => ({ activeWorkflowId: state.activeWorkflowId, @@ -137,31 +103,8 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel })) ) const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading' - const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null) - - // Check for locked blocks (disables auto-layout) - const hasLockedBlocks = useWorkflowStore((state) => - Object.values(state.blocks).some((block) => block.locked) - ) - - const allBlocksLocked = useWorkflowStore((state) => { - const blockList = Object.values(state.blocks) - return blockList.length > 0 && blockList.every((block) => block.locked) - }) - - const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0) - - const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow() const { navigateToSettings } = useSettingsNavigation() - // Delete workflow hook - const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({ - workspaceId, - workflowIds: activeWorkflowId || '', - isActive: true, - onSuccess: () => setIsDeleteModalOpen(false), - }) - // Usage limits hook const { usageExceeded } = useUsageLimits({ context: 'user', @@ -174,23 +117,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel // Panel resize hook const { handleMouseDown } = usePanelResize() - /** - * Opens subscription settings modal - */ const openSubscriptionSettings = () => { navigateToSettings({ section: 'subscription' }) } - /** - * Cancels the currently executing workflow - */ const cancelWorkflow = useCallback(async () => { await handleCancelExecution() }, [handleCancelExecution]) - /** - * Runs the workflow with usage limit check - */ const runWorkflow = useCallback(async () => { if (usageExceeded) { openSubscriptionSettings() @@ -199,23 +133,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel await handleRunWorkflow() }, [usageExceeded, handleRunWorkflow]) - // Chat state - const { isChatOpen, setIsChatOpen } = useChatStore( - useShallow((state) => ({ - isChatOpen: state.isChatOpen, - setIsChatOpen: state.setIsChatOpen, - })) - ) - const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesModalStore( - useShallow((state) => ({ - isOpen: state.isOpen, - setIsOpen: state.setIsOpen, - })) - ) - - const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null - const { isSnapshotView } = useCurrentWorkflow() - + // Copilot chat state const [copilotChatId, setCopilotChatId] = useState(undefined) const [copilotChatTitle, setCopilotChatTitle] = useState(null) const [copilotChatList, setCopilotChatList] = useState< @@ -405,7 +323,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel /** * Mark hydration as complete on mount - * This allows React to take over visibility control from CSS */ useEffect(() => { setHasHydrated(true) @@ -423,159 +340,34 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel }, [setActiveTab, copilotSendMessage]) /** - * Handles tab click events - */ - const handleTabClick = (tab: PanelTab) => { - setActiveTab(tab) - } - - /** - * Downloads a file with the given content - */ - const downloadFile = useCallback((content: string, filename: string, mimeType: string) => { - try { - const blob = new Blob([content], { type: mimeType }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - } catch (error) { - logger.error('Failed to download file:', error) - } - }, []) - - /** - * Handles auto-layout of workflow blocks - */ - const handleAutoLayout = useCallback(async () => { - if (isExecuting || !userPermissions.canEdit || isAutoLayouting) { - return - } - - setIsAutoLayouting(true) - try { - const result = await autoLayoutWithFitView() - if (!result.success && result.error) { - useNotificationStore.getState().addNotification({ - level: 'info', - message: result.error, - workflowId: activeWorkflowId || undefined, - }) - } - } finally { - setIsAutoLayouting(false) - } - }, [ - isExecuting, - userPermissions.canEdit, - isAutoLayouting, - autoLayoutWithFitView, - activeWorkflowId, - ]) - - /** - * Handles exporting workflow as JSON + * Context-aware tab switching: + * When a workflow run finishes, auto-switch to the Logs tab so the user + * can immediately see results — unless they're actively editing a block. */ - const handleExportJson = useCallback(async () => { - if (!currentWorkflow || !activeWorkflowId) { - logger.warn('No active workflow to export') - return - } - - setIsExporting(true) - try { - const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId) - - if (!workflow || !workflow.state) { - throw new Error('No workflow state found') + const wasExecutingRef = useRef(false) + useEffect(() => { + if (wasExecutingRef.current && !isExecuting) { + // Run just finished — show logs if the user isn't mid-edit + if (activeTab !== 'editor' && activeTab !== 'copilot') { + setActiveTab('logs') + if (!isPanelOpen) setIsPanelOpen(true) } - - const workflowVariables = useVariablesStore - .getState() - .getVariablesByWorkflowId(activeWorkflowId) - - const jsonContent = generateWorkflowJson(workflow.state, { - workflowId: activeWorkflowId, - name: currentWorkflow.name, - description: currentWorkflow.description, - variables: workflowVariables.map((v) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })), - }) - - const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json` - downloadFile(jsonContent, filename, 'application/json') - logger.info('Workflow exported as JSON') - } catch (error) { - logger.error('Failed to export workflow as JSON:', error) - } finally { - setIsExporting(false) - setIsMenuOpen(false) - } - }, [currentWorkflow, activeWorkflowId, downloadFile]) - - /** - * Handles duplicating the current workflow - */ - const handleDuplicateWorkflow = useCallback(async () => { - if (!activeWorkflowId || !userPermissions.canEdit || isDuplicating) { - return } + wasExecutingRef.current = isExecuting + }, [isExecuting, activeTab, isPanelOpen, setActiveTab, setIsPanelOpen]) - const sourceWorkflow = workflows[activeWorkflowId] - if (!sourceWorkflow) return - - setIsDuplicating(true) - try { - const result = await duplicateWorkflowMutation.mutateAsync({ - workspaceId, - sourceId: activeWorkflowId, - name: `${sourceWorkflow.name} (Copy)`, - description: sourceWorkflow.description, - color: sourceWorkflow.color ?? '', - folderId: sourceWorkflow.folderId, - }) - if (result?.id) { - router.push(`/workspace/${workspaceId}/w/${result.id}`) - } - } catch (error) { - logger.error('Error duplicating workflow:', error) - } finally { - setIsDuplicating(false) - setIsMenuOpen(false) + const handleTabClick = (tab: PanelTab) => { + if (!isPanelOpen) { + setIsPanelOpen(true) } - }, [activeWorkflowId, userPermissions.canEdit, isDuplicating, workflows, router, workspaceId]) - - /** - * Toggles the locked state of all blocks in the workflow - */ - const handleToggleWorkflowLock = useCallback(() => { - const blocks = useWorkflowStore.getState().blocks - const allLocked = Object.values(blocks).every((b) => b.locked) - const ids = getWorkflowLockToggleIds(blocks, !allLocked) - if (ids.length > 0) collaborativeBatchToggleLocked(ids) - setIsMenuOpen(false) - }, [collaborativeBatchToggleLocked]) - - // Compute run button state - const canRun = userPermissions.canRead // Running only requires read permissions - const isLoadingPermissions = userPermissions.isLoading - const hasValidationErrors = false // TODO: Add validation logic if needed - const isWorkflowBlocked = isExecuting || hasValidationErrors - const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions)) + setActiveTab(tab) + } /** - * Register global keyboard shortcuts using the central commands registry. - * - * - Mod+Enter: Run / cancel workflow (matches the Run button behavior) + * Register global keyboard shortcuts. + * - Mod+Enter: Run / cancel workflow * - Mod+F: Focus Toolbar tab and search input + * - Mod+L: Toggle Logs tab */ useRegisterGlobalCommands(() => createCommands([ @@ -595,6 +387,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel { id: 'focus-toolbar-search', handler: () => { + if (!isPanelOpen) setIsPanelOpen(true) setActiveTab('toolbar') toolbarRef.current?.focusSearch() }, @@ -605,274 +398,353 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel ]) ) - return ( - <> - - - {/* Delete Confirmation Modal */} - - - Delete Workflow - -

- Are you sure you want to delete{' '} - - {currentWorkflow?.name ?? 'this workflow'} - - ?{' '} - - All associated blocks, executions, and configuration will be removed. - {' '} - - You can restore it from Recently Deleted in Settings. - -

-
- - - - -
-
- - {/* Floating Variables Modal */} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 9272103bab7..6b8cf7165dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -93,7 +93,8 @@ const hasCanceledInTree = (nodes: EntryNode[]) => hasMatchInTree(nodes, (e) => Boolean(e.isCanceled)) /** - * Block row component for displaying actual block entries + * Block row component for displaying actual block entries. + * Click to select and view input/output in the output panel. */ const BlockRow = memo(function BlockRow({ entry, @@ -132,25 +133,36 @@ const BlockRow = memo(function BlockRow({ {entry.blockName} - + {entry.startedAt && ( + + {new Date(entry.startedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + )} - > - - + + + + ) }) @@ -553,15 +565,15 @@ function TerminalLogListRow({ if (row.rowType === 'separator') { return ( -
-
+
+
) } return ( -
-
+
+
(null) const prevWorkflowEntriesLengthRef = useRef(0) const hasInitializedEntriesRef = useRef(false) @@ -1250,14 +1270,21 @@ export const Terminal = memo(function Terminal() { const handleResize = () => { if (!selectedEntry) return - const sidebarWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' - ) - const panelWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' - ) + let terminalWidth: number + + if (isPanelMode && terminalRef.current) { + // In panel mode, use the terminal's own width (it's inside the panel) + terminalWidth = terminalRef.current.clientWidth + } else { + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + terminalWidth = window.innerWidth - sidebarWidth - panelWidth + } - const terminalWidth = window.innerWidth - sidebarWidth - panelWidth const maxWidth = terminalWidth - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX // Close output panel if there's not enough space for minimum width @@ -1296,8 +1323,11 @@ export const Terminal = memo(function Terminal() { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts index 545eb662ba6..2c9bb7d76cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -747,5 +747,5 @@ export const TERMINAL_CONFIG = { NEAR_MIN_THRESHOLD: 40, BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH, HEADER_TEXT_CLASS: 'font-base text-[var(--text-icon)] text-small', - LOG_ROW_HEIGHT_PX: 32, + LOG_ROW_HEIGHT_PX: 30, } as const diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/index.ts new file mode 100644 index 00000000000..34b55642aef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/index.ts @@ -0,0 +1 @@ +export { WorkflowActions } from './workflow-actions' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx new file mode 100644 index 00000000000..bddbf8f286e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx @@ -0,0 +1,334 @@ +'use client' + +import { memo, useCallback, useState } from 'react' +import { createLogger } from '@sim/logger' +import { useParams, useRouter } from 'next/navigation' +import { useShallow } from 'zustand/react/shallow' +import { + Button, + Copy, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Layout, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + MoreHorizontal, + Tooltip, + Trash, + Upload, +} from '@/components/emcn' +import { Lock, Unlock } from '@/components/emcn/icons' +import { VariableIcon } from '@/components/icons' +import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { WorkflowHistory } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history' +import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout' +import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' +import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' +import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useNotificationStore } from '@/stores/notifications/store' +import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel' +import { useVariablesStore } from '@/stores/variables/store' +import { getWorkflowWithValues } from '@/stores/workflows' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const logger = createLogger('WorkflowActions') + +/** + * Vertical floating toolbar on the left side of the canvas. + * Primary actions (auto layout, variables, history) are direct buttons. + * Secondary actions (lock, export, duplicate, delete) are behind a three-dots menu. + */ +interface WorkflowActionsProps { + workspaceId?: string +} + +export const WorkflowActions = memo(function WorkflowActions({ + workspaceId: propWorkspaceId, +}: WorkflowActionsProps) { + const router = useRouter() + const params = useParams() + const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + + const [isAutoLayouting, setIsAutoLayouting] = useState(false) + const [isExporting, setIsExporting] = useState(false) + const [isDuplicating, setIsDuplicating] = useState(false) + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isHistoryOpen, setIsHistoryOpen] = useState(false) + const [isMoreOpen, setIsMoreOpen] = useState(false) + + const userPermissions = useUserPermissionsContext() + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { activeWorkflowId } = useWorkflowRegistry( + useShallow((state) => ({ activeWorkflowId: state.activeWorkflowId })) + ) + const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null) + const duplicateWorkflowMutation = useDuplicateWorkflowMutation() + const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow() + const { isExecuting } = useWorkflowExecution() + const { isSnapshotView } = useCurrentWorkflow() + const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null + + const hasLockedBlocks = useWorkflowStore((state) => + Object.values(state.blocks).some((block) => block.locked) + ) + const allBlocksLocked = useWorkflowStore((state) => { + const blockList = Object.values(state.blocks) + return blockList.length > 0 && blockList.every((block) => block.locked) + }) + const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0) + + const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({ + workspaceId, + workflowIds: activeWorkflowId || '', + isActive: true, + onSuccess: () => setIsDeleteModalOpen(false), + }) + + const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore( + useShallow((state) => ({ + isOpen: state.isOpen, + setIsOpen: state.setIsOpen, + })) + ) + + const handleAutoLayout = useCallback(async () => { + if (isExecuting || !userPermissions.canEdit || isAutoLayouting) return + setIsAutoLayouting(true) + try { + const result = await autoLayoutWithFitView() + if (!result.success && result.error) { + useNotificationStore.getState().addNotification({ + level: 'info', + message: result.error, + workflowId: activeWorkflowId || undefined, + }) + } + } finally { + setIsAutoLayouting(false) + } + }, [ + isExecuting, + userPermissions.canEdit, + isAutoLayouting, + autoLayoutWithFitView, + activeWorkflowId, + ]) + + const downloadFile = useCallback((content: string, filename: string, mimeType: string) => { + try { + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } catch (error) { + logger.error('Failed to download file:', error) + } + }, []) + + const handleExportJson = useCallback(async () => { + if (!currentWorkflow || !activeWorkflowId) return + setIsExporting(true) + try { + const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId) + if (!workflow || !workflow.state) throw new Error('No workflow state found') + const workflowVariables = usePanelVariablesStore + .getState() + .getVariablesByWorkflowId(activeWorkflowId) + const jsonContent = generateWorkflowJson(workflow.state, { + workflowId: activeWorkflowId, + name: currentWorkflow.name, + description: currentWorkflow.description, + variables: workflowVariables.map((v) => ({ + id: v.id, + name: v.name, + type: v.type, + value: v.value, + })), + }) + const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json` + downloadFile(jsonContent, filename, 'application/json') + } catch (error) { + logger.error('Failed to export workflow as JSON:', error) + } finally { + setIsExporting(false) + setIsMoreOpen(false) + } + }, [currentWorkflow, activeWorkflowId, downloadFile, workspaceId]) + + const handleDuplicateWorkflow = useCallback(async () => { + if (!activeWorkflowId || !userPermissions.canEdit || isDuplicating) return + const sourceWorkflow = workflows[activeWorkflowId] + if (!sourceWorkflow) return + setIsDuplicating(true) + try { + const result = await duplicateWorkflowMutation.mutateAsync({ + workspaceId, + sourceId: activeWorkflowId, + name: `${sourceWorkflow.name} (Copy)`, + description: sourceWorkflow.description, + color: sourceWorkflow.color ?? '', + folderId: sourceWorkflow.folderId, + }) + if (result?.id) router.push(`/workspace/${workspaceId}/w/${result.id}`) + } catch (error) { + logger.error('Error duplicating workflow:', error) + } finally { + setIsDuplicating(false) + setIsMoreOpen(false) + } + }, [ + activeWorkflowId, + userPermissions.canEdit, + isDuplicating, + workflows, + router, + workspaceId, + duplicateWorkflowMutation, + ]) + + const handleToggleWorkflowLock = useCallback(() => { + const blocks = useWorkflowStore.getState().blocks + const allLocked = Object.values(blocks).every((b) => b.locked) + const ids = getWorkflowLockToggleIds(blocks, !allLocked) + if (ids.length > 0) collaborativeBatchToggleLocked(ids) + setIsMoreOpen(false) + }, [collaborativeBatchToggleLocked]) + + return ( + <> +
+ {/* Auto layout */} + + + + + Auto layout + + + {/* Variables */} + + + + + Variables + + + {/* History */} + + +
+ + {/* More actions */} + + + + + + + + {!isMoreOpen && More actions} + + + {userPermissions.canAdmin && !isSnapshotView && ( + + {allBlocksLocked ? : } + {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} + + )} + + + Export workflow + + + + Duplicate workflow + + setIsDeleteModalOpen(true)} + disabled={!userPermissions.canEdit || Object.keys(workflows).length <= 1} + > + + Delete workflow + + + +
+ + {/* Delete Confirmation Modal */} + + + Delete Workflow + +

+ Are you sure you want to delete{' '} + + {currentWorkflow?.name ?? 'this workflow'} + + ?{' '} + + All associated blocks, executions, and configuration will be removed. + {' '} + + You can restore it from Recently Deleted in Settings. + +

+
+ + + + +
+
+ + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index 29873d45ab7..881b8b10424 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Scan } from 'lucide-react' -import { useReactFlow } from 'reactflow' +import { useReactFlow, useViewport } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import { Button, @@ -18,6 +18,8 @@ import { Redo, Tooltip, Undo, + ZoomIn, + ZoomOut, } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' @@ -32,10 +34,11 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowControls') /** - * Floating controls for canvas mode, undo/redo, and fit-to-view. + * Floating controls for canvas mode, undo/redo, zoom, and fit-to-view. */ export const WorkflowControls = memo(function WorkflowControls() { const reactFlowInstance = useReactFlow() + const { zoom } = useViewport() const { fitViewToBounds } = useCanvasViewport(reactFlowInstance) const { mode, setMode } = useCanvasModeStore( useShallow((s) => ({ mode: s.mode, setMode: s.setMode })) @@ -52,10 +55,20 @@ export const WorkflowControls = memo(function WorkflowControls() { const canUndo = stack.undo.length > 0 const canRedo = stack.redo.length > 0 + const zoomPercentage = Math.round(zoom * 100) + const handleFitToView = useCallback(() => { fitViewToBounds({ padding: 0.1, duration: 300 }) }, [fitViewToBounds]) + const handleZoomIn = useCallback(() => { + reactFlowInstance.zoomIn({ duration: 200 }) + }, [reactFlowInstance]) + + const handleZoomOut = useCallback(() => { + reactFlowInstance.zoomOut({ duration: 200 }) + }, [reactFlowInstance]) + useRegisterGlobalCommands([ createCommand({ id: 'fit-to-view', @@ -193,6 +206,39 @@ export const WorkflowControls = memo(function WorkflowControls() { Fit to View + +
+ + {/* Zoom controls */} + + + + + Zoom Out + + + + {zoomPercentage}% + + + + + + + Zoom In +
state.activeWorkflowId) + const { isExecuting, isSourceActive } = useExecutionStore( + useShallow((state) => { + const wf = activeWorkflowId ? state.workflowExecutions.get(activeWorkflowId) : undefined + return { + isExecuting: wf?.isExecuting ?? false, + isSourceActive: wf?.activeBlockIds.has(source) ?? false, + } + }) + ) + const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error' const previewExecutionStatus = ( @@ -84,6 +97,12 @@ const WorkflowEdgeComponent = ({ targetHandle, ]) + // Determine whether to show the flow animation: + // - Edge just ran successfully (data flowed through) + // - OR the source block is currently active (data is in transit) + const showFlowAnimation = + !edgeDiffStatus && (edgeRunStatus === 'success' || (isExecuting && isSourceActive)) + const edgeStyle = useMemo(() => { let color = 'var(--workflow-edge)' let opacity = 1 @@ -126,6 +145,20 @@ const WorkflowEdgeComponent = ({ <> + {/* Animated flow overlay — subtle dashes that march along the edge path */} + {showFlowAnimation && ( + + )} + {isSelected && (
void +} + +/** + * Popover showing browser-local workflow change history. + * Each entry is a full state snapshot captured automatically when the user + * modifies the workflow. Clicking an entry restores the workflow to that state. + * + * History persists across page reloads via localStorage. + */ +export const WorkflowHistory = memo(function WorkflowHistory({ + open, + onOpenChange, +}: WorkflowHistoryProps) { + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const snapshots = useWorkflowHistoryStore((state) => + activeWorkflowId ? state.getSnapshots(activeWorkflowId) : [] + ) + const restoreSnapshot = useWorkflowHistoryStore((state) => state.restoreSnapshot) + const clearHistory = useWorkflowHistoryStore((state) => state.clearHistory) + + const handleRestore = useCallback( + (snapshot: WorkflowSnapshot) => { + if (!activeWorkflowId) return + restoreSnapshot(activeWorkflowId, snapshot.id) + onOpenChange(false) + }, + [activeWorkflowId, restoreSnapshot, onOpenChange] + ) + + const handleClear = useCallback(() => { + if (!activeWorkflowId) return + clearHistory(activeWorkflowId) + }, [activeWorkflowId, clearHistory]) + + return ( + + + + + + + {!open && Change history} + + + + {snapshots.length === 0 ? ( +
+ + No changes yet + + History is saved automatically as you edit + +
+ ) : ( + <> + + Recent Changes + {snapshots.map((snapshot) => ( + handleRestore(snapshot)}> + + {snapshot.label} + + {formatTime(snapshot.timestamp)} + + + ))} + + +
+ + + Clear history + +
+ + )} +
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx new file mode 100644 index 00000000000..71a923cb89c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx @@ -0,0 +1,98 @@ +'use client' + +import { memo, useCallback } from 'react' +import { Square } from 'lucide-react' +import { useParams } from 'next/navigation' +import { useShallow } from 'zustand/react/shallow' +import { Button, Play, Tooltip } from '@/components/emcn' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components' +import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' +import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +/** + * Compact floating toolbar at the top-right of the canvas. + * Contains only the two primary workflow actions: Run and Deploy. + * + * All secondary actions (auto layout, variables, history, lock, export, + * duplicate, delete) live in the left-side WorkflowActions toolbar. + */ +interface WorkflowToolbarProps { + workspaceId?: string +} + +export const WorkflowToolbar = memo(function WorkflowToolbar({ + workspaceId: propWorkspaceId, +}: WorkflowToolbarProps) { + const params = useParams() + const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + + const userPermissions = useUserPermissionsContext() + const { activeWorkflowId, hydration } = useWorkflowRegistry( + useShallow((state) => ({ + activeWorkflowId: state.activeWorkflowId, + hydration: state.hydration, + })) + ) + const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading' + const { navigateToSettings } = useSettingsNavigation() + + const { usageExceeded } = useUsageLimits({ + context: 'user', + autoRefresh: !isRegistryLoading, + }) + + const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution() + + const cancelWorkflow = useCallback(async () => { + await handleCancelExecution() + }, [handleCancelExecution]) + + const runWorkflow = useCallback(async () => { + if (usageExceeded) { + navigateToSettings({ section: 'subscription' }) + return + } + await handleRunWorkflow() + }, [usageExceeded, handleRunWorkflow, navigateToSettings]) + + const canRun = userPermissions.canRead + const isLoadingPermissions = userPermissions.isLoading + const isButtonDisabled = !isExecuting && (isExecuting || (!canRun && !isLoadingPermissions)) + + return ( +
+ {/* Run (secondary) */} + + + + + + Run workflow + + + +
+ + {/* Deploy (primary CTA) */} +
+ +
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 0a8c196c60c..6e539855147 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1,12 +1,15 @@ 'use client' -import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import ReactFlow, { applyNodeChanges, + Background, + BackgroundVariant, ConnectionLineType, type Edge, type EdgeTypes, + MiniMap, type Node, type NodeChange, type NodeTypes, @@ -31,7 +34,6 @@ import { Notifications, Panel, SubflowNodeComponent, - Terminal, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' import { BlockMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu' import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu' @@ -39,9 +41,11 @@ import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block' import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' +import { WorkflowActions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' +import { WorkflowToolbar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar' import { useAutoLayout, useCanvasContextMenu, @@ -92,13 +96,6 @@ import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' -/** Lazy-loaded components for non-critical UI that can load after initial render */ -const LazyChat = lazy(() => - import('@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat').then((mod) => ({ - default: mod.Chat, - })) -) - const logger = createLogger('Workflow') const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 } @@ -200,7 +197,7 @@ const reactFlowStyles = [ '[&_.react-flow__edge-labels]:!z-[1001]', '[&_.react-flow__pane]:select-none', '[&_.react-flow__selectionpane]:select-none', - '[&_.react-flow__background]:hidden', + '[&_.react-flow__node-subflowNode.selected]:!shadow-none', ].join(' ') const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const @@ -4042,17 +4039,33 @@ const WorkflowContent = React.memo( elevateNodesOnSelect={false} autoPanOnConnect={effectivePermissions.canEdit} autoPanOnNodeDrag={effectivePermissions.canEdit} - /> + > + + {!embedded && ( <> + + - - - - + } -
- + {(!embedded || sandbox) && } +
- {(!embedded || sandbox) && } - {!embedded && !sandbox && oauthModal && (
-
) } diff --git a/apps/sim/components/emcn/components/button/button.tsx b/apps/sim/components/emcn/components/button/button.tsx index d04dfcd47a4..e6af70c5c11 100644 --- a/apps/sim/components/emcn/components/button/button.tsx +++ b/apps/sim/components/emcn/components/button/button.tsx @@ -25,6 +25,8 @@ const buttonVariants = cva( subtle: 'text-[var(--text-body)] hover-hover:text-[var(--text-body)] hover-hover:bg-[var(--surface-4)]', 'ghost-secondary': 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]', + brand: + 'bg-[var(--brand)] text-white hover-hover:text-white hover-hover:bg-[var(--brand-hover)] font-semibold', }, size: { sm: 'px-1.5 py-1 text-[length:11px]', diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index 5e7d0c74015..a0033a05b50 100644 --- a/apps/sim/stores/panel/store.ts +++ b/apps/sim/stores/panel/store.ts @@ -29,6 +29,10 @@ export const usePanelStore = create()( document.documentElement.removeAttribute('data-panel-active-tab') } }, + isPanelOpen: true, + setIsPanelOpen: (isOpen) => { + set({ isPanelOpen: isOpen }) + }, isResizing: false, setIsResizing: (isResizing) => { set({ isResizing }) diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 5d0bf4eca21..5a21f21050a 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -1,7 +1,7 @@ /** * Available panel tabs */ -export type PanelTab = 'copilot' | 'editor' | 'toolbar' +export type PanelTab = 'copilot' | 'editor' | 'toolbar' | 'logs' /** * Panel state interface @@ -11,6 +11,10 @@ export interface PanelState { setPanelWidth: (width: number) => void activeTab: PanelTab setActiveTab: (tab: PanelTab) => void + /** Whether the panel sidebar is open (visible) */ + isPanelOpen: boolean + /** Toggles or sets the panel sidebar open state */ + setIsPanelOpen: (isOpen: boolean) => void /** Whether the panel is currently being resized */ isResizing: boolean /** Updates the panel resize state */ diff --git a/apps/sim/stores/workflow-history/index.ts b/apps/sim/stores/workflow-history/index.ts new file mode 100644 index 00000000000..ce43fb3c0a1 --- /dev/null +++ b/apps/sim/stores/workflow-history/index.ts @@ -0,0 +1,2 @@ +export { useWorkflowHistoryStore } from './store' +export type { WorkflowHistoryState, WorkflowSnapshot } from './types' diff --git a/apps/sim/stores/workflow-history/store.ts b/apps/sim/stores/workflow-history/store.ts new file mode 100644 index 00000000000..f78291f5fc3 --- /dev/null +++ b/apps/sim/stores/workflow-history/store.ts @@ -0,0 +1,228 @@ +import { createLogger } from '@sim/logger' +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { useUndoRedoStore } from '@/stores/undo-redo' +import type { UndoRedoState } from '@/stores/undo-redo/types' +import type { WorkflowHistoryState, WorkflowSnapshot } from '@/stores/workflow-history/types' +import { MAX_SNAPSHOTS_PER_WORKFLOW, MAX_TRACKED_WORKFLOWS } from '@/stores/workflow-history/types' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const logger = createLogger('WorkflowHistoryStore') + +/** + * Maps undo/redo operation types to human-readable labels for snapshots. + */ +const OPERATION_LABELS: Record = { + 'batch-add-blocks': 'Added blocks', + 'batch-remove-blocks': 'Removed blocks', + 'batch-add-edges': 'Added connections', + 'batch-remove-edges': 'Removed connections', + 'batch-move-blocks': 'Moved blocks', + 'update-parent': 'Moved to subflow', + 'batch-update-parent': 'Moved to subflow', + 'batch-toggle-enabled': 'Toggled enabled', + 'batch-toggle-handles': 'Toggled handles', + 'batch-toggle-locked': 'Toggled locked', + 'apply-diff': 'Applied changes', + 'accept-diff': 'Accepted changes', + 'reject-diff': 'Rejected changes', +} + +/** + * Generates a short unique id for snapshots. + */ +function generateSnapshotId(): string { + return `snap_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}` +} + +/** + * Captures the current sub-block values for all blocks in the workflow. + * Reads directly from the sub-block store's internal workflowValues map. + */ +function captureSubBlockValues(workflowId: string): Record> { + const workflowValues = useSubBlockStore.getState().workflowValues + const values = workflowValues[workflowId] + if (!values) return {} + // Deep clone to avoid reference sharing + return JSON.parse(JSON.stringify(values)) +} + +/** + * Enforces the LRU limit on tracked workflows. + * Keeps only the N most recently updated workflows. + */ +function enforceLRU( + snapshots: Record +): Record { + const workflowIds = Object.keys(snapshots) + if (workflowIds.length <= MAX_TRACKED_WORKFLOWS) return snapshots + + // Sort by most recent snapshot timestamp (descending) + const sorted = workflowIds + .map((id) => ({ + id, + latest: snapshots[id]?.[0]?.timestamp ?? '', + })) + .sort((a, b) => b.latest.localeCompare(a.latest)) + + const keepers = new Set(sorted.slice(0, MAX_TRACKED_WORKFLOWS).map((s) => s.id)) + const pruned: Record = {} + for (const id of keepers) { + pruned[id] = snapshots[id] + } + return pruned +} + +export const useWorkflowHistoryStore = create()( + devtools( + persist( + (set, get) => ({ + snapshots: {}, + + captureSnapshot: (workflowId: string, label: string) => { + try { + const workflowState = useWorkflowStore.getState().getWorkflowState() + const blocks = workflowState.blocks + const edges = workflowState.edges + + // Skip if no blocks (empty workflow) + if (Object.keys(blocks).length === 0) return + + const subBlockValues = captureSubBlockValues(workflowId) + + const snapshot: WorkflowSnapshot = { + id: generateSnapshotId(), + timestamp: new Date().toISOString(), + label, + blocks: JSON.parse(JSON.stringify(blocks)), + edges: JSON.parse(JSON.stringify(edges)), + subBlockValues, + } + + set((state) => { + const existing = state.snapshots[workflowId] ?? [] + + // Deduplicate: skip if the latest snapshot has the same block count, + // edge count, and label within the last 2 seconds + if (existing.length > 0) { + const latest = existing[0] + const timeDiff = Date.now() - new Date(latest.timestamp).getTime() + if ( + timeDiff < 2000 && + latest.label === label && + Object.keys(latest.blocks).length === Object.keys(blocks).length && + latest.edges.length === edges.length + ) { + return state + } + } + + const updated = [snapshot, ...existing].slice(0, MAX_SNAPSHOTS_PER_WORKFLOW) + + return { + snapshots: enforceLRU({ + ...state.snapshots, + [workflowId]: updated, + }), + } + }) + } catch (error) { + logger.error('Failed to capture workflow snapshot', { workflowId, error }) + } + }, + + restoreSnapshot: (workflowId: string, snapshotId: string): boolean => { + try { + const snapshots = get().snapshots[workflowId] ?? [] + const snapshot = snapshots.find((s) => s.id === snapshotId) + if (!snapshot) { + logger.warn('Snapshot not found', { workflowId, snapshotId }) + return false + } + + // Capture current state as a "before restore" snapshot + get().captureSnapshot(workflowId, 'Before restore') + + // Restore workflow state + const workflowStore = useWorkflowStore.getState() + workflowStore.replaceWorkflowState({ + blocks: JSON.parse(JSON.stringify(snapshot.blocks)), + edges: JSON.parse(JSON.stringify(snapshot.edges)), + loops: {}, + parallels: {}, + }) + + // Restore sub-block values (setValue reads active workflowId internally) + const subBlockStore = useSubBlockStore.getState() + for (const [blockId, values] of Object.entries(snapshot.subBlockValues)) { + for (const [subBlockId, value] of Object.entries(values as Record)) { + subBlockStore.setValue(blockId, subBlockId, value) + } + } + + logger.info('Restored workflow to snapshot', { + workflowId, + snapshotId, + label: snapshot.label, + }) + return true + } catch (error) { + logger.error('Failed to restore workflow snapshot', { workflowId, snapshotId, error }) + return false + } + }, + + getSnapshots: (workflowId: string): WorkflowSnapshot[] => { + return get().snapshots[workflowId] ?? [] + }, + + clearHistory: (workflowId: string) => { + set((state) => { + const { [workflowId]: _, ...rest } = state.snapshots + return { snapshots: rest } + }) + }, + + clearAllHistory: () => { + set({ snapshots: {} }) + }, + }), + { + name: 'workflow-history', + partialize: (state) => ({ snapshots: state.snapshots }), + } + ), + { name: 'workflow-history-store' } + ) +) + +/** + * Subscribe to the undo/redo store to automatically capture snapshots + * whenever a new operation is pushed onto the undo stack. + * + * This is the integration point — instead of modifying every push() call site, + * we listen for stack growth and snapshot the current state. + */ +if (typeof window !== 'undefined') { + const prevStackSizes: Record = {} + + useUndoRedoStore.subscribe((state: UndoRedoState) => { + for (const [key, stack] of Object.entries(state.stacks)) { + const prevSize = prevStackSizes[key] ?? 0 + const currentSize = stack.undo.length + + // A new entry was pushed (stack grew) + if (currentSize > prevSize && currentSize > 0) { + const latestEntry = stack.undo[stack.undo.length - 1] + if (latestEntry) { + const workflowId = key.split(':')[0] + const label = OPERATION_LABELS[latestEntry.operation.type] ?? 'Changed workflow' + useWorkflowHistoryStore.getState().captureSnapshot(workflowId, label) + } + } + + prevStackSizes[key] = currentSize + } + }) +} diff --git a/apps/sim/stores/workflow-history/types.ts b/apps/sim/stores/workflow-history/types.ts new file mode 100644 index 00000000000..782895a193c --- /dev/null +++ b/apps/sim/stores/workflow-history/types.ts @@ -0,0 +1,65 @@ +import type { Edge } from 'reactflow' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * A point-in-time snapshot of the workflow graph state. + * Stored in the browser via localStorage. + */ +export interface WorkflowSnapshot { + /** Unique identifier for this snapshot */ + id: string + /** ISO timestamp when this snapshot was captured */ + timestamp: string + /** Human-readable label describing what changed */ + label: string + /** Block states including merged sub-block values */ + blocks: Record + /** Edge connections */ + edges: Edge[] + /** Sub-block values keyed by blockId → subBlockId → value */ + subBlockValues: Record> +} + +/** Maximum number of snapshots stored per workflow */ +export const MAX_SNAPSHOTS_PER_WORKFLOW = 50 + +/** Maximum number of workflows to track history for (LRU) */ +export const MAX_TRACKED_WORKFLOWS = 5 + +export interface WorkflowHistoryState { + /** + * Snapshots keyed by workflowId. + * Each value is an array sorted newest-first (index 0 = most recent). + */ + snapshots: Record + + /** + * Captures a snapshot of the current workflow state. + * @param workflowId - The workflow to snapshot + * @param label - What changed (e.g. "Added blocks", "Moved blocks") + */ + captureSnapshot: (workflowId: string, label: string) => void + + /** + * Restores a workflow to a specific snapshot. + * @param workflowId - The workflow to restore + * @param snapshotId - The snapshot to restore to + * @returns true if restored successfully + */ + restoreSnapshot: (workflowId: string, snapshotId: string) => boolean + + /** + * Returns snapshots for a workflow (newest first). + */ + getSnapshots: (workflowId: string) => WorkflowSnapshot[] + + /** + * Clears all history for a workflow. + */ + clearHistory: (workflowId: string) => void + + /** + * Clears all history for all workflows. + */ + clearAllHistory: () => void +} From a1ee666f4606ae9a301ecb23bc2310655d1a0f2d Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Sat, 4 Apr 2026 22:05:11 +0530 Subject: [PATCH 2/6] chore: fix conflicts --- apps/sim/app/_styles/globals.css | 4 + .../credentials/credentials-manager.tsx | 206 ++++--- .../copilot-input/copilot-input.tsx | 96 +++ .../w/[workflowId]/components/index.ts | 1 + .../general/components/versions.tsx | 9 +- .../w/[workflowId]/components/panel/panel.tsx | 16 + .../components/terminal/terminal.tsx | 227 +++++-- .../workflow-actions/workflow-actions.tsx | 15 - .../workflow-controls/workflow-controls.tsx | 2 +- .../workflow-toolbar/workflow-history.tsx | 571 ++++++++++++++++-- .../workflow-toolbar/workflow-toolbar.tsx | 25 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 6 +- apps/sim/stores/panel/editor/store.ts | 4 +- apps/sim/stores/panel/store.ts | 12 + apps/sim/stores/panel/types.ts | 4 + apps/sim/stores/variables/store.ts | 25 +- apps/sim/stores/variables/types.ts | 2 + 17 files changed, 998 insertions(+), 227 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index eaebf8a70e1..ddb0d181485 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -991,3 +991,7 @@ input[type="search"]::-ms-clear { .react-flow__node[data-parent-node-id] .react-flow__handle { z-index: 30; } + +.react-flow__panel { + margin: 0 !important; +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index 21ae97ca049..30fe6d72186 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Check, Clipboard, Key, Search } from 'lucide-react' +import { Check, Clipboard, Info, Key, Search, Shield, UserPlus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { Avatar, @@ -1188,44 +1188,78 @@ export function CredentialsManager() {
)} -
- +
+ {/* Header */} +
+
+ +
+
+
+

+ Access Control +

+ + {activeMembers.length} {activeMembers.length === 1 ? 'member' : 'members'} + +
+

+ Only workspace members listed below can view and use this secret in their + workflows. Admins can manage access; members can only use the secret. +

+
+
+ {/* Member list */} {membersLoading ? ( -
- - +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
) : ( -
- {activeMembers.map((member) => ( +
+ {activeMembers.map((member, index) => (
0 ? 'border-t border-[var(--border)]' : '' + }`} > -
- - - {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} - - -
-

- {member.userName || member.userEmail || member.userId} -

-

- {member.userEmail || member.userId} -

-
+ + + {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} + + +
+

+ {member.userName || member.userEmail || member.userId} +

+

+ {member.userEmail || member.userId} +

{isSelectedAdmin ? ( - <> +
({ value: option.value, @@ -1250,55 +1284,85 @@ export function CredentialsManager() { variant='ghost' onClick={() => handleRemoveMember(member.userId)} disabled={member.role === 'admin' && adminMemberCount <= 1} - className='w-full justify-end' + className='h-7 px-2 text-[var(--text-tertiary)] text-caption hover-hover:text-[var(--text-error)]' > Remove - +
) : ( - <> - {member.role} -
- + + {member.role === 'admin' ? 'Admin' : 'Member'} + )}
))} + + {/* Add member row */} {isSelectedAdmin && ( -
- option.value === memberUserId) - ?.label || '' - } - selectedValue={memberUserId} - onChange={setMemberUserId} - placeholder='Add member...' - searchable - searchPlaceholder='Search members...' - size='sm' - /> - ({ - value: option.value, - label: option.label, - }))} - value={ - ROLE_OPTIONS.find((option) => option.value === memberRole)?.label || '' - } - selectedValue={memberRole} - onChange={(value) => setMemberRole(value as WorkspaceCredentialRole)} - placeholder='Role' - size='sm' - /> - +
+
+ +

+ Grant access to a workspace member +

+
+
+
+ option.value === memberUserId) + ?.label || '' + } + selectedValue={memberUserId} + onChange={setMemberUserId} + placeholder='Select workspace member...' + searchable + searchPlaceholder='Search workspace members...' + emptyMessage='No workspace members available. Invite members to the workspace first.' + size='sm' + /> +
+
+ ({ + value: option.value, + label: option.label, + }))} + value={ + ROLE_OPTIONS.find((option) => option.value === memberRole)?.label || + '' + } + selectedValue={memberRole} + onChange={(value) => setMemberRole(value as WorkspaceCredentialRole)} + placeholder='Role' + size='sm' + /> +
+ +
+

+ + Only members of this workspace appear here. To add someone new, invite + them to the workspace first. +

+
+ )} + + {/* Non-admin notice */} + {!isSelectedAdmin && ( +
+ +

+ Only admins of this secret can manage access control. +

)}
@@ -1307,7 +1371,7 @@ export function CredentialsManager() {
-
+
+
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts index c2b9652c708..9cc21cde6e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts @@ -1,6 +1,7 @@ export { BlockMenu } from './block-menu' export { CanvasMenu } from './canvas-menu' export { CommandList } from './command-list/command-list' +export { CopilotInput } from './copilot-input/copilot-input' export { Cursors } from './cursors/cursors' export { DiffControls } from './diff-controls/diff-controls' export { ErrorBoundary } from './error/index' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 82c09c5817c..825a1aa12ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' -import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react' +import { MoreVertical, NotepadText, Pencil, RotateCcw, SendToBack } from 'lucide-react' import { Button, Popover, @@ -304,7 +304,7 @@ export function Versions({ )} onClick={() => handleOpenDescriptionModal(v.version)} > - + @@ -329,10 +329,7 @@ export function Versions({ Rename - handleOpenDescriptionModal(v.version)}> - - {v.description ? 'Edit description' : 'Add description'} - + {!v.isActive && ( handlePromote(v.version)}> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 72764ca96d0..2b6bf241117 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -78,6 +78,8 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsPanelOpen, _hasHydrated, setHasHydrated, + pendingCopilotMessage, + setPendingCopilotMessage, } = usePanelStore( useShallow((state) => ({ activeTab: state.activeTab, @@ -87,6 +89,8 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsPanelOpen: state.setIsPanelOpen, _hasHydrated: state._hasHydrated, setHasHydrated: state.setHasHydrated, + pendingCopilotMessage: state.pendingCopilotMessage, + setPendingCopilotMessage: state.setPendingCopilotMessage, })) ) const toolbarRef = useRef<{ @@ -321,6 +325,18 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotSendMessage] ) + /** + * Auto-submit a pending copilot message that was queued from the floating input. + * Consumes the message immediately so it only fires once. + */ + useEffect(() => { + if (pendingCopilotMessage && isPanelOpen && activeTab === 'copilot') { + const message = pendingCopilotMessage + setPendingCopilotMessage(null) + handleCopilotSubmit(message) + } + }, [pendingCopilotMessage, isPanelOpen, activeTab, setPendingCopilotMessage, handleCopilotSubmit]) + /** * Mark hydration as complete on mount */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 6b8cf7165dc..ad91436324a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -61,7 +61,6 @@ import { sendMothershipMessage } from '@/stores/notifications/utils' import type { ConsoleEntry } from '@/stores/terminal' import { safeConsoleStringify, - useConsoleEntry, useTerminalConsoleStore, useTerminalStore, useWorkflowConsoleEntries, @@ -710,8 +709,10 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole) const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV) - const [selectedEntryId, setSelectedEntryId] = useState(null) - const selectedEntry = useConsoleEntry(selectedEntryId) + const [selectedEntry, setSelectedEntry] = useState(null) + const selectedEntryId = selectedEntry?.id ?? null + const [panelOutputHeight, setPanelOutputHeight] = useState(0) + const splitContainerRef = useRef(null) const [expandedNodes, setExpandedNodes] = useState>(() => new Set()) const [isToggling, setIsToggling] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) @@ -924,13 +925,19 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal const handleSelectEntry = useCallback( (entry: ConsoleEntry) => { focusTerminal() - setSelectedEntryId((prev) => { - // Disable auto-select on any manual selection/deselection - setAutoSelectEnabled(false) - return prev === entry.id ? null : entry.id - }) + setAutoSelectEnabled(false) + if (isPanelMode) { + setSelectedEntry(entry) + if (panelOutputHeight === 0) { + const container = splitContainerRef.current + const h = container ? Math.round(container.clientHeight * 0.4) : 200 + setPanelOutputHeight(h) + } + } else { + setSelectedEntry((prev) => (prev?.id === entry.id ? null : entry)) + } }, - [focusTerminal] + [focusTerminal, isPanelMode, panelOutputHeight] ) /** @@ -983,7 +990,7 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal const clearCurrentWorkflowConsole = useCallback(() => { if (activeWorkflowId) { clearWorkflowConsole(activeWorkflowId) - setSelectedEntryId(null) + setSelectedEntry(null) setExpandedNodes(new Set()) } }, [activeWorkflowId, clearWorkflowConsole]) @@ -1114,7 +1121,7 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal useEffect(() => { if (executionGroups.length === 0 || navigableEntries.length === 0) { setAutoSelectEnabled(true) - setSelectedEntryId(null) + setSelectedEntry(null) return } @@ -1134,7 +1141,7 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal if (!lastNavEntry) return if (selectedEntryId === lastNavEntry.entry.id) return - setSelectedEntryId(lastNavEntry.entry.id) + setSelectedEntry(lastNavEntry.entry) focusTerminal() if (lastNavEntry.parentNodeIds.length > 0) { @@ -1148,6 +1155,26 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal } }, [executionGroups, navigableEntries, autoSelectEnabled, selectedEntryId, focusTerminal]) + /** + * Sync selected entry with latest data from store. + */ + useEffect(() => { + if (!selectedEntry) return + const updatedEntry = filteredEntries.find((e) => e.id === selectedEntry.id) + if (updatedEntry && updatedEntry !== selectedEntry) { + const hasChanged = + updatedEntry.output !== selectedEntry.output || + updatedEntry.isRunning !== selectedEntry.isRunning || + updatedEntry.isCanceled !== selectedEntry.isCanceled || + updatedEntry.durationMs !== selectedEntry.durationMs || + updatedEntry.error !== selectedEntry.error || + updatedEntry.success !== selectedEntry.success + if (hasChanged) { + setSelectedEntry(updatedEntry) + } + } + }, [filteredEntries, selectedEntry]) + /** * Clear filters when there are no logs */ @@ -1163,7 +1190,7 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal const navigateToEntry = useCallback( (navEntry: NavigableBlockEntry) => { setAutoSelectEnabled(false) - setSelectedEntryId(navEntry.entry.id) + setSelectedEntry(navEntry.entry) // Auto-expand parent nodes (subflows, iterations) if (navEntry.parentNodeIds.length > 0) { @@ -1203,7 +1230,7 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal if (e.key === 'Escape') { if (currentEntry) { e.preventDefault() - setSelectedEntryId(null) + setSelectedEntry(null) setAutoSelectEnabled(true) } return @@ -1290,7 +1317,7 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal // Close output panel if there's not enough space for minimum width if (maxWidth < MIN_OUTPUT_PANEL_WIDTH_PX) { setAutoSelectEnabled(false) - setSelectedEntryId(null) + setSelectedEntry(null) return } @@ -1346,18 +1373,24 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal /> )} -
- {/* Left Section - Logs (hidden in panel mode when output is shown) */} +
+ {/* Top/Left Section - Logs */}
0 + ? { flex: '1 1 auto', minHeight: 60 } + : undefined + : selectedEntry + ? { width: `calc(100% - ${outputPanelWidth}px)` } + : undefined } > {/* Panel mode: compact filter bar */} @@ -1602,53 +1635,117 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal
- {/* Right Section - Block Output (Overlay in standalone, full-width in panel) */} - {selectedEntry && ( + {/* Bottom output panel — panel mode: collapsible, resizable */} + {isPanelMode && (
div:nth-child(2)]:!relative [&>div:nth-child(2)]:!w-full [&>div:nth-child(2)]:flex-1' - : undefined - } + className='flex flex-shrink-0 flex-col overflow-hidden border-t border-[var(--border)]' + style={{ height: panelOutputHeight > 0 ? `${panelOutputHeight}px` : '0px' }} > - {/* Back button in panel mode */} - {isPanelMode && ( - + {/* Drag handle */} +
{ + e.preventDefault() + const startY = e.clientY + const startH = panelOutputHeight + const container = splitContainerRef.current + const maxH = container ? container.clientHeight - 60 : 400 + const onMove = (ev: MouseEvent) => { + const delta = startY - ev.clientY + setPanelOutputHeight(Math.min(maxH, Math.max(0, startH + delta))) + } + const onUp = () => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + }} + role='separator' + aria-orientation='horizontal' + aria-label='Resize output panel' + > +
+
+ + {/* Output content */} + {selectedEntry ? ( +
+ {/* Output/Input tab header */} +
+ + {hasInputData && ( + + )} +
+ + {selectedEntry.blockName} + +
+ {/* Scrollable content */} +
+
+                      {outputData
+                        ? typeof outputData === 'string'
+                          ? outputData
+                          : JSON.stringify(outputData, null, 2)
+                        : 'No data'}
+                    
+
+
+ ) : ( +
+ + Select a log entry to view output + +
)} - 0} - handleExportConsole={handleExportConsole} - handleClearConsole={handleClearConsole} - shouldShowCodeDisplay={shouldShowCodeDisplay} - outputData={outputData} - handleClearConsoleFromMenu={handleClearConsoleFromMenu} - />
)} + + {/* Right Section - Block Output (standalone mode only) */} + {!isPanelMode && selectedEntry && ( + 0} + handleExportConsole={handleExportConsole} + handleClearConsole={handleClearConsole} + shouldShowCodeDisplay={shouldShowCodeDisplay} + outputData={outputData} + handleClearConsoleFromMenu={handleClearConsoleFromMenu} + /> + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx index bddbf8f286e..ef92b50339e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx @@ -23,7 +23,6 @@ import { Upload, } from '@/components/emcn' import { Lock, Unlock } from '@/components/emcn/icons' -import { VariableIcon } from '@/components/icons' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { WorkflowHistory } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history' @@ -230,20 +229,6 @@ export const WorkflowActions = memo(function WorkflowActions({ Auto layout - {/* Variables */} - - - - - Variables - - {/* History */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index 881b8b10424..b41758bb7ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -134,7 +134,7 @@ export const WorkflowControls = memo(function WorkflowControls() { {mode === 'hand' ? 'Mover' : 'Pointer'} - + { setMode('hand') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx index 922c247ca23..85f5dc2f4ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx @@ -1,9 +1,25 @@ 'use client' -import { memo, useCallback } from 'react' -import { Clock, RotateCcw, Trash2 } from 'lucide-react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import clsx from 'clsx' +import { + Clock, + MoreVertical, + NotepadText, + Pencil, + RotateCcw, + SendToBack, + Trash2, + X, +} from 'lucide-react' import { Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, Popover, PopoverContent, PopoverItem, @@ -12,10 +28,21 @@ import { PopoverTrigger, Tooltip, } from '@/components/emcn' +import { formatDateTime } from '@/lib/core/utils/formatting' +import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { VersionDescriptionModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal' +import { + useActivateDeploymentVersion, + useDeploymentVersions, + useUpdateDeploymentVersion, +} from '@/hooks/queries/deployments' +import { useRevertToVersion } from '@/hooks/queries/workflows' import type { WorkflowSnapshot } from '@/stores/workflow-history' import { useWorkflowHistoryStore } from '@/stores/workflow-history' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +const logger = createLogger('WorkflowHistory') + /** * Formats a timestamp as a relative time ("2m ago") or absolute time for older entries. */ @@ -29,7 +56,6 @@ function formatTime(isoTimestamp: string): string { if (minutes < 60) return `${minutes}m ago` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}h ago` - // For older entries, show date return new Date(isoTimestamp).toLocaleDateString([], { month: 'short', day: 'numeric', @@ -44,23 +70,75 @@ interface WorkflowHistoryProps { } /** - * Popover showing browser-local workflow change history. - * Each entry is a full state snapshot captured automatically when the user - * modifies the workflow. Clicking an entry restores the workflow to that state. - * - * History persists across page reloads via localStorage. + * Popover showing deployment versions and browser-local workflow change history. + * Deployment versions are fetched from the server, while local changes + * are snapshots captured automatically when the user modifies the workflow. */ export const WorkflowHistory = memo(function WorkflowHistory({ open, onOpenChange, }: WorkflowHistoryProps) { const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + + // Local snapshots const snapshots = useWorkflowHistoryStore((state) => activeWorkflowId ? state.getSnapshots(activeWorkflowId) : [] ) const restoreSnapshot = useWorkflowHistoryStore((state) => state.restoreSnapshot) const clearHistory = useWorkflowHistoryStore((state) => state.clearHistory) + // Deployment versions + const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions( + activeWorkflowId, + { enabled: open } + ) + const versions = versionsData?.versions ?? [] + + // Version actions + const activateVersionMutation = useActivateDeploymentVersion() + const revertMutation = useRevertToVersion() + const renameMutation = useUpdateDeploymentVersion() + + // Version action dropdown state + const [openDropdown, setOpenDropdown] = useState(null) + + // Inline rename state + const [editingVersion, setEditingVersion] = useState(null) + const [editValue, setEditValue] = useState('') + const inputRef = useRef(null) + + // Description modal state + const [descriptionModalVersion, setDescriptionModalVersion] = useState(null) + + // Confirmation dialogs + const [showLoadDialog, setShowLoadDialog] = useState(false) + const [showPromoteDialog, setShowPromoteDialog] = useState(false) + const [versionToLoad, setVersionToLoad] = useState(null) + const [versionToPromote, setVersionToPromote] = useState(null) + + const versionToLoadInfo = versions.find((v) => v.version === versionToLoad) + const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote) + const descriptionModalVersionData = + descriptionModalVersion !== null + ? versions.find((v) => v.version === descriptionModalVersion) + : null + + useEffect(() => { + if (editingVersion !== null && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editingVersion]) + + // Reset state when popover closes + useEffect(() => { + if (!open) { + setOpenDropdown(null) + setEditingVersion(null) + setEditValue('') + } + }, [open]) + const handleRestore = useCallback( (snapshot: WorkflowSnapshot) => { if (!activeWorkflowId) return @@ -75,60 +153,445 @@ export const WorkflowHistory = memo(function WorkflowHistory({ clearHistory(activeWorkflowId) }, [activeWorkflowId, clearHistory]) + const handleStartRename = useCallback( + (version: number, currentName: string | null | undefined) => { + setOpenDropdown(null) + setEditingVersion(version) + setEditValue(currentName || `v${version}`) + }, + [] + ) + + const handleSaveRename = useCallback( + (version: number) => { + if (renameMutation.isPending) return + if (!activeWorkflowId || !editValue.trim()) { + setEditingVersion(null) + return + } + + const currentVersion = versions.find((v) => v.version === version) + const currentName = currentVersion?.name || `v${version}` + + if (editValue.trim() === currentName) { + setEditingVersion(null) + return + } + + renameMutation.mutate( + { + workflowId: activeWorkflowId, + version, + name: editValue.trim(), + }, + { + onSuccess: () => setEditingVersion(null), + } + ) + }, + [activeWorkflowId, editValue, versions, renameMutation] + ) + + const handleCancelRename = useCallback(() => { + setEditingVersion(null) + setEditValue('') + }, []) + + const handleOpenDescriptionModal = useCallback((version: number) => { + setOpenDropdown(null) + setDescriptionModalVersion(version) + }, []) + + const handlePromote = useCallback((version: number) => { + setOpenDropdown(null) + setVersionToPromote(version) + setShowPromoteDialog(true) + }, []) + + const handleLoadDeployment = useCallback((version: number) => { + setOpenDropdown(null) + setVersionToLoad(version) + setShowLoadDialog(true) + }, []) + + const confirmPromoteToLive = useCallback(async () => { + if (!activeWorkflowId || versionToPromote === null) return + setShowPromoteDialog(false) + const version = versionToPromote + setVersionToPromote(null) + + try { + await activateVersionMutation.mutateAsync({ + workflowId: activeWorkflowId, + version, + }) + } catch (error) { + logger.error('Failed to promote version:', error) + } + }, [activeWorkflowId, versionToPromote, activateVersionMutation]) + + const confirmLoadDeployment = useCallback(async () => { + if (!activeWorkflowId || versionToLoad === null) return + setShowLoadDialog(false) + const version = versionToLoad + setVersionToLoad(null) + onOpenChange(false) + + try { + await revertMutation.mutateAsync({ workflowId: activeWorkflowId, version }) + } catch (error) { + logger.error('Failed to load deployment:', error) + } + }, [activeWorkflowId, versionToLoad, revertMutation, onOpenChange]) + + const hasVersions = versions.length > 0 + const hasSnapshots = snapshots.length > 0 + const isEmpty = !hasVersions && !hasSnapshots && !versionsLoading + return ( - - + <> + - - - + + + + + {!open && Change history} - - - {snapshots.length === 0 ? ( -
- - No changes yet - - History is saved automatically as you edit + { + // Defer close so the native click event chain completes first. + // Without this, Radix's flushSync-based dismiss can swallow the + // click that should reach canvas nodes and open their editor panel. + e.preventDefault() + requestAnimationFrame(() => onOpenChange(false)) + }} + > + {/* Header */} +
+ + Change History +
- ) : ( - <> + + {isEmpty ? ( +
+ + + No history yet + + + Deploy your workflow or make edits to see history + +
+ ) : ( - Recent Changes - {snapshots.map((snapshot) => ( - handleRestore(snapshot)}> - - {snapshot.label} - - {formatTime(snapshot.timestamp)} - - - ))} + {/* Deployment Versions Section */} + {(hasVersions || versionsLoading) && ( + <> + Deployment Versions + {versionsLoading && !hasVersions ? ( +
+ Loading... +
+ ) : ( + versions.map((v) => ( + + )) + )} + + )} + + {/* Recent Changes Section */} + {hasSnapshots && ( + <> + {hasVersions &&
} + Recent Changes + {snapshots.map((snapshot) => ( + handleRestore(snapshot)}> + + {snapshot.label} + + {formatTime(snapshot.timestamp)} + + + ))} +
+ + + Clear local history + +
+ + )} + )} + + -
- - - Clear history - -
- - )} - - + {/* Confirmation: Load Deployment */} + + + Load Deployment + +

+ Are you sure you want to load{' '} + + {versionToLoadInfo?.name || `v${versionToLoad}`} + + ?{' '} + + This will replace your current workflow with the deployed version. + +

+
+ + + + +
+
+ + {/* Confirmation: Promote to Live */} + + + Promote to live + +

+ Are you sure you want to promote{' '} + + {versionToPromoteInfo?.name || `v${versionToPromote}`} + {' '} + to live?{' '} + + This version will become the active deployment and serve all API requests. + +

+
+ + + + +
+
+ + {/* Version Description Modal */} + {activeWorkflowId && descriptionModalVersionData && ( + !openState && setDescriptionModalVersion(null)} + workflowId={activeWorkflowId} + version={descriptionModalVersionData.version} + versionName={ + descriptionModalVersionData.name || `v${descriptionModalVersionData.version}` + } + currentDescription={descriptionModalVersionData.description} + /> + )} + ) }) + +interface VersionRowProps { + version: WorkflowDeploymentVersionResponse + workflowId: string | null + editingVersion: number | null + editValue: string + inputRef: React.RefObject + openDropdown: number | null + renamePending: boolean + onEditValueChange: (value: string) => void + onSaveRename: (version: number) => void + onCancelRename: () => void + onStartRename: (version: number, currentName: string | null | undefined) => void + onOpenDropdown: (version: number | null) => void + onOpenDescription: (version: number) => void + onPromote: (version: number) => void + onLoadDeployment: (version: number) => void +} + +/** + * A single deployment version row inside the history popover. + * Uses disablePortal on the nested dropdown so clicking outside + * the parent popover correctly dismisses it. + */ +function VersionRow({ + version: v, + editingVersion, + editValue, + inputRef, + openDropdown, + renamePending, + onEditValueChange, + onSaveRename, + onCancelRename, + onStartRename, + onOpenDropdown, + onOpenDescription, + onPromote, + onLoadDeployment, +}: VersionRowProps) { + const isEditing = editingVersion === v.version + + return ( +
+ {/* Status dot */} +
+ + {/* Version name + timestamp */} +
+ {isEditing ? ( + onEditValueChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + onSaveRename(v.version) + } else if (e.key === 'Escape') { + e.preventDefault() + onCancelRename() + } + }} + onClick={(e) => e.stopPropagation()} + onBlur={() => onSaveRename(v.version)} + className='w-full border-0 bg-transparent p-0 text-[12px] font-medium leading-4 text-[var(--text-primary)] outline-none focus:outline-none focus:ring-0' + maxLength={100} + disabled={renamePending} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> + ) : ( + + {v.name || `v${v.version}`} + {v.isActive && (live)} + + )} + + {formatDateTime(new Date(v.createdAt))} + {v.deployedBy ? ` · ${v.deployedBy}` : ''} + +
+ + {/* Actions */} +
e.stopPropagation()}> + + + + + + {v.description ? ( +

{v.description}

+ ) : ( +

Add description

+ )} +
+
+ + onOpenDropdown(isOpen ? v.version : null)} + > + + + + + onStartRename(v.version, v.name)}> + + Rename + + {!v.isActive && ( + onPromote(v.version)}> + + Promote to live + + )} + onLoadDeployment(v.version)}> + + Load deployment + + + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx index 71a923cb89c..49d761063da 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx @@ -10,14 +10,12 @@ import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' /** * Compact floating toolbar at the top-right of the canvas. - * Contains only the two primary workflow actions: Run and Deploy. - * - * All secondary actions (auto layout, variables, history, lock, export, - * duplicate, delete) live in the left-side WorkflowActions toolbar. + * Layout: [Variables] [Run] | [Deploy] */ interface WorkflowToolbarProps { workspaceId?: string @@ -46,6 +44,13 @@ export const WorkflowToolbar = memo(function WorkflowToolbar({ const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution() + const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore( + useShallow((state) => ({ + isOpen: state.isOpen, + setIsOpen: state.setIsOpen, + })) + ) + const cancelWorkflow = useCallback(async () => { await handleCancelExecution() }, [handleCancelExecution]) @@ -64,7 +69,17 @@ export const WorkflowToolbar = memo(function WorkflowToolbar({ return (
- {/* Run (secondary) */} + {/* Variables */} + + + {/* Run */}
diff --git a/apps/sim/stores/panel/editor/store.ts b/apps/sim/stores/panel/editor/store.ts index f78c35b73f3..7516f83e8e6 100644 --- a/apps/sim/stores/panel/editor/store.ts +++ b/apps/sim/stores/panel/editor/store.ts @@ -48,7 +48,9 @@ export const usePanelEditorStore = create()( setCurrentBlockId: (blockId) => { set({ currentBlockId: blockId }) if (blockId !== null) { - usePanelStore.getState().setActiveTab('editor') + const panelStore = usePanelStore.getState() + panelStore.setActiveTab('editor') + panelStore.setIsPanelOpen(true) } }, clearCurrentBlock: () => { diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index a0033a05b50..21f98567740 100644 --- a/apps/sim/stores/panel/store.ts +++ b/apps/sim/stores/panel/store.ts @@ -37,6 +37,13 @@ export const usePanelStore = create()( setIsResizing: (isResizing) => { set({ isResizing }) }, + pendingCopilotMessage: null, + setPendingCopilotMessage: (message) => { + set({ pendingCopilotMessage: message }) + if (message !== null) { + set({ isPanelOpen: true, activeTab: 'copilot' }) + } + }, _hasHydrated: false, setHasHydrated: (hasHydrated) => { set({ _hasHydrated: hasHydrated }) @@ -44,6 +51,11 @@ export const usePanelStore = create()( }), { name: 'panel-state', + partialize: (state) => ({ + panelWidth: state.panelWidth, + activeTab: state.activeTab, + isPanelOpen: state.isPanelOpen, + }), onRehydrateStorage: () => (state) => { // Sync CSS variables with stored state after rehydration if (state && typeof window !== 'undefined') { diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 5a21f21050a..f588ee086bd 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -19,6 +19,10 @@ export interface PanelState { isResizing: boolean /** Updates the panel resize state */ setIsResizing: (isResizing: boolean) => void + /** Pending message to auto-submit to the copilot when the panel opens */ + pendingCopilotMessage: string | null + /** Sets a pending copilot message, opens the panel, and switches to the copilot tab */ + setPendingCopilotMessage: (message: string | null) => void _hasHydrated: boolean setHasHydrated: (hasHydrated: boolean) => void } diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index 0980f2ae6b3..14f6a8ab3ef 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -64,6 +64,9 @@ export const useVariablesStore = create()( isLoading: false, error: null, isEditing: null, + isOpen: false, + + setIsOpen: (open) => set({ isOpen: open }), addVariable: (variable, providedId?: string) => { const id = providedId || crypto.randomUUID() @@ -138,14 +141,21 @@ export const useVariablesStore = create()( if (targetWorkflowId) { const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {} const updatedWorkflowValues = { ...workflowValues } - const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> = - [] + const changedSubBlocks: Array<{ + blockId: string + subBlockId: string + value: unknown + }> = [] const oldVarName = normalizeName(oldVariableName) const newVarName = normalizeName(newName) const regex = new RegExp(``, 'gi') - const updateReferences = (value: any, pattern: RegExp, replacement: string): any => { + const updateReferences = ( + value: unknown, + pattern: RegExp, + replacement: string + ): unknown => { if (typeof value === 'string') { return pattern.test(value) ? value.replace(pattern, replacement) : value } @@ -155,7 +165,7 @@ export const useVariablesStore = create()( } if (value !== null && typeof value === 'object') { - const result = { ...value } + const result = { ...(value as Record) } for (const key in result) { result[key] = updateReferences(result[key], pattern, replacement) } @@ -166,7 +176,7 @@ export const useVariablesStore = create()( } Object.entries(workflowValues).forEach(([blockId, blockValues]) => { - Object.entries(blockValues as Record).forEach( + Object.entries(blockValues as Record).forEach( ([subBlockId, value]) => { const updatedValue = updateReferences(value, regex, ``) @@ -174,14 +184,14 @@ export const useVariablesStore = create()( if (!updatedWorkflowValues[blockId]) { updatedWorkflowValues[blockId] = { ...workflowValues[blockId] } } - updatedWorkflowValues[blockId][subBlockId] = updatedValue + ;(updatedWorkflowValues[blockId] as Record)[subBlockId] = + updatedValue changedSubBlocks.push({ blockId, subBlockId, value: updatedValue }) } } ) }) - // Update local state useSubBlockStore.setState({ workflowValues: { ...subBlockStore.workflowValues, @@ -189,7 +199,6 @@ export const useVariablesStore = create()( }, }) - // Queue operations for persistence via socket const operationQueue = useOperationQueueStore.getState() for (const { blockId, subBlockId, value } of changedSubBlocks) { diff --git a/apps/sim/stores/variables/types.ts b/apps/sim/stores/variables/types.ts index a3b43e5be8b..358340707d5 100644 --- a/apps/sim/stores/variables/types.ts +++ b/apps/sim/stores/variables/types.ts @@ -22,6 +22,8 @@ export interface VariablesStore { isLoading: boolean error: string | null isEditing: string | null + isOpen: boolean + setIsOpen: (open: boolean) => void /** * Adds a new variable with automatic name uniqueness validation From 73a7dcf705336616796c2b6f78aac8f6e65fb267 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Sat, 4 Apr 2026 22:06:02 +0530 Subject: [PATCH 3/6] chore: fix conflicts --- .../copilot-input/copilot-input.tsx | 5 --- .../w/[workflowId]/components/panel/panel.tsx | 27 +++++++------- .../components/terminal/terminal.tsx | 10 +----- .../workflow-actions/workflow-actions.tsx | 5 --- .../workflow-controls/workflow-controls.tsx | 10 ++++-- .../workflow-edge/workflow-edge.tsx | 11 +----- .../workflow-toolbar/workflow-history.tsx | 24 ------------- .../workflow-toolbar/workflow-toolbar.tsx | 4 --- apps/sim/stores/panel/types.ts | 4 --- apps/sim/stores/workflow-history/store.ts | 30 ---------------- apps/sim/stores/workflow-history/types.ts | 36 ------------------- 11 files changed, 22 insertions(+), 144 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx index 758a4e068bc..adb3dcdf911 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx @@ -11,11 +11,6 @@ const SEND_BUTTON_ACTIVE = 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' -/** - * Floating copilot input that appears centered on the canvas when the panel - * is collapsed. Provides a quick entry point to the copilot — on submit, - * the message is forwarded to the panel store and the copilot tab opens. - */ export const CopilotInput = memo(function CopilotInput() { const isPanelOpen = usePanelStore((s) => s.isPanelOpen) const setPendingCopilotMessage = usePanelStore((s) => s.setPendingCopilotMessage) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 2b6bf241117..c9f63bd396c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -46,10 +46,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Panel') /** - * Floating panel sidebar with tab navigation that persists across page refreshes. - * - * Renders as a floating overlay on the right side of the canvas with rounded corners - * and a collapsible toggle. Tabs: Copilot, Toolbar, Editor, Logs. + * Panel component with resizable width and tab navigation that persists across page refreshes. * * Uses a CSS-based approach to prevent hydration mismatches and flash on load: * 1. Width is controlled by CSS variable (--panel-width) @@ -58,7 +55,12 @@ const logger = createLogger('Panel') * 4. React takes over visibility control after hydration completes * 5. Store updates CSS variable when width changes * - * @returns Floating panel on the right side of the canvas + * This ensures server and client render identical HTML, preventing hydration errors and visual flash. + * + * Note: All tabs are kept mounted but hidden to preserve component state during tab switches. + * This prevents unnecessary remounting which would trigger data reloads and reset state. + * + * @returns Panel on the right side of the workflow */ interface PanelProps { /** Override workspaceId when rendered outside a workspace route (e.g. sandbox mode) */ @@ -137,7 +139,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel await handleRunWorkflow() }, [usageExceeded, handleRunWorkflow]) - // Copilot chat state + // Chat state const [copilotChatId, setCopilotChatId] = useState(undefined) const [copilotChatTitle, setCopilotChatTitle] = useState(null) const [copilotChatList, setCopilotChatList] = useState< @@ -325,10 +327,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotSendMessage] ) - /** - * Auto-submit a pending copilot message that was queued from the floating input. - * Consumes the message immediately so it only fires once. - */ useEffect(() => { if (pendingCopilotMessage && isPanelOpen && activeTab === 'copilot') { const message = pendingCopilotMessage @@ -339,6 +337,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel /** * Mark hydration as complete on mount + * This allows React to take over visibility control from CSS */ useEffect(() => { setHasHydrated(true) @@ -363,7 +362,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const wasExecutingRef = useRef(false) useEffect(() => { if (wasExecutingRef.current && !isExecuting) { - // Run just finished — show logs if the user isn't mid-edit if (activeTab !== 'editor' && activeTab !== 'copilot') { setActiveTab('logs') if (!isPanelOpen) setIsPanelOpen(true) @@ -380,10 +378,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel } /** - * Register global keyboard shortcuts. - * - Mod+Enter: Run / cancel workflow + * Register global keyboard shortcuts using the central commands registry. + * + * - Mod+Enter: Run / cancel workflow (matches the Run button behavior) * - Mod+F: Focus Toolbar tab and search input - * - Mod+L: Toggle Logs tab */ useRegisterGlobalCommands(() => createCommands([ @@ -414,7 +412,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel ]) ) - // When panel is closed, render a thin vertical strip with tab icons if (!isPanelOpen) { return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index ad91436324a..15beb462a82 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -92,8 +92,7 @@ const hasCanceledInTree = (nodes: EntryNode[]) => hasMatchInTree(nodes, (e) => Boolean(e.isCanceled)) /** - * Block row component for displaying actual block entries. - * Click to select and view input/output in the output panel. + * Block row component for displaying actual block entries */ const BlockRow = memo(function BlockRow({ entry, @@ -668,9 +667,6 @@ const TerminalLogsPane = memo(function TerminalLogsPane({ /** * Terminal component with resizable height that persists across page refreshes. - * - * @param mode - 'standalone' renders with its own height/resize (below canvas). - * 'panel' fills its parent container (inside a panel tab). */ interface TerminalProps { mode?: 'standalone' | 'panel' @@ -1155,9 +1151,6 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal } }, [executionGroups, navigableEntries, autoSelectEnabled, selectedEntryId, focusTerminal]) - /** - * Sync selected entry with latest data from store. - */ useEffect(() => { if (!selectedEntry) return const updatedEntry = filteredEntries.find((e) => e.id === selectedEntry.id) @@ -1300,7 +1293,6 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal let terminalWidth: number if (isPanelMode && terminalRef.current) { - // In panel mode, use the terminal's own width (it's inside the panel) terminalWidth = terminalRef.current.clientWidth } else { const sidebarWidth = Number.parseInt( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx index ef92b50339e..cefc1b19309 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx @@ -42,11 +42,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('WorkflowActions') -/** - * Vertical floating toolbar on the left side of the canvas. - * Primary actions (auto layout, variables, history) are direct buttons. - * Secondary actions (lock, export, duplicate, delete) are behind a three-dots menu. - */ interface WorkflowActionsProps { workspaceId?: string } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index b41758bb7ff..c96313f2528 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -34,7 +34,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowControls') /** - * Floating controls for canvas mode, undo/redo, zoom, and fit-to-view. + * Floating controls for canvas mode, undo/redo, and fit-to-view. */ export const WorkflowControls = memo(function WorkflowControls() { const reactFlowInstance = useReactFlow() @@ -134,7 +134,13 @@ export const WorkflowControls = memo(function WorkflowControls() { {mode === 'hand' ? 'Mover' : 'Pointer'} - + { setMode('hand') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 4a62eab2f1e..31010a20689 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -52,7 +52,6 @@ const WorkflowEdgeComponent = ({ ) const lastRunEdges = useLastRunEdges() - // Check if the workflow is currently executing and if this edge's source block is active const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const { isExecuting, isSourceActive } = useExecutionStore( useShallow((state) => { @@ -97,9 +96,6 @@ const WorkflowEdgeComponent = ({ targetHandle, ]) - // Determine whether to show the flow animation: - // - Edge just ran successfully (data flowed through) - // - OR the source block is currently active (data is in transit) const showFlowAnimation = !edgeDiffStatus && (edgeRunStatus === 'success' || (isExecuting && isSourceActive)) @@ -187,7 +183,7 @@ const WorkflowEdgeComponent = ({ } /** - * Workflow edge component with execution status, diff visualization, and flow animation. + * Workflow edge component with execution status and diff visualization. * * @remarks * Edge coloring priority: @@ -195,10 +191,5 @@ const WorkflowEdgeComponent = ({ * 2. Execution status (success/error) - for run visualization * 3. Error edge default (red) - for untaken error paths * 4. Default edge color - normal workflow connections - * - * Flow animation: - * When data flows through an edge (success status) or the source block is - * actively executing, a subtle marching-dashes overlay animates along the - * path to visualize data movement. */ export const WorkflowEdge = memo(WorkflowEdgeComponent) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx index 85f5dc2f4ce..b55f27bfd18 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-history.tsx @@ -43,9 +43,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowHistory') -/** - * Formats a timestamp as a relative time ("2m ago") or absolute time for older entries. - */ function formatTime(isoTimestamp: string): string { const now = Date.now() const then = new Date(isoTimestamp).getTime() @@ -69,48 +66,36 @@ interface WorkflowHistoryProps { onOpenChange: (open: boolean) => void } -/** - * Popover showing deployment versions and browser-local workflow change history. - * Deployment versions are fetched from the server, while local changes - * are snapshots captured automatically when the user modifies the workflow. - */ export const WorkflowHistory = memo(function WorkflowHistory({ open, onOpenChange, }: WorkflowHistoryProps) { const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) - // Local snapshots const snapshots = useWorkflowHistoryStore((state) => activeWorkflowId ? state.getSnapshots(activeWorkflowId) : [] ) const restoreSnapshot = useWorkflowHistoryStore((state) => state.restoreSnapshot) const clearHistory = useWorkflowHistoryStore((state) => state.clearHistory) - // Deployment versions const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions( activeWorkflowId, { enabled: open } ) const versions = versionsData?.versions ?? [] - // Version actions const activateVersionMutation = useActivateDeploymentVersion() const revertMutation = useRevertToVersion() const renameMutation = useUpdateDeploymentVersion() - // Version action dropdown state const [openDropdown, setOpenDropdown] = useState(null) - // Inline rename state const [editingVersion, setEditingVersion] = useState(null) const [editValue, setEditValue] = useState('') const inputRef = useRef(null) - // Description modal state const [descriptionModalVersion, setDescriptionModalVersion] = useState(null) - // Confirmation dialogs const [showLoadDialog, setShowLoadDialog] = useState(false) const [showPromoteDialog, setShowPromoteDialog] = useState(false) const [versionToLoad, setVersionToLoad] = useState(null) @@ -130,7 +115,6 @@ export const WorkflowHistory = memo(function WorkflowHistory({ } }, [editingVersion]) - // Reset state when popover closes useEffect(() => { if (!open) { setOpenDropdown(null) @@ -272,9 +256,6 @@ export const WorkflowHistory = memo(function WorkflowHistory({ maxHeight={480} style={{ minWidth: '300px', maxWidth: '340px' }} onPointerDownOutside={(e) => { - // Defer close so the native click event chain completes first. - // Without this, Radix's flushSync-based dismiss can swallow the - // click that should reach canvas nodes and open their editor panel. e.preventDefault() requestAnimationFrame(() => onOpenChange(false)) }} @@ -455,11 +436,6 @@ interface VersionRowProps { onLoadDeployment: (version: number) => void } -/** - * A single deployment version row inside the history popover. - * Uses disablePortal on the nested dropdown so clicking outside - * the parent popover correctly dismisses it. - */ function VersionRow({ version: v, editingVersion, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx index 49d761063da..2383d61849e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx @@ -13,10 +13,6 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -/** - * Compact floating toolbar at the top-right of the canvas. - * Layout: [Variables] [Run] | [Deploy] - */ interface WorkflowToolbarProps { workspaceId?: string } diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index f588ee086bd..88ecc824889 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -11,17 +11,13 @@ export interface PanelState { setPanelWidth: (width: number) => void activeTab: PanelTab setActiveTab: (tab: PanelTab) => void - /** Whether the panel sidebar is open (visible) */ isPanelOpen: boolean - /** Toggles or sets the panel sidebar open state */ setIsPanelOpen: (isOpen: boolean) => void /** Whether the panel is currently being resized */ isResizing: boolean /** Updates the panel resize state */ setIsResizing: (isResizing: boolean) => void - /** Pending message to auto-submit to the copilot when the panel opens */ pendingCopilotMessage: string | null - /** Sets a pending copilot message, opens the panel, and switches to the copilot tab */ setPendingCopilotMessage: (message: string | null) => void _hasHydrated: boolean setHasHydrated: (hasHydrated: boolean) => void diff --git a/apps/sim/stores/workflow-history/store.ts b/apps/sim/stores/workflow-history/store.ts index f78291f5fc3..08c1cc0bacb 100644 --- a/apps/sim/stores/workflow-history/store.ts +++ b/apps/sim/stores/workflow-history/store.ts @@ -10,9 +10,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('WorkflowHistoryStore') -/** - * Maps undo/redo operation types to human-readable labels for snapshots. - */ const OPERATION_LABELS: Record = { 'batch-add-blocks': 'Added blocks', 'batch-remove-blocks': 'Removed blocks', @@ -29,36 +26,23 @@ const OPERATION_LABELS: Record = { 'reject-diff': 'Rejected changes', } -/** - * Generates a short unique id for snapshots. - */ function generateSnapshotId(): string { return `snap_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}` } -/** - * Captures the current sub-block values for all blocks in the workflow. - * Reads directly from the sub-block store's internal workflowValues map. - */ function captureSubBlockValues(workflowId: string): Record> { const workflowValues = useSubBlockStore.getState().workflowValues const values = workflowValues[workflowId] if (!values) return {} - // Deep clone to avoid reference sharing return JSON.parse(JSON.stringify(values)) } -/** - * Enforces the LRU limit on tracked workflows. - * Keeps only the N most recently updated workflows. - */ function enforceLRU( snapshots: Record ): Record { const workflowIds = Object.keys(snapshots) if (workflowIds.length <= MAX_TRACKED_WORKFLOWS) return snapshots - // Sort by most recent snapshot timestamp (descending) const sorted = workflowIds .map((id) => ({ id, @@ -86,7 +70,6 @@ export const useWorkflowHistoryStore = create()( const blocks = workflowState.blocks const edges = workflowState.edges - // Skip if no blocks (empty workflow) if (Object.keys(blocks).length === 0) return const subBlockValues = captureSubBlockValues(workflowId) @@ -103,8 +86,6 @@ export const useWorkflowHistoryStore = create()( set((state) => { const existing = state.snapshots[workflowId] ?? [] - // Deduplicate: skip if the latest snapshot has the same block count, - // edge count, and label within the last 2 seconds if (existing.length > 0) { const latest = existing[0] const timeDiff = Date.now() - new Date(latest.timestamp).getTime() @@ -141,10 +122,8 @@ export const useWorkflowHistoryStore = create()( return false } - // Capture current state as a "before restore" snapshot get().captureSnapshot(workflowId, 'Before restore') - // Restore workflow state const workflowStore = useWorkflowStore.getState() workflowStore.replaceWorkflowState({ blocks: JSON.parse(JSON.stringify(snapshot.blocks)), @@ -153,7 +132,6 @@ export const useWorkflowHistoryStore = create()( parallels: {}, }) - // Restore sub-block values (setValue reads active workflowId internally) const subBlockStore = useSubBlockStore.getState() for (const [blockId, values] of Object.entries(snapshot.subBlockValues)) { for (const [subBlockId, value] of Object.entries(values as Record)) { @@ -197,13 +175,6 @@ export const useWorkflowHistoryStore = create()( ) ) -/** - * Subscribe to the undo/redo store to automatically capture snapshots - * whenever a new operation is pushed onto the undo stack. - * - * This is the integration point — instead of modifying every push() call site, - * we listen for stack growth and snapshot the current state. - */ if (typeof window !== 'undefined') { const prevStackSizes: Record = {} @@ -212,7 +183,6 @@ if (typeof window !== 'undefined') { const prevSize = prevStackSizes[key] ?? 0 const currentSize = stack.undo.length - // A new entry was pushed (stack grew) if (currentSize > prevSize && currentSize > 0) { const latestEntry = stack.undo[stack.undo.length - 1] if (latestEntry) { diff --git a/apps/sim/stores/workflow-history/types.ts b/apps/sim/stores/workflow-history/types.ts index 782895a193c..3f42a3387de 100644 --- a/apps/sim/stores/workflow-history/types.ts +++ b/apps/sim/stores/workflow-history/types.ts @@ -1,65 +1,29 @@ import type { Edge } from 'reactflow' import type { BlockState } from '@/stores/workflows/workflow/types' -/** - * A point-in-time snapshot of the workflow graph state. - * Stored in the browser via localStorage. - */ export interface WorkflowSnapshot { - /** Unique identifier for this snapshot */ id: string - /** ISO timestamp when this snapshot was captured */ timestamp: string - /** Human-readable label describing what changed */ label: string - /** Block states including merged sub-block values */ blocks: Record - /** Edge connections */ edges: Edge[] - /** Sub-block values keyed by blockId → subBlockId → value */ subBlockValues: Record> } -/** Maximum number of snapshots stored per workflow */ export const MAX_SNAPSHOTS_PER_WORKFLOW = 50 -/** Maximum number of workflows to track history for (LRU) */ export const MAX_TRACKED_WORKFLOWS = 5 export interface WorkflowHistoryState { - /** - * Snapshots keyed by workflowId. - * Each value is an array sorted newest-first (index 0 = most recent). - */ snapshots: Record - /** - * Captures a snapshot of the current workflow state. - * @param workflowId - The workflow to snapshot - * @param label - What changed (e.g. "Added blocks", "Moved blocks") - */ captureSnapshot: (workflowId: string, label: string) => void - /** - * Restores a workflow to a specific snapshot. - * @param workflowId - The workflow to restore - * @param snapshotId - The snapshot to restore to - * @returns true if restored successfully - */ restoreSnapshot: (workflowId: string, snapshotId: string) => boolean - /** - * Returns snapshots for a workflow (newest first). - */ getSnapshots: (workflowId: string) => WorkflowSnapshot[] - /** - * Clears all history for a workflow. - */ clearHistory: (workflowId: string) => void - /** - * Clears all history for all workflows. - */ clearAllHistory: () => void } From 476e6aa5a57e579aebb352b1ba6c4b8078c83ac2 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Sat, 4 Apr 2026 22:11:30 +0530 Subject: [PATCH 4/6] chore: remove unnecessary changes --- .claude/settings.local.json | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 63c81149923..00000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(docker compose:*)", - "Bash(docker system df:*)", - "Bash(docker image:*)", - "Bash(docker builder:*)", - "Bash(bun run:*)", - "Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/simstudio\" bunx drizzle-kit migrate --config=./drizzle.config.ts)", - "Bash(docker exec sim-db-1:*)", - "Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/simstudio\" bun run db:push)", - "Bash(POSTGRES_PORT=5433 docker compose:*)", - "Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5433/simstudio\" bun run db:migrate)" - ] - } -} From 3db31a4b4b49be040749a81a3cb6f30619defca2 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Sat, 4 Apr 2026 22:19:13 +0530 Subject: [PATCH 5/6] chore: fix review changes --- .../components/workflow-actions/workflow-actions.tsx | 3 +-- .../components/workflow-toolbar/workflow-toolbar.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx index cefc1b19309..39725aa4dc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions.tsx @@ -34,7 +34,6 @@ import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useNotificationStore } from '@/stores/notifications/store' -import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel' import { useVariablesStore } from '@/stores/variables/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -140,7 +139,7 @@ export const WorkflowActions = memo(function WorkflowActions({ try { const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId) if (!workflow || !workflow.state) throw new Error('No workflow state found') - const workflowVariables = usePanelVariablesStore + const workflowVariables = useVariablesStore .getState() .getVariablesByWorkflowId(activeWorkflowId) const jsonContent = generateWorkflowJson(workflow.state, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx index 2383d61849e..97b1ba980ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx @@ -61,7 +61,7 @@ export const WorkflowToolbar = memo(function WorkflowToolbar({ const canRun = userPermissions.canRead const isLoadingPermissions = userPermissions.isLoading - const isButtonDisabled = !isExecuting && (isExecuting || (!canRun && !isLoadingPermissions)) + const isButtonDisabled = !isExecuting && (!canRun && !isLoadingPermissions) return (
From e27b5068cdf30287db56a5c238bf7f7e8974601c Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Sun, 5 Apr 2026 01:30:07 +0530 Subject: [PATCH 6/6] chore: fix review changes --- .../components/credentials/credentials-manager.tsx | 14 +++++++------- .../components/copilot-input/copilot-input.tsx | 6 +++--- .../w/[workflowId]/components/panel/panel.tsx | 4 ++-- .../[workflowId]/components/terminal/terminal.tsx | 12 ++++++------ .../workflow-actions/workflow-actions.tsx | 7 ------- .../workflow-toolbar/workflow-history.tsx | 12 ++++++------ .../workflow-toolbar/workflow-toolbar.tsx | 2 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 2 +- 8 files changed, 26 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index 30fe6d72186..639a26e7bb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -1190,7 +1190,7 @@ export function CredentialsManager() {
{/* Header */} -
+
@@ -1221,7 +1221,7 @@ export function CredentialsManager() {
-
+
@@ -1236,7 +1236,7 @@ export function CredentialsManager() {
0 ? 'border-t border-[var(--border)]' : '' + index > 0 ? 'border-[var(--border)] border-t' : '' }`} > @@ -1299,10 +1299,10 @@ export function CredentialsManager() { {/* Add member row */} {isSelectedAdmin && ( -
+
-

+

Grant access to a workspace member

@@ -1348,7 +1348,7 @@ export function CredentialsManager() { {upsertMember.isPending ? 'Adding...' : 'Add'}
-

+

Only members of this workspace appear here. To add someone new, invite them to the workspace first. @@ -1358,7 +1358,7 @@ export function CredentialsManager() { {/* Non-admin notice */} {!isSelectedAdmin && ( -

+

Only admins of this secret can manage access control. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx index adb3dcdf911..d7c37a85293 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx @@ -8,8 +8,8 @@ import { usePanelStore } from '@/stores/panel' const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors' const SEND_BUTTON_ACTIVE = - 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' -const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' + 'bg-[var(--surface-inverted)] hover-hover:bg-[var(--surface-inverted-hover)]' +const SEND_BUTTON_DISABLED = 'bg-[var(--surface-6)]' export const CopilotInput = memo(function CopilotInput() { const isPanelOpen = usePanelStore((s) => s.isPanelOpen) @@ -81,7 +81,7 @@ export const CopilotInput = memo(function CopilotInput() { aria-label='Send message' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index c9f63bd396c..316209f018b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -420,7 +420,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel

{entry.startedAt && ( - + {new Date(entry.startedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', @@ -1387,7 +1387,7 @@ export const Terminal = memo(function Terminal({ mode = 'standalone' }: Terminal > {/* Panel mode: compact filter bar */} {isPanelMode && !selectedEntry && allWorkflowEntries.length > 0 && ( -
+