feat: notification store (#2025)

* feat: notification store

* feat: notification stack; improvement: chat output select
This commit is contained in:
Emir Karabeg
2025-11-18 10:43:24 -08:00
committed by GitHub
parent 33ca1483aa
commit e0aade85a6
17 changed files with 562 additions and 225 deletions

View File

@@ -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,

View File

@@ -601,6 +601,7 @@ export function Chat() {
disabled={!activeWorkflowId}
placeholder='Select outputs'
align='end'
maxHeight={180}
/>
</div>

View File

@@ -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' }}

View File

@@ -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'

View File

@@ -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>
)
})

View File

@@ -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])

View File

@@ -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) => {

View File

@@ -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>
)
}

View File

@@ -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 />}

View File

@@ -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

View File

@@ -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>
)
}
)

View File

@@ -0,0 +1,7 @@
export type {
AddNotificationParams,
Notification,
NotificationAction,
} from './store'
export { useNotificationStore } from './store'
export { openCopilotWithMessage } from './utils'

View 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),
}),
}
)
)

View 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 })
}
}

View File

@@ -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
},
/**

View File

@@ -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'

View File

@@ -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 })