diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 0685f106822..f066279140d 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type RefObject, useCallback, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { @@ -26,6 +26,8 @@ import { useGitHubStars } from '@/hooks/queries/github-stars' const logger = createLogger('ChatClient') +const NEAR_BOTTOM_THRESHOLD_PX = 100 + interface ChatRequestFile { name: string size: number @@ -87,13 +89,11 @@ export default function ChatClient({ identifier }: { identifier: string }) { const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } = useChatStreaming() - const NEAR_BOTTOM_THRESHOLD_PX = 100 - /** * ChatGPT-style scroll. Without `force`, no-ops when the user has scrolled away. * With `force` (jump button), re-pins to bottom. */ - const scrollToBottom = useCallback((options?: { behavior?: ScrollBehavior; force?: boolean }) => { + const scrollToBottom = (options?: { behavior?: ScrollBehavior; force?: boolean }) => { const behavior = options?.behavior ?? 'smooth' const force = options?.force === true if (!force && !stickToBottomRef.current) return @@ -112,52 +112,46 @@ export default function ChatClient({ identifier }: { identifier: string }) { }, behavior === 'smooth' ? 400 : 50 ) - }, []) + } - const scrollToMessage = useCallback( - (messageId: string, scrollToShowOnlyMessage = false) => { - const messageElement = document.querySelector(`[data-message-id="${messageId}"]`) - if (messageElement && messagesContainerRef.current) { - const container = messagesContainerRef.current - const containerRect = container.getBoundingClientRect() - const messageRect = messageElement.getBoundingClientRect() - - if (scrollToShowOnlyMessage) { - const scrollTop = container.scrollTop + messageRect.top - containerRect.top - - container.scrollTo({ - top: scrollTop, - behavior: 'smooth', - }) - } else { - const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80 - - container.scrollTo({ - top: scrollTop, - behavior: 'smooth', - }) - } - } - }, - [messagesContainerRef] - ) + const scrollToMessage = (messageId: string) => { + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`) + if (!messageElement || !messagesContainerRef.current) return - useEffect(() => { const container = messagesContainerRef.current - if (!container) return + const containerRect = container.getBoundingClientRect() + const messageRect = messageElement.getBoundingClientRect() + + container.scrollTo({ + top: container.scrollTop + messageRect.top - containerRect.top, + behavior: 'smooth', + }) + } + + /** + * Attaches on mount via a ref callback rather than an effect: the container + * renders only after the auth/loading early returns, so an effect would need + * unrelated render values as a stand-in for "the node exists yet". + */ + const attachMessagesContainer = useCallback((node: HTMLDivElement | null) => { + messagesContainerRef.current = node + if (!node) return const handleScroll = () => { if (ignoreScrollRef.current) return - const { scrollTop, scrollHeight, clientHeight } = container + const { scrollTop, scrollHeight, clientHeight } = node const distanceFromBottom = scrollHeight - scrollTop - clientHeight const nearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_PX stickToBottomRef.current = nearBottom setShowScrollButton(!nearBottom) } - container.addEventListener('scroll', handleScroll, { passive: true }) - return () => container.removeEventListener('scroll', handleScroll) - }, [chatConfig, authRequired]) + node.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + node.removeEventListener('scroll', handleScroll) + messagesContainerRef.current = null + } + }, []) const handleSendMessage = async ( messageToSend: string, @@ -199,7 +193,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { setIsLoading(true) setTimeout(() => { - scrollToMessage(userMessage.id, true) + scrollToMessage(userMessage.id) }, 100) // One AbortController for fetch + SSE body reads so Stop cancels server work too. @@ -314,7 +308,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { } return ( -
+
@@ -322,10 +316,9 @@ export default function ChatClient({ identifier }: { identifier: string }) { messages={displayMessages} isLoading={isLoading} showScrollButton={showScrollButton} - messagesContainerRef={messagesContainerRef as RefObject} + messagesContainerRef={attachMessagesContainer} messagesEndRef={messagesEndRef as RefObject} scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })} - scrollToMessage={scrollToMessage} chatConfig={chatConfig} /> diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx index a964d796cb0..405a06bc0e7 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx @@ -3,7 +3,7 @@ import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' export default function ChatLoading() { return ( -
+
diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx index f5cb4767fc8..392106c515a 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -35,7 +35,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { const [email, setEmail] = useState('') const [authError, setAuthError] = useState(null) const [emailErrors, setEmailErrors] = useState([]) - const [showEmailValidationError, setShowEmailValidationError] = useState(false) + const hasEmailError = emailErrors.length > 0 const [showOtpVerification, setShowOtpVerification] = useState(false) const [otpValue, setOtpValue] = useState('') @@ -53,15 +53,12 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) - setEmailErrors(errors) - setShowEmailValidationError(false) + setEmailErrors([]) } const handleSendOtp = async () => { const emailValidationErrors = validateEmailField(email) setEmailErrors(emailValidationErrors) - setShowEmailValidationError(emailValidationErrors.length > 0) if (emailValidationErrors.length > 0) { return @@ -75,7 +72,6 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { } catch (error) { logger.error('Error sending OTP:', error) setEmailErrors([toError(error).message || 'Failed to send verification code']) - setShowEmailValidationError(true) } } @@ -149,12 +145,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { value={email} onChange={handleEmailChange} className={cn( - showEmailValidationError && - emailErrors.length > 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' + hasEmailError && 'border-[var(--text-error)] focus:border-[var(--text-error)]' )} /> - {showEmailValidationError && emailErrors.length > 0 && ( + {hasEmailError && (
{emailErrors.map((error) => (

{error}

diff --git a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx index 0d6a1841e9c..bbf1471f8b9 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx @@ -17,21 +17,19 @@ interface PasswordAuthProps { export default function PasswordAuth({ identifier }: PasswordAuthProps) { const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) - const [showValidationError, setShowValidationError] = useState(false) const [passwordErrors, setPasswordErrors] = useState([]) + const hasPasswordError = passwordErrors.length > 0 const authenticate = useChatPasswordAuth(identifier) const handlePasswordChange = (e: React.ChangeEvent) => { const newPassword = e.target.value setPassword(newPassword) - setShowValidationError(false) setPasswordErrors([]) } const handleAuthenticate = async () => { if (!password.trim()) { setPasswordErrors(['Password is required']) - setShowValidationError(true) return } @@ -41,7 +39,6 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) { } catch (error) { logger.error('Authentication error:', error) setPasswordErrors([toError(error).message || 'Invalid password. Please try again.']) - setShowValidationError(true) } } @@ -84,15 +81,14 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) { onChange={handlePasswordChange} className={cn( 'pr-10', - showValidationError && - passwordErrors.length > 0 && + hasPasswordError && 'border-[var(--text-error)] focus:border-[var(--text-error)]' )} /> -
- - -

{file.name}

-
- - ))} -
- )} - -