Skip to content

Commit e0aade8

Browse files
authored
feat: notification store (#2025)
* feat: notification store * feat: notification stack; improvement: chat output select
1 parent 33ca148 commit e0aade8

File tree

17 files changed

+562
-225
lines changed

17 files changed

+562
-225
lines changed

apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useWorkspacePermissions,
1111
type WorkspacePermissions,
1212
} from '@/hooks/use-workspace-permissions'
13+
import { useNotificationStore } from '@/stores/notifications'
1314

1415
const logger = createLogger('WorkspacePermissionsProvider')
1516

@@ -60,16 +61,46 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
6061
// Manage offline mode state locally
6162
const [isOfflineMode, setIsOfflineMode] = useState(false)
6263

64+
// Track whether we've already surfaced an offline notification to avoid duplicates
65+
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
66+
6367
// Get operation error state from collaborative workflow
6468
const { hasOperationError } = useCollaborativeWorkflow()
6569

70+
const addNotification = useNotificationStore((state) => state.addNotification)
71+
6672
// Set offline mode when there are operation errors
6773
useEffect(() => {
6874
if (hasOperationError) {
6975
setIsOfflineMode(true)
7076
}
7177
}, [hasOperationError])
7278

79+
/**
80+
* Surface a global notification when entering offline mode.
81+
* Uses the shared notifications system instead of bespoke UI in individual components.
82+
*/
83+
useEffect(() => {
84+
if (!isOfflineMode || hasShownOfflineNotification) {
85+
return
86+
}
87+
88+
try {
89+
addNotification({
90+
level: 'error',
91+
message: 'Connection unavailable',
92+
// Global notification (no workflowId) so it is visible regardless of the active workflow
93+
action: {
94+
type: 'refresh',
95+
message: '',
96+
},
97+
})
98+
setHasShownOfflineNotification(true)
99+
} catch (error) {
100+
logger.error('Failed to add offline notification', { error })
101+
}
102+
}, [addNotification, hasShownOfflineNotification, isOfflineMode])
103+
73104
// Fetch workspace permissions and loading state
74105
const {
75106
permissions: workspacePermissions,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ export function Chat() {
601601
disabled={!activeWorkflowId}
602602
placeholder='Select outputs'
603603
align='end'
604+
maxHeight={180}
604605
/>
605606
</div>
606607

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface OutputSelectProps {
2424
placeholder?: string
2525
valueMode?: 'id' | 'label'
2626
align?: 'start' | 'end' | 'center'
27+
maxHeight?: number
2728
}
2829

2930
export function OutputSelect({
@@ -34,6 +35,7 @@ export function OutputSelect({
3435
placeholder = 'Select outputs',
3536
valueMode = 'id',
3637
align = 'start',
38+
maxHeight = 300,
3739
}: OutputSelectProps) {
3840
const [open, setOpen] = useState(false)
3941
const [highlightedIndex, setHighlightedIndex] = useState(-1)
@@ -369,9 +371,9 @@ export function OutputSelect({
369371
side='bottom'
370372
align={align}
371373
sideOffset={4}
372-
maxHeight={300}
373-
maxWidth={300}
374-
minWidth={200}
374+
maxHeight={maxHeight}
375+
maxWidth={160}
376+
minWidth={160}
375377
onKeyDown={handleKeyDown}
376378
tabIndex={0}
377379
style={{ outline: 'none' }}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { ControlBar } from './control-bar/control-bar'
33
export { Cursors } from './cursors/cursors'
44
export { DiffControls } from './diff-controls/diff-controls'
55
export { ErrorBoundary } from './error/index'
6+
export { Notifications } from './notifications/notifications'
67
export { Panel } from './panel-new/panel-new'
78
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
89
export { SubflowNodeComponent } from './subflows/subflow-node'
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { memo, useCallback } from 'react'
2+
import { X } from 'lucide-react'
3+
import { useParams } from 'next/navigation'
4+
import { Button } from '@/components/emcn'
5+
import { createLogger } from '@/lib/logs/console/logger'
6+
import {
7+
type NotificationAction,
8+
openCopilotWithMessage,
9+
useNotificationStore,
10+
} from '@/stores/notifications'
11+
12+
const logger = createLogger('Notifications')
13+
const MAX_VISIBLE_NOTIFICATIONS = 4
14+
15+
/**
16+
* Notifications display component
17+
* Positioned in the bottom-right workspace area, aligned with terminal and panel spacing
18+
* Shows both global notifications and workflow-specific notifications
19+
*/
20+
export const Notifications = memo(function Notifications() {
21+
const params = useParams()
22+
const workflowId = params.workflowId as string
23+
24+
const notifications = useNotificationStore((state) =>
25+
state.notifications.filter((n) => !n.workflowId || n.workflowId === workflowId)
26+
)
27+
const removeNotification = useNotificationStore((state) => state.removeNotification)
28+
const visibleNotifications = notifications.slice(0, MAX_VISIBLE_NOTIFICATIONS)
29+
30+
/**
31+
* Executes a notification action and handles side effects.
32+
*
33+
* @param notificationId - The ID of the notification whose action is executed.
34+
* @param action - The action configuration to execute.
35+
*/
36+
const executeAction = useCallback(
37+
(notificationId: string, action: NotificationAction) => {
38+
try {
39+
logger.info('Executing notification action', {
40+
notificationId,
41+
actionType: action.type,
42+
messageLength: action.message.length,
43+
})
44+
45+
switch (action.type) {
46+
case 'copilot':
47+
openCopilotWithMessage(action.message)
48+
break
49+
case 'refresh':
50+
window.location.reload()
51+
break
52+
default:
53+
logger.warn('Unknown action type', { notificationId, actionType: action.type })
54+
}
55+
56+
// Dismiss the notification after the action is triggered
57+
removeNotification(notificationId)
58+
} catch (error) {
59+
logger.error('Failed to execute notification action', {
60+
notificationId,
61+
actionType: action.type,
62+
error,
63+
})
64+
}
65+
},
66+
[removeNotification]
67+
)
68+
69+
if (visibleNotifications.length === 0) {
70+
return null
71+
}
72+
73+
return (
74+
<div className='fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end'>
75+
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
76+
const depth = stacked.length - index - 1
77+
const xOffset = depth * 3
78+
79+
return (
80+
<div
81+
key={notification.id}
82+
style={{ transform: `translateX(${xOffset}px)` }}
83+
className={`relative w-[240px] rounded-[4px] border bg-[#232323] transition-transform duration-200 ${
84+
index > 0 ? '-mt-[78px]' : ''
85+
}`}
86+
>
87+
<div className='flex flex-col gap-[6px] px-[8px] pt-[6px] pb-[8px]'>
88+
<div className='line-clamp-6 font-medium text-[12px] leading-[16px]'>
89+
<Button
90+
variant='ghost'
91+
onClick={() => removeNotification(notification.id)}
92+
aria-label='Dismiss notification'
93+
className='!p-1.5 -m-1.5 float-right ml-[16px]'
94+
>
95+
<X className='h-3 w-3' />
96+
</Button>
97+
{notification.level === 'error' && (
98+
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
99+
)}
100+
{notification.message}
101+
</div>
102+
{notification.action && (
103+
<Button
104+
variant='active'
105+
onClick={() => executeAction(notification.id, notification.action!)}
106+
className='px-[8px] py-[4px] font-medium text-[12px]'
107+
>
108+
{notification.action.type === 'copilot'
109+
? 'Fix in Copilot'
110+
: notification.action.type === 'refresh'
111+
? 'Refresh'
112+
: 'Take action'}
113+
</Button>
114+
)}
115+
</div>
116+
</div>
117+
)
118+
})}
119+
</div>
120+
)
121+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspace
3232
import { useChatStore } from '@/stores/chat/store'
3333
import { usePanelStore } from '@/stores/panel-new/store'
3434
import type { PanelTab } from '@/stores/panel-new/types'
35+
import { DEFAULT_TERMINAL_HEIGHT, MIN_TERMINAL_HEIGHT, useTerminalStore } from '@/stores/terminal'
3536
import { useVariablesStore } from '@/stores/variables/store'
3637
import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
3738
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -130,6 +131,10 @@ export function Panel() {
130131
openSubscriptionSettings()
131132
return
132133
}
134+
const { openOnRun, terminalHeight, setTerminalHeight } = useTerminalStore.getState()
135+
if (openOnRun && terminalHeight <= MIN_TERMINAL_HEIGHT) {
136+
setTerminalHeight(DEFAULT_TERMINAL_HEIGHT)
137+
}
133138
await handleRunWorkflow()
134139
}, [usageExceeded, handleRunWorkflow])
135140

0 commit comments

Comments
 (0)