feat(ui): added component playground & fixed training modal (#2354)

This commit is contained in:
Waleed
2025-12-12 21:05:26 -08:00
committed by GitHub
parent 65ac64c2cf
commit 9c8d845ba4
8 changed files with 1167 additions and 551 deletions

View File

@@ -0,0 +1,566 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, Bell, Folder, Key, Settings, User } from 'lucide-react'
import { notFound, useRouter } from 'next/navigation'
import {
Badge,
Breadcrumb,
BubbleChatPreview,
Button,
Card as CardIcon,
ChevronDown,
Code,
Combobox,
Connections,
Copy,
DocumentAttachment,
Duplicate,
Eye,
FolderCode,
FolderPlus,
HexSimple,
Input,
Key as KeyIcon,
Label,
Layout,
Library,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
ModalTrigger,
MoreHorizontal,
NoWrap,
PanelLeft,
Play,
Popover,
PopoverBackButton,
PopoverContent,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
PopoverSearch,
PopoverSection,
PopoverTrigger,
Redo,
Rocket,
SModal,
SModalContent,
SModalMain,
SModalMainBody,
SModalMainHeader,
SModalSidebar,
SModalSidebarHeader,
SModalSidebarItem,
SModalSidebarSection,
SModalSidebarSectionTitle,
SModalTrigger,
Switch,
Textarea,
Tooltip,
Trash,
Trash2,
Undo,
Wrap,
ZoomIn,
ZoomOut,
} from '@/components/emcn'
import { env, isTruthy } from '@/lib/core/config/env'
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className='space-y-4'>
<h2 className='border-[var(--border)] border-b pb-2 font-medium text-[var(--text-primary)] text-lg'>
{title}
</h2>
<div className='space-y-4'>{children}</div>
</section>
)
}
function VariantRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className='flex items-center gap-4'>
<span className='w-32 shrink-0 text-[var(--text-secondary)] text-sm'>{label}</span>
<div className='flex flex-wrap items-center gap-2'>{children}</div>
</div>
)
}
const SAMPLE_CODE = `function greet(name) {
console.log("Hello, " + name);
return { success: true };
}`
const SAMPLE_PYTHON = `def greet(name):
print(f"Hello, {name}")
return {"success": True}`
const COMBOBOX_OPTIONS = [
{ label: 'Option 1', value: 'opt1' },
{ label: 'Option 2', value: 'opt2' },
{ label: 'Option 3', value: 'opt3' },
]
export default function PlaygroundPage() {
const router = useRouter()
const [comboboxValue, setComboboxValue] = useState('')
const [switchValue, setSwitchValue] = useState(false)
const [activeTab, setActiveTab] = useState('profile')
if (!isTruthy(env.NEXT_PUBLIC_ENABLE_PLAYGROUND)) {
notFound()
}
return (
<Tooltip.Provider>
<div className='relative min-h-screen bg-[var(--bg)] p-8'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => router.back()}
className='absolute top-8 left-8 h-8 w-8 p-0'
>
<ArrowLeft className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Go back</Tooltip.Content>
</Tooltip.Root>
<div className='mx-auto max-w-4xl space-y-12'>
<div>
<h1 className='font-semibold text-2xl text-[var(--text-primary)]'>
EMCN Component Playground
</h1>
<p className='mt-2 text-[var(--text-secondary)]'>
All emcn UI components and their variants
</p>
</div>
{/* Button */}
<Section title='Button'>
<VariantRow label='default'>
<Button variant='default'>Default</Button>
</VariantRow>
<VariantRow label='active'>
<Button variant='active'>Active</Button>
</VariantRow>
<VariantRow label='3d'>
<Button variant='3d'>3D</Button>
</VariantRow>
<VariantRow label='outline'>
<Button variant='outline'>Outline</Button>
</VariantRow>
<VariantRow label='primary'>
<Button variant='primary'>Primary</Button>
</VariantRow>
<VariantRow label='secondary'>
<Button variant='secondary'>Secondary</Button>
</VariantRow>
<VariantRow label='tertiary'>
<Button variant='tertiary'>Tertiary</Button>
</VariantRow>
<VariantRow label='ghost'>
<Button variant='ghost'>Ghost</Button>
</VariantRow>
<VariantRow label='ghost-secondary'>
<Button variant='ghost-secondary'>Ghost Secondary</Button>
</VariantRow>
<VariantRow label='disabled'>
<Button disabled>Disabled</Button>
</VariantRow>
</Section>
{/* Badge */}
<Section title='Badge'>
<VariantRow label='default'>
<Badge variant='default'>Default</Badge>
</VariantRow>
<VariantRow label='outline'>
<Badge variant='outline'>Outline</Badge>
</VariantRow>
</Section>
{/* Input */}
<Section title='Input'>
<VariantRow label='default'>
<Input placeholder='Enter text...' className='max-w-xs' />
</VariantRow>
<VariantRow label='disabled'>
<Input placeholder='Disabled' disabled className='max-w-xs' />
</VariantRow>
</Section>
{/* Textarea */}
<Section title='Textarea'>
<Textarea placeholder='Enter your message...' className='max-w-md' rows={4} />
</Section>
{/* Label */}
<Section title='Label'>
<div className='flex flex-col gap-2'>
<Label htmlFor='demo-input'>Label Text</Label>
<Input id='demo-input' placeholder='Input with label' className='max-w-xs' />
</div>
</Section>
{/* Switch */}
<Section title='Switch'>
<VariantRow label='default'>
<Switch checked={switchValue} onCheckedChange={setSwitchValue} />
<span className='text-[var(--text-secondary)] text-sm'>
{switchValue ? 'On' : 'Off'}
</span>
</VariantRow>
</Section>
{/* Combobox */}
<Section title='Combobox'>
<VariantRow label='default'>
<div className='w-48'>
<Combobox
options={COMBOBOX_OPTIONS}
value={comboboxValue}
onChange={setComboboxValue}
placeholder='Select option...'
/>
</div>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-48'>
<Combobox
options={COMBOBOX_OPTIONS}
value=''
onChange={() => {}}
placeholder='Small size'
size='sm'
/>
</div>
</VariantRow>
<VariantRow label='searchable'>
<div className='w-48'>
<Combobox
options={COMBOBOX_OPTIONS}
value=''
onChange={() => {}}
placeholder='With search'
searchable
/>
</div>
</VariantRow>
<VariantRow label='editable'>
<div className='w-48'>
<Combobox
options={COMBOBOX_OPTIONS}
value=''
onChange={() => {}}
placeholder='Type or select...'
editable
/>
</div>
</VariantRow>
<VariantRow label='multiSelect'>
<div className='w-48'>
<Combobox
options={COMBOBOX_OPTIONS}
multiSelectValues={[]}
onMultiSelectChange={() => {}}
placeholder='Select multiple...'
multiSelect
searchable
/>
</div>
</VariantRow>
</Section>
{/* Breadcrumb */}
<Section title='Breadcrumb'>
<Breadcrumb
items={[
{ label: 'Home', href: '#' },
{ label: 'Settings', href: '#' },
{ label: 'Profile' },
]}
/>
</Section>
{/* Tooltip */}
<Section title='Tooltip'>
<VariantRow label='default'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='default'>Hover me</Button>
</Tooltip.Trigger>
<Tooltip.Content>Tooltip content</Tooltip.Content>
</Tooltip.Root>
</VariantRow>
</Section>
{/* Popover */}
<Section title='Popover'>
<VariantRow label='default'>
<Popover>
<PopoverTrigger asChild>
<Button variant='default'>Open Popover</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverSection>Section Title</PopoverSection>
<PopoverItem>Item 1</PopoverItem>
<PopoverItem>Item 2</PopoverItem>
<PopoverItem active>Active Item</PopoverItem>
</PopoverContent>
</Popover>
</VariantRow>
<VariantRow label='primary variant'>
<Popover variant='primary'>
<PopoverTrigger asChild>
<Button variant='primary'>Primary Popover</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverItem>Item 1</PopoverItem>
<PopoverItem active>Active Item</PopoverItem>
</PopoverContent>
</Popover>
</VariantRow>
<VariantRow label='with search'>
<Popover>
<PopoverTrigger asChild>
<Button variant='default'>Searchable Popover</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverSearch placeholder='Search items...' />
<PopoverScrollArea className='max-h-40'>
<PopoverItem>Apple</PopoverItem>
<PopoverItem>Banana</PopoverItem>
<PopoverItem>Cherry</PopoverItem>
<PopoverItem>Date</PopoverItem>
<PopoverItem>Elderberry</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
</Popover>
</VariantRow>
<VariantRow label='with folders'>
<Popover>
<PopoverTrigger asChild>
<Button variant='default'>Folder Navigation</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverBackButton />
<PopoverItem rootOnly>Root Item</PopoverItem>
<PopoverFolder
id='folder1'
title='Folder 1'
icon={<Folder className='h-3 w-3' />}
>
<PopoverItem>Nested Item 1</PopoverItem>
<PopoverItem>Nested Item 2</PopoverItem>
</PopoverFolder>
<PopoverFolder
id='folder2'
title='Folder 2'
icon={<Folder className='h-3 w-3' />}
>
<PopoverItem>Another Nested Item</PopoverItem>
</PopoverFolder>
</PopoverContent>
</Popover>
</VariantRow>
</Section>
{/* Modal */}
<Section title='Modal'>
<VariantRow label='sizes'>
{(['sm', 'md', 'lg', 'xl', 'full'] as const).map((size) => (
<Modal key={size}>
<ModalTrigger asChild>
<Button variant='default'>{size}</Button>
</ModalTrigger>
<ModalContent size={size}>
<ModalHeader>Modal {size.toUpperCase()}</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>This is a {size} sized modal.</p>
</ModalBody>
<ModalFooter>
<Button variant='ghost'>Cancel</Button>
<Button variant='primary'>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
))}
</VariantRow>
<VariantRow label='with tabs'>
<Modal>
<ModalTrigger asChild>
<Button variant='default'>Modal with Tabs</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>Settings</ModalHeader>
<ModalTabs defaultValue='tab1'>
<ModalTabsList>
<ModalTabsTrigger value='tab1'>General</ModalTabsTrigger>
<ModalTabsTrigger value='tab2'>Advanced</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalTabsContent value='tab1'>
<p className='text-[var(--text-secondary)]'>General settings content</p>
</ModalTabsContent>
<ModalTabsContent value='tab2'>
<p className='text-[var(--text-secondary)]'>Advanced settings content</p>
</ModalTabsContent>
</ModalBody>
</ModalTabs>
<ModalFooter>
<Button variant='primary'>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VariantRow>
</Section>
{/* SModal (Sidebar Modal) */}
<Section title='SModal (Sidebar Modal)'>
<SModal>
<SModalTrigger asChild>
<Button variant='default'>Open Sidebar Modal</Button>
</SModalTrigger>
<SModalContent>
<SModalSidebar>
<SModalSidebarHeader>Settings</SModalSidebarHeader>
<SModalSidebarSection>
<SModalSidebarSectionTitle>Account</SModalSidebarSectionTitle>
<SModalSidebarItem
icon={<User />}
active={activeTab === 'profile'}
onClick={() => setActiveTab('profile')}
>
Profile
</SModalSidebarItem>
<SModalSidebarItem
icon={<Key />}
active={activeTab === 'security'}
onClick={() => setActiveTab('security')}
>
Security
</SModalSidebarItem>
</SModalSidebarSection>
<SModalSidebarSection>
<SModalSidebarSectionTitle>Preferences</SModalSidebarSectionTitle>
<SModalSidebarItem
icon={<Bell />}
active={activeTab === 'notifications'}
onClick={() => setActiveTab('notifications')}
>
Notifications
</SModalSidebarItem>
<SModalSidebarItem
icon={<Settings />}
active={activeTab === 'general'}
onClick={() => setActiveTab('general')}
>
General
</SModalSidebarItem>
</SModalSidebarSection>
</SModalSidebar>
<SModalMain>
<SModalMainHeader>
{activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
</SModalMainHeader>
<SModalMainBody>
<p className='text-[var(--text-secondary)]'>Content for {activeTab} tab</p>
</SModalMainBody>
</SModalMain>
</SModalContent>
</SModal>
</Section>
{/* Code */}
<Section title='Code'>
<VariantRow label='javascript'>
<div className='w-full max-w-lg'>
<Code.Viewer code={SAMPLE_CODE} language='javascript' showGutter />
</div>
</VariantRow>
<VariantRow label='json'>
<div className='w-full max-w-lg'>
<Code.Viewer
code={JSON.stringify({ name: 'Sim', version: '1.0' }, null, 2)}
language='json'
showGutter
/>
</div>
</VariantRow>
<VariantRow label='python'>
<div className='w-full max-w-lg'>
<Code.Viewer code={SAMPLE_PYTHON} language='python' showGutter />
</div>
</VariantRow>
<VariantRow label='no gutter'>
<div className='w-full max-w-lg'>
<Code.Viewer code={SAMPLE_CODE} language='javascript' />
</div>
</VariantRow>
<VariantRow label='wrap text'>
<div className='w-full max-w-lg'>
<Code.Viewer
code="const longLine = 'This is a very long line that should wrap when wrapText is enabled to demonstrate the text wrapping functionality';"
language='javascript'
showGutter
wrapText
/>
</div>
</VariantRow>
</Section>
{/* Icons */}
<Section title='Icons'>
<div className='grid grid-cols-6 gap-4 sm:grid-cols-8 md:grid-cols-10'>
{[
{ Icon: BubbleChatPreview, name: 'BubbleChatPreview' },
{ Icon: CardIcon, name: 'Card' },
{ Icon: ChevronDown, name: 'ChevronDown' },
{ Icon: Connections, name: 'Connections' },
{ Icon: Copy, name: 'Copy' },
{ Icon: DocumentAttachment, name: 'DocumentAttachment' },
{ Icon: Duplicate, name: 'Duplicate' },
{ Icon: Eye, name: 'Eye' },
{ Icon: FolderCode, name: 'FolderCode' },
{ Icon: FolderPlus, name: 'FolderPlus' },
{ Icon: HexSimple, name: 'HexSimple' },
{ Icon: KeyIcon, name: 'Key' },
{ Icon: Layout, name: 'Layout' },
{ Icon: Library, name: 'Library' },
{ Icon: MoreHorizontal, name: 'MoreHorizontal' },
{ Icon: NoWrap, name: 'NoWrap' },
{ Icon: PanelLeft, name: 'PanelLeft' },
{ Icon: Play, name: 'Play' },
{ Icon: Redo, name: 'Redo' },
{ Icon: Rocket, name: 'Rocket' },
{ Icon: Trash, name: 'Trash' },
{ Icon: Trash2, name: 'Trash2' },
{ Icon: Undo, name: 'Undo' },
{ Icon: Wrap, name: 'Wrap' },
{ Icon: ZoomIn, name: 'ZoomIn' },
{ Icon: ZoomOut, name: 'ZoomOut' },
].map(({ Icon, name }) => (
<Tooltip.Root key={name}>
<Tooltip.Trigger asChild>
<div className='flex h-10 w-10 cursor-pointer items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-5)]'>
<Icon className='h-5 w-5 text-[var(--text-secondary)]' />
</div>
</Tooltip.Trigger>
<Tooltip.Content>{name}</Tooltip.Content>
</Tooltip.Root>
))}
</div>
</Section>
</div>
</div>
</Tooltip.Provider>
)
}

