Skip to content
4 changes: 3 additions & 1 deletion src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─── */
Expand Down
23 changes: 23 additions & 0 deletions src/testUtils/mocks/framer-motion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useMotionValue>) {
return motionValue;
}
126 changes: 86 additions & 40 deletions src/view/ConversationView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,44 +42,33 @@ export function ConversationView({
}: ConversationViewProps) {
const scrollContainerRef = useRef<HTMLDivElement>(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;
});
Expand All @@ -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<HTMLDivElement>(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 (
<motion.div
ref={motionRef}
key="chat-area"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ opacity: { duration: 0.2 } }}
className="chat-area flex-1 min-h-0 flex flex-col"
style={{ height: heightSpring, overflow: 'hidden' }}
className="chat-area min-h-0 flex flex-col"
>
<WindowControls onClose={onClose} />

<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="chat-messages-scroll px-5 py-4 flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto"
>
{messages.map((msg, i) => (
Expand Down Expand Up @@ -154,7 +195,12 @@ export function ConversationView({
<motion.div
initial={{ opacity: 0, scaleX: 0 }}
animate={{ opacity: 1, scaleX: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.15,
}}
className="h-px shrink-0 bg-surface-border origin-center"
/>
</motion.div>
Expand Down
38 changes: 5 additions & 33 deletions src/view/__tests__/ConversationView.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -117,31 +117,6 @@ describe('ConversationView', () => {
expect(container.querySelectorAll('.chat-bubble')).toHaveLength(0);
});

it('handleScroll updates pinned state when user scrolls', () => {
const { container } = render(
<ConversationView
messages={[{ id: '1', role: 'user' as const, content: 'Hello' }]}
streamingContent=""
isGenerating={false}
error={null}
onClose={vi.fn()}
/>,
);

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(
<ConversationView
Expand All @@ -158,8 +133,8 @@ describe('ConversationView', () => {
) 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,
Expand All @@ -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(
<ConversationView
Expand Down