mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]'>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.{' '}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
|
||||
export { useIdentifierValidation } from './use-identifier-validation'
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Versions } from './versions'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { DeployModal } from './deploy-modal/deploy-modal'
|
||||
@@ -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!}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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...'
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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>>({})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]' />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useFloatBoundarySync } from './use-float-boundary-sync'
|
||||
export { useFloatDrag } from './use-float-drag'
|
||||
export { useFloatResize } from './use-float-resize'
|
||||
@@ -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 })
|
||||
@@ -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 })
|
||||
@@ -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') {
|
||||
@@ -49,7 +49,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[#22C55E]',
|
||||
'ring-[var(--brand-tertiary)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user