Skip to content

Commit 79f8f34

Browse files
committed
fix(ui/ux): console and notifications
1 parent 8e06c7d commit 79f8f34

File tree

6 files changed

+310
-77
lines changed

6 files changed

+310
-77
lines changed

sim/app/w/[id]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ErrorIcon } from '@/components/icons'
55
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
66
import { cn } from '@/lib/utils'
77
import { useNotificationStore } from '@/stores/notifications/store'
8-
import { NotificationOptions, NotificationType } from '@/stores/notifications/types'
8+
import { Notification, NotificationOptions, NotificationType } from '@/stores/notifications/types'
99

1010
interface NotificationDropdownItemProps {
1111
id: string
@@ -40,7 +40,8 @@ export function NotificationDropdownItem({
4040
options,
4141
setDropdownOpen,
4242
}: NotificationDropdownItemProps) {
43-
const { showNotification } = useNotificationStore()
43+
const { notifications, showNotification, hideNotification, removeNotification, addNotification } =
44+
useNotificationStore()
4445
const Icon = NotificationIcon[type]
4546
const [, forceUpdate] = useState({})
4647

@@ -50,11 +51,59 @@ export function NotificationDropdownItem({
5051
return () => clearInterval(interval)
5152
}, [])
5253

54+
// Find the full notification object from the store
55+
const getFullNotification = (): Notification | undefined => {
56+
return notifications.find((n) => n.id === id)
57+
}
58+
5359
// Handle click to show the notification
5460
const handleClick = (e: React.MouseEvent) => {
5561
e.preventDefault()
5662
e.stopPropagation()
57-
showNotification(id)
63+
64+
const notification = getFullNotification()
65+
66+
if (notification) {
67+
// For persistent notifications like API info, just re-show them
68+
if (notification.options?.isPersistent) {
69+
showNotification(id)
70+
} else {
71+
// For non-persistent notifications, we have different strategies:
72+
73+
if (notification.isVisible) {
74+
if (notification.isFading) {
75+
// If it's currently fading, remove and re-add it to restart animation sequence
76+
removeNotification(id)
77+
78+
// Re-add with same properties but new ID
79+
addNotification(
80+
notification.type,
81+
notification.message,
82+
notification.workflowId,
83+
notification.options
84+
)
85+
} else {
86+
// If visible but not fading, just make sure it's at the top of the stack
87+
showNotification(id)
88+
}
89+
} else {
90+
// If not visible, we re-add it instead of just showing it
91+
// This ensures a fresh animation sequence
92+
93+
// Create a new notification with same properties
94+
addNotification(
95+
notification.type,
96+
notification.message,
97+
notification.workflowId,
98+
notification.options
99+
)
100+
}
101+
}
102+
} else {
103+
// Fallback for any case where the notification doesn't exist anymore
104+
addNotification(type, message, null, options)
105+
}
106+
58107
// Close the dropdown after clicking
59108
if (setDropdownOpen) {
60109
setDropdownOpen(false)

sim/app/w/[id]/components/notifications/notifications.tsx

Lines changed: 111 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@ import { Button } from '@/components/ui/button'
1616
import { CopyButton } from '@/components/ui/copy-button'
1717
import { createLogger } from '@/lib/logs/console-logger'
1818
import { cn } from '@/lib/utils'
19-
import { useNotificationStore } from '@/stores/notifications/store'
19+
import {
20+
MAX_VISIBLE_NOTIFICATIONS,
21+
NOTIFICATION_TIMEOUT,
22+
useNotificationStore,
23+
} from '@/stores/notifications/store'
2024
import { Notification } from '@/stores/notifications/types'
2125
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2226
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
2327

2428
const logger = createLogger('Notifications')
2529

2630
// Constants
27-
const NOTIFICATION_TIMEOUT = 4000 // Show notification for 4 seconds
2831
const FADE_DURATION = 500 // Fade out over 500ms
2932

3033
// Define keyframes for the animations in a style tag
@@ -52,13 +55,33 @@ const AnimationStyles = () => (
5255
}
5356
}
5457
58+
@keyframes notification-slide-up {
59+
0% {
60+
transform: translateY(0);
61+
}
62+
100% {
63+
transform: translateY(-100%);
64+
}
65+
}
66+
5567
.animate-notification-slide {
5668
animation: notification-slide 300ms ease forwards;
5769
}
5870
5971
.animate-notification-fade-out {
6072
animation: notification-fade-out ${FADE_DURATION}ms ease forwards;
6173
}
74+
75+
.animate-notification-slide-up {
76+
animation: notification-slide-up 300ms ease forwards;
77+
}
78+
79+
.notification-container {
80+
transition:
81+
height 300ms ease,
82+
opacity 300ms ease,
83+
transform 300ms ease;
84+
}
6285
`}</style>
6386
)
6487

@@ -129,12 +152,19 @@ function DeleteApiConfirmation({
129152
*/
130153
export function NotificationList() {
131154
// Store access
132-
const { notifications, hideNotification, markAsRead, removeNotification } = useNotificationStore()
155+
const {
156+
notifications,
157+
hideNotification,
158+
markAsRead,
159+
removeNotification,
160+
setNotificationFading,
161+
getVisibleNotificationCount,
162+
} = useNotificationStore()
133163
const { activeWorkflowId } = useWorkflowRegistry()
134164

135165
// Local state
136-
const [fadingNotifications, setFadingNotifications] = useState<Set<string>>(new Set())
137166
const [removedIds, setRemovedIds] = useState<Set<string>>(new Set())
167+
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set())
138168

139169
// Filter to only show:
140170
// 1. Visible notifications for the current workflow
@@ -148,6 +178,10 @@ export function NotificationList() {
148178
!removedIds.has(n.id)
149179
)
150180

181+
// Check if we're over the limit of visible notifications
182+
const visibleCount = activeWorkflowId ? getVisibleNotificationCount(activeWorkflowId) : 0
183+
const isOverLimit = visibleCount > MAX_VISIBLE_NOTIFICATIONS
184+
151185
// Reset removedIds whenever a notification's visibility changes from false to true
152186
useEffect(() => {
153187
const newlyVisibleNotifications = notifications.filter(
@@ -160,51 +194,46 @@ export function NotificationList() {
160194
newlyVisibleNotifications.forEach((n) => next.delete(n.id))
161195
return next
162196
})
163-
164-
// Also reset fading state for these notifications
165-
setFadingNotifications((prev) => {
166-
const next = new Set(prev)
167-
newlyVisibleNotifications.forEach((n) => next.delete(n.id))
168-
return next
169-
})
170197
}
171198
}, [notifications, removedIds])
172199

173-
// Handle auto-dismissal of non-persistent notifications
200+
// Handle fading notifications created by the store
174201
useEffect(() => {
175-
// Setup timers for each notification
176-
const timers: ReturnType<typeof setTimeout>[] = []
202+
// This effect watches for notifications that are fading
203+
// and handles the DOM removal after animation completes
204+
205+
const timers: Record<string, ReturnType<typeof setTimeout>> = {}
177206

178207
visibleNotifications.forEach((notification) => {
179-
// Skip if already hidden or marked as persistent
180-
if (!notification.isVisible || notification.options?.isPersistent) return
181-
182-
// Start fade out animation
183-
const fadeTimer = setTimeout(() => {
184-
setFadingNotifications((prev) => new Set([...prev, notification.id]))
185-
}, NOTIFICATION_TIMEOUT)
186-
187-
// Hide notification after fade completes and mark for removal from DOM
188-
const hideTimer = setTimeout(() => {
189-
hideNotification(notification.id)
190-
markAsRead(notification.id)
191-
192-
// Mark this notification ID as removed to exclude it from rendering
193-
setRemovedIds((prev) => new Set([...prev, notification.id]))
194-
195-
setFadingNotifications((prev) => {
196-
const next = new Set(prev)
197-
next.delete(notification.id)
198-
return next
199-
})
200-
}, NOTIFICATION_TIMEOUT + FADE_DURATION)
201-
202-
timers.push(fadeTimer, hideTimer)
208+
// For notifications that have started fading, set up cleanup timers
209+
if (notification.isFading && !animatingIds.has(notification.id)) {
210+
// Start slide up animation after fade animation
211+
const slideTimer = setTimeout(() => {
212+
setAnimatingIds((prev) => new Set([...prev, notification.id]))
213+
214+
// After slide animation, remove from DOM
215+
setTimeout(() => {
216+
hideNotification(notification.id)
217+
markAsRead(notification.id)
218+
setRemovedIds((prev) => new Set([...prev, notification.id]))
219+
220+
// Remove from animating set
221+
setAnimatingIds((prev) => {
222+
const next = new Set(prev)
223+
next.delete(notification.id)
224+
return next
225+
})
226+
}, 300)
227+
}, FADE_DURATION)
228+
229+
timers[notification.id] = slideTimer
230+
}
203231
})
204232

205-
// Cleanup timers on unmount or when notifications change
206-
return () => timers.forEach(clearTimeout)
207-
}, [visibleNotifications, hideNotification, markAsRead])
233+
return () => {
234+
Object.values(timers).forEach(clearTimeout)
235+
}
236+
}, [visibleNotifications, animatingIds, hideNotification, markAsRead])
208237

209238
// Early return if no notifications to show
210239
if (visibleNotifications.length === 0) return null
@@ -220,26 +249,48 @@ export function NotificationList() {
220249
}}
221250
>
222251
{visibleNotifications.map((notification) => (
223-
<NotificationAlert
252+
<div
224253
key={notification.id}
225-
notification={notification}
226-
isFading={fadingNotifications.has(notification.id)}
227-
onHide={(id) => {
228-
hideNotification(id)
229-
markAsRead(id)
230-
// Start the fade out animation
231-
setFadingNotifications((prev) => new Set([...prev, id]))
232-
// Remove from DOM after animation completes
233-
setTimeout(() => {
234-
setRemovedIds((prev) => new Set([...prev, id]))
235-
setFadingNotifications((prev) => {
236-
const next = new Set(prev)
237-
next.delete(id)
238-
return next
239-
})
240-
}, FADE_DURATION)
241-
}}
242-
/>
254+
className={cn(
255+
'notification-container',
256+
animatingIds.has(notification.id) && 'animate-notification-slide-up'
257+
)}
258+
>
259+
<NotificationAlert
260+
notification={notification}
261+
isFading={notification.isFading ?? false}
262+
onHide={(id) => {
263+
// For persistent notifications like API, just hide immediately without animations
264+
if (notification.options?.isPersistent) {
265+
hideNotification(id)
266+
markAsRead(id)
267+
setRemovedIds((prev) => new Set([...prev, id]))
268+
return
269+
}
270+
271+
// For regular notifications, use the animation sequence
272+
// Start the fade out animation when manually closing
273+
setNotificationFading(id)
274+
275+
// Start slide up animation after fade completes
276+
setTimeout(() => {
277+
setAnimatingIds((prev) => new Set([...prev, id]))
278+
}, FADE_DURATION)
279+
280+
// Remove from DOM after all animations complete
281+
setTimeout(() => {
282+
hideNotification(id)
283+
markAsRead(id)
284+
setRemovedIds((prev) => new Set([...prev, id]))
285+
setAnimatingIds((prev) => {
286+
const next = new Set(prev)
287+
next.delete(id)
288+
return next
289+
})
290+
}, FADE_DURATION + 300) // Fade + slide durations
291+
}}
292+
/>
293+
</div>
243294
))}
244295
</div>
245296
</>

sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,41 @@ interface ConsoleEntryProps {
1010
consoleWidth: number
1111
}
1212

13+
// Maximum character length for a word before it's broken up
14+
const MAX_WORD_LENGTH = 25
15+
16+
const WordWrap = ({ text }: { text: string }) => {
17+
if (!text) return null
18+
19+
// Split text into words, keeping spaces and punctuation
20+
const parts = text.split(/(\s+)/g)
21+
22+
return (
23+
<>
24+
{parts.map((part, index) => {
25+
// If the part is whitespace or shorter than the max length, render it as is
26+
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
27+
return <span key={index}>{part}</span>
28+
}
29+
30+
// For long words, break them up into chunks
31+
const chunks = []
32+
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
33+
chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
34+
}
35+
36+
return (
37+
<span key={index} className="break-all">
38+
{chunks.map((chunk, chunkIndex) => (
39+
<span key={chunkIndex}>{chunk}</span>
40+
))}
41+
</span>
42+
)
43+
})}
44+
</>
45+
)
46+
}
47+
1348
export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
1449
const [isExpanded, setIsExpanded] = useState(false)
1550

@@ -79,20 +114,24 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
79114

80115
{entry.error && (
81116
<div className="flex items-start gap-2 border rounded-md p-3 border-red-500 bg-red-50 text-destructive dark:border-border dark:text-foreground dark:bg-background">
82-
<AlertCircle className="h-4 w-4 text-red-500 mt-1" />
83-
<div className="flex-1 break-normal whitespace-normal overflow-wrap-anywhere">
117+
<AlertCircle className="h-4 w-4 text-red-500 mt-1 flex-shrink-0" />
118+
<div className="flex-1 min-w-0">
84119
<div className="font-medium">Error</div>
85-
<pre className="text-sm whitespace-pre-wrap">{entry.error}</pre>
120+
<div className="text-sm whitespace-pre-wrap overflow-hidden w-full">
121+
<WordWrap text={entry.error} />
122+
</div>
86123
</div>
87124
</div>
88125
)}
89126

90127
{entry.warning && (
91128
<div className="flex items-start gap-2 border rounded-md p-3 border-yellow-500 bg-yellow-50 text-yellow-700 dark:border-border dark:text-yellow-500 dark:bg-background">
92-
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-1" />
93-
<div className="flex-1 break-normal whitespace-normal overflow-wrap-anywhere">
129+
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-1 flex-shrink-0" />
130+
<div className="flex-1 min-w-0">
94131
<div className="font-medium">Warning</div>
95-
<pre className="text-sm whitespace-pre-wrap">{entry.warning}</pre>
132+
<div className="text-sm whitespace-pre-wrap overflow-hidden w-full">
133+
<WordWrap text={entry.warning} />
134+
</div>
96135
</div>
97136
</div>
98137
)}

0 commit comments

Comments
 (0)