feat: Flow Transfer, Procedural Assist & UI Design System #97
80
frontend/src/components/editor-ai/ChatTab.tsx
Normal file
80
frontend/src/components/editor-ai/ChatTab.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Send, Sparkles } from 'lucide-react'
|
||||
import type { EditorAIChatMessage } from '@/types'
|
||||
|
||||
interface ChatTabProps {
|
||||
messages: EditorAIChatMessage[]
|
||||
input: string
|
||||
onInputChange: (value: string) => void
|
||||
onSend: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: ChatTabProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (input.trim() && !isLoading) onSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<Sparkles className="h-8 w-8 mb-2 opacity-40" />
|
||||
<p>Ask me to help build your flow</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-sm rounded-lg px-3 py-2 ${
|
||||
msg.role === 'user'
|
||||
? 'ml-6 bg-primary/15 text-foreground'
|
||||
: 'mr-2 bg-[rgba(255,255,255,0.04)] border border-border text-foreground'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="mr-2 bg-[rgba(255,255,255,0.04)] border border-border rounded-lg px-3 py-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0.15s]" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0.3s]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-border p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask AI to help..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-brand text-[#101114] transition-all hover:opacity-90 active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
frontend/src/components/editor-ai/EditorAIPanel.tsx
Normal file
97
frontend/src/components/editor-ai/EditorAIPanel.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NodeSummary } from './NodeSummary'
|
||||
import { ChatTab } from './ChatTab'
|
||||
import { SuggestionsTab } from './SuggestionsTab'
|
||||
import type { EditorAIChatMessage, AISuggestion } from '@/types'
|
||||
|
||||
interface EditorAIPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
focalNode?: { id: string; type: string; question?: string; title?: string; description?: string } | null
|
||||
flowName?: string
|
||||
flowType?: string
|
||||
nodeCount?: number
|
||||
messages: EditorAIChatMessage[]
|
||||
input: string
|
||||
onInputChange: (value: string) => void
|
||||
onSend: () => void
|
||||
isLoading: boolean
|
||||
suggestions: AISuggestion[]
|
||||
}
|
||||
|
||||
type Tab = 'chat' | 'suggestions'
|
||||
|
||||
export function EditorAIPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
focalNode,
|
||||
flowName,
|
||||
flowType,
|
||||
nodeCount,
|
||||
messages,
|
||||
input,
|
||||
onInputChange,
|
||||
onSend,
|
||||
isLoading,
|
||||
suggestions,
|
||||
}: EditorAIPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('chat')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const pendingCount = suggestions.filter((s) => s.status === 'pending').length
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[320px] shrink-0 flex-col border-l border-border bg-[rgba(24,26,31,0.55)] backdrop-blur-[var(--glass-blur)]">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2.5 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">AI Assist</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-[rgba(255,255,255,0.06)] hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<NodeSummary node={focalNode} flowName={flowName} flowType={flowType} nodeCount={nodeCount} />
|
||||
<div className="flex border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 text-xs font-medium transition-colors',
|
||||
activeTab === 'chat'
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suggestions')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 text-xs font-medium transition-colors relative',
|
||||
activeTab === 'suggestions'
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Suggestions
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/20 px-1 text-[0.5625rem] text-primary">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'chat' ? (
|
||||
<ChatTab messages={messages} input={input} onInputChange={onInputChange} onSend={onSend} isLoading={isLoading} />
|
||||
) : (
|
||||
<SuggestionsTab suggestions={suggestions} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/editor-ai/NodeSummary.tsx
Normal file
59
frontend/src/components/editor-ai/NodeSummary.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { HelpCircle, Zap, CheckCircle, FileText, Layout } from 'lucide-react'
|
||||
|
||||
interface NodeSummaryProps {
|
||||
node?: { id: string; type: string; question?: string; title?: string; description?: string } | null
|
||||
flowName?: string
|
||||
flowType?: string
|
||||
nodeCount?: number
|
||||
}
|
||||
|
||||
const NODE_ICONS: Record<string, typeof HelpCircle> = {
|
||||
decision: HelpCircle,
|
||||
action: Zap,
|
||||
solution: CheckCircle,
|
||||
}
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
decision: 'text-blue-400',
|
||||
action: 'text-yellow-400',
|
||||
solution: 'text-green-400',
|
||||
}
|
||||
|
||||
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="border-b border-border px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{flowName || 'Untitled Flow'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
<span>{flowType || 'flow'}</span>
|
||||
{nodeCount !== undefined && <span>{nodeCount} nodes</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Icon = NODE_ICONS[node.type] || FileText
|
||||
const colorClass = NODE_COLORS[node.type] || 'text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className="border-b border-border px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-3.5 w-3.5 ${colorClass}`} />
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{node.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium text-foreground truncate">
|
||||
{node.question || node.title || node.id}
|
||||
</p>
|
||||
{node.description && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground truncate">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
frontend/src/components/editor-ai/SuggestionsTab.tsx
Normal file
50
frontend/src/components/editor-ai/SuggestionsTab.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Check, X, Clock } from 'lucide-react'
|
||||
import type { AISuggestion } from '@/types'
|
||||
|
||||
interface SuggestionsTabProps {
|
||||
suggestions: AISuggestion[]
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
accepted: { icon: Check, color: 'text-emerald-400', label: 'Accepted' },
|
||||
dismissed: { icon: X, color: 'text-rose-400', label: 'Dismissed' },
|
||||
pending: { icon: Clock, color: 'text-amber-400', label: 'Pending' },
|
||||
} as const
|
||||
|
||||
export function SuggestionsTab({ suggestions }: SuggestionsTabProps) {
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground p-6">
|
||||
No AI suggestions yet
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||||
{suggestions.map((s) => {
|
||||
const config = STATUS_CONFIG[s.status]
|
||||
const StatusIcon = config.icon
|
||||
return (
|
||||
<div key={s.id} className="rounded-lg border border-border bg-[rgba(255,255,255,0.02)] px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{s.action_type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 text-xs ${config.color}`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
{s.target_node_id && (
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">Node: {s.target_node_id}</p>
|
||||
)}
|
||||
<p className="mt-0.5 font-label text-[0.625rem] text-[#5a6170]">
|
||||
{new Date(s.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user