fix: AI Assist panel sits below topbar with slide-in animation
- Panel now uses top:56px to sit below the app shell topbar instead of covering it (matches the main-content grid cell area) - Added slideInRight CSS animation for smooth drawer entrance - Editor pages use dynamic paddingRight via PANEL_WIDTH constant - ChatTab upgraded: markdown rendering, CopilotPanel-style message bubbles, auto-focus input, Shift+Enter hint - All borders use --glass-border for consistent glassmorphism Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
import { Send, Sparkles } from 'lucide-react'
|
import { Send, Sparkles, Loader2 } from 'lucide-react'
|
||||||
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
import type { EditorAIChatMessage } from '@/types'
|
import type { EditorAIChatMessage } from '@/types'
|
||||||
|
|
||||||
interface ChatTabProps {
|
interface ChatTabProps {
|
||||||
@@ -12,11 +13,17 @@ interface ChatTabProps {
|
|||||||
|
|
||||||
export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: ChatTabProps) {
|
export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: ChatTabProps) {
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
// Focus input when panel mounts
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus())
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -26,7 +33,8 @@ export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: C
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
<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" />
|
<Sparkles className="h-8 w-8 mb-2 opacity-40" />
|
||||||
@@ -36,44 +44,52 @@ export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: C
|
|||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`text-sm rounded-lg px-3 py-2 ${
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
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
|
||||||
|
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-primary/15 text-foreground'
|
||||||
|
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 bg-[rgba(255,255,255,0.04)] border border-border rounded-lg px-3 py-2">
|
<div className="flex justify-start">
|
||||||
<div className="flex gap-1">
|
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-xl px-3.5 py-2.5">
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce" />
|
<Loader2 size={16} className="animate-spin text-primary" />
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 border-t border-border p-3">
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Ask AI to help..."
|
placeholder="Ask AI to help..."
|
||||||
rows={1}
|
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"
|
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
disabled={!input.trim() || isLoading}
|
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"
|
className="bg-gradient-brand text-[#101114] p-2.5 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { ChatTab } from './ChatTab'
|
|||||||
import { SuggestionsTab } from './SuggestionsTab'
|
import { SuggestionsTab } from './SuggestionsTab'
|
||||||
import type { EditorAIChatMessage, AISuggestion } from '@/types'
|
import type { EditorAIChatMessage, AISuggestion } from '@/types'
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 380
|
||||||
|
|
||||||
interface EditorAIPanelProps {
|
interface EditorAIPanelProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -23,6 +25,8 @@ interface EditorAIPanelProps {
|
|||||||
|
|
||||||
type Tab = 'chat' | 'suggestions'
|
type Tab = 'chat' | 'suggestions'
|
||||||
|
|
||||||
|
export { PANEL_WIDTH }
|
||||||
|
|
||||||
export function EditorAIPanel({
|
export function EditorAIPanel({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -45,28 +49,37 @@ export function EditorAIPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed right-0 top-0 bottom-0 z-50 flex flex-col border-l"
|
className="fixed right-0 bottom-0 z-40 flex flex-col border-l"
|
||||||
style={{
|
style={{
|
||||||
width: '380px',
|
top: '56px',
|
||||||
|
width: `${PANEL_WIDTH}px`,
|
||||||
background: 'rgba(16, 17, 20, 0.95)',
|
background: 'rgba(16, 17, 20, 0.95)',
|
||||||
backdropFilter: 'var(--glass-blur)',
|
backdropFilter: 'var(--glass-blur)',
|
||||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||||
borderColor: 'var(--glass-border)',
|
borderColor: 'var(--glass-border)',
|
||||||
|
animation: 'slideInRight 200ms ease-out',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2.5 shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles size={16} className="text-primary" />
|
||||||
<span className="text-sm font-medium text-foreground">AI Assist</span>
|
<span className="text-sm font-semibold text-foreground">AI Assist</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NodeSummary node={focalNode} flowName={flowName} flowType={flowType} nodeCount={nodeCount} />
|
<NodeSummary node={focalNode} flowName={flowName} flowType={flowType} nodeCount={nodeCount} />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
<div className="flex border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
<div className="flex border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('chat')}
|
onClick={() => setActiveTab('chat')}
|
||||||
@@ -96,6 +109,7 @@ export function EditorAIPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'chat' ? (
|
{activeTab === 'chat' ? (
|
||||||
<ChatTab messages={messages} input={input} onInputChange={onInputChange} onSend={onSend} isLoading={isLoading} />
|
<ChatTab messages={messages} input={input} onInputChange={onInputChange} onSend={onSend} isLoading={isLoading} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const NODE_COLORS: Record<string, string> = {
|
|||||||
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
|
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border px-3 py-2.5">
|
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layout className="h-3.5 w-3.5 text-muted-foreground" />
|
<Layout className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-xs font-medium text-foreground truncate">
|
<span className="text-xs font-medium text-foreground truncate">
|
||||||
@@ -41,7 +41,7 @@ export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummary
|
|||||||
const colorClass = NODE_COLORS[node.type] || 'text-muted-foreground'
|
const colorClass = NODE_COLORS[node.type] || 'text-muted-foreground'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border px-3 py-2.5">
|
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className={`h-3.5 w-3.5 ${colorClass}`} />
|
<Icon className={`h-3.5 w-3.5 ${colorClass}`} />
|
||||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
|||||||
@@ -162,6 +162,11 @@
|
|||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeInRight {
|
@keyframes fadeInRight {
|
||||||
from { transform: translateX(30px); opacity: 0; }
|
from { transform: translateX(30px); opacity: 0; }
|
||||||
to { transform: translateX(0); opacity: 1; }
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils
|
|||||||
import { StepList } from '@/components/procedural-editor/StepList'
|
import { StepList } from '@/components/procedural-editor/StepList'
|
||||||
import { TagInput } from '@/components/common/TagInput'
|
import { TagInput } from '@/components/common/TagInput'
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
import { Spinner } from '@/components/common/Spinner'
|
||||||
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
import { EditorAIPanel, PANEL_WIDTH } from '@/components/editor-ai/EditorAIPanel'
|
||||||
import { ContextMenu } from '@/components/common/ContextMenu'
|
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||||
import { useEditorAI } from '@/hooks/useEditorAI'
|
import { useEditorAI } from '@/hooks/useEditorAI'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -180,7 +180,7 @@ export function ProceduralEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col overflow-hidden transition-[padding] duration-200', editorAI.isOpen && 'pr-[380px]')}>
|
<div className="flex h-full flex-col overflow-hidden transition-[padding] duration-200" style={{ paddingRight: editorAI.isOpen ? `${PANEL_WIDTH}px` : 0 }}>
|
||||||
{/* Toolbar — sticky */}
|
{/* Toolbar — sticky */}
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { cn, safeGetItem } from '@/lib/utils'
|
|||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||||
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
||||||
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
import { EditorAIPanel, PANEL_WIDTH } from '@/components/editor-ai/EditorAIPanel'
|
||||||
import { ContextMenu } from '@/components/common/ContextMenu'
|
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||||
import { useEditorAI } from '@/hooks/useEditorAI'
|
import { useEditorAI } from '@/hooks/useEditorAI'
|
||||||
import { findNodeInTree } from '@/store/treeEditorStore'
|
import { findNodeInTree } from '@/store/treeEditorStore'
|
||||||
@@ -522,7 +522,7 @@ export function TreeEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col overflow-hidden transition-[padding] duration-200', editorAI.isOpen && 'pr-[380px]')}>
|
<div className="flex h-full flex-col overflow-hidden transition-[padding] duration-200" style={{ paddingRight: editorAI.isOpen ? `${PANEL_WIDTH}px` : 0 }}>
|
||||||
|
|
||||||
{/* Draft Restore Prompt */}
|
{/* Draft Restore Prompt */}
|
||||||
{showDraftPrompt && (
|
{showDraftPrompt && (
|
||||||
|
|||||||
Reference in New Issue
Block a user