diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx index 62576e582..9115a9e4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx @@ -10,6 +10,7 @@ import { useWorkspacePermissions, type WorkspacePermissions, } from '@/hooks/use-workspace-permissions' +import { useNotificationStore } from '@/stores/notifications' const logger = createLogger('WorkspacePermissionsProvider') @@ -60,9 +61,14 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP // Manage offline mode state locally const [isOfflineMode, setIsOfflineMode] = useState(false) + // Track whether we've already surfaced an offline notification to avoid duplicates + const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false) + // Get operation error state from collaborative workflow const { hasOperationError } = useCollaborativeWorkflow() + const addNotification = useNotificationStore((state) => state.addNotification) + // Set offline mode when there are operation errors useEffect(() => { if (hasOperationError) { @@ -70,6 +76,31 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP } }, [hasOperationError]) + /** + * Surface a global notification when entering offline mode. + * Uses the shared notifications system instead of bespoke UI in individual components. + */ + useEffect(() => { + if (!isOfflineMode || hasShownOfflineNotification) { + return + } + + try { + addNotification({ + level: 'error', + message: 'Connection unavailable', + // Global notification (no workflowId) so it is visible regardless of the active workflow + action: { + type: 'refresh', + message: '', + }, + }) + setHasShownOfflineNotification(true) + } catch (error) { + logger.error('Failed to add offline notification', { error }) + } + }, [addNotification, hasShownOfflineNotification, isOfflineMode]) + // Fetch workspace permissions and loading state const { permissions: workspacePermissions, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 50ad0f94c..03e9323ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -601,6 +601,7 @@ export function Chat() { disabled={!activeWorkflowId} placeholder='Select outputs' align='end' + maxHeight={180} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index a8a9e0c80..f43747b20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -24,6 +24,7 @@ interface OutputSelectProps { placeholder?: string valueMode?: 'id' | 'label' align?: 'start' | 'end' | 'center' + maxHeight?: number } export function OutputSelect({ @@ -34,6 +35,7 @@ export function OutputSelect({ placeholder = 'Select outputs', valueMode = 'id', align = 'start', + maxHeight = 300, }: OutputSelectProps) { const [open, setOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) @@ -369,9 +371,9 @@ export function OutputSelect({ side='bottom' align={align} sideOffset={4} - maxHeight={300} - maxWidth={300} - minWidth={200} + maxHeight={maxHeight} + maxWidth={160} + minWidth={160} onKeyDown={handleKeyDown} tabIndex={0} style={{ outline: 'none' }} 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 92bd289e9..7f6597e53 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts @@ -3,6 +3,7 @@ export { ControlBar } from './control-bar/control-bar' export { Cursors } from './cursors/cursors' export { DiffControls } from './diff-controls/diff-controls' export { ErrorBoundary } from './error/index' +export { Notifications } from './notifications/notifications' export { Panel } from './panel-new/panel-new' export { SkeletonLoading } from './skeleton-loading/skeleton-loading' export { SubflowNodeComponent } from './subflows/subflow-node' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx new file mode 100644 index 000000000..91ec370bf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -0,0 +1,121 @@ +import { memo, useCallback } from 'react' +import { X } from 'lucide-react' +import { useParams } from 'next/navigation' +import { Button } from '@/components/emcn' +import { createLogger } from '@/lib/logs/console/logger' +import { + type NotificationAction, + openCopilotWithMessage, + useNotificationStore, +} from '@/stores/notifications' + +const logger = createLogger('Notifications') +const MAX_VISIBLE_NOTIFICATIONS = 4 + +/** + * Notifications display component + * Positioned in the bottom-right workspace area, aligned with terminal and panel spacing + * Shows both global notifications and workflow-specific notifications + */ +export const Notifications = memo(function Notifications() { + const params = useParams() + const workflowId = params.workflowId as string + + const notifications = useNotificationStore((state) => + state.notifications.filter((n) => !n.workflowId || n.workflowId === workflowId) + ) + const removeNotification = useNotificationStore((state) => state.removeNotification) + const visibleNotifications = notifications.slice(0, MAX_VISIBLE_NOTIFICATIONS) + + /** + * Executes a notification action and handles side effects. + * + * @param notificationId - The ID of the notification whose action is executed. + * @param action - The action configuration to execute. + */ + const executeAction = useCallback( + (notificationId: string, action: NotificationAction) => { + try { + logger.info('Executing notification action', { + notificationId, + actionType: action.type, + messageLength: action.message.length, + }) + + switch (action.type) { + case 'copilot': + openCopilotWithMessage(action.message) + break + case 'refresh': + window.location.reload() + break + default: + logger.warn('Unknown action type', { notificationId, actionType: action.type }) + } + + // Dismiss the notification after the action is triggered + removeNotification(notificationId) + } catch (error) { + logger.error('Failed to execute notification action', { + notificationId, + actionType: action.type, + error, + }) + } + }, + [removeNotification] + ) + + if (visibleNotifications.length === 0) { + return null + } + + return ( +
+ {[...visibleNotifications].reverse().map((notification, index, stacked) => { + const depth = stacked.length - index - 1 + const xOffset = depth * 3 + + return ( +
0 ? '-mt-[78px]' : '' + }`} + > +
+
+ + {notification.level === 'error' && ( + + )} + {notification.message} +
+ {notification.action && ( + + )} +
+
+ ) + })} +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx index d6a119872..7ce096f71 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx @@ -32,6 +32,7 @@ import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspace import { useChatStore } from '@/stores/chat/store' import { usePanelStore } from '@/stores/panel-new/store' import type { PanelTab } from '@/stores/panel-new/types' +import { DEFAULT_TERMINAL_HEIGHT, MIN_TERMINAL_HEIGHT, useTerminalStore } from '@/stores/terminal' import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowJsonStore } from '@/stores/workflows/json/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -130,6 +131,10 @@ export function Panel() { openSubscriptionSettings() return } + const { openOnRun, terminalHeight, setTerminalHeight } = useTerminalStore.getState() + if (openOnRun && terminalHeight <= MIN_TERMINAL_HEIGHT) { + setTerminalHeight(DEFAULT_TERMINAL_HEIGHT) + } await handleRunWorkflow() }, [usageExceeded, handleRunWorkflow]) 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 c31db6003..2699a823f 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 @@ -10,6 +10,7 @@ import { Clipboard, Filter, FilterX, + MoreHorizontal, RepeatIcon, SplitIcon, Trash2, @@ -17,14 +18,12 @@ import { import { Button, Code, - NoWrap, Popover, PopoverContent, PopoverItem, PopoverScrollArea, PopoverTrigger, Tooltip, - Wrap, } from '@/components/emcn' import { getBlock } from '@/blocks' import type { ConsoleEntry } from '@/stores/terminal' @@ -254,6 +253,8 @@ export function Terminal() { setTerminalHeight, outputPanelWidth, setOutputPanelWidth, + openOnRun, + setOpenOnRun, // displayMode, // setDisplayMode, setHasHydrated, @@ -271,6 +272,8 @@ export function Terminal() { const [blockFilterOpen, setBlockFilterOpen] = useState(false) const [statusFilterOpen, setStatusFilterOpen] = useState(false) const [runIdFilterOpen, setRunIdFilterOpen] = useState(false) + const [mainOptionsOpen, setMainOptionsOpen] = useState(false) + const [outputOptionsOpen, setOutputOptionsOpen] = useState(false) // Terminal resize hooks const { handleMouseDown } = useTerminalResize() @@ -927,6 +930,40 @@ export function Terminal() { )} + + + + + e.stopPropagation()} + style={{ minWidth: '140px', maxWidth: '160px' }} + className='gap-[2px]' + > + { + e.stopPropagation() + setOpenOnRun(!openOnRun) + }} + > + Open on run + + + { @@ -1143,72 +1180,6 @@ export function Terminal() { {showCopySuccess ? 'Copied' : 'Copy output'} - - - - - - {wrapText ? 'Wrap text' : 'No wrap'} - - - {/* - - - - e.stopPropagation()} - > - Display - { - e.stopPropagation() - setDisplayMode('prettier') - setDisplayPopoverOpen(false) - }} - > - Prettier - - { - e.stopPropagation() - setDisplayMode('raw') - setDisplayPopoverOpen(false) - }} - className='mt-[2px]' - > - Raw - - - */} {hasActiveFilters && ( @@ -1246,6 +1217,50 @@ export function Terminal() { )} + + + + + e.stopPropagation()} + style={{ minWidth: '140px', maxWidth: '160px' }} + className='gap-[2px]' + > + { + e.stopPropagation() + setWrapText((prev) => !prev) + }} + > + Wrap text + + { + e.stopPropagation() + setOpenOnRun(!openOnRun) + }} + > + Open on run + + + { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog/trigger-warning-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog/trigger-warning-dialog.tsx deleted file mode 100644 index b6b2dd323..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog/trigger-warning-dialog.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' - -export enum TriggerWarningType { - DUPLICATE_TRIGGER = 'duplicate_trigger', - LEGACY_INCOMPATIBILITY = 'legacy_incompatibility', - TRIGGER_IN_SUBFLOW = 'trigger_in_subflow', -} - -interface TriggerWarningDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - triggerName: string - type: TriggerWarningType -} - -export function TriggerWarningDialog({ - open, - onOpenChange, - triggerName, - type, -}: TriggerWarningDialogProps) { - const getTitle = () => { - switch (type) { - case TriggerWarningType.LEGACY_INCOMPATIBILITY: - return 'Cannot mix trigger types' - case TriggerWarningType.DUPLICATE_TRIGGER: - return `Only one ${triggerName} trigger allowed` - case TriggerWarningType.TRIGGER_IN_SUBFLOW: - return 'Triggers not allowed in subflows' - } - } - - const getDescription = () => { - switch (type) { - case TriggerWarningType.LEGACY_INCOMPATIBILITY: - return 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' - case TriggerWarningType.DUPLICATE_TRIGGER: - return `A workflow can only have one ${triggerName} trigger block. Please remove the existing one before adding a new one.` - case TriggerWarningType.TRIGGER_IN_SUBFLOW: - return 'Triggers cannot be placed inside loop or parallel subflows.' - } - } - - return ( - - - - {getTitle()} - {getDescription()} - - - onOpenChange(false)}>Got it - - - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 03ab11d0a..be827c486 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -17,6 +17,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { CommandList, DiffControls, + Notifications, Panel, SubflowNodeComponent, Terminal, @@ -26,10 +27,6 @@ import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/ch import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' 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 { - TriggerWarningDialog, - TriggerWarningType, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog/trigger-warning-dialog' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { @@ -45,6 +42,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useStreamCleanup } from '@/hooks/use-stream-cleanup' import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions' import { useExecutionStore } from '@/stores/execution/store' +import { useNotificationStore } from '@/stores/notifications/store' import { useCopilotStore } from '@/stores/panel-new/copilot/store' import { usePanelEditorStore } from '@/stores/panel-new/editor/store' import { useGeneralStore } from '@/stores/settings/general/store' @@ -93,17 +91,6 @@ const WorkflowContent = React.memo(() => { // Enhanced edge selection with parent context and unique identifier const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) - // State for trigger warning dialog - const [triggerWarning, setTriggerWarning] = useState<{ - open: boolean - triggerName: string - type: TriggerWarningType - }>({ - open: false, - triggerName: '', - type: TriggerWarningType.DUPLICATE_TRIGGER, - }) - // Track whether the active connection drag started from an error handle const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) @@ -116,6 +103,9 @@ const WorkflowContent = React.memo(() => { // Get workspace ID from the params const workspaceId = params.workspaceId as string + // Notification store + const addNotification = useNotificationStore((state) => state.addNotification) + const { workflows, activeWorkflowId, isLoading, setActiveWorkflow } = useWorkflowRegistry() // Use the clean abstraction for current workflow state @@ -667,10 +657,10 @@ const WorkflowContent = React.memo(() => { if (isTriggerBlock) { const triggerName = TriggerUtils.getDefaultTriggerName(data.type) || 'trigger' - setTriggerWarning({ - open: true, - triggerName, - type: TriggerWarningType.TRIGGER_IN_SUBFLOW, + addNotification({ + level: 'error', + message: 'Triggers cannot be placed inside loop or parallel subflows.', + workflowId: activeWorkflowId || undefined, }) return } @@ -773,13 +763,14 @@ const WorkflowContent = React.memo(() => { // Centralized trigger constraints const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type) if (dropIssue) { - setTriggerWarning({ - open: true, - triggerName: dropIssue.triggerName, - type: - dropIssue.issue === 'legacy' - ? TriggerWarningType.LEGACY_INCOMPATIBILITY - : TriggerWarningType.DUPLICATE_TRIGGER, + const message = + dropIssue.issue === 'legacy' + ? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' + : `A workflow can only have one ${dropIssue.triggerName} trigger block. Please remove the existing one before adding a new one.` + addNotification({ + level: 'error', + message, + workflowId: activeWorkflowId || undefined, }) return } @@ -841,7 +832,8 @@ const WorkflowContent = React.memo(() => { isPointInLoopNode, resizeLoopNodesWrapper, addBlock, - setTriggerWarning, + addNotification, + activeWorkflowId, ] ) @@ -959,19 +951,15 @@ const WorkflowContent = React.memo(() => { // Centralized trigger constraints const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type) if (additionIssue) { - if (additionIssue.issue === 'legacy') { - setTriggerWarning({ - open: true, - triggerName: additionIssue.triggerName, - type: TriggerWarningType.LEGACY_INCOMPATIBILITY, - }) - } else { - setTriggerWarning({ - open: true, - triggerName: additionIssue.triggerName, - type: TriggerWarningType.DUPLICATE_TRIGGER, - }) - } + const message = + additionIssue.issue === 'legacy' + ? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' + : `A workflow can only have one ${additionIssue.triggerName} trigger block. Please remove the existing one before adding a new one.` + addNotification({ + level: 'error', + message, + workflowId: activeWorkflowId || undefined, + }) return } @@ -1006,7 +994,8 @@ const WorkflowContent = React.memo(() => { findClosestOutput, determineSourceHandle, effectivePermissions.canEdit, - setTriggerWarning, + addNotification, + activeWorkflowId, ]) /** @@ -1086,10 +1075,16 @@ const WorkflowContent = React.memo(() => { useEffect(() => { const handleShowTriggerWarning = (event: CustomEvent) => { const { type, triggerName } = event.detail - setTriggerWarning({ - open: true, - triggerName: triggerName || 'trigger', - type: type === 'trigger_in_subflow' ? TriggerWarningType.TRIGGER_IN_SUBFLOW : type, + const message = + type === 'trigger_in_subflow' + ? 'Triggers cannot be placed inside loop or parallel subflows.' + : type === 'legacy_incompatibility' + ? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' + : `A workflow can only have one ${triggerName || 'trigger'} trigger block. Please remove the existing one before adding a new one.` + addNotification({ + level: 'error', + message, + workflowId: activeWorkflowId || undefined, }) } @@ -1098,7 +1093,7 @@ const WorkflowContent = React.memo(() => { return () => { window.removeEventListener('show-trigger-warning', handleShowTriggerWarning as EventListener) } - }, [setTriggerWarning]) + }, [addNotification, activeWorkflowId]) // Update the onDrop handler to delegate to the shared toolbar-drop handler const onDrop = useCallback( @@ -1849,11 +1844,10 @@ const WorkflowContent = React.memo(() => { if (potentialParentId) { const block = blocks[node.id] if (block && TriggerUtils.isTriggerBlock(block)) { - const triggerName = TriggerUtils.getDefaultTriggerName(block.type) || 'trigger' - setTriggerWarning({ - open: true, - triggerName, - type: TriggerWarningType.TRIGGER_IN_SUBFLOW, + addNotification({ + level: 'error', + message: 'Triggers cannot be placed inside loop or parallel subflows.', + workflowId: activeWorkflowId || undefined, }) logger.warn('Prevented trigger block from being placed inside a container', { blockId: node.id, @@ -1967,6 +1961,8 @@ const WorkflowContent = React.memo(() => { getNodeAbsolutePosition, getDragStartPosition, setDragStartPosition, + addNotification, + activeWorkflowId, ] ) @@ -2165,13 +2161,8 @@ const WorkflowContent = React.memo(() => { {/* Show DiffControls if diff is available (regardless of current view mode) */} - {/* Trigger warning dialog */} - setTriggerWarning({ ...triggerWarning, open })} - triggerName={triggerWarning.triggerName} - type={triggerWarning.type} - /> + {/* Notifications display */} + {/* Trigger list for empty workflows - only show after workflow has loaded and hydrated */} {isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx index b6000160a..2c507da37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { ArrowDown, Plus, RefreshCw } from 'lucide-react' +import { ArrowDown, Plus } from 'lucide-react' import { Badge, Button, @@ -173,13 +173,6 @@ export function WorkspaceHeader({ const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null - /** - * Handles page refresh when disconnected - */ - const handleRefresh = () => { - window.location.reload() - } - /** * Handle right-click context menu */ @@ -272,23 +265,6 @@ export function WorkspaceHeader({ {/* Workspace Actions */}
- {/* Disconnection Indicator */} - {userPermissions.isOfflineMode && ( - - - - - Connection lost - refresh - - )} {/* Invite */} setIsInviteModalOpen(true)}> Invite diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 7d42a0d5f..eb6199370 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -51,7 +51,7 @@ import * as React from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' -import { ChevronLeft, ChevronRight, Search } from 'lucide-react' +import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react' import { cn } from '@/lib/utils' /** @@ -364,6 +364,11 @@ export interface PopoverItemProps extends React.HTMLAttributes { * Whether this item is disabled */ disabled?: boolean + /** + * Whether to show a checkmark when active + * @default false + */ + showCheck?: boolean } /** @@ -378,7 +383,7 @@ export interface PopoverItemProps extends React.HTMLAttributes { * ``` */ const PopoverItem = React.forwardRef( - ({ className, active, rootOnly, disabled, ...props }, ref) => { + ({ className, active, rootOnly, disabled, showCheck = false, children, ...props }, ref) => { // Try to get context - if not available, we're outside Popover (shouldn't happen) const context = React.useContext(PopoverContext) const variant = context?.variant || 'default' @@ -401,7 +406,10 @@ const PopoverItem = React.forwardRef( aria-selected={active} aria-disabled={disabled} {...props} - /> + > + {children} + {showCheck && active && } +
) } ) diff --git a/apps/sim/stores/notifications/index.ts b/apps/sim/stores/notifications/index.ts new file mode 100644 index 000000000..3c97363da --- /dev/null +++ b/apps/sim/stores/notifications/index.ts @@ -0,0 +1,7 @@ +export type { + AddNotificationParams, + Notification, + NotificationAction, +} from './store' +export { useNotificationStore } from './store' +export { openCopilotWithMessage } from './utils' diff --git a/apps/sim/stores/notifications/store.ts b/apps/sim/stores/notifications/store.ts new file mode 100644 index 000000000..5aec5eb95 --- /dev/null +++ b/apps/sim/stores/notifications/store.ts @@ -0,0 +1,155 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('NotificationStore') + +/** + * Notification action configuration + * Stores serializable data - handlers are reconstructed at runtime + */ +export interface NotificationAction { + /** + * Action type identifier for handler reconstruction + */ + type: 'copilot' | 'refresh' + + /** + * Message or data to pass to the action handler. + * + * For: + * - {@link NotificationAction.type} = `copilot` - message sent to Copilot + * - {@link NotificationAction.type} = `refresh` - optional context, not required for the action + */ + message: string +} + +/** + * Core notification data structure + */ +export interface Notification { + /** + * Unique identifier for the notification + */ + id: string + + /** + * Notification severity level + */ + level: 'info' | 'error' + + /** + * Message to display to the user + */ + message: string + + /** + * Optional action to execute when user clicks the action button + */ + action?: NotificationAction + + /** + * Timestamp when notification was created + */ + createdAt: number + + /** + * Optional workflow ID - if provided, notification is workflow-specific + * If omitted, notification is shown across all workflows + */ + workflowId?: string +} + +/** + * Parameters for adding a new notification + * Omits auto-generated fields (id, createdAt) + */ +export type AddNotificationParams = Omit + +interface NotificationStore { + /** + * Array of active notifications (newest first) + */ + notifications: Notification[] + + /** + * Adds a new notification to the stack + * + * @param params - Notification parameters + * @returns The created notification ID + */ + addNotification: (params: AddNotificationParams) => string + + /** + * Removes a notification by ID + * + * @param id - Notification ID to remove + */ + removeNotification: (id: string) => void + + /** + * Gets notifications for a specific workflow + * Returns both global notifications (no workflowId) and workflow-specific notifications + * + * @param workflowId - The workflow ID to filter by + * @returns Array of notifications for the workflow + */ + getNotificationsForWorkflow: (workflowId: string) => Notification[] +} + +export const useNotificationStore = create()( + persist( + (set, get) => ({ + notifications: [], + + addNotification: (params: AddNotificationParams) => { + const id = crypto.randomUUID() + + const notification: Notification = { + id, + level: params.level, + message: params.message, + action: params.action, + createdAt: Date.now(), + workflowId: params.workflowId, + } + + set((state) => ({ + notifications: [notification, ...state.notifications], + })) + + logger.info('Notification added', { + id, + level: params.level, + message: params.message, + workflowId: params.workflowId, + actionType: params.action?.type, + }) + + return id + }, + + removeNotification: (id: string) => { + set((state) => ({ + notifications: state.notifications.filter((n) => n.id !== id), + })) + + logger.info('Notification removed', { id }) + }, + + getNotificationsForWorkflow: (workflowId: string) => { + return get().notifications.filter((n) => !n.workflowId || n.workflowId === workflowId) + }, + }), + { + name: 'notification-storage', + /** + * Only persist workflow-level notifications. + * Global notifications (without a workflowId) are kept in memory only. + */ + partialize: (state): Pick => ({ + notifications: state.notifications.filter((notification) => !!notification.workflowId), + }), + } + ) +) diff --git a/apps/sim/stores/notifications/utils.ts b/apps/sim/stores/notifications/utils.ts new file mode 100644 index 000000000..1c522e81c --- /dev/null +++ b/apps/sim/stores/notifications/utils.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@/lib/logs/console/logger' +import { useCopilotStore } from '@/stores/panel-new/copilot/store' +import { usePanelStore } from '@/stores/panel-new/store' + +const logger = createLogger('NotificationUtils') + +/** + * Opens the copilot panel and directly sends the message. + * + * @param message - The message to send in the copilot. + */ +export function openCopilotWithMessage(message: string): void { + try { + const trimmedMessage = message.trim() + + // Avoid sending empty/whitespace messages + if (!trimmedMessage) { + logger.warn('openCopilotWithMessage called with empty message') + return + } + + // Switch to copilot tab + const panelStore = usePanelStore.getState() + panelStore.setActiveTab('copilot') + + // Read current copilot state + const copilotStore = useCopilotStore.getState() + + // If workflowId is not set, sendMessage will early-return; surface that explicitly + if (!copilotStore.workflowId) { + logger.warn('Copilot workflowId is not set, skipping sendMessage', { + messageLength: trimmedMessage.length, + }) + return + } + + // Avoid overlapping sends; let existing stream finish/abort first + if (copilotStore.isSendingMessage) { + logger.warn('Copilot is already sending a message, skipping new send', { + messageLength: trimmedMessage.length, + }) + return + } + + const messageWithInstructions = `${trimmedMessage}\n\nPlease fix this.` + + void copilotStore.sendMessage(messageWithInstructions, { stream: true }).catch((error) => { + logger.error('Failed to send message to copilot', { error }) + }) + + logger.info('Opened copilot and sent message', { messageLength: trimmedMessage.length }) + } catch (error) { + logger.error('Failed to open copilot with message', { error }) + } +} diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 36a8a0f39..9d375b348 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -1,10 +1,14 @@ import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' +import { createLogger } from '@/lib/logs/console/logger' import { redactApiKeys } from '@/lib/utils' import type { NormalizedBlockOutput } from '@/executor/types' import { useExecutionStore } from '@/stores/execution/store' +import { useNotificationStore } from '@/stores/notifications' import type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types' +const logger = createLogger('TerminalConsoleStore') + /** * Updates a NormalizedBlockOutput with new content */ @@ -95,7 +99,31 @@ export const useTerminalConsoleStore = create()( return { entries: [newEntry, ...state.entries] } }) - return get().entries[0] + const newEntry = get().entries[0] + + // Surface error notifications immediately when error entries are added + if (newEntry?.error) { + try { + const errorMessage = String(newEntry.error) + + useNotificationStore.getState().addNotification({ + level: 'error', + message: errorMessage, + workflowId: entry.workflowId, + action: { + type: 'copilot', + message: errorMessage, + }, + }) + } catch (notificationError) { + logger.error('Failed to create block error notification', { + entryId: newEntry.id, + error: notificationError, + }) + } + } + + return newEntry }, /** diff --git a/apps/sim/stores/terminal/index.ts b/apps/sim/stores/terminal/index.ts index f771a5a91..fc88ce281 100644 --- a/apps/sim/stores/terminal/index.ts +++ b/apps/sim/stores/terminal/index.ts @@ -1,3 +1,3 @@ export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console' export { useTerminalConsoleStore } from './console' -export { DEFAULT_TERMINAL_HEIGHT, useTerminalStore } from './store' +export { DEFAULT_TERMINAL_HEIGHT, MIN_TERMINAL_HEIGHT, useTerminalStore } from './store' diff --git a/apps/sim/stores/terminal/store.ts b/apps/sim/stores/terminal/store.ts index e556a671c..65c99366e 100644 --- a/apps/sim/stores/terminal/store.ts +++ b/apps/sim/stores/terminal/store.ts @@ -14,6 +14,8 @@ interface TerminalState { setTerminalHeight: (height: number) => void outputPanelWidth: number setOutputPanelWidth: (width: number) => void + openOnRun: boolean + setOpenOnRun: (open: boolean) => void // displayMode: DisplayMode // setDisplayMode: (mode: DisplayMode) => void _hasHydrated: boolean @@ -24,7 +26,7 @@ interface TerminalState { * Terminal height constraints * Note: Maximum height is enforced dynamically at 70% of viewport height in the resize hook */ -const MIN_TERMINAL_HEIGHT = 30 +export const MIN_TERMINAL_HEIGHT = 30 export const DEFAULT_TERMINAL_HEIGHT = 196 /** @@ -56,6 +58,10 @@ export const useTerminalStore = create()( const clampedWidth = Math.max(MIN_OUTPUT_PANEL_WIDTH, width) set({ outputPanelWidth: clampedWidth }) }, + openOnRun: true, + setOpenOnRun: (open) => { + set({ openOnRun: open }) + }, // displayMode: DEFAULT_DISPLAY_MODE, // setDisplayMode: (mode) => { // set({ displayMode: mode })