feat: light, emcn, modals (#2104)

* feat: aligned current unassigned hex to old globals

* feat: emcn modal, help-modal; improvement(ui): emcn textarea, emcn combobox, messages-input

* improvement(modal): ui, transition

* improvement: terminal expand, variables styling; refactor(float): hooks

* improvement(invite-modal): emcn aligned

* feat(terminal): height memory

* improvement(invite-modal): skeleton ui

* improvement(invite-modal): badges UI

* feat: deploy-modal, emcn

* refactor: deleted duplicate dark styles

* feat: emcn, settings, light

* improvement: emcn, settings

* improvement: settings-modal, emcn

* improvement: SSO, light-mode

* improvement: EMCN, light

* fix issues, run lint

* fix: reverted mock data
This commit is contained in:
Emir Karabeg
2025-12-03 16:31:27 -08:00
committed by GitHub
parent 7de721e090
commit 3158b62da8
179 changed files with 10848 additions and 10227 deletions

View File

@@ -8,7 +8,7 @@ ENSURE that you use the logger.info and logger.warn and logger.error instead of
## Comments
You must use TSDOC for comments. Do not use ==== for comments to separate sections.
You must use TSDOC for comments. Do not use ==== for comments to separate sections. Do not leave any comments that are not TSDOC.
## Globals styles

View File

@@ -1,6 +1,6 @@
# Sim App Architecture Guidelines
You are building features in the Sim app following the architecture established by the sidebar-new component. This file defines the patterns, structures, and conventions you must follow.
You are building features in the Sim app following the architecture. This file defines the patterns, structures, and conventions you must follow.
---
@@ -428,20 +428,22 @@ setSidebarWidth: (width) => {
### Tailwind Classes
1. **No Inline Styles**: Use Tailwind utility classes exclusively
2. **Dark Mode**: Always include dark mode variants (`dark:bg-[var(--surface-1)]`)
3. **Exact Values**: Use exact values from design system (`text-[14px]`, `h-[25px]`)
4. **clsx for Conditionals**: Use clsx() for conditional classes
5. **Consistent Spacing**: Use spacing tokens (`gap-[8px]`, `px-[14px]`)
6. **Transitions**: Add transitions for interactive states (`transition-colors`)
7. **Prefer px units**: Use arbitrary px values over scale utilities (e.g., `px-[4px]` instead of `px-1`)
2. **Dark Mode**: Include dark mode variants only when the value differs from light mode
3. **No Duplicate Dark Classes**: Never add a `dark:` class when the value is identical to the light mode class (e.g., `text-[var(--text-primary)] dark:text-[var(--text-primary)]` is redundant - just use `text-[var(--text-primary)]`)
4. **Exact Values**: Use exact values from design system (`text-[14px]`, `h-[25px]`)
5. **cn for Conditionals**: Use `cn()` from `@/lib/utils` for conditional classes (wraps clsx + tailwind-merge for conflict resolution)
6. **Consistent Spacing**: Use spacing tokens (`gap-[8px]`, `px-[14px]`)
7. **Transitions**: Add transitions for interactive states (`transition-colors`)
8. **Prefer px units**: Use arbitrary px values over scale utilities (e.g., `px-[4px]` instead of `px-1`)
```typescript
import { cn } from '@/lib/utils'
<div
className={clsx(
className={cn(
'base-classes that-always-apply',
isActive && 'active-state-classes',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-accent',
'dark:bg-[var(--surface-1)] dark:border-[var(--border)]' // Dark mode variants
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-accent'
)}
>
```
@@ -620,9 +622,10 @@ Before considering a component/hook complete, verify:
### Styling
- [ ] No styles attributes (use className with Tailwind)
- [ ] Dark mode variants included
- [ ] Dark mode variants only when values differ from light mode
- [ ] No duplicate dark: classes with identical values
- [ ] Consistent spacing using design tokens
- [ ] clsx for conditional classes
- [ ] cn() for conditional classes
### Accessibility
- [ ] Semantic HTML elements
@@ -652,6 +655,11 @@ Before considering a component/hook complete, verify:
// ❌ Inline styles
<div style={{ width: 200, marginTop: 10 }}>
// ❌ Duplicate dark mode classes (same value as light mode)
<div className='text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'>
<div className='hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'>
// ❌ console.log
console.log('Debug info')
@@ -690,6 +698,14 @@ export function Component() {
// ✅ Tailwind classes
<div className='w-[200px] mt-[10px]'>
// ✅ No duplicate dark classes - CSS variables already handle theming
<div className='text-[var(--text-primary)]'>
<div className='bg-[var(--surface-9)]'>
<div className='hover:bg-[var(--border)]'>
// ✅ Only add dark: when values differ between modes
<div className='bg-[var(--surface-6)] dark:bg-[var(--surface-9)]'>
// ✅ Logger
logger.info('Debug info', { context })

View File

@@ -7,25 +7,23 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const pathname = usePathname()
// Force dark mode for workspace pages and templates
// Force light mode for certain public pages
// Force light mode for public/marketing pages
// Workspace and templates respect user's theme preference from settings
const forcedTheme =
pathname.startsWith('/workspace') || pathname.startsWith('/templates')
? 'dark'
: pathname === '/' ||
pathname.startsWith('/login') ||
pathname.startsWith('/signup') ||
pathname.startsWith('/sso') ||
pathname.startsWith('/terms') ||
pathname.startsWith('/privacy') ||
pathname.startsWith('/invite') ||
pathname.startsWith('/verify') ||
pathname.startsWith('/careers') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio')
? 'light'
: undefined
pathname === '/' ||
pathname.startsWith('/login') ||
pathname.startsWith('/signup') ||
pathname.startsWith('/sso') ||
pathname.startsWith('/terms') ||
pathname.startsWith('/privacy') ||
pathname.startsWith('/invite') ||
pathname.startsWith('/verify') ||
pathname.startsWith('/careers') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio')
? 'light'
: undefined
return (
<NextThemesProvider

View File

@@ -80,91 +80,96 @@
@layer base {
:root,
.light {
/* Neutrals (surfaces) */
--bg: #ffffff;
--surface-1: #ffffff;
--surface-2: #fafafa;
--surface-3: #f8f8f8;
--surface-4: #f6f6f6;
--surface-5: #f3f3f3;
--surface-6: #f0f0f0;
--surface-9: #eaeaea;
--surface-11: #e6e6e6;
--surface-12: #e0e0e0;
--surface-13: #dcdcdc;
--surface-14: #d6d6d6;
--surface-15: #cfcfcf;
--surface-elevated: #ffffff;
--bg-strong: #f5f5f5;
/* Neutrals (surfaces) - shadcn stone palette */
--bg: #fafaf9; /* stone-50 */
--surface-1: #fafaf9; /* stone-50 */
--surface-2: #ffffff; /* white */
--surface-3: #f5f5f4; /* stone-100 */
--surface-4: #f5f5f4; /* stone-100 */
--surface-5: #eeedec; /* stone-150 */
--surface-6: #f5f5f4; /* stone-100 */
--surface-9: #f5f5f4; /* stone-100 */
--surface-11: #e7e5e4; /* stone-200 */
--surface-12: #d6d3d1; /* stone-300 */
--surface-13: #a8a29e; /* stone-400 */
--surface-14: #78716c; /* stone-500 */
--surface-15: #57534e; /* stone-600 */
--surface-elevated: #ffffff; /* white */
--bg-strong: #e7e5e4; /* stone-200 */
/* Text */
--text-primary: #1b1b1b;
--text-secondary: #404040;
--text-tertiary: #555555;
--text-muted: #737373;
--text-subtle: #8a8a8a;
--text-inverse: #1b1b1b;
--text-error: #ef4444;
/* Text - shadcn stone palette for proper contrast */
--text-primary: #1c1917; /* stone-900 */
--text-secondary: #292524; /* stone-800 */
--text-tertiary: #57534e; /* stone-600 */
--text-muted: #78716c; /* stone-500 */
--text-subtle: #a8a29e; /* stone-400 */
--text-inverse: #fafaf9; /* stone-50 */
--text-error: #dc2626;
/* Borders / dividers */
--border: #dddddd;
--border-strong: #d1d1d1;
--divider: #e5e5e5;
--border-muted: #eeeeee;
--border-success: #d5d5d5;
/* Borders / dividers - shadcn stone palette */
--border: #d6d3d1; /* stone-300 */
--border-strong: #d6d3d1; /* stone-300 */
--divider: #e7e5e4; /* stone-200 */
--border-muted: #e7e5e4; /* stone-200 */
--border-success: #d6d3d1; /* stone-300 */
/* Brand & state */
--brand-400: #8e4cfb;
--brand-500: #6f3dfa;
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--warning: #ff6600;
--brand-tertiary-2: #33c481;
--warning: #ea580c;
/* Utility */
--white: #ffffff;
/* RGB for opacity usage */
--surface-4-rgb: 246 246 246;
--surface-5-rgb: 243 243 243;
--surface-7-rgb: 230 230 230;
--surface-9-rgb: 234 234 234;
--divider-rgb: 229 229 229;
/* Font weights - lighter for light mode (-20 from dark) */
--font-weight-base: 430;
--font-weight-medium: 450;
--font-weight-semibold: 500;
/* RGB for opacity usage - stone palette */
--surface-4-rgb: 245 245 244; /* stone-100 */
--surface-5-rgb: 238 237 236; /* stone-150 */
--surface-7-rgb: 245 245 244; /* stone-100 */
--surface-9-rgb: 245 245 244; /* stone-100 */
--divider-rgb: 231 229 228; /* stone-200 */
--white-rgb: 255 255 255;
--black-rgb: 0 0 0;
/* Extended palette (exhaustive from code usage via -[#...]) */
/* Neutral deep shades */
--c-0D0D0D: #0d0d0d;
--c-1A1A1A: #1a1a1a;
--c-1F1F1F: #1f1f1f;
--c-2A2A2A: #2a2a2a;
--c-383838: #383838;
--c-414141: #414141;
/* Extended palette - mapped to shadcn stone palette */
--c-0D0D0D: #0c0a09; /* stone-950 */
--c-1A1A1A: #1c1917; /* stone-900 */
--c-1F1F1F: #1c1917; /* stone-900 */
--c-2A2A2A: #292524; /* stone-800 */
--c-383838: #44403c; /* stone-700 */
--c-414141: #57534e; /* stone-600 */
--c-442929: #442929;
--c-491515: #491515;
--c-575757: #575757;
--c-686868: #686868;
--c-707070: #707070;
--c-727272: #727272;
--c-737373: #737373;
--c-808080: #808080;
--c-858585: #858585;
--c-868686: #868686;
--c-8D8D8D: #8d8d8d;
--c-939393: #939393;
--c-A8A8A8: #a8a8a8;
--c-B8B8B8: #b8b8b8;
--c-C0C0C0: #c0c0c0;
--c-CDCDCD: #cdcdcd;
--c-D0D0D0: #d0d0d0;
--c-D2D2D2: #d2d2d2;
--c-E0E0E0: #e0e0e0;
--c-E5E5E5: #e5e5e5;
--c-E8E8E8: #e8e8e8;
--c-EEEEEE: #eeeeee;
--c-F0F0F0: #f0f0f0;
--c-F4F4F4: #f4f4f4;
--c-F5F5F5: #f5f5f5;
--c-575757: #78716c; /* stone-500 */
--c-686868: #78716c; /* stone-500 */
--c-707070: #78716c; /* stone-500 */
--c-727272: #78716c; /* stone-500 */
--c-737373: #78716c; /* stone-500 */
--c-808080: #a8a29e; /* stone-400 */
--c-858585: #a8a29e; /* stone-400 */
--c-868686: #a8a29e; /* stone-400 */
--c-8D8D8D: #a8a29e; /* stone-400 */
--c-939393: #a8a29e; /* stone-400 */
--c-A8A8A8: #a8a29e; /* stone-400 */
--c-B8B8B8: #d6d3d1; /* stone-300 */
--c-C0C0C0: #d6d3d1; /* stone-300 */
--c-CDCDCD: #d6d3d1; /* stone-300 */
--c-D0D0D0: #d6d3d1; /* stone-300 */
--c-D2D2D2: #d6d3d1; /* stone-300 */
--c-E0E0E0: #e7e5e4; /* stone-200 */
--c-E5E5E5: #e7e5e4; /* stone-200 */
--c-E8E8E8: #e7e5e4; /* stone-200 */
--c-EEEEEE: #f5f5f4; /* stone-100 */
--c-F0F0F0: #f5f5f4; /* stone-100 */
--c-F4F4F4: #fafaf9; /* stone-50 */
--c-F5F5F5: #fafaf9; /* stone-50 */
/* Blues and cyans */
--c-00B0B0: #00b0b0;
@@ -180,20 +185,27 @@
--c-8C10FF: #8c10ff;
/* Greens */
--c-4CAF50: #4caf50;
--c-4CAF50: #22c55e;
/* Oranges / Ambers */
--c-F59E0B: #f59e0b;
--c-F97316: #f97316;
--c-FF972F: #ff972f;
--c-F97316: #ea580c;
--c-FF972F: #f97316;
/* Reds */
--c-DC2626: #dc2626;
--c-F6D2D2: #f6d2d2;
--c-F6D2D2: #fecaca;
--c-F87171: #f87171;
--c-FF402F: #ff402f;
--c-FF402F: #ef4444;
--c-B91C1C: #b91c1c;
--c-883827: #883827;
--c-883827: #7c2d12;
/* Terminal status badges */
--terminal-status-error-bg: #feeeee;
--terminal-status-error-border: #f87171;
--terminal-status-info-bg: #f5f5f4; /* stone-100 */
--terminal-status-info-border: #a8a29e; /* stone-400 */
--terminal-status-info-color: #57534e; /* stone-600 */
}
.dark {
/* Neutrals (surfaces) */
@@ -215,8 +227,8 @@
/* Text */
--text-primary: #e6e6e6;
--text-secondary: #b1b1b1;
--text-tertiary: #aeaeae;
--text-secondary: #cccccc;
--text-tertiary: #b3b3b3;
--text-muted: #787878;
--text-subtle: #7d7d7d;
--text-inverse: #1b1b1b;
@@ -233,11 +245,17 @@
--brand-400: #8e4cfb;
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #33c481;
--warning: #ff6600;
/* Utility */
--white: #ffffff;
/* Font weights - standard weights for dark mode */
--font-weight-base: 440;
--font-weight-medium: 480;
--font-weight-semibold: 550;
/* RGB for opacity usage */
--surface-4-rgb: 37 37 37;
--surface-5-rgb: 39 39 39;
@@ -311,6 +329,13 @@
--c-FF402F: #ff402f;
--c-B91C1C: #b91c1c;
--c-883827: #883827;
/* Terminal status badges */
--terminal-status-error-bg: #491515;
--terminal-status-error-border: #883827;
--terminal-status-info-bg: #383838;
--terminal-status-info-border: #686868;
--terminal-status-info-color: #b7b7b7;
}
}
@@ -360,16 +385,16 @@
}
::-webkit-scrollbar-track {
background: var(--white);
background: var(--surface-1);
}
::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.3);
background-color: var(--surface-12);
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.4);
background-color: var(--surface-13);
}
/* Dark Mode Global Scrollbar */
@@ -378,20 +403,20 @@
}
.dark ::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.3);
background-color: var(--surface-12);
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.4);
background-color: var(--surface-13);
}
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) var(--white);
scrollbar-color: var(--surface-12) var(--surface-1);
}
.dark * {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) var(--surface-5);
scrollbar-color: var(--surface-12) var(--surface-5);
}
.copilot-scrollable {

View File

@@ -13,8 +13,6 @@ const logger = createLogger('UserSettingsAPI')
const SettingsSchema = z.object({
theme: z.enum(['system', 'light', 'dark']).optional(),
autoConnect: z.boolean().optional(),
autoPan: z.boolean().optional(),
consoleExpandedByDefault: z.boolean().optional(),
telemetryEnabled: z.boolean().optional(),
emailPreferences: z
.object({
@@ -25,7 +23,6 @@ const SettingsSchema = z.object({
})
.optional(),
billingUsageNotificationsEnabled: z.boolean().optional(),
showFloatingControls: z.boolean().optional(),
showTrainingControls: z.boolean().optional(),
superUserModeEnabled: z.boolean().optional(),
errorNotificationsEnabled: z.boolean().optional(),
@@ -35,12 +32,9 @@ const SettingsSchema = z.object({
const defaultSettings = {
theme: 'system',
autoConnect: true,
autoPan: true,
consoleExpandedByDefault: true,
telemetryEnabled: true,
emailPreferences: {},
billingUsageNotificationsEnabled: true,
showFloatingControls: true,
showTrainingControls: false,
superUserModeEnabled: false,
errorNotificationsEnabled: true,
@@ -72,12 +66,9 @@ export async function GET() {
data: {
theme: userSettings.theme,
autoConnect: userSettings.autoConnect,
autoPan: userSettings.autoPan,
consoleExpandedByDefault: userSettings.consoleExpandedByDefault,
telemetryEnabled: userSettings.telemetryEnabled,
emailPreferences: userSettings.emailPreferences ?? {},
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
showFloatingControls: userSettings.showFloatingControls ?? true,
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,

View File

@@ -270,7 +270,7 @@ function TemplateCardInner({
</div>
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
{authorImageUrl ? (
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />

View File

@@ -2,7 +2,8 @@
import { useRef, useState } from 'react'
import { AlertCircle, Loader2, X } from 'lucide-react'
import { Button, Modal, ModalContent, ModalTitle, Textarea } from '@/components/emcn'
import { Button, Textarea } from '@/components/emcn'
import { Modal, ModalContent, ModalTitle } from '@/components/emcn/components/modal/modal'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'

View File

@@ -6,7 +6,8 @@ import { AlertCircle, Check, Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Input, Label, Modal, ModalContent, ModalTitle, Textarea } from '@/components/emcn'
import { Button, Input, Label, Textarea } from '@/components/emcn'
import { Modal, ModalContent, ModalTitle } from '@/components/emcn/components/modal/modal'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -125,7 +125,7 @@ export const TxtIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<path d='M14 2V8H20' fill='#9E9E9E' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#424242'
stroke='var(--border-muted)'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'

View File

@@ -287,7 +287,7 @@ export function WorkflowDetails({
<LineChart
data={details.errorRates}
label='Error Rate'
color='#ef4444'
color='var(--text-error)'
unit='%'
/>
{hasDuration && (
@@ -467,12 +467,12 @@ export function WorkflowDetails({
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
backgroundColor: isError ? 'var(--text-error)' : '#B7B7B7',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
style={{ color: isError ? 'var(--text-error)' : '#B7B7B7' }}
>
{statusLabel}
</span>

View File

@@ -483,12 +483,12 @@ export default function Logs() {
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
backgroundColor: isError ? 'var(--text-error)' : '#B7B7B7',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
style={{ color: isError ? 'var(--text-error)' : '#B7B7B7' }}
>
{statusLabel}
</span>

View File

@@ -27,7 +27,12 @@ interface TemplateCardProps {
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
<div
className={cn(
'h-[268px] w-full rounded-[8px] bg-[var(--surface-elevated)] p-[8px]',
className
)}
>
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
<div className='mt-[14px] flex items-center justify-between'>
@@ -196,7 +201,10 @@ function TemplateCardInner({
return (
<div
onClick={handleCardClick}
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
className={cn(
'w-full cursor-pointer rounded-[8px] bg-[var(--surface-elevated)] p-[8px]',
className
)}
>
<div
ref={previewRef}
@@ -242,7 +250,7 @@ function TemplateCardInner({
)
})}
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--surface-14)]'
style={{ marginLeft: '-4px' }}
>
<span className='font-medium text-[10px] text-white'>+{blockTypes.length - 3}</span>
@@ -277,7 +285,7 @@ function TemplateCardInner({
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-14)]'>
<User className='h-[12px] w-[12px] text-[#888888]' />
</div>
)}

View File

@@ -28,13 +28,13 @@ import {
ChatMessage,
OutputSelect,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components'
import {
useChatBoundarySync,
useChatDrag,
useChatFileUpload,
useChatResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks'
import { useChatFileUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks'
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
useFloatBoundarySync,
useFloatDrag,
useFloatResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { getChatPosition, useChatStore } from '@/stores/chat/store'
@@ -312,7 +312,7 @@ export function Chat() {
)
// Drag hook
const { handleMouseDown } = useChatDrag({
const { handleMouseDown } = useFloatDrag({
position: actualPosition,
width: chatWidth,
height: chatHeight,
@@ -320,7 +320,7 @@ export function Chat() {
})
// Boundary sync hook - keeps chat within bounds when layout changes
useChatBoundarySync({
useFloatBoundarySync({
isOpen: isChatOpen,
position: actualPosition,
width: chatWidth,
@@ -334,7 +334,7 @@ export function Chat() {
handleMouseMove: handleResizeMouseMove,
handleMouseLeave: handleResizeMouseLeave,
handleMouseDown: handleResizeMouseDown,
} = useChatResize({
} = useFloatResize({
position: actualPosition,
width: chatWidth,
height: chatHeight,

View File

@@ -65,7 +65,7 @@ export function OutputSelect({
valueMode = 'id',
disablePopoverPortal = false,
align = 'start',
maxHeight = 300,
maxHeight = 200,
}: OutputSelectProps) {
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
@@ -423,6 +423,7 @@ export function OutputSelect({
maxHeight={maxHeight}
maxWidth={300}
minWidth={160}
border
disablePortal={disablePopoverPortal}
onKeyDown={handleKeyDown}
tabIndex={0}

View File

@@ -1,5 +1 @@
export { useChatBoundarySync } from './use-chat-boundary-sync'
export { useChatDrag } from './use-chat-drag'
export type { ChatFile } from './use-chat-file-upload'
export { useChatFileUpload } from './use-chat-file-upload'
export { useChatResize } from './use-chat-resize'
export { type ChatFile, useChatFileUpload } from './use-chat-file-upload'

View File

@@ -1,8 +1,10 @@
import { memo, useCallback } from 'react'
import clsx from 'clsx'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -11,6 +13,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
// Optimized: Single diff store subscription
const {
isShowingDiff,
@@ -312,7 +315,10 @@ export const DiffControls = memo(function DiffControls() {
return (
<div
className='-translate-x-1/2 fixed left-1/2 z-30'
className={clsx(
'-translate-x-1/2 fixed left-1/2 z-30',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
)}
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
>
<div className='flex items-center gap-[6px] rounded-[10px] p-[6px]'>

View File

@@ -4,7 +4,7 @@ import type { NodeProps } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
@@ -76,7 +76,7 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
return (
<code
{...props}
className='whitespace-normal rounded bg-gray-200 px-1 py-0.5 font-mono text-[#F59E0B] text-xs dark:bg-[var(--surface-11)] dark:text-[#F59E0B]'
className='whitespace-normal rounded bg-gray-200 px-1 py-0.5 font-mono text-[#F59E0B] text-xs dark:bg-[var(--surface-11)]'
>
{children}
</code>
@@ -121,9 +121,10 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlockNodeData>) {
const { type, config, name } = data
const { activeWorkflowId, isEnabled, isFocused, handleClick, hasRing, ringStyles } = useBlockCore(
{ blockId: id, data }
)
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
blockId: id,
data,
})
const storedValues = useSubBlockStore(
useCallback(
(state) => {

View File

@@ -1,4 +1,5 @@
import { memo, useCallback } from 'react'
import clsx from 'clsx'
import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
@@ -10,6 +11,7 @@ import {
openCopilotWithMessage,
useNotificationStore,
} from '@/stores/notifications'
import { useTerminalStore } from '@/stores/terminal'
const logger = createLogger('Notifications')
const MAX_VISIBLE_NOTIFICATIONS = 4
@@ -29,6 +31,7 @@ export const Notifications = memo(function Notifications() {
const removeNotification = useNotificationStore((state) => state.removeNotification)
const clearNotifications = useNotificationStore((state) => state.clearNotifications)
const visibleNotifications = notifications.slice(0, MAX_VISIBLE_NOTIFICATIONS)
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
/**
* Executes a notification action and handles side effects.
@@ -95,7 +98,12 @@ export const Notifications = memo(function Notifications() {
}
return (
<div className='fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end'>
<div
className={clsx(
'fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
)}
>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
const depth = stacked.length - index - 1
const xOffset = depth * 3
@@ -105,7 +113,7 @@ export const Notifications = memo(function Notifications() {
<div
key={notification.id}
style={{ transform: `translateX(${xOffset}px)` }}
className={`relative h-[78px] w-[240px] overflow-hidden rounded-[4px] border bg-[#232323] transition-transform duration-200 ${
className={`relative h-[78px] w-[240px] overflow-hidden rounded-[4px] border bg-[var(--surface-2)] transition-transform duration-200 ${
index > 0 ? '-mt-[78px]' : ''
}`}
>

View File

@@ -137,29 +137,29 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
() => ({
// Paragraph
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1 font-[470] font-season text-[#707070] text-sm leading-[1.25rem] last:mb-0 dark:text-[#E8E8E8]'>
<p className='mb-1 font-base font-season text-[#1f2124] text-sm leading-[1.25rem] last:mb-0 dark:font-[470] dark:text-[#E8E8E8]'>
{children}
</p>
),
// Headings
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-3 mb-3 font-season font-semibold text-2xl text-[#0D0D0D] dark:text-[#F0F0F0]'>
<h1 className='mt-3 mb-3 font-season font-semibold text-2xl text-[var(--text-primary)] dark:text-[#F0F0F0]'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-2.5 mb-2.5 font-season font-semibold text-[#0D0D0D] text-xl dark:text-[#F0F0F0]'>
<h2 className='mt-2.5 mb-2.5 font-season font-semibold text-[var(--text-primary)] text-xl dark:text-[#F0F0F0]'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-2 mb-2 font-season font-semibold text-[#0D0D0D] text-lg dark:text-[#F0F0F0]'>
<h3 className='mt-2 mb-2 font-season font-semibold text-[var(--text-primary)] text-lg dark:text-[#F0F0F0]'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-5 mb-2 font-season font-semibold text-[#0D0D0D] text-base dark:text-[#F0F0F0]'>
<h4 className='mt-5 mb-2 font-season font-semibold text-[var(--text-primary)] text-base dark:text-[#F0F0F0]'>
{children}
</h4>
),
@@ -167,7 +167,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
// Lists
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-[470] font-season text-[#707070] dark:text-[#E8E8E8]'
className='mt-1 mb-1 space-y-1 pl-6 font-base font-season text-[#1f2124] dark:font-[470] dark:text-[#E8E8E8]'
style={{ listStyleType: 'disc' }}
>
{children}
@@ -175,7 +175,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='mt-1 mb-1 space-y-1 pl-6 font-[470] font-season text-[#707070] dark:text-[#E8E8E8]'
className='mt-1 mb-1 space-y-1 pl-6 font-base font-season text-[#1f2124] dark:font-[470] dark:text-[#E8E8E8]'
style={{ listStyleType: 'decimal' }}
>
{children}
@@ -186,7 +186,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
ordered,
}: React.LiHTMLAttributes<HTMLLIElement> & { ordered?: boolean }) => (
<li
className='font-[470] font-season text-[#707070] dark:text-[#E8E8E8]'
className='font-base font-season text-[#1f2124] dark:font-[470] dark:text-[#E8E8E8]'
style={{ display: 'list-item' }}
>
{children}
@@ -309,33 +309,35 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
// Bold text
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<strong className='font-semibold text-[#0D0D0D] dark:text-[#F0F0F0]'>{children}</strong>
<strong className='font-semibold text-[var(--text-primary)] dark:text-[#F0F0F0]'>
{children}
</strong>
),
// Bold text (alternative)
b: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<b className='font-semibold text-[#0D0D0D] dark:text-[#F0F0F0]'>{children}</b>
<b className='font-semibold text-[var(--text-primary)] dark:text-[#F0F0F0]'>{children}</b>
),
// Italic text
em: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<em className='text-[#707070] italic dark:text-[#E8E8E8]'>{children}</em>
<em className='text-[#1f2124] italic dark:text-[#E8E8E8]'>{children}</em>
),
// Italic text (alternative)
i: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<i className='text-[#707070] italic dark:text-[#E8E8E8]'>{children}</i>
<i className='text-[#1f2124] italic dark:text-[#E8E8E8]'>{children}</i>
),
// Blockquotes
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-season text-[#858585] italic dark:border-gray-600 dark:text-[#E0E0E0]'>
<blockquote className='my-4 border-[var(--border-strong)] border-l-4 py-1 pl-4 font-season text-[#3a3d41] italic dark:border-gray-600 dark:text-[#E0E0E0]'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
hr: () => <hr className='my-8 border-[var(--divider)] border-t dark:border-gray-400/[.07]' />,
// Links
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
@@ -347,29 +349,29 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
// Tables
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-season text-sm dark:border-gray-600'>
<table className='min-w-full table-auto border border-[var(--border)] font-season text-sm dark:border-gray-600'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-gray-200 text-left dark:bg-[#2A2A2A]'>{children}</thead>
<thead className='bg-[var(--surface-9)] text-left dark:bg-[#2A2A2A]'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-gray-300 dark:divide-gray-600'>{children}</tbody>
<tbody className='divide-y divide-[var(--border)] dark:divide-gray-600'>{children}</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-gray-300 border-b transition-colors hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-[#2A2A2A]/60'>
<tr className='border-[var(--border)] border-b transition-colors hover:bg-[var(--surface-9)] dark:border-gray-600 dark:hover:bg-[#2A2A2A]/60'>
{children}
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-gray-300 border-r px-4 py-2 align-top font-[470] text-[#858585] last:border-r-0 dark:border-gray-600 dark:text-[#E0E0E0]'>
<th className='border-[var(--border)] border-r px-4 py-2 align-top font-base text-[#3a3d41] last:border-r-0 dark:border-gray-600 dark:font-[470] dark:text-[#E0E0E0]'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-gray-300 border-r px-4 py-2 align-top font-[470] text-[#707070] last:border-r-0 dark:border-gray-600 dark:text-[#E8E8E8]'>
<td className='break-words border-[var(--border)] border-r px-4 py-2 align-top font-base text-[#1f2124] last:border-r-0 dark:border-gray-600 dark:font-[470] dark:text-[#E8E8E8]'>
{children}
</td>
),
@@ -388,7 +390,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
)
return (
<div className='copilot-markdown-wrapper max-w-full space-y-3 break-words font-[470] font-season text-[#707070] text-sm leading-[1.25rem] dark:text-[#E8E8E8]'>
<div className='copilot-markdown-wrapper max-w-full space-y-3 break-words font-base font-season text-[#1f2124] text-sm leading-[1.25rem] dark:font-[470] dark:text-[#E8E8E8]'>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>

View File

@@ -37,7 +37,7 @@ function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayText
return (
<span className='relative inline-block'>
<span style={{ color: '#B8B8B8' }}>{label}</span>
<span style={{ color: '#787878' }}>{value}</span>
<span style={{ color: 'var(--text-muted)' }}>{value}</span>
{active ? (
<span
aria-hidden='true'

View File

@@ -3,7 +3,7 @@
import { type FC, memo, useMemo, useState } from 'react'
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
import { Button } from '@/components/emcn'
import { InlineToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
FileAttachmentDisplay,
SmoothStreamingText,
@@ -221,7 +221,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
key={`tool-${block.toolCall.id}`}
className='opacity-100 transition-opacity duration-300 ease-in-out'
>
<InlineToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
</div>
)
}
@@ -264,7 +264,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
{showCheckpointDiscardModal && (
<div className='mt-[8px] rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] p-[10px] dark:border-[var(--surface-11)] dark:bg-[var(--surface-9)]'>
<div className='mt-[8px] rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] p-[10px] dark:bg-[var(--surface-9)]'>
<p className='mb-[8px] text-[var(--text-primary)] text-sm'>
Continue from a previous message?
</p>
@@ -311,7 +311,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
onClick={handleMessageClick}
onMouseEnter={() => setIsHoveringMessage(true)}
onMouseLeave={() => setIsHoveringMessage(false)}
className='group relative w-full cursor-pointer rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[6px] py-[6px] transition-all duration-200 hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:border-[var(--surface-11)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]'
className='group relative w-full cursor-pointer rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[6px] py-[6px] transition-all duration-200 hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]'
>
<div
ref={messageContentRef}
@@ -405,7 +405,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Inline Restore Checkpoint Confirmation */}
{showRestoreConfirmation && (
<div className='mt-[8px] rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] p-[10px] dark:border-[var(--surface-11)] dark:bg-[var(--surface-9)]'>
<div className='mt-[8px] rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] p-[10px] dark:bg-[var(--surface-9)]'>
<p className='mb-[8px] text-[var(--text-primary)] text-sm'>
Revert to checkpoint? This will restore your workflow to the state saved at this
checkpoint.{' '}

View File

@@ -1,6 +1,6 @@
export * from './copilot-message/copilot-message'
export * from './inline-tool-call/inline-tool-call'
export * from './plan-mode-section/plan-mode-section'
export * from './todo-list/todo-list'
export * from './tool-call/tool-call'
export * from './user-input/user-input'
export * from './welcome/welcome'

View File

@@ -35,9 +35,9 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
/**
* Shared border and background styles
*/
const SURFACE_5 = 'bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
const SURFACE_9 = 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
const BORDER_STRONG = 'border-[var(--border-strong)] dark:border-[var(--border-strong)]'
const SURFACE_5 = 'bg-[var(--surface-5)]'
const SURFACE_9 = 'bg-[var(--surface-9)]'
const BORDER_STRONG = 'border-[var(--border-strong)]'
export interface PlanModeSectionProps {
/**
@@ -184,8 +184,8 @@ const PlanModeSection: React.FC<PlanModeSectionProps> = ({
style={{ height: `${height}px` }}
>
{/* Header with build/edit/save/clear buttons */}
<div className='flex flex-shrink-0 items-center justify-between border-[var(--border-strong)] border-b py-[6px] pr-[2px] pl-[12px] dark:border-[var(--border-strong)]'>
<span className='font-[500] text-[11px] text-[var(--text-secondary)] uppercase tracking-wide dark:text-[var(--text-secondary)]'>
<div className='flex flex-shrink-0 items-center justify-between border-[var(--border-strong)] border-b py-[6px] pr-[2px] pl-[12px]'>
<span className='font-[500] text-[11px] text-[var(--text-secondary)] uppercase tracking-wide'>
Workflow Plan
</span>
<div className='ml-auto flex items-center gap-[4px]'>
@@ -252,7 +252,7 @@ const PlanModeSection: React.FC<PlanModeSectionProps> = ({
ref={textareaRef}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className='h-full min-h-full w-full resize-none border-0 bg-transparent p-0 font-[470] font-season text-[13px] text-[var(--text-primary)] leading-[1.4rem] outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-[var(--text-primary)]'
className='h-full min-h-full w-full resize-none border-0 bg-transparent p-0 font-[470] font-season text-[13px] text-[var(--text-primary)] leading-[1.4rem] outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0'
placeholder='Enter your workflow plan...'
/>
) : (
@@ -265,7 +265,7 @@ const PlanModeSection: React.FC<PlanModeSectionProps> = ({
className={cn(
'group flex h-[20px] w-full cursor-ns-resize items-center justify-center border-t',
BORDER_STRONG,
'transition-colors hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
'transition-colors hover:bg-[var(--surface-9)]',
isResizing && SURFACE_9
)}
onMouseDown={handleResizeStart}
@@ -273,7 +273,7 @@ const PlanModeSection: React.FC<PlanModeSectionProps> = ({
aria-orientation='horizontal'
aria-label='Resize plan section'
>
<GripHorizontal className='h-3 w-3 text-[var(--text-secondary)] transition-colors group-hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:group-hover:text-[var(--text-primary)]' />
<GripHorizontal className='h-3 w-3 text-[var(--text-secondary)] transition-colors group-hover:text-[var(--text-primary)]' />
</div>
</div>
)

View File

@@ -66,7 +66,7 @@ export const TodoList = memo(function TodoList({
return (
<div
className={cn(
'w-full rounded-t-[4px] rounded-b-none border-[var(--surface-11)] border-x border-t bg-[var(--surface-6)] dark:border-[var(--surface-11)] dark:bg-[var(--surface-9)]',
'w-full rounded-t-[4px] rounded-b-none border-[var(--surface-11)] border-x border-t bg-[var(--surface-6)] dark:bg-[var(--surface-9)]',
className
)}
>
@@ -84,19 +84,17 @@ export const TodoList = memo(function TodoList({
<ChevronDown className='h-[14px] w-[14px]' />
)}
</Button>
<span className='font-medium text-[var(--text-primary)] text-xs dark:text-[var(--text-primary)]'>
Todo:
</span>
<span className='font-medium text-[var(--text-primary)] text-xs dark:text-[var(--text-primary)]'>
<span className='font-medium text-[var(--text-primary)] text-xs'>Todo:</span>
<span className='font-medium text-[var(--text-primary)] text-xs'>
{completedCount}/{totalCount}
</span>
</div>
<div className='flex flex-1 items-center gap-[8px] pl-[10px]'>
{/* Progress bar */}
<div className='h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-11)] dark:bg-[var(--surface-11)]'>
<div className='h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-11)]'>
<div
className='h-full bg-[var(--brand-400)] transition-all duration-300 ease-out dark:bg-[var(--brand-400)]'
className='h-full bg-[var(--brand-400)] transition-all duration-300 ease-out'
style={{ width: `${progress}%` }}
/>
</div>
@@ -122,21 +120,20 @@ export const TodoList = memo(function TodoList({
key={todo.id}
className={cn(
'flex items-start gap-2 px-3 py-1.5 transition-colors hover:bg-[var(--surface-9)]/50 dark:hover:bg-[var(--surface-11)]/50',
index !== todos.length - 1 &&
'border-[var(--surface-11)] border-b dark:border-[var(--surface-11)]'
index !== todos.length - 1 && 'border-[var(--surface-11)] border-b'
)}
>
{todo.executing ? (
<div className='mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center'>
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-primary)] dark:text-[var(--text-primary)]' />
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-primary)]' />
</div>
) : (
<div
className={cn(
'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border transition-all',
todo.completed
? 'border-[var(--brand-400)] bg-[var(--brand-400)] dark:border-[var(--brand-400)] dark:bg-[var(--brand-400)]'
: 'border-[#707070] dark:border-[#707070]'
? 'border-[var(--brand-400)] bg-[var(--brand-400)]'
: 'border-[#707070]'
)}
>
{todo.completed ? <Check className='h-3 w-3 text-white' strokeWidth={3} /> : null}
@@ -146,9 +143,7 @@ export const TodoList = memo(function TodoList({
<span
className={cn(
'flex-1 font-base text-[12px] leading-relaxed',
todo.completed
? 'text-[var(--text-muted)] line-through dark:text-[var(--text-muted)]'
: 'text-[var(--white)] dark:text-[var(--white)]'
todo.completed ? 'text-[var(--text-muted)] line-through' : 'text-[var(--white)]'
)}
>
{todo.content}

View File

@@ -12,7 +12,7 @@ import { getEnv } from '@/lib/core/config/env'
import { CLASS_TOOL_METADATA, useCopilotStore } from '@/stores/panel/copilot/store'
import type { CopilotToolCall } from '@/stores/panel/copilot/types'
interface InlineToolCallProps {
interface ToolCallProps {
toolCall?: CopilotToolCall
toolCallId?: string
onStateChange?: (state: any) => void
@@ -139,16 +139,11 @@ function ShimmerOverlayText({
// Special tools: use gradient for entire text
if (isSpecial) {
const baseTextStyle = {
backgroundImage: 'linear-gradient(90deg, #B99FFD 0%, #D1BFFF 100%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}
return (
<span className={`relative inline-block ${className || ''}`}>
<span style={baseTextStyle}>{text}</span>
<span className='bg-gradient-to-r from-[#7c3aed] to-[#9061f9] bg-clip-text text-transparent dark:from-[#B99FFD] dark:to-[#D1BFFF]'>
{text}
</span>
{active ? (
<span
aria-hidden='true'
@@ -182,13 +177,13 @@ function ShimmerOverlayText({
)
}
// Normal tools: two-tone rendering with lighter action verb
// Normal tools: two-tone rendering - action verb darker in light mode, lighter in dark mode
return (
<span className={`relative inline-block ${className || ''}`}>
{actionVerb ? (
<>
<span style={{ color: '#B8B8B8' }}>{actionVerb}</span>
<span style={{ color: '#787878' }}>{remainder}</span>
<span className='text-[#1f2124] dark:text-[#B8B8B8]'>{actionVerb}</span>
<span className='text-[#6b7075] dark:text-[var(--text-muted)]'>{remainder}</span>
</>
) : (
<span>{text}</span>
@@ -453,11 +448,7 @@ function RunSkipButtons({
)
}
export function InlineToolCall({
toolCall: toolCallProp,
toolCallId,
onStateChange,
}: InlineToolCallProps) {
export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: ToolCallProps) {
const [, forceUpdate] = useState({})
const liveToolCall = useCopilotStore((s) =>
toolCallId ? s.toolCallsById[toolCallId] : undefined
@@ -692,18 +683,18 @@ export function InlineToolCall({
return (
<div className='w-full overflow-hidden rounded border border-muted bg-card'>
<div className='grid grid-cols-3 gap-0 border-muted/60 border-b bg-muted/40 py-1.5'>
<div className='self-start px-2 font-medium font-season text-[#858585] text-[10px] uppercase tracking-wide dark:text-[#E0E0E0]'>
<div className='self-start px-2 font-medium font-season text-[#3a3d41] text-[10px] uppercase tracking-wide dark:text-[#E0E0E0]'>
Name
</div>
<div className='self-start px-2 font-medium font-season text-[#858585] text-[10px] uppercase tracking-wide dark:text-[#E0E0E0]'>
<div className='self-start px-2 font-medium font-season text-[#3a3d41] text-[10px] uppercase tracking-wide dark:text-[#E0E0E0]'>
Type
</div>
<div className='self-start px-2 font-medium font-season text-[#858585] text-[10px] uppercase tracking-wide dark:text-[#E0E0E0]'>
<div className='self-start px-2 font-medium font-season text-[#3a3d41] text-[10px] uppercase tracking-wide dark:text-[#E0E0E0]'>
Value
</div>
</div>
{ops.length === 0 ? (
<div className='px-2 py-2 font-[470] font-season text-[#707070] text-xs dark:text-[#E8E8E8]'>
<div className='px-2 py-2 font-[470] font-season text-[#1f2124] text-xs dark:text-[#E8E8E8]'>
No operations provided
</div>
) : (
@@ -723,7 +714,7 @@ export function InlineToolCall({
/>
</div>
<div className='self-start px-2'>
<span className='rounded border px-1 py-0.5 font-[470] font-season text-[#707070] text-[10px] dark:text-[#E8E8E8]'>
<span className='rounded border px-1 py-0.5 font-[470] font-season text-[#1f2124] text-[10px] dark:text-[#E8E8E8]'>
{String(op.type || '')}
</span>
</div>
@@ -740,7 +731,7 @@ export function InlineToolCall({
className='w-full bg-transparent font-[470] font-mono text-amber-700 text-xs outline-none focus:text-amber-800 dark:text-amber-300 dark:focus:text-amber-200'
/>
) : (
<span className='font-[470] font-season text-[#707070] text-xs dark:text-[#E8E8E8]'>
<span className='font-[470] font-season text-[#1f2124] text-xs dark:text-[#E8E8E8]'>
</span>
)}
@@ -869,7 +860,7 @@ export function InlineToolCall({
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[#939393] text-sm dark:text-[#939393]'
className='font-[470] font-season text-[#3a3d41] text-sm dark:text-[#939393]'
/>
<div className='mt-[8px]'>{renderPendingDetails()}</div>
{showButtons && (
@@ -895,7 +886,7 @@ export function InlineToolCall({
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[#939393] text-sm dark:text-[#939393]'
className='font-[470] font-season text-[#3a3d41] text-sm dark:text-[#939393]'
/>
</div>
{isExpandableTool && expanded && <div>{renderPendingDetails()}</div>}

View File

@@ -552,11 +552,11 @@ export function MentionMenu({
active={index === submenuActiveIndex}
>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[#AEAEAE] text-[10px] dark:text-[#AEAEAE]'>·</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[#AEAEAE] text-[10px] dark:text-[#AEAEAE]'>·</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
@@ -583,9 +583,7 @@ export function MentionMenu({
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
·
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(item.data.createdAt)}
</span>
@@ -758,15 +756,11 @@ export function MentionMenu({
mentionData.logsList.map((log) => (
<PopoverItem key={log.id} onClick={() => insertLogMention(log)}>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[10px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
·
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
·
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>

View File

@@ -64,16 +64,14 @@ export function Welcome({ onQuestionClick, mode = 'ask' }: WelcomeProps) {
>
<div className='flex flex-col items-start'>
<p className='font-medium'>{title}</p>
<p className='text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{question}
</p>
<p className='text-[var(--text-secondary)]'>{question}</p>
</div>
</Button>
))}
</div>
{/* Tips */}
<p className='pt-[12px] text-center text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<p className='pt-[12px] text-center text-[13px] text-[var(--text-secondary)]'>
Tip: Use <span className='font-medium'>@</span> to reference chats, workflows, knowledge,
blocks, or templates
</p>

View File

@@ -385,8 +385,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
className='flex h-full flex-col overflow-hidden'
>
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between rounded-[4px] bg-[#2A2A2A] px-[12px] py-[8px] dark:bg-[#2A2A2A]'>
<h2 className='font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'>
<div className='flex flex-shrink-0 items-center justify-between rounded-[4px] bg-[var(--surface-5)] px-[12px] py-[8px]'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>
{currentChat?.title || 'New Chat'}
</h2>
<div className='flex items-center gap-[8px]'>
@@ -405,7 +405,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
<ChatHistorySkeleton />
</PopoverScrollArea>
) : groupedChats.length === 0 ? (
<div className='px-[6px] py-[16px] text-center text-[12px] text-[var(--white)] dark:text-[var(--white)]'>
<div className='px-[6px] py-[16px] text-center text-[12px] text-[var(--white)]'>
No chats yet
</div>
) : (
@@ -460,7 +460,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
{!isInitialized ? (
<div className='flex h-full w-full items-center justify-center'>
<div className='flex flex-col items-center gap-3'>
<p className='text-muted-foreground text-sm'>Loading chat history...</p>
<p className='text-muted-foreground text-sm'>Loading copilot</p>
</div>
</div>
) : (

View File

@@ -1,25 +0,0 @@
'use client'
import { Label } from '@/components/emcn'
import { CopyButton } from '@/components/ui/copy-button'
interface ApiEndpointProps {
endpoint: string
showLabel?: boolean
}
export function ApiEndpoint({ endpoint, showLabel = true }: ApiEndpointProps) {
return (
<div className='space-y-1.5'>
{showLabel && (
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>API Endpoint</Label>
</div>
)}
<div className='group relative rounded-md border bg-background transition-colors hover:bg-muted/50'>
<pre className='overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'>{endpoint}</pre>
<CopyButton text={endpoint} />
</div>
</div>
)
}

View File

@@ -0,0 +1,664 @@
'use client'
import { useState } from 'react'
import { Check, Clipboard } from 'lucide-react'
import {
Badge,
Button,
Code,
Label,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
interface WorkflowDeploymentInfo {
isDeployed: boolean
deployedAt?: string
apiKey: string
endpoint: string
exampleCommand: string
needsRedeployment: boolean
}
interface ApiDeployProps {
workflowId: string | null
deploymentInfo: WorkflowDeploymentInfo | null
isLoading: boolean
needsRedeployment: boolean
apiDeployError: string | null
getInputFormatExample: (includeStreaming?: boolean) => string
selectedStreamingOutputs: string[]
onSelectedStreamingOutputsChange: (outputs: string[]) => void
}
type AsyncExampleType = 'execute' | 'status' | 'rate-limits'
type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript'
type CopiedState = {
endpoint: boolean // @remark: not used
sync: boolean
stream: boolean
async: boolean
}
const LANGUAGE_LABELS: Record<CodeLanguage, string> = {
curl: 'cURL',
python: 'Python',
javascript: 'JavaScript',
typescript: 'TypeScript',
}
const LANGUAGE_SYNTAX: Record<CodeLanguage, 'python' | 'javascript' | 'json'> = {
curl: 'javascript',
python: 'python',
javascript: 'javascript',
typescript: 'javascript',
}
export function ApiDeploy({
workflowId,
deploymentInfo,
isLoading,
needsRedeployment,
apiDeployError,
getInputFormatExample,
selectedStreamingOutputs,
onSelectedStreamingOutputsChange,
}: ApiDeployProps) {
const [asyncExampleType, setAsyncExampleType] = useState<AsyncExampleType>('execute')
const [language, setLanguage] = useState<CodeLanguage>('curl')
const [copied, setCopied] = useState<CopiedState>({
endpoint: false, // @remark: not used
sync: false,
stream: false,
async: false,
})
const isAsyncEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED'))
const info = deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
const getBaseEndpoint = () => {
if (!info) return ''
return info.endpoint.replace(info.apiKey, '$SIM_API_KEY')
}
const getPayloadObject = (): Record<string, unknown> => {
const inputExample = getInputFormatExample ? getInputFormatExample(false) : ''
const match = inputExample.match(/-d\s*'([\s\S]*)'/)
if (match) {
try {
return JSON.parse(match[1]) as Record<string, unknown>
} catch {
return { input: 'your data here' }
}
}
return { input: 'your data here' }
}
const getStreamPayloadObject = (): Record<string, unknown> => {
const payload: Record<string, unknown> = { ...getPayloadObject(), stream: true }
if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) {
payload.selectedOutputs = selectedStreamingOutputs
}
return payload
}
const getSyncCommand = (): string => {
if (!info) return ''
const endpoint = getBaseEndpoint()
const payload = getPayloadObject()
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
case 'python':
return `import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
print(response.json())`
case 'javascript':
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data = await response.json();
console.log(data);`
case 'typescript':
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const data: Record<string, unknown> = await response.json();
console.log(data);`
default:
return ''
}
}
const getStreamCommand = (): string => {
if (!info) return ''
const endpoint = getBaseEndpoint()
const payload = getStreamPayloadObject()
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
case 'python':
return `import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')},
stream=True
)
for line in response.iter_lines():
if line:
print(line.decode("utf-8"))`
case 'javascript':
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}`
case 'typescript':
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}`
default:
return ''
}
}
const getAsyncCommand = (): string => {
if (!info) return ''
const endpoint = getBaseEndpoint()
const baseUrl = endpoint.split('/api/workflows/')[0]
const payload = getPayloadObject()
switch (asyncExampleType) {
case 'execute':
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
-H "X-Execution-Mode: async" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
case 'python':
return `import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json",
"X-Execution-Mode": "async"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
job = response.json()
print(job) # Contains job_id for status checking`
case 'javascript':
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json",
"X-Execution-Mode": "async"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const job = await response.json();
console.log(job); // Contains job_id for status checking`
case 'typescript':
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": SIM_API_KEY,
"Content-Type": "application/json",
"X-Execution-Mode": "async"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
const job: { job_id: string } = await response.json();
console.log(job); // Contains job_id for status checking`
default:
return ''
}
case 'status':
switch (language) {
case 'curl':
return `curl -H "X-API-Key: $SIM_API_KEY" \\
${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION`
case 'python':
return `import requests
response = requests.get(
"${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
headers={"X-API-Key": SIM_API_KEY}
)
status = response.json()
print(status)`
case 'javascript':
return `const response = await fetch(
"${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
{
headers: { "X-API-Key": SIM_API_KEY }
}
);
const status = await response.json();
console.log(status);`
case 'typescript':
return `const response = await fetch(
"${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
{
headers: { "X-API-Key": SIM_API_KEY }
}
);
const status: Record<string, unknown> = await response.json();
console.log(status);`
default:
return ''
}
case 'rate-limits':
switch (language) {
case 'curl':
return `curl -H "X-API-Key: $SIM_API_KEY" \\
${baseUrl}/api/users/me/usage-limits`
case 'python':
return `import requests
response = requests.get(
"${baseUrl}/api/users/me/usage-limits",
headers={"X-API-Key": SIM_API_KEY}
)
limits = response.json()
print(limits)`
case 'javascript':
return `const response = await fetch(
"${baseUrl}/api/users/me/usage-limits",
{
headers: { "X-API-Key": SIM_API_KEY }
}
);
const limits = await response.json();
console.log(limits);`
case 'typescript':
return `const response = await fetch(
"${baseUrl}/api/users/me/usage-limits",
{
headers: { "X-API-Key": SIM_API_KEY }
}
);
const limits: Record<string, unknown> = await response.json();
console.log(limits);`
default:
return ''
}
default:
return ''
}
}
const getAsyncExampleTitle = () => {
switch (asyncExampleType) {
case 'execute':
return 'Execute Job'
case 'status':
return 'Check Status'
case 'rate-limits':
return 'Rate Limits'
default:
return 'Execute Job'
}
}
const handleCopy = (key: keyof CopiedState, value: string) => {
navigator.clipboard.writeText(value)
setCopied((prev) => ({ ...prev, [key]: true }))
setTimeout(() => setCopied((prev) => ({ ...prev, [key]: false })), 2000)
}
if (isLoading || !info) {
return (
<div className='space-y-[16px]'>
{apiDeployError && (
<div className='rounded-[4px] border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[62px]' />
<Skeleton className='h-[28px] w-[260px] rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[90px]' />
<Skeleton className='h-[120px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[180px]' />
<Skeleton className='h-[160px] w-full rounded-[4px]' />
</div>
</div>
)
}
return (
<div className='space-y-[16px]'>
{apiDeployError && (
<div className='rounded-[4px] border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
{/* <div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
URL
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleCopy('endpoint', info.endpoint)}
aria-label='Copy endpoint'
className='!p-1.5 -my-1.5'
>
{copied.endpoint ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Code.Viewer
code={info.endpoint}
language='javascript'
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-strong)]'
/>
</div> */}
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Language
</Label>
</div>
<div className='inline-flex gap-[2px]'>
{(Object.keys(LANGUAGE_LABELS) as CodeLanguage[]).map((lang, index, arr) => (
<Button
key={lang}
type='button'
variant={language === lang ? 'active' : 'default'}
onClick={() => setLanguage(lang)}
className={`px-[8px] py-[4px] text-[12px] ${
index === 0
? 'rounded-r-none'
: index === arr.length - 1
? 'rounded-l-none'
: 'rounded-none'
}`}
>
{LANGUAGE_LABELS[lang]}
</Button>
))}
</div>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Run workflow
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleCopy('sync', getSyncCommand())}
aria-label='Copy command'
className='!p-1.5 -my-1.5'
>
{copied.sync ? <Check className='h-3 w-3' /> : <Clipboard className='h-3 w-3' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied.sync ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Code.Viewer
code={getSyncCommand()}
language={LANGUAGE_SYNTAX[language]}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-strong)]'
/>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Run workflow (stream response)
</Label>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleCopy('stream', getStreamCommand())}
aria-label='Copy command'
className='!p-1.5 -my-1.5'
>
{copied.stream ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied.stream ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
<OutputSelect
workflowId={workflowId}
selectedOutputs={selectedStreamingOutputs}
onOutputSelect={onSelectedStreamingOutputsChange}
placeholder='Select outputs'
valueMode='label'
align='end'
/>
</div>
</div>
<Code.Viewer
code={getStreamCommand()}
language={LANGUAGE_SYNTAX[language]}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-strong)]'
/>
</div>
{isAsyncEnabled && (
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Run workflow (async)
</Label>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleCopy('async', getAsyncCommand())}
aria-label='Copy command'
className='!p-1.5 -my-1.5'
>
{copied.async ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied.async ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
<Popover>
<PopoverTrigger asChild>
<div className='min-w-0 max-w-full'>
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
>
<span className='whitespace-nowrap text-[12px]'>
{getAsyncExampleTitle()}
</span>
</Badge>
</div>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
maxHeight={300}
maxWidth={300}
minWidth={160}
border
>
<PopoverItem
active={asyncExampleType === 'execute'}
onClick={() => setAsyncExampleType('execute')}
>
Execute Job
</PopoverItem>
<PopoverItem
active={asyncExampleType === 'status'}
onClick={() => setAsyncExampleType('status')}
>
Check Status
</PopoverItem>
<PopoverItem
active={asyncExampleType === 'rate-limits'}
onClick={() => setAsyncExampleType('rate-limits')}
>
Rate Limits
</PopoverItem>
</PopoverContent>
</Popover>
</div>
</div>
<Code.Viewer
code={getAsyncCommand()}
language={LANGUAGE_SYNTAX[language]}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-strong)]'
/>
</div>
)}
</div>
)
}

View File

@@ -1,293 +0,0 @@
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Plus, RefreshCw } from 'lucide-react'
import { Button, Input, Label } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Card, CardContent } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { generatePassword } from '@/lib/core/security/encryption'
import { cn } from '@/lib/core/utils/cn'
import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form'
interface AuthSelectorProps {
authType: AuthType
password: string
emails: string[]
onAuthTypeChange: (type: AuthType) => void
onPasswordChange: (password: string) => void
onEmailsChange: (emails: string[]) => void
disabled?: boolean
isExistingChat?: boolean
error?: string
}
export function AuthSelector({
authType,
password,
emails,
onAuthTypeChange,
onPasswordChange,
onEmailsChange,
disabled = false,
isExistingChat = false,
error,
}: AuthSelectorProps) {
const [showPassword, setShowPassword] = useState(false)
const [newEmail, setNewEmail] = useState('')
const [emailError, setEmailError] = useState('')
const [copySuccess, setCopySuccess] = useState(false)
const handleGeneratePassword = () => {
const password = generatePassword(24)
onPasswordChange(password)
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
const handleAddEmail = () => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail) && !newEmail.startsWith('@')) {
setEmailError('Please enter a valid email or domain (e.g., user@example.com or @example.com)')
return
}
if (emails.includes(newEmail)) {
setEmailError('This email or domain is already in the list')
return
}
onEmailsChange([...emails, newEmail])
setNewEmail('')
setEmailError('')
}
const handleRemoveEmail = (email: string) => {
onEmailsChange(emails.filter((e) => e !== email))
}
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const authOptions = ssoEnabled
? (['public', 'password', 'email', 'sso'] as const)
: (['public', 'password', 'email'] as const)
return (
<div className='space-y-2'>
<Label className='font-medium text-sm'>Access Control</Label>
{/* Auth Type Selection */}
<div
className={cn('grid grid-cols-1 gap-3', ssoEnabled ? 'md:grid-cols-4' : 'md:grid-cols-3')}
>
{authOptions.map((type) => (
<Card
key={type}
className={cn(
'cursor-pointer overflow-hidden rounded-[4px] shadow-none transition-all duration-200',
authType === type
? 'border border-[#727272] bg-[var(--border-strong)] dark:border-[#727272] dark:bg-[var(--border-strong)]'
: 'border border-[var(--surface-11)] bg-[var(--surface-6)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-11)]'
)}
>
<CardContent className='relative flex flex-col items-center justify-center p-3 text-center'>
<button
type='button'
className='absolute inset-0 z-10 h-full w-full cursor-pointer'
onClick={() => !disabled && onAuthTypeChange(type)}
aria-label={`Select ${type} access`}
disabled={disabled}
/>
<div className='justify-center text-center align-middle'>
<h3
className={cn(
'font-medium text-xs',
authType === type && 'text-[var(--text-primary)]'
)}
>
{type === 'public' && 'Public Access'}
{type === 'password' && 'Password Protected'}
{type === 'email' && 'Email Access'}
{type === 'sso' && 'SSO Access'}
</h3>
<p className='text-[11px] text-[var(--text-tertiary)]'>
{type === 'public' && 'Anyone can access your chat'}
{type === 'password' && 'Secure with a single password'}
{type === 'email' && 'Restrict to specific emails'}
{type === 'sso' && 'Authenticate via SSO provider'}
</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* Auth Settings */}
{authType === 'password' && (
<Card className='rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-9)]'>
<CardContent className='p-4'>
<h3 className='mb-2 font-medium text-[var(--text-primary)] text-xs'>
Password Settings
</h3>
{isExistingChat && !password && (
<div className='mb-2 flex items-center text-[11px] text-[var(--text-secondary)]'>
<div className='mr-2 rounded-full bg-[var(--surface-9)] px-2 py-0.5 font-medium text-[var(--text-secondary)] dark:bg-[var(--surface-11)]'>
Password set
</div>
<span>Current password is securely stored</span>
</div>
)}
<div className='relative'>
<Input
type={showPassword ? 'text' : 'password'}
placeholder={
isExistingChat
? 'Enter new password (leave empty to keep current)'
: 'Enter password'
}
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
disabled={disabled}
className='pr-28'
required={!isExistingChat}
autoComplete='new-password'
/>
<div className='-translate-y-1/2 absolute top-1/2 right-1 flex items-center gap-1'>
<Button
type='button'
variant='ghost'
onClick={handleGeneratePassword}
disabled={disabled}
className='h-6 w-6 p-0'
>
<RefreshCw className='h-3.5 w-3.5 transition-transform duration-200 hover:rotate-90' />
<span className='sr-only'>Generate password</span>
</Button>
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(password)}
disabled={!password || disabled}
className='h-6 w-6 p-0'
>
{copySuccess ? (
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Copy password</span>
</Button>
<Button
type='button'
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
className='h-6 w-6 p-0'
>
{showPassword ? (
<EyeOff className='h-3.5 w-3.5' />
) : (
<Eye className='h-3.5 w-3.5' />
)}
<span className='sr-only'>
{showPassword ? 'Hide password' : 'Show password'}
</span>
</Button>
</div>
</div>
<p className='mt-2 text-[11px] text-[var(--text-secondary)]'>
{isExistingChat
? 'Leaving this empty will keep the current password. Enter a new password to change it.'
: 'This password will be required to access your chat.'}
</p>
</CardContent>
</Card>
)}
{(authType === 'email' || authType === 'sso') && (
<Card className='rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-9)]'>
<CardContent className='p-4'>
<h3 className='mb-2 font-medium text-[var(--text-primary)] text-xs'>
{authType === 'email' ? 'Email Access Settings' : 'SSO Access Settings'}
</h3>
<div className='flex gap-2'>
<Input
placeholder='user@example.com or @domain.com'
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={disabled}
className='flex-1'
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddEmail()
}
}}
/>
<Button
type='button'
variant='default'
onClick={handleAddEmail}
disabled={!newEmail.trim() || disabled}
className='shrink-0 gap-[4px]'
>
<Plus className='h-4 w-4' />
Add
</Button>
</div>
{emailError && <p className='mt-1 text-destructive text-sm'>{emailError}</p>}
{emails.length > 0 && (
<div className='mt-3 max-h-[150px] overflow-y-auto rounded-md border bg-background px-2 py-0 shadow-none'>
<ul className='divide-y divide-border'>
{emails.map((email) => (
<li key={email} className='relative'>
<div className='group my-1 flex items-center justify-between rounded-sm px-2 py-2 text-sm'>
<span className='font-medium text-foreground'>{email}</span>
<Button
type='button'
variant='ghost'
onClick={() => handleRemoveEmail(email)}
disabled={disabled}
className='h-7 w-7 p-0 opacity-70'
>
<Trash className='h-4 w-4' />
</Button>
</div>
</li>
))}
</ul>
</div>
)}
<p className='mt-2 text-[11px] text-[var(--text-secondary)]'>
{authType === 'email'
? 'Add specific emails or entire domains (@example.com)'
: 'Add specific emails or entire domains (@example.com) that can access via SSO'}
</p>
</CardContent>
</Card>
)}
{authType === 'public' && (
<Card className='rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-9)]'>
<CardContent className='p-4'>
<h3 className='mb-2 font-medium text-[var(--text-primary)] text-xs'>
Public Access Settings
</h3>
<p className='text-[11px] text-[var(--text-secondary)]'>
This chat will be publicly accessible to anyone with the link.
</p>
</CardContent>
</Card>
)}
{error && <p className='text-destructive text-sm'>{error}</p>}
</div>
)
}

View File

@@ -1,486 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import {
Button,
Input,
Label,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Textarea,
} from '@/components/emcn'
import { Alert, AlertDescription, Skeleton } from '@/components/ui'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/auth-selector'
import { IdentifierInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/identifier-input'
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/success-view'
import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-deployment'
import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form'
const logger = createLogger('ChatDeploy')
interface ChatDeployProps {
workflowId: string
deploymentInfo: {
apiKey: string
} | null
onChatExistsChange?: (exists: boolean) => void
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
showDeleteConfirmation?: boolean
setShowDeleteConfirmation?: (show: boolean) => void
onDeploymentComplete?: () => void
onDeployed?: () => void
onUndeploy?: () => Promise<void>
onVersionActivated?: () => void
}
interface ExistingChat {
id: string
identifier: string
title: string
description: string
authType: 'public' | 'password' | 'email'
allowedEmails: string[]
outputConfigs: Array<{ blockId: string; path: string }>
customizations?: {
welcomeMessage?: string
}
isActive: boolean
}
export function ChatDeploy({
workflowId,
deploymentInfo,
onChatExistsChange,
chatSubmitting,
setChatSubmitting,
onValidationChange,
showDeleteConfirmation: externalShowDeleteConfirmation,
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
onDeploymentComplete,
onDeployed,
onUndeploy,
onVersionActivated,
}: ChatDeployProps) {
const [isLoading, setIsLoading] = useState(false)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [imageUploadError, setImageUploadError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isImageUploading, setIsImageUploading] = useState(false)
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
const [showSuccessView, setShowSuccessView] = useState(false)
// Use external state for delete confirmation if provided
const showDeleteConfirmation =
externalShowDeleteConfirmation !== undefined
? externalShowDeleteConfirmation
: internalShowDeleteConfirmation
const setShowDeleteConfirmation =
externalSetShowDeleteConfirmation || setInternalShowDeleteConfirmation
const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
const { deployedUrl, deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const isFormValid =
isIdentifierValid &&
Boolean(formData.title.trim()) &&
formData.selectedOutputBlocks.length > 0 &&
(formData.authType !== 'password' ||
Boolean(formData.password.trim()) ||
Boolean(existingChat)) &&
((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0)
useEffect(() => {
onValidationChange?.(isFormValid)
}, [isFormValid, onValidationChange])
useEffect(() => {
if (workflowId) {
fetchExistingChat()
}
}, [workflowId])
const fetchExistingChat = async () => {
try {
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.deployment) {
const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
if (detailResponse.ok) {
const chatDetail = await detailResponse.json()
setExistingChat(chatDetail)
setFormData({
identifier: chatDetail.identifier || '',
title: chatDetail.title || '',
description: chatDetail.description || '',
authType: chatDetail.authType || 'public',
password: '',
emails: Array.isArray(chatDetail.allowedEmails) ? [...chatDetail.allowedEmails] : [],
welcomeMessage:
chatDetail.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
selectedOutputBlocks: Array.isArray(chatDetail.outputConfigs)
? chatDetail.outputConfigs.map(
(config: { blockId: string; path: string }) =>
`${config.blockId}_${config.path}`
)
: [],
})
if (chatDetail.customizations?.imageUrl) {
setImageUrl(chatDetail.customizations.imageUrl)
}
setImageUploadError(null)
onChatExistsChange?.(true)
}
} else {
setExistingChat(null)
setImageUrl(null)
setImageUploadError(null)
onChatExistsChange?.(false)
}
}
} catch (error) {
logger.error('Error fetching chat status:', error)
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (chatSubmitting) return
setChatSubmitting(true)
try {
if (!validateForm()) {
setChatSubmitting(false)
return
}
if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) {
setError('identifier', 'Please wait for identifier validation to complete')
setChatSubmitting(false)
return
}
await deployChat(workflowId, formData, null, existingChat?.id, imageUrl)
onChatExistsChange?.(true)
setShowSuccessView(true)
onDeployed?.()
onVersionActivated?.()
await fetchExistingChat()
} catch (error: any) {
if (error.message?.includes('identifier')) {
setError('identifier', error.message)
} else {
setError('general', error.message)
}
} finally {
setChatSubmitting(false)
}
}
const handleDelete = async () => {
if (!existingChat || !existingChat.id) return
try {
setIsDeleting(true)
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete chat')
}
setExistingChat(null)
setImageUrl(null)
setImageUploadError(null)
onChatExistsChange?.(false)
onDeploymentComplete?.()
} catch (error: any) {
logger.error('Failed to delete chat:', error)
setError('general', error.message || 'An unexpected error occurred while deleting')
} finally {
setIsDeleting(false)
setShowDeleteConfirmation(false)
}
}
if (isLoading) {
return <LoadingSkeleton />
}
if (deployedUrl && showSuccessView) {
return (
<>
<div id='chat-deploy-form'>
<SuccessView
deployedUrl={deployedUrl}
existingChat={existingChat}
onDelete={() => setShowDeleteConfirmation(true)}
onUpdate={() => setShowSuccessView(false)}
/>
</div>
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete Chat?</ModalTitle>
<ModalDescription>
This will delete your chat deployment at "{getEmailDomain()}/chat/
{existingChat?.identifier}". All users will lose access to the chat interface. You
can recreate this chat deployment at any time.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => setShowDeleteConfirmation(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isDeleting ? (
<>
<Loader2 className='mr-2 h-3.5 w-3.5 animate-spin' />
Deleting...
</>
) : (
'Delete'
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
return (
<>
<form
id='chat-deploy-form'
ref={formRef}
onSubmit={handleSubmit}
className='-mx-1 space-y-4 overflow-y-auto px-1'
>
{errors.general && (
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>{errors.general}</AlertDescription>
</Alert>
)}
<div className='space-y-4'>
<IdentifierInput
value={formData.identifier}
onChange={(value) => updateField('identifier', value)}
originalIdentifier={existingChat?.identifier || undefined}
disabled={chatSubmitting}
onValidationChange={setIsIdentifierValid}
isEditingExisting={!!existingChat}
/>
<div className='space-y-2'>
<Label htmlFor='title' className='font-medium text-sm'>
Chat Title
</Label>
<Input
id='title'
placeholder='Customer Support Assistant'
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
required
disabled={chatSubmitting}
/>
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
</div>
<div className='space-y-2'>
<Label htmlFor='description' className='font-medium text-sm'>
Description
</Label>
<Textarea
id='description'
placeholder='A brief description of what this chat does'
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
rows={3}
disabled={chatSubmitting}
className='min-h-[80px] resize-none'
/>
</div>
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Output</Label>
<OutputSelect
workflowId={workflowId}
selectedOutputs={formData.selectedOutputBlocks}
onOutputSelect={(values) => updateField('selectedOutputBlocks', values)}
placeholder='Select which block outputs to use'
disabled={chatSubmitting}
/>
{errors.outputBlocks && (
<p className='text-destructive text-sm'>{errors.outputBlocks}</p>
)}
<p className='mt-2 text-[11px] text-[var(--text-secondary)]'>
Select which block's output to return to the user in the chat interface
</p>
</div>
<AuthSelector
authType={formData.authType}
password={formData.password}
emails={formData.emails}
onAuthTypeChange={(type) => updateField('authType', type)}
onPasswordChange={(password) => updateField('password', password)}
onEmailsChange={(emails) => updateField('emails', emails)}
disabled={chatSubmitting}
isExistingChat={!!existingChat}
error={errors.password || errors.emails}
/>
<div className='space-y-2'>
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
Welcome Message
</Label>
<Textarea
id='welcomeMessage'
placeholder='Enter a welcome message for your chat'
value={formData.welcomeMessage}
onChange={(e) => updateField('welcomeMessage', e.target.value)}
rows={3}
disabled={chatSubmitting}
className='min-h-[80px] resize-none'
/>
<p className='text-muted-foreground text-xs'>
This message will be displayed when users first open the chat
</p>
</div>
{/* <div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Logo</Label>
<ImageUpload
value={imageUrl}
onUpload={(url) => {
setImageUrl(url)
setImageUploadError(null)
}}
onError={setImageUploadError}
onUploadStart={setIsImageUploading}
disabled={chatSubmitting}
uploadToServer={true}
height='h-32'
hideHeader={true}
/>
{imageUploadError && <p className='text-destructive text-sm'>{imageUploadError}</p>}
{!imageUrl && !isImageUploading && (
<p className='text-muted-foreground text-xs'>
Upload a logo for your chat (PNG, JPEG - max 5MB)
</p>
)}
</div> */}
<button
type='button'
data-delete-trigger
onClick={() => setShowDeleteConfirmation(true)}
style={{ display: 'none' }}
/>
</div>
</form>
<Modal open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete Chat?</ModalTitle>
<ModalDescription>
This will delete your chat deployment at "{getEmailDomain()}/chat/
{existingChat?.identifier}". All users will lose access to the chat interface. You can
recreate this chat deployment at any time.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => setShowDeleteConfirmation(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isDeleting ? (
<>
<Loader2 className='mr-2 h-3.5 w-3.5 animate-spin' />
Deleting...
</>
) : (
'Delete'
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
function LoadingSkeleton() {
return (
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-10 w-full' />
</div>
<div className='space-y-2'>
<Skeleton className='h-5 w-20' />
<Skeleton className='h-10 w-full' />
</div>
<div className='space-y-2'>
<Skeleton className='h-5 w-32' />
<Skeleton className='h-24 w-full' />
</div>
<div className='space-y-2'>
<Skeleton className='h-5 w-40' />
<Skeleton className='h-32 w-full rounded-lg' />
</div>
</div>
)
}

View File

@@ -0,0 +1,871 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Check, Clipboard, Eye, EyeOff, Loader2, RefreshCw, X } from 'lucide-react'
import { Button, Input, Label, Textarea, Tooltip } from '@/components/emcn'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components/modal/modal'
import { Alert, AlertDescription, Skeleton } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { generatePassword } from '@/lib/core/security/encryption'
import { cn } from '@/lib/core/utils/cn'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
import {
type AuthType,
type ChatFormData,
useChatDeployment,
useIdentifierValidation,
} from './hooks'
const logger = createLogger('ChatDeploy')
const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/
interface ChatDeployProps {
workflowId: string
deploymentInfo: {
apiKey: string
} | null
existingChat: ExistingChat | null
isLoadingChat: boolean
onRefetchChat: () => Promise<void>
onChatExistsChange?: (exists: boolean) => void
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
showDeleteConfirmation?: boolean
setShowDeleteConfirmation?: (show: boolean) => void
onDeploymentComplete?: () => void
onDeployed?: () => void
onVersionActivated?: () => void
}
export interface ExistingChat {
id: string
identifier: string
title: string
description: string
authType: 'public' | 'password' | 'email' | 'sso'
allowedEmails: string[]
outputConfigs: Array<{ blockId: string; path: string }>
customizations?: {
welcomeMessage?: string
imageUrl?: string
}
isActive: boolean
}
interface FormErrors {
identifier?: string
title?: string
password?: string
emails?: string
outputBlocks?: string
general?: string
}
const initialFormData: ChatFormData = {
identifier: '',
title: '',
description: '',
authType: 'public',
password: '',
emails: [],
welcomeMessage: 'Hi there! How can I help you today?',
selectedOutputBlocks: [],
}
export function ChatDeploy({
workflowId,
deploymentInfo,
existingChat,
isLoadingChat,
onRefetchChat,
onChatExistsChange,
chatSubmitting,
setChatSubmitting,
onValidationChange,
showDeleteConfirmation: externalShowDeleteConfirmation,
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
onDeploymentComplete,
onDeployed,
onVersionActivated,
}: ChatDeployProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
const showDeleteConfirmation =
externalShowDeleteConfirmation !== undefined
? externalShowDeleteConfirmation
: internalShowDeleteConfirmation
const setShowDeleteConfirmation =
externalSetShowDeleteConfirmation || setInternalShowDeleteConfirmation
const [formData, setFormData] = useState<ChatFormData>(initialFormData)
const [errors, setErrors] = useState<FormErrors>({})
const { deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const [hasInitializedForm, setHasInitializedForm] = useState(false)
const updateField = <K extends keyof ChatFormData>(field: K, value: ChatFormData[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }))
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
}
const setError = (field: keyof FormErrors, message: string) => {
setErrors((prev) => ({ ...prev, [field]: message }))
}
const validateForm = (isExistingChat: boolean): boolean => {
const newErrors: FormErrors = {}
if (!formData.identifier.trim()) {
newErrors.identifier = 'Identifier is required'
} else if (!IDENTIFIER_PATTERN.test(formData.identifier)) {
newErrors.identifier = 'Identifier can only contain lowercase letters, numbers, and hyphens'
}
if (!formData.title.trim()) {
newErrors.title = 'Title is required'
}
if (formData.authType === 'password' && !isExistingChat && !formData.password.trim()) {
newErrors.password = 'Password is required when using password protection'
}
if (
(formData.authType === 'email' || formData.authType === 'sso') &&
formData.emails.length === 0
) {
newErrors.emails = `At least one email or domain is required when using ${formData.authType === 'sso' ? 'SSO' : 'email'} access control`
}
if (formData.selectedOutputBlocks.length === 0) {
newErrors.outputBlocks = 'Please select at least one output block'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const isFormValid =
isIdentifierValid &&
Boolean(formData.title.trim()) &&
formData.selectedOutputBlocks.length > 0 &&
(formData.authType !== 'password' ||
Boolean(formData.password.trim()) ||
Boolean(existingChat)) &&
((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0)
useEffect(() => {
onValidationChange?.(isFormValid)
}, [isFormValid, onValidationChange])
useEffect(() => {
if (existingChat && !hasInitializedForm) {
setFormData({
identifier: existingChat.identifier || '',
title: existingChat.title || '',
description: existingChat.description || '',
authType: existingChat.authType || 'public',
password: '',
emails: Array.isArray(existingChat.allowedEmails) ? [...existingChat.allowedEmails] : [],
welcomeMessage:
existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
selectedOutputBlocks: Array.isArray(existingChat.outputConfigs)
? existingChat.outputConfigs.map(
(config: { blockId: string; path: string }) => `${config.blockId}_${config.path}`
)
: [],
})
if (existingChat.customizations?.imageUrl) {
setImageUrl(existingChat.customizations.imageUrl)
}
setHasInitializedForm(true)
} else if (!existingChat && !isLoadingChat) {
setFormData(initialFormData)
setImageUrl(null)
setHasInitializedForm(false)
}
}, [existingChat, isLoadingChat, hasInitializedForm])
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (chatSubmitting) return
setChatSubmitting(true)
try {
if (!validateForm(!!existingChat)) {
setChatSubmitting(false)
return
}
if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) {
setError('identifier', 'Please wait for identifier validation to complete')
setChatSubmitting(false)
return
}
const chatUrl = await deployChat(
workflowId,
formData,
deploymentInfo,
existingChat?.id,
imageUrl
)
onChatExistsChange?.(true)
onDeployed?.()
onVersionActivated?.()
if (chatUrl) {
window.open(chatUrl, '_blank', 'noopener,noreferrer')
}
setHasInitializedForm(false)
await onRefetchChat()
} catch (error: any) {
if (error.message?.includes('identifier')) {
setError('identifier', error.message)
} else {
setError('general', error.message)
}
} finally {
setChatSubmitting(false)
}
}
const handleDelete = async () => {
if (!existingChat || !existingChat.id) return
try {
setIsDeleting(true)
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete chat')
}
setImageUrl(null)
setHasInitializedForm(false)
onChatExistsChange?.(false)
await onRefetchChat()
onDeploymentComplete?.()
} catch (error: any) {
logger.error('Failed to delete chat:', error)
setError('general', error.message || 'An unexpected error occurred while deleting')
} finally {
setIsDeleting(false)
setShowDeleteConfirmation(false)
}
}
if (isLoadingChat) {
return <LoadingSkeleton />
}
return (
<>
<form
id='chat-deploy-form'
ref={formRef}
onSubmit={handleSubmit}
className='-mx-1 space-y-4 overflow-y-auto px-1'
>
{errors.general && (
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>{errors.general}</AlertDescription>
</Alert>
)}
<div className='space-y-[12px]'>
<IdentifierInput
value={formData.identifier}
onChange={(value) => updateField('identifier', value)}
originalIdentifier={existingChat?.identifier || undefined}
disabled={chatSubmitting}
onValidationChange={setIsIdentifierValid}
isEditingExisting={!!existingChat}
/>
<div>
<Label
htmlFor='title'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Title
</Label>
<Input
id='title'
placeholder='Customer Support Assistant'
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
required
disabled={chatSubmitting}
/>
{errors.title && <p className='mt-1 text-destructive text-sm'>{errors.title}</p>}
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Output
</Label>
<OutputSelect
workflowId={workflowId}
selectedOutputs={formData.selectedOutputBlocks}
onOutputSelect={(values) => updateField('selectedOutputBlocks', values)}
placeholder='Select which block outputs to use'
disabled={chatSubmitting}
/>
{errors.outputBlocks && (
<p className='mt-1 text-destructive text-sm'>{errors.outputBlocks}</p>
)}
</div>
<AuthSelector
authType={formData.authType}
password={formData.password}
emails={formData.emails}
onAuthTypeChange={(type) => updateField('authType', type)}
onPasswordChange={(password) => updateField('password', password)}
onEmailsChange={(emails) => updateField('emails', emails)}
disabled={chatSubmitting}
isExistingChat={!!existingChat}
error={errors.password || errors.emails}
/>
<div>
<Label
htmlFor='welcomeMessage'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Welcome message
</Label>
<Textarea
id='welcomeMessage'
placeholder='Enter a welcome message for your chat'
value={formData.welcomeMessage}
onChange={(e) => updateField('welcomeMessage', e.target.value)}
rows={3}
disabled={chatSubmitting}
className='min-h-[80px] resize-none'
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
This message will be displayed when users first open the chat
</p>
</div>
<button
type='button'
data-delete-trigger
onClick={() => setShowDeleteConfirmation(true)}
style={{ display: 'none' }}
/>
</div>
</form>
<Modal open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Chat</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to delete this chat?{' '}
<span className='text-[var(--text-error)]'>
This will remove the chat at "{getEmailDomain()}/chat/{existingChat?.identifier}"
and make it unavailable to all users.
</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => setShowDeleteConfirmation(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant='primary'
onClick={handleDelete}
disabled={isDeleting}
className='gap-[8px] bg-[var(--text-error)] text-[13px] text-white hover:bg-[var(--text-error)]'
>
{isDeleting && <Loader2 className='mr-1 h-4 w-4 animate-spin' />}
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
function LoadingSkeleton() {
return (
<div className='-mx-1 space-y-4 px-1'>
<div className='space-y-[12px]'>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[26px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
<Skeleton className='mt-[6.5px] h-[14px] w-[320px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[30px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[46px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[95px]' />
<Skeleton className='h-[28px] w-[170px] rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[115px]' />
<Skeleton className='h-[80px] w-full rounded-[4px]' />
<Skeleton className='mt-[6.5px] h-[14px] w-[340px]' />
</div>
</div>
</div>
)
}
interface IdentifierInputProps {
value: string
onChange: (value: string) => void
originalIdentifier?: string
disabled?: boolean
onValidationChange?: (isValid: boolean) => void
isEditingExisting?: boolean
}
const getDomainPrefix = (() => {
const prefix = `${getEmailDomain()}/chat/`
return () => prefix
})()
function IdentifierInput({
value,
onChange,
originalIdentifier,
disabled = false,
onValidationChange,
isEditingExisting = false,
}: IdentifierInputProps) {
const { isChecking, error, isValid } = useIdentifierValidation(
value,
originalIdentifier,
isEditingExisting
)
useEffect(() => {
onValidationChange?.(isValid)
}, [isValid, onValidationChange])
const handleChange = (newValue: string) => {
const lowercaseValue = newValue.toLowerCase()
onChange(lowercaseValue)
}
const fullUrl = `${getEnv('NEXT_PUBLIC_APP_URL')}/chat/${value}`
const displayUrl = fullUrl.replace(/^https?:\/\//, '')
return (
<div>
<Label
htmlFor='chat-url'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
URL
</Label>
<div
className={cn(
'relative flex items-stretch overflow-hidden rounded-[4px] border border-[var(--surface-11)]',
error && 'border-[var(--text-error)]'
)}
>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-6)] px-[8px] font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-9)]'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
<Input
id='chat-url'
placeholder='company-name'
value={value}
onChange={(e) => handleChange(e.target.value)}
required
disabled={disabled}
className={cn(
'rounded-none border-0 pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
isChecking && 'pr-[32px]'
)}
/>
{isChecking && (
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-tertiary)]' />
</div>
)}
</div>
</div>
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
<p className='mt-[6.5px] truncate text-[11px] text-[var(--text-secondary)]'>
{isEditingExisting && value ? (
<>
Live at:{' '}
<a
href={fullUrl}
target='_blank'
rel='noopener noreferrer'
className='text-[var(--text-primary)] hover:underline'
>
{displayUrl}
</a>
</>
) : (
'The unique URL path where your chat will be accessible'
)}
</p>
</div>
)
}
interface AuthSelectorProps {
authType: AuthType
password: string
emails: string[]
onAuthTypeChange: (type: AuthType) => void
onPasswordChange: (password: string) => void
onEmailsChange: (emails: string[]) => void
disabled?: boolean
isExistingChat?: boolean
error?: string
}
const AUTH_LABELS: Record<AuthType, string> = {
public: 'Public',
password: 'Password',
email: 'Email',
sso: 'SSO',
}
function AuthSelector({
authType,
password,
emails,
onAuthTypeChange,
onPasswordChange,
onEmailsChange,
disabled = false,
isExistingChat = false,
error,
}: AuthSelectorProps) {
const [showPassword, setShowPassword] = useState(false)
const [emailInputValue, setEmailInputValue] = useState('')
const [emailError, setEmailError] = useState('')
const [copySuccess, setCopySuccess] = useState(false)
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const handleGeneratePassword = () => {
const newPassword = generatePassword(24)
onPasswordChange(newPassword)
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
const addEmail = (email: string): boolean => {
if (!email.trim()) return false
const normalized = email.trim().toLowerCase()
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) || normalized.startsWith('@')
if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
return false
}
if (!isValid) {
setInvalidEmails((prev) => [...prev, normalized])
setEmailInputValue('')
return false
}
setEmailError('')
onEmailsChange([...emails, normalized])
setEmailInputValue('')
return true
}
const handleRemoveEmail = (emailToRemove: string) => {
onEmailsChange(emails.filter((e) => e !== emailToRemove))
}
const handleRemoveInvalidEmail = (index: number) => {
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && emailInputValue.trim()) {
e.preventDefault()
addEmail(emailInputValue)
}
if (e.key === 'Backspace' && !emailInputValue) {
if (invalidEmails.length > 0) {
handleRemoveInvalidEmail(invalidEmails.length - 1)
} else if (emails.length > 0) {
handleRemoveEmail(emails[emails.length - 1])
}
}
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
let addedCount = 0
pastedEmails.forEach((email) => {
if (addEmail(email)) {
addedCount++
}
})
if (addedCount === 0 && pastedEmails.length === 1) {
setEmailInputValue(emailInputValue + pastedEmails[0])
}
}
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const authOptions = ssoEnabled
? (['public', 'password', 'email', 'sso'] as const)
: (['public', 'password', 'email'] as const)
return (
<div className='space-y-[16px]'>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Access control
</Label>
<div className='inline-flex gap-[2px]'>
{authOptions.map((type, index, arr) => (
<Button
key={type}
type='button'
variant={authType === type ? 'active' : 'default'}
onClick={() => !disabled && onAuthTypeChange(type)}
disabled={disabled}
className={`px-[8px] py-[4px] text-[12px] ${
index === 0
? 'rounded-r-none'
: index === arr.length - 1
? 'rounded-l-none'
: 'rounded-none'
}`}
>
{AUTH_LABELS[type]}
</Button>
))}
</div>
</div>
{authType === 'password' && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Password
</Label>
<div className='relative'>
<Input
type={showPassword ? 'text' : 'password'}
placeholder={isExistingChat ? 'Enter new password to change' : 'Enter password'}
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
disabled={disabled}
className='pr-[88px]'
required={!isExistingChat}
autoComplete='new-password'
/>
<div className='-translate-y-1/2 absolute top-1/2 right-[4px] flex items-center'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleGeneratePassword}
disabled={disabled}
aria-label='Generate password'
className='!p-1.5'
>
<RefreshCw className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Generate</span>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(password)}
disabled={!password || disabled}
aria-label='Copy password'
className='!p-1.5'
>
{copySuccess ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copySuccess ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
aria-label={showPassword ? 'Hide password' : 'Show password'}
className='!p-1.5'
>
{showPassword ? <EyeOff className='h-3 w-3' /> : <Eye className='h-3 w-3' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{showPassword ? 'Hide' : 'Show'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{isExistingChat
? 'Leave empty to keep the current password'
: 'This password will be required to access your chat'}
</p>
</div>
)}
{(authType === 'email' || authType === 'sso') && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
{authType === 'email' ? 'Allowed emails' : 'Allowed SSO emails'}
</Label>
<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-[6px] py-[4px] focus-within:outline-none dark:bg-[var(--surface-9)]'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => handleRemoveInvalidEmail(index)}
disabled={disabled}
isInvalid={true}
/>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => handleRemoveEmail(email)}
disabled={disabled}
/>
))}
<Input
type='text'
value={emailInputValue}
onChange={(e) => setEmailInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => emailInputValue.trim() && addEmail(emailInputValue)}
placeholder={
emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails or domains (@example.com)'
}
className={cn(
'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]'
)}
disabled={disabled}
/>
</div>
{emailError && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{emailError}</p>
)}
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{authType === 'email'
? 'Add specific emails or entire domains (@example.com)'
: 'Add emails or domains that can access via SSO'}
</p>
</div>
)}
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
</div>
)
}
interface EmailTagProps {
email: string
onRemove: () => void
disabled?: boolean
isInvalid?: boolean
}
function EmailTag({ email, onRemove, disabled, isInvalid }: EmailTagProps) {
return (
<div
className={cn(
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
isInvalid
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
: 'border-[var(--surface-11)] bg-[var(--surface-5)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='max-w-[200px] truncate'>{email}</span>
{!disabled && (
<button
type='button'
onClick={onRemove}
className={cn(
'flex-shrink-0 transition-colors focus:outline-none',
isInvalid
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
)}
aria-label={`Remove ${email}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
export { useIdentifierValidation } from './use-identifier-validation'

View File

@@ -0,0 +1,131 @@
import { useCallback } from 'react'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import type { OutputConfig } from '@/stores/chat/store'
const logger = createLogger('ChatDeployment')
export type AuthType = 'public' | 'password' | 'email' | 'sso'
export interface ChatFormData {
identifier: string
title: string
description: string
authType: AuthType
password: string
emails: string[]
welcomeMessage: string
selectedOutputBlocks: string[]
}
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputConfigs: z
.array(
z.object({
blockId: z.string(),
path: z.string(),
})
)
.optional()
.default([]),
})
/**
* Parses output block selections into structured output configs
*/
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
return selectedOutputBlocks
.map((outputId) => {
const firstUnderscoreIndex = outputId.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
if (blockId && path) {
return { blockId, path }
}
}
return null
})
.filter((config): config is OutputConfig => config !== null)
}
/**
* Hook for deploying or updating a chat interface
*/
export function useChatDeployment() {
const deployChat = useCallback(
async (
workflowId: string,
formData: ChatFormData,
deploymentInfo: { apiKey: string } | null,
existingChatId?: string,
imageUrl?: string | null
): Promise<string> => {
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
const payload = {
workflowId,
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey: deploymentInfo?.apiKey,
deployApiEnabled: !existingChatId,
}
chatSchema.parse(payload)
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
const method = existingChatId ? 'PATCH' : 'POST'
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
}
if (!result.chatUrl) {
throw new Error('Response missing chatUrl')
}
logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
return result.chatUrl
},
[]
)
return { deployChat }
}

View File

@@ -1,10 +1,25 @@
import { useEffect, useRef, useState } from 'react'
const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/
const DEBOUNCE_MS = 500
interface IdentifierValidationState {
isChecking: boolean
error: string | null
isValid: boolean
}
/**
* Hook for validating chat identifier availability with debounced API checks
* @param identifier - The identifier to validate
* @param originalIdentifier - The original identifier when editing an existing chat
* @param isEditingExisting - Whether we're editing an existing chat deployment
*/
export function useIdentifierValidation(
identifier: string,
originalIdentifier?: string,
isEditingExisting?: boolean
) {
): IdentifierValidationState {
const [isChecking, setIsChecking] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isValid, setIsValid] = useState(false)
@@ -16,36 +31,29 @@ export function useIdentifierValidation(
clearTimeout(timeoutRef.current)
}
// Reset states immediately when identifier changes
setError(null)
setIsValid(false)
setIsChecking(false)
// Skip validation if empty
if (!identifier.trim()) {
return
}
// Skip validation if same as original (existing deployment)
if (originalIdentifier && identifier === originalIdentifier) {
setIsValid(true)
return
}
// If we're editing an existing deployment but originalIdentifier isn't available yet,
// assume it's valid and wait for the data to load
if (isEditingExisting && !originalIdentifier) {
setIsValid(true)
return
}
// Validate format first - client-side validation
if (!/^[a-z0-9-]+$/.test(identifier)) {
if (!IDENTIFIER_PATTERN.test(identifier)) {
setError('Identifier can only contain lowercase letters, numbers, and hyphens')
return
}
// Check availability with server
setIsChecking(true)
timeoutRef.current = setTimeout(async () => {
try {
@@ -64,13 +72,13 @@ export function useIdentifierValidation(
setError(null)
setIsValid(true)
}
} catch (error) {
} catch {
setError('Error checking identifier availability')
setIsValid(false)
} finally {
setIsChecking(false)
}
}, 500)
}, DEBOUNCE_MS)
return () => {
if (timeoutRef.current) {

View File

@@ -1,40 +0,0 @@
'use client'
import { cn } from '@/lib/core/utils/cn'
interface DeployStatusProps {
needsRedeployment: boolean
}
export function DeployStatus({ needsRedeployment }: DeployStatusProps) {
return (
<div className='flex items-center gap-2'>
<span className='font-medium text-[var(--text-secondary)] text-xs'>Status:</span>
<div className='flex items-center gap-1.5'>
<div className='relative flex items-center justify-center'>
{needsRedeployment ? (
<>
<div className='absolute h-3 w-3 animate-ping rounded-full bg-amber-500/20' />
<div className='relative h-2 w-2 rounded-full bg-amber-500' />
</>
) : (
<>
<div className='absolute h-3 w-3 animate-ping rounded-full bg-green-500/20' />
<div className='relative h-2 w-2 rounded-full bg-green-500' />
</>
)}
</div>
<span
className={cn(
'font-medium text-xs',
needsRedeployment
? 'text-amber-600 dark:text-amber-400'
: 'text-green-600 dark:text-green-400'
)}
>
{needsRedeployment ? 'Changes Detected' : 'Active'}
</span>
</div>
</div>
)
}

View File

@@ -1,121 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployedWorkflowCard')
interface DeployedWorkflowCardProps {
currentWorkflowState?: WorkflowState
activeDeployedWorkflowState?: WorkflowState
selectedDeployedWorkflowState?: WorkflowState
selectedVersionLabel?: string
className?: string
}
export function DeployedWorkflowCard({
currentWorkflowState,
activeDeployedWorkflowState,
selectedDeployedWorkflowState,
selectedVersionLabel,
className,
}: DeployedWorkflowCardProps) {
type View = 'current' | 'active' | 'selected'
const hasCurrent = !!currentWorkflowState
const hasActive = !!activeDeployedWorkflowState
const hasSelected = !!selectedDeployedWorkflowState
const [view, setView] = useState<View>(hasSelected ? 'selected' : 'active')
const workflowToShow =
view === 'current'
? currentWorkflowState
: view === 'active'
? activeDeployedWorkflowState
: selectedDeployedWorkflowState
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const previewKey = useMemo(() => {
return `${view}-preview-${activeWorkflowId}`
}, [view, activeWorkflowId])
return (
<Card className={cn('relative overflow-hidden', className)}>
<CardHeader
className={cn(
'sticky top-0 z-10 space-y-4 p-4',
'bg-background/70 dark:bg-background/50',
'border-border/30 border-b dark:border-border/20',
'shadow-sm'
)}
>
<div className='flex items-center justify-between'>
<h3 className='font-medium'>Workflow Preview</h3>
<div className='flex items-center gap-2'>
{/* Show Current only when no explicit version is selected */}
{hasCurrent && !hasSelected && (
<button
type='button'
className={cn(
'rounded px-2 py-1 text-xs',
view === 'current' ? 'bg-accent text-foreground' : 'text-muted-foreground'
)}
onClick={() => setView('current')}
>
Current
</button>
)}
{/* Always show Active Deployed */}
{hasActive && (
<button
type='button'
className={cn(
'rounded px-2 py-1 text-xs',
view === 'active' ? 'bg-accent text-foreground' : 'text-muted-foreground'
)}
onClick={() => setView('active')}
>
Active Deployed
</button>
)}
{/* If a specific version is selected, show its label */}
{hasSelected && (
<button
type='button'
className={cn(
'rounded px-2 py-1 text-xs',
view === 'selected' ? 'bg-accent text-foreground' : 'text-muted-foreground'
)}
onClick={() => setView('selected')}
>
{selectedVersionLabel || 'Selected Version'}
</button>
)}
</div>
</div>
</CardHeader>
<div className='h-px w-full bg-border shadow-sm' />
<CardContent className='p-0'>
{/* Workflow preview with fixed height */}
<div className='h-[500px] w-full'>
<WorkflowPreview
key={previewKey}
workflowState={workflowToShow as WorkflowState}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,170 +0,0 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/emcn'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console/logger'
import { DeployedWorkflowCard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-card'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployedWorkflowModal')
interface DeployedWorkflowModalProps {
isOpen: boolean
onClose: () => void
needsRedeployment: boolean
activeDeployedState?: WorkflowState
selectedDeployedState?: WorkflowState
selectedVersion?: number
onActivateVersion?: () => void
isActivating?: boolean
selectedVersionLabel?: string
workflowId: string
isSelectedVersionActive?: boolean
onLoadDeploymentComplete?: () => void
}
export function DeployedWorkflowModal({
isOpen,
onClose,
needsRedeployment,
activeDeployedState,
selectedDeployedState,
selectedVersion,
onActivateVersion,
isActivating,
selectedVersionLabel,
workflowId,
isSelectedVersionActive,
onLoadDeploymentComplete,
}: DeployedWorkflowModalProps) {
const [showRevertDialog, setShowRevertDialog] = useState(false)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get current workflow state to compare with deployed state
const currentWorkflowState = useWorkflowStore((state) => ({
blocks: activeWorkflowId ? mergeSubblockState(state.blocks, activeWorkflowId) : state.blocks,
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
}))
const handleRevert = async () => {
if (!activeWorkflowId) {
logger.error('Cannot revert: no active workflow ID')
return
}
try {
const versionToRevert = selectedVersion !== undefined ? selectedVersion : 'active'
const response = await fetch(
`/api/workflows/${workflowId}/deployments/${versionToRevert}/revert`,
{
method: 'POST',
}
)
if (!response.ok) {
throw new Error('Failed to revert to version')
}
setShowRevertDialog(false)
onClose()
onLoadDeploymentComplete?.()
} catch (error) {
logger.error('Failed to revert workflow:', error)
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className='max-h-[100vh] overflow-y-auto sm:max-w-[1100px]'
style={{ zIndex: 10000020 }}
hideCloseButton={true}
>
<div className='sr-only'>
<DialogHeader>
<DialogTitle>Deployed Workflow</DialogTitle>
</DialogHeader>
</div>
<DeployedWorkflowCard
currentWorkflowState={currentWorkflowState}
activeDeployedWorkflowState={activeDeployedState}
selectedDeployedWorkflowState={selectedDeployedState}
selectedVersionLabel={selectedVersionLabel}
/>
<div className='mt-1 flex justify-between'>
<div className='flex items-center gap-2'>
{onActivateVersion &&
(isSelectedVersionActive ? (
<div className='inline-flex items-center gap-2 rounded-md bg-emerald-500/10 px-2.5 py-1 font-medium text-emerald-600 text-xs dark:text-emerald-400'>
<span className='relative flex h-2 w-2 items-center justify-center'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-75' />
<span className='relative inline-flex h-2 w-2 rounded-full bg-emerald-500' />
</span>
Active
</div>
) : (
<div className='flex items-center gap-0'>
<Button
variant='outline'
disabled={!!isActivating}
onClick={() => onActivateVersion?.()}
>
{isActivating ? 'Activating…' : 'Activate'}
</Button>
</div>
))}
</div>
<div className='flex items-center gap-2'>
{(needsRedeployment || selectedVersion !== undefined) && (
<AlertDialog open={showRevertDialog} onOpenChange={setShowRevertDialog}>
<AlertDialogTrigger asChild>
<Button variant='outline'>Load Deployment</Button>
</AlertDialogTrigger>
<AlertDialogContent className='sm:max-w-[425px]'>
<AlertDialogHeader>
<AlertDialogTitle>Load this Deployment?</AlertDialogTitle>
<AlertDialogDescription>
This will replace your current workflow with the deployed version. Your
current changes will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevert}
className='bg-primary text-primary-foreground hover:bg-primary/90'
>
Load Deployment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button variant='outline' onClick={onClose}>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,203 +0,0 @@
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { ApiEndpoint } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api-endpoint'
import { DeployStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-status'
import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-modal'
import { ExampleCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/example-command'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface WorkflowDeploymentInfo {
isDeployed: boolean
deployedAt?: string
apiKey: string
endpoint: string
exampleCommand: string
needsRedeployment: boolean
}
interface DeploymentInfoProps {
isLoading: boolean
deploymentInfo: WorkflowDeploymentInfo | null
onRedeploy: () => void
onUndeploy: () => void
isSubmitting: boolean
isUndeploying: boolean
workflowId: string | null
deployedState: WorkflowState
isLoadingDeployedState: boolean
getInputFormatExample?: (includeStreaming?: boolean) => string
selectedStreamingOutputs: string[]
onSelectedStreamingOutputsChange: (outputs: string[]) => void
onLoadDeploymentComplete: () => void
}
export function DeploymentInfo({
isLoading,
deploymentInfo,
onRedeploy,
onUndeploy,
isSubmitting,
isUndeploying,
workflowId,
deployedState,
isLoadingDeployedState,
getInputFormatExample,
selectedStreamingOutputs,
onSelectedStreamingOutputsChange,
onLoadDeploymentComplete,
}: DeploymentInfoProps) {
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
const [showUndeployModal, setShowUndeployModal] = useState(false)
const handleViewDeployed = async () => {
if (!workflowId) {
return
}
// If deployedState is already loaded, use it directly
if (deployedState) {
setIsViewingDeployed(true)
return
}
}
if (isLoading || !deploymentInfo) {
return (
<div className='space-y-4 overflow-y-auto px-1'>
{/* API Endpoint skeleton */}
<div className='space-y-3'>
<Skeleton className='h-5 w-28' />
<Skeleton className='h-10 w-full' />
</div>
{/* API Key skeleton */}
<div className='space-y-3'>
<Skeleton className='h-5 w-20' />
<Skeleton className='h-10 w-full' />
</div>
{/* Example Command skeleton */}
<div className='space-y-3'>
<Skeleton className='h-5 w-36' />
<Skeleton className='h-24 w-full rounded-md' />
</div>
{/* Deploy Status and buttons skeleton */}
<div className='mt-4 flex items-center justify-between pt-2'>
<Skeleton className='h-6 w-32' />
<div className='flex gap-2'>
<Skeleton className='h-9 w-24' />
<Skeleton className='h-9 w-24' />
</div>
</div>
</div>
)
}
return (
<>
<div className='space-y-4 overflow-y-auto px-1'>
<div className='space-y-4'>
<ApiEndpoint endpoint={deploymentInfo.endpoint} />
<ExampleCommand
command={deploymentInfo.exampleCommand}
apiKey={deploymentInfo.apiKey}
endpoint={deploymentInfo.endpoint}
getInputFormatExample={getInputFormatExample}
workflowId={workflowId}
selectedStreamingOutputs={selectedStreamingOutputs}
onSelectedStreamingOutputsChange={onSelectedStreamingOutputsChange}
/>
</div>
<div className='mt-4 flex items-center justify-between pt-2'>
<DeployStatus needsRedeployment={deploymentInfo.needsRedeployment} />
<div className='flex gap-2'>
<Button variant='outline' onClick={handleViewDeployed} className='h-8 text-xs'>
View Deployment
</Button>
{deploymentInfo.needsRedeployment && (
<Button
variant='primary'
onClick={onRedeploy}
disabled={isSubmitting}
className='h-8 text-xs'
>
{isSubmitting ? <Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' /> : null}
{isSubmitting ? 'Redeploying...' : 'Redeploy'}
</Button>
)}
<Button
variant='outline'
disabled={isUndeploying}
className='h-8 text-xs'
onClick={() => setShowUndeployModal(true)}
>
{isUndeploying ? <Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' /> : null}
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</div>
</div>
</div>
{deployedState && workflowId && (
<DeployedWorkflowModal
isOpen={isViewingDeployed}
onClose={() => setIsViewingDeployed(false)}
needsRedeployment={deploymentInfo.needsRedeployment}
activeDeployedState={deployedState}
workflowId={workflowId}
onLoadDeploymentComplete={onLoadDeploymentComplete}
/>
)}
{/* Undeploy Confirmation Modal */}
<Modal open={showUndeployModal} onOpenChange={setShowUndeployModal}>
<ModalContent>
<ModalHeader>
<ModalTitle>Undeploy API</ModalTitle>
<ModalDescription>
Are you sure you want to undeploy this workflow?{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This will remove the API endpoint and make it unavailable to external users.{' '}
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
className='h-[32px] px-[12px]'
variant='outline'
onClick={() => setShowUndeployModal(false)}
disabled={isUndeploying}
>
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
onClick={() => {
onUndeploy()
setShowUndeployModal(false)
}}
disabled={isUndeploying}
>
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,231 +0,0 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Button, Label } from '@/components/emcn'
import { Button as UIButton } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
interface ExampleCommandProps {
command: string
apiKey: string
endpoint: string
showLabel?: boolean
getInputFormatExample?: (includeStreaming?: boolean) => string
workflowId: string | null
selectedStreamingOutputs: string[]
onSelectedStreamingOutputsChange: (outputs: string[]) => void
}
type ExampleMode = 'sync' | 'async' | 'stream'
type ExampleType = 'execute' | 'status' | 'rate-limits'
export function ExampleCommand({
command,
apiKey,
endpoint,
showLabel = true,
getInputFormatExample,
workflowId,
selectedStreamingOutputs,
onSelectedStreamingOutputsChange,
}: ExampleCommandProps) {
const [mode, setMode] = useState<ExampleMode>('sync')
const [exampleType, setExampleType] = useState<ExampleType>('execute')
const isAsyncEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED'))
const formatCurlCommand = (command: string, apiKey: string) => {
if (!command.includes('curl')) return command
const sanitizedCommand = command.replace(apiKey, '$SIM_API_KEY')
return sanitizedCommand
.replace(' -H ', '\n -H ')
.replace(' -d ', '\n -d ')
.replace(' http', '\n http')
}
const getActualCommand = () => {
const displayCommand = getDisplayCommand()
return displayCommand
.replace(/\\\n\s*/g, ' ') // Remove backslash + newline + whitespace
.replace(/\n\s*/g, ' ') // Remove any remaining newlines + whitespace
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
.trim()
}
const getDisplayCommand = () => {
const baseEndpoint = endpoint.replace(apiKey, '$SIM_API_KEY')
const inputExample = getInputFormatExample
? getInputFormatExample(false)
: ' -d \'{"input": "your data here"}\''
const addStreamingParams = (dashD: string) => {
const match = dashD.match(/-d\s*'([\s\S]*)'/)
if (!match) {
const payload: Record<string, any> = { stream: true }
if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) {
payload.selectedOutputs = selectedStreamingOutputs
}
return ` -d '${JSON.stringify(payload)}'`
}
try {
const payload = JSON.parse(match[1]) as Record<string, any>
payload.stream = true
if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) {
payload.selectedOutputs = selectedStreamingOutputs
}
return ` -d '${JSON.stringify(payload)}'`
} catch {
return dashD
}
}
switch (mode) {
case 'sync':
if (getInputFormatExample) {
const syncInputExample = getInputFormatExample(false)
return `curl -X POST \\\n -H "X-API-Key: $SIM_API_KEY" \\\n -H "Content-Type: application/json"${syncInputExample} \\\n ${baseEndpoint}`
}
return formatCurlCommand(command, apiKey)
case 'stream': {
const streamDashD = addStreamingParams(inputExample)
return `curl -X POST \\\n -H "X-API-Key: $SIM_API_KEY" \\\n -H "Content-Type: application/json"${streamDashD} \\\n ${baseEndpoint}`
}
case 'async':
switch (exampleType) {
case 'execute':
return `curl -X POST \\\n -H "X-API-Key: $SIM_API_KEY" \\\n -H "Content-Type: application/json" \\\n -H "X-Execution-Mode: async"${inputExample} \\\n ${baseEndpoint}`
case 'status': {
const baseUrl = baseEndpoint.split('/api/workflows/')[0]
return `curl -H "X-API-Key: $SIM_API_KEY" \\\n ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION`
}
case 'rate-limits': {
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
return `curl -H "X-API-Key: $SIM_API_KEY" \\\n ${baseUrlForRateLimit}/api/users/me/usage-limits`
}
default:
return formatCurlCommand(command, apiKey)
}
default:
return formatCurlCommand(command, apiKey)
}
}
const getExampleTitle = () => {
switch (exampleType) {
case 'execute':
return 'Async Execution'
case 'status':
return 'Check Job Status'
case 'rate-limits':
return 'Rate Limits & Usage'
default:
return 'Async Execution'
}
}
return (
<div className='space-y-4'>
{/* Example Command */}
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
<div className='flex items-center gap-1'>
<Button
variant={mode === 'sync' ? 'active' : 'default'}
onClick={() => setMode('sync')}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
Sync
</Button>
<Button
variant={mode === 'stream' ? 'active' : 'default'}
onClick={() => setMode('stream')}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
Stream
</Button>
{isAsyncEnabled && (
<>
<Button
variant={mode === 'async' ? 'active' : 'default'}
onClick={() => setMode('async')}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
Async
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<UIButton
variant='outline'
size='sm'
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
disabled={mode === 'sync' || mode === 'stream'}
>
<span className='truncate'>{getExampleTitle()}</span>
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
</UIButton>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='z-[10000050]'>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('execute')}
>
Async Execution
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('status')}
>
Check Job Status
</DropdownMenuItem>
<DropdownMenuItem
className='cursor-pointer'
onClick={() => setExampleType('rate-limits')}
>
Rate Limits & Usage
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
{/* Output selector for Stream mode */}
{mode === 'stream' && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>Select outputs to stream</div>
<OutputSelect
workflowId={workflowId}
selectedOutputs={selectedStreamingOutputs}
onOutputSelect={onSelectedStreamingOutputsChange}
placeholder='Select outputs for streaming'
valueMode='label'
/>
</div>
)}
<div className='group relative overflow-x-auto rounded-md border bg-background transition-colors hover:bg-muted/50'>
<pre className='whitespace-pre p-3 font-mono text-xs'>{getDisplayCommand()}</pre>
<CopyButton text={getActualCommand()} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,333 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
const logger = createLogger('Versions')
/** Shared styling constants aligned with terminal component */
const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[12px]'
const COLUMN_BASE_CLASS = 'flex-shrink-0'
/** Column width configuration */
const COLUMN_WIDTHS = {
VERSION: 'w-[180px]',
DEPLOYED_BY: 'w-[140px]',
TIMESTAMP: 'flex-1',
ACTIONS: 'w-[32px]',
} as const
interface VersionsProps {
workflowId: string | null
versions: WorkflowDeploymentVersionResponse[]
versionsLoading: boolean
selectedVersion: number | null
onSelectVersion: (version: number | null) => void
onPromoteToLive: (version: number) => void
onLoadDeployment: (version: number) => void
fetchVersions: () => Promise<void>
}
/**
* Formats a timestamp into a readable string.
* @param value - The date string or Date object to format
* @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
*/
const formatDate = (value: string | Date): string => {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const timePart = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short',
})
const datePart = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
return `${timePart} on ${datePart}`
}
/**
* Displays a list of workflow deployment versions with actions
* for viewing, promoting to live, renaming, and loading deployments.
*/
export function Versions({
workflowId,
versions,
versionsLoading,
selectedVersion,
onSelectVersion,
onPromoteToLive,
onLoadDeployment,
fetchVersions,
}: VersionsProps) {
const [editingVersion, setEditingVersion] = useState<number | null>(null)
const [editValue, setEditValue] = useState('')
const [isRenaming, setIsRenaming] = useState(false)
const [openDropdown, setOpenDropdown] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editingVersion !== null && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editingVersion])
const handleStartRename = (version: number, currentName: string | null | undefined) => {
setOpenDropdown(null)
setEditingVersion(version)
setEditValue(currentName || `v${version}`)
}
const handleSaveRename = async (version: number) => {
if (!workflowId || !editValue.trim()) {
setEditingVersion(null)
return
}
const currentVersion = versions.find((v) => v.version === version)
const currentName = currentVersion?.name || `v${version}`
if (editValue.trim() === currentName) {
setEditingVersion(null)
return
}
setIsRenaming(true)
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editValue.trim() }),
})
if (res.ok) {
await fetchVersions()
setEditingVersion(null)
} else {
logger.error('Failed to rename version')
}
} catch (error) {
logger.error('Error renaming version:', error)
} finally {
setIsRenaming(false)
}
}
const handleCancelRename = () => {
setEditingVersion(null)
setEditValue('')
}
const handleRowClick = (version: number) => {
if (editingVersion === version) return
onSelectVersion(selectedVersion === version ? null : version)
}
const handlePromote = (version: number) => {
setOpenDropdown(null)
onPromoteToLive(version)
}
const handleLoadDeployment = (version: number) => {
setOpenDropdown(null)
onLoadDeployment(version)
}
if (versionsLoading && versions.length === 0) {
return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
<div className='flex h-[30px] items-center bg-[var(--surface-1)] px-[16px]'>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS)}>
<Skeleton className='h-[12px] w-[50px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS)}>
<Skeleton className='h-[12px] w-[76px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<Skeleton className='h-[12px] w-[68px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS)} />
</div>
<div className='bg-[var(--surface-2)]'>
{[0, 1].map((i) => (
<div key={i} className='flex h-[36px] items-center px-[16px]'>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS, 'min-w-0 pr-[8px]')}>
<div className='flex items-center gap-[16px]'>
<Skeleton className='h-[6px] w-[6px] rounded-[2px]' />
<Skeleton className='h-[12px] w-[60px]' />
</div>
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS, 'min-w-0')}>
<Skeleton className='h-[12px] w-[80px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<Skeleton className='h-[12px] w-[160px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}>
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
</div>
</div>
))}
</div>
</div>
)
}
if (versions.length === 0) {
return (
<div className='flex h-[120px] items-center justify-center rounded-[4px] border border-[var(--border)] text-[#8D8D8D] text-[13px]'>
No deployments yet
</div>
)
}
return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
<div className='flex h-[30px] items-center bg-[var(--surface-1)] px-[16px]'>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS)}>
<span className={HEADER_TEXT_CLASS}>Version</span>
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS)}>
<span className={HEADER_TEXT_CLASS}>Deployed by</span>
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<span className={HEADER_TEXT_CLASS}>Timestamp</span>
</div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS)} />
</div>
<div className='bg-[var(--surface-2)]'>
{versions.map((v) => {
const isSelected = selectedVersion === v.version
return (
<div
key={v.id}
className={clsx(
'flex h-[36px] cursor-pointer items-center px-[16px] transition-colors',
isSelected
? 'bg-[var(--accent)]/10 hover:bg-[var(--accent)]/15'
: 'hover:bg-[var(--border)]'
)}
onClick={() => handleRowClick(v.version)}
>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS, 'min-w-0 pr-[8px]')}>
<div className='flex items-center gap-[16px]'>
<div
className={clsx(
'h-[6px] w-[6px] shrink-0 rounded-[2px]',
v.isActive ? 'bg-[#4ADE80]' : 'bg-[#B7B7B7]'
)}
title={v.isActive ? 'Live' : 'Inactive'}
/>
{editingVersion === v.version ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveRename(v.version)
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelRename()
}
}}
onClick={(e) => e.stopPropagation()}
onBlur={() => handleSaveRename(v.version)}
className={clsx(
'w-full border-0 bg-transparent p-0 font-medium text-[12px] leading-5 outline-none',
'text-[var(--text-primary)] focus:outline-none focus:ring-0'
)}
maxLength={100}
disabled={isRenaming}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<span
className={clsx('block flex items-center gap-[4px] truncate', ROW_TEXT_CLASS)}
>
<span className='truncate'>{v.name || `v${v.version}`}</span>
{v.isActive && <span className='text-[var(--text-tertiary)]'> (live)</span>}
{isSelected && (
<span className='text-[var(--text-tertiary)]'> (selected)</span>
)}
</span>
)}
</div>
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS, 'min-w-0')}>
<span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
>
{v.deployedBy || 'Unknown'}
</span>
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
>
{formatDate(v.createdAt)}
</span>
</div>
<div
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}
onClick={(e) => e.stopPropagation()}
>
<Popover
open={openDropdown === v.version}
onOpenChange={(open) => setOpenDropdown(open ? v.version : null)}
>
<PopoverTrigger asChild>
<Button variant='ghost' className='!p-1'>
<MoreVertical className='h-3.5 w-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' sideOffset={4} minWidth={160} maxWidth={200} border>
<PopoverItem onClick={() => handleStartRename(v.version, v.name)}>
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
{!v.isActive && (
<PopoverItem onClick={() => handlePromote(v.version)}>
<RotateCcw className='h-3 w-3' />
<span>Promote to live</span>
</PopoverItem>
)}
<PopoverItem onClick={() => handleLoadDeployment(v.version)}>
<SendToBack className='h-3 w-3' />
<span>Load deployment</span>
</PopoverItem>
</PopoverContent>
</Popover>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,312 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Label } from '@/components/emcn'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components/modal/modal'
import { Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { Versions } from './components'
const logger = createLogger('GeneralDeploy')
interface GeneralDeployProps {
workflowId: string | null
deployedState: WorkflowState
isLoadingDeployedState: boolean
versions: WorkflowDeploymentVersionResponse[]
versionsLoading: boolean
onPromoteToLive: (version: number) => Promise<void>
onLoadDeploymentComplete: () => void
fetchVersions: () => Promise<void>
}
type PreviewMode = 'active' | 'selected'
/**
* General deployment tab content displaying live workflow preview and version history.
*/
export function GeneralDeploy({
workflowId,
deployedState,
isLoadingDeployedState,
versions,
versionsLoading,
onPromoteToLive,
onLoadDeploymentComplete,
fetchVersions,
}: GeneralDeployProps) {
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
const versionCacheRef = useRef<Map<number, WorkflowState>>(new Map())
const [, forceUpdate] = useState({})
const selectedVersionInfo = versions.find((v) => v.version === selectedVersion)
const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote)
const versionToLoadInfo = versions.find((v) => v.version === versionToLoad)
const cachedSelectedState =
selectedVersion !== null ? versionCacheRef.current.get(selectedVersion) : null
const fetchSelectedVersionState = useCallback(
async (version: number) => {
if (!workflowId) return
if (versionCacheRef.current.has(version)) return
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
if (res.ok) {
const data = await res.json()
if (data.deployedState) {
versionCacheRef.current.set(version, data.deployedState)
forceUpdate({})
}
}
} catch (error) {
logger.error('Error fetching version state:', error)
}
},
[workflowId]
)
useEffect(() => {
if (selectedVersion !== null) {
fetchSelectedVersionState(selectedVersion)
setPreviewMode('selected')
} else {
setPreviewMode('active')
}
}, [selectedVersion, fetchSelectedVersionState])
const handleSelectVersion = useCallback((version: number | null) => {
setSelectedVersion(version)
}, [])
const handleLoadDeployment = useCallback((version: number) => {
setVersionToLoad(version)
setShowLoadDialog(true)
}, [])
const handlePromoteToLive = useCallback((version: number) => {
setVersionToPromote(version)
setShowPromoteDialog(true)
}, [])
const confirmLoadDeployment = async () => {
if (!workflowId || versionToLoad === null) return
// Close modal immediately for snappy UX
setShowLoadDialog(false)
const version = versionToLoad
setVersionToLoad(null)
try {
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to load deployment')
}
onLoadDeploymentComplete()
} catch (error) {
logger.error('Failed to load deployment:', error)
}
}
const confirmPromoteToLive = async () => {
if (versionToPromote === null) return
// Close modal immediately for snappy UX
setShowPromoteDialog(false)
const version = versionToPromote
setVersionToPromote(null)
try {
await onPromoteToLive(version)
} catch (error) {
logger.error('Failed to promote version:', error)
}
}
const workflowToShow = useMemo(() => {
if (previewMode === 'selected' && cachedSelectedState) {
return cachedSelectedState
}
return deployedState
}, [previewMode, cachedSelectedState, deployedState])
const showToggle = selectedVersion !== null && deployedState
// Only show skeleton on initial load when we have no deployed data
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
if (showLoadingSkeleton) {
return (
<div className='space-y-[12px]'>
<div>
<div className='relative mb-[6.5px]'>
<Skeleton className='h-[16px] w-[90px]' />
</div>
<div className='h-[260px] w-full overflow-hidden rounded-[4px] border border-[var(--border)]'>
<Skeleton className='h-full w-full rounded-none' />
</div>
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[60px]' />
<div className='h-[120px] w-full overflow-hidden rounded-[4px] border border-[var(--border)]'>
<Skeleton className='h-full w-full rounded-none' />
</div>
</div>
</div>
)
}
return (
<>
<div className='space-y-[12px]'>
<div>
<div className='relative mb-[6.5px]'>
<Label className='block truncate pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
{previewMode === 'selected' && selectedVersionInfo
? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'}
</Label>
<div
className='absolute top-[-5px] right-0 inline-flex gap-[2px]'
style={{ visibility: showToggle ? 'visible' : 'hidden' }}
>
<Button
type='button'
variant={previewMode === 'active' ? 'active' : 'default'}
onClick={() => setPreviewMode('active')}
className='rounded-r-none px-[8px] py-[4px] text-[12px]'
>
Live
</Button>
<Button
type='button'
variant={previewMode === 'selected' ? 'active' : 'default'}
onClick={() => setPreviewMode('selected')}
className='truncate rounded-l-none px-[8px] py-[4px] text-[12px]'
>
{selectedVersionInfo?.name || `v${selectedVersion}`}
</Button>
</div>
</div>
<div
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
onWheelCapture={(e) => {
if (e.ctrlKey || e.metaKey) return
e.stopPropagation()
}}
>
{workflowToShow ? (
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
) : (
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
Deploy your workflow to see a preview
</div>
)}
</div>
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Versions
</Label>
<Versions
workflowId={workflowId}
versions={versions}
versionsLoading={versionsLoading}
selectedVersion={selectedVersion}
onSelectVersion={handleSelectVersion}
onPromoteToLive={handlePromoteToLive}
onLoadDeployment={handleLoadDeployment}
fetchVersions={fetchVersions}
/>
</div>
</div>
<Modal open={showLoadDialog} onOpenChange={setShowLoadDialog}>
<ModalContent className='w-[400px]'>
<ModalHeader>Load Deployment</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to load{' '}
<span className='font-medium text-[var(--text-primary)]'>
{versionToLoadInfo?.name || `v${versionToLoad}`}
</span>
?{' '}
<span className='text-[var(--text-error)]'>
This will replace your current workflow with the deployed version.
</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowLoadDialog(false)}>
Cancel
</Button>
<Button
variant='primary'
onClick={confirmLoadDeployment}
className='bg-[var(--text-error)] text-white hover:bg-[var(--text-error)]'
>
Load deployment
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={showPromoteDialog} onOpenChange={setShowPromoteDialog}>
<ModalContent className='w-[400px]'>
<ModalHeader>Promote to live</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to promote{' '}
<span className='font-medium text-[var(--text-primary)]'>
{versionToPromoteInfo?.name || `v${versionToPromote}`}
</span>{' '}
to live?{' '}
<span className='text-[var(--text-primary)]'>
This version will become the active deployment and serve all API requests.
</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowPromoteDialog(false)}>
Cancel
</Button>
<Button variant='primary' onClick={confirmPromoteToLive}>
Promote to live
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,78 +0,0 @@
import { useEffect } from 'react'
import { Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { useIdentifierValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation'
interface IdentifierInputProps {
value: string
onChange: (value: string) => void
originalIdentifier?: string
disabled?: boolean
onValidationChange?: (isValid: boolean) => void
isEditingExisting?: boolean
}
const getDomainPrefix = (() => {
const prefix = `${getEmailDomain()}/chat/`
return () => prefix
})()
export function IdentifierInput({
value,
onChange,
originalIdentifier,
disabled = false,
onValidationChange,
isEditingExisting = false,
}: IdentifierInputProps) {
const { isChecking, error, isValid } = useIdentifierValidation(
value,
originalIdentifier,
isEditingExisting
)
// Notify parent of validation changes
useEffect(() => {
onValidationChange?.(isValid)
}, [isValid, onValidationChange])
const handleChange = (newValue: string) => {
const lowercaseValue = newValue.toLowerCase()
onChange(lowercaseValue)
}
return (
<div className='space-y-2'>
<Label htmlFor='identifier' className='font-medium text-sm'>
Identifier
</Label>
<div className='relative flex items-stretch'>
<div className='flex items-center whitespace-nowrap rounded-l-[4px] border border-[var(--surface-11)] border-r-0 bg-[var(--surface-6)] px-[8px] py-[6px] font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-9)]'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
<Input
id='identifier'
placeholder='company-name'
value={value}
onChange={(e) => handleChange(e.target.value)}
required
disabled={disabled}
className={cn(
'rounded-l-none border-l-0',
isChecking && 'pr-8',
error && 'border-destructive'
)}
/>
{isChecking && (
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[var(--brand-primary-hex)]' />
</div>
)}
</div>
</div>
{error && <p className='mt-1 text-destructive text-sm'>{error}</p>}
</div>
)
}

View File

@@ -1,81 +0,0 @@
import { Label } from '@/components/emcn'
import { getBaseDomain, getEmailDomain } from '@/lib/core/utils/urls'
interface ExistingChat {
id: string
identifier: string
title: string
description: string
authType: 'public' | 'password' | 'email'
allowedEmails: string[]
outputConfigs: Array<{ blockId: string; path: string }>
customizations?: {
welcomeMessage?: string
}
isActive: boolean
}
interface SuccessViewProps {
deployedUrl: string
existingChat: ExistingChat | null
onDelete?: () => void
onUpdate?: () => void
}
export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: SuccessViewProps) {
const url = new URL(deployedUrl)
const hostname = url.hostname
const isDevelopmentUrl = hostname.includes('localhost')
// Extract identifier from path-based URL format (e.g., sim.ai/chat/identifier)
const pathParts = url.pathname.split('/')
const identifierPart = pathParts[2] || '' // /chat/identifier
let domainPrefix
if (isDevelopmentUrl) {
const baseDomain = getBaseDomain()
const baseHost = baseDomain.split(':')[0]
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
domainPrefix = `${baseHost}:${port}/chat/`
} else {
domainPrefix = `${getEmailDomain()}/chat/`
}
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label className='font-medium text-sm'>
Chat {existingChat ? 'Update' : 'Deployment'} Successful
</Label>
<div className='relative flex items-center rounded-md ring-offset-background'>
<div className='flex h-10 items-center whitespace-nowrap rounded-l-md border border-r-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
{domainPrefix}
</div>
<a
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='flex h-10 flex-1 items-center break-all rounded-r-md border border-l-0 p-2 font-medium text-foreground text-sm'
>
{identifierPart}
</a>
</div>
<p className='text-muted-foreground text-xs'>
Your chat is now live at{' '}
<a
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='text-foreground hover:underline'
>
this URL
</a>
</p>
</div>
{/* Hidden triggers for modal footer buttons */}
<button type='button' data-delete-trigger onClick={onDelete} style={{ display: 'none' }} />
<button type='button' data-update-trigger onClick={onUpdate} style={{ display: 'none' }} />
</div>
)
}

View File

@@ -1,489 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { CheckCircle2, Loader2, Plus } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Badge, Button, Input, Textarea, Trash } from '@/components/emcn'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { TagInput } from '@/components/ui/tag-input'
import { useSession } from '@/lib/auth/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
useCreateTemplate,
useDeleteTemplate,
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
const templateSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Max 100 characters'),
tagline: z.string().max(500, 'Max 500 characters').optional(),
about: z.string().optional(), // Markdown long description
creatorId: z.string().optional(), // Creator profile ID
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
})
type TemplateFormData = z.infer<typeof templateSchema>
interface CreatorOption {
id: string
name: string
referenceType: 'user' | 'organization'
referenceId: string
}
interface TemplateDeployProps {
workflowId: string
onDeploymentComplete?: () => void
}
export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDeployProps) {
const { data: session } = useSession()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [showPreviewDialog, setShowPreviewDialog] = useState(false)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
},
})
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
setLoadingCreators(true)
try {
const response = await fetch('/api/creators')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
return profiles
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
}
return []
}
useEffect(() => {
fetchCreatorOptions()
}, [session?.user?.id])
useEffect(() => {
const currentCreatorId = form.getValues('creatorId')
if (creatorOptions.length === 1 && !currentCreatorId) {
form.setValue('creatorId', creatorOptions[0].id)
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
}
}, [creatorOptions, form])
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
await fetchCreatorOptions()
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
}, 100)
}
window.addEventListener('creator-profile-saved', handleCreatorProfileSaved)
return () => {
window.removeEventListener('creator-profile-saved', handleCreatorProfileSaved)
}
}, [])
useEffect(() => {
if (existingTemplate) {
const tagline = existingTemplate.details?.tagline || ''
const about = existingTemplate.details?.about || ''
form.reset({
name: existingTemplate.name,
tagline: tagline,
about: about,
creatorId: existingTemplate.creatorId || undefined,
tags: existingTemplate.tags || [],
})
}
}, [existingTemplate, form])
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
logger.error('User not authenticated')
return
}
try {
const templateData = {
name: data.name,
details: {
tagline: data.tagline || '',
about: data.about || '',
},
creatorId: data.creatorId || undefined,
tags: data.tags || [],
}
if (existingTemplate) {
await updateMutation.mutateAsync({
id: existingTemplate.id,
data: {
...templateData,
updateState: true,
},
})
} else {
await createMutation.mutateAsync({ ...templateData, workflowId })
}
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
}
}
const handleDelete = async () => {
if (!existingTemplate) return
try {
await deleteMutation.mutateAsync(existingTemplate.id)
setShowDeleteDialog(false)
form.reset({
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
})
} catch (error) {
logger.error('Error deleting template:', error)
}
}
if (isLoadingTemplate) {
return (
<div className='flex h-64 items-center justify-center'>
<Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />
</div>
)
}
return (
<div className='space-y-4'>
{existingTemplate && (
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] px-[16px] py-[12px]'>
<div className='flex items-center gap-[12px]'>
<CheckCircle2 className='h-[16px] w-[16px] text-green-600 dark:text-green-400' />
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
Template Connected
</span>
{existingTemplate.status === 'pending' && (
<Badge
variant='outline'
className='border-yellow-300 bg-yellow-100 text-yellow-700 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
>
Under Review
</Badge>
)}
{existingTemplate.status === 'approved' && existingTemplate.views > 0 && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{existingTemplate.views} views
{existingTemplate.stars > 0 && `${existingTemplate.stars} stars`}
</span>
)}
</div>
</div>
<Button
type='button'
variant='ghost'
onClick={() => setShowDeleteDialog(true)}
className='h-[32px] px-[8px] text-[var(--text-muted)] hover:text-red-600 dark:hover:text-red-400'
>
<Trash className='h-[14px] w-[14px]' />
</Button>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Template Name</FormLabel>
<FormControl>
<Input placeholder='My Awesome Template' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tagline'
render={({ field }) => (
<FormItem>
<FormLabel>Tagline</FormLabel>
<FormControl>
<Input placeholder='Brief description of what this template does' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='about'
render={({ field }) => (
<FormItem>
<FormLabel>About (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder='Detailed description (supports Markdown)'
className='min-h-[150px] resize-none'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='creatorId'
render={({ field }) => (
<FormItem>
<FormLabel>Creator Profile</FormLabel>
{creatorOptions.length === 0 && !loadingCreators ? (
<div className='flex items-center gap-2'>
<Button
type='button'
variant='outline'
onClick={() => {
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,
})
}
}}
className='gap-[8px]'
>
<Plus className='h-[14px] w-[14px] text-[var(--text-muted)]' />
<span className='text-[var(--text-muted)]'>Create a Creator Profile</span>
</Button>
</div>
) : (
<Select
onValueChange={field.onChange}
value={field.value}
disabled={loadingCreators}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={loadingCreators ? 'Loading...' : 'Select creator profile'}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{creatorOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tags'
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<TagInput
value={field.value || []}
onChange={field.onChange}
placeholder='Type and press Enter to add tags'
maxTags={10}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</FormControl>
<p className='text-muted-foreground text-xs'>
Add up to 10 tags to help users discover your template
</p>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end gap-[8px] border-[var(--border)] border-t pt-[16px]'>
{existingTemplate && (
<Button
type='button'
variant='outline'
onClick={() => setShowPreviewDialog(true)}
disabled={!existingTemplate?.state}
>
View Current
</Button>
)}
<Button
type='submit'
variant='primary'
disabled={
createMutation.isPending || updateMutation.isPending || !form.formState.isValid
}
>
{createMutation.isPending || updateMutation.isPending ? (
<>
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
{existingTemplate ? 'Updating...' : 'Publishing...'}
</>
) : existingTemplate ? (
'Update Template'
) : (
'Publish Template'
)}
</Button>
</div>
</form>
</Form>
{showDeleteDialog && (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
<div className='w-full max-w-md rounded-[8px] bg-[var(--surface-3)] p-[24px] shadow-lg'>
<h3 className='mb-[16px] font-semibold text-[18px] text-[var(--text-primary)]'>
Delete Template?
</h3>
<p className='mb-[24px] text-[14px] text-[var(--text-secondary)]'>
This will permanently delete your template. This action cannot be undone.
</p>
<div className='flex justify-end gap-[8px]'>
<Button variant='outline' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className='bg-red-600 text-white hover:bg-red-700'
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
</div>
)}
{/* Template State Preview Dialog */}
<Dialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog}>
<DialogContent className='max-h-[80vh] max-w-5xl overflow-auto'>
<DialogHeader>
<DialogTitle>Published Template Preview</DialogTitle>
</DialogHeader>
{showPreviewDialog && (
<div className='mt-4'>
{(() => {
if (!existingTemplate?.state || !existingTemplate.state.blocks) {
return (
<div className='flex flex-col items-center gap-4 py-8'>
<div className='text-center text-muted-foreground'>
<p className='mb-2'>No template state available yet.</p>
<p className='text-sm'>
Click "Update Template" to capture the current workflow state.
</p>
</div>
</div>
)
}
const workflowState: WorkflowState = {
blocks: existingTemplate.state.blocks || {},
edges: existingTemplate.state.edges || [],
loops: existingTemplate.state.loops || {},
parallels: existingTemplate.state.parallels || {},
lastSaved: existingTemplate.state.lastSaved || Date.now(),
}
return (
<div className='h-[500px] w-full'>
<WorkflowPreview
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
showSubBlocks={true}
height='100%'
width='100%'
/>
</div>
)
})()}
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,451 @@
'use client'
import { useEffect, useState } from 'react'
import { Loader2, Plus } from 'lucide-react'
import { Button, Combobox, Input, Label, Textarea } from '@/components/emcn'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components/modal/modal'
import { Skeleton, TagInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
useCreateTemplate,
useDeleteTemplate,
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
interface TemplateFormData {
name: string
tagline: string
about: string
creatorId: string
tags: string[]
}
const initialFormData: TemplateFormData = {
name: '',
tagline: '',
about: '',
creatorId: '',
tags: [],
}
interface CreatorOption {
id: string
name: string
referenceType: 'user' | 'organization'
referenceId: string
}
interface TemplateStatus {
status: 'pending' | 'approved' | 'rejected' | null
views?: number
stars?: number
}
interface TemplateDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
onExistingTemplateChange?: (exists: boolean) => void
onTemplateStatusChange?: (status: TemplateStatus | null) => void
}
export function TemplateDeploy({
workflowId,
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
onExistingTemplateChange,
onTemplateStatusChange,
}: TemplateDeployProps) {
const { data: session } = useSession()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
const isSubmitting = createMutation.isPending || updateMutation.isPending
const isFormValid = formData.name.trim().length > 0 && formData.name.length <= 100
const updateField = <K extends keyof TemplateFormData>(field: K, value: TemplateFormData[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
useEffect(() => {
onValidationChange?.(isFormValid)
}, [isFormValid, onValidationChange])
useEffect(() => {
onSubmittingChange?.(isSubmitting)
}, [isSubmitting, onSubmittingChange])
useEffect(() => {
onExistingTemplateChange?.(!!existingTemplate)
}, [existingTemplate, onExistingTemplateChange])
useEffect(() => {
if (existingTemplate) {
onTemplateStatusChange?.({
status: existingTemplate.status as 'pending' | 'approved' | 'rejected',
views: existingTemplate.views,
stars: existingTemplate.stars,
})
} else {
onTemplateStatusChange?.(null)
}
}, [existingTemplate, onTemplateStatusChange])
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
setLoadingCreators(true)
try {
const response = await fetch('/api/creator-profiles')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
return profiles
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
}
return []
}
useEffect(() => {
fetchCreatorOptions()
}, [session?.user?.id])
useEffect(() => {
if (creatorOptions.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorOptions[0].id)
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
}
}, [creatorOptions, formData.creatorId])
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
await fetchCreatorOptions()
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
}, 100)
}
window.addEventListener('creator-profile-saved', handleCreatorProfileSaved)
return () => {
window.removeEventListener('creator-profile-saved', handleCreatorProfileSaved)
}
}, [])
useEffect(() => {
if (existingTemplate) {
setFormData({
name: existingTemplate.name,
tagline: existingTemplate.details?.tagline || '',
about: existingTemplate.details?.about || '',
creatorId: existingTemplate.creatorId || '',
tags: existingTemplate.tags || [],
})
}
}, [existingTemplate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!session?.user || !isFormValid) {
logger.error('User not authenticated or form invalid')
return
}
try {
const templateData = {
name: formData.name.trim(),
details: {
tagline: formData.tagline.trim(),
about: formData.about.trim(),
},
creatorId: formData.creatorId || undefined,
tags: formData.tags,
}
if (existingTemplate) {
await updateMutation.mutateAsync({
id: existingTemplate.id,
data: {
...templateData,
updateState: true,
},
})
} else {
await createMutation.mutateAsync({ ...templateData, workflowId })
}
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
}
}
const handleDelete = async () => {
if (!existingTemplate) return
try {
await deleteMutation.mutateAsync(existingTemplate.id)
setShowDeleteDialog(false)
setFormData(initialFormData)
} catch (error) {
logger.error('Error deleting template:', error)
}
}
if (isLoadingTemplate) {
return (
<div className='space-y-[12px]'>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[40px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[76px]' />
<Skeleton className='h-[160px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[32px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
</div>
)
}
return (
<div className='space-y-4'>
<form id='template-deploy-form' onSubmit={handleSubmit} className='space-y-[12px]'>
{existingTemplate?.state && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Live Template
</Label>
<div
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
onWheelCapture={(e) => {
if (e.ctrlKey || e.metaKey) return
e.stopPropagation()
}}
>
<TemplatePreviewContent existingTemplate={existingTemplate} />
</div>
</div>
)}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Name
</Label>
<Input
placeholder='Deep Research Agent'
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
disabled={isSubmitting}
/>
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Tagline
</Label>
<Input
placeholder='A deep research agent that can find information on any topic'
value={formData.tagline}
onChange={(e) => updateField('tagline', e.target.value)}
disabled={isSubmitting}
/>
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Description
</Label>
<Textarea
placeholder='Optional description that supports Markdown'
className='min-h-[160px] resize-none'
value={formData.about}
onChange={(e) => updateField('about', e.target.value)}
disabled={isSubmitting}
/>
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Creator
</Label>
{creatorOptions.length === 0 && !loadingCreators ? (
<Button
type='button'
variant='outline'
onClick={() => {
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,
})
}
}}
className='gap-[8px]'
>
<Plus className='h-[14px] w-[14px] text-[var(--text-muted)]' />
<span className='text-[var(--text-muted)]'>Create a Creator Profile</span>
</Button>
) : (
<Combobox
options={creatorOptions.map((option) => ({
label: option.name,
value: option.id,
}))}
value={formData.creatorId}
selectedValue={formData.creatorId}
onChange={(value) => updateField('creatorId', value)}
placeholder={loadingCreators ? 'Loading...' : 'Select creator profile'}
editable={false}
filterOptions={false}
disabled={loadingCreators || isSubmitting}
/>
)}
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Tags
</Label>
<TagInput
value={formData.tags}
onChange={(tags) => updateField('tags', tags)}
placeholder='Dev, Agents, Research, etc.'
maxTags={10}
disabled={isSubmitting}
/>
</div>
<button
type='button'
data-template-delete-trigger
onClick={() => setShowDeleteDialog(true)}
style={{ display: 'none' }}
/>
</form>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Template</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to delete this template?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleDelete}
disabled={deleteMutation.isPending}
className='bg-[var(--text-error)] text-[13px] text-white hover:bg-[var(--text-error)]'
>
{deleteMutation.isPending ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Deleting...
</>
) : (
'Delete'
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}
interface TemplatePreviewContentProps {
existingTemplate:
| {
id: string
state?: Partial<WorkflowState>
}
| null
| undefined
}
function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProps) {
if (!existingTemplate?.state || !existingTemplate.state.blocks) {
return null
}
const workflowState: WorkflowState = {
blocks: existingTemplate.state.blocks,
edges: existingTemplate.state.edges ?? [],
loops: existingTemplate.state.loops ?? {},
parallels: existingTemplate.state.parallels ?? {},
lastSaved: existingTemplate.state.lastSaved ?? Date.now(),
}
return (
<WorkflowPreview
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
)
}

View File

@@ -0,0 +1 @@
export { DeployModal } from './deploy-modal/deploy-modal'

View File

@@ -62,7 +62,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
const isEmpty = !hasBlocks()
const canDeploy = userPermissions.canAdmin
const isDisabled = isDeploying || !canDeploy || isEmpty
const isPreviousVersionActive = isDeployed && changeDetected
/**
* Handle deploy button click
@@ -135,6 +134,7 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
open={isModalOpen}
onOpenChange={setIsModalOpen}
workflowId={activeWorkflowId}
isDeployed={isDeployed}
needsRedeployment={changeDetected}
setNeedsRedeployment={setChangeDetected}
deployedState={deployedState!}

View File

@@ -1,156 +0,0 @@
import { useCallback, useState } from 'react'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChatFormData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form'
import type { OutputConfig } from '@/stores/chat/store'
const logger = createLogger('ChatDeployment')
export interface ChatDeploymentState {
isLoading: boolean
error: string | null
deployedUrl: string | null
}
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email']).default('public'),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputConfigs: z
.array(
z.object({
blockId: z.string(),
path: z.string(),
})
)
.optional()
.default([]),
})
export function useChatDeployment() {
const [state, setState] = useState<ChatDeploymentState>({
isLoading: false,
error: null,
deployedUrl: null,
})
const deployChat = useCallback(
async (
workflowId: string,
formData: ChatFormData,
deploymentInfo: { apiKey: string } | null,
existingChatId?: string,
imageUrl?: string | null
) => {
setState({ isLoading: true, error: null, deployedUrl: null })
try {
// Prepare output configs
const outputConfigs: OutputConfig[] = formData.selectedOutputBlocks
.map((outputId) => {
const firstUnderscoreIndex = outputId.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
if (blockId && path) {
return { blockId, path }
}
}
return null
})
.filter(Boolean) as OutputConfig[]
const payload = {
workflowId,
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey: deploymentInfo?.apiKey,
deployApiEnabled: !existingChatId,
}
chatSchema.parse(payload)
// Determine endpoint and method
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
const method = existingChatId ? 'PATCH' : 'POST'
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
// Handle identifier conflict specifically
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
}
if (!result.chatUrl) {
throw new Error('Response missing chatUrl')
}
setState({
isLoading: false,
error: null,
deployedUrl: result.chatUrl,
})
logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
return result.chatUrl
} catch (error: any) {
const errorMessage = error.message || 'An unexpected error occurred'
setState({
isLoading: false,
error: errorMessage,
deployedUrl: null,
})
logger.error(`Failed to ${existingChatId ? 'update' : 'deploy'} chat:`, error)
throw error
}
},
[]
)
const reset = useCallback(() => {
setState({
isLoading: false,
error: null,
deployedUrl: null,
})
}, [])
return {
...state,
deployChat,
reset,
}
}

View File

@@ -1,116 +0,0 @@
import { useCallback, useState } from 'react'
export type AuthType = 'public' | 'password' | 'email' | 'sso'
export interface ChatFormData {
identifier: string
title: string
description: string
authType: AuthType
password: string
emails: string[]
welcomeMessage: string
selectedOutputBlocks: string[]
}
export interface ChatFormErrors {
identifier?: string
title?: string
password?: string
emails?: string
outputBlocks?: string
general?: string
}
const initialFormData: ChatFormData = {
identifier: '',
title: '',
description: '',
authType: 'public',
password: '',
emails: [],
welcomeMessage: 'Hi there! How can I help you today?',
selectedOutputBlocks: [],
}
export function useChatForm(initialData?: Partial<ChatFormData>) {
const [formData, setFormData] = useState<ChatFormData>({
...initialFormData,
...initialData,
})
const [errors, setErrors] = useState<ChatFormErrors>({})
const updateField = useCallback(
<K extends keyof ChatFormData>(field: K, value: ChatFormData[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Clear error when user starts typing
if (field in errors && errors[field as keyof ChatFormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
},
[errors]
)
const setError = useCallback((field: keyof ChatFormErrors, message: string) => {
setErrors((prev) => ({ ...prev, [field]: message }))
}, [])
const clearError = useCallback((field: keyof ChatFormErrors) => {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}, [])
const clearAllErrors = useCallback(() => {
setErrors({})
}, [])
const validateForm = useCallback((): boolean => {
const newErrors: ChatFormErrors = {}
if (!formData.identifier.trim()) {
newErrors.identifier = 'Identifier is required'
} else if (!/^[a-z0-9-]+$/.test(formData.identifier)) {
newErrors.identifier = 'Identifier can only contain lowercase letters, numbers, and hyphens'
}
if (!formData.title.trim()) {
newErrors.title = 'Title is required'
}
if (formData.authType === 'password' && !formData.password.trim()) {
newErrors.password = 'Password is required when using password protection'
}
if (formData.authType === 'email' && formData.emails.length === 0) {
newErrors.emails = 'At least one email or domain is required when using email access control'
}
if (formData.authType === 'sso' && formData.emails.length === 0) {
newErrors.emails = 'At least one email or domain is required when using SSO access control'
}
if (formData.selectedOutputBlocks.length === 0) {
newErrors.outputBlocks = 'Please select at least one output block'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [formData])
const resetForm = useCallback(() => {
setFormData(initialFormData)
setErrors({})
}, [])
return {
formData,
errors,
updateField,
setError,
clearError,
clearAllErrors,
validateForm,
resetForm,
setFormData,
}
}

View File

@@ -91,7 +91,7 @@ export function FieldItem({
onDragStart={handleDragStart}
onClick={handleClick}
className={clsx(
'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing',
hasChildren && 'cursor-pointer'
)}
style={{ marginLeft: `${indent}px` }}
@@ -99,7 +99,7 @@ export function FieldItem({
<span
className={clsx(
'flex-1 truncate font-medium',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{field.name}
@@ -109,7 +109,7 @@ export function FieldItem({
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)}
/>

View File

@@ -25,7 +25,7 @@ interface ConnectionBlocksProps {
}
const TREE_STYLES = {
LINE_COLOR: '#2C2C2C',
LINE_COLOR: 'var(--border)',
LINE_OFFSET: 4,
} as const
@@ -123,7 +123,7 @@ function ConnectionItem({
draggable
onDragStart={(e) => onConnectionDragStart(e, connection)}
className={clsx(
'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing',
hasFields && 'cursor-pointer'
)}
onClick={() => hasFields && onToggleExpand(connection.id)}
@@ -145,7 +145,7 @@ function ConnectionItem({
<span
className={clsx(
'truncate font-medium',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{connection.name}
@@ -154,7 +154,7 @@ function ConnectionItem({
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)}
/>

View File

@@ -633,8 +633,12 @@ export function Code({
numbers.push(
<div
key={`${lineNumber}-0`}
className={cn('text-right text-xs tabular-nums leading-[21px]')}
style={{ color: isActive ? '#eeeeee' : '#a8a8a8' }}
className={cn(
'text-right text-xs tabular-nums leading-[21px]',
isActive
? 'text-[var(--text-primary)] dark:text-[#eeeeee]'
: 'text-[var(--text-muted)] dark:text-[#a8a8a8]'
)}
>
{lineNumber}
</div>
@@ -656,8 +660,10 @@ export function Code({
numbers.push(
<div
key={'1-0'}
className={cn('text-right text-xs tabular-nums leading-[21px]')}
style={{ color: '#a8a8a8' }}
className={cn(
'text-right text-xs tabular-nums leading-[21px]',
'text-[var(--text-muted)] dark:text-[#a8a8a8]'
)}
>
1
</div>

View File

@@ -709,7 +709,7 @@ export function ConditionInput({
{conditionalBlocks.map((block, index) => (
<div
key={block.id}
className='group relative overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]'
className='group relative overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-3)] dark:bg-[#1F1F1F]'
>
<div
className={cn(

View File

@@ -257,7 +257,7 @@ export function InputMapping({
if (!selectedWorkflowId) {
return (
<div className='flex flex-col items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-8 text-center'>
<div className='flex flex-col items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-3)] p-8 text-center dark:bg-[#1F1F1F]'>
<svg
className='mb-3 h-10 w-10 text-[var(--text-tertiary)]'
fill='none'
@@ -369,7 +369,7 @@ function InputMappingField({
return (
<div
className={cn(
'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
'rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-3)] dark:bg-[#1F1F1F]',
collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>

View File

@@ -12,6 +12,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import type { SubBlockConfig } from '@/blocks/types'
const MIN_TEXTAREA_HEIGHT_PX = 80
const MAX_TEXTAREA_HEIGHT_PX = 320
/**
* Interface for individual message in the messages array
@@ -236,10 +237,12 @@ export function MessagesInput({
return
}
// Auto-resize to fit content only when user hasn't manually resized.
textarea.style.height = 'auto'
const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
const nextHeight = Math.min(
MAX_TEXTAREA_HEIGHT_PX,
Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
)
textarea.style.height = `${nextHeight}px`
if (overlay) {
@@ -453,7 +456,7 @@ export function MessagesInput({
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
rows={3}
placeholder='Enter message content...'
value={message.content}
@@ -496,7 +499,7 @@ export function MessagesInput({
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
className='pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
>
{formatDisplayText(message.content, {
accessiblePrefixes,

View File

@@ -485,9 +485,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
<ModalDescription>
Are you sure you want to delete this schedule configuration? This will stop the
workflow from running automatically.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
@@ -500,7 +498,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
</Button>
<Button
onClick={handleDeleteConfirm}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)]'
>
Delete
</Button>

View File

@@ -332,7 +332,7 @@ export function FieldFormat({
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{
'[\n {\n "data": "data:application/pdf;base64,...",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
}
</Code.Placeholder>
<Editor
@@ -405,7 +405,7 @@ export function FieldFormat({
key={field.id}
data-field-id={field.id}
className={cn(
'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
'rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-3)] dark:bg-[#1F1F1F]',
field.collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>

View File

@@ -57,8 +57,9 @@ export function Table({
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Create refs for input elements
// Create refs for input and overlay elements
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Memoized template for empty cells for current columns
const emptyCellsTemplate = useMemo(
@@ -180,16 +181,42 @@ export function Table({
cellValue,
(newValue) => updateCellValue(rowIndex, column, newValue)
)
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
const overlay = overlayRefs.current.get(cellKey)
if (overlay) {
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}
const syncScrollAfterUpdate = () => {
requestAnimationFrame(() => {
const input = inputRefs.current.get(cellKey)
const overlay = overlayRefs.current.get(cellKey)
if (input && overlay) {
overlay.scrollLeft = input.scrollLeft
}
})
}
const baseTagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
cellKey,
cellValue,
(newValue) => updateCellValue(rowIndex, column, newValue)
)
const envVarSelectHandler = inputController.fieldHelpers.createEnvVarSelectHandler(
const tagSelectHandler = (tag: string) => {
baseTagSelectHandler(tag)
syncScrollAfterUpdate()
}
const baseEnvVarSelectHandler = inputController.fieldHelpers.createEnvVarSelectHandler(
cellKey,
cellValue,
(newValue) => updateCellValue(rowIndex, column, newValue)
)
const envVarSelectHandler = (envVar: string) => {
baseEnvVarSelectHandler(envVar)
syncScrollAfterUpdate()
}
return (
<td
@@ -208,17 +235,21 @@ export function Table({
placeholder={column}
onChange={handlers.onChange}
onKeyDown={handlers.onKeyDown}
onScroll={handleScroll}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
disabled={isPreview || disabled}
autoComplete='off'
className='w-full border-0 bg-transparent px-[10px] py-[8px] text-transparent caret-white placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div
ref={(el) => {
if (el) overlayRefs.current.set(cellKey, el)
}}
data-overlay={cellKey}
className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[10px] font-medium text-[#eeeeee] text-sm'
className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'
>
<div className='whitespace-pre leading-[21px]'>
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
@@ -279,7 +310,7 @@ export function Table({
return (
<div className='relative'>
<div className='overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]'>
<div className='overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
<table className='w-full bg-transparent'>
{renderHeader()}
<tbody className='bg-transparent'>

View File

@@ -20,6 +20,7 @@ interface CodeEditorProps {
language: 'javascript' | 'json'
placeholder?: string
className?: string
gutterClassName?: string
minHeight?: string
highlightVariables?: boolean
onKeyDown?: (e: React.KeyboardEvent) => void
@@ -36,6 +37,7 @@ export function CodeEditor({
language,
placeholder = '',
className = '',
gutterClassName = '',
minHeight = '360px',
highlightVariables = true,
onKeyDown,
@@ -180,7 +182,9 @@ export function CodeEditor({
</Button>
)}
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Gutter width={gutterWidth} className={gutterClassName}>
{renderLineNumbers()}
</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`} editorRef={editorRef}>
<Code.Placeholder gutterWidth={gutterWidth} show={code.length === 0 && !!placeholder}>

View File

@@ -1,14 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Code, FileJson, Wand2, X } from 'lucide-react'
import { AlertCircle, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button as EmcnButton,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Button,
Popover,
PopoverAnchor,
PopoverContent,
@@ -16,16 +10,17 @@ import {
PopoverScrollArea,
PopoverSection,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
} from '@/components/emcn/components/modal/modal'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
@@ -347,10 +342,7 @@ try {
}
}
const validateFunctionCode = (code: string): boolean => {
return true // Allow empty code
}
/** Extracts parameters from JSON schema for autocomplete */
const schemaParameters = useMemo(() => {
try {
if (!jsonSchema) return []
@@ -369,8 +361,8 @@ try {
}
}, [jsonSchema])
/** Memoized schema validation result */
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
const isCodeValid = useMemo(() => validateFunctionCode(functionCode), [functionCode])
const handleSave = async () => {
try {
@@ -835,53 +827,11 @@ try {
}
}
const navigationItems = [
{
id: 'schema' as const,
label: 'Schema',
icon: FileJson,
complete: isSchemaValid,
},
{
id: 'code' as const,
label: 'Code',
icon: Code,
complete: isCodeValid,
},
]
useEffect(() => {
if (!open) return
const styleId = 'custom-tool-modal-z-index'
let styleEl = document.getElementById(styleId) as HTMLStyleElement
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = styleId
styleEl.textContent = `
[data-radix-portal] [data-radix-dialog-overlay] {
z-index: 10000048 !important;
}
`
document.head.appendChild(styleEl)
}
return () => {
const el = document.getElementById(styleId)
if (el) {
el.remove()
}
}
}, [open])
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className='flex h-[80vh] w-full max-w-[840px] flex-col gap-0 p-0'
style={{ zIndex: 10000050 }}
hideCloseButton
<Modal open={open} onOpenChange={handleClose}>
<ModalContent
className='h-[80vh] w-[900px]'
onKeyDown={(e) => {
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
e.preventDefault()
@@ -892,57 +842,27 @@ try {
}
}}
>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>
{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}
</DialogTitle>
<EmcnButton variant='ghost' onClick={handleClose}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</EmcnButton>
</div>
<DialogDescription className='mt-1.5'>
Step {activeSection === 'schema' ? '1' : '2'} of 2:{' '}
{activeSection === 'schema' ? 'Define schema' : 'Implement code'}
</DialogDescription>
</DialogHeader>
<ModalHeader>{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}</ModalHeader>
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
<div className='flex border-b'>
{navigationItems.map((item) => (
<button
key={item.id}
onClick={() => setActiveSection(item.id)}
className={cn(
'flex items-center gap-2 border-b-2 px-6 py-3 text-sm transition-colors',
'hover:bg-muted/50',
activeSection === item.id
? 'border-primary font-medium text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<item.icon className='h-4 w-4' />
<span>{item.label}</span>
</button>
))}
</div>
<ModalTabs
value={activeSection}
onValueChange={(value) => setActiveSection(value as ToolSection)}
className='flex min-h-0 flex-1 flex-col'
>
<ModalTabsList activeValue={activeSection}>
<ModalTabsTrigger value='schema'>Schema</ModalTabsTrigger>
<ModalTabsTrigger value='code'>Code</ModalTabsTrigger>
</ModalTabsList>
<div className='relative flex-1 overflow-auto px-6 pt-6 pb-12'>
<div
className={cn(
'flex h-full flex-1 flex-col',
activeSection === 'schema' ? 'block' : 'hidden'
)}
>
<ModalBody className='min-h-0 flex-1'>
<ModalTabsContent value='schema'>
<div className='mb-1 flex min-h-6 items-center justify-between gap-2'>
<div className='flex min-w-0 items-center gap-2'>
<FileJson className='h-4 w-4' />
<Label htmlFor='json-schema' className='font-medium'>
<Label htmlFor='json-schema' className='font-medium text-[13px]'>
JSON Schema
</Label>
{schemaError && (
<div className='ml-2 flex min-w-0 items-center gap-1 text-destructive text-xs'>
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
<AlertCircle className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>{schemaError}</span>
</div>
@@ -950,7 +870,7 @@ try {
</div>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
{!isSchemaPromptActive && schemaPromptSummary && (
<span className='text-muted-foreground text-xs italic'>
<span className='text-[var(--text-tertiary)] text-xs italic'>
with {schemaPromptSummary}
</span>
)}
@@ -973,19 +893,18 @@ try {
onBlur={handleSchemaPromptBlur}
onKeyDown={handleSchemaPromptKeyDown}
disabled={schemaGeneration.isStreaming}
className='h-[16px] w-full 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'
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe schema...'
/>
)}
</div>
</div>
<div className='relative'>
<CodeEditor
value={jsonSchema}
onChange={handleJsonSchemaChange}
language='json'
showWandButton={false}
placeholder={`{
<CodeEditor
value={jsonSchema}
onChange={handleJsonSchemaChange}
language='json'
showWandButton={false}
placeholder={`{
"type": "function",
"function": {
"name": "addItemToOrder",
@@ -1002,35 +921,35 @@ try {
}
}
}`}
minHeight='360px'
className={cn(
schemaError && 'border-red-500',
(schemaGeneration.isLoading || schemaGeneration.isStreaming) &&
'cursor-not-allowed opacity-50'
)}
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming} // Use disabled prop instead of readOnly
onKeyDown={handleKeyDown} // Pass keydown handler
/>
</div>
<div className='h-6' />
</div>
minHeight='420px'
className={cn(
'bg-[var(--bg)]',
schemaError && 'border-[var(--text-error)]',
(schemaGeneration.isLoading || schemaGeneration.isStreaming) &&
'cursor-not-allowed opacity-50'
)}
gutterClassName='bg-[var(--bg)]'
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
onKeyDown={handleKeyDown}
/>
</ModalTabsContent>
<div
className={cn(
'flex h-full flex-1 flex-col pb-6',
activeSection === 'code' ? 'block' : 'hidden'
)}
>
<ModalTabsContent value='code'>
<div className='mb-1 flex min-h-6 items-center justify-between gap-2'>
<div className='flex min-w-0 items-center gap-2'>
<Code className='h-4 w-4' />
<Label htmlFor='function-code' className='font-medium'>
<Label htmlFor='function-code' className='font-medium text-[13px]'>
Code
</Label>
{codeError && !codeGeneration.isStreaming && (
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
<AlertCircle className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>{codeError}</span>
</div>
)}
</div>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
{!isCodePromptActive && codePromptSummary && (
<span className='text-muted-foreground text-xs italic'>
<span className='text-[var(--text-tertiary)] text-xs italic'>
with {codePromptSummary}
</span>
)}
@@ -1053,23 +972,19 @@ try {
onBlur={handleCodePromptBlur}
onKeyDown={handleCodePromptKeyDown}
disabled={codeGeneration.isStreaming}
className='h-[16px] w-full 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'
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe code...'
/>
)}
</div>
{codeError &&
!codeGeneration.isStreaming && ( // Hide code error while streaming
<div className='ml-4 break-words text-red-600 text-sm'>{codeError}</div>
)}
</div>
{schemaParameters.length > 0 && (
<div className='mb-2 rounded-md bg-muted/50 p-2'>
<p className='text-muted-foreground text-xs'>
<div className='mb-2 rounded-[6px] border bg-[var(--surface-2)] p-2'>
<p className='text-[var(--text-tertiary)] text-xs'>
<span className='font-medium'>Available parameters:</span>{' '}
{schemaParameters.map((param, index) => (
<span key={param.name}>
<code className='rounded bg-background px-1 py-0.5 text-foreground'>
<code className='rounded bg-[var(--bg)] px-1 py-0.5 text-[var(--text-primary)]'>
{param.name}
</code>
{index < schemaParameters.length - 1 && ', '}
@@ -1085,22 +1000,21 @@ try {
onChange={handleFunctionCodeChange}
language='javascript'
showWandButton={false}
placeholder={
'// This code will be executed when the tool is called. You can use environment variables with {{VARIABLE_NAME}}.'
}
minHeight='360px'
placeholder={'return schemaVariable + {{environmentVariable}}'}
minHeight={schemaParameters.length > 0 ? '380px' : '420px'}
className={cn(
codeError && !codeGeneration.isStreaming ? 'border-red-500' : '',
'bg-[var(--bg)]',
codeError && !codeGeneration.isStreaming && 'border-[var(--text-error)]',
(codeGeneration.isLoading || codeGeneration.isStreaming) &&
'cursor-not-allowed opacity-50'
)}
gutterClassName='bg-[var(--bg)]'
highlightVariables={true}
disabled={codeGeneration.isLoading || codeGeneration.isStreaming} // Use disabled prop instead of readOnly
onKeyDown={handleKeyDown} // Pass keydown handler
schemaParameters={schemaParameters} // Pass schema parameters for highlighting
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
onKeyDown={handleKeyDown}
schemaParameters={schemaParameters}
/>
{/* Environment variables dropdown */}
{showEnvVars && (
<EnvVarDropdown
visible={showEnvVars}
@@ -1122,7 +1036,6 @@ try {
/>
)}
{/* Tags dropdown */}
{showTags && (
<TagDropdown
visible={showTags}
@@ -1144,7 +1057,6 @@ try {
/>
)}
{/* Schema parameters dropdown */}
{showSchemaParams && schemaParameters.length > 0 && (
<Popover
open={showSchemaParams}
@@ -1172,7 +1084,6 @@ try {
side='bottom'
align='start'
collisionPadding={6}
style={{ zIndex: 100000000 }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
@@ -1198,7 +1109,9 @@ try {
<span className='h-3 w-3 font-bold text-white text-xs'>P</span>
</div>
<span className='flex-1 truncate'>{param.name}</span>
<span className='text-muted-foreground text-xs'>{param.type}</span>
<span className='text-[var(--text-tertiary)] text-xs'>
{param.type}
</span>
</PopoverItem>
))}
</PopoverScrollArea>
@@ -1206,90 +1119,92 @@ try {
</Popover>
)}
</div>
<div className='h-6' />
</div>
</div>
</div>
</ModalTabsContent>
</ModalBody>
</ModalTabs>
<DialogFooter className='mt-auto border-t px-6 py-4'>
<div className='flex w-full justify-between'>
{activeSection === 'schema' && (
<ModalFooter className='items-center justify-between'>
{isEditing ? (
<EmcnButton
className='h-[32px] gap-1 bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
<Button
variant='default'
onClick={() => setShowDeleteConfirm(true)}
className='bg-[var(--text-error)] text-white hover:bg-[var(--text-error)]'
>
<Trash className='h-4 w-4' />
Delete
</EmcnButton>
</Button>
) : (
<EmcnButton
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => {
if (activeSection === 'code') {
setActiveSection('schema')
}
}}
disabled={activeSection === 'schema'}
>
Back
</EmcnButton>
<div />
)}
<div className='flex space-x-2'>
<EmcnButton variant='outline' className='h-[32px] px-[12px]' onClick={handleClose}>
<div className='flex gap-2'>
<Button variant='default' onClick={handleClose}>
Cancel
</EmcnButton>
{activeSection === 'schema' ? (
<EmcnButton
variant='primary'
className='h-[32px] px-[12px]'
onClick={() => setActiveSection('code')}
disabled={!isSchemaValid || !!schemaError}
>
Next
</EmcnButton>
) : (
<EmcnButton
variant='primary'
className='h-[32px] px-[12px]'
onClick={handleSave}
disabled={!isSchemaValid || !!schemaError}
>
{isEditing ? 'Update Tool' : 'Save Tool'}
</EmcnButton>
)}
</Button>
<Button
variant='primary'
onClick={() => setActiveSection('code')}
disabled={!isSchemaValid || !!schemaError}
>
Next
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</ModalFooter>
)}
{activeSection === 'code' && (
<ModalFooter className='items-center justify-between'>
{isEditing ? (
<Button
variant='default'
onClick={() => setShowDeleteConfirm(true)}
className='bg-[var(--text-error)] text-white hover:bg-[var(--text-error)]'
>
Delete
</Button>
) : (
<Button variant='default' onClick={() => setActiveSection('schema')}>
Back
</Button>
)}
<div className='flex gap-2'>
<Button variant='default' onClick={handleClose}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleSave}
disabled={!isSchemaValid || !!schemaError}
>
{isEditing ? 'Update Tool' : 'Save Tool'}
</Button>
</div>
</ModalFooter>
)}
</ModalContent>
</Modal>
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete custom tool?</ModalTitle>
<ModalDescription>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Custom Tool</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This will permanently delete the tool and remove it from any workflows that are using
it.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
it. <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
variant='default'
onClick={() => setShowDeleteConfirm(false)}
disabled={deleteToolMutation.isPending}
>
Cancel
</Button>
<Button
variant='primary'
onClick={handleDelete}
disabled={deleteToolMutation.isPending}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
{deleteToolMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>

View File

@@ -1,20 +1,20 @@
import type React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Loader2, PlusIcon, Server, WrenchIcon, XIcon } from 'lucide-react'
import { Loader2, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Combobox,
Popover,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverSearch,
PopoverSection,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { McpIcon } from '@/components/icons'
import { Switch } from '@/components/ui/switch'
import { Toggle } from '@/components/ui/toggle'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -1601,7 +1601,7 @@ export function ToolInput({
{selectedTools.length === 0 ? (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className='flex w-full cursor-pointer items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] px-[10px] py-[6px] font-medium text-sm transition-colors hover:bg-[var(--surface-4)]'>
<div className='flex w-full cursor-pointer items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-4)] px-[10px] py-[6px] font-medium text-sm transition-colors hover:bg-[var(--surface-5)]'>
<div className='flex items-center text-[13px] text-[var(--text-muted)]'>
<PlusIcon className='mr-2 h-4 w-4' />
Add Tool
@@ -1631,9 +1631,7 @@ export function ToolInput({
}}
disabled={isPreview}
>
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<WrenchIcon className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<WrenchIcon className='h-[14px] w-[14px] flex-shrink-0 text-muted-foreground' />
<span className='truncate'>Create Tool</span>
</ToolCommand.Item>
@@ -1649,9 +1647,7 @@ export function ToolInput({
}}
disabled={isPreview}
>
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<Server className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<McpIcon className='h-[14px] w-[14px] flex-shrink-0 text-muted-foreground' />
<span className='truncate'>Add MCP Server</span>
</ToolCommand.Item>
@@ -1822,7 +1818,7 @@ export function ToolInput({
<div
key={`${tool.toolId}-${toolIndex}`}
className={cn(
'group relative flex flex-col overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] transition-all duration-200 ease-in-out',
'group relative flex flex-col overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-4)] transition-all duration-200 ease-in-out',
draggedIndex === toolIndex ? 'scale-95 opacity-40' : '',
dragOverIndex === toolIndex && draggedIndex !== toolIndex && draggedIndex !== null
? 'translate-y-1 transform border-t-2 border-t-muted-foreground/40'
@@ -1833,7 +1829,7 @@ export function ToolInput({
>
<div
className={cn(
'flex items-center justify-between px-[10px] py-[8px]',
'flex items-center justify-between gap-[8px] px-[10px] py-[8px]',
isExpandedForDisplay &&
!isCustomTool &&
'border-[var(--border-strong)] border-b',
@@ -1867,7 +1863,7 @@ export function ToolInput({
{isCustomTool ? (
<WrenchIcon className='h-[10px] w-[10px] text-white' />
) : isMcpTool ? (
<IconComponent icon={Server} className='h-[10px] w-[10px] text-white' />
<IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' />
) : (
<IconComponent
icon={toolBlock?.icon}
@@ -1875,86 +1871,63 @@ export function ToolInput({
/>
)}
</div>
<span className='truncate font-medium text-[#EEEEEE] text-[13px]'>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{tool.title}
</span>
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{supportsToolControl && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Toggle
className='group flex h-auto items-center justify-center rounded-sm p-0 hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=on]:bg-transparent'
pressed={true}
onPressedChange={() => {}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
const currentState = tool.usageControl || 'auto'
const nextState =
currentState === 'auto'
? 'force'
: currentState === 'force'
? 'none'
: 'auto'
handleUsageControlChange(toolIndex, nextState)
}}
aria-label='Toggle tool usage control'
<Popover>
<PopoverTrigger asChild>
<button
className='flex items-center justify-center font-medium text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
onClick={(e: React.MouseEvent) => e.stopPropagation()}
aria-label='Tool usage control'
>
<span
className={`font-medium text-[var(--text-tertiary)] text-xs ${
tool.usageControl === 'auto' ? 'block' : 'hidden'
}`}
>
Auto
</span>
<span
className={`font-medium text-[var(--text-tertiary)] text-xs ${
tool.usageControl === 'force' ? 'block' : 'hidden'
}`}
>
Force
</span>
<span
className={`font-medium text-[var(--text-tertiary)] text-xs ${
tool.usageControl === 'none' ? 'block' : 'hidden'
}`}
>
None
</span>
</Toggle>
</Tooltip.Trigger>
<Tooltip.Content className='max-w-[280px] p-2' side='top'>
<p className='text-xs'>
{tool.usageControl === 'auto' && (
<span>
<span className='font-medium' /> The model decides when to use the
tool
</span>
)}
{tool.usageControl === 'force' && (
<span>
<span className='font-medium' /> Always use this tool in the
response
</span>
)}
{tool.usageControl === 'none' && (
<span>
<span className='font-medium' /> Never use this tool
</span>
)}
</p>
</Tooltip.Content>
</Tooltip.Root>
{tool.usageControl === 'auto' && 'Auto'}
{tool.usageControl === 'force' && 'Force'}
{tool.usageControl === 'none' && 'None'}
{!tool.usageControl && 'Auto'}
</button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={8}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className='gap-[2px]'
>
<PopoverItem
active={(tool.usageControl || 'auto') === 'auto'}
onClick={() => handleUsageControlChange(toolIndex, 'auto')}
>
Auto{' '}
<span className='text-[var(--text-tertiary)]'>(model decides)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'force'}
onClick={() => handleUsageControlChange(toolIndex, 'force')}
>
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'none'}
onClick={() => handleUsageControlChange(toolIndex, 'none')}
>
None
</PopoverItem>
</PopoverContent>
</Popover>
)}
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveTool(toolIndex)
}}
className='text-[var(--text-tertiary)] transition-colors hover:text-[#EEEEEE]'
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove tool'
>
<XIcon className='h-[14px] w-[14px]' />
<XIcon className='h-[13px] w-[13px]' />
</button>
</div>
</div>
@@ -2143,7 +2116,7 @@ export function ToolInput({
{/* Add Tool Button */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className='flex w-full cursor-pointer items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] px-[10px] py-[6px] font-medium text-sm transition-colors hover:bg-[var(--surface-4)]'>
<div className='flex w-full cursor-pointer items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-4)] px-[10px] py-[6px] font-medium text-sm transition-colors hover:bg-[var(--surface-5)]'>
<div className='flex items-center text-[13px] text-[var(--text-muted)]'>
<PlusIcon className='mr-2 h-4 w-4' />
Add Tool
@@ -2170,9 +2143,7 @@ export function ToolInput({
setCustomToolModalOpen(true)
}}
>
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<WrenchIcon className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<WrenchIcon className='h-[14px] w-[14px] flex-shrink-0 text-muted-foreground' />
<span className='truncate'>Create Tool</span>
</ToolCommand.Item>
@@ -2185,9 +2156,7 @@ export function ToolInput({
)
}}
>
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<Server className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<McpIcon className='h-[14px] w-[14px] flex-shrink-0 text-muted-foreground' />
<span className='truncate'>Add MCP Server</span>
</ToolCommand.Item>

View File

@@ -469,7 +469,7 @@ export function TriggerSave({
</Button>
<Button
onClick={handleDeleteConfirm}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)]'
>
Delete
</Button>

View File

@@ -294,7 +294,7 @@ export function VariablesInput({
key={assignment.id}
data-assignment-id={assignment.id}
className={cn(
'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
'rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-3)] dark:bg-[#1F1F1F]',
collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>

View File

@@ -235,7 +235,7 @@ const renderLabel = (
}}
disabled={isStreaming}
className={cn(
'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)]',
'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-[var(--text-muted)] focus:outline-none',
isStreaming && 'text-muted-foreground'
)}
placeholder='Describe...'

View File

@@ -71,7 +71,7 @@ export function SubflowEditor({
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[5px] pb-[8px]'>
{/* Type Selection */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
{currentBlock.type === 'loop' ? 'Loop Type' : 'Parallel Type'}
</Label>
<Combobox
@@ -89,14 +89,14 @@ export function SubflowEditor({
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, #2C2C2C 0px, #2C2C2C 6px, transparent 6px, transparent 12px)',
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
{/* Configuration */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
{isCountMode
? `${currentBlock.type === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: isConditionMode
@@ -165,7 +165,7 @@ export function SubflowEditor({
{hasIncomingConnections && (
<div
className={
'connections-section flex flex-shrink-0 flex-col overflow-hidden border-[var(--border)] border-t dark:border-[var(--border)]' +
'connections-section flex flex-shrink-0 flex-col overflow-hidden border-[var(--border)] border-t' +
(!isResizing ? ' transition-[height] duration-100 ease-out' : '')
}
style={{ height: `${connectionsHeight}px` }}
@@ -198,9 +198,7 @@ export function SubflowEditor({
(!isConnectionsAtMinHeight ? ' rotate-180' : '')
}
/>
<div className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Connections
</div>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>Connections</div>
</div>
{/* Connections Content - Always visible */}

View File

@@ -183,7 +183,7 @@ export function Editor() {
return (
<div className='flex h-full flex-col'>
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between rounded-[4px] bg-[#2A2A2A] px-[12px] py-[8px] dark:bg-[#2A2A2A]'>
<div className='flex flex-shrink-0 items-center justify-between rounded-[4px] bg-[var(--surface-5)] px-[12px] py-[8px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{(blockConfig || isSubflow) && (
<div
@@ -210,11 +210,11 @@ export function Editor() {
handleCancelRename()
}
}}
className='min-w-0 flex-1 truncate bg-transparent pr-[8px] font-medium text-[14px] text-[var(--white)] outline-none dark:text-[var(--white)]'
className='min-w-0 flex-1 truncate bg-transparent pr-[8px] font-medium text-[14px] text-[var(--text-primary)] outline-none'
/>
) : (
<h2
className='min-w-0 flex-1 cursor-pointer select-none truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
className='min-w-0 flex-1 cursor-pointer select-none truncate pr-[8px] font-medium text-[14px] text-[var(--text-primary)]'
title={title}
onDoubleClick={handleStartRename}
onMouseDown={(e) => {
@@ -363,7 +363,7 @@ export function Editor() {
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, #2C2C2C 0px, #2C2C2C 6px, transparent 6px, transparent 12px)',
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
@@ -380,7 +380,7 @@ export function Editor() {
{hasIncomingConnections && (
<div
className={
'connections-section flex flex-shrink-0 flex-col overflow-hidden border-[var(--border)] border-t dark:border-[var(--border)]' +
'connections-section flex flex-shrink-0 flex-col overflow-hidden border-[var(--border)] border-t' +
(!isResizing ? ' transition-[height] duration-100 ease-out' : '')
}
style={{ height: `${connectionsHeight}px` }}
@@ -415,7 +415,7 @@ export function Editor() {
(!isConnectionsAtMinHeight ? ' rotate-180' : '')
}
/>
<div className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
Connections
</div>
</div>

View File

@@ -18,7 +18,7 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
const preview = document.createElement('div')
preview.style.cssText = `
width: 250px;
background: #232323;
background: var(--surface-1);
border-radius: 8px;
padding: 8px 9px;
display: flex;

View File

@@ -488,12 +488,10 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
>
{/* Header */}
<div
className='flex flex-shrink-0 cursor-pointer items-center justify-between rounded-[4px] bg-[#2A2A2A] px-[12px] py-[8px] dark:bg-[#2A2A2A]'
className='flex flex-shrink-0 cursor-pointer items-center justify-between rounded-[4px] bg-[var(--surface-5)] px-[12px] py-[8px]'
onClick={handleSearchClick}
>
<h2 className='font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'>
Toolbar
</h2>
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Toolbar</h2>
<div className='flex shrink-0 items-center gap-[8px]'>
{!isSearchActive ? (
<Button
@@ -511,7 +509,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className='w-full border-none bg-transparent pr-[2px] text-right font-medium text-[13px] text-[var(--text-primary)] placeholder:text-[#737373] focus:outline-none dark:text-[var(--text-primary)]'
className='w-full border-none bg-transparent pr-[2px] text-right font-medium text-[13px] text-[var(--text-primary)] placeholder:text-[#737373] focus:outline-none'
/>
)}
</div>
@@ -529,7 +527,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
>
<div
ref={triggersHeaderRef}
className='px-[10px] pt-[5px] pb-[5px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
className='px-[10px] pt-[5px] pb-[5px] font-medium text-[13px] text-[var(--text-primary)]'
>
Triggers
</div>
@@ -556,9 +554,9 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
}}
onClick={() => handleItemClick(trigger.type, isTriggerCapable)}
className={clsx(
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5px] text-[14px]',
'cursor-pointer hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
'focus-visible:bg-[var(--border)] focus-visible:outline-none dark:focus-visible:bg-[var(--border)]'
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
'cursor-pointer hover:bg-[var(--surface-9)] active:cursor-grabbing',
'focus-visible:bg-[var(--surface-9)] focus-visible:outline-none'
)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -569,7 +567,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
}}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: trigger.bgColor }}
>
{Icon && (
@@ -577,7 +575,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
className={clsx(
'toolbar-item-icon text-white transition-transform duration-200',
'group-hover:scale-110',
'!h-[10px] !w-[10px]'
'!h-[9px] !w-[9px]'
)}
/>
)}
@@ -585,8 +583,8 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
<span
className={clsx(
'truncate font-medium',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]',
'group-focus-visible:text-[var(--text-primary)] dark:group-focus-visible:text-[var(--text-primary)]'
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
'group-focus-visible:text-[var(--text-primary)]'
)}
>
{trigger.name}
@@ -599,7 +597,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
</div>
{/* Resize Handle */}
<div className='relative flex-shrink-0 border-[var(--border)] border-t dark:border-[var(--border)]'>
<div className='relative flex-shrink-0 border-[var(--border)] border-t'>
<div
className='absolute top-[-4px] right-0 left-0 z-30 h-[8px] cursor-ns-resize'
onMouseDown={handleMouseDown}
@@ -611,7 +609,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
<div
ref={blocksHeaderRef}
onClick={handleBlocksHeaderClick}
className='cursor-pointer px-[10px] pt-[5px] pb-[5px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
className='cursor-pointer px-[10px] pt-[5px] pb-[5px] font-medium text-[13px] text-[var(--text-primary)]'
>
Blocks
</div>
@@ -646,8 +644,8 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
onClick={() => handleItemClick(block.type, false)}
className={clsx(
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
'cursor-pointer hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
'focus-visible:bg-[var(--border)] focus-visible:outline-none dark:focus-visible:bg-[var(--border)]'
'cursor-pointer hover:bg-[var(--surface-9)] active:cursor-grabbing',
'focus-visible:bg-[var(--surface-9)] focus-visible:outline-none'
)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -658,7 +656,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
}}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: block.bgColor }}
>
{Icon && (
@@ -666,7 +664,7 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
className={clsx(
'toolbar-item-icon text-white transition-transform duration-200',
'group-hover:scale-110',
'!h-[10px] !w-[10px]'
'!h-[9px] !w-[9px]'
)}
/>
)}
@@ -674,8 +672,8 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
<span
className={clsx(
'truncate font-medium',
'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]',
'group-focus-visible:text-[var(--text-primary)] dark:group-focus-visible:text-[var(--text-primary)]'
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
'group-focus-visible:text-[var(--text-primary)]'
)}
>
{block.name}

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Braces, Square } from 'lucide-react'
import { ArrowUp, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
BubbleChatPreview,
@@ -43,7 +43,6 @@ import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspace
import { useChatStore } from '@/stores/chat/store'
import { usePanelStore } from '@/stores/panel/store'
import type { PanelTab } from '@/stores/panel/types'
import { DEFAULT_TERMINAL_HEIGHT, MIN_TERMINAL_HEIGHT, useTerminalStore } from '@/stores/terminal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -143,10 +142,6 @@ export function Panel() {
openSubscriptionSettings()
return
}
const { openOnRun, terminalHeight, setTerminalHeight } = useTerminalStore.getState()
if (openOnRun && terminalHeight <= MIN_TERMINAL_HEIGHT) {
setTerminalHeight(DEFAULT_TERMINAL_HEIGHT)
}
await handleRunWorkflow()
}, [usageExceeded, handleRunWorkflow])
@@ -373,10 +368,10 @@ export function Panel() {
<>
<aside
ref={panelRef}
className='panel-container fixed inset-y-0 right-0 z-10 overflow-hidden dark:bg-[var(--surface-1)]'
className='panel-container fixed inset-y-0 right-0 z-10 overflow-hidden bg-[var(--surface-1)]'
aria-label='Workflow panel'
>
<div className='flex h-full flex-col border-l pt-[14px] dark:border-[var(--border)]'>
<div className='flex h-full flex-col border-[var(--border)] border-l pt-[14px]'>
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between px-[8px]'>
{/* More and Chat */}
@@ -413,7 +408,7 @@ export function Panel() {
onClick={handleExportJson}
disabled={!userPermissions.canEdit || isExporting || !currentWorkflow}
>
<Braces className='h-3 w-3' />
<ArrowUp className='h-3 w-3' />
<span>Export workflow</span>
</PopoverItem>
<PopoverItem
@@ -467,7 +462,7 @@ export function Panel() {
<div className='flex flex-shrink-0 items-center justify-between px-[8px] pt-[14px]'>
<div className='flex gap-[4px]'>
<Button
className='h-[28px] px-[8px] py-[5px] text-[12.5px] hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-9)] dark:hover:text-[var(--text-primary)]'
className='h-[28px] truncate px-[8px] py-[5px] text-[12.5px] hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
onClick={() => handleTabClick('copilot')}
data-tab-button='copilot'
@@ -475,7 +470,7 @@ export function Panel() {
Copilot
</Button>
<Button
className='h-[28px] px-[8px] py-[5px] text-[12.5px] hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-9)] dark:hover:text-[var(--text-primary)]'
className='h-[28px] px-[8px] py-[5px] text-[12.5px] hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
variant={_hasHydrated && activeTab === 'toolbar' ? 'active' : 'ghost'}
onClick={() => handleTabClick('toolbar')}
data-tab-button='toolbar'
@@ -483,7 +478,7 @@ export function Panel() {
Toolbar
</Button>
<Button
className='h-[28px] px-[8px] py-[5px] text-[12.5px] hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-9)] dark:hover:text-[var(--text-primary)]'
className='h-[28px] px-[8px] py-[5px] text-[12.5px] hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
variant={_hasHydrated && activeTab === 'editor' ? 'active' : 'ghost'}
onClick={() => handleTabClick('editor')}
data-tab-button='editor'
@@ -555,9 +550,7 @@ export function Panel() {
<ModalDescription>
Deleting this workflow will permanently remove all associated blocks, executions, and
configuration.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
@@ -570,7 +563,7 @@ export function Panel() {
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)]'
onClick={handleDeleteWorkflow}
disabled={isDeleting}
>

View File

@@ -25,7 +25,7 @@ const SubflowNodeStyles: React.FC = () => {
/* Drag-over states */
.loop-node-drag-over,
.parallel-node-drag-over {
box-shadow: 0 0 0 1.75px #33B4FF !important;
box-shadow: 0 0 0 1.75px var(--brand-secondary) !important;
border-radius: 8px !important;
}
@@ -161,7 +161,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
{/* Header Section */}
<div
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--divider)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] dark:bg-[var(--surface-2)] [&:active]:cursor-grabbing'
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--divider)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
)}
onMouseDown={(e) => {
e.stopPropagation()
@@ -216,7 +216,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
data-node-role={`${data.kind}-start`}
data-extent='parent'
>
<span className='font-medium text-[14px] text-white'>Start</span>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
<Handle
type='source'

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { useTerminalStore } from '@/stores/terminal'
/**
@@ -15,15 +15,14 @@ const MAX_HEIGHT_PERCENTAGE = 0.7 // 70% of viewport height
* @returns Resize state and handlers
*/
export function useTerminalResize() {
const { setTerminalHeight } = useTerminalStore()
const [isResizing, setIsResizing] = useState(false)
const { setTerminalHeight, isResizing, setIsResizing } = useTerminalStore()
/**
* Handles mouse down on resize handle
*/
const handleMouseDown = useCallback(() => {
setIsResizing(true)
}, [])
}, [setIsResizing])
/**
* Setup resize event listeners and body styles when resizing
@@ -57,7 +56,7 @@ export function useTerminalResize() {
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, setTerminalHeight])
}, [isResizing, setTerminalHeight, setIsResizing])
return {
isResizing,

View File

@@ -35,11 +35,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
import { getBlock } from '@/blocks'
import type { ConsoleEntry } from '@/stores/terminal'
import {
DEFAULT_TERMINAL_HEIGHT,
useTerminalConsoleStore,
useTerminalStore,
} from '@/stores/terminal'
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
@@ -82,9 +78,8 @@ const RUN_ID_COLORS = [
/**
* Shared styling constants
*/
const HEADER_TEXT_CLASS =
'font-medium text-[var(--text-tertiary)] text-[12px] dark:text-[var(--text-tertiary)]'
const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[12px] dark:text-[#D2D2D2]'
const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]'
const COLUMN_BASE_CLASS = 'flex-shrink-0'
/**
@@ -254,9 +249,11 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
export function Terminal() {
const terminalRef = useRef<HTMLElement>(null)
const prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0)
const {
terminalHeight,
setTerminalHeight,
lastExpandedHeight,
outputPanelWidth,
setOutputPanelWidth,
openOnRun,
@@ -301,6 +298,22 @@ export function Terminal() {
const isExpanded = terminalHeight > NEAR_MIN_THRESHOLD
/**
* Expands the terminal to its last meaningful height, with safeguards:
* - Never expands below {@link DEFAULT_EXPANDED_HEIGHT}.
* - Never exceeds 70% of the viewport height.
*/
const expandToLastHeight = useCallback(() => {
setIsToggling(true)
const maxHeight = window.innerHeight * 0.7
const desiredHeight = Math.max(
lastExpandedHeight || DEFAULT_EXPANDED_HEIGHT,
DEFAULT_EXPANDED_HEIGHT
)
const targetHeight = Math.min(desiredHeight, maxHeight)
setTerminalHeight(targetHeight)
}, [lastExpandedHeight, setTerminalHeight])
/**
* Get all entries for current workflow (before filtering) for filter options
*/
@@ -404,6 +417,28 @@ export function Terminal() {
return selectedEntry.output
}, [selectedEntry, showInput])
/**
* Auto-open the terminal on new entries when "Open on run" is enabled.
* This mirrors the header toggle behavior by using expandToLastHeight,
* ensuring we always get the same smooth height transition.
*/
useEffect(() => {
if (!openOnRun) {
prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
return
}
const previousLength = prevWorkflowEntriesLengthRef.current
const currentLength = allWorkflowEntries.length
// Only react when new entries are added for the active workflow
if (currentLength > previousLength && terminalHeight <= MIN_HEIGHT) {
expandToLastHeight()
}
prevWorkflowEntriesLengthRef.current = currentLength
}, [allWorkflowEntries.length, expandToLastHeight, openOnRun, terminalHeight])
/**
* Handle row click - toggle if clicking same entry
* Disables auto-selection when user manually selects, re-enables when deselecting
@@ -421,14 +456,13 @@ export function Terminal() {
* Handle header click - toggle between expanded and collapsed
*/
const handleHeaderClick = useCallback(() => {
setIsToggling(true)
if (isExpanded) {
setIsToggling(true)
setTerminalHeight(MIN_HEIGHT)
} else {
setTerminalHeight(DEFAULT_TERMINAL_HEIGHT)
expandToLastHeight()
}
}, [isExpanded, setTerminalHeight])
}, [expandToLastHeight, isExpanded, setTerminalHeight])
/**
* Handle transition end - reset toggling state
@@ -628,10 +662,7 @@ export function Terminal() {
e.preventDefault()
if (!isExpanded) {
setIsToggling(true)
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
expandToLastHeight()
}
if (e.key === 'ArrowLeft') {
@@ -647,7 +678,7 @@ export function Terminal() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEntry, showInput, hasInputData, isExpanded])
}, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded])
/**
* Handle Escape to unselect and Enter to re-enable auto-selection
@@ -721,13 +752,13 @@ export function Terminal() {
<aside
ref={terminalRef}
className={clsx(
'terminal-container fixed right-[var(--panel-width)] bottom-0 left-[var(--sidebar-width)] z-10 overflow-hidden dark:bg-[var(--surface-1)]',
'terminal-container fixed right-[var(--panel-width)] bottom-0 left-[var(--sidebar-width)] z-10 overflow-hidden bg-[var(--surface-1)]',
isToggling && 'transition-[height] duration-100 ease-out'
)}
onTransitionEnd={handleTransitionEnd}
aria-label='Terminal'
>
<div className='relative flex h-full border-t dark:border-[var(--border)]'>
<div className='relative flex h-full border-[var(--border)] border-t'>
{/* Left Section - Logs Table */}
<div
className={clsx('flex flex-col', !selectedEntry && 'flex-1')}
@@ -827,7 +858,7 @@ export function Terminal() {
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: '#EF4444' }}
style={{ backgroundColor: 'var(--text-error)' }}
/>
<span className='flex-1'>Error</span>
{filters.statuses.has('error') && <Check className='h-3 w-3' />}
@@ -839,7 +870,7 @@ export function Terminal() {
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: '#B7B7B7' }}
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
/>
<span className='flex-1'>Info</span>
{filters.statuses.has('info') && <Check className='h-3 w-3' />}
@@ -1053,8 +1084,8 @@ export function Terminal() {
<div
key={entry.id}
className={clsx(
'flex h-[36px] cursor-pointer items-center px-[24px] hover:bg-[var(--border)]',
isSelected && 'bg-[var(--border)]'
'flex h-[36px] cursor-pointer items-center px-[24px] hover:bg-[var(--surface-9)] dark:hover:bg-[var(--border)]',
isSelected && 'bg-[var(--surface-9)] dark:bg-[var(--border)]'
)}
onClick={() => handleRowClick(entry)}
>
@@ -1067,7 +1098,7 @@ export function Terminal() {
)}
>
{BlockIcon && (
<BlockIcon className='h-[13px] w-[13px] flex-shrink-0 text-[#D2D2D2]' />
<BlockIcon className='h-[13px] w-[13px] flex-shrink-0 text-[var(--text-secondary)]' />
)}
<span className={clsx('truncate', ROW_TEXT_CLASS)}>{entry.blockName}</span>
</div>
@@ -1079,19 +1110,25 @@ export function Terminal() {
className={clsx(
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
statusInfo.isError
? 'gap-[5px] border-[#883827] bg-[#491515]'
: 'gap-[8px] border-[#686868] bg-[#383838]'
? 'gap-[5px] border-[var(--terminal-status-error-border)] bg-[var(--terminal-status-error-bg)]'
: 'gap-[8px] border-[var(--terminal-status-info-border)] bg-[var(--terminal-status-info-bg)]'
)}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: statusInfo.isError ? '#EF4444' : '#B7B7B7',
backgroundColor: statusInfo.isError
? 'var(--text-error)'
: 'var(--terminal-status-info-color)',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: statusInfo.isError ? '#EF4444' : '#B7B7B7' }}
style={{
color: statusInfo.isError
? 'var(--text-error)'
: 'var(--terminal-status-info-color)',
}}
>
{statusInfo.label}
</span>
@@ -1155,7 +1192,7 @@ export function Terminal() {
{/* Right Section - Block Output (Overlay) */}
{selectedEntry && (
<div
className='absolute top-0 right-0 bottom-0 flex flex-col border-l dark:border-[var(--border)] dark:bg-[var(--surface-1)]'
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--surface-1)]'
style={{ width: `${outputPanelWidth}px` }}
>
{/* Horizontal Resize Handle */}
@@ -1184,10 +1221,7 @@ export function Terminal() {
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
setIsToggling(true)
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
expandToLastHeight()
}
if (showInput) setShowInput(false)
}}
@@ -1205,10 +1239,7 @@ export function Terminal() {
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
setIsToggling(true)
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
expandToLastHeight()
}
setShowInput(true)
}}

View File

@@ -19,12 +19,23 @@ import { Label } from '@/components/emcn/components/label/label'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import { validateName } from '@/lib/core/utils/validation'
import {
useFloatBoundarySync,
useFloatDrag,
useFloatResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel/variables/store'
import { getVariablesPosition, useVariablesStore } from '@/stores/variables/store'
import {
getVariablesPosition,
MAX_VARIABLES_HEIGHT,
MAX_VARIABLES_WIDTH,
MIN_VARIABLES_HEIGHT,
MIN_VARIABLES_WIDTH,
useVariablesStore,
} from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useChatBoundarySync, useChatDrag, useChatResize } from '../chat/hooks'
/**
* Type options for variable type selection
@@ -42,7 +53,7 @@ const TYPE_OPTIONS: ComboboxOption[] = [
*/
const BADGE_HEIGHT = 20
const BADGE_TEXT_SIZE = 13
const ICON_SIZE = 14
const ICON_SIZE = 13
const HEADER_ICON_SIZE = 16
const LINE_HEIGHT = 21
const MIN_EDITOR_HEIGHT = 120
@@ -97,14 +108,14 @@ export function Variables() {
[position, width, height]
)
const { handleMouseDown } = useChatDrag({
const { handleMouseDown } = useFloatDrag({
position: actualPosition,
width,
height,
onPositionChange: setPosition,
})
useChatBoundarySync({
useFloatBoundarySync({
isOpen,
position: actualPosition,
width,
@@ -117,12 +128,16 @@ export function Variables() {
handleMouseMove: handleResizeMouseMove,
handleMouseLeave: handleResizeMouseLeave,
handleMouseDown: handleResizeMouseDown,
} = useChatResize({
} = useFloatResize({
position: actualPosition,
width,
height,
onPositionChange: setPosition,
onDimensionsChange: setDimensions,
minWidth: MIN_VARIABLES_WIDTH,
minHeight: MIN_VARIABLES_HEIGHT,
maxWidth: MAX_VARIABLES_WIDTH,
maxHeight: MAX_VARIABLES_HEIGHT,
})
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>({})

View File

@@ -1170,9 +1170,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
<ModalTitle>Delete webhook?</ModalTitle>
<ModalDescription>
This will permanently remove the webhook configuration and stop all notifications.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
@@ -1187,7 +1185,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
<EmcnButton
onClick={confirmDeleteWebhook}
disabled={isDeleting}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)]'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</EmcnButton>

View File

@@ -90,7 +90,7 @@ export const ActionBar = memo(
collaborativeToggleBlockEnabled(blockId)
}
}}
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
{isEnabled ? (
@@ -116,7 +116,7 @@ export const ActionBar = memo(
collaborativeDuplicateBlock(blockId)
}
}}
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
<Duplicate className='h-[11px] w-[11px]' />
@@ -139,7 +139,7 @@ export const ActionBar = memo(
)
}
}}
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-[11px] w-[11px]' />
@@ -159,7 +159,7 @@ export const ActionBar = memo(
collaborativeToggleBlockHandles(blockId)
}
}}
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
{horizontalHandles ? (
@@ -184,7 +184,7 @@ export const ActionBar = memo(
collaborativeRemoveBlock(blockId)
}
}}
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)] '
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
<Trash2 className='h-[11px] w-[11px]' />

View File

@@ -24,7 +24,7 @@ import {
getProviderName,
shouldSkipBlockRender,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
@@ -404,7 +404,7 @@ const SubBlockRow = ({
</span>
{displayValue !== undefined && (
<span
className='flex-1 truncate text-right text-[14px] text-[var(--white)]'
className='flex-1 truncate text-right text-[14px] text-[var(--text-primary)]'
title={displayValue}
>
{displayValue}
@@ -430,15 +430,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow,
activeWorkflowId,
isEnabled,
isActive,
diffStatus,
isDeletedBlock,
isFocused,
handleClick,
hasRing,
ringStyles,
runPathStatus,
} = useBlockCore({ blockId: id, data, isPending })
} = useBlockVisual({ blockId: id, data, isPending })
const currentBlock = currentWorkflow.getBlockById(id)
@@ -856,7 +852,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<span
className={cn(
'truncate font-medium text-[16px]',
!isEnabled && runPathStatus !== 'success' && 'text-[#808080]'
!isEnabled && runPathStatus !== 'success' && 'text-[var(--text-muted)]'
)}
title={name}
>
@@ -874,8 +870,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
variant='outline'
className='cursor-pointer'
style={{
borderColor: !childIsDeployed ? '#EF4444' : '#FF6600',
color: !childIsDeployed ? '#EF4444' : '#FF6600',
borderColor: !childIsDeployed ? 'var(--text-error)' : 'var(--warning)',
color: !childIsDeployed ? 'var(--text-error)' : 'var(--warning)',
}}
onClick={(e) => {
e.stopPropagation()
@@ -903,8 +899,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
variant='outline'
className='cursor-pointer'
style={{
borderColor: '#FF6600',
color: '#FF6600',
borderColor: 'var(--warning)',
color: 'var(--warning)',
}}
onClick={(e) => {
e.stopPropagation()
@@ -957,7 +953,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<Badge
variant='outline'
className='cursor-pointer'
style={{ borderColor: '#FF6600', color: '#FF6600' }}
style={{ borderColor: 'var(--warning)', color: 'var(--warning)' }}
onClick={(e) => {
e.stopPropagation()
reactivateWebhook(webhookId)

View File

@@ -141,7 +141,7 @@ export const WorkflowEdge = ({
}
}}
>
<X className='h-4 w-4 text-[var(--text-error)] transition-colors group-hover:text-[var(--text-error)]/80 dark:text-[var(--text-error)] dark:group-hover:text-[var(--text-error)]/80' />
<X className='h-4 w-4 text-[var(--text-error)] transition-colors group-hover:text-[var(--text-error)]/80' />
</div>
</EdgeLabelRenderer>
)}

View File

@@ -1,7 +1,8 @@
export { useAutoLayout } from './use-auto-layout'
export { useBlockCore } from './use-block-core'
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
export { useBlockVisual } from './use-block-visual'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float'
export { useNodeUtilities } from './use-node-utilities'
export { useScrollManagement } from './use-scroll-management'
export { useWorkflowExecution } from './use-workflow-execution'

View File

@@ -7,72 +7,72 @@ import { useExecutionStore } from '@/stores/execution/store'
import { usePanelEditorStore } from '@/stores/panel/editor/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface UseBlockCoreOptions {
/**
* Props for the useBlockVisual hook.
*/
interface UseBlockVisualProps {
/** The unique identifier of the block */
blockId: string
/** Block data including type, config, and preview state */
data: WorkflowBlockProps
/** Whether the block is pending execution */
isPending?: boolean
}
/**
* Consolidated hook for core block functionality shared across all block types.
* Combines workflow state, block state, focus, and ring styling.
* Provides visual state and interaction handlers for workflow blocks.
* Computes ring styling based on execution, focus, diff, and run path states.
* In preview mode, all interactive and execution-related visual states are disabled.
*
* @param props - The hook properties
* @returns Visual state, click handler, and ring styling for the block
*/
export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreOptions) {
// Workflow context
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
const isPreview = data.isPreview ?? false
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Block state (enabled, active, diff status, deleted)
const { isEnabled, isActive, diffStatus, isDeletedBlock } = useBlockState(
blockId,
currentWorkflow,
data
)
const {
isEnabled,
isActive: blockIsActive,
diffStatus,
isDeletedBlock,
} = useBlockState(blockId, currentWorkflow, data)
const isActive = isPreview ? false : blockIsActive
// Run path state (from last execution)
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
const runPathStatus = lastRunPath.get(blockId)
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
// Focus management
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === blockId
const isFocused = isPreview ? false : currentBlockId === blockId
const handleClick = useCallback(() => {
setCurrentBlockId(blockId)
}, [blockId, setCurrentBlockId])
if (!isPreview) {
setCurrentBlockId(blockId)
}
}, [blockId, setCurrentBlockId, isPreview])
// Ring styling based on all states
// Priority: active (executing) > pending > focused > deleted > diff > run path
const { hasRing, ringClassName: ringStyles } = useMemo(
() =>
getBlockRingStyles({
isActive,
isPending,
isPending: isPreview ? false : isPending,
isFocused,
isDeletedBlock,
diffStatus,
isDeletedBlock: isPreview ? false : isDeletedBlock,
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
}),
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus]
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview]
)
return {
// Workflow context
currentWorkflow,
activeWorkflowId,
// Block state
isEnabled,
isActive,
diffStatus,
isDeletedBlock,
// Focus
isFocused,
handleClick,
// Ring styling
hasRing,
ringStyles,
runPathStatus,

View File

@@ -0,0 +1,3 @@
export { useFloatBoundarySync } from './use-float-boundary-sync'
export { useFloatDrag } from './use-float-drag'
export { useFloatResize } from './use-float-resize'

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
interface UseChatBoundarySyncProps {
interface UseFloatBoundarySyncProps {
isOpen: boolean
position: { x: number; y: number }
width: number
@@ -9,17 +9,17 @@ interface UseChatBoundarySyncProps {
}
/**
* Hook to synchronize chat position with layout boundary changes
* Keeps chat within bounds when sidebar, panel, or terminal resize
* Hook to synchronize floats position with layout boundary changes.
* Keeps the float within bounds when sidebar, panel, or terminal resize.
* Uses requestAnimationFrame for smooth real-time updates
*/
export function useChatBoundarySync({
export function useFloatBoundarySync({
isOpen,
position,
width,
height,
onPositionChange,
}: UseChatBoundarySyncProps) {
}: UseFloatBoundarySyncProps) {
const rafIdRef = useRef<number | null>(null)
const positionRef = useRef(position)
const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 })

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react'
import { constrainChatPosition } from '@/stores/chat/store'
interface UseChatDragProps {
interface UseFloatDragProps {
position: { x: number; y: number }
width: number
height: number
@@ -9,10 +9,10 @@ interface UseChatDragProps {
}
/**
* Hook for handling drag functionality of floating chat modal
* Hook for handling drag functionality of floats.
* Provides mouse event handlers and manages drag state
*/
export function useChatDrag({ position, width, height, onPositionChange }: UseChatDragProps) {
export function useFloatDrag({ position, width, height, onPositionChange }: UseFloatDragProps) {
const isDraggingRef = useRef(false)
const dragStartRef = useRef({ x: 0, y: 0 })
const initialPositionRef = useRef({ x: 0, y: 0 })

View File

@@ -6,12 +6,20 @@ import {
MIN_CHAT_WIDTH,
} from '@/stores/chat/store'
interface UseChatResizeProps {
interface UseFloatResizeProps {
position: { x: number; y: number }
width: number
height: number
onPositionChange: (position: { x: number; y: number }) => void
onDimensionsChange: (dimensions: { width: number; height: number }) => void
/**
* Optional dimension constraints.
* If omitted, chat defaults are used for backward compatibility.
*/
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
}
/**
@@ -34,16 +42,20 @@ type ResizeDirection =
const EDGE_THRESHOLD = 8
/**
* Hook for handling multi-directional resize functionality of floating chat modal
* Supports resizing from all 8 directions: 4 corners and 4 edges
* Hook for handling multi-directional resize functionality of floating panels.
* Supports resizing from all 8 directions: 4 corners and 4 edges.
*/
export function useChatResize({
export function useFloatResize({
position,
width,
height,
onPositionChange,
onDimensionsChange,
}: UseChatResizeProps) {
minWidth,
minHeight,
maxWidth,
maxHeight,
}: UseFloatResizeProps) {
const [cursor, setCursor] = useState<string>('')
const isResizingRef = useRef(false)
const activeDirectionRef = useRef<ResizeDirection>(null)
@@ -285,9 +297,18 @@ export function useChatResize({
break
}
// Constrain dimensions to min/max
const constrainedWidth = Math.max(MIN_CHAT_WIDTH, Math.min(MAX_CHAT_WIDTH, newWidth))
const constrainedHeight = Math.max(MIN_CHAT_HEIGHT, Math.min(MAX_CHAT_HEIGHT, newHeight))
// Constrain dimensions to min/max. If explicit constraints are not provided,
// fall back to the chat defaults for backward compatibility.
const effectiveMinWidth = typeof minWidth === 'number' ? minWidth : MIN_CHAT_WIDTH
const effectiveMaxWidth = typeof maxWidth === 'number' ? maxWidth : MAX_CHAT_WIDTH
const effectiveMinHeight = typeof minHeight === 'number' ? minHeight : MIN_CHAT_HEIGHT
const effectiveMaxHeight = typeof maxHeight === 'number' ? maxHeight : MAX_CHAT_HEIGHT
const constrainedWidth = Math.max(effectiveMinWidth, Math.min(effectiveMaxWidth, newWidth))
const constrainedHeight = Math.max(
effectiveMinHeight,
Math.min(effectiveMaxHeight, newHeight)
)
// Adjust position if dimensions were constrained on left/top edges
if (direction === 'top-left' || direction === 'bottom-left' || direction === 'left') {

View File

@@ -49,7 +49,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
!isFocused &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[#22C55E]',
'ring-[var(--brand-tertiary)]',
!isActive &&
!isPending &&
!isFocused &&

View File

@@ -308,7 +308,7 @@ const WorkflowContent = React.memo(() => {
*/
const connectionLineStyle = useMemo(() => {
return {
stroke: isErrorConnectionDrag ? '#EF4444' : '#434343',
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--surface-12)',
strokeWidth: 2,
}
}, [isErrorConnectionDrag])

View File

@@ -81,30 +81,28 @@ export function FooterNavigation() {
return (
<>
<div className='flex flex-shrink-0 flex-col gap-[2px] border-t px-[7.75px] pt-[8px] pb-[8px] dark:border-[var(--border)]'>
<div className='flex flex-shrink-0 flex-col gap-[2px] border-[var(--border)] border-t px-[7.75px] pt-[8px] pb-[8px]'>
{navigationItems.map((item) => {
const Icon = item.icon
const active = item.href ? isActive(item.href) : false
const itemClasses = clsx(
'group flex h-[24px] items-center gap-[8px] rounded-[8px] px-[7px] text-[14px]',
active
? 'bg-[var(--border)] dark:bg-[var(--border)]'
: 'hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
active ? 'bg-[var(--surface-9)]' : 'hover:bg-[var(--surface-9)]'
)
const iconClasses = clsx(
'h-[14px] w-[14px] flex-shrink-0',
active
? 'text-[var(--text-primary)] dark:text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)
const labelClasses = clsx(
'truncate font-base text-[13px]',
'truncate font-medium text-[13px]',
active
? 'text-[var(--text-primary)] dark:text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)
const content = (

View File

@@ -3,19 +3,18 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import imageCompression from 'browser-image-compression'
import { Loader2, X } from 'lucide-react'
import { X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Combobox, Input, Textarea } from '@/components/emcn'
import {
Button,
Combobox,
Input,
Modal,
ModalBody,
ModalContent,
ModalTitle,
Textarea,
} from '@/components/emcn'
ModalFooter,
ModalHeader,
} from '@/components/emcn/components/modal/modal'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
@@ -60,13 +59,10 @@ interface HelpModalProps {
export function HelpModal({ open, onOpenChange }: HelpModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<'success' | 'error' | null>(null)
const [errorMessage, setErrorMessage] = useState('')
const [images, setImages] = useState<ImageWithPreview[]>([])
const [imageError, setImageError] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -93,8 +89,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
useEffect(() => {
if (open) {
setSubmitStatus(null)
setErrorMessage('')
setImageError(null)
setImages([])
setIsDragging(false)
setIsProcessing(false)
@@ -262,8 +256,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
*/
const processFiles = useCallback(
async (files: FileList | File[]) => {
setImageError(null)
if (!files || files.length === 0) return
setIsProcessing(true)
@@ -275,16 +267,12 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
for (const file of Array.from(files)) {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
setImageError(`File ${file.name} is too large. Maximum size is 20MB.`)
hasError = true
continue
}
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setImageError(
`File ${file.name} has an unsupported format. Please use JPEG, PNG, WebP, or GIF.`
)
hasError = true
continue
}
@@ -303,7 +291,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
}
} catch (error) {
logger.error('Error processing images:', { error })
setImageError('An error occurred while processing images. Please try again.')
} finally {
setIsProcessing(false)
@@ -378,7 +365,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
setErrorMessage('')
try {
// Prepare form data with images
@@ -413,7 +399,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
} catch (error) {
logger.error('Error submitting help request:', { error })
setSubmitStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'An unknown error occurred')
} finally {
setIsSubmitting(false)
}
@@ -430,213 +415,149 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
return (
<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 */}
<div className='flex-shrink-0 px-6 py-5'>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Help & Support
</ModalTitle>
</div>
<ModalContent>
<ModalHeader>Help &amp; Support</ModalHeader>
{/* Modal Body */}
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
{/* Scrollable Form Content */}
<div
ref={scrollContainerRef}
className='scrollbar-hide min-h-0 flex-1 overflow-y-auto pb-20'
>
<div className='px-6'>
<div className='space-y-[12px]'>
{/* Request Type Field */}
<div className='space-y-[8px]'>
<Label
htmlFor='type'
className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
>
Request
</Label>
<Combobox
id='type'
options={REQUEST_TYPE_OPTIONS}
value={watch('type') || DEFAULT_REQUEST_TYPE}
selectedValue={watch('type') || DEFAULT_REQUEST_TYPE}
onChange={(value) => setValue('type', value as FormValues['type'])}
placeholder='Select a request type'
editable={false}
filterOptions={false}
className={cn(
errors.type && 'border-[var(--text-error)] dark:border-[var(--text-error)]'
)}
/>
{errors.type && (
<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-[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-[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-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{errors.subject.message}
</p>
)}
</div>
{/* Message Field */}
<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-[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-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{errors.message.message}
</p>
)}
</div>
{/* Image Upload Section */}
<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}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'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()}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileChange}
className='hidden'
multiple
/>
<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-[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-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{imageError}
</p>
)}
{isProcessing && (
<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-[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-3'>
{images.map((image, index) => (
<div
key={index}
className='group relative overflow-hidden rounded-[4px] border border-[var(--surface-11)]'
>
<div className='relative flex max-h-[120px] min-h-[80px] w-full items-center justify-center bg-[var(--surface-2)]'>
<Image
src={image.preview}
alt={`Preview ${index + 1}`}
fill
className='object-contain'
/>
<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-5 w-5 text-white' />
</button>
</div>
<div className='truncate bg-[var(--surface-5)] p-1.5 text-[12px] text-[var(--text-secondary)] dark:bg-[var(--surface-5)] dark:text-[var(--text-secondary)]'>
{image.name}
</div>
</div>
))}
</div>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<div>
<Label
htmlFor='type'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Request
</Label>
<Combobox
id='type'
options={REQUEST_TYPE_OPTIONS}
value={watch('type') || DEFAULT_REQUEST_TYPE}
selectedValue={watch('type') || DEFAULT_REQUEST_TYPE}
onChange={(value) => setValue('type', value as FormValues['type'])}
placeholder='Select a request type'
editable={false}
filterOptions={false}
className={cn(errors.type && 'border-[var(--text-error)]')}
/>
</div>
</div>
</div>
{/* Fixed Footer with Actions */}
<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'
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={isSubmitting || isProcessing}>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{isSubmitting
? 'Submitting...'
: submitStatus === 'error'
? 'Error'
: submitStatus === 'success'
? 'Success'
: 'Submit'}
</Button>
<div>
<Label
htmlFor='subject'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Subject
</Label>
<Input
id='subject'
placeholder='Brief description of your request'
{...register('subject')}
className={cn(errors.subject && 'border-[var(--text-error)]')}
/>
</div>
<div>
<Label
htmlFor='message'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Message
</Label>
<Textarea
id='message'
placeholder='Please provide details about your request...'
rows={6}
{...register('message')}
className={cn(errors.message && 'border-[var(--text-error)]')}
/>
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Attach Images (Optional)
</Label>
<Button
type='button'
variant='default'
onClick={() => fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'w-full justify-center border border-[var(--c-575757)] border-dashed',
{
'border-[var(--brand-primary-hex)]': isDragging,
}
)}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileChange}
className='hidden'
multiple
/>
<div className='flex flex-col text-center'>
<span className='text-[var(--text-primary)]'>
{isDragging ? 'Drop images here' : 'Drop images here or click to browse'}
</span>
<span className='text-[11px]'>PNG, JPEG, WebP, GIF (max 20MB each)</span>
</div>
</Button>
</div>
{images.length > 0 && (
<div className='space-y-2'>
<Label>Uploaded Images</Label>
<div className='grid grid-cols-2 gap-3'>
{images.map((image, index) => (
<div
className='group relative overflow-hidden rounded-[4px] border'
key={index}
>
<div className='relative flex max-h-[120px] min-h-[80px] w-full items-center justify-center'>
<Image
src={image.preview}
alt={`Preview ${index + 1}`}
fill
className='object-contain'
/>
<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-[18px] w-[18px] text-white' />
</button>
</div>
<div className='truncate p-[6px] text-[12px]'>{image.name}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</form>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} type='button' disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={isSubmitting || isProcessing}>
{isSubmitting
? 'Submitting...'
: submitStatus === 'error'
? 'Error'
: submitStatus === 'success'
? 'Success'
: 'Submit'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)

View File

@@ -501,14 +501,14 @@ export function SearchModal({
</VisuallyHidden.Root>
{/* Search input container */}
<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-[15px] w-[15px] flex-shrink-0 text-[var(--text-subtle)] dark:text-[var(--text-subtle)]' />
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-5)] px-[12px] py-[8px] shadow-sm'>
<Search className='h-[15px] w-[15px] flex-shrink-0 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-[15px] 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-[15px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none'
autoFocus
/>
</div>
@@ -523,7 +523,7 @@ export function SearchModal({
return (
<div key={type} className='mb-[10px] last:mb-0'>
{/* Section header */}
<div className='pt-[2px] pb-[4px] font-medium text-[13px] 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'>
{sectionTitles[type]}
</div>
@@ -545,10 +545,10 @@ export function SearchModal({
onClick={() => handleItemClick(item)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
'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',
'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',
isSelected
? 'bg-[var(--border)] shadow-sm dark:bg-[var(--border)]'
: 'hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'
? 'bg-[var(--border)] shadow-sm'
: 'hover:bg-[var(--border)]'
)}
>
{/* Icon - different rendering for workflows vs others */}
@@ -574,7 +574,7 @@ export function SearchModal({
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
/>
</div>
@@ -588,8 +588,8 @@ export function SearchModal({
className={cn(
'truncate font-medium',
isSelected
? 'text-[var(--text-primary)] dark:text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{item.name}
@@ -598,7 +598,7 @@ export function SearchModal({
{/* Shortcut */}
{item.shortcut && (
<span className='ml-auto flex-shrink-0 font-medium text-[13px] text-[var(--text-subtle)] dark:text-[var(--text-subtle)]'>
<span className='ml-auto flex-shrink-0 font-medium text-[13px] text-[var(--text-subtle)]'>
{item.shortcut}
</span>
)}
@@ -611,8 +611,8 @@ 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-[15px] text-[var(--text-subtle)] dark:text-[var(--text-subtle)]'>
<div className='flex items-center justify-center rounded-[10px] bg-[var(--surface-5)] px-[16px] py-[24px] shadow-sm'>
<p className='text-[15px] text-[var(--text-subtle)]'>
No results found for "{searchQuery}"
</p>
</div>

View File

@@ -1,320 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Camera } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { signOut } from '@/lib/auth/auth-client'
import { useBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/hooks/use-profile-picture-upload'
import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
import { clearUserData } from '@/stores'
const logger = createLogger('Account')
interface AccountProps {
onOpenChange: (open: boolean) => void
}
export function Account(_props: AccountProps) {
const router = useRouter()
const brandConfig = useBrandConfig()
// React Query hooks - with placeholderData to show cached data immediately
const { data: profile } = useUserProfile()
const updateProfile = useUpdateUserProfile()
// Local UI state
const [name, setName] = useState(profile?.name || '')
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [resetPasswordMessage, setResetPasswordMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
const [uploadError, setUploadError] = useState<string | null>(null)
// Update local name state when profile data changes
useEffect(() => {
if (profile?.name) {
setName(profile.name)
}
}, [profile?.name])
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,
handleThumbnailClick: handleProfilePictureClick,
handleFileChange: handleProfilePictureChange,
isUploading: isUploadingProfilePicture,
} = useProfilePictureUpload({
currentImage: profile?.image || null,
onUpload: async (url) => {
try {
await updateProfile.mutateAsync({ image: url })
setUploadError(null)
} catch (error) {
setUploadError(
url ? 'Failed to update profile picture' : 'Failed to remove profile picture'
)
}
},
onError: (error) => {
setUploadError(error)
setTimeout(() => setUploadError(null), 5000)
},
})
useEffect(() => {
if (isEditingName && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditingName])
const handleUpdateName = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
return
}
if (trimmedName === profile?.name) {
setIsEditingName(false)
return
}
try {
await updateProfile.mutateAsync({ name: trimmedName })
setIsEditingName(false)
} catch (error) {
logger.error('Error updating name:', error)
setName(profile?.name || '')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleUpdateName()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
const handleCancelEdit = () => {
setIsEditingName(false)
setName(profile?.name || '')
}
const handleInputBlur = () => {
handleUpdateName()
}
const handleSignOut = async () => {
try {
await Promise.all([signOut(), clearUserData()])
router.push('/login?fromLogout=true')
} catch (error) {
logger.error('Error signing out:', { error })
router.push('/login?fromLogout=true')
}
}
const handleResetPassword = async () => {
if (!profile?.email) return
setIsResettingPassword(true)
setResetPasswordMessage(null)
try {
const response = await fetch('/api/auth/forget-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: profile.email,
redirectTo: `${getBaseUrl()}/reset-password`,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to send reset password email')
}
setResetPasswordMessage({
type: 'success',
text: 'email sent',
})
setTimeout(() => {
setResetPasswordMessage(null)
}, 5000)
} catch (error) {
logger.error('Error resetting password:', error)
setResetPasswordMessage({
type: 'error',
text: 'error',
})
setTimeout(() => {
setResetPasswordMessage(null)
}, 5000)
} finally {
setIsResettingPassword(false)
}
}
return (
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-4'>
{/* User Info Section */}
<div className='flex items-center gap-4'>
{/* Profile Picture Upload */}
<div className='relative'>
<div
className='group relative flex h-12 w-12 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
onClick={handleProfilePictureClick}
>
{(() => {
const imageUrl = profilePictureUrl || profile?.image || brandConfig.logoUrl
return imageUrl ? (
<Image
src={imageUrl}
alt={profile?.name || 'User'}
width={48}
height={48}
className={`h-full w-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
) : (
<AgentIcon className='h-6 w-6 text-white' />
)
})()}
{/* Upload overlay */}
<div
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
isUploadingProfilePicture ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
>
{isUploadingProfilePicture ? (
<div className='h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<Camera className='h-5 w-5 text-white' />
)}
</div>
</div>
{/* Hidden file input */}
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
{/* User Details */}
<div className='flex flex-1 flex-col justify-center'>
<h3 className='font-medium text-base'>{profile?.name || ''}</h3>
<p className='font-normal text-muted-foreground text-sm'>{profile?.email || ''}</p>
{uploadError && (
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{uploadError}
</p>
)}
</div>
</div>
{/* Name Field */}
<div className='flex flex-col gap-2'>
<Label htmlFor='name' className='font-normal text-muted-foreground text-sm'>
Name
</Label>
{isEditingName ? (
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-base outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={updateProfile.isPending}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div className='flex items-center gap-4'>
<span className='text-base'>{profile?.name || ''}</span>
<Button
variant='ghost'
className='h-auto p-0 font-normal text-muted-foreground text-sm transition-colors hover:bg-transparent hover:text-foreground'
onClick={() => setIsEditingName(true)}
>
update
<span className='sr-only'>Update name</span>
</Button>
</div>
)}
</div>
{/* Email Field - Read Only */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-sm'>Email</Label>
<p className='text-base'>{profile?.email || ''}</p>
</div>
{/* Password Field */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-sm'>Password</Label>
<div className='flex items-center gap-4'>
<span className='text-base'></span>
<Button
variant='ghost'
className={`h-auto p-0 font-normal text-sm transition-colors hover:bg-transparent ${
resetPasswordMessage
? resetPasswordMessage.type === 'success'
? 'text-green-500 hover:text-green-600'
: 'text-destructive hover:text-destructive/80'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={handleResetPassword}
disabled={isResettingPassword}
>
{isResettingPassword
? 'sending...'
: resetPasswordMessage
? resetPasswordMessage.text
: 'reset'}
<span className='sr-only'>Reset password</span>
</Button>
</div>
</div>
{/* Sign Out Button */}
<div>
<Button onClick={handleSignOut} variant='outline'>
Sign Out
</Button>
</div>
</div>
</div>
)
}

View File

@@ -3,17 +3,15 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Copy, Info, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Input as EmcnInput, Tooltip } from '@/components/emcn'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { Input, Label, Skeleton, Switch } from '@/components/ui'
} from '@/components/emcn/components/modal/modal'
import { Input, Skeleton, Switch } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -33,19 +31,6 @@ interface ApiKeysProps {
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
interface ApiKeyDisplayProps {
apiKey: ApiKey
}
function ApiKeyDisplay({ apiKey }: ApiKeyDisplayProps) {
const displayValue = apiKey.displayKey || apiKey.key
return (
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>{displayValue}</code>
</div>
)
}
export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
const { data: session } = useSession()
const userId = session?.user?.id
@@ -111,11 +96,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
.filter(({ key }) => key.name.toLowerCase().includes(searchTerm.toLowerCase()))
}, [personalKeys, searchTerm])
const personalHeaderMarginClass = useMemo(() => {
if (!searchTerm.trim()) return 'mt-8'
return filteredWorkspaceKeys.length > 0 ? 'mt-8' : 'mt-0'
}, [searchTerm, filteredWorkspaceKeys])
const handleCreateKey = async () => {
if (!userId || !newKeyName.trim()) return
@@ -222,307 +202,314 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
)}
<div className='flex h-full flex-col gap-[16px]'>
{/* Search Input and Create Button */}
<div className='flex items-center gap-[8px]'>
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border bg-[var(--surface-6)] px-[8px] py-[5px]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
onClick={(e) => {
if (createButtonDisabled) {
return
}
e.currentTarget.blur()
setIsCreateDialogOpen(true)
setKeyType(defaultKeyType)
setCreateError(null)
}}
variant='primary'
disabled={createButtonDisabled}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90 disabled:cursor-not-allowed disabled:opacity-60'
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Create
</Button>
</div>
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading ? (
<div className='space-y-2'>
<ApiKeySkeleton />
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
{isLoading ? (
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[70px]' />
<div className='text-[13px] text-[var(--text-muted)]'>
<Skeleton className='h-[13px] w-[140px]' />
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[55px]' />
<ApiKeySkeleton />
<ApiKeySkeleton />
</div>
) : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Create Key" below to get started
</div>
) : (
</div>
) : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
Click "Create" above to get started
</div>
) : (
<div className='flex flex-col gap-[16px]'>
<>
{/* Allow Personal API Keys Toggle */}
{!searchTerm.trim() && (
<Tooltip.Provider delayDuration={150}>
<div className='mb-6 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[12px] text-foreground'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-1 text-muted-foreground transition hover:text-foreground'
>
<Info className='h-3 w-3' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-xs'>
Allow collaborators to create and use their own keys with billing charged
to them.
</Tooltip.Content>
</Tooltip.Root>
</div>
{isLoadingSettings ? (
<Skeleton className='h-5 w-16 rounded-full' />
) : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
onCheckedChange={async (checked) => {
try {
await updateSettingsMutation.mutateAsync({
workspaceId,
allowPersonalApiKeys: checked,
})
} catch (error) {
logger.error('Error updating workspace settings:', { error })
}
}}
/>
)}
</div>
</Tooltip.Provider>
)}
{/* Workspace section */}
{!searchTerm.trim() ? (
<div className='mb-6 space-y-2'>
<div className='font-medium text-[13px] text-foreground'>Workspace</div>
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
Workspace
</div>
{workspaceKeys.length === 0 ? (
<div className='text-muted-foreground text-sm'>No workspace API keys yet.</div>
<div className='text-[13px] text-[var(--text-muted)]'>
No workspace API keys yet
</div>
) : (
workspaceKeys.map((key) => (
<div key={key.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{key.name}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<ApiKeyDisplay apiKey={key} />
<p className='text-muted-foreground text-xs'>
Last used: {formatDate(key.lastUsed)}
</p>
<div key={key.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[280px] truncate font-medium text-[14px]'>
{key.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
</span>
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
disabled={!canManageWorkspaceKeys}
>
Delete
</Button>
</div>
</div>
</div>
))
)}
</div>
) : filteredWorkspaceKeys.length > 0 ? (
<div className='mb-6 space-y-2'>
<div className='font-medium text-[13px] text-foreground'>Workspace</div>
{filteredWorkspaceKeys.map(({ key }) => (
<div key={key.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{key.name}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<ApiKeyDisplay apiKey={key} />
<p className='text-muted-foreground text-xs'>
Last used: {formatDate(key.lastUsed)}
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{key.displayKey || key.key}
</p>
</div>
<Button
variant='ghost'
className='flex-shrink-0'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
disabled={!canManageWorkspaceKeys}
>
Delete
</Button>
</div>
))
)}
</div>
) : filteredWorkspaceKeys.length > 0 ? (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
Workspace
</div>
{filteredWorkspaceKeys.map(({ key }) => (
<div key={key.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[280px] truncate font-medium text-[14px]'>
{key.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{key.displayKey || key.key}
</p>
</div>
<Button
variant='ghost'
className='flex-shrink-0'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
disabled={!canManageWorkspaceKeys}
>
Delete
</Button>
</div>
))}
</div>
) : null}
{/* Personal section */}
<div
className={`${personalHeaderMarginClass} mb-2 font-medium text-[13px] text-foreground`}
>
Personal
</div>
{filteredPersonalKeys.map(({ key }) => {
const isConflict = conflicts.includes(key.name)
return (
<div key={key.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{key.name}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<ApiKeyDisplay apiKey={key} />
<p className='text-muted-foreground text-xs'>
Last used: {formatDate(key.lastUsed)}
</p>
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
>
Delete
</Button>
</div>
</div>
{isConflict && (
<div className='col-span-3 mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
Workspace API key with the same name overrides this. Rename your personal
key to use it.
</div>
)}
{(!searchTerm.trim() || filteredPersonalKeys.length > 0) && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
Personal
</div>
)
})}
{filteredPersonalKeys.map(({ key }) => {
const isConflict = conflicts.includes(key.name)
return (
<div key={key.id} className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[280px] truncate font-medium text-[14px]'>
{key.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{key.displayKey || key.key}
</p>
</div>
<Button
variant='ghost'
className='flex-shrink-0'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
>
Delete
</Button>
</div>
{isConflict && (
<div className='text-[12px] text-[var(--text-error)] leading-tight'>
Workspace API key with the same name overrides this. Rename your
personal key to use it.
</div>
)}
</div>
)
})}
</div>
)}
{/* Show message when search has no results across both sections */}
{searchTerm.trim() &&
filteredPersonalKeys.length === 0 &&
filteredWorkspaceKeys.length === 0 &&
(personalKeys.length > 0 || workspaceKeys.length > 0) && (
<div className='py-8 text-center text-muted-foreground text-sm'>
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No API keys found matching "{searchTerm}"
</div>
)}
</>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center px-6 py-4'>
{isLoading ? (
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
) : (
<Button
onClick={(e) => {
if (createButtonDisabled) {
return
}
// Remove focus from button before opening dialog to prevent focus trap
e.currentTarget.blur()
setIsCreateDialogOpen(true)
setKeyType(defaultKeyType)
setCreateError(null)
}}
variant='ghost'
disabled={createButtonDisabled}
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
</Button>
)}
</div>
</div>
{/* Allow Personal API Keys Toggle - Fixed at bottom */}
{!isLoading && canManageWorkspaceKeys && (
<Tooltip.Provider delayDuration={150}>
<div className='mt-auto flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-[4px] text-[var(--text-muted)] transition hover:text-[var(--text-primary)]'
>
<Info className='h-[12px] w-[12px]' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-[12px]'>
Allow collaborators to create and use their own keys with billing charged to them.
</Tooltip.Content>
</Tooltip.Root>
</div>
{isLoadingSettings ? (
<Skeleton className='h-5 w-16 rounded-full' />
) : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
onCheckedChange={async (checked) => {
try {
await updateSettingsMutation.mutateAsync({
workspaceId,
allowPersonalApiKeys: checked,
})
} catch (error) {
logger.error('Error updating workspace settings:', { error })
}
}}
/>
)}
</div>
</Tooltip.Provider>
)}
{/* Create API Key Dialog */}
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Create new API key</ModalTitle>
<ModalDescription>
<ModalContent className='w-[400px]'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</ModalDescription>
</ModalHeader>
</p>
<div className='space-y-4 py-2'>
{canManageWorkspaceKeys && (
<div className='space-y-2'>
<p className='font-[360] text-sm'>API Key Type</p>
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'outline' : 'default'}
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
disabled={!allowPersonalApiKeys}
className='h-8 disabled:cursor-not-allowed disabled:opacity-60'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'outline' : 'default'}
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8'
>
Workspace
</Button>
<div className='mt-[16px] flex flex-col gap-[16px]'>
{canManageWorkspaceKeys && (
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
API Key Type
</p>
<div className='flex gap-[8px]'>
<Button
type='button'
variant={keyType === 'personal' ? 'active' : 'default'}
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
disabled={!allowPersonalApiKeys}
className='disabled:cursor-not-allowed disabled:opacity-60'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'active' : 'default'}
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
>
Workspace
</Button>
</div>
</div>
</div>
)}
<div className='space-y-2'>
<p className='font-[360] text-sm'>
Enter a name for your API key to help you identify it later.
</p>
<Input
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null) // Clear error when user types
}}
placeholder='e.g., Development, Production'
className='h-9 rounded-[8px]'
autoFocus
/>
{createError && (
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{createError}
</p>
)}
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
Enter a name for your API key to help you identify it later.
</p>
<EmcnInput
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null)
}}
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{createError}
</p>
)}
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
variant='default'
onClick={() => {
setIsCreateDialogOpen(false)
setNewKeyName('')
@@ -534,13 +521,13 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<Button
type='button'
variant='primary'
className='h-[32px] px-[12px]'
onClick={handleCreateKey}
disabled={
!newKeyName.trim() ||
createApiKeyMutation.isPending ||
(keyType === 'workspace' && !canManageWorkspaceKeys)
}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
>
{createApiKeyMutation.isPending ? 'Creating...' : 'Create'}
</Button>
@@ -559,51 +546,56 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}}
>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Your API key has been created</ModalTitle>
<ModalDescription>
<ModalContent className='w-[400px]'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</ModalDescription>
</ModalHeader>
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.
</span>
</p>
{newKey && (
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.key}
</code>
{newKey && (
<div className='relative mt-[10px]'>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
)}
</ModalBody>
</ModalContent>
</Modal>
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Delete API key?</ModalTitle>
<ModalDescription>
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Deleting{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span> will
immediately revoke access for any integrations using it.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
className='h-[32px] px-[12px]'
variant='outline'
variant='default'
onClick={() => {
setShowDeleteDialog(false)
setDeleteKey(null)
@@ -613,9 +605,10 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
variant='primary'
onClick={handleDeleteKey}
disabled={deleteApiKeyMutation.isPending}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
{deleteApiKeyMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
@@ -628,15 +621,15 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
function ApiKeySkeleton() {
return (
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* API key name */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-20 rounded-[8px]' /> {/* Key preview */}
<Skeleton className='h-4 w-24' /> {/* Last used */}
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-[14px] w-[80px]' />
<Skeleton className='h-[13px] w-[140px]' />
</div>
<Skeleton className='h-8 w-16' /> {/* Delete button */}
<Skeleton className='h-[13px] w-[100px]' />
</div>
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
</div>
)
}

View File

@@ -1,15 +1,16 @@
import { useState } from 'react'
import { Check, Copy, Plus } from 'lucide-react'
'use client'
import { useMemo, useState } from 'react'
import { Check, Copy, Plus, Search } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Label } from '@/components/ui'
} from '@/components/emcn/components/modal/modal'
import { Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import {
type CopilotKey,
@@ -20,91 +21,41 @@ import {
const logger = createLogger('CopilotSettings')
// Commented out model-related code
// interface ModelOption {
// value: string
// label: string
// icon: 'brain' | 'brainCircuit' | 'zap'
// }
// const OPENAI_MODELS: ModelOption[] = [
// // Zap models first
// { value: 'gpt-4o', label: 'gpt-4o', icon: 'zap' },
// { value: 'gpt-4.1', label: 'gpt-4.1', icon: 'zap' },
// { value: 'gpt-5-fast', label: 'gpt-5-fast', icon: 'zap' },
// { value: 'gpt-5.1-fast', label: 'gpt-5.1-fast', icon: 'zap' },
// // Brain models
// { value: 'gpt-5', label: 'gpt-5', icon: 'brain' },
// { value: 'gpt-5-medium', label: 'gpt-5-medium', icon: 'brain' },
// { value: 'gpt-5.1', label: 'gpt-5.1', icon: 'brain' },
// { value: 'gpt-5.1-medium', label: 'gpt-5.1-medium', icon: 'brain' },
// // BrainCircuit models
// { value: 'gpt-5-high', label: 'gpt-5-high', icon: 'brainCircuit' },
// { value: 'gpt-5.1-high', label: 'gpt-5.1-high', icon: 'brainCircuit' },
// { value: 'gpt-5-codex', label: 'gpt-5-codex', icon: 'brainCircuit' },
// { value: 'gpt-5.1-codex', label: 'gpt-5.1-codex', icon: 'brainCircuit' },
// { value: 'o3', label: 'o3', icon: 'brainCircuit' },
// ]
// const ANTHROPIC_MODELS: ModelOption[] = [
// // Zap model (Haiku)
// { value: 'claude-4.5-haiku', label: 'claude-4.5-haiku', icon: 'zap' },
// // Brain models
// { value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' },
// { value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet', icon: 'brain' },
// // BrainCircuit models
// { value: 'claude-4.1-opus', label: 'claude-4.1-opus', icon: 'brainCircuit' },
// ]
// const ALL_MODELS: ModelOption[] = [...OPENAI_MODELS, ...ANTHROPIC_MODELS]
// // Default enabled/disabled state for all models
// const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
// 'gpt-4o': false,
// 'gpt-4.1': false,
// 'gpt-5-fast': false,
// 'gpt-5': true,
// 'gpt-5-medium': false,
// 'gpt-5-high': false,
// 'gpt-5.1-fast': false,
// 'gpt-5.1': true,
// 'gpt-5.1-medium': true,
// 'gpt-5.1-high': false,
// 'gpt-5-codex': false,
// 'gpt-5.1-codex': true,
// o3: true,
// 'claude-4-sonnet': false,
// 'claude-4.5-haiku': true,
// 'claude-4.5-sonnet': true,
// 'claude-4.1-opus': true,
// }
// const getModelIcon = (iconType: 'brain' | 'brainCircuit' | 'zap') => {
// switch (iconType) {
// case 'brainCircuit':
// return <BrainCircuit className='h-3.5 w-3.5 text-muted-foreground' />
// case 'brain':
// return <Brain className='h-3.5 w-3.5 text-muted-foreground' />
// case 'zap':
// return <Zap className='h-3.5 w-3.5 text-muted-foreground' />
// }
// }
/**
* Skeleton component for loading state of copilot key items
*/
function CopilotKeySkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[13px] w-[120px]' />
</div>
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
</div>
)
}
/**
* Copilot Keys management component for handling API keys used with the Copilot feature.
* Provides functionality to create, view, and delete copilot API keys.
*/
export function Copilot() {
// React Query hooks
const { data: keys = [] } = useCopilotKeys()
const { data: keys = [], isLoading } = useCopilotKeys()
const generateKey = useGenerateCopilotKey()
const deleteKeyMutation = useDeleteCopilotKey()
// Create flow state
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [newKey, setNewKey] = useState<string | null>(null)
const [copySuccess, setCopySuccess] = useState(false)
// Delete flow state
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmationKey, setDeleteConfirmationKey] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const filteredKeys = useMemo(() => {
if (!searchTerm.trim()) return keys
const term = searchTerm.toLowerCase()
return keys.filter((key) => key.displayKey?.toLowerCase().includes(term))
}, [keys, searchTerm])
const onGenerate = async () => {
try {
@@ -127,11 +78,9 @@ export function Copilot() {
const handleDeleteKey = async () => {
if (!deleteKey) return
try {
// Close dialog and clear state immediately for optimistic update
setShowDeleteDialog(false)
const keyToDelete = deleteKey
setDeleteKey(null)
setDeleteConfirmationKey('')
await deleteKeyMutation.mutateAsync({ keyId: keyToDelete.id })
} catch (error) {
@@ -139,97 +88,79 @@ export function Copilot() {
}
}
// Commented out model-related functions
// const toggleModel = async (modelValue: string, enabled: boolean) => {
// const newModelsMap = { ...enabledModelsMap, [modelValue]: enabled }
// setEnabledModelsMap(newModelsMap)
// // Convert to array for store
// const enabledArray = Object.entries(newModelsMap)
// .filter(([_, isEnabled]) => isEnabled)
// .map(([modelId]) => modelId)
// setStoreEnabledModels(enabledArray)
// try {
// const res = await fetch('/api/copilot/user-models', {
// method: 'PUT',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ enabledModels: newModelsMap }),
// })
// if (!res.ok) {
// throw new Error('Failed to update models')
// }
// } catch (error) {
// logger.error('Failed to update enabled models', { error })
// // Revert on error
// setEnabledModelsMap(enabledModelsMap)
// const revertedArray = Object.entries(enabledModelsMap)
// .filter(([_, isEnabled]) => isEnabled)
// .map(([modelId]) => modelId)
// setStoreEnabledModels(revertedArray)
// }
// }
// const enabledCount = Object.values(enabledModelsMap).filter(Boolean).length
// const totalCount = ALL_MODELS.length
const hasKeys = keys.length > 0
const showEmptyState = !hasKeys
const showNoResults = searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0
return (
<div className='relative flex h-full flex-col'>
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='space-y-2 pt-2 pb-6'>
{keys.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Create Key" below to get started
</div>
) : (
<>
<div className='mb-2 font-medium text-[13px] text-foreground'>Copilot API Keys</div>
{keys.map((key) => (
<div key={key.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
API KEY
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>{key.displayKey}</code>
</div>
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
>
Delete
</Button>
</div>
</div>
</div>
))}
</>
)}
</div>
</div>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center px-6 py-4'>
<>
<div className='flex h-full flex-col gap-[16px]'>
{/* Search Input and Create Button */}
<div className='flex items-center gap-[8px]'>
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border bg-[var(--surface-6)] px-[8px] py-[5px]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
onClick={onGenerate}
variant='ghost'
disabled={generateKey.isPending}
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
variant='primary'
disabled={isLoading || generateKey.isPending}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90 disabled:cursor-not-allowed disabled:opacity-60'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
<Plus className='mr-[6px] h-[13px] w-[13px]' />
{generateKey.isPending ? 'Creating...' : 'Create'}
</Button>
</div>
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto'>
{isLoading ? (
<div className='flex flex-col gap-[8px]'>
<CopilotKeySkeleton />
<CopilotKeySkeleton />
<CopilotKeySkeleton />
</div>
) : showEmptyState ? (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
Click "Create" above to get started
</div>
) : (
<div className='flex flex-col gap-[8px]'>
{filteredKeys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<p className='truncate text-[13px] text-[var(--text-primary)]'>
{key.displayKey}
</p>
</div>
<Button
variant='ghost'
className='flex-shrink-0'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
>
Delete
</Button>
</div>
))}
{showNoResults && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No keys found matching "{searchTerm}"
</div>
)}
</div>
)}
</div>
</div>
{/* New API Key Dialog */}
@@ -243,64 +174,73 @@ export function Copilot() {
}
}}
>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Your API key has been created</ModalTitle>
<ModalDescription>
<ModalContent className='w-[400px]'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</ModalDescription>
</ModalHeader>
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.
</span>
</p>
{newKey && (
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>{newKey}</code>
{newKey && (
<div className='relative mt-[10px]'>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{newKey}
</code>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={() => copyToClipboard(newKey)}
>
{copySuccess ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey)}
>
{copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
)}
</ModalBody>
</ModalContent>
</Modal>
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Delete API key?</ModalTitle>
<ModalDescription>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</ModalDescription>
</ModalHeader>
<ModalFooter className='flex'>
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
variant='default'
onClick={() => {
setShowDeleteDialog(false)
setDeleteKey(null)
setDeleteConfirmationKey('')
}}
disabled={deleteKeyMutation.isPending}
>
Cancel
</Button>
<Button
variant='primary'
onClick={handleDeleteKey}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={deleteKeyMutation.isPending}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
Delete
{deleteKeyMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</>
)
}

View File

@@ -1,453 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Camera, Check, User, Users } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Combobox, Input, Textarea } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import type { CreatorProfileDetails } from '@/app/_types/creator-profile'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/hooks/use-profile-picture-upload'
import {
useCreatorProfile,
useOrganizations,
useSaveCreatorProfile,
} from '@/hooks/queries/creator-profile'
const logger = createLogger('CreatorProfile')
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
const creatorProfileSchema = z.object({
referenceType: z.enum(['user', 'organization']),
referenceId: z.string().min(1, 'Reference is required'),
name: z.string().min(1, 'Display Name is required').max(100, 'Max 100 characters'),
profileImageUrl: z.string().min(1, 'Profile Picture is required'),
about: z.string().max(2000, 'Max 2000 characters').optional(),
xUrl: z.string().url().optional().or(z.literal('')),
linkedinUrl: z.string().url().optional().or(z.literal('')),
websiteUrl: z.string().url().optional().or(z.literal('')),
contactEmail: z.string().email().optional().or(z.literal('')),
})
type CreatorProfileFormData = z.infer<typeof creatorProfileSchema>
export function CreatorProfile() {
const { data: session } = useSession()
const userId = session?.user?.id || ''
// React Query hooks - with placeholderData to show cached data immediately
const { data: organizations = [] } = useOrganizations()
const { data: existingProfile } = useCreatorProfile(userId)
const saveProfile = useSaveCreatorProfile()
// Local UI state
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [uploadError, setUploadError] = useState<string | null>(null)
const form = useForm<CreatorProfileFormData>({
resolver: zodResolver(creatorProfileSchema),
defaultValues: {
referenceType: 'user',
referenceId: session?.user?.id || '',
name: session?.user?.name || session?.user?.email || '',
profileImageUrl: '',
about: '',
xUrl: '',
linkedinUrl: '',
websiteUrl: '',
contactEmail: '',
},
})
const profileImageUrl = form.watch('profileImageUrl')
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,
handleThumbnailClick: handleProfilePictureClick,
handleFileChange: handleProfilePictureChange,
isUploading: isUploadingProfilePicture,
} = useProfilePictureUpload({
currentImage: profileImageUrl,
onUpload: async (url) => {
form.setValue('profileImageUrl', url || '')
setUploadError(null)
},
onError: (error) => {
setUploadError(error)
setTimeout(() => setUploadError(null), 5000)
},
})
const referenceType = form.watch('referenceType')
// Update form when profile data loads
useEffect(() => {
if (existingProfile) {
const details = existingProfile.details as CreatorProfileDetails | null
form.reset({
referenceType: existingProfile.referenceType,
referenceId: existingProfile.referenceId,
name: existingProfile.name || '',
profileImageUrl: existingProfile.profileImageUrl || '',
about: details?.about || '',
xUrl: details?.xUrl || '',
linkedinUrl: details?.linkedinUrl || '',
websiteUrl: details?.websiteUrl || '',
contactEmail: details?.contactEmail || '',
})
}
}, [existingProfile, form])
const [saveError, setSaveError] = useState<string | null>(null)
const onSubmit = async (data: CreatorProfileFormData) => {
if (!userId) return
setSaveStatus('saving')
setSaveError(null)
try {
const details: CreatorProfileDetails = {}
if (data.about) details.about = data.about
if (data.xUrl) details.xUrl = data.xUrl
if (data.linkedinUrl) details.linkedinUrl = data.linkedinUrl
if (data.websiteUrl) details.websiteUrl = data.websiteUrl
if (data.contactEmail) details.contactEmail = data.contactEmail
await saveProfile.mutateAsync({
referenceType: data.referenceType,
referenceId: data.referenceId,
name: data.name,
profileImageUrl: data.profileImageUrl,
details: Object.keys(details).length > 0 ? details : undefined,
existingProfileId: existingProfile?.id,
})
setSaveStatus('saved')
// Reset to idle after 2 seconds
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
} catch (error) {
logger.error('Error saving creator profile:', error)
const errorMessage = error instanceof Error ? error.message : 'Failed to save creator profile'
setSaveError(errorMessage)
setSaveStatus('idle')
}
}
return (
<div className='relative flex h-full flex-col'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='flex h-full flex-col'>
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='space-y-2 pt-2 pb-6'>
{/* Profile Type - only show if user has organizations */}
{organizations.length > 0 && (
<FormField
control={form.control}
name='referenceType'
render={({ field }) => (
<FormItem className='space-y-2'>
<FormLabel className='font-[360] text-sm'>Profile Type</FormLabel>
<FormControl>
<div className='flex gap-2'>
<Button
type='button'
variant={field.value === 'user' ? 'outline' : 'default'}
onClick={() => field.onChange('user')}
className='h-8'
>
<User className='mr-1.5 h-3.5 w-3.5' />
Personal
</Button>
<Button
type='button'
variant={field.value === 'organization' ? 'outline' : 'default'}
onClick={() => field.onChange('organization')}
className='h-8'
>
<Users className='mr-1.5 h-3.5 w-3.5' />
Organization
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Reference Selection */}
{referenceType === 'organization' && organizations.length > 0 && (
<FormField
control={form.control}
name='referenceId'
render={({ field }) => (
<FormItem>
<FormLabel className='font-[360] text-sm'>Organization</FormLabel>
<FormControl>
<Combobox
options={organizations.map((org) => ({
label: org.name,
value: org.id,
}))}
value={field.value}
onChange={field.onChange}
placeholder='Select organization'
editable={false}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Profile Name */}
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='font-normal text-[13px]'>
Display Name <span className='text-destructive'>*</span>
</FormLabel>
<FormControl>
<Input
placeholder='How your name appears on templates'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Profile Picture Upload */}
<FormField
control={form.control}
name='profileImageUrl'
render={() => (
<FormItem>
<FormLabel className='font-normal text-[13px]'>
Profile Picture <span className='text-destructive'>*</span>
</FormLabel>
<FormControl>
<div className='flex items-center gap-3'>
<div className='relative inline-block'>
<div
className='group relative flex h-16 w-16 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
onClick={handleProfilePictureClick}
>
{profilePictureUrl ? (
<Image
src={profilePictureUrl}
alt='Profile picture'
width={64}
height={64}
className={`h-full w-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
) : (
<AgentIcon className='h-8 w-8 text-white' />
)}
{/* Upload overlay */}
<div
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
isUploadingProfilePicture
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{isUploadingProfilePicture ? (
<div className='h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<Camera className='h-4 w-4 text-white' />
)}
</div>
</div>
{/* Hidden file input */}
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
<div className='flex flex-col gap-1'>
{uploadError && (
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{uploadError}
</p>
)}
<p className='text-muted-foreground text-xs'>PNG or JPEG (max 5MB)</p>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* About */}
<FormField
control={form.control}
name='about'
render={({ field }) => (
<FormItem>
<FormLabel className='font-normal text-[13px]'>About</FormLabel>
<FormControl>
<Textarea
placeholder='Tell people about yourself or your organization'
className='min-h-[120px] w-full resize-none'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Social Links */}
<div className='space-y-4'>
<div className='font-medium text-[13px] text-foreground'>Social Links</div>
<FormField
control={form.control}
name='xUrl'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
X (Twitter)
</FormLabel>
<FormControl>
<Input
placeholder='https://x.com/username'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='linkedinUrl'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
LinkedIn
</FormLabel>
<FormControl>
<Input
placeholder='https://linkedin.com/in/username'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='websiteUrl'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
Website
</FormLabel>
<FormControl>
<Input
placeholder='https://yourwebsite.com'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='contactEmail'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
Contact Email
</FormLabel>
<FormControl>
<Input
placeholder='contact@example.com'
type='email'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
{/* Error Message */}
{saveError && (
<div className='px-6 pb-2'>
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{saveError}
</p>
</div>
)}
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
<div className='text-muted-foreground text-xs'>
Set up your creator profile for publishing templates
</div>
<Button type='submit' disabled={saveStatus === 'saving'} className='h-9'>
{saveStatus === 'saving' && (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Saving...
</>
)}
{saveStatus === 'saved' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'idle' && (
<>{existingProfile ? 'Update Profile' : 'Create Profile'}</>
)}
</Button>
</div>
</div>
</form>
</Form>
</div>
)
}

View File

@@ -1,310 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { Input, Label } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import {
type ServiceInfo,
useConnectOAuthService,
useDisconnectOAuthService,
useOAuthConnections,
} from '@/hooks/queries/oauth-connections'
const logger = createLogger('Credentials')
interface CredentialsProps {
onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const pendingServiceRef = useRef<HTMLDivElement>(null)
// React Query hooks - with placeholderData to show cached data immediately
const { data: services = [] } = useOAuthConnections()
const connectService = useConnectOAuthService()
const disconnectService = useDisconnectOAuthService()
// Local UI state
const [searchTerm, setSearchTerm] = useState('')
const [pendingService, setPendingService] = useState<string | null>(null)
const [authSuccess, setAuthSuccess] = useState(false)
const [showActionRequired, setShowActionRequired] = useState(false)
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
const connectionAddedRef = useRef<boolean>(false)
// Check for OAuth callback - just show success message
useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (code && state) {
logger.info('OAuth callback successful')
setAuthSuccess(true)
// Clear URL parameters without changing the page
const url = new URL(window.location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
router.replace(url.pathname + url.search)
} else if (error) {
logger.error('OAuth error:', { error })
}
}, [searchParams, router])
// Track when a new connection is added compared to previous render
useEffect(() => {
try {
const currentConnected = new Set<string>()
services.forEach((svc) => {
if (svc.isConnected) currentConnected.add(svc.id)
})
// Detect new connections by comparing to previous connected set
for (const id of currentConnected) {
if (!prevConnectedIdsRef.current.has(id)) {
connectionAddedRef.current = true
break
}
}
prevConnectedIdsRef.current = currentConnected
} catch {}
}, [services])
// On mount, register a close handler so the parent modal can delegate close events here
useEffect(() => {
if (!registerCloseHandler) return
const handle = (open: boolean) => {
if (open) return
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('oauth-integration-closed', {
detail: { success: connectionAddedRef.current === true },
})
)
}
} catch {}
onOpenChange?.(open)
}
registerCloseHandler(handle)
}, [registerCloseHandler, onOpenChange])
// Handle connect button click
const handleConnect = async (service: ServiceInfo) => {
try {
logger.info('Connecting service:', {
serviceId: service.id,
providerId: service.providerId,
scopes: service.scopes,
})
// better-auth will automatically redirect back to this URL after OAuth
await connectService.mutateAsync({
providerId: service.providerId,
callbackURL: window.location.href,
})
} catch (error) {
logger.error('OAuth connection error:', { error })
}
}
// Handle disconnect button click
const handleDisconnect = async (service: ServiceInfo, accountId: string) => {
try {
await disconnectService.mutateAsync({
provider: service.providerId.split('-')[0],
providerId: service.providerId,
serviceId: service.id,
accountId,
})
} catch (error) {
logger.error('Error disconnecting service:', { error })
}
}
// Group services by provider
const groupedServices = services.reduce(
(acc, service) => {
// Find the provider for this service
const providerKey =
Object.keys(OAUTH_PROVIDERS).find((key) =>
Object.keys(OAUTH_PROVIDERS[key].services).includes(service.id)
) || 'other'
if (!acc[providerKey]) {
acc[providerKey] = []
}
acc[providerKey].push(service)
return acc
},
{} as Record<string, ServiceInfo[]>
)
// Filter services based on search term
const filteredGroupedServices = Object.entries(groupedServices).reduce(
(acc, [providerKey, providerServices]) => {
const filteredServices = providerServices.filter(
(service) =>
service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
service.description.toLowerCase().includes(searchTerm.toLowerCase())
)
if (filteredServices.length > 0) {
acc[providerKey] = filteredServices
}
return acc
},
{} as Record<string, ServiceInfo[]>
)
const scrollToHighlightedService = () => {
if (pendingServiceRef.current) {
pendingServiceRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
return (
<div className='relative flex h-full flex-col'>
{/* Search Input */}
<div className='px-6 pt-4 pb-2'>
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search services...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
</div>
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='flex flex-col gap-6 pt-2 pb-6'>
{/* Success message */}
{authSuccess && (
<div className='rounded-[8px] border border-green-200 bg-green-50 p-4'>
<div className='flex'>
<div className='flex-shrink-0'>
<Check className='h-5 w-5 text-green-400' />
</div>
<div className='ml-3'>
<p className='font-medium text-green-800 text-sm'>
Account connected successfully!
</p>
</div>
</div>
</div>
)}
{/* Pending service message - only shown when coming from OAuth required modal */}
{pendingService && showActionRequired && (
<div className='flex items-start gap-3 rounded-[8px] border border-primary/20 bg-primary/5 p-5 text-sm shadow-sm'>
<div className='mt-0.5 min-w-5'>
<ExternalLink className='h-4 w-4 text-muted-foreground' />
</div>
<div className='flex flex-1 flex-col'>
<p className='text-muted-foreground'>
<span className='font-medium text-foreground'>Action Required:</span> Please
connect your account to enable the requested features. The required service is
highlighted below.
</p>
<Button
variant='outline'
onClick={scrollToHighlightedService}
className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-muted-foreground text-sm transition-colors hover:border-primary hover:bg-primary/10 hover:text-muted-foreground'
>
<span>Go to service</span>
<ChevronDown className='h-3.5 w-3.5' />
</Button>
</div>
</div>
)}
{/* Services list */}
<div className='flex flex-col gap-6'>
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</Label>
{providerServices.map((service) => (
<div
key={service.id}
className={cn(
'flex items-center justify-between gap-4',
pendingService === service.id && '-m-2 rounded-[8px] bg-primary/5 p-2'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className='flex items-center gap-3'>
<div className='flex h-10 w-10 shrink-0 items-center justify-center rounded-[8px] bg-muted'>
{typeof service.icon === 'function'
? service.icon({ className: 'h-5 w-5' })
: service.icon}
</div>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-normal text-sm'>{service.name}</span>
</div>
{service.accounts && service.accounts.length > 0 ? (
<p className='truncate text-muted-foreground text-xs'>
{service.accounts.map((a) => a.name).join(', ')}
</p>
) : (
<p className='truncate text-muted-foreground text-xs'>
{service.description}
</p>
)}
</div>
</div>
{service.accounts && service.accounts.length > 0 ? (
<Button
variant='ghost'
onClick={() => handleDisconnect(service, service.accounts![0].id)}
disabled={disconnectService.isPending}
className='h-8 text-muted-foreground hover:text-foreground'
>
Disconnect
</Button>
) : (
<Button
variant='outline'
onClick={() => handleConnect(service)}
disabled={connectService.isPending}
className='h-8'
>
Connect
</Button>
)}
</div>
))}
</div>
))}
{/* Show message when search has no results */}
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No services found matching "{searchTerm}"
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -3,17 +3,16 @@
import { useState } from 'react'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
} from '@/components/emcn/components/modal/modal'
import { Input, Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
@@ -22,17 +21,14 @@ const logger = createLogger('CustomToolsSettings')
function CustomToolSkeleton() {
return (
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* Tool title */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-24 rounded-[8px]' /> {/* Function name */}
<Skeleton className='h-4 w-48' /> {/* Description */}
</div>
<div className='flex items-center gap-2'>
<Skeleton className='h-8 w-12' /> {/* Edit button */}
<Skeleton className='h-8 w-16' /> {/* Delete button */}
</div>
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
</div>
</div>
)
@@ -42,7 +38,6 @@ export function CustomTools() {
const params = useParams()
const workspaceId = params.workspaceId as string
// React Query hooks
const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId)
const deleteToolMutation = useDeleteCustomTool()
@@ -107,127 +102,98 @@ export function CustomTools() {
refetchTools()
}
const hasTools = tools && tools.length > 0
const showEmptyState = !hasTools && !showAddForm && !editingTool
const showNoResults = searchTerm.trim() && filteredTools.length === 0 && tools.length > 0
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header with Search */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-[8px]' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<div
className={cn(
'flex flex-1 items-center gap-[8px] rounded-[8px] border bg-[var(--surface-6)] px-[8px] py-[5px]',
isLoading && 'opacity-50'
)}
>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search tools...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
/>
</div>
)}
</div>
<Button
onClick={() => setShowAddForm(true)}
disabled={isLoading}
variant='primary'
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading ? (
<div className='space-y-2'>
<CustomToolSkeleton />
<CustomToolSkeleton />
<CustomToolSkeleton />
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<div className='min-h-0 flex-1 overflow-y-auto'>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{error instanceof Error ? error.message : 'Failed to load tools'}
</p>
</div>
) : tools.length === 0 && !showAddForm && !editingTool ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Create Tool" below to get started
) : isLoading ? (
<div className='flex flex-col gap-[8px]'>
<CustomToolSkeleton />
<CustomToolSkeleton />
<CustomToolSkeleton />
</div>
) : showEmptyState ? (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
Click "Add" above to get started
</div>
) : (
<>
<div className='space-y-2'>
{filteredTools.map((tool) => (
<div key={tool.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{tool.title}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>
{tool.schema?.function?.name || 'unnamed'}
</code>
</div>
{tool.schema?.function?.description && (
<p className='truncate text-muted-foreground text-xs'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
onClick={() => setEditingTool(tool.id)}
className='h-8'
>
Edit
</Button>
<Button
variant='ghost'
onClick={() => handleDeleteClick(tool.id)}
disabled={deletingTools.has(tool.id)}
className='h-8'
>
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
{filteredTools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate font-medium text-[14px]'>
{tool.title || 'Unnamed Tool'}
</span>
{tool.schema?.function?.description && (
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.schema.function.description}
</p>
)}
</div>
))}
</div>
{/* Show message when search has no results */}
{searchTerm.trim() && filteredTools.length === 0 && tools.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Button variant='ghost' onClick={() => setEditingTool(tool.id)}>
Edit
</Button>
<Button
variant='ghost'
onClick={() => handleDeleteClick(tool.id)}
disabled={deletingTools.has(tool.id)}
>
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
))}
{showNoResults && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No tools found matching "{searchTerm}"
</div>
)}
</>
</div>
)}
</div>
</div>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[200px]' />
</>
) : (
<>
<Button
onClick={() => setShowAddForm(true)}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Tool
</Button>
<div className='text-muted-foreground text-xs'>
Custom tools extend agent capabilities with workspace-specific functions
</div>
</>
)}
</div>
</div>
{/* Create/Edit Modal - rendered as overlay */}
<CustomToolModal
open={showAddForm || !!editingTool}
onOpenChange={(open) => {
@@ -251,41 +217,30 @@ export function CustomTools() {
}
/>
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete custom tool?</ModalTitle>
<ModalDescription>
Deleting "{toolToDelete?.name}" will permanently remove this custom tool from your
workspace.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Custom Tool</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{toolToDelete?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
className='h-[32px] px-[12px]'
variant='outline'
onClick={() => {
setShowDeleteDialog(false)
setToolToDelete(null)
}}
disabled={deleteToolMutation.isPending}
>
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
variant='primary'
onClick={handleDeleteTool}
disabled={deleteToolMutation.isPending}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
{deleteToolMutation.isPending ? 'Deleting...' : 'Delete'}
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</>
)
}

View File

@@ -1,10 +1,10 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { ArrowDown, Search } from 'lucide-react'
import { ArrowDown, Loader2, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Tooltip, Trash } from '@/components/emcn'
import { Input, Progress } from '@/components/ui'
import { Input, Progress, Skeleton } from '@/components/ui'
import {
Table,
TableBody,
@@ -31,6 +31,9 @@ import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
const logger = createLogger('FileUploadsSettings')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const PRIMARY_BUTTON_STYLES =
'!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
const SUPPORTED_EXTENSIONS = [
// Documents
'pdf',
@@ -66,6 +69,16 @@ const SUPPORTED_EXTENSIONS = [
const ACCEPT_ATTR =
'.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.yaml,.yml,.mp3,.m4a,.wav,.webm,.ogg,.flac,.aac,.opus,.mp4,.mov,.avi,.mkv'
const PLAN_NAMES = {
enterprise: 'Enterprise',
team: 'Team',
pro: 'Pro',
free: 'Free',
} as const
const GRADIENT_TEXT_STYLES =
'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
export function Files() {
const params = useParams()
const workspaceId = params?.workspaceId as string
@@ -80,7 +93,10 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [downloadingFileId, setDownloadingFileId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { permissions: workspacePermissions, loading: permissionsLoading } =
useWorkspacePermissions(workspaceId)
@@ -139,9 +155,35 @@ export function Files() {
}
const handleDownload = async (file: WorkspaceFileRecord) => {
if (!workspaceId) return
if (!workspaceId || downloadingFileId === file.id) return
window.open(`/workspace/${workspaceId}/files/${file.id}/view`, '_blank')
setDownloadingFileId(file.id)
try {
const response = await fetch(`/api/workspaces/${workspaceId}/files/${file.id}/download`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to get download URL')
}
const data = await response.json()
if (!data.success || !data.downloadUrl) {
throw new Error('Invalid download response')
}
const link = document.createElement('a')
link.href = data.downloadUrl
link.download = data.fileName || file.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
logger.error('Error downloading file:', error)
} finally {
setDownloadingFileId(null)
}
}
const handleDelete = async (file: WorkspaceFileRecord) => {
@@ -172,7 +214,6 @@ export function Files() {
return `${mm}/${dd}/${yy}`
}
const [search, setSearch] = useState('')
const filteredFiles = useMemo(() => {
if (!search) return files
const q = search.toLowerCase()
@@ -192,143 +233,202 @@ export function Files() {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
const PLAN_NAMES = {
enterprise: 'Enterprise',
team: 'Team',
pro: 'Pro',
free: 'Free',
} as const
const planName = storageInfo?.plan || 'free'
const displayPlanName = PLAN_NAMES[planName as keyof typeof PLAN_NAMES] || 'Free'
const GRADIENT_TEXT_STYLES =
'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
const renderTableSkeleton = () => (
<Table className='table-auto text-[13px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[56%] px-[12px] py-[6px] text-[12px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[40px]' />
</TableHead>
<TableHead className='w-[14%] px-[12px] py-[6px] text-left text-[12px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[28px]' />
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[6px] text-left text-[12px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[56px]' />
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[6px] text-left text-[12px] text-[var(--text-secondary)]'>
<Skeleton className='h-[12px] w-[48px]' />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 3 }, (_, i) => (
<TableRow key={i} className='hover:bg-transparent'>
<TableCell className='px-[12px] py-[6px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
<Skeleton className='h-[14px] w-[180px]' />
</div>
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[6px] text-[12px]'>
<Skeleton className='h-[12px] w-[48px]' />
</TableCell>
<TableCell className='whitespace-nowrap px-[12px] py-[6px] text-[12px]'>
<Skeleton className='h-[12px] w-[56px]' />
</TableCell>
<TableCell className='px-[12px] py-[6px]'>
<div className='flex items-center gap-[4px]'>
<Skeleton className='h-[28px] w-[28px] rounded-[4px]' />
<Skeleton className='h-[28px] w-[28px] rounded-[4px]' />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
return (
<div className='relative flex h-full flex-col'>
{/* Header: search left, file count + Upload right */}
<div className='flex items-center justify-between px-6 pt-4 pb-2'>
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<div className='flex h-full flex-col gap-[2px]'>
{/* Search Input and Upload Button */}
<div className='flex items-center gap-[8px]'>
<div
className={cn(
'flex flex-1 items-center gap-[8px] rounded-[8px] border bg-[var(--surface-6)] px-[8px] py-[5px]',
permissionsLoading && 'opacity-50'
)}
>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search files...'
value={search}
onChange={(e) => setSearch(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={permissionsLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
/>
</div>
<div className='flex items-center gap-3'>
{isBillingEnabled && storageInfo && (
<div className='flex flex-col items-end gap-1'>
<div className='flex items-center gap-2 text-sm'>
<span
className={cn(
'font-medium',
planName === 'free' ? 'text-foreground' : GRADIENT_TEXT_STYLES
)}
>
{displayPlanName}
</span>
<span className='text-muted-foreground tabular-nums'>
{formatStorageSize(storageInfo.usedBytes)} /{' '}
{formatStorageSize(storageInfo.limitBytes)}
</span>
</div>
<Progress
value={Math.min(storageInfo.percentUsed, 100)}
className='h-1 w-full'
indicatorClassName='bg-black dark:bg-white'
/>
</div>
)}
{userPermissions.canEdit && (
<div className='flex items-center'>
<input
ref={fileInputRef}
type='file'
className='hidden'
onChange={handleFileChange}
disabled={uploading}
accept={ACCEPT_ATTR}
multiple
/>
<Button
onClick={handleUploadClick}
disabled={uploading}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
{!permissionsLoading && isBillingEnabled && storageInfo && (
<div className='flex flex-col items-end gap-[4px]'>
<div className='flex items-center gap-[8px] text-[13px]'>
<span
className={cn(
'font-medium',
planName === 'free' ? 'text-[var(--text-primary)]' : GRADIENT_TEXT_STYLES
)}
>
{uploading && uploadProgress.total > 0
? `Uploading ${uploadProgress.completed}/${uploadProgress.total}...`
: uploading
? 'Uploading...'
: 'Upload File'}
</Button>
{displayPlanName}
</span>
<span className='text-[var(--text-muted)] tabular-nums'>
{formatStorageSize(storageInfo.usedBytes)} /{' '}
{formatStorageSize(storageInfo.limitBytes)}
</span>
</div>
)}
</div>
<Progress
value={Math.min(storageInfo.percentUsed, 100)}
className='h-1 w-full'
indicatorClassName='bg-black dark:bg-white'
/>
</div>
)}
{(permissionsLoading || userPermissions.canEdit) && (
<>
<input
ref={fileInputRef}
type='file'
className='hidden'
onChange={handleFileChange}
disabled={uploading || permissionsLoading}
accept={ACCEPT_ATTR}
multiple
/>
<Button
onClick={handleUploadClick}
disabled={uploading || permissionsLoading}
variant='primary'
className={PRIMARY_BUTTON_STYLES}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
{uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload'}
</Button>
</>
)}
</div>
{/* Error message */}
{uploadError && (
<div className='px-6 pb-2'>
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{uploadError}
</p>
</div>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{uploadError}</p>
)}
{/* Files Table */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
{files.length === 0 ? (
<div className='py-8 text-center text-muted-foreground text-sm'>
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
{permissionsLoading ? (
renderTableSkeleton()
) : files.length === 0 ? (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
No files uploaded yet
</div>
) : filteredFiles.length === 0 ? (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No files found matching "{search}"
</div>
) : (
<Table className='table-auto text-[13px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[56%] px-3 text-xs'>Name</TableHead>
<TableHead className='w-[14%] px-3 text-left text-xs'>Size</TableHead>
<TableHead className='w-[15%] px-3 text-left text-xs'>Uploaded</TableHead>
<TableHead className='w-[15%] px-3 text-left text-xs'>Actions</TableHead>
<TableHead className='w-[56%] px-[12px] py-[6px] text-[12px] text-[var(--text-secondary)]'>
Name
</TableHead>
<TableHead className='w-[14%] px-[12px] py-[6px] text-left text-[12px] text-[var(--text-secondary)]'>
Size
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[6px] text-left text-[12px] text-[var(--text-secondary)]'>
Uploaded
</TableHead>
<TableHead className='w-[15%] px-[12px] py-[6px] text-left text-[12px] text-[var(--text-secondary)]'>
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFiles.map((file) => {
const Icon = getDocumentIcon(file.type || '', file.name)
return (
<TableRow key={file.id} className='hover:bg-muted/50'>
<TableCell className='px-3'>
<div className='flex min-w-0 items-center gap-2'>
<Icon className='h-3.5 w-3.5 shrink-0 text-muted-foreground' />
<TableRow key={file.id} className='hover:bg-[var(--surface-2)]'>
<TableCell className='px-[12px] py-[6px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
<button
onClick={() => handleDownload(file)}
className='min-w-0 truncate text-left font-normal hover:underline'
disabled={downloadingFileId === file.id}
className='min-w-0 truncate text-left font-normal text-[14px] text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50'
title={file.name}
>
{truncateMiddle(file.name)}
</button>
</div>
</TableCell>
<TableCell className='whitespace-nowrap px-3 text-[12px] text-muted-foreground'>
<TableCell className='whitespace-nowrap px-[12px] py-[6px] text-[12px] text-[var(--text-muted)]'>
{formatFileSize(file.size)}
</TableCell>
<TableCell className='whitespace-nowrap px-3 text-[12px] text-muted-foreground'>
<TableCell className='whitespace-nowrap px-[12px] py-[6px] text-[12px] text-[var(--text-muted)]'>
{formatDate(file.uploadedAt)}
</TableCell>
<TableCell className='px-3'>
<div className='flex items-center gap-1'>
<TableCell className='px-[12px] py-[6px]'>
<div className='flex items-center gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleDownload(file)}
className='h-6 w-6 p-0'
className='h-[28px] w-[28px] p-0'
disabled={downloadingFileId === file.id}
aria-label={`Download ${file.name}`}
>
<ArrowDown className='h-[14px] w-[14px]' />
{downloadingFileId === file.id ? (
<Loader2 className='h-[14px] w-[14px] animate-spin' />
) : (
<ArrowDown className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Download file</Tooltip.Content>
@@ -339,7 +439,7 @@ export function Files() {
<Button
variant='ghost'
onClick={() => handleDelete(file)}
className='h-6 w-6 p-0'
className='h-[28px] w-[28px] p-0'
disabled={deleteFile.isPending}
aria-label={`Delete ${file.name}`}
>

Some files were not shown because too many files have changed in this diff Show More