feat: Better autoscroll - for #909 (#1054)

This commit is contained in:
Alex Hancock
2025-02-03 16:03:47 -05:00
committed by GitHub
parent 26b494b9f7
commit a0a7a5b984
3 changed files with 87 additions and 46 deletions
+6 -29
View File
@@ -8,7 +8,7 @@ import Input from './components/Input';
import LoadingGoose from './components/LoadingGoose'; import LoadingGoose from './components/LoadingGoose';
import MoreMenu from './components/MoreMenu'; import MoreMenu from './components/MoreMenu';
import { Card } from './components/ui/card'; import { Card } from './components/ui/card';
import { ScrollArea } from './components/ui/scroll-area'; import { ScrollArea, ScrollAreaHandle } from './components/ui/scroll-area';
import UserMessage from './components/UserMessage'; import UserMessage from './components/UserMessage';
import WingToWing, { Working } from './components/WingToWing'; import WingToWing, { Working } from './components/WingToWing';
import { askAi } from './utils/askAI'; import { askAi } from './utils/askAI';
@@ -22,7 +22,6 @@ import { useRecentModels } from './components/settings/models/RecentModels';
import { createSelectedModel } from './components/settings/models/utils'; import { createSelectedModel } from './components/settings/models/utils';
import { getDefaultModel } from './components/settings/models/hardcoded_stuff'; import { getDefaultModel } from './components/settings/models/hardcoded_stuff';
import Splash from './components/Splash'; import Splash from './components/Splash';
import { loadAndAddStoredExtensions } from './extensions';
declare global { declare global {
interface Window { interface Window {
@@ -53,13 +52,10 @@ export interface Chat {
}>; }>;
} }
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
export function ChatContent({ export function ChatContent({
chats, chats,
setChats, setChats,
selectedChatId, selectedChatId,
setSelectedChatId,
initialQuery, initialQuery,
setProgressMessage, setProgressMessage,
setWorking, setWorking,
@@ -77,8 +73,8 @@ export function ChatContent({
const [hasMessages, setHasMessages] = useState(false); const [hasMessages, setHasMessages] = useState(false);
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now()); const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [showGame, setShowGame] = useState(false); const [showGame, setShowGame] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [working, setWorkingLocal] = useState<Working>(Working.Idle); const [working, setWorkingLocal] = useState<Working>(Working.Idle);
const scrollRef = useRef<ScrollAreaHandle>(null);
useEffect(() => { useEffect(() => {
setWorking(working); setWorking(working);
@@ -94,7 +90,6 @@ export function ChatContent({
onToolCall: ({ toolCall }) => { onToolCall: ({ toolCall }) => {
updateWorking(Working.Working); updateWorking(Working.Working);
setProgressMessage(`Executing tool: ${toolCall.toolName}`); setProgressMessage(`Executing tool: ${toolCall.toolName}`);
requestAnimationFrame(() => scrollToBottom('instant'));
}, },
onResponse: (response) => { onResponse: (response) => {
if (!response.ok) { if (!response.ok) {
@@ -115,8 +110,6 @@ export function ChatContent({
const fetchResponses = await askAi(message.content); const fetchResponses = await askAi(message.content);
setMessageMetadata((prev) => ({ ...prev, [message.id]: fetchResponses })); setMessageMetadata((prev) => ({ ...prev, [message.id]: fetchResponses }));
requestAnimationFrame(() => scrollToBottom('smooth'));
const timeSinceLastInteraction = Date.now() - lastInteractionTime; const timeSinceLastInteraction = Date.now() - lastInteractionTime;
window.electron.logInfo('last interaction:' + lastInteractionTime); window.electron.logInfo('last interaction:' + lastInteractionTime);
if (timeSinceLastInteraction > 60000) { if (timeSinceLastInteraction > 60000) {
@@ -150,23 +143,6 @@ export function ChatContent({
} }
}, [messages]); }, [messages]);
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({
behavior,
block: 'end',
inline: 'nearest',
});
}
};
// Single effect to handle all scrolling
useEffect(() => {
if (isLoading || messages.length > 0 || working === Working.Working) {
scrollToBottom(isLoading || working === Working.Working ? 'instant' : 'smooth');
}
}, [messages, isLoading, working]);
// Handle submit // Handle submit
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
window.electron.startPowerSaveBlocker(); window.electron.startPowerSaveBlocker();
@@ -178,7 +154,9 @@ export function ChatContent({
role: 'user', role: 'user',
content: content, content: content,
}); });
scrollToBottom('instant'); if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
} }
}; };
@@ -241,7 +219,7 @@ export function ChatContent({
{messages.length === 0 ? ( {messages.length === 0 ? (
<Splash append={append} /> <Splash append={append} />
) : ( ) : (
<ScrollArea className="flex-1 px-4" id="chat-scroll-area"> <ScrollArea ref={scrollRef} className="flex-1 px-4" autoScroll>
{messages.map((message) => ( {messages.map((message) => (
<div key={message.id} className="mt-[16px]"> <div key={message.id} className="mt-[16px]">
{message.role === 'user' ? ( {message.role === 'user' ? (
@@ -288,7 +266,6 @@ export function ChatContent({
</div> </div>
)} )}
<div className="block h-16" /> <div className="block h-16" />
<div ref={messagesEndRef} style={{ height: '1px' }} />
</ScrollArea> </ScrollArea>
)} )}
+1 -1
View File
@@ -4,7 +4,7 @@ import GooseLogo from './GooseLogo';
const LoadingGoose = () => { const LoadingGoose = () => {
return ( return (
<div className="w-full pb-[2px]"> <div className="w-full pb-[2px]">
<div className="flex items-center text-xs text-textStandard mb-2 pl-4 animate-[appear_250ms_ease-in_forwards]"> <div className="flex items-center text-xs text-textStandard mb-2 mt-2 pl-4 animate-[appear_250ms_ease-in_forwards]">
<GooseLogo className="mr-2" size="small" hover={false} /> <GooseLogo className="mr-2" size="small" hover={false} />
goose is working on it.. goose is working on it..
</div> </div>
+80 -16
View File
@@ -3,22 +3,86 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '../../utils'; import { cn } from '../../utils';
const ScrollArea = React.forwardRef< export interface ScrollAreaHandle {
React.ElementRef<typeof ScrollAreaPrimitive.Root>, scrollToBottom: () => void;
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> }
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
ref={ref} autoScroll?: boolean;
className={cn('relative overflow-hidden', className)} }
{...props}
> const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> ({ className, children, autoScroll = false, ...props }, ref) => {
{children} const rootRef = React.useRef<React.ElementRef<typeof ScrollAreaPrimitive.Root>>(null);
</ScrollAreaPrimitive.Viewport> const viewportRef = React.useRef<HTMLDivElement>(null);
<ScrollBar /> const viewportEndRef = React.useRef<HTMLDivElement>(null);
<ScrollAreaPrimitive.Corner /> const [isFollowing, setIsFollowing] = React.useState(true);
</ScrollAreaPrimitive.Root>
)); const scrollToBottom = React.useCallback(() => {
if (viewportEndRef.current) {
viewportEndRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest',
});
setIsFollowing(true);
}
}, []);
// Expose the scrollToBottom method to parent components
React.useImperativeHandle(
ref,
() => ({
scrollToBottom,
}),
[scrollToBottom]
);
// Handle scroll events to update isFollowing state
const handleScroll = React.useCallback(() => {
if (!viewportRef.current) return;
const viewport = viewportRef.current;
const { scrollHeight, scrollTop, clientHeight } = viewport;
const scrollBottom = scrollTop + clientHeight;
const newIsFollowing = scrollHeight === scrollBottom;
// react will internally optimize this to not re-store the same values
setIsFollowing(newIsFollowing);
}, []);
React.useEffect(() => {
if (!autoScroll || !isFollowing) return;
scrollToBottom();
}, [children, autoScroll, isFollowing, scrollToBottom]);
// Add scroll event listener
React.useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
viewport.addEventListener('scroll', handleScroll);
return () => viewport.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
return (
<ScrollAreaPrimitive.Root
ref={rootRef}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
{children}
{autoScroll && <div ref={viewportEndRef} style={{ height: '1px' }} />}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
);
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef< const ScrollBar = React.forwardRef<