Skip to content

Commit

Permalink
chore: scroll-related UX adjustments in the Unleash AI chat (#8478)
Browse files Browse the repository at this point in the history
https://linear.app/unleash/issue/2-2857/make-some-scroll-related-ux-adjustments-to-the-unleash-ai-chat

Introduces scroll-related UX enhancements to the Unleash AI chat,
providing a smoother and more refined user experience.
  • Loading branch information
nunogois authored Oct 18, 2024
1 parent 1c29f70 commit ffcfe85
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 11 deletions.
48 changes: 40 additions & 8 deletions frontend/src/component/ai/AIChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const AI_ERROR_MESSAGE = {
content: `I'm sorry, I'm having trouble understanding you right now. I've reported the issue to the team. Please try again later.`,
} as const;

type ScrollOptions = ScrollIntoViewOptions & {
onlyIfAtEnd?: boolean;
};

const StyledAIIconContainer = styled('div')(({ theme }) => ({
position: 'fixed',
bottom: 20,
Expand Down Expand Up @@ -88,22 +92,44 @@ export const AIChat = () => {

const [messages, setMessages] = useState<ChatMessage[]>([]);

const isAtEndRef = useRef(true);
const chatEndRef = useRef<HTMLDivElement | null>(null);

const scrollToEnd = (options?: ScrollIntoViewOptions) => {
const scrollToEnd = (options?: ScrollOptions) => {
if (chatEndRef.current) {
chatEndRef.current.scrollIntoView(options);
const shouldScroll = !options?.onlyIfAtEnd || isAtEndRef.current;

if (shouldScroll) {
chatEndRef.current.scrollIntoView(options);
}
}
};

useEffect(() => {
scrollToEnd({ behavior: 'smooth' });
}, [messages]);

useEffect(() => {
scrollToEnd();

const intersectionObserver = new IntersectionObserver(
([entry]) => {
isAtEndRef.current = entry.isIntersecting;
},
{ threshold: 1.0 },
);

if (chatEndRef.current) {
intersectionObserver.observe(chatEndRef.current);
}

return () => {
if (chatEndRef.current) {
intersectionObserver.unobserve(chatEndRef.current);
}
};
}, [open]);

useEffect(() => {
scrollToEnd({ behavior: 'smooth', onlyIfAtEnd: true });
}, [messages]);

const onSend = async (content: string) => {
if (!content.trim() || loading) return;

Expand Down Expand Up @@ -153,7 +179,7 @@ export const AIChat = () => {
minSize={{ width: '270px', height: '200px' }}
maxSize={{ width: '90vw', height: '90vh' }}
defaultSize={{ width: '320px', height: '450px' }}
onResize={scrollToEnd}
onResize={() => scrollToEnd({ onlyIfAtEnd: true })}
>
<StyledChat>
<AIChatHeader
Expand All @@ -176,7 +202,13 @@ export const AIChat = () => {
)}
<div ref={chatEndRef} />
</StyledChatContent>
<AIChatInput onSend={onSend} loading={loading} />
<AIChatInput
onSend={onSend}
loading={loading}
onHeightChange={() =>
scrollToEnd({ onlyIfAtEnd: true })
}
/>
</StyledChat>
</StyledResizable>
</StyledAIChatContainer>
Expand Down
35 changes: 32 additions & 3 deletions frontend/src/component/ai/AIChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
IconButton,
InputAdornment,
Expand Down Expand Up @@ -32,19 +32,48 @@ const StyledIconButton = styled(IconButton)({
export interface IAIChatInputProps {
onSend: (message: string) => void;
loading: boolean;
onHeightChange?: () => void;
}

export const AIChatInput = ({ onSend, loading }: IAIChatInputProps) => {
export const AIChatInput = ({
onSend,
loading,
onHeightChange,
}: IAIChatInputProps) => {
const [message, setMessage] = useState('');

const inputContainerRef = useRef<HTMLDivElement | null>(null);
const previousHeightRef = useRef<number>(0);

useEffect(() => {
const resizeObserver = new ResizeObserver(([entry]) => {
const newHeight = entry.contentRect.height;

if (newHeight !== previousHeightRef.current) {
previousHeightRef.current = newHeight;
onHeightChange?.();
}
});

if (inputContainerRef.current) {
resizeObserver.observe(inputContainerRef.current);
}

return () => {
if (inputContainerRef.current) {
resizeObserver.unobserve(inputContainerRef.current);
}
};
}, [onHeightChange]);

const send = () => {
if (!message.trim() || loading) return;
onSend(message);
setMessage('');
};

return (
<StyledAIChatInputContainer>
<StyledAIChatInputContainer ref={inputContainerRef}>
<StyledAIChatInput
autoFocus
size='small'
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,27 @@ class ResizeObserver {
disconnect() {}
}

class IntersectionObserver {
root: any;
rootMargin: any;
thresholds: any;

observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}

if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver;
}

if (!window.IntersectionObserver) {
window.IntersectionObserver = IntersectionObserver;
}

process.env.TZ = 'UTC';

const errorsToIgnore = [
Expand Down

0 comments on commit ffcfe85

Please sign in to comment.