feat: Flow Transfer, Procedural Assist & UI Design System #97

Merged
chihlasm merged 51 commits from feat/flow-transfer-and-procedural-assist into main 2026-03-07 23:44:14 +00:00
4 changed files with 286 additions and 0 deletions
Showing only changes of commit c5d07ce90a - Show all commits

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}