feat: add EditorAIPanel component with Chat and Suggestions tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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