View File

@@ -7,7 +7,6 @@ export { Panel } from './panel/panel'
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
export { SubflowNodeComponent } from './subflows/subflow-node'
export { Terminal } from './terminal/terminal'
export { TrainingControls } from './training-controls/training-controls'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowEdge } from './workflow-edge/workflow-edge'

View File

@@ -9,15 +9,19 @@ import {
Check,
ChevronDown,
Clipboard,
Database,
Filter,
FilterX,
MoreHorizontal,
Palette,
Pause,
RepeatIcon,
Search,
SplitIcon,
Trash2,
X,
} from 'lucide-react'
import Link from 'next/link'
import { useShallow } from 'zustand/react/shallow'
import {
Button,
@@ -30,6 +34,7 @@ import {
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -38,6 +43,8 @@ import {
useTerminalResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
import { getBlock } from '@/blocks'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import type { ConsoleEntry } from '@/stores/terminal'
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -331,6 +338,14 @@ export function Terminal() {
const outputSearchInputRef = useRef<HTMLInputElement>(null)
const outputContentRef = useRef<HTMLDivElement>(null)
// Training controls state
const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false)
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
const { isTraining, toggleModal: toggleTrainingModal, stopTraining } = useCopilotTrainingStore()
// Playground state
const [isPlaygroundEnabled, setIsPlaygroundEnabled] = useState(false)
// Terminal resize hooks
const { handleMouseDown } = useTerminalResize()
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
@@ -612,6 +627,26 @@ export function Terminal() {
[activeWorkflowId, exportConsoleCSV]
)
/**
* Handle training button click - toggle training state or open modal
*/
const handleTrainingClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (isTraining) {
stopTraining()
} else {
toggleTrainingModal()
}
},
[isTraining, stopTraining, toggleTrainingModal]
)
/**
* Whether training controls should be visible
*/
const shouldShowTrainingButton = isTrainingEnvEnabled && showTrainingControls
/**
* Register global keyboard shortcuts for the terminal:
* - Mod+D: Clear terminal console for the active workflow
@@ -640,6 +675,14 @@ export function Terminal() {
setHasHydrated(true)
}, [setHasHydrated])
/**
* Check environment variables on mount
*/
useEffect(() => {
setIsTrainingEnvEnabled(isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED')))
setIsPlaygroundEnabled(isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND')))
}, [])
/**
* Adjust showInput when selected entry changes
* Stay on input view if the new entry has input data
@@ -1104,6 +1147,48 @@ export function Terminal() {
)}
{!selectedEntry && (
<div className='ml-auto flex items-center gap-[8px]'>
{isPlaygroundEnabled && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Link href='/playground'>
<Button
variant='ghost'
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-3 w-3' />
</Button>
</Link>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Component Playground</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{shouldShowTrainingButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleTrainingClick}
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
className={clsx(
'!p-1.5 -m-1.5',
isTraining && 'text-orange-600 dark:text-orange-400'
)}
>
{isTraining ? (
<Pause className='h-3 w-3' />
) : (
<Database className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{hasActiveFilters && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -1426,6 +1511,50 @@ export function Terminal() {
</Tooltip.Root>
)}
{isPlaygroundEnabled && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Link href='/playground'>
<Button
variant='ghost'
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-[12px] w-[12px]' />
</Button>
</Link>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Component Playground</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{shouldShowTrainingButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleTrainingClick}
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
className={clsx(
'!p-1.5 -m-1.5',
isTraining && 'text-orange-600 dark:text-orange-400'
)}
>
{isTraining ? (
<Pause className='h-[12px] w-[12px]' />
) : (
<Database className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -1,40 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { TrainingFloatingButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-floating-button'
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-modal'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useGeneralStore } from '@/stores/settings/general/store'
/**
* Main training controls component that manages the training UI
* Only renders if COPILOT_TRAINING_ENABLED env var is set AND user has enabled it in settings
*/
export function TrainingControls() {
const [isEnvEnabled, setIsEnvEnabled] = useState(false)
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
const { isTraining, showModal, toggleModal } = useCopilotTrainingStore()
// Check environment variable on mount
useEffect(() => {
// Use getEnv to check if training is enabled
const trainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
setIsEnvEnabled(trainingEnabled)
}, [])
// Don't render if not enabled by env var OR user settings
if (!isEnvEnabled || !showTrainingControls) {
return null
}
return (
<>
{/* Floating button to start/stop training */}
<TrainingFloatingButton isTraining={isTraining} onToggleModal={toggleModal} />
{/* Modal for entering prompt and viewing dataset */}
{showModal && <TrainingModal />}
</>
)
}

View File

@@ -1,78 +0,0 @@
'use client'
import { Database, Pause } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/core/utils/cn'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
interface TrainingFloatingButtonProps {
isTraining: boolean
onToggleModal: () => void
}
/**
* Floating button positioned above the diff controls
* Shows training state and allows starting/stopping training
*/
export function TrainingFloatingButton({ isTraining, onToggleModal }: TrainingFloatingButtonProps) {
const { stopTraining } = useCopilotTrainingStore()
const handleClick = () => {
if (isTraining) {
// Stop and save the training session
const dataset = stopTraining()
if (dataset) {
// Show a brief success indicator
const button = document.getElementById('training-button')
if (button) {
button.classList.add('animate-pulse')
setTimeout(() => button.classList.remove('animate-pulse'), 1000)
}
}
} else {
// Open modal to start new training
onToggleModal()
}
}
return (
<div className='-translate-x-1/2 fixed bottom-32 left-1/2 z-30'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
id='training-button'
variant='outline'
size='sm'
onClick={handleClick}
className={cn(
'flex items-center gap-2 rounded-[14px] border bg-card/95 px-3 py-2 shadow-lg backdrop-blur-sm transition-all',
'hover:bg-muted/80',
isTraining &&
'border-orange-500 bg-orange-50 dark:border-orange-400 dark:bg-orange-950/30'
)}
>
{isTraining ? (
<>
<Pause className='h-4 w-4 text-orange-600 dark:text-orange-400' />
<span className='font-medium text-orange-700 text-sm dark:text-orange-300'>
Stop Training
</span>
</>
) : (
<>
<Database className='h-4 w-4' />
<span className='font-medium text-sm'>Train Copilot</span>
</>
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isTraining
? 'Stop recording and save training dataset'
: 'Start recording workflow changes for training'}
</Tooltip.Content>
</Tooltip.Root>
</div>
)
}

View File

@@ -13,20 +13,21 @@ import {
X,
XCircle,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
Textarea,
} from '@/components/emcn'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
@@ -74,6 +75,7 @@ export function TrainingModal() {
const [liveWorkflowFailed, setLiveWorkflowFailed] = useState(false)
const [liveWorkflowTitle, setLiveWorkflowTitle] = useState('')
const [liveWorkflowDescription, setLiveWorkflowDescription] = useState('')
const [activeTab, setActiveTab] = useState(isTraining ? 'datasets' : 'new')
const handleStart = () => {
if (localTitle.trim() && localPrompt.trim()) {
@@ -340,438 +342,470 @@ export function TrainingModal() {
}
return (
<Dialog open={showModal} onOpenChange={toggleModal}>
<DialogContent className='max-w-3xl'>
<DialogHeader>
<DialogTitle>Copilot Training Dataset Builder</DialogTitle>
<DialogDescription>
Record workflow editing sessions to create training datasets for the copilot
</DialogDescription>
</DialogHeader>
<Modal open={showModal} onOpenChange={toggleModal}>
<ModalContent size='lg'>
<ModalHeader>Copilot Training Dataset Builder</ModalHeader>
{isTraining && (
<>
<div className='mt-4 rounded-lg border bg-orange-50 p-4 dark:bg-orange-950/30'>
<p className='mb-2 font-medium text-orange-700 dark:text-orange-300'>
Recording: {currentTitle}
</p>
<p className='mb-3 text-sm'>{currentPrompt}</p>
<div className='flex gap-2'>
<Button variant='outline' size='sm' onClick={cancelTraining} className='flex-1'>
<X className='mr-2 h-4 w-4' />
Cancel
</Button>
<Button
variant='default'
size='sm'
onClick={() => {
useCopilotTrainingStore.getState().stopTraining()
setLocalPrompt('')
}}
className='flex-1'
>
<Check className='mr-2 h-4 w-4' />
Save Dataset
</Button>
<ModalTabs value={activeTab} onValueChange={setActiveTab}>
<ModalTabsList>
<ModalTabsTrigger value='new' disabled={isTraining}>
New Session
</ModalTabsTrigger>
<ModalTabsTrigger value='datasets'>Datasets ({datasets.length})</ModalTabsTrigger>
<ModalTabsTrigger value='live'>Send Live State</ModalTabsTrigger>
</ModalTabsList>
<ModalBody className='flex min-h-[400px] flex-col overflow-hidden'>
{/* Recording Banner */}
{isTraining && (
<div className='mb-[16px] rounded-[8px] border bg-orange-50 p-[12px] dark:bg-orange-950/30'>
<p className='mb-[8px] font-medium text-[13px] text-orange-700 dark:text-orange-300'>
Recording: {currentTitle}
</p>
<p className='mb-[12px] text-[12px] text-[var(--text-secondary)]'>
{currentPrompt}
</p>
<div className='flex gap-[8px]'>
<Button variant='default' onClick={cancelTraining} className='flex-1'>
<X className='mr-[6px] h-[14px] w-[14px]' />
Cancel
</Button>
<Button
variant='primary'
onClick={() => {
useCopilotTrainingStore.getState().stopTraining()
setLocalPrompt('')
}}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90 flex-1'
>
<Check className='mr-[6px] h-[14px] w-[14px]' />
Save Dataset
</Button>
</div>
{startSnapshot && (
<div className='mt-[8px] flex items-center gap-[12px] text-[12px]'>
<span className='text-orange-600 dark:text-orange-400'>Starting state:</span>
<span className='text-[var(--text-primary)]'>
{Object.keys(startSnapshot.blocks).length} blocks
</span>
<span className='text-[var(--text-tertiary)]'>·</span>
<span className='text-[var(--text-primary)]'>
{startSnapshot.edges.length} edges
</span>
</div>
)}
</div>
</div>
)}
{startSnapshot && (
<div className='mt-3 rounded-lg border p-3'>
<p className='mb-2 font-medium text-sm'>Starting State</p>
<p className='text-muted-foreground text-xs'>
{Object.keys(startSnapshot.blocks).length} blocks, {startSnapshot.edges.length}{' '}
edges
{/* New Training Session Tab */}
<ModalTabsContent value='new' className='flex flex-col gap-[16px]'>
<div className='flex items-center gap-[16px] text-[13px]'>
<span className='text-[var(--text-muted)]'>Current workflow:</span>
<span className='text-[var(--text-primary)]'>
{currentWorkflow.getBlockCount()} blocks
</span>
<span className='text-[var(--text-tertiary)]'>·</span>
<span className='text-[var(--text-primary)]'>
{currentWorkflow.getEdgeCount()} edges
</span>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='title'>Title</Label>
<Input
id='title'
placeholder='Enter a title for this training dataset...'
value={localTitle}
onChange={(e) => setLocalTitle(e.target.value)}
className='h-9'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='prompt'>Training Prompt</Label>
<Textarea
id='prompt'
placeholder='Enter the user intent/prompt for this workflow transformation...'
value={localPrompt}
onChange={(e) => setLocalPrompt(e.target.value)}
rows={3}
/>
<p className='text-[12px] text-[var(--text-muted)]'>
Describe what the next sequence of edits aim to achieve
</p>
</div>
)}
</>
)}
<Tabs defaultValue={isTraining ? 'datasets' : 'new'} className='mt-4'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='new' disabled={isTraining}>
New Session
</TabsTrigger>
<TabsTrigger value='datasets'>Datasets ({datasets.length})</TabsTrigger>
<TabsTrigger value='live'>Send Live State</TabsTrigger>
</TabsList>
<Button
onClick={handleStart}
disabled={!localTitle.trim() || !localPrompt.trim()}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90 w-full'
>
Start Training Session
</Button>
</ModalTabsContent>
{/* New Training Session Tab */}
<TabsContent value='new' className='space-y-4'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='mb-2 font-medium text-muted-foreground text-sm'>
Current Workflow State
</p>
<p className='text-sm'>
{currentWorkflow.getBlockCount()} blocks, {currentWorkflow.getEdgeCount()} edges
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='title'>Title</Label>
<Input
id='title'
placeholder='Enter a title for this training dataset...'
value={localTitle}
onChange={(e) => setLocalTitle(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='prompt'>Training Prompt</Label>
<Textarea
id='prompt'
placeholder='Enter the user intent/prompt for this workflow transformation...'
value={localPrompt}
onChange={(e) => setLocalPrompt(e.target.value)}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
Describe what the next sequence of edits aim to achieve
</p>
</div>
<Button
onClick={handleStart}
disabled={!localTitle.trim() || !localPrompt.trim()}
className='w-full'
>
Start Training Session
</Button>
</TabsContent>
{/* Datasets Tab */}
<TabsContent value='datasets' className='space-y-4'>
{datasets.length === 0 ? (
<div className='py-8 text-center text-muted-foreground'>
No training datasets yet. Start a new session to create one.
</div>
) : (
<>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<Checkbox
checked={datasets.length > 0 && selectedDatasets.size === datasets.length}
onCheckedChange={toggleSelectAll}
disabled={datasets.length === 0}
/>
<p className='text-muted-foreground text-sm'>
{selectedDatasets.size > 0
? `${selectedDatasets.size} of ${datasets.length} selected`
: `${datasets.length} dataset${datasets.length !== 1 ? 's' : ''} recorded`}
</p>
</div>
<div className='flex gap-2'>
{selectedDatasets.size > 0 && (
<Button
variant='default'
size='sm'
onClick={handleSendSelected}
disabled={sendingSelected}
>
<Send className='mr-2 h-4 w-4' />
{sendingSelected ? 'Sending...' : `Send ${selectedDatasets.size} Selected`}
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={handleSendAll}
disabled={datasets.length === 0 || sendingAll}
>
<Send className='mr-2 h-4 w-4' />
{sendingAll ? 'Sending...' : 'Send All'}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleExportAll}
disabled={datasets.length === 0}
>
<Download className='mr-2 h-4 w-4' />
Export
</Button>
<Button
variant='outline'
size='sm'
onClick={clearDatasets}
disabled={datasets.length === 0}
>
<Trash2 className='mr-2 h-4 w-4' />
Clear
</Button>
</div>
{/* Datasets Tab */}
<ModalTabsContent value='datasets' className='flex flex-col gap-[16px]'>
{datasets.length === 0 ? (
<div className='py-[32px] text-center text-[13px] text-[var(--text-muted)]'>
No training datasets yet. Start a new session to create one.
</div>
<ScrollArea className='h-[400px]'>
<div className='space-y-3'>
{datasets.map((dataset, index) => (
<div
key={dataset.id}
className='rounded-lg border bg-card transition-colors hover:bg-muted/50'
>
<div className='flex items-start p-4'>
<Checkbox
checked={selectedDatasets.has(dataset.id)}
onCheckedChange={() => toggleDatasetSelection(dataset.id)}
className='mt-0.5 mr-3'
/>
<button
className='flex flex-1 items-center justify-between text-left'
onClick={() =>
setExpandedDataset(expandedDataset === dataset.id ? null : dataset.id)
}
>
<div className='flex-1'>
<p className='font-medium text-sm'>{dataset.title}</p>
<p className='text-muted-foreground text-xs'>
{dataset.prompt.substring(0, 50)}
{dataset.prompt.length > 50 ? '...' : ''}
</p>
</div>
<div className='flex items-center gap-3'>
{dataset.sentAt && (
<span className='inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-green-700 text-xs ring-1 ring-green-600/20 ring-inset dark:bg-green-900/20 dark:text-green-300'>
<CheckCircle2 className='mr-1 h-3 w-3' /> Sent
</span>
)}
<span className='text-muted-foreground text-xs'>
{dataset.editSequence.length} ops
</span>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
expandedDataset === dataset.id && 'rotate-180'
)}
/>
</div>
</button>
</div>
{expandedDataset === dataset.id && (
<div className='space-y-3 border-t px-4 pt-3 pb-4'>
<div>
<p className='mb-1 font-medium text-sm'>Prompt</p>
<p className='text-muted-foreground text-sm'>{dataset.prompt}</p>
</div>
<div>
<p className='mb-1 font-medium text-sm'>Statistics</p>
<div className='grid grid-cols-2 gap-2 text-sm'>
<div>
<span className='text-muted-foreground'>Duration:</span>{' '}
{dataset.metadata?.duration
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
: 'N/A'}
</div>
<div>
<span className='text-muted-foreground'>Operations:</span>{' '}
{dataset.editSequence.length}
</div>
<div>
<span className='text-muted-foreground'>Final blocks:</span>{' '}
{dataset.metadata?.blockCount || 0}
</div>
<div>
<span className='text-muted-foreground'>Final edges:</span>{' '}
{dataset.metadata?.edgeCount || 0}
</div>
</div>
</div>
<div>
<p className='mb-1 font-medium text-sm'>Edit Sequence</p>
<div className='max-h-32 overflow-y-auto rounded border bg-muted/50 p-2'>
<ul className='space-y-1 font-mono text-xs'>
{formatEditSequence(dataset.editSequence).map((desc, i) => (
<li key={i}>{desc}</li>
))}
</ul>
</div>
</div>
<div className='flex gap-2'>
<Button
variant={
sentDatasets.has(dataset.id)
? 'outline'
: failedDatasets.has(dataset.id)
? 'destructive'
: 'outline'
}
size='sm'
onClick={() => handleSendOne(dataset)}
disabled={sendingDatasets.has(dataset.id)}
className={
sentDatasets.has(dataset.id)
? 'border-green-500 text-green-600 hover:bg-green-50 dark:border-green-400 dark:text-green-400 dark:hover:bg-green-950'
: ''
}
>
{sendingDatasets.has(dataset.id) ? (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent' />
Sending...
</>
) : sentDatasets.has(dataset.id) ? (
<>
<CheckCircle2 className='mr-2 h-4 w-4' />
Sent
</>
) : failedDatasets.has(dataset.id) ? (
<>
<XCircle className='mr-2 h-4 w-4' />
Failed
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Send
</>
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setViewingDataset(dataset.id)}
>
<Eye className='mr-2 h-4 w-4' />
View
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleCopyDataset(dataset)}
>
{copiedId === dataset.id ? (
<>
<Check className='mr-2 h-4 w-4' />
Copied!
</>
) : (
<>
<Clipboard className='mr-2 h-4 w-4' />
Copy
</>
)}
</Button>
</div>
{viewingDataset === dataset.id && (
<div className='rounded border bg-muted/50 p-3'>
<pre className='max-h-64 overflow-auto text-xs'>
{JSON.stringify(
{
prompt: dataset.prompt,
editSequence: dataset.editSequence,
metadata: dataset.metadata,
},
null,
2
)}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
</ScrollArea>
</>
)}
</TabsContent>
{/* Send Live State Tab */}
<TabsContent value='live' className='space-y-4'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='mb-2 font-medium text-muted-foreground text-sm'>
Current Workflow State
</p>
<p className='text-sm'>
{currentWorkflow.getBlockCount()} blocks, {currentWorkflow.getEdgeCount()} edges
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='live-title'>Title</Label>
<Input
id='live-title'
placeholder='e.g., Customer Onboarding Workflow'
value={liveWorkflowTitle}
onChange={(e) => setLiveWorkflowTitle(e.target.value)}
/>
<p className='text-muted-foreground text-xs'>
A short title identifying this workflow
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='live-description'>Description</Label>
<Textarea
id='live-description'
placeholder='Describe what this workflow does...'
value={liveWorkflowDescription}
onChange={(e) => setLiveWorkflowDescription(e.target.value)}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
Explain the purpose and functionality of this workflow
</p>
</div>
<Button
onClick={handleSendLiveWorkflow}
disabled={
!liveWorkflowTitle.trim() ||
!liveWorkflowDescription.trim() ||
sendingLiveWorkflow ||
currentWorkflow.getBlockCount() === 0
}
className='w-full'
>
{sendingLiveWorkflow ? (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent' />
Sending...
</>
) : liveWorkflowSent ? (
<>
<CheckCircle2 className='mr-2 h-4 w-4' />
Sent Successfully
</>
) : liveWorkflowFailed ? (
<>
<XCircle className='mr-2 h-4 w-4' />
Failed - Try Again
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Send Live Workflow State
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Checkbox
checked={datasets.length > 0 && selectedDatasets.size === datasets.length}
onCheckedChange={toggleSelectAll}
disabled={datasets.length === 0}
/>
<p className='text-[13px] text-[var(--text-muted)]'>
{selectedDatasets.size > 0
? `${selectedDatasets.size} of ${datasets.length} selected`
: `${datasets.length} dataset${datasets.length !== 1 ? 's' : ''} recorded`}
</p>
</div>
<div className='flex gap-[8px]'>
{selectedDatasets.size > 0 && (
<Button
variant='primary'
onClick={handleSendSelected}
disabled={sendingSelected}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
>
<Send className='mr-[6px] h-[12px] w-[12px]' />
{sendingSelected
? 'Sending...'
: `Send ${selectedDatasets.size} Selected`}
</Button>
)}
<Button
variant='default'
onClick={handleSendAll}
disabled={datasets.length === 0 || sendingAll}
>
<Send className='mr-[6px] h-[12px] w-[12px]' />
{sendingAll ? 'Sending...' : 'Send All'}
</Button>
<Button
variant='default'
onClick={handleExportAll}
disabled={datasets.length === 0}
>
<Download className='mr-[6px] h-[12px] w-[12px]' />
Export
</Button>
<Button
variant='default'
onClick={clearDatasets}
disabled={datasets.length === 0}
>
<Trash2 className='mr-[6px] h-[12px] w-[12px]' />
Clear
</Button>
</div>
</div>
<div className='max-h-[320px] overflow-y-auto'>
<div className='flex flex-col gap-[8px]'>
{datasets.map((dataset, index) => (
<div
key={dataset.id}
className='rounded-[8px] border bg-[var(--surface-3)] transition-colors hover:bg-[var(--surface-5)]'
>
<div className='flex items-start p-[12px]'>
<Checkbox
checked={selectedDatasets.has(dataset.id)}
onCheckedChange={() => toggleDatasetSelection(dataset.id)}
className='mt-[2px] mr-[12px]'
/>
<button
className='flex flex-1 items-center justify-between text-left'
onClick={() =>
setExpandedDataset(
expandedDataset === dataset.id ? null : dataset.id
)
}
>
<div className='flex-1'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
{dataset.title}
</p>
<p className='text-[12px] text-[var(--text-muted)]'>
{dataset.prompt.substring(0, 50)}
{dataset.prompt.length > 50 ? '...' : ''}
</p>
</div>
<div className='flex items-center gap-[12px]'>
{dataset.sentAt && (
<span className='inline-flex items-center rounded-full bg-green-50 px-[8px] py-[2px] text-[11px] text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-900/20 dark:text-green-300'>
<CheckCircle2 className='mr-[4px] h-[10px] w-[10px]' /> Sent
</span>
)}
<span className='text-[12px] text-[var(--text-muted)]'>
{dataset.editSequence.length} ops
</span>
<ChevronDown
className={cn(
'h-[14px] w-[14px] text-[var(--text-muted)] transition-transform',
expandedDataset === dataset.id && 'rotate-180'
)}
/>
</div>
</button>
</div>
{expandedDataset === dataset.id && (
<div className='flex flex-col gap-[12px] border-t px-[12px] pt-[12px] pb-[16px]'>
<div>
<p className='mb-[4px] font-medium text-[13px] text-[var(--text-primary)]'>
Prompt
</p>
<p className='text-[13px] text-[var(--text-secondary)]'>
{dataset.prompt}
</p>
</div>
<div>
<p className='mb-[4px] font-medium text-[13px] text-[var(--text-primary)]'>
Statistics
</p>
<div className='grid grid-cols-2 gap-[8px] text-[13px]'>
<div>
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
<span className='text-[var(--text-secondary)]'>
{dataset.metadata?.duration
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
: 'N/A'}
</span>
</div>
<div>
<span className='text-[var(--text-muted)]'>Operations:</span>{' '}
<span className='text-[var(--text-secondary)]'>
{dataset.editSequence.length}
</span>
</div>
<div>
<span className='text-[var(--text-muted)]'>Final blocks:</span>{' '}
<span className='text-[var(--text-secondary)]'>
{dataset.metadata?.blockCount || 0}
</span>
</div>
<div>
<span className='text-[var(--text-muted)]'>Final edges:</span>{' '}
<span className='text-[var(--text-secondary)]'>
{dataset.metadata?.edgeCount || 0}
</span>
</div>
</div>
</div>
<div>
<p className='mb-[4px] font-medium text-[13px] text-[var(--text-primary)]'>
Edit Sequence
</p>
<div className='max-h-[100px] overflow-y-auto rounded-[6px] border bg-[var(--surface-6)] p-[8px]'>
<ul className='flex flex-col gap-[4px] font-mono text-[11px]'>
{formatEditSequence(dataset.editSequence).map((desc, i) => (
<li key={i} className='text-[var(--text-secondary)]'>
{desc}
</li>
))}
</ul>
</div>
</div>
<div className='flex gap-[8px]'>
<Button
variant={
sentDatasets.has(dataset.id)
? 'default'
: failedDatasets.has(dataset.id)
? 'default'
: 'default'
}
onClick={() => handleSendOne(dataset)}
disabled={sendingDatasets.has(dataset.id)}
className={
sentDatasets.has(dataset.id)
? '!border-green-500 !text-green-600 dark:!border-green-400 dark:!text-green-400'
: failedDatasets.has(dataset.id)
? '!border-red-500 !text-red-600 dark:!border-red-400 dark:!text-red-400'
: ''
}
>
{sendingDatasets.has(dataset.id) ? (
<>
<div className='mr-[6px] h-[12px] w-[12px] animate-spin rounded-full border-2 border-current border-t-transparent' />
Sending...
</>
) : sentDatasets.has(dataset.id) ? (
<>
<CheckCircle2 className='mr-[6px] h-[12px] w-[12px]' />
Sent
</>
) : failedDatasets.has(dataset.id) ? (
<>
<XCircle className='mr-[6px] h-[12px] w-[12px]' />
Failed
</>
) : (
<>
<Send className='mr-[6px] h-[12px] w-[12px]' />
Send
</>
)}
</Button>
<Button
variant='default'
onClick={() => setViewingDataset(dataset.id)}
>
<Eye className='mr-[6px] h-[12px] w-[12px]' />
View
</Button>
<Button
variant='default'
onClick={() => handleCopyDataset(dataset)}
>
{copiedId === dataset.id ? (
<>
<Check className='mr-[6px] h-[12px] w-[12px]' />
Copied!
</>
) : (
<>
<Clipboard className='mr-[6px] h-[12px] w-[12px]' />
Copy
</>
)}
</Button>
</div>
{viewingDataset === dataset.id && (
<div className='rounded-[6px] border bg-[var(--surface-6)] p-[12px]'>
<pre className='max-h-[200px] overflow-auto text-[11px] text-[var(--text-secondary)]'>
{JSON.stringify(
{
prompt: dataset.prompt,
editSequence: dataset.editSequence,
metadata: dataset.metadata,
},
null,
2
)}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</>
)}
</Button>
</ModalTabsContent>
{liveWorkflowSent && (
<div className='rounded-lg border bg-green-50 p-3 dark:bg-green-950/30'>
<p className='text-green-700 text-sm dark:text-green-300'>
Workflow state sent successfully!
{/* Send Live State Tab */}
<ModalTabsContent value='live' className='flex flex-col gap-[16px]'>
<div className='flex items-center gap-[16px] text-[13px]'>
<span className='text-[var(--text-muted)]'>Current workflow:</span>
<span className='text-[var(--text-primary)]'>
{currentWorkflow.getBlockCount()} blocks
</span>
<span className='text-[var(--text-tertiary)]'>·</span>
<span className='text-[var(--text-primary)]'>
{currentWorkflow.getEdgeCount()} edges
</span>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='live-title'>Title</Label>
<Input
id='live-title'
placeholder='e.g., Customer Onboarding Workflow'
value={liveWorkflowTitle}
onChange={(e) => setLiveWorkflowTitle(e.target.value)}
className='h-9'
/>
<p className='text-[12px] text-[var(--text-muted)]'>
A short title identifying this workflow
</p>
</div>
)}
{liveWorkflowFailed && (
<div className='rounded-lg border bg-red-50 p-3 dark:bg-red-950/30'>
<p className='text-red-700 text-sm dark:text-red-300'>
Failed to send workflow state. Please try again.
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='live-description'>Description</Label>
<Textarea
id='live-description'
placeholder='Describe what this workflow does...'
value={liveWorkflowDescription}
onChange={(e) => setLiveWorkflowDescription(e.target.value)}
rows={3}
/>
<p className='text-[12px] text-[var(--text-muted)]'>
Explain the purpose and functionality of this workflow
</p>
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
<Button
onClick={handleSendLiveWorkflow}
disabled={
!liveWorkflowTitle.trim() ||
!liveWorkflowDescription.trim() ||
sendingLiveWorkflow ||
currentWorkflow.getBlockCount() === 0
}
className={cn(
'w-full',
liveWorkflowSent
? '!bg-green-600 !text-white hover:!bg-green-700'
: liveWorkflowFailed
? '!bg-red-600 !text-white hover:!bg-red-700'
: '!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
)}
>
{sendingLiveWorkflow ? (
<>
<div className='mr-[6px] h-[14px] w-[14px] animate-spin rounded-full border-2 border-current border-t-transparent' />
Sending...
</>
) : liveWorkflowSent ? (
<>
<CheckCircle2 className='mr-[6px] h-[14px] w-[14px]' />
Sent Successfully
</>
) : liveWorkflowFailed ? (
<>
<XCircle className='mr-[6px] h-[14px] w-[14px]' />
Failed - Try Again
</>
) : (
<>
<Send className='mr-[6px] h-[14px] w-[14px]' />
Send Live Workflow State
</>
)}
</Button>
{liveWorkflowSent && (
<div className='rounded-[8px] border bg-green-50 p-[12px] dark:bg-green-950/30'>
<p className='text-[13px] text-green-700 dark:text-green-300'>
Workflow state sent successfully!
</p>
</div>
)}
{liveWorkflowFailed && (
<div className='rounded-[8px] border bg-red-50 p-[12px] dark:bg-red-950/30'>
<p className='text-[13px] text-red-700 dark:text-red-300'>
Failed to send workflow state. Please try again.
</p>
</div>
)}
</ModalTabsContent>
</ModalBody>
</ModalTabs>
</ModalContent>
</Modal>
)
}

View File

@@ -24,13 +24,13 @@ import {
Panel,
SubflowNodeComponent,
Terminal,
TrainingControls,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-modal'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import {
@@ -45,6 +45,7 @@ import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useNotificationStore } from '@/stores/notifications/store'
import { useCopilotStore } from '@/stores/panel/copilot/store'
@@ -134,6 +135,9 @@ const WorkflowContent = React.memo(() => {
// Get copilot cleanup function
const copilotCleanup = useCopilotStore((state) => state.cleanup)
// Training modal state
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
// Handle copilot stream cleanup on page unload and component unmount
useStreamCleanup(copilotCleanup)
@@ -2244,8 +2248,8 @@ const WorkflowContent = React.memo(() => {
return (
<div className='flex h-full w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1 transition-all duration-200'>
{/* Training Controls - for recording workflow edits */}
<TrainingControls />
{/* Training Modal - for recording workflow edits */}
{showTrainingModal && <TrainingModal />}
<ReactFlow
nodes={nodes}

View File

@@ -303,7 +303,8 @@ export const env = createEnv({
NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
@@ -352,6 +353,7 @@ export const env = createEnv({
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND,
NEXT_PUBLIC_POSTHOG_ENABLED: process.env.NEXT_PUBLIC_POSTHOG_ENABLED,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NODE_ENV: process.env.NODE_ENV,