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 ? ( - - {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 ? ( + + {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) => ( - - ))} + {showWaitingIndicator && ( + + + + )} + + {isStreaming && streamingMessage && !showWaitingIndicator && ( + + + + {streamingMessage.todos && streamingMessage.todos.length > 0 && ( + + )} + {streamingMessage.content && ( + + + {streamingMessage.content} + + + )} + + + )} + + + + + + {/* Prompt bar — bottom position */} +
+
+ {promptBar} +
- )} -
- - - - -
- - - -
-
+ + )}
-
+ ) } 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 && ( - - ) + + {!isAtBottom && ( + + + + )} + ) } 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() } },