From b11bde032eac45f5f91ae6e6f436cd97765a1bf6 Mon Sep 17 00:00:00 2001 From: Adrien Reibel Date: Wed, 18 Mar 2026 17:20:02 +0100 Subject: [PATCH 1/3] =?UTF-8?q?ui:=20polish=20chat=20interface=20=E2=80=94?= =?UTF-8?q?=20centered=20empty=20state,=20smooth=20transitions,=20visual?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centered layout with BrandLogo hero when chat is empty, prompt bar elevated inline instead of stuck at bottom - Smooth layout transition (layoutId) when prompt slides from center to bottom on first message - Skeleton shimmer placeholders during starter question loading, replacing the static SVG spinner - Suggestion chips with Sparkles icons, hover effects, responsive grid - Prompt bar: backdrop-blur, visible focus border, submit-only disable (textarea stays editable during streaming) - Streaming cursor: inline CSS pseudo-element instead of block-level span (eliminates line-jump on stream completion) - Fix message bubble padding asymmetry (CSS specificity: prose-agent margin rules moved out of Tailwind layers) - Fix double border on prompt bar (InputGroup border/ring neutralized) - Scroll-to-bottom button: AnimatePresence fade in/out - Skip entry animation on messages transitioning from streaming to final --- frontend/app/globals.css | 36 ++ .../app/project/[id]/chat/[chatId]/page.tsx | 403 +++++++++++------- .../components/ai-elements/conversation.tsx | 41 +- frontend/components/ai-elements/message.tsx | 2 +- .../components/ai-elements/prompt-input.tsx | 2 +- 5 files changed, 308 insertions(+), 176 deletions(-) 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..1859fb4 100644 --- a/frontend/app/project/[id]/chat/[chatId]/page.tsx +++ b/frontend/app/project/[id]/chat/[chatId]/page.tsx @@ -2,9 +2,8 @@ import { useCallback, useEffect, useRef } 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,24 @@ export default function ProjectChatPage() { } }, [isStreaming, hasPendingQuestions]) + // Track streaming→final transition to skip entry animation on the + // last assistant message (avoids flash when streaming completes). + const prevIsStreamingRef = useRef(isStreaming) + const justFinishedStreaming = prevIsStreamingRef.current && !isStreaming + useEffect(() => { + prevIsStreamingRef.current = isStreaming + }) + + // Track whether we went through the analyzing phase, so we only + // animate suggestion chips when they replace skeletons (not on reload). + const wasAnalyzingRef = useRef(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) wasAnalyzingRef.current = 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 +158,240 @@ 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={wasAnalyzingRef.current ? { opacity: 0, y: 6 } : false} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: wasAnalyzingRef.current ? 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) => { + const isLastMsg = msg === lastMsg + // Skip fade-in animation for the message that was just streamed + const skipEntry = isLastMsg && justFinishedStreaming -
- -
- -
-
- {showSuggestions && ( -
- {suggestedQuestions.map((q, i) => ( - - ))} + return ( + + + {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 && ( + + + + )} + + {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..a48bf92 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} ) } From ef2760f5e91589257e8596053b2de0374c26d7f7 Mon Sep 17 00:00:00 2001 From: Adrien Reibel Date: Wed, 18 Mar 2026 17:34:24 +0100 Subject: [PATCH 2/3] fix: resolve React 19 lint errors (refs during render, setState in effects) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace ref-based streaming transition tracking with role-based approach: assistant messages never animate entry (they are either loaded from history or just streamed — both already visible) - Convert wasAnalyzing ref to state using "set state during render" pattern (React 19 compliant, no effect needed) --- .../app/project/[id]/chat/[chatId]/page.tsx | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/frontend/app/project/[id]/chat/[chatId]/page.tsx b/frontend/app/project/[id]/chat/[chatId]/page.tsx index 1859fb4..85c55fc 100644 --- a/frontend/app/project/[id]/chat/[chatId]/page.tsx +++ b/frontend/app/project/[id]/chat/[chatId]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useParams } from 'next/navigation' import { AlertCircle, Sparkles } from 'lucide-react' import { motion, AnimatePresence, LayoutGroup, type Variants } from 'motion/react' @@ -132,22 +132,15 @@ export default function ProjectChatPage() { } }, [isStreaming, hasPendingQuestions]) - // Track streaming→final transition to skip entry animation on the - // last assistant message (avoids flash when streaming completes). - const prevIsStreamingRef = useRef(isStreaming) - const justFinishedStreaming = prevIsStreamingRef.current && !isStreaming - useEffect(() => { - prevIsStreamingRef.current = isStreaming - }) - // Track whether we went through the analyzing phase, so we only // animate suggestion chips when they replace skeletons (not on reload). - const wasAnalyzingRef = useRef(false) + // Uses the "set state during render" pattern (React 19 compliant). + const [wasAnalyzing, setWasAnalyzing] = useState(false) const hasSources = sources.length > 0 const isEmpty = messages.length === 0 && !isStreaming && !isLoading const showAnalyzing = isAnalyzing && isEmpty - if (showAnalyzing) wasAnalyzingRef.current = true + if (showAnalyzing && !wasAnalyzing) setWasAnalyzing(true) const showSuggestions = hasSources && isEmpty && !isAnalyzing && suggestedQuestions.length > 0 const showWelcome = !hasSources && isEmpty && !isAnalyzing const showWaitingIndicator = isStreaming && streamingMessage @@ -251,9 +244,9 @@ export default function ProjectChatPage() { key={`suggestion-${i}`} type="button" onClick={() => handleSuggestionClick(q)} - initial={wasAnalyzingRef.current ? { opacity: 0, y: 6 } : false} + initial={wasAnalyzing ? { opacity: 0, y: 6 } : false} animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.3, delay: wasAnalyzingRef.current ? i * 0.07 : 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]" > @@ -285,16 +278,11 @@ export default function ProjectChatPage() { )} - {messages.map((msg) => { - const isLastMsg = msg === lastMsg - // Skip fade-in animation for the message that was just streamed - const skipEntry = isLastMsg && justFinishedStreaming - - return ( + {messages.map((msg) => ( @@ -343,8 +331,7 @@ export default function ProjectChatPage() { )} - ) - })} + ))} {showWaitingIndicator && ( From 7358a100350f699b75779a89d7972c4e2de0cad4 Mon Sep 17 00:00:00 2001 From: Adrien Reibel Date: Wed, 18 Mar 2026 17:38:13 +0100 Subject: [PATCH 3/3] fix: allow Enter to insert newline when submit is disabled Move preventDefault() after the disabled-button check so that pressing Enter while streaming inserts a newline instead of silently dropping the keystroke. --- frontend/components/ai-elements/prompt-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/ai-elements/prompt-input.tsx b/frontend/components/ai-elements/prompt-input.tsx index a48bf92..c5bc0c7 100644 --- a/frontend/components/ai-elements/prompt-input.tsx +++ b/frontend/components/ai-elements/prompt-input.tsx @@ -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() } },