From 7616be43046a057673662542ed987637f3134bf8 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 31 Mar 2026 23:11:31 -0500 Subject: [PATCH 1/8] feat: add Apple Sheet spring morph transition for askbar-to-chat mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the abrupt class-swap transition with a cohesive spring-physics morph: the container bounces open via Framer Motion layout animation, chat content slides down from above, and the input bar glides into position — all driven by a pronounced spring (stiffness 300, damping 20). Co-Authored-By: Claude Sonnet 4.6 --- src/App.css | 4 +++- src/App.tsx | 10 ++++++++-- src/view/ConversationView.tsx | 16 +++++++++++----- 3 files changed, 22 insertions(+), 8 deletions(-) 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/App.tsx b/src/App.tsx index 555676df..86e2f1f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -367,8 +367,12 @@ function App() { > {/* Morphing Container — flex column ensures the input bar always sticks to the bottom without spring animation lag */} -
{/* Input Bar — always pinned to the bottom */} + -
+ + ) : null} diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 2da0e699..92cd1ef7 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -98,10 +98,11 @@ export function ConversationView({ return ( @@ -154,7 +155,12 @@ export function ConversationView({ From 4de09ab307881d267968bac2b6501429b3eb8f10 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 31 Mar 2026 23:44:34 -0500 Subject: [PATCH 2/8] fix: use height animation for morph to prevent clipping and overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Framer Motion layout transforms with explicit height animation (0 → auto) on ConversationView. Layout transforms used CSS scale which overflowed the native window boundary, causing bottom clipping during the spring overshoot. Height animation grows the chat area via real CSS height changes — no transforms, no clipping, no input bar overlap. The isMorphing ref is set synchronously during render so the spring transition is active on the exact frame where chat mode activates. After 600ms the ref flips to false, switching streaming resizes to instant (duration: 0) to prevent the input bar from lagging behind growing content. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 42 ++++++++++++++---- src/__tests__/App.test.tsx | 45 ++++++++++++++++++++ src/view/ConversationView.tsx | 19 ++++++--- src/view/__tests__/ConversationView.test.tsx | 12 ++++++ 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 86e2f1f3..cd36172a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,6 +79,37 @@ function App() { const isChatMode = messages.length > 0 || isGenerating; const shouldRenderOverlay = overlayState === 'visible'; + /** + * Synchronous morph detection via refs — ensures `layout={true}` is present + * on the exact render where `isChatMode` first becomes true. A `useEffect` + * would be one render too late, causing Framer Motion to miss the "before" + * snapshot and skip the animation entirely. + * + * After 600ms (spring settle time), the ref flips to false so subsequent + * streaming-token resizes use an instant `duration: 0` transition instead + * of the spring, preventing the input bar from overlapping growing content. + */ + const morphingRef = useRef(false); + const prevChatModeRef = useRef(false); + + if (isChatMode && !prevChatModeRef.current) { + morphingRef.current = true; + prevChatModeRef.current = true; + } else if (!isChatMode && prevChatModeRef.current) { + morphingRef.current = false; + prevChatModeRef.current = false; + } + + useEffect(() => { + if (!morphingRef.current) return; + const timer = setTimeout(() => { + morphingRef.current = false; + }, 600); + return () => clearTimeout(timer); + }, [isChatMode]); + + const isMorphing = morphingRef.current; + /** * Reference stored for ResizeObserver cleanup. */ @@ -367,12 +398,8 @@ function App() { > {/* Morphing Container — flex column ensures the input bar always sticks to the bottom without spring animation lag */} - ) : null} {/* Input Bar — always pinned to the bottom */} - - - + ) : null} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index c5c63d82..41edb1d1 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -380,4 +380,49 @@ describe('App', () => { // Old messages should be gone expect(screen.queryByText('First response')).toBeNull(); }); + + it('disables morph layout animation after initial transition', async () => { + vi.useFakeTimers(); + render(); + await act(async () => {}); + + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + + // Enter chat mode — triggers isMorphing=true + act(() => { + fireEvent.change(textarea, { target: { value: 'test' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + // Advance past the 600ms morph window — triggers isMorphing=false + await act(async () => { + vi.advanceTimersByTime(700); + }); + + // Complete the conversation turn + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'reply' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + expect(screen.getByText('reply')).toBeInTheDocument(); + + // Re-enable channel capture for the second session + enableChannelCapture(); + + // Reopen overlay — reset() clears messages, isChatMode becomes false, + // exercising the !isChatMode cleanup branch in the morph effect. + await showOverlay(); + + expect( + screen.getByPlaceholderText('Ask Thuki anything...'), + ).toBeInTheDocument(); + + vi.useRealTimers(); + }); }); diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 92cd1ef7..6e3277bd 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -20,6 +20,8 @@ interface ConversationViewProps { error: string | null; /** Callback fired when the user requests to close the overlay. */ onClose: () => void; + /** True only during the initial askbar→chat morph (~600ms). */ + isMorphing: boolean; } /** @@ -34,6 +36,7 @@ export function ConversationView({ isGenerating, error, onClose, + isMorphing, }: ConversationViewProps) { const scrollContainerRef = useRef(null); @@ -98,12 +101,16 @@ export function ConversationView({ return ( diff --git a/src/view/__tests__/ConversationView.test.tsx b/src/view/__tests__/ConversationView.test.tsx index 5bab8e68..d99f8ca5 100644 --- a/src/view/__tests__/ConversationView.test.tsx +++ b/src/view/__tests__/ConversationView.test.tsx @@ -15,6 +15,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} + isMorphing={false} />, ); expect(screen.getByText('Hello there')).toBeInTheDocument(); @@ -29,6 +30,7 @@ describe('ConversationView', () => { isGenerating={true} error={null} onClose={vi.fn()} + isMorphing={false} />, ); expect(screen.getByText('streaming response...')).toBeInTheDocument(); @@ -42,6 +44,7 @@ describe('ConversationView', () => { isGenerating={true} error={null} onClose={vi.fn()} + isMorphing={false} />, ); const dots = container.querySelectorAll('.rounded-full.bg-primary\\/70'); @@ -56,6 +59,7 @@ describe('ConversationView', () => { isGenerating={true} error={null} onClose={vi.fn()} + isMorphing={false} />, ); const dots = container.querySelectorAll('.rounded-full.bg-primary\\/70'); @@ -70,6 +74,7 @@ describe('ConversationView', () => { isGenerating={false} error="Something went wrong" onClose={vi.fn()} + isMorphing={false} />, ); expect(screen.getByText('Something went wrong')).toBeInTheDocument(); @@ -83,6 +88,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} + isMorphing={false} />, ); expect(screen.queryByText('Something went wrong')).toBeNull(); @@ -97,6 +103,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={onClose} + isMorphing={false} />, ); expect( @@ -112,6 +119,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} + isMorphing={false} />, ); expect(container.querySelectorAll('.chat-bubble')).toHaveLength(0); @@ -125,6 +133,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} + isMorphing={false} />, ); @@ -150,6 +159,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} + isMorphing={false} />, ); @@ -190,6 +200,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} + isMorphing={false} />, ); }); @@ -211,6 +222,7 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} + isMorphing={false} />, ); for (let i = 0; i < 10; i++) { From 14f273d3d9615bb1f1dacd0dda8d5ac81b4bd321 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 31 Mar 2026 23:55:37 -0500 Subject: [PATCH 3/8] fix: use always-on spring for morph height animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove morph-detection refs and transition-switching logic that caused a visible height jump when the transition changed from spring to instant mid-animation. The spring (stiffness 300, damping 30) naturally handles both cases: large initial morph (0→200px) takes ~300ms for a visible effect, while streaming increments (~10-20px) settle in <50ms. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 32 -------------- src/__tests__/App.test.tsx | 44 -------------------- src/view/ConversationView.tsx | 9 +--- src/view/__tests__/ConversationView.test.tsx | 12 ------ 4 files changed, 1 insertion(+), 96 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cd36172a..555676df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,37 +79,6 @@ function App() { const isChatMode = messages.length > 0 || isGenerating; const shouldRenderOverlay = overlayState === 'visible'; - /** - * Synchronous morph detection via refs — ensures `layout={true}` is present - * on the exact render where `isChatMode` first becomes true. A `useEffect` - * would be one render too late, causing Framer Motion to miss the "before" - * snapshot and skip the animation entirely. - * - * After 600ms (spring settle time), the ref flips to false so subsequent - * streaming-token resizes use an instant `duration: 0` transition instead - * of the spring, preventing the input bar from overlapping growing content. - */ - const morphingRef = useRef(false); - const prevChatModeRef = useRef(false); - - if (isChatMode && !prevChatModeRef.current) { - morphingRef.current = true; - prevChatModeRef.current = true; - } else if (!isChatMode && prevChatModeRef.current) { - morphingRef.current = false; - prevChatModeRef.current = false; - } - - useEffect(() => { - if (!morphingRef.current) return; - const timer = setTimeout(() => { - morphingRef.current = false; - }, 600); - return () => clearTimeout(timer); - }, [isChatMode]); - - const isMorphing = morphingRef.current; - /** * Reference stored for ResizeObserver cleanup. */ @@ -415,7 +384,6 @@ function App() { isGenerating={isGenerating} error={error} onClose={handleCloseOverlay} - isMorphing={isMorphing} /> ) : null} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 41edb1d1..9d9a6b1d 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -381,48 +381,4 @@ describe('App', () => { expect(screen.queryByText('First response')).toBeNull(); }); - it('disables morph layout animation after initial transition', async () => { - vi.useFakeTimers(); - render(); - await act(async () => {}); - - await showOverlay(); - - const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); - - // Enter chat mode — triggers isMorphing=true - act(() => { - fireEvent.change(textarea, { target: { value: 'test' } }); - }); - act(() => { - fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); - }); - await act(async () => {}); - - // Advance past the 600ms morph window — triggers isMorphing=false - await act(async () => { - vi.advanceTimersByTime(700); - }); - - // Complete the conversation turn - act(() => { - getLastChannel()?.simulateMessage({ type: 'Token', data: 'reply' }); - getLastChannel()?.simulateMessage({ type: 'Done' }); - }); - - expect(screen.getByText('reply')).toBeInTheDocument(); - - // Re-enable channel capture for the second session - enableChannelCapture(); - - // Reopen overlay — reset() clears messages, isChatMode becomes false, - // exercising the !isChatMode cleanup branch in the morph effect. - await showOverlay(); - - expect( - screen.getByPlaceholderText('Ask Thuki anything...'), - ).toBeInTheDocument(); - - vi.useRealTimers(); - }); }); diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 6e3277bd..fbf788f7 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -20,8 +20,6 @@ interface ConversationViewProps { error: string | null; /** Callback fired when the user requests to close the overlay. */ onClose: () => void; - /** True only during the initial askbar→chat morph (~600ms). */ - isMorphing: boolean; } /** @@ -36,7 +34,6 @@ export function ConversationView({ isGenerating, error, onClose, - isMorphing, }: ConversationViewProps) { const scrollContainerRef = useRef(null); @@ -104,11 +101,7 @@ export function ConversationView({ initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} - transition={ - isMorphing - ? { type: 'spring', stiffness: 300, damping: 30 } - : { duration: 0 } - } + transition={{ type: 'spring', stiffness: 300, damping: 30 }} style={{ overflow: 'hidden' }} className="chat-area min-h-0 flex flex-col" > diff --git a/src/view/__tests__/ConversationView.test.tsx b/src/view/__tests__/ConversationView.test.tsx index d99f8ca5..5bab8e68 100644 --- a/src/view/__tests__/ConversationView.test.tsx +++ b/src/view/__tests__/ConversationView.test.tsx @@ -15,7 +15,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} - isMorphing={false} />, ); expect(screen.getByText('Hello there')).toBeInTheDocument(); @@ -30,7 +29,6 @@ describe('ConversationView', () => { isGenerating={true} error={null} onClose={vi.fn()} - isMorphing={false} />, ); expect(screen.getByText('streaming response...')).toBeInTheDocument(); @@ -44,7 +42,6 @@ describe('ConversationView', () => { isGenerating={true} error={null} onClose={vi.fn()} - isMorphing={false} />, ); const dots = container.querySelectorAll('.rounded-full.bg-primary\\/70'); @@ -59,7 +56,6 @@ describe('ConversationView', () => { isGenerating={true} error={null} onClose={vi.fn()} - isMorphing={false} />, ); const dots = container.querySelectorAll('.rounded-full.bg-primary\\/70'); @@ -74,7 +70,6 @@ describe('ConversationView', () => { isGenerating={false} error="Something went wrong" onClose={vi.fn()} - isMorphing={false} />, ); expect(screen.getByText('Something went wrong')).toBeInTheDocument(); @@ -88,7 +83,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} - isMorphing={false} />, ); expect(screen.queryByText('Something went wrong')).toBeNull(); @@ -103,7 +97,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={onClose} - isMorphing={false} />, ); expect( @@ -119,7 +112,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} - isMorphing={false} />, ); expect(container.querySelectorAll('.chat-bubble')).toHaveLength(0); @@ -133,7 +125,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} - isMorphing={false} />, ); @@ -159,7 +150,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} - isMorphing={false} />, ); @@ -200,7 +190,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} - isMorphing={false} />, ); }); @@ -222,7 +211,6 @@ describe('ConversationView', () => { isGenerating={false} error={null} onClose={vi.fn()} - isMorphing={false} />, ); for (let i = 0; i < 10; i++) { From 1d85662147f0a93387393bda6bb9b74ebbc780f4 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 1 Apr 2026 08:41:12 -0500 Subject: [PATCH 4/8] fix: replace height auto snap with spring-driven content tracking Framer Motion's height:'auto' measures once at mount and snaps when the spring finishes, causing a 50-100px jump when streaming tokens grow content during the animation. Replace with useLayoutEffect that temporarily flips to height:auto, measures natural height via getBoundingClientRect, restores the spring value (all before paint), and feeds the measurement to a useSpring. Capped at 600px so the flex chain stays intact and the scroll container can scroll when full. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/testUtils/mocks/framer-motion.tsx | 21 ++++++++++ src/view/ConversationView.tsx | 56 +++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/testUtils/mocks/framer-motion.tsx b/src/testUtils/mocks/framer-motion.tsx index 011b46a5..7c67a1f8 100644 --- a/src/testUtils/mocks/framer-motion.tsx +++ b/src/testUtils/mocks/framer-motion.tsx @@ -90,3 +90,24 @@ 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. + */ +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. + */ +export function useSpring(motionValue: ReturnType) { + return motionValue; +} \ No newline at end of file diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index fbf788f7..a5caccff 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -1,5 +1,5 @@ -import { motion, AnimatePresence } from 'framer-motion'; -import { useRef, useCallback, useEffect } from 'react'; +import { motion, AnimatePresence, useMotionValue, useSpring } from 'framer-motion'; +import { useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { ChatBubble } from '../components/ChatBubble'; import { TypingIndicator } from '../components/TypingIndicator'; import { WindowControls } from '../components/WindowControls'; @@ -95,14 +95,56 @@ export function ConversationView({ } }, [messages]); + /** + * 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. + */ + const motionRef = useRef(null); + const [targetHeight, setTargetHeight] = useState(0); + + /** Cap so the spring settles at the available space and the scroll container takes over. */ + const MAX_CONVERSATION_HEIGHT = 600; + + /* 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 can lay out + // children at their natural sizes. This runs before paint, so no flicker. + const prev = node.style.height; + node.style.height = 'auto'; + const naturalH = Math.ceil(node.getBoundingClientRect().height); + node.style.height = prev; + setTargetHeight(Math.min(naturalH, MAX_CONVERSATION_HEIGHT)); + }, [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 ( From 259ce3e76ac5f9f7821f092968c109eb033c77dd Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 1 Apr 2026 09:18:07 -0500 Subject: [PATCH 5/8] fix: reliable auto-scroll during spring-driven height animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for auto-scroll breaking when the conversation reaches max height: 1. Height cap: measure actual flex-available space (parent clientHeight minus sibling heights) instead of a hardcoded 600px cap. The spring now targets the exact rendered height, so the scroll container's internal layout matches the visible area — no more content hidden behind the input bar. 2. Auto-scroll: check scroll position fresh on each content change instead of relying on isUserNearBottomRef, which goes stale when spring animation triggers layout-induced scroll events. Treat "no overflow" as "at the bottom" so the growth-to-scroll transition is seamless. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/view/ConversationView.tsx | 61 ++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index a5caccff..f5f639d8 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -64,17 +64,28 @@ export function ConversationView({ /** * 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 instead of + * relying on `isUserNearBottomRef`, which can go stale when the spring + * animation triggers layout-induced scroll events at unpredictable times. + * 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; }); @@ -82,19 +93,6 @@ export function ConversationView({ return () => cancelAnimationFrame(raf); }, [messages, streamingContent]); - /** - * Re-pin to bottom whenever the user sends a new message. - * Ensures the view snaps to the latest query immediately. - */ - useEffect(() => { - if (messages.length > 0) { - const lastMsg = messages[messages.length - 1]; - if (lastMsg.role === 'user') { - isUserNearBottomRef.current = true; - } - } - }, [messages]); - /** * Spring-driven height that smoothly tracks the growing content. * @@ -112,20 +110,37 @@ export function ConversationView({ const motionRef = useRef(null); const [targetHeight, setTargetHeight] = useState(0); - /** Cap so the spring settles at the available space and the scroll container takes over. */ - const MAX_CONVERSATION_HEIGHT = 600; - /* 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 can lay out - // children at their natural sizes. This runs before paint, so no flicker. + + // 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; + } + node.style.height = prev; - setTargetHeight(Math.min(naturalH, MAX_CONVERSATION_HEIGHT)); + setTargetHeight(Math.min(naturalH, Math.max(maxAvailable, 0))); }, [messages, streamingContent, isGenerating, error]); /* v8 ignore stop */ From 18beb3d30c582398b096f75130f6ae99ecd51684 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 1 Apr 2026 14:12:52 -0500 Subject: [PATCH 6/8] refactor: remove dead scroll-pinning ref and handler The isUserNearBottomRef and handleScroll callback became dead code when the auto-scroll effect was rewritten to check scroll position fresh from the DOM. Remove the unused ref, callback, onScroll binding, and the test that only exercised the dead path. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/view/ConversationView.tsx | 37 ++++--------------- src/view/__tests__/ConversationView.test.tsx | 38 +++----------------- 2 files changed, 12 insertions(+), 63 deletions(-) diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index f5f639d8..2a20a78c 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -1,5 +1,5 @@ import { motion, AnimatePresence, useMotionValue, useSpring } from 'framer-motion'; -import { useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { useRef, useEffect, useLayoutEffect, useState } from 'react'; import { ChatBubble } from '../components/ChatBubble'; import { TypingIndicator } from '../components/TypingIndicator'; import { WindowControls } from '../components/WindowControls'; @@ -37,40 +37,18 @@ 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 near the bottom or the container hasn't started scrolling yet. * - * Checks scroll position **fresh** on every content change instead of - * relying on `isUserNearBottomRef`, which can go stale when the spring - * animation triggers layout-induced scroll events at unpredictable times. - * Treating "no overflow" as "at the bottom" ensures the growth→scroll - * transition works seamlessly. + * 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(() => { const container = scrollContainerRef.current; @@ -166,7 +144,6 @@ export function ConversationView({
{messages.map((msg, i) => ( 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( Date: Wed, 1 Apr 2026 14:16:46 -0500 Subject: [PATCH 7/8] formated code --- src/__tests__/App.test.tsx | 1 - src/testUtils/mocks/framer-motion.tsx | 2 +- src/view/ConversationView.tsx | 7 ++++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 9d9a6b1d..c5c63d82 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -380,5 +380,4 @@ describe('App', () => { // Old messages should be gone expect(screen.queryByText('First response')).toBeNull(); }); - }); diff --git a/src/testUtils/mocks/framer-motion.tsx b/src/testUtils/mocks/framer-motion.tsx index 7c67a1f8..2223418d 100644 --- a/src/testUtils/mocks/framer-motion.tsx +++ b/src/testUtils/mocks/framer-motion.tsx @@ -110,4 +110,4 @@ export function useMotionValue(initial: number) { */ export function useSpring(motionValue: ReturnType) { return motionValue; -} \ No newline at end of file +} diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 2a20a78c..3919ce11 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -1,4 +1,9 @@ -import { motion, AnimatePresence, useMotionValue, useSpring } from 'framer-motion'; +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'; From 9b380f4b577410226f4e34570a67d9e18486dbcf Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 1 Apr 2026 14:18:18 -0500 Subject: [PATCH 8/8] chore: suppress intentional eslint warnings with inline directives Mock stubs must match framer-motion's `use`-prefixed export names; setTargetHeight in useLayoutEffect is intentional (measure before paint). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/testUtils/mocks/framer-motion.tsx | 2 ++ src/view/ConversationView.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/src/testUtils/mocks/framer-motion.tsx b/src/testUtils/mocks/framer-motion.tsx index 2223418d..d42acbdb 100644 --- a/src/testUtils/mocks/framer-motion.tsx +++ b/src/testUtils/mocks/framer-motion.tsx @@ -95,6 +95,7 @@ export const AnimatePresence = ({ * 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 { @@ -108,6 +109,7 @@ export function useMotionValue(initial: number) { /** * 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 3919ce11..c81c6720 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -123,6 +123,7 @@ export function ConversationView({ } 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 */