(feat)chats: shadcn msg scroller, fix jitter

This commit is contained in:
ManishMadan2882 committed 2026-07-12 20:27:36 +05:30
1 parent 3e978759de
commit d78ddeadd0
6 files changed
+266 -252

No files matched your search

+19
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@reduxjs/toolkit": "^2.12.0",
"@shadcn/react": "^0.2.1",
"chart.js": "^4.4.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -2746,6 +2747,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@shadcn/react": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@shadcn/react/-/react-0.2.1.tgz",
"integrity": "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": ">=19",
"react": ">=19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@reduxjs/toolkit": "^2.12.0",
"@shadcn/react": "0.2.1",
"chart.js": "^4.4.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -0,0 +1,137 @@
import * as React from 'react';
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from '@shadcn/react/message-scroller';
import { ArrowDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
) {
return <MessageScrollerPrimitive.Provider {...props} />;
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',
className,
)}
{...props}
/>
);
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
// Registry default also has: scroll-fade-b scrollbar-thin
// scrollbar-gutter-stable data-autoscrolling:scrollbar-none.
// scrollbar-thin is a real @utility here (index.css) and would thin
// the chat scrollbar; the fade would double Conversation's gradient.
'size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content',
className,
)}
{...props}
/>
);
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn('flex h-max min-h-full flex-col', className)}
{...props}
/>
);
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
// Registry default adds [content-visibility:auto]
// [contain-intrinsic-size:auto_10rem] here as an off-screen render
// optimization. Dropped: content-visibility:auto forces paint
// containment, which clips hover popovers that escape a message box
// (e.g. the source-preview tooltip) at the message's right edge.
'min-w-0 shrink-0',
className,
)}
{...props}
/>
);
}
function MessageScrollerButton({
direction = 'end',
className,
children,
render,
variant = 'secondary',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
'border-border bg-background text-foreground hover:bg-muted hover:text-foreground absolute inset-s-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',
className,
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === 'end' ? 'Scroll to end' : 'Scroll to start'}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
);
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
};
+15 -1
View File
@@ -137,6 +137,19 @@ export default function Conversation() {
toolName: string;
} | null>(null);
const [conversationMountKey, setConversationMountKey] = useState(0);
const [prevMountConversationId, setPrevMountConversationId] = useState<
string | null
>(conversationId);
if (prevMountConversationId !== conversationId) {
const isServerAssignedId =
prevMountConversationId === null &&
conversationId !== null &&
isNewChatRoute;
setPrevMountConversationId(conversationId);
if (!isServerAssignedId) setConversationMountKey((k) => k + 1);
}
useEffect(() => {
const prevId = prevConversationId.current;
// Don't reset when the backend assigns the conversation id mid-stream (null -> id)
@@ -324,6 +337,7 @@ export default function Conversation() {
>
<div className="relative min-h-0 flex-1">
<ConversationMessages
key={conversationMountKey}
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
handleFeedback={handleFeedback}
@@ -372,7 +386,7 @@ export default function Conversation() {
>
<div className="flex w-full items-center rounded-full px-2">
<MessageInput
key={conversationId || 'new'}
key={conversationMountKey}
onSubmit={(text) => {
handleQuestionSubmission(text);
}}
@@ -1200,7 +1200,7 @@ export function ToolCalls({
type="button"
variant="ghost"
size="sm"
className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent"
className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent dark:hover:bg-transparent"
onClick={() => setIsToolCallsOpen(!isToolCallsOpen)}
>
<p className="text-base font-semibold">Tool Calls</p>
@@ -1250,7 +1250,7 @@ export function ToolCalls({
)}
/>
</p>
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="dark:text-muted-foreground leading-5.75 text-black">
{JSON.stringify(toolCall.arguments, null, 2)}
</span>
@@ -1273,14 +1273,14 @@ export function ToolCalls({
</span>
)}
{toolCall.status === 'completed' && (
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="dark:text-muted-foreground leading-5.75 text-black">
{JSON.stringify(toolCall.result, null, 2)}
</span>
</p>
)}
{toolCall.status === 'error' && (
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="text-destructive leading-5.75">
{toolCall.error}
</span>
@@ -1,26 +1,21 @@
import {
Fragment,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Fragment, ReactNode, useEffect, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import ArrowDown from '../assets/arrow-down.svg';
import DocsGPT3 from '../assets/cute_docsgpt3.svg';
import Retry from '../assets/retry.svg?react';
import { Button } from '../components/ui/button';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '../components/ui/message-scroller';
import Hero from '../Hero';
import ConversationBubble from './ConversationBubble';
import { FEEDBACK, Query, Status } from './conversationModels';
const SCROLL_THRESHOLD = 10;
const LAST_BUBBLE_MARGIN = 'mb-32';
const DEFAULT_BUBBLE_MARGIN = 'mb-7';
const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5';
type ConversationMessagesProps = {
handleQuestion: (params: {
question: string;
@@ -48,6 +43,13 @@ type ConversationMessagesProps = {
agentId?: string;
};
const MS_VIEWPORT_SELECTOR = '[data-slot="message-scroller-viewport"]';
const STICK_THRESHOLD_PX = 48;
const LAST_BUBBLE_MARGIN = 'mb-32';
const DEFAULT_BUBBLE_MARGIN = 'mb-7';
const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5';
export default function ConversationMessages({
handleQuestion,
handleQuestionSubmission,
@@ -63,178 +65,35 @@ export default function ConversationMessages({
}: ConversationMessagesProps) {
const { t } = useTranslation();
const conversationRef = useRef<HTMLDivElement>(null);
const [scrollButtonVisible, setScrollButtonVisible] = useState(false);
const userInterruptedRef = useRef(false);
const [interrupted, setInterrupted] = useState(false);
const lastTouchYRef = useRef<number | null>(null);
const isInitialLoad = useRef(true);
const prevQueriesRef = useRef(queries);
const isAutoScrollingRef = useRef(false);
const smoothScrollTimeoutRef =
useRef<ReturnType<typeof setTimeout>>(undefined);
const showButtonTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
clearTimeout(smoothScrollTimeoutRef.current);
clearTimeout(showButtonTimerRef.current);
};
}, []);
const isAtBottom = useCallback(() => {
const el = conversationRef.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
}, []);
// Arm on upward scroll intent; requiring !isAtBottom() missed small nudges still inside SCROLL_THRESHOLD.
const markInterruptedIfLoading = useCallback(() => {
if (userInterruptedRef.current || status !== 'loading') return;
userInterruptedRef.current = true;
setInterrupted(true);
}, [status]);
const handleWheel = useCallback(
(e: React.WheelEvent) => {
if (e.deltaY < 0) markInterruptedIfLoading();
},
[markInterruptedIfLoading],
);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
lastTouchYRef.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
const y = e.touches[0].clientY;
if (lastTouchYRef.current !== null && y > lastTouchYRef.current) {
markInterruptedIfLoading();
}
lastTouchYRef.current = y;
},
[markInterruptedIfLoading],
);
const setButtonHidden = useCallback(() => {
clearTimeout(showButtonTimerRef.current);
showButtonTimerRef.current = undefined;
setScrollButtonVisible(false);
}, []);
const setButtonVisibleDebounced = useCallback(() => {
if (showButtonTimerRef.current) return;
showButtonTimerRef.current = setTimeout(() => {
setScrollButtonVisible(true);
showButtonTimerRef.current = undefined;
}, 300);
}, []);
const scrollConversationToBottom = useCallback(
(instant?: boolean) => {
if (!conversationRef.current) return;
isAutoScrollingRef.current = true;
clearTimeout(smoothScrollTimeoutRef.current);
requestAnimationFrame(() => {
if (!conversationRef?.current) return;
if (instant) {
conversationRef.current.scrollTop =
conversationRef.current.scrollHeight;
if (isAtBottom()) {
setButtonHidden();
}
isAutoScrollingRef.current = false;
} else {
conversationRef.current.scrollTo({
behavior: 'smooth',
top: conversationRef.current.scrollHeight,
});
smoothScrollTimeoutRef.current = setTimeout(() => {
if (isAtBottom()) {
setButtonHidden();
}
isAutoScrollingRef.current = false;
}, 500);
}
});
},
[isAtBottom, setButtonHidden],
);
const handleScroll = useCallback(() => {
const el = conversationRef.current;
if (!el) return;
const atBottom = isAtBottom();
if (atBottom && userInterruptedRef.current) {
userInterruptedRef.current = false;
setInterrupted(false);
}
if (atBottom) {
setButtonHidden();
isAutoScrollingRef.current = false;
return;
}
if (isAutoScrollingRef.current) {
return;
}
setButtonVisibleDebounced();
}, [isAtBottom, setButtonHidden, setButtonVisibleDebounced]);
const hasMessages = queries.length > 0;
const lastQuery = queries[queries.length - 1];
const lastQueryResponse = lastQuery?.response;
const lastQueryError = lastQuery?.error;
const lastQueryThought = lastQuery?.thought;
const lastTurnContentLength =
(lastQuery?.thought?.length ?? 0) + (lastQuery?.response?.length ?? 0);
const stickToBottomRef = useRef(true);
useEffect(() => {
if (interrupted) return;
const prevQueries = prevQueriesRef.current;
const isConversationSwitch =
prevQueries !== queries && prevQueries[0] !== queries[0];
if (isInitialLoad.current || isConversationSwitch) {
isInitialLoad.current = false;
scrollConversationToBottom(true);
prevQueriesRef.current = queries;
return;
}
const isNewMessage = queries.length > prevQueries.length;
prevQueriesRef.current = queries;
scrollConversationToBottom(isNewMessage ? false : true);
}, [
queries.length,
lastQueryResponse,
lastQueryError,
lastQueryThought,
interrupted,
scrollConversationToBottom,
]);
useEffect(() => {
if (status === 'idle') {
userInterruptedRef.current = false;
setInterrupted(false);
}
}, [status]);
useEffect(() => {
const currentConversationRef = conversationRef.current;
currentConversationRef?.addEventListener('scroll', handleScroll);
return () => {
currentConversationRef?.removeEventListener('scroll', handleScroll);
if (!hasMessages) return;
const vp = document.querySelector<HTMLElement>(MS_VIEWPORT_SELECTOR);
if (!vp) return;
const onScroll = () => {
stickToBottomRef.current =
vp.scrollHeight - vp.scrollTop - vp.clientHeight < STICK_THRESHOLD_PX;
};
}, [handleScroll]);
onScroll();
vp.addEventListener('scroll', onScroll, { passive: true });
return () => vp.removeEventListener('scroll', onScroll);
}, [hasMessages]);
useLayoutEffect(() => {
if (status !== 'loading' || !hasMessages) return;
const vp = document.querySelector<HTMLElement>(MS_VIEWPORT_SELECTOR);
if (!vp) return;
const dist = vp.scrollHeight - vp.scrollTop - vp.clientHeight;
if (stickToBottomRef.current && dist > 0) vp.scrollTop = vp.scrollHeight;
}, [status, lastTurnContentLength, hasMessages]);
const columnClass = isSplitView
? 'w-full max-w-325 px-2'
: 'w-full max-w-325 px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12';
const renderResponseView = (query: Query, index: number) => {
const isLastMessage = index === queries.length - 1;
@@ -328,10 +187,10 @@ export default function ConversationMessages({
);
}
if (status === 'loading' && isLastMessage) {
if (status === 'loading' && index === queries.length - 1) {
return (
<div
className={`fade-in-bubble flex flex-wrap self-start ${bubbleMargin} group dark:text-foreground flex-col`}
className={`fade-in-bubble group dark:text-foreground flex flex-col flex-wrap self-start ${bubbleMargin}`}
>
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
@@ -361,68 +220,52 @@ export default function ConversationMessages({
return null;
};
return (
<div
ref={conversationRef}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
className="flex h-full w-full justify-center overflow-y-auto will-change-scroll sm:pt-6 lg:pt-12"
>
{queries.length > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
userInterruptedRef.current = false;
setInterrupted(false);
scrollConversationToBottom();
}}
aria-label={t('Scroll to bottom') || 'Scroll to bottom'}
className={`bg-card fixed bottom-40 left-1/2 z-10 h-7 w-7 -translate-x-1/2 rounded-full transition-all duration-300 ease-in-out md:right-14 md:left-auto md:h-9 md:w-9 md:translate-x-0 ${
scrollButtonVisible
? 'pointer-events-auto scale-100 opacity-100'
: 'pointer-events-none scale-75 opacity-0'
}`}
>
<img
src={ArrowDown}
alt="arrow down"
className="h-4 w-4 opacity-50 filter md:h-5 md:w-5 dark:invert"
/>
</Button>
)}
<div
className={
isSplitView
? 'w-full max-w-325 px-2'
: 'w-full max-w-325 px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12'
}
>
{headerContent}
{queries.length > 0 ? (
queries.map((query, index) => (
<Fragment key={`${index}-query-fragment`}>
<ConversationBubble
className={index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''}
key={`${index}-QUESTION`}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
filesAttached={query.attachments}
/>
{renderResponseView(query, index)}
</Fragment>
))
) : showHeroOnEmpty ? (
<Hero handleQuestion={handleQuestion} />
) : null}
if (queries.length === 0) {
return (
<div className="flex h-full w-full justify-center overflow-y-auto will-change-scroll sm:pt-6 lg:pt-12">
<div className={columnClass}>
{headerContent}
{showHeroOnEmpty ? <Hero handleQuestion={handleQuestion} /> : null}
</div>
</div>
</div>
);
}
return (
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="sm:pt-6 lg:pt-12">
<MessageScrollerContent className={`mx-auto pb-7 ${columnClass}`}>
{headerContent}
{queries.map((query, index) => {
const responseView = renderResponseView(query, index);
return (
<Fragment key={`${index}-query-fragment`}>
<MessageScrollerItem messageId={`q-${index}`} scrollAnchor>
<ConversationBubble
className={
index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''
}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
filesAttached={query.attachments}
/>
</MessageScrollerItem>
{responseView && (
<MessageScrollerItem messageId={`a-${index}`}>
{responseView}
</MessageScrollerItem>
)}
</Fragment>
);
})}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
);
}