feat: templates, usage indicator, wand, approval, logs, help, settings, deploy template, error UI (#1931)

* templates, usage indicator, wand, approval, ...

* fix: build error import isTruthy
This commit is contained in:
Emir Karabeg
2025-11-12 07:42:40 -08:00
committed by GitHub
parent 42e2769526
commit f4434e302c
29 changed files with 1508 additions and 1046 deletions

View File

@@ -43,34 +43,45 @@ export function FrozenCanvasModal({
hideCloseButton={true}
>
{/* Header */}
<DialogHeader className='flex flex-row items-center justify-between border-b bg-background p-4'>
<div className='flex items-center gap-3'>
<DialogHeader className='flex flex-row items-center justify-between border-b bg-[var(--surface-1)] p-[16px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='flex items-center gap-[12px]'>
<div>
<DialogTitle className='font-semibold text-foreground text-lg'>
<DialogTitle className='font-semibold text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Logged Workflow State
</DialogTitle>
<div className='mt-1 flex items-center gap-2'>
<div className='mt-[4px] flex items-center gap-[8px]'>
{workflowName && (
<span className='text-muted-foreground text-sm'>{workflowName}</span>
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{workflowName}
</span>
)}
{trigger && (
<Badge variant='secondary' className='text-xs'>
<Badge variant='secondary' className='text-[12px]'>
{trigger}
</Badge>
)}
<span className='font-mono text-muted-foreground text-xs'>
<span className='font-mono text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{executionId.slice(0, 8)}...
</span>
</div>
</div>
</div>
<div className='flex items-center gap-2'>
<Button variant='ghost' size='sm' onClick={toggleFullscreen} className='h-8 w-8 p-0'>
{isFullscreen ? <Minimize2 className='h-4 w-4' /> : <Maximize2 className='h-4 w-4' />}
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={toggleFullscreen}
className='h-[32px] w-[32px] p-0'
>
{isFullscreen ? (
<Minimize2 className='h-[14px] w-[14px]' />
) : (
<Maximize2 className='h-[14px] w-[14px]' />
)}
</Button>
<Button variant='ghost' size='sm' onClick={onClose} className='h-8 w-8 p-0'>
<X className='h-4 w-4' />
<Button variant='ghost' size='sm' onClick={onClose} className='h-[32px] w-[32px] p-0'>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</DialogHeader>
@@ -87,8 +98,8 @@ export function FrozenCanvasModal({
</div>
{/* Footer with instructions */}
<div className='border-t bg-background px-6 py-3'>
<div className='text-muted-foreground text-sm'>
<div className='border-t bg-[var(--surface-1)] px-[24px] py-[12px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Click on blocks to see their input and output data at execution time. This canvas shows
the exact state of the workflow when this execution was captured.
</div>

View File

@@ -34,51 +34,61 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
return (
<>
<div>
<div className='mb-2 flex items-center justify-between'>
<h4 className='font-medium text-foreground text-sm'>{title}</h4>
<div className='flex items-center gap-1'>
<div className='mb-[8px] flex items-center justify-between'>
<h4 className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{title}
</h4>
<div className='flex items-center gap-[4px]'>
{isLargeData && (
<button
onClick={() => setIsModalOpen(true)}
className='rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
title='Expand in modal'
>
<Maximize2 className='h-3 w-3' />
<Maximize2 className='h-[12px] w-[12px]' />
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className='rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
>
{isExpanded ? <ChevronUp className='h-3 w-3' /> : <ChevronDown className='h-3 w-3' />}
{isExpanded ? (
<ChevronUp className='h-[12px] w-[12px]' />
) : (
<ChevronDown className='h-[12px] w-[12px]' />
)}
</button>
</div>
</div>
<div
className={cn(
'overflow-y-auto rounded bg-muted p-3 font-mono text-xs transition-all duration-200',
'overflow-y-auto rounded-[8px] bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
isExpanded ? 'max-h-96' : 'max-h-32'
)}
>
<pre className='whitespace-pre-wrap break-words text-foreground'>{jsonString}</pre>
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
</div>
{/* Modal for large data */}
{isModalOpen && (
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
<div className='mx-4 h-[80vh] w-full max-w-4xl rounded-lg border bg-background shadow-lg'>
<div className='flex items-center justify-between border-b p-4'>
<h3 className='font-medium text-foreground text-lg'>{title}</h3>
<div className='mx-[16px] h-[80vh] w-full max-w-4xl rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='flex items-center justify-between border-b p-[16px] dark:border-[var(--border)]'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{title}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className='rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
>
<X className='h-4 w-4' />
<X className='h-[14px] w-[14px]' />
</button>
</div>
<div className='h-[calc(80vh-4rem)] overflow-auto p-4'>
<pre className='whitespace-pre-wrap break-words font-mono text-foreground text-sm'>
<div className='h-[calc(80vh-4rem)] overflow-auto p-[16px]'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
@@ -184,28 +194,31 @@ function PinnedLogs({
}
return (
<Card className='fixed top-4 right-4 z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border-border bg-background shadow-lg'>
<CardHeader className='pb-3'>
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-foreground text-lg'>
<Zap className='h-5 w-5' />
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button onClick={onClose} className='rounded-sm p-1 text-foreground hover:bg-muted'>
<X className='h-4 w-4' />
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
>
<X className='h-[14px] w-[14px]' />
</button>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='flex items-center gap-[8px]'>
<Badge variant='secondary'>{formatted.blockType}</Badge>
<Badge variant='outline'>not executed</Badge>
</div>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-md bg-muted/50 p-4 text-center'>
<div className='text-muted-foreground text-sm'>
<CardContent className='space-y-[16px]'>
<div className='rounded-[8px] bg-[var(--surface-5)] p-[16px] text-center'>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
This block was not executed because the workflow failed before reaching it.
</div>
</div>
@@ -236,19 +249,22 @@ function PinnedLogs({
}
return (
<Card className='fixed top-4 right-4 z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border-border bg-background shadow-lg'>
<CardHeader className='pb-3'>
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-foreground text-lg'>
<Zap className='h-5 w-5' />
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button onClick={onClose} className='rounded-sm p-1 text-foreground hover:bg-muted'>
<X className='h-4 w-4' />
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
>
<X className='h-[14px] w-[14px]' />
</button>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='flex items-center gap-[8px]'>
<Badge variant={formatted.status === 'success' ? 'default' : 'destructive'}>
{formatted.blockType}
</Badge>
@@ -257,15 +273,15 @@ function PinnedLogs({
{/* Iteration Navigation */}
{iterationInfo.hasMultipleIterations && (
<div className='flex items-center gap-1'>
<div className='flex items-center gap-[4px]'>
<button
onClick={goToPreviousIteration}
disabled={currentIterationIndex === 0}
className='rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
>
<ChevronLeft className='h-4 w-4' />
<ChevronLeft className='h-[14px] w-[14px]' />
</button>
<span className='px-2 text-muted-foreground text-xs'>
<span className='px-[8px] text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{iterationInfo.totalIterations !== undefined
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
: `${currentIterationIndex + 1}`}
@@ -273,33 +289,39 @@ function PinnedLogs({
<button
onClick={goToNextIteration}
disabled={currentIterationIndex === totalIterations - 1}
className='rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
>
<ChevronRight className='h-4 w-4' />
<ChevronRight className='h-[14px] w-[14px]' />
</button>
</div>
)}
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='flex items-center gap-2'>
<Clock className='h-4 w-4 text-muted-foreground' />
<span className='text-foreground text-sm'>{formatted.duration}</span>
<CardContent className='space-y-[16px]'>
<div className='grid grid-cols-2 gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{formatted.duration}
</span>
</div>
{formatted.cost && formatted.cost.total > 0 && (
<div className='flex items-center gap-2'>
<DollarSign className='h-4 w-4 text-muted-foreground' />
<span className='text-foreground text-sm'>${formatted.cost.total.toFixed(5)}</span>
<div className='flex items-center gap-[8px]'>
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
${formatted.cost.total.toFixed(5)}
</span>
</div>
)}
{formatted.tokens && formatted.tokens.total > 0 && (
<div className='flex items-center gap-2'>
<Hash className='h-4 w-4 text-muted-foreground' />
<span className='text-foreground text-sm'>{formatted.tokens.total} tokens</span>
<div className='flex items-center gap-[8px]'>
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{formatted.tokens.total} tokens
</span>
</div>
)}
</div>
@@ -310,17 +332,19 @@ function PinnedLogs({
{formatted.cost && formatted.cost.total > 0 && (
<div>
<h4 className='mb-2 font-medium text-foreground text-sm'>Cost Breakdown</h4>
<div className='space-y-1 text-sm'>
<div className='flex justify-between text-foreground'>
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Cost Breakdown
</h4>
<div className='space-y-[4px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<span>Input:</span>
<span>${formatted.cost.input.toFixed(5)}</span>
</div>
<div className='flex justify-between text-foreground'>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<span>Output:</span>
<span>${formatted.cost.output.toFixed(5)}</span>
</div>
<div className='flex justify-between border-border border-t pt-1 font-medium text-foreground'>
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
<span>Total:</span>
<span>${formatted.cost.total.toFixed(5)}</span>
</div>
@@ -330,17 +354,19 @@ function PinnedLogs({
{formatted.tokens && formatted.tokens.total > 0 && (
<div>
<h4 className='mb-2 font-medium text-foreground text-sm'>Token Usage</h4>
<div className='space-y-1 text-sm'>
<div className='flex justify-between text-foreground'>
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Token Usage
</h4>
<div className='space-y-[4px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<span>Prompt:</span>
<span>{formatted.tokens.prompt}</span>
</div>
<div className='flex justify-between text-foreground'>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<span>Completion:</span>
<span>{formatted.tokens.completion}</span>
</div>
<div className='flex justify-between border-border border-t pt-1 font-medium text-foreground'>
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
<span>Total:</span>
<span>{formatted.tokens.total}</span>
</div>
@@ -527,9 +553,9 @@ export function FrozenCanvas({
if (loading) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='flex items-center gap-2 text-muted-foreground'>
<Loader2 className='h-5 w-5 animate-spin' />
<span>Loading frozen canvas...</span>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading frozen canvas...</span>
</div>
</div>
)
@@ -538,9 +564,9 @@ export function FrozenCanvas({
if (error) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='flex items-center gap-2 text-destructive'>
<AlertCircle className='h-5 w-5' />
<span>Failed to load frozen canvas: {error}</span>
<div className='flex items-center gap-[8px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
</div>
</div>
)
@@ -549,7 +575,9 @@ export function FrozenCanvas({
if (!data) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='text-muted-foreground'>No data available</div>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
No data available
</div>
</div>
)
}
@@ -559,18 +587,18 @@ export function FrozenCanvas({
if (isMigratedLog) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-4 p-8', className)}
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
style={{ height, width }}
>
<div className='flex items-center gap-3 text-amber-600 dark:text-amber-400'>
<AlertCircle className='h-6 w-6' />
<span className='font-medium text-lg'>Logged State Not Found</span>
<div className='flex items-center gap-[12px] text-amber-600 dark:text-amber-400'>
<AlertCircle className='h-[20px] w-[20px]' />
<span className='font-medium text-[15px]'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-muted-foreground text-sm'>
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
This log was migrated from the old logging system. The workflow state at execution time is
not available.
</div>
<div className='text-muted-foreground text-xs'>
<div className='text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Note: {(data.workflowState as any)?._note}
</div>
</div>

View File

@@ -269,28 +269,30 @@ export function Sidebar({
}
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newWidth = window.innerWidth - e.clientX
const minWidthToUse = isTraceExpanded ? Math.max(MIN_WIDTH, EXPANDED_WIDTH) : MIN_WIDTH
setWidth(Math.max(minWidthToUse, Math.min(newWidth, window.innerWidth * 0.8)))
}
const newWidth = window.innerWidth - e.clientX
const minWidthToUse = isTraceExpanded ? Math.max(MIN_WIDTH, EXPANDED_WIDTH) : MIN_WIDTH
setWidth(Math.max(minWidthToUse, Math.min(newWidth, window.innerWidth * 0.8)))
}
const handleMouseUp = () => {
setIsDragging(false)
}
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isDragging, isTraceExpanded, MIN_WIDTH, EXPANDED_WIDTH, width])
}, [isDragging, isTraceExpanded])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -321,32 +323,39 @@ export function Sidebar({
return (
<div
className={`fixed top-24 right-4 bottom-4 transform rounded-[14px] border bg-card shadow-xs ${
className={`fixed top-[96px] right-[16px] bottom-[16px] z-50 flex transform flex-col rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)] ${
isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+1rem)]'
} ${isDragging ? '' : 'transition-all duration-300 ease-in-out'} z-50 flex flex-col`}
} ${isDragging ? '' : 'transition-all duration-300 ease-in-out'}`}
style={{ width: `${width}px`, minWidth: `${MIN_WIDTH}px` }}
aria-label='Log details sidebar'
>
{/* Resize Handle */}
<div
className='absolute top-0 bottom-0 left-[-4px] z-50 w-4 cursor-ew-resize hover:bg-accent/50'
className='absolute top-0 bottom-0 left-[-4px] z-[60] w-[8px] cursor-ew-resize'
onMouseDown={handleMouseDown}
role='separator'
aria-orientation='vertical'
aria-label='Resize sidebar'
/>
{log && (
<>
{/* Header */}
<div className='flex items-center justify-between px-3 pt-3 pb-1'>
<h2 className='font-[450] text-base text-card-foreground'>Log Details</h2>
<div className='flex items-center gap-2'>
<div className='flex items-center justify-between px-[12px] pt-[12px] pb-[4px]'>
<h2 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Log Details
</h2>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
className='h-[32px] w-[32px] p-0'
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
aria-label='Previous log'
>
<ChevronUp className='h-4 w-4' />
<ChevronUp className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Previous log</Tooltip.Content>
@@ -356,12 +365,12 @@ export function Sidebar({
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
className='h-[32px] w-[32px] p-0'
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
aria-label='Next log'
>
<ChevronDown className='h-4 w-4' />
<ChevronDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Next log</Tooltip.Content>
@@ -370,23 +379,25 @@ export function Sidebar({
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
className='h-[32px] w-[32px] p-0'
onClick={onClose}
aria-label='Close'
>
<X className='h-4 w-4' />
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
{/* Content */}
<div className='flex-1 overflow-hidden px-3'>
<div className='flex-1 overflow-hidden px-[12px]'>
<ScrollArea className='h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='w-full space-y-4 pr-3 pb-4'>
<div className='w-full space-y-[16px] pr-[12px] pb-[16px]'>
{/* Timestamp */}
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Timestamp</h3>
<div className='group relative text-sm'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Timestamp
</h3>
<div className='group relative text-[13px]'>
<CopyButton text={formatDate(log.createdAt).full} />
{formatDate(log.createdAt).full}
</div>
@@ -395,16 +406,18 @@ export function Sidebar({
{/* Workflow */}
{log.workflow && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Workflow</h3>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Workflow
</h3>
<div
className='group relative text-sm'
className='group relative text-[13px]'
style={{
color: log.workflow.color,
}}
>
<CopyButton text={log.workflow.name} />
<div
className='inline-flex items-center rounded-md px-2 py-1 text-xs'
className='inline-flex items-center rounded-[8px] px-[8px] py-[4px] text-[12px]'
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
@@ -419,8 +432,10 @@ export function Sidebar({
{/* Execution ID */}
{log.executionId && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Execution ID</h3>
<div className='group relative break-all font-mono text-sm'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Execution ID
</h3>
<div className='group relative break-all font-mono text-[13px]'>
<CopyButton text={log.executionId} />
{log.executionId}
</div>
@@ -429,7 +444,9 @@ export function Sidebar({
{/* Status */}
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Status</h3>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Status
</h3>
{(() => {
const baseLevel = (log.level || 'info').toLowerCase()
const isPending = log.duration == null
@@ -437,7 +454,7 @@ export function Sidebar({
? 'Pending'
: `${baseLevel.charAt(0).toUpperCase()}${baseLevel.slice(1)}`
return (
<div className='group relative text-sm capitalize'>
<div className='group relative text-[13px] capitalize'>
<CopyButton text={statusLabel} />
{statusLabel}
</div>
@@ -448,8 +465,10 @@ export function Sidebar({
{/* Trigger */}
{log.trigger && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Trigger</h3>
<div className='group relative text-sm capitalize'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Trigger
</h3>
<div className='group relative text-[13px] capitalize'>
<CopyButton text={log.trigger} />
{log.trigger}
</div>
@@ -459,8 +478,10 @@ export function Sidebar({
{/* Duration */}
{log.duration && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Duration</h3>
<div className='group relative text-sm'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Duration
</h3>
<div className='group relative text-[13px]'>
<CopyButton text={log.duration} />
{log.duration}
</div>
@@ -469,34 +490,34 @@ export function Sidebar({
{/* Suspense while details load (positioned after summary fields) */}
{isLoadingDetails && (
<div className='flex w-full items-center justify-start gap-2 py-2 text-muted-foreground'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-sm'>Loading details</span>
<div className='flex w-full items-center justify-start gap-[8px] py-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading details</span>
</div>
)}
{/* Files */}
{log.files && log.files.length > 0 && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Files ({log.files.length})
</h3>
<div className='space-y-2'>
<div className='space-y-[8px]'>
{log.files.map((file, index) => (
<div
key={file.id || index}
className='flex items-center justify-between rounded-md border bg-muted/30 p-2'
className='flex items-center justify-between rounded-[8px] border bg-muted/30 p-[8px] dark:border-[var(--border)]'
>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-sm' title={file.name}>
<div className='truncate font-medium text-[13px]' title={file.name}>
{file.name}
</div>
<div className='text-muted-foreground text-xs'>
<div className='text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{file.size ? `${Math.round(file.size / 1024)}KB` : 'Unknown size'}
{file.type && `${file.type.split('/')[0]}`}
</div>
</div>
<div className='ml-2 flex items-center gap-1'>
<div className='ml-[8px] flex items-center gap-[4px]'>
<FileDownload file={file} isExecutionFile={true} />
</div>
</div>
@@ -508,19 +529,19 @@ export function Sidebar({
{/* Frozen Canvas Button - only show for workflow execution logs with execution ID */}
{isWorkflowExecutionLog && log.executionId && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Workflow State
</h3>
<Button
variant='ghost'
size='sm'
onClick={() => setIsFrozenCanvasOpen(true)}
className='w-full justify-start gap-2 rounded-md border bg-muted/30 hover:bg-muted/50'
className='w-full justify-start gap-[8px] rounded-[8px] border bg-muted/30 hover:bg-muted/50 dark:border-[var(--border)]'
>
<Eye className='h-4 w-4' />
<Eye className='h-[14px] w-[14px]' />
View Snapshot
</Button>
<p className='mt-1 text-muted-foreground text-xs'>
<p className='mt-[4px] text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
See the exact workflow state and block inputs/outputs at execution time
</p>
</div>
@@ -544,8 +565,10 @@ export function Sidebar({
{/* Tool Calls (if available) */}
{log.executionData?.toolCalls && log.executionData.toolCalls.length > 0 && (
<div className='w-full'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Tool Calls</h3>
<div className='w-full overflow-x-hidden rounded-md bg-secondary/30 p-3'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Tool Calls
</h3>
<div className='w-full overflow-x-hidden rounded-[8px] bg-secondary/30 p-[12px]'>
<ToolCallsDisplay metadata={log.executionData} />
</div>
</div>
@@ -554,32 +577,42 @@ export function Sidebar({
{/* Cost Information (moved to bottom) */}
{hasCostInfo && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Cost Breakdown
</h3>
<div className='overflow-hidden rounded-md border'>
<div className='space-y-2 p-3'>
<div className='overflow-hidden rounded-[8px] border dark:border-[var(--border)]'>
<div className='space-y-[8px] p-[12px]'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Base Execution:</span>
<span className='text-sm'>{formatCost(BASE_EXECUTION_CHARGE)}</span>
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Base Execution:
</span>
<span className='text-[13px]'>{formatCost(BASE_EXECUTION_CHARGE)}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Model Input:</span>
<span className='text-sm'>{formatCost(log.cost?.input || 0)}</span>
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Model Input:
</span>
<span className='text-[13px]'>{formatCost(log.cost?.input || 0)}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Model Output:</span>
<span className='text-sm'>{formatCost(log.cost?.output || 0)}</span>
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Model Output:
</span>
<span className='text-[13px]'>{formatCost(log.cost?.output || 0)}</span>
</div>
<div className='mt-1 flex items-center justify-between border-t pt-2'>
<span className='text-muted-foreground text-sm'>Total:</span>
<span className='text-foreground text-sm'>
<div className='mt-[4px] flex items-center justify-between border-t pt-[8px] dark:border-[var(--border)]'>
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Total:
</span>
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{formatCost(log.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs'>Tokens:</span>
<span className='text-muted-foreground text-xs'>
<span className='text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Tokens:
</span>
<span className='text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
out
</span>
@@ -588,44 +621,52 @@ export function Sidebar({
{/* Models Breakdown */}
{log.cost?.models && Object.keys(log.cost?.models).length > 0 && (
<div className='border-t'>
<div className='border-t dark:border-[var(--border)]'>
<button
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
className='flex w-full items-center justify-between p-[12px] text-left transition-colors hover:bg-muted/50'
>
<span className='font-medium text-muted-foreground text-xs'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Model Breakdown ({Object.keys(log.cost?.models || {}).length})
</span>
{isModelsExpanded ? (
<ChevronUp className='h-3 w-3 text-muted-foreground' />
<ChevronUp className='h-[12px] w-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]' />
) : (
<ChevronDown className='h-3 w-3 text-muted-foreground' />
<ChevronDown className='h-[12px] w-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]' />
)}
</button>
{isModelsExpanded && (
<div className='space-y-3 border-t bg-muted/30 p-3'>
<div className='space-y-[12px] border-t bg-muted/30 p-[12px] dark:border-[var(--border)]'>
{Object.entries(log.cost?.models || {}).map(
([model, cost]: [string, any]) => (
<div key={model} className='space-y-1'>
<div className='font-medium font-mono text-xs'>{model}</div>
<div className='space-y-1 text-xs'>
<div key={model} className='space-y-[4px]'>
<div className='font-medium font-mono text-[12px]'>{model}</div>
<div className='space-y-[4px] text-[12px]'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Input:</span>
<span className='text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Input:
</span>
<span>{formatCost(cost.input || 0)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Output:</span>
<span className='text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Output:
</span>
<span>{formatCost(cost.output || 0)}</span>
</div>
<div className='flex justify-between border-t pt-1'>
<span className='text-muted-foreground'>Total:</span>
<div className='flex justify-between border-t pt-[4px] dark:border-[var(--border)]'>
<span className='text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Total:
</span>
<span className='font-medium'>
{formatCost(cost.total || 0)}
</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Tokens:</span>
<span className='text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Tokens:
</span>
<span>
{cost.tokens?.prompt || 0} in /{' '}
{cost.tokens?.completion || 0} out
@@ -641,7 +682,7 @@ export function Sidebar({
)}
{isWorkflowWithCost && (
<div className='border-t bg-muted p-3 text-muted-foreground text-xs'>
<div className='border-t bg-muted p-[12px] text-[12px] text-[var(--text-secondary)] dark:border-[var(--border)] dark:text-[var(--text-secondary)]'>
<p>
Total cost includes a base execution charge of{' '}
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.

View File

@@ -20,6 +20,12 @@ import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types'
const logger = createLogger('Logs')
const LOGS_PER_PAGE = 50
/**
* Returns the background color for a trigger type badge.
*
* @param trigger - The trigger type (manual, schedule, webhook, chat, api)
* @returns Hex color code for the trigger type
*/
const getTriggerColor = (trigger: string | null | undefined): string => {
if (!trigger) return '#9ca3af'
@@ -685,14 +691,14 @@ export default function Logs() {
}
return (
<div className='flex h-full min-w-0 flex-col pl-64'>
<div className='fixed inset-0 left-[256px] flex min-w-0 flex-col'>
{/* Add the animation styles */}
<style jsx global>
{selectedRowAnimation}
</style>
<div className='flex min-w-0 flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto p-6'>
<div className='flex flex-1 flex-col p-[24px]'>
<Controls
isRefetching={isRefreshing}
resetToNow={handleRefresh}
@@ -717,62 +723,57 @@ export default function Logs() {
/>
{/* Table container */}
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Table with responsive layout */}
<div className='w-full overflow-x-auto'>
{/* Header */}
<div>
<div className='border-border border-b'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px] gap-2 px-2 pb-3 md:grid-cols-[140px_90px_140px_120px] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px] lg:gap-4 xl:grid-cols-[160px_100px_160px_120px_120px_100px]'>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Status
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Workflow
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Cost
</div>
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
Trigger
</div>
<div className='flex flex-1 flex-col overflow-hidden rounded-[8px] border dark:border-[var(--border)]'>
{/* Header */}
<div className='flex-shrink-0 border-b bg-[var(--surface-1)] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px] gap-[8px] px-[24px] py-[12px] md:grid-cols-[140px_90px_140px_120px] md:gap-[12px] lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px] lg:gap-[16px] xl:grid-cols-[160px_100px_160px_120px_120px_100px]'>
<div className='font-medium text-[13px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Time
</div>
<div className='font-medium text-[13px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Status
</div>
<div className='font-medium text-[13px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Workflow
</div>
<div className='font-medium text-[13px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Cost
</div>
<div className='hidden font-medium text-[13px] text-[var(--text-tertiary)] xl:block dark:text-[var(--text-tertiary)]'>
Trigger
</div>
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
Duration
</div>
</div>
<div className='hidden font-medium text-[13px] text-[var(--text-tertiary)] xl:block dark:text-[var(--text-tertiary)]'>
Duration
</div>
</div>
</div>
{/* Table body - scrollable */}
<div className='flex-1 overflow-auto' ref={scrollContainerRef}>
<div className='flex-1 overflow-y-auto overflow-x-hidden' ref={scrollContainerRef}>
{loading && page === 1 ? (
<div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Loader2 className='h-5 w-5 animate-spin' />
<span className='text-sm'>Loading logs...</span>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading logs...</span>
</div>
</div>
) : error ? (
<div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-2 text-destructive'>
<AlertCircle className='h-5 w-5' />
<span className='text-sm'>Error: {error}</span>
<div className='flex items-center gap-[8px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Error: {error}</span>
</div>
</div>
) : logs.length === 0 ? (
<div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Info className='h-5 w-5' />
<span className='text-sm'>No logs found</span>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Info className='h-[16px] w-[16px]' />
<span className='text-[13px]'>No logs found</span>
</div>
</div>
) : (
<div className='pb-4'>
<div className='pb-[16px]'>
{logs.map((log) => {
const formattedDate = formatDate(log.createdAt)
const isSelected = selectedLog?.id === log.id
@@ -788,22 +789,19 @@ export default function Logs() {
<div
key={log.id}
ref={isSelected ? selectedRowRef : null}
className={`cursor-pointer border-border border-b transition-all duration-200 ${
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
className={`cursor-pointer border-b transition-all duration-200 dark:border-[var(--border)] ${
isSelected ? 'bg-[var(--border)]' : 'hover:bg-[var(--border)]'
}`}
onClick={() => handleLogClick(log)}
>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px_40px] items-center gap-2 px-2 py-4 md:grid-cols-[140px_90px_140px_120px_40px] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px_40px] lg:gap-4 xl:grid-cols-[160px_100px_160px_120px_120px_100px_40px]'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px_40px] items-center gap-[8px] px-[24px] py-[12px] md:grid-cols-[140px_90px_140px_120px_40px] md:gap-[12px] lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px_40px] lg:gap-[16px] xl:grid-cols-[160px_100px_160px_120px_120px_100px_40px]'>
{/* Time */}
<div>
<div className='text-[13px]'>
<span className='font-sm text-muted-foreground'>
<span className='text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{formattedDate.compactDate}
</span>
<span
style={{ marginLeft: '8px' }}
className='hidden font-medium sm:inline'
>
<span className='ml-[8px] hidden font-medium sm:inline'>
{formattedDate.compactTime}
</span>
</div>
@@ -813,7 +811,7 @@ export default function Logs() {
<div>
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
isError
? 'bg-red-500 text-white'
: isPending
@@ -827,14 +825,14 @@ export default function Logs() {
{/* Workflow */}
<div className='min-w-0'>
<div className='truncate font-medium text-[13px]'>
<div className='truncate font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{log.workflow?.name || 'Unknown Workflow'}
</div>
</div>
{/* Cost */}
<div>
<div className='font-medium text-muted-foreground text-xs'>
<div className='font-medium text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{typeof (log as any)?.cost?.total === 'number'
? `$${((log as any).cost.total as number).toFixed(4)}`
: '—'}
@@ -846,7 +844,7 @@ export default function Logs() {
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-medium text-xs transition-all duration-200 lg:px-[8px]',
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
@@ -860,13 +858,15 @@ export default function Logs() {
{log.trigger}
</div>
) : (
<div className='text-muted-foreground text-xs'></div>
<div className='font-medium text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
</div>
)}
</div>
{/* Duration */}
<div className='hidden xl:block'>
<div className='text-muted-foreground text-xs'>
<div className='font-medium text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{log.duration || '—'}
</div>
</div>
@@ -878,13 +878,13 @@ export default function Logs() {
(log.workflow?.id || log.workflowId) ? (
<Link
href={`/resume/${log.workflow?.id || log.workflowId}/${log.executionId}`}
className='inline-flex h-7 w-7 items-center justify-center rounded-md border border-primary/60 border-dashed text-primary hover:bg-primary/10'
className='inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] border border-primary/60 border-dashed text-primary hover:bg-primary/10'
aria-label='Open resume console'
>
<ArrowUpRight className='h-4 w-4' />
<ArrowUpRight className='h-[14px] w-[14px]' />
</Link>
) : (
<span className='h-7 w-7' />
<span className='h-[28px] w-[28px]' />
)}
</div>
</div>
@@ -894,18 +894,18 @@ export default function Logs() {
{/* Infinite scroll loader */}
{hasMore && (
<div className='flex items-center justify-center py-4'>
<div className='flex items-center justify-center py-[16px]'>
<div
ref={loaderRef}
className='flex items-center gap-2 text-muted-foreground'
className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'
>
{isFetchingMore ? (
<>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-sm'>Loading more...</span>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading more...</span>
</>
) : (
<span className='text-sm'>Scroll to load more</span>
<span className='text-[13px]'>Scroll to load more</span>
)}
</div>
</div>

View File

@@ -90,12 +90,12 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
} catch (error) {
logger.error('Error loading template:', error)
return (
<div className='flex h-screen items-center justify-center'>
<div className='flex h-[100vh] items-center justify-center pl-64'>
<div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Error Loading Template</h1>
<p className='text-muted-foreground'>There was an error loading this template.</p>
<p className='mt-2 text-muted-foreground text-sm'>Template ID: {id}</p>
<p className='mt-2 text-red-500 text-xs'>
<h1 className='mb-[14px] font-medium text-[18px]'>Error Loading Template</h1>
<p className='text-[#888888] text-[14px]'>There was an error loading this template.</p>
<p className='mt-[10px] text-[#888888] text-[12px]'>Template ID: {id}</p>
<p className='mt-[10px] text-[12px] text-red-500'>
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>

View File

@@ -46,7 +46,7 @@ import {
Zap,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
@@ -103,10 +103,16 @@ const iconMap = {
Award,
}
// Get icon component from template-card logic
/**
* Get icon component from template icon name
*/
const getIconComponent = (icon: string): React.ReactNode => {
const IconComponent = iconMap[icon as keyof typeof iconMap]
return IconComponent ? <IconComponent className='h-6 w-6' /> : <FileText className='h-6 w-6' />
return IconComponent ? (
<IconComponent className='h-[14px] w-[14px]' />
) : (
<FileText className='h-[14px] w-[14px]' />
)
}
export default function TemplateDetails({
@@ -117,7 +123,27 @@ export default function TemplateDetails({
const router = useRouter()
const searchParams = useSearchParams()
// Defensive check for template BEFORE initializing state hooks
// Initialize all state hooks first (hooks must be called unconditionally)
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
const [starCount, setStarCount] = useState(template?.stars || 0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const isOwner = currentUserId && template?.userId === currentUserId
// Auto-use template after login if use=true query param is present
useEffect(() => {
if (!template?.id) return
const shouldAutoUse = searchParams?.get('use') === 'true'
if (shouldAutoUse && currentUserId && !isUsing) {
handleUseTemplate()
// Clean up URL
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
}
}, [searchParams, currentUserId, template?.id])
// Defensive check for template AFTER initializing hooks
if (!template) {
logger.error('Template prop is undefined or null in TemplateDetails component', {
template,
@@ -125,11 +151,13 @@ export default function TemplateDetails({
currentUserId,
})
return (
<div className='flex h-screen items-center justify-center'>
<div className='flex h-[100vh] items-center justify-center pl-64'>
<div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1>
<p className='text-muted-foreground'>The template you're looking for doesn't exist.</p>
<p className='mt-2 text-muted-foreground text-xs'>Template data failed to load</p>
<h1 className='mb-[14px] font-medium text-[18px]'>Template Not Found</h1>
<p className='text-[#888888] text-[14px]'>
The template you're looking for doesn't exist.
</p>
<p className='mt-[10px] text-[#888888] text-[12px]'>Template data failed to load</p>
</div>
</div>
)
@@ -141,34 +169,18 @@ export default function TemplateDetails({
hasState: !!template.state,
})
const [isStarred, setIsStarred] = useState(template.isStarred || false)
const [starCount, setStarCount] = useState(template.stars || 0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const isOwner = currentUserId && template.userId === currentUserId
// Auto-use template after login if use=true query param is present
useEffect(() => {
const shouldAutoUse = searchParams?.get('use') === 'true'
if (shouldAutoUse && currentUserId && !isUsing) {
handleUseTemplate()
// Clean up URL
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
}
}, [searchParams, currentUserId])
// Render workflow preview exactly like deploy-modal.tsx
/**
* Render workflow preview with consistent error handling
*/
const renderWorkflowPreview = () => {
// Follow the same pattern as deployed-workflow-card.tsx
if (!template?.state) {
logger.info('Template has no state:', template)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'> No Workflow Data</div>
<div className='text-sm'>This template doesn't contain workflow state data.</div>
<div className='text-[#888888]'>
<div className='mb-[10px] font-medium text-[14px]'> No Workflow Data</div>
<div className='text-[12px]'>This template doesn't contain workflow state data.</div>
</div>
</div>
)
@@ -192,12 +204,12 @@ export default function TemplateDetails({
/>
)
} catch (error) {
console.error('Error rendering workflow preview:', error)
logger.error('Error rendering workflow preview:', error)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'>⚠️ Preview Error</div>
<div className='text-sm'>Unable to render workflow preview</div>
<div className='text-[#888888]'>
<div className='mb-[10px] font-medium text-[14px]'>⚠️ Preview Error</div>
<div className='text-[12px]'>Unable to render workflow preview</div>
</div>
</div>
)
@@ -303,119 +315,107 @@ export default function TemplateDetails({
}
return (
<div className='flex min-h-screen flex-col'>
{/* Header */}
<div className='border-b bg-background p-6'>
<div className='mx-auto max-w-7xl'>
{/* Back button */}
<button
onClick={handleBack}
className='mb-6 flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground'
>
<ArrowLeft className='h-4 w-4' />
<span className='text-sm'>Go back</span>
</button>
<div className='flex h-[100vh] flex-col pl-64'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
{/* Back button */}
<button
onClick={handleBack}
className='mb-[14px] flex items-center gap-[8px] text-[#888888] transition-colors hover:text-white'
>
<ArrowLeft className='h-[14px] w-[14px]' />
<span className='font-medium text-[12px]'>Go back</span>
</button>
{/* Template header */}
<div className='flex items-start justify-between'>
<div className='flex items-start gap-4'>
{/* Icon */}
<div
className='flex h-12 w-12 items-center justify-center rounded-lg'
style={{ backgroundColor: template.color }}
>
{getIconComponent(template.icon)}
</div>
{/* Title and description */}
<div>
<h1 className='font-bold text-3xl text-foreground'>{template.name}</h1>
<p className='mt-2 max-w-3xl text-lg text-muted-foreground'>
{template.description}
</p>
</div>
</div>
{/* Action buttons */}
<div className='flex items-center gap-3'>
{/* Star button - only for logged-in users */}
{currentUserId && (
<Button
variant='outline'
size='sm'
onClick={handleStarToggle}
disabled={isStarring}
className={cn(
'transition-colors',
isStarred &&
'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
)}
>
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
{starCount}
</Button>
)}
{/* Edit button - only for template owner when logged in */}
{isOwner && currentUserId && (
<Button
variant='outline'
onClick={handleEditTemplate}
disabled={isEditing}
className='border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
>
<Edit className='mr-2 h-4 w-4' />
{isEditing ? 'Opening...' : 'Edit'}
</Button>
)}
{/* Use template button */}
<Button
onClick={handleUseTemplate}
disabled={isUsing}
className='bg-purple-600 text-white hover:bg-purple-700'
>
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
</Button>
{/* Header */}
<div>
<div className='flex items-start gap-[12px]'>
{/* Icon */}
<div
className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px]'
style={{ backgroundColor: template.color }}
>
{getIconComponent(template.icon)}
</div>
<h1 className='font-medium text-[18px]'>{template.name}</h1>
</div>
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>{template.description}</p>
</div>
{/* Tags */}
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'>
{/* Views */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Eye className='h-3 w-3' />
{/* Stats and Actions */}
<div className='mt-[14px] flex items-center justify-between'>
{/* Stats */}
<div className='flex items-center gap-[12px] font-medium text-[#888888] text-[12px]'>
<div className='flex items-center gap-[6px]'>
<Eye className='h-[12px] w-[12px]' />
<span>{template.views} views</span>
</div>
{/* Stars */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Star className='h-3 w-3' />
<div className='flex items-center gap-[6px]'>
<Star className='h-[12px] w-[12px]' />
<span>{starCount} stars</span>
</div>
{/* Author */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<User className='h-3 w-3' />
<div className='flex items-center gap-[6px]'>
<User className='h-[12px] w-[12px]' />
<span>by {template.author}</span>
</div>
{/* Author Type - show if organization */}
{template.authorType === 'organization' && (
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Users className='h-3 w-3' />
<div className='flex items-center gap-[6px]'>
<Users className='h-[12px] w-[12px]' />
<span>Organization</span>
</div>
)}
</div>
</div>
</div>
{/* Workflow preview */}
<div className='flex-1 p-6'>
<div className='mx-auto max-w-7xl'>
<h2 className='mb-4 font-semibold text-xl'>Workflow Preview</h2>
<div className='h-[600px] w-full'>{renderWorkflowPreview()}</div>
{/* Action buttons */}
<div className='flex items-center gap-[8px]'>
{/* Star button - only for logged-in users */}
{currentUserId && (
<Button
variant={isStarred ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={handleStarToggle}
disabled={isStarring}
>
<Star className={cn('mr-[6px] h-[14px] w-[14px]', isStarred && 'fill-current')} />
<span className='font-medium text-[12px]'>{starCount}</span>
</Button>
)}
{/* Edit button - only for template owner when logged in */}
{isOwner && currentUserId && (
<Button
variant='default'
className='h-[32px] rounded-[6px]'
onClick={handleEditTemplate}
disabled={isEditing}
>
<Edit className='mr-[6px] h-[14px] w-[14px]' />
<span className='font-medium text-[12px]'>{isEditing ? 'Opening...' : 'Edit'}</span>
</Button>
)}
{/* Use template button */}
<Button
variant='active'
className='h-[32px] rounded-[6px]'
onClick={handleUseTemplate}
disabled={isUsing}
>
<span className='font-medium text-[12px]'>
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
</span>
</Button>
</div>
</div>
{/* Divider */}
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
{/* Workflow preview */}
<div className='mt-[24px] flex-1'>
<h2 className='mb-[14px] font-medium text-[14px]'>Workflow Preview</h2>
<div className='h-[calc(100vh-280px)] w-full overflow-hidden rounded-[8px] bg-[#202020]'>
{renderWorkflowPreview()}
</div>
</div>
</div>
</div>

View File

@@ -1,114 +1,14 @@
import { useState } from 'react'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
// Icon mapping for template icons
const iconMap = {
// Content & Documentation
FileText,
NotebookPen,
BookOpen,
Edit,
// Analytics & Charts
BarChart3,
LineChart,
TrendingUp,
Target,
// Database & Storage
Database,
Server,
Cloud,
Folder,
// Marketing & Communication
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
// Sales & Finance
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
// Support & Service
HeadphonesIcon,
User,
Users,
Settings,
Wrench,
// AI & Technology
Bot,
Brain,
Cpu,
Code,
Zap,
// Workflow & Process
Workflow,
Search,
Play,
Layers,
// General
Lightbulb,
Star,
Globe,
Award,
}
interface TemplateCardProps {
id: string
title: string
@@ -121,10 +21,8 @@ interface TemplateCardProps {
blocks?: string[]
onClick?: () => void
className?: string
// Add state prop to extract block types
state?: {
blocks?: Record<string, { type: string; name?: string }>
}
// Workflow state for rendering preview
state?: WorkflowState
isStarred?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
@@ -134,62 +32,40 @@ interface TemplateCardProps {
isAuthenticated?: boolean
}
// Skeleton component for loading states
/**
* Skeleton component for loading states
*/
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon skeleton */}
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
{/* Title skeleton */}
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
</div>
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
{/* Workflow preview skeleton */}
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
{/* Star and Use button skeleton */}
<div className='flex flex-shrink-0 items-center gap-3'>
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
</div>
</div>
{/* Description skeleton */}
<div className='space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Bottom section skeleton */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
{/* Stars section - hidden on smaller screens */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
</div>
{/* Title and blocks row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
<div className='flex items-center gap-[-4px]'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='h-[18px] w-[18px] animate-pulse rounded-[4px] bg-gray-700'
/>
))}
</div>
</div>
{/* Right side - Block Icons skeleton */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='animate-pulse rounded bg-gray-200'
style={{ width: '30px', height: '30px' }}
/>
))}
{/* Creator and stats row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
<div className='h-3 w-20 animate-pulse rounded bg-gray-700' />
</div>
<div className='flex items-center gap-[6px]'>
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
</div>
</div>
</div>
)
@@ -210,31 +86,58 @@ const extractBlockTypesFromState = (state?: {
return [...new Set(blockTypes)]
}
// Utility function to get icon component from string or return the component directly
const getIconComponent = (icon: React.ReactNode | string | undefined): React.ReactNode => {
if (typeof icon === 'string') {
const IconComponent = iconMap[icon as keyof typeof iconMap]
return IconComponent ? <IconComponent /> : <FileText />
}
if (icon) {
return icon
}
// Default fallback icon
return <FileText />
}
// Utility function to get block display name
const getBlockDisplayName = (blockType: string): string => {
const block = getBlock(blockType)
return block?.name || blockType
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
/**
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
*/
function normalizeWorkflowState(input?: any): WorkflowState | null {
if (!input || !input.blocks) return null
const normalizedBlocks: WorkflowState['blocks'] = {}
for (const [id, raw] of Object.entries<any>(input.blocks || {})) {
if (!raw || !raw.type) continue
normalizedBlocks[id] = {
id: raw.id ?? id,
type: raw.type,
name: raw.name ?? raw.type,
position: raw.position ?? { x: 0, y: 0 },
subBlocks: raw.subBlocks ?? {},
outputs: raw.outputs ?? {},
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : true,
horizontalHandles: raw.horizontalHandles,
height: raw.height,
advancedMode: raw.advancedMode,
triggerMode: raw.triggerMode,
data: raw.data ?? {},
layout: raw.layout,
}
}
const normalized: WorkflowState = {
blocks: normalizedBlocks,
edges: Array.isArray(input.edges) ? input.edges : [],
loops: input.loops ?? {},
parallels: input.parallels ?? {},
lastSaved: input.lastSaved,
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
isDeployed: input.isDeployed,
deployedAt: input.deployedAt,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}
return normalized
}
export function TemplateCard({
id,
title,
@@ -267,9 +170,6 @@ export function TemplateCard({
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Get the icon component
const iconComponent = getIconComponent(icon)
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
e.stopPropagation()
@@ -323,12 +223,30 @@ export function TemplateCard({
}
}
// Handle use click - just navigate to detail page
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
router.push(`/templates/${id}`)
/**
* Get the appropriate template detail page URL based on context.
* If we're in a workspace context, navigate to the workspace template page.
* Otherwise, navigate to the global template page.
*/
const getTemplateUrl = () => {
const workspaceId = params?.workspaceId as string | undefined
if (workspaceId) {
return `/workspace/${workspaceId}/templates/${id}`
}
return `/templates/${id}`
}
/**
* Handle use button click - navigate to template detail page
*/
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
router.push(getTemplateUrl())
}
/**
* Handle card click - navigate to template detail page
*/
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
@@ -336,150 +254,111 @@ export function TemplateCard({
return
}
router.push(`/templates/${id}`)
router.push(getTemplateUrl())
}
return (
<div
onClick={handleCardClick}
className={cn(
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Left side - Info */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon container */}
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[8px]',
// Use CSS class if iconColor doesn't start with #
iconColor?.startsWith('#') ? '' : iconColor || 'bg-blue-500'
)}
style={{
// Use inline style for hex colors
backgroundColor: iconColor?.startsWith('#') ? iconColor : undefined,
}}
>
<div className='h-3 w-3 text-white [&>svg]:h-3 [&>svg]:w-3'>{iconComponent}</div>
</div>
{/* Template name */}
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
{title}
</h3>
</div>
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
isStarLoading && 'opacity-50'
)}
/>
)}
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</div>
</div>
{/* Description */}
<p className='line-clamp-3 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
{description}
</p>
</div>
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'></span>
<User className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<span></span>
<Star className='h-3 w-3' />
<span>{localStarCount}</span>
</div>
</div>
{/* Workflow Preview */}
<div className='h-[180px] w-full overflow-hidden rounded-[6px]'>
{normalizeWorkflowState(state) ? (
<WorkflowPreview
workflowState={normalizeWorkflowState(state)!}
showSubBlocks={false}
height={180}
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
/>
) : (
<div className='h-full w-full bg-[#2A2A2A]' />
)}
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{blockTypes.length > 3 ? (
<>
{/* Show first 2 blocks when there are more than 3 */}
{blockTypes.slice(0, 2).map((blockType, index) => {
{/* Title and Blocks Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Template Name */}
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
{/* Block Icons */}
<div className='flex flex-shrink-0'>
{blockTypes.length > 4 ? (
<>
{/* Show first 3 blocks when there are more than 4 */}
{blockTypes.slice(0, 3).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div
key={index}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
marginLeft: index > 0 ? '-4px' : '0',
}}
>
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
</div>
)
})}
{/* Show +n for remaining blocks */}
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
style={{ marginLeft: '-4px' }}
>
<span className='font-medium text-[10px] text-white'>+{blockTypes.length - 3}</span>
</div>
</>
) : (
/* Show all blocks when 4 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})}
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
</div>
</div>
</>
) : (
/* Show all blocks when 3 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
key={index}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
marginLeft: index > 0 ? '-4px' : '0',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
</div>
</div>
)
})
)}
)
})
)}
</div>
</div>
{/* Creator and Stats Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] flex-shrink-0 rounded-full bg-[#4A4A4A]' />
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
{/* Stats */}
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
<User className='h-[12px] w-[12px]' />
<span>{usageCount}</span>
<Star
onClick={handleStarClick}
className={cn(
'h-[12px] w-[12px] cursor-pointer transition-colors',
localIsStarred ? 'fill-yellow-400 text-yellow-400' : 'text-[#888888]',
isStarLoading && 'opacity-50'
)}
/>
<span>{localStarCount}</span>
</div>
</div>
</div>
)

View File

@@ -1,6 +1,155 @@
import { redirect } from 'next/navigation'
import { db } from '@sim/db'
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
/**
* Workspace-scoped Templates page.
*
* Mirrors the global templates data loading while rendering the workspace
* templates UI (which accounts for the sidebar layout). This avoids redirecting
* to the global /templates route and keeps users within their workspace context.
*/
export default async function TemplatesPage() {
// Redirect all users to the root templates page
redirect('/templates')
const session = await getSession()
// Determine effective super user (DB flag AND UI mode enabled)
let effectiveSuperUser = false
if (session?.user?.id) {
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userSettings = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, session.user.id))
.limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
effectiveSuperUser = isSuperUser && superUserModeEnabled
}
// Load templates (same logic as global page)
let rows:
| Array<{
id: string
workflowId: string | null
name: string
details?: any
creatorId: string | null
creator: {
id: string
referenceType: 'user' | 'organization'
referenceId: string
name: string
profileImageUrl?: string | null
details?: unknown
} | null
views: number
stars: number
status: 'pending' | 'approved' | 'rejected'
tags: string[]
requiredCredentials: unknown
state: unknown
createdAt: Date | string
updatedAt: Date | string
isStarred?: boolean
}>
| undefined
if (session?.user?.id) {
const whereCondition = effectiveSuperUser ? undefined : eq(templates.status, 'approved')
rows = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
})
.from(templates)
.leftJoin(
templateStars,
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(whereCondition)
.orderBy(desc(templates.views), desc(templates.createdAt))
} else {
rows = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.status, 'approved'))
.orderBy(desc(templates.views), desc(templates.createdAt))
.then((r) => r.map((row) => ({ ...row, isStarred: false })))
}
const initialTemplates: WorkspaceTemplate[] =
rows?.map((row) => {
const authorType = (row.creator?.referenceType as 'user' | 'organization') ?? 'user'
const organizationId =
row.creator?.referenceType === 'organization' ? row.creator.referenceId : null
const userId =
row.creator?.referenceType === 'user' ? row.creator.referenceId : '' /* no owner context */
return {
id: row.id,
workflowId: row.workflowId,
userId,
name: row.name,
description: row.details?.tagline ?? null,
author: row.creator?.name ?? 'Unknown',
authorType,
organizationId,
views: row.views,
stars: row.stars,
color: '#3972F6', // default color for workspace cards
icon: 'Workflow', // default icon for workspace cards
status: row.status,
state: row.state as WorkspaceTemplate['state'],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isStarred: row.isStarred ?? false,
isSuperUser: effectiveSuperUser,
}
}) ?? []
return (
<Templates
initialTemplates={initialTemplates}
currentUserId={session?.user?.id || ''}
isSuperUser={effectiveSuperUser}
/>
)
}

View File

@@ -1,10 +1,10 @@
'use client'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { Layout, Search } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { NavigationTabs } from '@/app/workspace/[workspaceId]/templates/components/navigation-tabs'
import {
TemplateCard,
TemplateCardSkeleton,
@@ -108,7 +108,7 @@ export default function Templates({
stars={template.stars}
icon={template.icon}
iconColor={template.color}
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={true}
@@ -154,50 +154,54 @@ export default function Templates({
return (
<div className='flex h-[100vh] flex-col pl-64'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto p-6'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
{/* Header */}
<div className='mb-6'>
<h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
Templates
</h1>
<p className='font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'>
Grab a template and start building, or make
<br />
one from scratch.
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
</div>
<h1 className='font-medium text-[18px]'>Templates</h1>
</div>
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
Grab a template and start building, or make one from scratch.
</p>
</div>
{/* Search and Create New */}
<div className='mb-6 flex items-center justify-between'>
<div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
{/* Search and Badges */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
<Input
placeholder='Search templates...'
placeholder='Search'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
{/* <Button
onClick={handleCreateNew}
className='flex h-9 items-center gap-2 rounded-lg bg-[var(--brand-primary-hex)] px-4 py-2 font-normal font-sans text-sm text-white hover:bg-[#601EE0]'
>
<Plus className='h-4 w-4' />
Create New
</Button> */}
<div className='flex items-center gap-[8px]'>
<Button
variant={activeTab === 'gallery' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => handleTabClick('gallery')}
>
Gallery
</Button>
<Button
variant={activeTab === 'your' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => handleTabClick('your')}
>
Your Templates
</Button>
</div>
</div>
{/* Navigation */}
<div className='mb-6'>
<NavigationTabs
tabs={navigationTabs}
activeTab={activeTab}
onTabClick={handleTabClick}
/>
</div>
{/* Divider */}
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
{/* Templates Grid - Based on Active Tab */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading ? (
renderSkeletonCards()
) : activeTemplates.length === 0 ? (

View File

@@ -332,20 +332,16 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
size='sm'
className='gap-2'
onClick={() => {
// Open settings modal to creator profile tab
const settingsButton = document.querySelector(
'[data-settings-button]'
) as HTMLButtonElement
if (settingsButton) {
settingsButton.click()
setTimeout(() => {
const creatorProfileTab = document.querySelector(
'[data-section="creator-profile"]'
) as HTMLButtonElement
if (creatorProfileTab) {
creatorProfileTab.click()
}
}, 100)
try {
const event = new CustomEvent('open-settings', {
detail: { tab: 'creator-profile' },
})
window.dispatchEvent(event)
logger.info('Opened Settings modal at creator-profile section')
} catch (error) {
logger.error('Failed to open Settings modal for creator profile', {
error,
})
}
}}
>

View File

@@ -302,13 +302,16 @@ export const DiffControls = memo(function DiffControls() {
}
return (
<div className='-translate-x-1/2 fixed bottom-20 left-1/2 z-30'>
<div className='flex items-center gap-[6px] rounded-[10px] bg-[var(--surface-3)] p-[6px]'>
<div
className='-translate-x-1/2 fixed left-1/2 z-30'
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
>
<div className='flex items-center gap-[6px] rounded-[10px] p-[6px]'>
{/* Toggle (left, icon-only) */}
<Button
variant='ghost'
variant='active'
onClick={handleToggleDiff}
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-400)] hover:text-[var(--text-primary)]'
className='h-[30px] w-[30px] rounded-[8px] p-0'
title={isShowingDiff ? 'View original' : 'Preview changes'}
>
{isShowingDiff ? (
@@ -320,19 +323,19 @@ export const DiffControls = memo(function DiffControls() {
{/* Reject */}
<Button
variant='ghost'
variant='active'
onClick={handleReject}
className='h-[30px] rounded-[8px] bg-[var(--surface-9)] px-3 text-[#868686] hover:bg-[var(--brand-400)] hover:text-[var(--text-primary)]'
className='h-[30px] rounded-[8px] px-3'
title='Reject changes'
>
Reject
</Button>
{/* Accept (primary) */}
{/* Accept */}
<Button
variant='primary'
variant='ghost'
onClick={handleAccept}
className='h-[30px] rounded-[8px] px-3'
className='!text-[var(--bg)] h-[30px] rounded-[8px] bg-[var(--brand-tertiary)] px-3'
title='Accept changes'
>
Accept

View File

@@ -1,15 +1,15 @@
'use client'
import { Component, type ReactNode, useEffect } from 'react'
import { BotIcon } from 'lucide-react'
import { Card } from '@/components/ui/card'
import { createLogger } from '@/lib/logs/console/logger'
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new'
import { SidebarNew } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new'
const logger = createLogger('ErrorBoundary')
// ======== Shared Error UI Component ========
/**
* Shared Error UI Component
*/
interface ErrorUIProps {
title?: string
message?: string
@@ -24,37 +24,40 @@ export function ErrorUI({
fullScreen = false,
}: ErrorUIProps) {
const containerClass = fullScreen
? 'flex flex-col w-full h-screen bg-muted/40'
: 'flex flex-col w-full h-full bg-muted/40'
? 'flex flex-col w-full h-screen bg-[var(--surface-1)]'
: 'flex flex-col w-full h-full bg-[var(--surface-1)]'
return (
<div className={containerClass}>
{/* Control bar */}
<ControlBar hasValidationErrors={false} />
{/* Sidebar */}
<SidebarNew />
{/* Main content area */}
<div className='relative flex flex-1'>
{/* Error message */}
<div className='flex flex-1 items-center justify-center'>
<Card className='max-w-md space-y-4 p-6 text-center'>
<div className='flex justify-center'>
<BotIcon className='h-16 w-16 text-muted-foreground' />
</div>
<h3 className='font-semibold text-lg'>{title}</h3>
<p className='text-muted-foreground'>{message}</p>
</Card>
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='pointer-events-none flex flex-col items-center gap-[16px]'>
{/* Title */}
<h3 className='font-semibold text-[16px] text-[var(--text-primary)]'>{title}</h3>
{/* Message */}
<p className='max-w-md text-center font-medium text-[14px] text-[var(--text-tertiary)]'>
{message}
</p>
</div>
</div>
{/* Console panel */}
<div className='fixed top-0 right-0 z-10'>
<Panel />
</div>
{/* Panel */}
<Panel />
</div>
</div>
)
}
// ======== React Error Boundary Component ========
/**
* React Error Boundary Component
* Catches React rendering errors and displays ErrorUI fallback
*/
interface ErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
@@ -83,7 +86,10 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
}
// ======== Next.js Error Page Component ========
/**
* Next.js Error Page Component
* Renders when a workflow-specific error occurs
*/
interface NextErrorProps {
error: Error & { digest?: string }
reset: () => void
@@ -91,14 +97,16 @@ interface NextErrorProps {
export function NextError({ error, reset }: NextErrorProps) {
useEffect(() => {
// Optionally log the error to an error reporting service
logger.error('Workflow error:', { error })
}, [error])
return <ErrorUI onReset={reset} />
}
// ======== Next.js Global Error Page Component ========
/**
* Next.js Global Error Page Component
* Renders for application-level errors
*/
export function NextGlobalError({
error,
reset,

View File

@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import 'prismjs/components/prism-python'
@@ -30,6 +30,7 @@ import {
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -167,6 +168,10 @@ interface CodeProps {
placeholder?: string
maintainHistory?: boolean
}
/** Ref to expose wand control handlers to parent */
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
/** Whether to hide the internal wand button (controlled by parent) */
hideInternalWand?: boolean
}
export function Code({
@@ -184,6 +189,8 @@ export function Code({
showCopyButton = false,
onValidationChange,
wandConfig,
wandControlRef,
hideInternalWand = false,
}: CodeProps) {
// Route params
const params = useParams()
@@ -574,6 +581,19 @@ export function Code({
return accessiblePrefixes.has(normalizedPrefix)
}
// Expose wand control handlers to parent via ref
useImperativeHandle(
wandControlRef,
() => ({
onWandTrigger: (prompt: string) => {
generateCodeStream({ prompt })
},
isWandActive: isPromptVisible,
isWandStreaming: isAiStreaming,
}),
[generateCodeStream, isPromptVisible, isAiStreaming]
)
/**
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
* @returns Array of React elements representing the line numbers
@@ -641,16 +661,18 @@ export function Code({
{copied ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
</Button>
)}
<WandPromptBar
isVisible={isPromptVisible}
isLoading={isAiLoading}
isStreaming={isAiStreaming}
promptValue={promptInputValue}
onSubmit={(prompt: string) => generateCodeStream({ prompt })}
onCancel={isAiStreaming ? cancelGeneration : hidePromptInline}
onChange={updatePromptValue}
placeholder={dynamicWandConfig?.placeholder || aiPromptPlaceholder}
/>
{!hideInternalWand && (
<WandPromptBar
isVisible={isPromptVisible}
isLoading={isAiLoading}
isStreaming={isAiStreaming}
promptValue={promptInputValue}
onSubmit={(prompt: string) => generateCodeStream({ prompt })}
onCancel={isAiStreaming ? cancelGeneration : hidePromptInline}
onChange={updatePromptValue}
placeholder={dynamicWandConfig?.placeholder || aiPromptPlaceholder}
/>
)}
<CodeEditor.Container
onDragOver={(e) => e.preventDefault()}
@@ -658,18 +680,22 @@ export function Code({
isStreaming={isAiStreaming}
>
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
{wandConfig?.enabled && !isAiStreaming && !isPreview && !readOnly && (
<Button
variant='ghost'
size='icon'
onClick={isPromptVisible ? hidePromptInline : showPromptInline}
disabled={isAiLoading || isAiStreaming}
aria-label='Generate code with AI'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
>
<Wand2 className='h-4 w-4' />
</Button>
)}
{wandConfig?.enabled &&
!isAiStreaming &&
!isPreview &&
!readOnly &&
!hideInternalWand && (
<Button
variant='ghost'
size='icon'
onClick={isPromptVisible ? hidePromptInline : showPromptInline}
disabled={isAiLoading || isAiStreaming}
aria-label='Generate code with AI'
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
>
<Wand2 className='h-4 w-4' />
</Button>
)}
</div>
<CodeEditor.Gutter width={gutterWidthPx}>{renderLineNumbers()}</CodeEditor.Gutter>

View File

@@ -1,4 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { ChevronsUpDown, Wand2 } from 'lucide-react'
import { Textarea } from '@/components/emcn'
import { Button } from '@/components/ui/button'
@@ -7,6 +15,7 @@ import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -53,6 +62,10 @@ interface LongInputProps {
onChange?: (value: string) => void
/** Whether the input is disabled */
disabled?: boolean
/** Ref to expose wand control handlers to parent */
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
/** Whether to hide the internal wand button (controlled by parent) */
hideInternalWand?: boolean
}
/**
@@ -76,6 +89,8 @@ export function LongInput({
value: propValue,
onChange,
disabled,
wandControlRef,
hideInternalWand = false,
}: LongInputProps) {
// Local state for immediate UI updates during streaming
const [localContent, setLocalContent] = useState<string>('')
@@ -225,10 +240,23 @@ export function LongInput({
[height]
)
// Expose wand control handlers to parent via ref
useImperativeHandle(
wandControlRef,
() => ({
onWandTrigger: (prompt: string) => {
wandHook.generateStream({ prompt })
},
isWandActive: wandHook.isPromptVisible,
isWandStreaming: wandHook.isStreaming,
}),
[wandHook]
)
return (
<>
{/* Wand Prompt Bar - positioned above the textarea */}
{isWandEnabled && (
{isWandEnabled && !hideInternalWand && (
<WandPromptBar
isVisible={wandHook.isPromptVisible}
isLoading={wandHook.isLoading}
@@ -303,8 +331,8 @@ export function LongInput({
})}
</div>
{/* Wand Button */}
{isWandEnabled && !isPreview && !wandHook.isStreaming && (
{/* Wand Button - only show if not hidden by parent */}
{isWandEnabled && !isPreview && !wandHook.isStreaming && !hideInternalWand && (
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
<Button
variant='ghost'

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useReactFlow } from 'reactflow'
import { Input } from '@/components/emcn/components/input/input'
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -42,6 +43,10 @@ interface ShortInputProps {
showCopyButton?: boolean
/** Whether to use webhook URL as value */
useWebhookUrl?: boolean
/** Ref to expose wand control handlers to parent */
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
/** Whether to hide the internal wand button (controlled by parent) */
hideInternalWand?: boolean
}
/**
@@ -70,6 +75,8 @@ export function ShortInput({
readOnly = false,
showCopyButton = false,
useWebhookUrl = false,
wandControlRef,
hideInternalWand = false,
}: ShortInputProps) {
// Local state for immediate UI updates during streaming
const [localContent, setLocalContent] = useState<string>('')
@@ -350,9 +357,22 @@ export function ShortInput({
setIsFocused(false)
}, [])
// Expose wand control handlers to parent via ref
useImperativeHandle(
wandControlRef,
() => ({
onWandTrigger: (prompt: string) => {
wandHook.generateStream({ prompt })
},
isWandActive: wandHook.isPromptVisible,
isWandStreaming: wandHook.isStreaming,
}),
[wandHook]
)
return (
<>
{isWandEnabled && (
{isWandEnabled && !hideInternalWand && (
<WandPromptBar
isVisible={wandHook.isPromptVisible}
isLoading={wandHook.isLoading}
@@ -467,8 +487,8 @@ export function ShortInput({
</div>
)}
{/* Wand Button */}
{isWandEnabled && !isPreview && !wandHook.isStreaming && (
{/* Wand Button - only show if not hidden by parent */}
{isWandEnabled && !isPreview && !wandHook.isStreaming && !hideInternalWand && (
<div className='-translate-y-1/2 absolute top-1/2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
<Button
variant='ghost'

View File

@@ -1,6 +1,7 @@
import { type JSX, type MouseEvent, memo, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
import { AlertTriangle, Wand2 } from 'lucide-react'
import { Label, Tooltip } from '@/components/emcn/components'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
@@ -43,6 +44,15 @@ import {
WebhookConfig,
} from './components'
/**
* Interface for wand control handlers exposed by sub-block inputs
*/
export interface WandControlHandlers {
onWandTrigger: (prompt: string) => void
isWandActive: boolean
isWandStreaming: boolean
}
/**
* Props for the `SubBlock` UI element. Renders a single configurable input within a workflow block.
*/
@@ -82,32 +92,102 @@ const getPreviewValue = (
}
/**
* Renders the label with optional validation and description tooltips.
* Renders the label with optional validation, description tooltips, and inline wand control.
* @param config - The sub-block configuration
* @param isValidJson - Whether the JSON is valid
* @param wandState - Wand interaction state
* @returns The label JSX element or null if no title or for switch types
*/
const renderLabel = (config: SubBlockConfig, isValidJson: boolean): JSX.Element | null => {
const renderLabel = (
config: SubBlockConfig,
isValidJson: boolean,
wandState: {
isSearchActive: boolean
searchQuery: string
isWandEnabled: boolean
isPreview: boolean
isStreaming: boolean
onSearchClick: () => void
onSearchBlur: () => void
onSearchChange: (value: string) => void
onSearchSubmit: () => void
onSearchCancel: () => void
searchInputRef: React.RefObject<HTMLInputElement | null>
}
): JSX.Element | null => {
if (config.type === 'switch') return null
if (!config.title) return null
const {
isSearchActive,
searchQuery,
isWandEnabled,
isPreview,
isStreaming,
onSearchClick,
onSearchBlur,
onSearchChange,
onSearchSubmit,
onSearchCancel,
searchInputRef,
} = wandState
return (
<Label className='flex items-center gap-[6px] pl-[2px]'>
{config.title}
{config.id === 'responseFormat' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertTriangle
<Label className='flex items-center justify-between gap-[6px] pl-[2px]'>
<div className='flex items-center gap-[6px] whitespace-nowrap'>
{config.title}
{config.id === 'responseFormat' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertTriangle
className={cn(
'h-4 w-4 cursor-pointer text-destructive',
!isValidJson ? 'opacity-100' : 'opacity-0'
)}
/>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Invalid JSON</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{/* Wand inline prompt */}
{isWandEnabled && !isPreview && (
<div className='flex items-center pr-[4px]'>
{!isSearchActive ? (
<Button
variant='ghost'
className='h-[12px] w-[12px] p-0 hover:bg-transparent'
aria-label='Generate with AI'
onClick={onSearchClick}
>
<Wand2 className='!h-[12px] !w-[12px] bg-transparent text-[var(--text-secondary)]' />
</Button>
) : (
<input
ref={searchInputRef}
type='text'
value={isStreaming ? 'Generating...' : searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={onSearchBlur}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
onSearchSubmit()
} else if (e.key === 'Escape') {
onSearchCancel()
}
}}
disabled={isStreaming}
className={cn(
'h-4 w-4 cursor-pointer text-destructive',
!isValidJson ? 'opacity-100' : 'opacity-0'
'h-[12px] w-full max-w-[200px] border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[#737373] focus:outline-none dark:text-[var(--text-primary)]',
isStreaming && 'text-muted-foreground'
)}
placeholder='Describe...'
/>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Invalid JSON</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
)}
</Label>
)
@@ -144,6 +224,10 @@ function SubBlockComponent({
allowExpandInPreview,
}: SubBlockProps): JSX.Element {
const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const wandControlRef = useRef<WandControlHandlers | null>(null)
const handleMouseDown = (e: MouseEvent<HTMLDivElement>): void => {
e.stopPropagation()
@@ -153,6 +237,54 @@ function SubBlockComponent({
setIsValidJson(isValid)
}
// Check if wand is enabled for this sub-block
const isWandEnabled = config.wandConfig?.enabled ?? false
/**
* Handle wand icon click to activate inline prompt mode
*/
const handleSearchClick = (): void => {
setIsSearchActive(true)
setTimeout(() => {
searchInputRef.current?.focus()
}, 0)
}
/**
* Handle search input blur - deactivate if empty and not streaming
*/
const handleSearchBlur = (): void => {
if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) {
setIsSearchActive(false)
}
}
/**
* Handle search query change
*/
const handleSearchChange = (value: string): void => {
setSearchQuery(value)
}
/**
* Handle search submit - trigger generation
*/
const handleSearchSubmit = (): void => {
if (searchQuery.trim() && wandControlRef.current) {
wandControlRef.current.onWandTrigger(searchQuery)
setSearchQuery('')
setIsSearchActive(false)
}
}
/**
* Handle search cancel
*/
const handleSearchCancel = (): void => {
setSearchQuery('')
setIsSearchActive(false)
}
const previewValue = getPreviewValue(config, isPreview, subBlockValues) as
| string
| string[]
@@ -187,6 +319,8 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as string | null | undefined}
disabled={isDisabled}
wandControlRef={wandControlRef}
hideInternalWand={true}
/>
)
@@ -201,6 +335,8 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
wandControlRef={wandControlRef}
hideInternalWand={true}
/>
)
@@ -295,6 +431,8 @@ function SubBlockComponent({
placeholder: '',
}
}
wandControlRef={wandControlRef}
hideInternalWand={true}
/>
)
@@ -631,7 +769,19 @@ function SubBlockComponent({
return (
<div onMouseDown={handleMouseDown} className='flex flex-col gap-[10px]'>
{renderLabel(config, isValidJson)}
{renderLabel(config, isValidJson, {
isSearchActive,
searchQuery,
isWandEnabled,
isPreview,
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
onSearchClick: handleSearchClick,
onSearchBlur: handleSearchBlur,
onSearchChange: handleSearchChange,
onSearchSubmit: handleSearchSubmit,
onSearchCancel: handleSearchCancel,
searchInputRef,
})}
{renderInput()}
</div>
)

View File

@@ -3,18 +3,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import imageCompression from 'browser-image-compression'
import { X } from 'lucide-react'
import { Loader2, X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/emcn/components/button/button'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Button, Input, Modal, ModalContent } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -358,12 +351,14 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
}, [onOpenChange])
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='flex h-[75vh] max-h-[75vh] flex-col gap-0 p-0 sm:max-w-[700px]'>
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent className='flex h-[75vh] max-h-[75vh] w-full max-w-[700px] flex-col gap-0 p-0'>
{/* Modal Header */}
<AlertDialogHeader className='flex-shrink-0 px-6 py-5'>
<AlertDialogTitle className='font-medium text-lg'>Help & Support</AlertDialogTitle>
</AlertDialogHeader>
<div className='flex-shrink-0 px-6 py-5'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Help & Support
</h2>
</div>
{/* Modal Body */}
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
@@ -374,21 +369,30 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
className='scrollbar-hide min-h-0 flex-1 overflow-y-auto pb-20'
>
<div className='px-6'>
<div className='space-y-4'>
<div className='space-y-[12px]'>
{/* Request Type Field */}
<div className='space-y-1'>
<Label htmlFor='type'>Request</Label>
<div className='space-y-[8px]'>
<Label
htmlFor='type'
className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
>
Request
</Label>
<Select
defaultValue={DEFAULT_REQUEST_TYPE}
onValueChange={(value) => setValue('type', value as FormValues['type'])}
>
<SelectTrigger
id='type'
className={cn('h-9 rounded-[8px]', errors.type && 'border-red-500')}
className={cn(
'h-9 rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] text-[13px] dark:bg-[var(--surface-9)]',
errors.type &&
'border-[var(--text-error)] dark:border-[var(--text-error)]'
)}
>
<SelectValue placeholder='Select a request type' />
</SelectTrigger>
<SelectContent>
<SelectContent className='z-[10000000]'>
<SelectItem value='bug'>Bug Report</SelectItem>
<SelectItem value='feedback'>Feedback</SelectItem>
<SelectItem value='feature_request'>Feature Request</SelectItem>
@@ -396,42 +400,68 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
</SelectContent>
</Select>
{errors.type && (
<p className='mt-1 text-red-500 text-sm'>{errors.type.message}</p>
<p className='mt-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{errors.type.message}
</p>
)}
</div>
{/* Subject Field */}
<div className='space-y-1'>
<Label htmlFor='subject'>Subject</Label>
<div className='space-y-[8px]'>
<Label
htmlFor='subject'
className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
>
Subject
</Label>
<Input
id='subject'
placeholder='Brief description of your request'
{...register('subject')}
className={cn('h-9 rounded-[8px]', errors.subject && 'border-red-500')}
className={cn(
'h-9 rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] text-[13px] dark:bg-[var(--surface-9)]',
errors.subject &&
'border-[var(--text-error)] dark:border-[var(--text-error)]'
)}
/>
{errors.subject && (
<p className='mt-1 text-red-500 text-sm'>{errors.subject.message}</p>
<p className='mt-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{errors.subject.message}
</p>
)}
</div>
{/* Message Field */}
<div className='space-y-1'>
<Label htmlFor='message'>Message</Label>
<div className='space-y-[8px]'>
<Label
htmlFor='message'
className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
>
Message
</Label>
<Textarea
id='message'
placeholder='Please provide details about your request...'
rows={6}
{...register('message')}
className={cn('rounded-[8px]', errors.message && 'border-red-500')}
className={cn(
'rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] text-[13px] dark:bg-[var(--surface-9)]',
errors.message &&
'border-[var(--text-error)] dark:border-[var(--text-error)]'
)}
/>
{errors.message && (
<p className='mt-1 text-red-500 text-sm'>{errors.message.message}</p>
<p className='mt-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{errors.message.message}
</p>
)}
</div>
{/* Image Upload Section */}
<div className='mt-6 space-y-1'>
<Label>Attach Images (Optional)</Label>
<div className='space-y-[8px]'>
<Label className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Attach Images (Optional)
</Label>
<div
ref={dropZoneRef}
onDragEnter={handleDragEnter}
@@ -439,8 +469,9 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'cursor-pointer rounded-lg border-[1.5px] border-muted-foreground/25 border-dashed p-6 text-center transition-colors hover:bg-muted/50',
isDragging && 'border-primary bg-primary/5'
'cursor-pointer rounded-[4px] border-[1.5px] border-[var(--surface-11)] border-dashed bg-[var(--surface-3)] p-6 text-center transition-colors hover:bg-[var(--surface-5)] dark:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-5)]',
isDragging &&
'border-[var(--brand-primary-hex)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
)}
onClick={() => fileInputRef.current?.click()}
>
@@ -452,28 +483,37 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
className='hidden'
multiple
/>
<p className='text-sm'>
<p className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{isDragging ? 'Drop images here!' : 'Drop images here or click to browse'}
</p>
<p className='mt-1 text-muted-foreground text-xs'>
<p className='mt-[4px] text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
JPEG, PNG, WebP, GIF (max 20MB each)
</p>
</div>
{imageError && <p className='mt-1 text-red-500 text-sm'>{imageError}</p>}
{imageError && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{imageError}
</p>
)}
{isProcessing && (
<p className='text-muted-foreground text-sm'>Processing images...</p>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Loader2 className='h-4 w-4 animate-spin' />
<p className='text-[12px]'>Processing images...</p>
</div>
)}
</div>
{/* Image Preview Grid */}
{images.length > 0 && (
<div className='space-y-1'>
<Label>Uploaded Images</Label>
<div className='space-y-[8px]'>
<Label className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Uploaded Images
</Label>
<div className='grid grid-cols-2 gap-4'>
{images.map((image, index) => (
<div
key={index}
className='group relative overflow-hidden rounded-md border'
className='group relative overflow-hidden rounded-[4px] border border-[var(--surface-11)]'
>
<div className='relative aspect-video'>
<Image
@@ -482,14 +522,17 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
fill
className='object-cover'
/>
<div
<button
type='button'
className='absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => removeImage(index)}
>
<X className='h-6 w-6 text-white' />
</div>
</button>
</div>
<div className='truncate bg-[var(--surface-5)] p-2 text-[12px] text-[var(--text-secondary)] dark:bg-[var(--surface-5)] dark:text-[var(--text-secondary)]'>
{image.name}
</div>
<div className='truncate bg-muted/50 p-2 text-xs'>{image.name}</div>
</div>
))}
</div>
@@ -500,31 +543,31 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
</div>
{/* Fixed Footer with Actions */}
<div className='absolute inset-x-0 bottom-0 bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
<div className='absolute inset-x-0 bottom-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'>
<div className='flex w-full items-center justify-between gap-[8px] px-6 py-4'>
<Button
variant='default'
onClick={handleClose}
type='button'
className='min-w-[80px] px-[10px] py-[8px] text-[13px]'
className='h-[32px] px-[12px] font-medium text-[13px]'
disabled={isSubmitting}
>
Cancel
</Button>
<Button
<button
type='submit'
disabled={isSubmitting || isProcessing}
variant={
submitStatus === 'error' || submitStatus === 'success' ? 'outline' : 'primary'
}
className={cn(
'min-w-[80px] px-[10px] py-[8px] text-[13px] transition-all duration-200',
'flex h-[32px] items-center justify-center gap-[8px] rounded-[8px] px-[12px] font-medium text-[13px] text-white transition-all duration-200',
submitStatus === 'error'
? 'border border-red-500 bg-transparent text-red-500 hover:bg-red-500 hover:text-white dark:border-red-500 dark:text-red-500 dark:hover:bg-red-500'
? 'bg-[var(--text-error)] hover:opacity-90 dark:bg-[var(--text-error)]'
: submitStatus === 'success'
? 'border border-green-500 bg-transparent text-green-500 hover:bg-green-500 hover:text-white dark:border-green-500 dark:text-green-500 dark:hover:bg-green-500'
: ''
? 'bg-green-500 hover:opacity-90'
: 'bg-[var(--brand-primary-hex)] shadow-[0_0_0_0_var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
)}
>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{isSubmitting
? 'Submitting...'
: submitStatus === 'error'
@@ -532,12 +575,12 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
: submitStatus === 'success'
? 'Success'
: 'Submit'}
</Button>
</button>
</div>
</div>
</form>
</div>
</AlertDialogContent>
</AlertDialog>
</ModalContent>
</Modal>
)
}

View File

@@ -2,5 +2,6 @@ export { FooterNavigation } from './footer-navigation'
export { HelpModal } from './help-modal'
export { SearchModal } from './search-modal'
export { SettingsModal } from './settings-modal'
export { UsageIndicator } from './usage-indicator/usage-indicator'
export { WorkflowList } from './workflow-list/workflow-list'
export { WorkspaceHeader } from './workspace-header'

View File

@@ -469,8 +469,8 @@ export function SearchModal({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay
className='bg-white/80 dark:bg-transparent'
style={{ backdropFilter: 'blur(3px)' }}
className='bg-white/80 dark:bg-[#1b1b1b]/90'
style={{ backdropFilter: 'blur(4px)' }}
/>
<DialogPrimitive.Content className='fixed top-[15%] left-[50%] z-50 flex w-[500px] translate-x-[-50%] flex-col gap-[12px] p-0 focus:outline-none focus-visible:outline-none'>
<VisuallyHidden.Root>
@@ -478,28 +478,28 @@ export function SearchModal({
</VisuallyHidden.Root>
{/* Search input container */}
<div className='flex items-center gap-[6px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-5)] px-[10px] py-[8px] shadow-sm dark:border-[var(--border)] dark:bg-[var(--surface-5)]'>
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-5)] px-[12px] py-[8px] shadow-sm dark:border-[var(--border)] dark:bg-[var(--surface-5)]'>
<Search className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-subtle)] dark:text-[var(--text-subtle)]' />
<input
type='text'
placeholder='Search anything...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='w-full border-0 bg-transparent font-base text-[16px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-secondary)]'
className='w-full border-0 bg-transparent font-base text-[18px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-secondary)]'
autoFocus
/>
</div>
{/* Floating results container */}
{filteredItems.length > 0 ? (
<div className='scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent max-h-[400px] overflow-y-auto rounded-[10px] py-[8px] shadow-sm backdrop-blur-lg'>
<div className='scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent max-h-[400px] overflow-y-auto rounded-[10px] py-[10px] shadow-sm'>
{Object.entries(groupedItems).map(([type, items]) => {
if (items.length === 0) return null
return (
<div key={type} className='mb-[10px] last:mb-0'>
{/* Section header */}
<div className='pt-[2px] pb-[4px] font-medium text-[11px] text-[var(--text-subtle)] uppercase tracking-wide dark:text-[var(--text-subtle)]'>
<div className='pt-[2px] pb-[4px] font-medium text-[13px] text-[var(--text-subtle)] uppercase tracking-wide dark:text-[var(--text-subtle)]'>
{sectionTitles[type]}
</div>
@@ -520,7 +520,7 @@ export function SearchModal({
data-search-item-index={globalIndex}
onClick={() => handleItemClick(item)}
className={cn(
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[8px] text-left text-[13px] transition-all focus:outline-none dark:bg-[var(--surface-4)]/60',
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none dark:bg-[var(--surface-4)]/60',
isSelected
? 'bg-[var(--border)] shadow-sm dark:bg-[var(--border)]'
: 'hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'
@@ -573,7 +573,7 @@ export function SearchModal({
{/* Shortcut */}
{item.shortcut && (
<span className='ml-auto flex-shrink-0 font-medium text-[11px] text-[var(--text-subtle)] dark:text-[var(--text-subtle)]'>
<span className='ml-auto flex-shrink-0 font-medium text-[13px] text-[var(--text-subtle)] dark:text-[var(--text-subtle)]'>
{item.shortcut}
</span>
)}
@@ -587,7 +587,7 @@ export function SearchModal({
</div>
) : searchQuery ? (
<div className='flex items-center justify-center rounded-[10px] bg-[var(--surface-5)] px-[16px] py-[24px] shadow-sm dark:bg-[var(--surface-5)]'>
<p className='text-[13px] text-[var(--text-subtle)] dark:text-[var(--text-subtle)]'>
<p className='text-[15px] text-[var(--text-subtle)] dark:text-[var(--text-subtle)]'>
No results found for "{searchQuery}"
</p>
</div>

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'
import {
Bot,
CreditCard,
FileCode,
Files,
@@ -118,11 +117,11 @@ const allNavigationItems: NavigationItem[] = [
label: 'File Uploads',
icon: Files,
},
{
id: 'copilot',
label: 'Copilot',
icon: Bot,
},
// {
// id: 'copilot',
// label: 'Copilot',
// icon: Bot,
// },
{
id: 'privacy',
label: 'Privacy',

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { Modal, ModalContent } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -118,15 +118,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className='flex h-[70vh] flex-col gap-0 p-0 sm:max-w-[840px]'>
<DialogHeader className='border-b px-6 py-4'>
<DialogTitle className='font-medium text-lg'>Settings</DialogTitle>
</DialogHeader>
<Modal open={open} onOpenChange={handleDialogOpenChange}>
<ModalContent className='flex h-[70vh] w-full max-w-[840px] flex-col gap-0 p-0'>
<div className='flex flex-col border-[var(--surface-11)] border-b px-[16px] py-[12px]'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Settings
</h2>
</div>
<div className='flex min-h-0 flex-1'>
{/* Navigation Sidebar */}
<div className='w-[180px]'>
<div className='w-[180px] border-[var(--surface-11)] border-r'>
<SettingsNavigation
activeSection={activeSection}
onSectionChange={setActiveSection}
@@ -218,7 +220,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
)}
</div>
</div>
</DialogContent>
</Dialog>
</ModalContent>
</Modal>
)
}

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react'
import { Badge, Progress, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -13,6 +14,8 @@ const GRADIENT_TEXT_STYLES =
const CONTAINER_STYLES =
'pointer-events-auto flex-shrink-0 rounded-[10px] border bg-background px-3 py-2.5 shadow-xs cursor-pointer transition-colors hover:bg-muted/50'
const logger = createLogger('UsageIndicator')
// Plan name mapping
const PLAN_NAMES = {
enterprise: 'Enterprise',
@@ -66,8 +69,29 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const isBlocked = billingStatus === 'blocked'
const badgeText = isBlocked ? 'Payment Failed' : planType === 'free' ? 'Upgrade' : undefined
const handleClick = () => {
try {
if (onClick) {
onClick()
return
}
const subscriptionStore = useSubscriptionStore.getState()
const blocked = subscriptionStore.getBillingStatus() === 'blocked'
const canUpgrade = subscriptionStore.canUpgrade()
// Open Settings modal to the subscription tab (upgrade UI lives there)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
logger.info('Opened settings to subscription tab', { blocked, canUpgrade })
}
} catch (error) {
logger.error('Failed to handle usage indicator click', { error })
}
}
return (
<div className={CONTAINER_STYLES} onClick={() => onClick?.()}>
<div className={CONTAINER_STYLES} onClick={handleClick}>
<div className='space-y-2'>
{/* Plan and usage info */}
<div className='flex items-center justify-between'>

View File

@@ -3,19 +3,7 @@
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2, RotateCw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge, Button, Input, Modal, ModalContent, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth-client'
import { quickValidateEmail } from '@/lib/email/validation'
@@ -85,14 +73,18 @@ interface PendingInvitation {
const EmailTag = React.memo<EmailTagProps>(({ email, onRemove, disabled, isInvalid, isSent }) => (
<div
className={cn(
'flex w-auto items-center gap-1 rounded-[8px] border px-2 py-0.5 text-sm',
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[8px] py-[4px] text-[12px]',
isInvalid
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'border bg-muted text-muted-foreground'
: 'border-[var(--surface-11)] bg-[var(--surface-5)] text-[var(--text-secondary)] dark:bg-[var(--surface-5)] dark:text-[var(--text-secondary)]'
)}
>
<span className='max-w-[200px] truncate'>{email}</span>
{isSent && <span className='text-muted-foreground text-xs'>sent</span>}
{isSent && (
<span className='text-[11px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
sent
</span>
)}
{!disabled && !isSent && (
<button
type='button'
@@ -101,7 +93,7 @@ const EmailTag = React.memo<EmailTagProps>(({ email, onRemove, disabled, isInval
'flex-shrink-0 transition-colors focus:outline-none',
isInvalid
? 'text-red-400 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300'
: 'text-muted-foreground hover:text-foreground'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:hover:text-[var(--text-primary)]'
)}
aria-label={`Remove ${email}`}
>
@@ -133,7 +125,10 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
return (
<div
className={cn('inline-flex rounded-[12px] border border-input bg-background', className)}
className={cn(
'inline-flex rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)]',
className
)}
>
{permissionOptions.map((option, index) => (
<button
@@ -142,13 +137,13 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
onClick={() => !disabled && onChange(option.value)}
disabled={disabled}
className={cn(
'px-2.5 py-1.5 font-medium text-xs transition-colors focus:outline-none',
'first:rounded-l-[11px] last:rounded-r-[11px]',
'px-[8px] py-[4px] font-medium text-[12px] transition-colors focus:outline-none',
'first:rounded-l-[4px] last:rounded-r-[4px]',
disabled && 'cursor-not-allowed opacity-50',
value === option.value
? 'bg-foreground text-background'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
index > 0 && 'border-input border-l'
? 'bg-[var(--surface-9)] text-[var(--text-primary)] dark:bg-[var(--surface-5)] dark:text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-8)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--surface-8)] dark:hover:text-[var(--text-primary)]',
index > 0 && 'border-[var(--surface-11)] border-l'
)}
>
{option.label}
@@ -164,13 +159,13 @@ PermissionSelector.displayName = 'PermissionSelector'
const PermissionsTableSkeleton = React.memo(() => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className='flex items-center justify-between gap-2 py-2'>
<Skeleton className='h-5 w-40' />
<div className='flex items-center gap-2'>
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
<div className='flex w-10 items-center gap-1 sm:w-12'>
<Skeleton className='h-4 w-4 rounded' />
<Skeleton className='h-4 w-4 rounded' />
<div key={idx} className='flex items-center justify-between gap-[8px] py-[8px]'>
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[28px] w-32 flex-shrink-0 rounded-[4px]' />
<div className='flex w-10 items-center gap-[4px] sm:w-12'>
<Skeleton className='h-4 w-4 rounded-[4px]' />
<Skeleton className='h-4 w-4 rounded-[4px]' />
</div>
</div>
</div>
@@ -263,17 +258,19 @@ const PermissionsTable = ({
if (isSaving) {
return (
<div className='space-y-4'>
<h3 className='font-medium text-sm'>Member Permissions</h3>
<div className='rounded-[8px] border bg-card'>
<div className='flex items-center justify-center py-12'>
<div className='flex items-center space-x-2 text-muted-foreground'>
<div className='space-y-[12px]'>
<h3 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Member Permissions
</h3>
<div className='rounded-[8px] border border-[var(--surface-11)] bg-[var(--surface-3)] dark:bg-[var(--surface-3)]'>
<div className='flex items-center justify-center py-[48px]'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Loader2 className='h-5 w-5 animate-spin' />
<span className='font-medium text-sm'>Saving permission changes...</span>
<span className='font-medium text-[13px]'>Saving permission changes...</span>
</div>
</div>
</div>
<p className='flex min-h-[2rem] items-start text-muted-foreground text-xs'>
<p className='flex min-h-[2rem] items-start text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Please wait while we update the permissions.
</p>
</div>
@@ -316,13 +313,15 @@ const PermissionsTable = ({
: `new-${user.email}`
return (
<div key={uniqueKey} className='flex items-center justify-between gap-2 py-2'>
<div key={uniqueKey} className='flex items-center justify-between gap-[8px] py-[8px]'>
{/* Email and status badges */}
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{user.email}
</span>
{isPendingInvitation && (
<span className='inline-flex items-center gap-1 rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
<Badge variant='default' className='gap-[4px]'>
{resendingInvitationIds &&
user.invitationId &&
resendingInvitationIds[user.invitationId] ? (
@@ -337,18 +336,14 @@ const PermissionsTable = ({
) : (
<span>Sent</span>
)}
</span>
)}
{hasChanges && (
<span className='inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
Modified
</span>
</Badge>
)}
{hasChanges && <Badge variant='default'>Modified</Badge>}
</div>
</div>
{/* Permission selector and fixed-width action area to keep rows aligned */}
<div className='flex flex-shrink-0 items-center gap-2'>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<PermissionSelector
value={user.permissionType}
onChange={(newPermission) => onPermissionChange(userIdentifier, newPermission)}
@@ -362,7 +357,7 @@ const PermissionsTable = ({
/>
{/* Fixed-width action area so selector stays inline across rows */}
<div className='flex h-4 w-10 items-center justify-center gap-1 sm:w-12'>
<div className='flex h-4 w-10 items-center justify-center gap-[4px] sm:w-12'>
{isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
@@ -372,7 +367,6 @@ const PermissionsTable = ({
<span className='inline-flex'>
<Button
variant='ghost'
size='icon'
onClick={() => onResendInvitation(user.invitationId!, user.email)}
disabled={
disabled ||
@@ -380,7 +374,7 @@ const PermissionsTable = ({
resendingInvitationIds?.[user.invitationId!] ||
(resendCooldowns && resendCooldowns[user.invitationId!] > 0)
}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
className='h-4 w-4 p-0'
>
{resendingInvitationIds?.[user.invitationId!] ? (
<Loader2 className='h-3.5 w-3.5 animate-spin' />
@@ -409,7 +403,6 @@ const PermissionsTable = ({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (canShowRemoveButton && onRemoveMember) {
onRemoveMember(user.userId!, user.email)
@@ -422,7 +415,7 @@ const PermissionsTable = ({
}
}}
disabled={disabled || isSaving}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
className='h-4 w-4 p-0'
>
<X className='h-3.5 w-3.5' />
<span className='sr-only'>
@@ -1049,26 +1042,31 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
}, [])
return (
<AlertDialog
<Modal
open={open}
onOpenChange={(newOpen) => {
onOpenChange={(newOpen: boolean) => {
if (!newOpen) {
resetState()
}
onOpenChange(newOpen)
}}
>
<AlertDialogContent className='flex max-h-[80vh] flex-col gap-0 sm:max-w-[560px]'>
<AlertDialogHeader>
<AlertDialogTitle>Invite members to {workspaceName || 'Workspace'}</AlertDialogTitle>
</AlertDialogHeader>
<ModalContent className='flex max-h-[80vh] w-full max-w-[560px] flex-col gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Invite members to {workspaceName || 'Workspace'}
</h2>
</div>
<form ref={formRef} onSubmit={handleSubmit} className='mt-5'>
<div className='space-y-2'>
<label htmlFor='emails' className='font-medium text-sm'>
<form ref={formRef} onSubmit={handleSubmit} className='mt-[8px]'>
<div className='space-y-[8px]'>
<label
htmlFor='emails'
className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
>
Email Addresses
</label>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border border-input bg-background px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] focus-within:outline-none dark:bg-[var(--surface-9)]'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
@@ -1102,18 +1100,22 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
: 'Enter emails'
}
className={cn(
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-1'
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
/>
</div>
{errorMessage && <p className='mt-1 text-destructive text-xs'>{errorMessage}</p>}
{errorMessage && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{errorMessage}
</p>
)}
</div>
{/* Line separator */}
<div className='mt-6 mb-4 border-t' />
<div className='mt-[16px] mb-[12px] border-[var(--surface-11)] border-t' />
<PermissionsTable
userPermissions={userPermissions}
@@ -1135,26 +1137,26 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
</form>
{/* Consistent spacing below user list to match spacing above */}
<div className='mb-4' />
<div className='mb-[12px]' />
<AlertDialogFooter className='flex justify-between'>
<div className='flex justify-between gap-[8px]'>
{hasPendingChanges && userPerms.canAdmin && (
<>
<Button
type='button'
variant='outline'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
className='h-[32px] gap-[8px] px-[12px] font-medium'
>
Restore Changes
</Button>
<Button
type='button'
variant='outline'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
className='h-[32px] gap-[8px] px-[12px] font-medium'
>
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
Save Changes
@@ -1162,85 +1164,101 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
</>
)}
<Button
<button
type='button'
onClick={() => formRef.current?.requestSubmit()}
disabled={
!userPerms.canAdmin || isSubmitting || isSaving || !workspaceId || !hasNewInvites
}
className={cn(
'ml-auto flex h-9 items-center justify-center gap-2 rounded-[8px] px-4 py-2 font-medium transition-all duration-200',
'bg-[var(--brand-primary-hex)] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(112,31,252,0.15)] disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
'ml-auto flex h-[32px] items-center justify-center gap-[8px] rounded-[8px] px-[12px] font-medium text-[13px] transition-all duration-200',
'bg-[var(--brand-primary-hex)] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
)}
>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{!userPerms.canAdmin ? 'Admin Access Required' : 'Send Invite'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</button>
</div>
</ModalContent>
{/* Remove Member Confirmation Dialog */}
<AlertDialog open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
<ModalContent>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Remove Member
</h2>
<p className='text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Are you sure you want to remove{' '}
<span className='font-medium text-foreground'>{memberToRemove?.email}</span> from this
workspace?{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
<span className='font-medium text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{memberToRemove?.email}
</span>{' '}
from this workspace?{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</p>
</div>
<div className='flex justify-between gap-[8px]'>
<Button
variant='default'
className='h-[32px] w-full px-[12px]'
onClick={handleRemoveMemberCancel}
disabled={isRemovingMember}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
</Button>
<button
onClick={handleRemoveMemberConfirm}
disabled={isRemovingMember}
className='h-9 w-full gap-2 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] w-full gap-[8px] rounded-[8px] bg-[var(--text-error)] px-[12px] font-medium text-[13px] text-white transition-all duration-200 hover:bg-[var(--text-error)] disabled:opacity-50 dark:bg-[var(--text-error)] dark:hover:bg-[var(--text-error)]'
>
{isRemovingMember && <Loader2 className='h-4 w-4 animate-spin' />}
{isRemovingMember && <Loader2 className='mr-1 h-4 w-4 animate-spin' />}
Remove Member
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</button>
</div>
</ModalContent>
</Modal>
{/* Remove Invitation Confirmation Dialog */}
<AlertDialog open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Invitation</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
<ModalContent>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Cancel Invitation
</h2>
<p className='text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Are you sure you want to cancel the invitation for{' '}
<span className='font-medium text-foreground'>{invitationToRemove?.email}</span>?{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
<span className='font-medium text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{invitationToRemove?.email}
</span>
?{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</p>
</div>
<div className='flex justify-between gap-[8px]'>
<Button
variant='default'
className='h-[32px] w-full px-[12px]'
onClick={handleRemoveInvitationCancel}
disabled={isRemovingInvitation}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
</Button>
<button
onClick={handleRemoveInvitationConfirm}
disabled={isRemovingInvitation}
className='h-9 w-full gap-2 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] w-full gap-[8px] rounded-[8px] bg-[var(--text-error)] px-[12px] font-medium text-[13px] text-white transition-all duration-200 hover:bg-[var(--text-error)] disabled:opacity-50 dark:bg-[var(--text-error)] dark:hover:bg-[var(--text-error)]'
>
{isRemovingInvitation && <Loader2 className='h-4 w-4 animate-spin' />}
{isRemovingInvitation && <Loader2 className='mr-1 h-4 w-4 animate-spin' />}
Cancel Invitation
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AlertDialog>
</button>
</div>
</ModalContent>
</Modal>
</Modal>
)
}

View File

@@ -98,6 +98,10 @@ interface WorkspaceHeaderProps {
* Whether workspace import is in progress
*/
isImportingWorkspace: boolean
/**
* Whether to show the collapse button
*/
showCollapseButton?: boolean
}
/**
@@ -122,6 +126,7 @@ export function WorkspaceHeader({
onExportWorkspace,
onImportWorkspace,
isImportingWorkspace,
showCollapseButton = true,
}: WorkspaceHeaderProps) {
const userPermissions = useUserPermissionsContext()
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
@@ -438,15 +443,17 @@ export function WorkspaceHeader({
</PopoverContent>
</Popover>
{/* Sidebar Collapse Toggle */}
<Button
variant='ghost-secondary'
type='button'
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className='group !p-[3px] -m-[3px]'
onClick={onToggleCollapse}
>
<PanelLeft className='h-[17.5px] w-[17.5px]' />
</Button>
{showCollapseButton && (
<Button
variant='ghost-secondary'
type='button'
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className='group !p-[3px] -m-[3px]'
onClick={onToggleCollapse}
>
<PanelLeft className='h-[17.5px] w-[17.5px]' />
</Button>
)}
</div>
{/* Context Menu */}

View File

@@ -1,10 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { useSidebarStore } from '@/stores/sidebar/store'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
/**
* Constants for sidebar sizing
*/
const MIN_WIDTH = 232
const MAX_WIDTH_PERCENTAGE = 0.3 // 30% of viewport width
/**
@@ -36,7 +35,7 @@ export function useSidebarResize() {
const newWidth = e.clientX
const maxWidth = window.innerWidth * MAX_WIDTH_PERCENTAGE
if (newWidth >= MIN_WIDTH && newWidth <= maxWidth) {
if (newWidth >= MIN_SIDEBAR_WIDTH && newWidth <= maxWidth) {
setSidebarWidth(newWidth)
}
}

View File

@@ -5,11 +5,13 @@ import { ArrowDown, Plus, Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button, FolderPlus, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth-client'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import {
FooterNavigation,
SearchModal,
UsageIndicator,
WorkflowList,
WorkspaceHeader,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new'
@@ -25,10 +27,14 @@ import {
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolderStore } from '@/stores/folders/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('SidebarNew')
// Feature flag: Billing usage indicator visibility (matches legacy sidebar behavior)
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
// const isBillingEnabled = true
/**
* Sidebar component with resizable width that persists across page refreshes.
*
@@ -57,6 +63,10 @@ export function SidebarNew() {
// Sidebar state
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
// Determine if we're on a workflow page (only workflow pages allow collapse and resize)
const isOnWorkflowPage = !!workflowId
// Import state
const [isImporting, setIsImporting] = useState(false)
@@ -207,6 +217,20 @@ export function SidebarNew() {
}
}, [])
/**
* Force sidebar to minimum width and ensure it's expanded when not on a workflow page
*/
useEffect(() => {
if (!isOnWorkflowPage) {
// Ensure sidebar is always expanded on non-workflow pages
if (isCollapsed) {
setIsCollapsed(false)
}
// Force sidebar to minimum width
setSidebarWidth(MIN_SIDEBAR_WIDTH)
}
}, [isOnWorkflowPage, isCollapsed, setIsCollapsed, setSidebarWidth])
/**
* Handle create workflow - creates workflow and scrolls to it
*/
@@ -391,8 +415,7 @@ export function SidebarNew() {
router.push(`/workspace/${pathWorkspaceId}/templates`)
logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
} else {
router.push('/templates')
logger.info('Navigated to global templates (no workspace in path)')
logger.warn('No workspace ID found, cannot navigate to templates')
}
} catch (err) {
logger.error('Failed to navigate to templates', { err })
@@ -459,6 +482,7 @@ export function SidebarNew() {
onExportWorkspace={handleExportWorkspace}
onImportWorkspace={handleImportWorkspace}
isImportingWorkspace={isImportingWorkspace}
showCollapseButton={isOnWorkflowPage}
/>
</div>
) : (
@@ -491,6 +515,7 @@ export function SidebarNew() {
onExportWorkspace={handleExportWorkspace}
onImportWorkspace={handleImportWorkspace}
isImportingWorkspace={isImportingWorkspace}
showCollapseButton={isOnWorkflowPage}
/>
</div>
@@ -584,19 +609,28 @@ export function SidebarNew() {
</div>
</div>
{/* Usage Indicator */}
{isBillingEnabled && (
<div className='flex flex-shrink-0 flex-col gap-[2px] border-t px-[7.75px] pt-[8px] pb-[8px] dark:border-[var(--border)]'>
<UsageIndicator />
</div>
)}
{/* Footer Navigation */}
<FooterNavigation />
</div>
</aside>
{/* Resize Handle */}
<div
className='fixed top-0 bottom-0 left-[calc(var(--sidebar-width)-4px)] z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleMouseDown}
role='separator'
aria-orientation='vertical'
aria-label='Resize sidebar'
/>
{/* Resize Handle - Only visible on workflow pages */}
{isOnWorkflowPage && (
<div
className='fixed top-0 bottom-0 left-[calc(var(--sidebar-width)-4px)] z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleMouseDown}
role='separator'
aria-orientation='vertical'
aria-label='Resize sidebar'
/>
)}
</>
)}

View File

@@ -3,7 +3,6 @@
import { useMemo } from 'react'
import { cloneDeep } from 'lodash'
import ReactFlow, {
Background,
ConnectionLineType,
type Edge,
type EdgeTypes,
@@ -276,7 +275,7 @@ export function WorkflowPreview({
return (
<ReactFlowProvider>
<div style={{ height, width }} className={cn('preview-mode')}>
<div style={{ height, width, backgroundColor: '#1B1B1B' }} className={cn('preview-mode')}>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -308,14 +307,7 @@ export function WorkflowPreview({
}
: undefined
}
>
<Background
color='hsl(var(--workflow-dots))'
size={4}
gap={40}
style={{ backgroundColor: 'hsl(var(--workflow-background))' }}
/>
</ReactFlow>
/>
</div>
</ReactFlowProvider>
)

View File

@@ -10,7 +10,7 @@ const buttonVariants = cva(
default:
'bg-[var(--surface-5)] dark:bg-[var(--surface-5)] hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
active:
'bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:text-[var(--text-primary)] text-[var(--text-primary)]',
'bg-[var(--surface-9)] dark:bg-[var(--surface-9)] hover:bg-[var(--surface-11)] dark:hover:bg-[var(--surface-11)] dark:text-[var(--text-primary)] text-[var(--text-primary)]',
'3d': 'dark:text-[var(--text-tertiary)] border-t border-l border-r dark:border-[var(--border-strong)] shadow-[0_2px_0_0] dark:shadow-[var(--border-strong)] hover:shadow-[0_4px_0_0] transition-all hover:-translate-y-0.5 hover:dark:text-[var(--text-primary)]',
outline:
'border border-[#727272] bg-[var(--border-strong)] hover:bg-[var(--surface-11)] dark:border-[#727272] dark:bg-[var(--border-strong)] dark:hover:bg-[var(--surface-11)]',

View File

@@ -17,8 +17,8 @@ interface SidebarState {
* Sidebar width constraints
* Note: Maximum width is enforced dynamically at 30% of viewport width in the resize hook
*/
const DEFAULT_SIDEBAR_WIDTH = 232
const MIN_SIDEBAR_WIDTH = 232
export const DEFAULT_SIDEBAR_WIDTH = 232
export const MIN_SIDEBAR_WIDTH = 232
export const useSidebarStore = create<SidebarState>()(
persist(