diff --git a/src/App.css b/src/App.css index c8d57716..4406c977 100644 --- a/src/App.css +++ b/src/App.css @@ -43,7 +43,9 @@ body { /* ─── Morphing Container ─── */ .morphing-container { will-change: transform; - transition: box-shadow 0.35s ease-out; + transition: + box-shadow 0.5s cubic-bezier(0.12, 0.8, 0.2, 1.18), + border-radius 0.5s cubic-bezier(0.12, 0.8, 0.2, 1.18); } /* ─── Chat Bubble Styles ─── */ diff --git a/src/testUtils/mocks/framer-motion.tsx b/src/testUtils/mocks/framer-motion.tsx index 011b46a5..d42acbdb 100644 --- a/src/testUtils/mocks/framer-motion.tsx +++ b/src/testUtils/mocks/framer-motion.tsx @@ -90,3 +90,26 @@ export const AnimatePresence = ({ }: { children: React.ReactNode; }) => <>{children}; + +/** + * Stub for `useMotionValue` — returns a minimal object with get/set methods. + * No animation in tests; the DOM renders with static values. + */ +// eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix +export function useMotionValue(initial: number) { + let value = initial; + return { + get: () => value, + set: (v: number) => { + value = v; + }, + }; +} + +/** + * Stub for `useSpring` — passthrough, no spring physics in tests. + */ +// eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix +export function useSpring(motionValue: ReturnType) { + return motionValue; +} diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 2da0e699..c81c6720 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -1,5 +1,10 @@ -import { motion, AnimatePresence } from 'framer-motion'; -import { useRef, useCallback, useEffect } from 'react'; +import { + motion, + AnimatePresence, + useMotionValue, + useSpring, +} from 'framer-motion'; +import { useRef, useEffect, useLayoutEffect, useState } from 'react'; import { ChatBubble } from '../components/ChatBubble'; import { TypingIndicator } from '../components/TypingIndicator'; import { WindowControls } from '../components/WindowControls'; @@ -37,44 +42,33 @@ export function ConversationView({ }: ConversationViewProps) { const scrollContainerRef = useRef(null); - /** - * Tracks whether the user is "pinned" near the bottom of the scroll - * container. When pinned, new streaming tokens auto-scroll the view. - * When the user manually scrolls up, pinning is released so they can - * read older messages undisturbed. - */ - const isUserNearBottomRef = useRef(true); - - /** Threshold in pixels — if within this distance of the bottom, consider "pinned". */ + /** Threshold in pixels — if within this distance of the bottom, consider "near bottom". */ const NEAR_BOTTOM_THRESHOLD = 60; - /** - * Scroll event handler — updates the pinned state based on the user's - * current scroll position relative to the bottom of the container. - */ - const handleScroll = useCallback(() => { - const container = scrollContainerRef.current; - /* v8 ignore start */ - if (!container) return; // defensive null guard, ref always populated when handler fires - /* v8 ignore stop */ - const { scrollTop, scrollHeight, clientHeight } = container; - isUserNearBottomRef.current = - scrollHeight - scrollTop - clientHeight < NEAR_BOTTOM_THRESHOLD; - }, []); - /** * Auto-scroll the chat container to the bottom — but only when the user - * is pinned near the bottom. This prevents yanking the user back down - * if they are reading old messages while generation occurs. + * is near the bottom or the container hasn't started scrolling yet. + * + * Checks scroll position **fresh** on every content change rather than + * tracking it across renders, since the spring animation can trigger + * layout-induced scroll events at unpredictable times that would make + * stale state unreliable. Treating "no overflow" as "at the bottom" + * ensures the growth→scroll transition works seamlessly. */ useEffect(() => { - if (!isUserNearBottomRef.current) return; - const container = scrollContainerRef.current; /* v8 ignore start */ if (!container) return; // defensive null guard, ref always populated when effect fires /* v8 ignore stop */ + const { scrollTop, scrollHeight, clientHeight } = container; + const hasOverflow = scrollHeight > clientHeight; + const isNearBottom = + !hasOverflow || + scrollHeight - scrollTop - clientHeight < NEAR_BOTTOM_THRESHOLD; + + if (!isNearBottom) return; + const raf = requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); @@ -83,32 +77,79 @@ export function ConversationView({ }, [messages, streamingContent]); /** - * Re-pin to bottom whenever the user sends a new message. - * Ensures the view snaps to the latest query immediately. + * Spring-driven height that smoothly tracks the growing content. + * + * Framer Motion's `height: 'auto'` measures the target once at mount and + * snaps when the spring finishes — causing a visible jump when streaming + * tokens grow the content beyond the initial measurement. Instead, we + * temporarily flip the element to `height: auto` inside a `useLayoutEffect` + * (before the browser paints), measure the natural height, restore the + * spring value, and feed the measurement to a spring. The user never sees + * the temporary auto state. The spring smoothly chases the growing content. + * + * Capped at `MAX_CONVERSATION_HEIGHT` so the flex chain stays intact and + * the scroll container can scroll when content exceeds the available space. */ - useEffect(() => { - if (messages.length > 0) { - const lastMsg = messages[messages.length - 1]; - if (lastMsg.role === 'user') { - isUserNearBottomRef.current = true; + const motionRef = useRef(null); + const [targetHeight, setTargetHeight] = useState(0); + + /* v8 ignore start -- useLayoutEffect + DOM measurement requires a real browser */ + useLayoutEffect(() => { + const node = motionRef.current; + if (!node) return; + + // Temporarily remove the spring-driven height so the browser lays out + // children at their natural sizes. This runs before paint — no flicker. + const prev = node.style.height; + node.style.height = 'auto'; + const naturalH = Math.ceil(node.getBoundingClientRect().height); + + // Compute the actual flex-available space by reading the parent container's + // clientHeight (capped by its max-h-[600px]) and subtracting sibling heights + // (AskBarView). Without this, the spring would target 600px while the flex + // algorithm renders the motion.div at ~548px — the mismatch makes the scroll + // container 52px taller than the visible area, hiding the latest streamed + // content behind the input bar. + let maxAvailable = naturalH; + const parent = node.parentElement; + if (parent) { + let siblingH = 0; + for (const child of parent.children) { + if (child !== node) { + siblingH += (child as HTMLElement).offsetHeight; + } } + maxAvailable = parent.clientHeight - siblingH; } - }, [messages]); + + node.style.height = prev; + // eslint-disable-next-line @eslint-react/set-state-in-effect -- intentional: measure DOM in useLayoutEffect before paint, then feed the spring + setTargetHeight(Math.min(naturalH, Math.max(maxAvailable, 0))); + }, [messages, streamingContent, isGenerating, error]); + /* v8 ignore stop */ + + const heightMotion = useMotionValue(0); + const heightSpring = useSpring(heightMotion, { stiffness: 300, damping: 30 }); + + useLayoutEffect(() => { + heightMotion.set(targetHeight); + }, [targetHeight, heightMotion]); return (
{messages.map((msg, i) => ( @@ -154,7 +195,12 @@ export function ConversationView({ diff --git a/src/view/__tests__/ConversationView.test.tsx b/src/view/__tests__/ConversationView.test.tsx index 5bab8e68..7fb27184 100644 --- a/src/view/__tests__/ConversationView.test.tsx +++ b/src/view/__tests__/ConversationView.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ConversationView } from '../ConversationView'; @@ -117,31 +117,6 @@ describe('ConversationView', () => { expect(container.querySelectorAll('.chat-bubble')).toHaveLength(0); }); - it('handleScroll updates pinned state when user scrolls', () => { - const { container } = render( - , - ); - - const scrollEl = container.querySelector( - '.chat-messages-scroll', - ) as HTMLElement; - expect(scrollEl).not.toBeNull(); - - // Fire a scroll event — the handler reads scrollTop/scrollHeight/clientHeight - act(() => { - fireEvent.scroll(scrollEl); - }); - - // No assertion needed beyond "no crash" — the callback just updates a ref - expect(scrollEl).not.toBeNull(); - }); - it('auto-scroll is skipped when user is not near bottom (early return branch)', () => { const { container, rerender } = render( { ) as HTMLElement; expect(scrollEl).not.toBeNull(); - // Simulate scrolling far up — sets isUserNearBottomRef to false - // by making scrollHeight - scrollTop - clientHeight > NEAR_BOTTOM_THRESHOLD (60) + // Simulate a scroll container where the user is far from the bottom: + // scrollHeight - scrollTop - clientHeight = 500 - 0 - 100 = 400 > 60 threshold Object.defineProperty(scrollEl, 'scrollHeight', { value: 500, configurable: true, @@ -174,11 +149,8 @@ describe('ConversationView', () => { writable: true, }); - act(() => { - fireEvent.scroll(scrollEl); - }); - - // Now rerender with new messages — the auto-scroll useEffect should hit the early return + // Rerender with new messages — the auto-scroll useEffect reads scroll + // position fresh and should hit the early return (not near bottom) act(() => { rerender(