mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)]',
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user