mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-30 17:28:11 -05:00
feat: notification store (#2025)
* feat: notification store * feat: notification stack; improvement: chat output select
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -601,6 +601,7 @@ export function Chat() {
|
||||
disabled={!activeWorkflowId}
|
||||
placeholder='Select outputs'
|
||||
align='end'
|
||||
maxHeight={180}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<div className='fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end'>
|
||||
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
||||
const depth = stacked.length - index - 1
|
||||
const xOffset = depth * 3
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
style={{ transform: `translateX(${xOffset}px)` }}
|
||||
className={`relative w-[240px] rounded-[4px] border bg-[#232323] transition-transform duration-200 ${
|
||||
index > 0 ? '-mt-[78px]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-col gap-[6px] px-[8px] pt-[6px] pb-[8px]'>
|
||||
<div className='line-clamp-6 font-medium text-[12px] leading-[16px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
aria-label='Dismiss notification'
|
||||
className='!p-1.5 -m-1.5 float-right ml-[16px]'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
{notification.level === 'error' && (
|
||||
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{notification.message}
|
||||
</div>
|
||||
{notification.action && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => executeAction(notification.id, notification.action!)}
|
||||
className='px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
{notification.action.type === 'copilot'
|
||||
? 'Fix in Copilot'
|
||||
: notification.action.type === 'refresh'
|
||||
? 'Refresh'
|
||||
: 'Take action'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Popover open={mainOptionsOpen} onOpenChange={setMainOptionsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
aria-label='Terminal options'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<MoreHorizontal className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
collisionPadding={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ minWidth: '140px', maxWidth: '160px' }}
|
||||
className='gap-[2px]'
|
||||
>
|
||||
<PopoverItem
|
||||
active={openOnRun}
|
||||
showCheck
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenOnRun(!openOnRun)
|
||||
}}
|
||||
>
|
||||
<span>Open on run</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ToggleButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={(e) => {
|
||||
@@ -1143,72 +1180,6 @@ export function Terminal() {
|
||||
<span>{showCopySuccess ? 'Copied' : 'Copy output'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setWrapText((prev) => !prev)
|
||||
}}
|
||||
aria-label='Toggle text wrap'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
{wrapText ? (
|
||||
<Wrap className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<NoWrap className='h-3.5 w-3.5' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{wrapText ? 'Wrap text' : 'No wrap'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/* <Popover open={displayPopoverOpen} onOpenChange={setDisplayPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
aria-label='Display options'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<MoreHorizontal className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
collisionPadding={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PopoverSection>Display</PopoverSection>
|
||||
<PopoverItem
|
||||
active={displayMode === 'prettier'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDisplayMode('prettier')
|
||||
setDisplayPopoverOpen(false)
|
||||
}}
|
||||
>
|
||||
<span>Prettier</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={displayMode === 'raw'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDisplayMode('raw')
|
||||
setDisplayPopoverOpen(false)
|
||||
}}
|
||||
className='mt-[2px]'
|
||||
>
|
||||
<span>Raw</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover> */}
|
||||
{hasActiveFilters && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -1246,6 +1217,50 @@ export function Terminal() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
aria-label='Terminal options'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<MoreHorizontal className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
collisionPadding={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ minWidth: '140px', maxWidth: '160px' }}
|
||||
className='gap-[2px]'
|
||||
>
|
||||
<PopoverItem
|
||||
active={wrapText}
|
||||
showCheck
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setWrapText((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
<span>Wrap text</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={openOnRun}
|
||||
showCheck
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenOnRun(!openOnRun)
|
||||
}}
|
||||
>
|
||||
<span>Open on run</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ToggleButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -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 (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getDescription()}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => onOpenChange(false)}>Got it</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -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<SelectedEdgeInfo | null>(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) */}
|
||||
<DiffControls />
|
||||
|
||||
{/* Trigger warning dialog */}
|
||||
<TriggerWarningDialog
|
||||
open={triggerWarning.open}
|
||||
onOpenChange={(open) => setTriggerWarning({ ...triggerWarning, open })}
|
||||
triggerName={triggerWarning.triggerName}
|
||||
type={triggerWarning.type}
|
||||
/>
|
||||
{/* Notifications display */}
|
||||
<Notifications />
|
||||
|
||||
{/* Trigger list for empty workflows - only show after workflow has loaded and hydrated */}
|
||||
{isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && <CommandList />}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
{/* Workspace Actions */}
|
||||
<div className='flex items-center gap-[10px]'>
|
||||
{/* Disconnection Indicator */}
|
||||
{userPermissions.isOfflineMode && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
type='button'
|
||||
aria-label='Connection lost - click to refresh'
|
||||
className='group !p-[3px] -m-[3px]'
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='h-[14px] w-[14px] text-[var(--text-error)] dark:text-[var(--text-error)]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Connection lost - refresh</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Invite */}
|
||||
<Badge className='cursor-pointer' onClick={() => setIsInviteModalOpen(true)}>
|
||||
Invite
|
||||
|
||||
@@ -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<HTMLDivElement> {
|
||||
* 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<HTMLDivElement> {
|
||||
* ```
|
||||
*/
|
||||
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
({ 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<HTMLDivElement, PopoverItemProps>(
|
||||
aria-selected={active}
|
||||
aria-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
{showCheck && active && <Check className='ml-auto h-[12px] w-[12px]' />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
7
apps/sim/stores/notifications/index.ts
Normal file
7
apps/sim/stores/notifications/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
AddNotificationParams,
|
||||
Notification,
|
||||
NotificationAction,
|
||||
} from './store'
|
||||
export { useNotificationStore } from './store'
|
||||
export { openCopilotWithMessage } from './utils'
|
||||
155
apps/sim/stores/notifications/store.ts
Normal file
155
apps/sim/stores/notifications/store.ts
Normal file
@@ -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<Notification, 'id' | 'createdAt'>
|
||||
|
||||
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<NotificationStore>()(
|
||||
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<NotificationStore, 'notifications'> => ({
|
||||
notifications: state.notifications.filter((notification) => !!notification.workflowId),
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
55
apps/sim/stores/notifications/utils.ts
Normal file
55
apps/sim/stores/notifications/utils.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<ConsoleStore>()(
|
||||
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
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<TerminalState>()(
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user