From e0aade85a63804617bf39bdde7edcbf2f558f385 Mon Sep 17 00:00:00 2001
From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Date: Tue, 18 Nov 2025 10:43:24 -0800
Subject: [PATCH] feat: notification store (#2025)
* feat: notification store
* feat: notification stack; improvement: chat output select
---
.../workspace-permissions-provider.tsx | 31 ++++
.../w/[workflowId]/components/chat/chat.tsx | 1 +
.../output-select/output-select.tsx | 8 +-
.../w/[workflowId]/components/index.ts | 1 +
.../notifications/notifications.tsx | 121 ++++++++++++++
.../components/panel-new/panel-new.tsx | 5 +
.../components/terminal/terminal.tsx | 151 +++++++++--------
.../trigger-warning-dialog.tsx | 65 --------
.../[workspaceId]/w/[workflowId]/workflow.tsx | 107 ++++++------
.../workspace-header/workspace-header.tsx | 26 +--
.../emcn/components/popover/popover.tsx | 14 +-
apps/sim/stores/notifications/index.ts | 7 +
apps/sim/stores/notifications/store.ts | 155 ++++++++++++++++++
apps/sim/stores/notifications/utils.ts | 55 +++++++
apps/sim/stores/terminal/console/store.ts | 30 +++-
apps/sim/stores/terminal/index.ts | 2 +-
apps/sim/stores/terminal/store.ts | 8 +-
17 files changed, 562 insertions(+), 225 deletions(-)
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx
delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog/trigger-warning-dialog.tsx
create mode 100644 apps/sim/stores/notifications/index.ts
create mode 100644 apps/sim/stores/notifications/store.ts
create mode 100644 apps/sim/stores/notifications/utils.ts
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 })