diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 5b70bc5..d159e9c 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -185,6 +185,9 @@
@apply border-b border-border/50 px-3 py-2;
}
+.prose-agent > *:first-child { margin-top: 0; }
+.prose-agent > *:last-child { margin-bottom: 0; }
+
.prose-agent a {
@apply text-primary underline underline-offset-2;
}
@@ -193,6 +196,39 @@
@apply my-6 border-border/50;
}
+/* streaming cursor — inline caret after the last text element */
+.streaming-cursor > *:last-child::after {
+ content: '';
+ display: inline-block;
+ width: 2px;
+ height: 1em;
+ margin-left: 2px;
+ vertical-align: text-bottom;
+ border-radius: 1px;
+ background: hsl(var(--primary));
+ animation: cursor-blink 1s steps(2) infinite;
+}
+@keyframes cursor-blink {
+ 0% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* skeleton shimmer for loading placeholders */
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+.skeleton-shimmer {
+ background: linear-gradient(
+ 90deg,
+ hsl(var(--muted)) 25%,
+ hsl(var(--muted-foreground) / 0.08) 50%,
+ hsl(var(--muted)) 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.8s ease-in-out infinite;
+}
+
/* pulsing dots */
@keyframes pulsing-dot {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
diff --git a/frontend/app/project/[id]/chat/[chatId]/page.tsx b/frontend/app/project/[id]/chat/[chatId]/page.tsx
index 2f1ce91..85c55fc 100644
--- a/frontend/app/project/[id]/chat/[chatId]/page.tsx
+++ b/frontend/app/project/[id]/chat/[chatId]/page.tsx
@@ -1,10 +1,9 @@
'use client'
-import { useCallback, useEffect, useRef } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
-import Image from 'next/image'
-import { AlertCircle } from 'lucide-react'
-import { motion, AnimatePresence, type Variants } from 'motion/react'
+import { AlertCircle, Sparkles } from 'lucide-react'
+import { motion, AnimatePresence, LayoutGroup, type Variants } from 'motion/react'
import {
Conversation,
@@ -29,6 +28,7 @@ import { CardProposals } from '@/components/chat/card-proposals'
import { CardSelectionChips } from '@/components/chat/card-selection-chips'
import { QuestionInput } from '@/components/chat/question-input'
import { PulsingDots } from '@/components/ui/pulsing-dots'
+import { BrandLogo } from '@/components/shared/brand-logo'
import { useProjectStore } from '@/lib/stores/project-store'
import { useChatsStore } from '@/lib/stores/chats-store'
@@ -132,10 +132,17 @@ export default function ProjectChatPage() {
}
}, [isStreaming, hasPendingQuestions])
+ // Track whether we went through the analyzing phase, so we only
+ // animate suggestion chips when they replace skeletons (not on reload).
+ // Uses the "set state during render" pattern (React 19 compliant).
+ const [wasAnalyzing, setWasAnalyzing] = useState(false)
+
const hasSources = sources.length > 0
- const showAnalyzing = isAnalyzing && messages.length === 0 && !isLoading
- const showSuggestions = hasSources && messages.length === 0 && !isAnalyzing && !isLoading && suggestedQuestions.length > 0
- const showWelcome = !hasSources && messages.length === 0 && !isAnalyzing && !isStreaming && !isLoading
+ const isEmpty = messages.length === 0 && !isStreaming && !isLoading
+ const showAnalyzing = isAnalyzing && isEmpty
+ if (showAnalyzing && !wasAnalyzing) setWasAnalyzing(true)
+ const showSuggestions = hasSources && isEmpty && !isAnalyzing && suggestedQuestions.length > 0
+ const showWelcome = !hasSources && isEmpty && !isAnalyzing
const showWaitingIndicator = isStreaming && streamingMessage
&& (streamingMessage.cot_entries ?? []).length === 0 && !streamingMessage.content
&& !streamingMessage.active_tool
@@ -144,169 +151,234 @@ export default function ProjectChatPage() {
? 'Ask a question about your data...'
: 'Ask me anything, or upload data to analyze...'
- return (
-
-
-
- {isLoading && (
-
- )}
-
- {showAnalyzing && (
-
-
-
- )}
-
- {showWelcome && (
-
-
- Welcome!
-
-
- Upload data to analyze, or just ask me anything about data analysis, statistics, or visualization.
-
-
- )}
+ // ── Prompt bar (shared via layoutId for smooth position animation) ──
+ const promptBar = (
+
+
+
+
+
+
+
+
+
+
+ )
-
- {messages.map((msg) => (
-
-
- {msg.role === 'assistant' && (
-
- )}
- {msg.role === 'assistant' && msg.todos && msg.todos.length > 0 && (
-
- )}
- {msg.content && msg.is_error ? (
-
- ) : msg.content ? (
-
- {msg.content}
-
- ) : null}
- {msg.has_figures && msg.figure_count > 0 && (
-
- )}
- {msg.proposals && msg.proposals.length > 0 && (
-
- )}
- {msg.asked_questions && msg.asked_questions.length > 0 && (
-
- )}
-
-
- ))}
-
-
- {showWaitingIndicator && (
-
-
-
- )}
+ // Which phase are the suggestion chips in?
+ const suggestionsPhase = showAnalyzing
+ ? 'skeleton' as const
+ : showSuggestions
+ ? 'ready' as const
+ : null
- {isStreaming && streamingMessage && !showWaitingIndicator && (
+ return (
+
+
+ {/* ── Empty state: centered hero ── */}
+
+ {isEmpty && (
-
-
- {streamingMessage.todos && streamingMessage.todos.length > 0 && (
-
- )}
- {streamingMessage.content && (
-
-
- {streamingMessage.content}
-
-
-
+
+ {/* Hero */}
+
+
+
+
+ {showAnalyzing
+ ? 'Analyzing your data...'
+ : hasSources
+ ? 'Your data is ready. What would you like to explore?'
+ : 'Upload data to analyze, or just ask me anything.'}
+
+
+
+
+ {/* Suggestions / skeleton placeholders */}
+ {suggestionsPhase && (
+
+
+ {suggestionsPhase === 'skeleton'
+ ? Array.from({ length: 4 }, (_, i) => (
+
+
+
+
+ ))
+ : suggestedQuestions.map((q, i) => (
+ handleSuggestionClick(q)}
+ initial={wasAnalyzing ? { opacity: 0, y: 6 } : false}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.3, delay: wasAnalyzing ? i * 0.07 : 0 }}
+ className="group/chip flex items-start gap-2.5 rounded-xl border border-border/50 bg-card/60 px-4 py-3 text-left text-sm text-foreground backdrop-blur-sm transition-all hover:border-primary/30 hover:bg-card hover:shadow-md active:scale-[0.98]"
+ >
+
+ {q}
+
+ ))}
+
+
)}
-
+
+ {/* Prompt bar — centered position */}
+
+ {promptBar}
+
+
)}
+
+
+ {/* ── Conversation layout ── */}
+ {!isEmpty && (
+ <>
+
+
+ {isLoading && (
+
+ )}
+
+
+ {messages.map((msg) => (
+
+
+ {msg.role === 'assistant' && (
+
+ )}
+ {msg.role === 'assistant' && msg.todos && msg.todos.length > 0 && (
+
+ )}
+ {msg.content && msg.is_error ? (
+
+ ) : msg.content ? (
+
+ {msg.content}
+
+ ) : null}
+ {msg.has_figures && msg.figure_count > 0 && (
+
+ )}
+ {msg.proposals && msg.proposals.length > 0 && (
+
+ )}
+ {msg.asked_questions && msg.asked_questions.length > 0 && (
+
+ )}
+
+
+ ))}
+
-
-
-
-
-
-
- {showSuggestions && (
-
- {suggestedQuestions.map((q, i) => (
-
handleSuggestionClick(q)}
- className="rounded-lg border border-border/60 bg-card px-3 py-2 text-left text-sm text-foreground shadow-sm transition-all hover:border-primary/30 hover:shadow-md"
- >
- {q}
-
- ))}
+ {showWaitingIndicator && (
+
+
+
+ )}
+
+ {isStreaming && streamingMessage && !showWaitingIndicator && (
+
+
+
+ {streamingMessage.todos && streamingMessage.todos.length > 0 && (
+
+ )}
+ {streamingMessage.content && (
+
+
+ {streamingMessage.content}
+
+
+ )}
+
+
+ )}
+
+
+
+
+
+ {/* Prompt bar — bottom position */}
+
- )}
-
-
+ >
+ )}
-
+
)
}
diff --git a/frontend/components/ai-elements/conversation.tsx b/frontend/components/ai-elements/conversation.tsx
index fb6e5dd..e8ae8f6 100644
--- a/frontend/components/ai-elements/conversation.tsx
+++ b/frontend/components/ai-elements/conversation.tsx
@@ -5,6 +5,7 @@ import type { ComponentProps } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ArrowDownIcon } from 'lucide-react'
+import { AnimatePresence, motion } from 'motion/react'
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
export type ConversationProps = ComponentProps
@@ -42,20 +43,30 @@ export const ConversationScrollButton = ({
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
return (
- !isAtBottom && (
- scrollToBottom()}
- size='icon'
- type='button'
- variant='outline'
- {...props}
- >
-
-
- )
+
+ {!isAtBottom && (
+
+ scrollToBottom()}
+ size='icon'
+ type='button'
+ variant='outline'
+ {...props}
+ >
+
+
+
+ )}
+
)
}
diff --git a/frontend/components/ai-elements/message.tsx b/frontend/components/ai-elements/message.tsx
index 6ab808f..4535b8d 100644
--- a/frontend/components/ai-elements/message.tsx
+++ b/frontend/components/ai-elements/message.tsx
@@ -55,7 +55,7 @@ export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
*:first-child]:mt-0 [&>*:last-child]:mb-0',
+ 'prose-agent size-full',
className
)}
plugins={streamdownPlugins}
diff --git a/frontend/components/ai-elements/prompt-input.tsx b/frontend/components/ai-elements/prompt-input.tsx
index 43ccee4..c5bc0c7 100644
--- a/frontend/components/ai-elements/prompt-input.tsx
+++ b/frontend/components/ai-elements/prompt-input.tsx
@@ -69,7 +69,7 @@ export const PromptInput = ({
ref={formRef}
{...props}
>
- {children}
+ {children}
)
}
@@ -105,7 +105,6 @@ export const PromptInputTextarea = forwardRef<
if (e.shiftKey) {
return
}
- e.preventDefault()
const { form } = e.currentTarget
const submitButton = form?.querySelector(
@@ -115,6 +114,7 @@ export const PromptInputTextarea = forwardRef<
return
}
+ e.preventDefault()
form?.requestSubmit()
}
},