From c825c58db863f2de9e1b53117939691ef02d741a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 18 Oct 2024 12:10:48 +0100 Subject: [PATCH 1/3] chore: scroll-related UX adjustments in the Unleash AI chat --- frontend/src/component/ai/AIChat.tsx | 49 +++++++++++++++++++---- frontend/src/component/ai/AIChatInput.tsx | 36 +++++++++++++++-- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/frontend/src/component/ai/AIChat.tsx b/frontend/src/component/ai/AIChat.tsx index 9fea0bf4d233..1c1ee78b7e74 100644 --- a/frontend/src/component/ai/AIChat.tsx +++ b/frontend/src/component/ai/AIChat.tsx @@ -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, @@ -88,22 +92,45 @@ export const AIChat = () => { const [messages, setMessages] = useState([]); + const isAtEndRef = useRef(true); const chatEndRef = useRef(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 }, + ); + + const target = chatEndRef.current; + if (target) { + intersectionObserver.observe(target); + } + + return () => { + if (target) { + intersectionObserver.unobserve(target); + } + }; }, [open]); + useEffect(() => { + scrollToEnd({ behavior: 'smooth', onlyIfAtEnd: true }); + }, [messages]); + const onSend = async (content: string) => { if (!content.trim() || loading) return; @@ -153,7 +180,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 })} > { )}
- + + scrollToEnd({ onlyIfAtEnd: true }) + } + /> diff --git a/frontend/src/component/ai/AIChatInput.tsx b/frontend/src/component/ai/AIChatInput.tsx index cc8ffbf1d06e..86cd8ccaaba5 100644 --- a/frontend/src/component/ai/AIChatInput.tsx +++ b/frontend/src/component/ai/AIChatInput.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { IconButton, InputAdornment, @@ -32,11 +32,41 @@ 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(null); + const previousHeightRef = useRef(0); + + useEffect(() => { + const resizeObserver = new ResizeObserver(([entry]) => { + const newHeight = entry.contentRect.height; + + if (newHeight !== previousHeightRef.current) { + previousHeightRef.current = newHeight; + onHeightChange?.(); + } + }); + + const target = inputContainerRef.current; + if (target) { + resizeObserver.observe(target); + } + + return () => { + if (target) { + resizeObserver.unobserve(target); + } + }; + }, [onHeightChange]); + const send = () => { if (!message.trim() || loading) return; onSend(message); @@ -44,7 +74,7 @@ export const AIChatInput = ({ onSend, loading }: IAIChatInputProps) => { }; return ( - + Date: Fri, 18 Oct 2024 14:05:48 +0100 Subject: [PATCH 2/3] test: fix failing test --- frontend/src/setupTests.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 870c883bfdab..338adb8256a8 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -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 = [ From 8d473e1ae504d8c8e1f7c1fb258e01a31897c464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 18 Oct 2024 14:11:21 +0100 Subject: [PATCH 3/3] refactor: address PR comment --- frontend/src/component/ai/AIChat.tsx | 9 ++++----- frontend/src/component/ai/AIChatInput.tsx | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/frontend/src/component/ai/AIChat.tsx b/frontend/src/component/ai/AIChat.tsx index 1c1ee78b7e74..13e3ad91a4e0 100644 --- a/frontend/src/component/ai/AIChat.tsx +++ b/frontend/src/component/ai/AIChat.tsx @@ -115,14 +115,13 @@ export const AIChat = () => { { threshold: 1.0 }, ); - const target = chatEndRef.current; - if (target) { - intersectionObserver.observe(target); + if (chatEndRef.current) { + intersectionObserver.observe(chatEndRef.current); } return () => { - if (target) { - intersectionObserver.unobserve(target); + if (chatEndRef.current) { + intersectionObserver.unobserve(chatEndRef.current); } }; }, [open]); diff --git a/frontend/src/component/ai/AIChatInput.tsx b/frontend/src/component/ai/AIChatInput.tsx index 86cd8ccaaba5..ba900c16649d 100644 --- a/frontend/src/component/ai/AIChatInput.tsx +++ b/frontend/src/component/ai/AIChatInput.tsx @@ -55,14 +55,13 @@ export const AIChatInput = ({ } }); - const target = inputContainerRef.current; - if (target) { - resizeObserver.observe(target); + if (inputContainerRef.current) { + resizeObserver.observe(inputContainerRef.current); } return () => { - if (target) { - resizeObserver.unobserve(target); + if (inputContainerRef.current) { + resizeObserver.unobserve(inputContainerRef.current); } }; }, [onHeightChange]);