diff --git a/apps/sim/app/chat/[subdomain]/chat-client.tsx b/apps/sim/app/chat/[subdomain]/chat-client.tsx index 69ea41f2e0..cba981897d 100644 --- a/apps/sim/app/chat/[subdomain]/chat-client.tsx +++ b/apps/sim/app/chat/[subdomain]/chat-client.tsx @@ -1,6 +1,6 @@ 'use client' -import { KeyboardEvent, RefObject, useEffect, useRef, useState } from 'react' +import { KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import { getFormattedGitHubStars } from '@/app/(landing)/actions/github' import EmailAuth from './components/auth/email/email-auth' @@ -26,6 +26,29 @@ interface ChatConfig { authType?: 'public' | 'password' | 'email' } +function throttle any>(func: T, delay: number): T { + let timeoutId: NodeJS.Timeout | null = null + let lastExecTime = 0 + + return ((...args: Parameters) => { + const currentTime = Date.now() + + if (currentTime - lastExecTime > delay) { + func(...args) + lastExecTime = currentTime + } else { + if (timeoutId) clearTimeout(timeoutId) + timeoutId = setTimeout( + () => { + func(...args) + lastExecTime = Date.now() + }, + delay - (currentTime - lastExecTime) + ) + } + }) as T +} + export default function ChatClient({ subdomain }: { subdomain: string }) { const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') @@ -51,45 +74,45 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } = useChatStreaming() - const scrollToBottom = () => { + const scrollToBottom = useCallback(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) } - } + }, []) - const scrollToMessage = (messageId: string, scrollToShowOnlyMessage: boolean = 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() + const scrollToMessage = useCallback( + (messageId: string, scrollToShowOnlyMessage: boolean = 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) { - // ChatGPT-like behavior: scroll so only this message (and loading indicator if present) are visible - // Position the message at the very top of the container - const scrollTop = container.scrollTop + messageRect.top - containerRect.top + if (scrollToShowOnlyMessage) { + const scrollTop = container.scrollTop + messageRect.top - containerRect.top - container.scrollTo({ - top: scrollTop, - behavior: 'smooth', - }) - } else { - // Original behavior: Calculate scroll position to put the message near the top of the visible area - const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80 + container.scrollTo({ + top: scrollTop, + behavior: 'smooth', + }) + } else { + const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80 - container.scrollTo({ - top: scrollTop, - behavior: 'smooth', - }) + container.scrollTo({ + top: scrollTop, + behavior: 'smooth', + }) + } } - } - } + }, + [messagesContainerRef] + ) - useEffect(() => { - const container = messagesContainerRef.current - if (!container) return + const handleScroll = useCallback( + throttle(() => { + const container = messagesContainerRef.current + if (!container) return - const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container const distanceFromBottom = scrollHeight - scrollTop - clientHeight setShowScrollButton(distanceFromBottom > 100) @@ -98,11 +121,17 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { if (isStreamingResponse && !isUserScrollingRef.current) { setUserHasScrolled(true) } - } + }, 100), + [isStreamingResponse] + ) + + useEffect(() => { + const container = messagesContainerRef.current + if (!container) return container.addEventListener('scroll', handleScroll, { passive: true }) return () => container.removeEventListener('scroll', handleScroll) - }, [isStreamingResponse]) + }, [handleScroll]) // Reset user scroll tracking when streaming starts useEffect(() => { @@ -144,6 +173,9 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { throw new Error(`Failed to load chat configuration: ${response.status}`) } + // Reset auth required state when authentication is successful + setAuthRequired(null) + const data = await response.json() setChatConfig(data) @@ -168,10 +200,8 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { // Fetch chat config on mount and generate new conversation ID useEffect(() => { fetchChatConfig() - // Generate a new conversation ID whenever the page/chat is refreshed setConversationId(uuidv4()) - // Fetch GitHub stars getFormattedGitHubStars() .then((formattedStars) => { setStarCount(formattedStars) @@ -181,12 +211,15 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { }) }, [subdomain]) - // Handle keyboard input for message sending - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSendMessage() - } + const refreshChat = () => { + fetchChatConfig() + } + + const handleAuthSuccess = () => { + setAuthRequired(null) + setTimeout(() => { + refreshChat() + }, 800) } // Handle sending a message @@ -348,8 +381,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { return ( @@ -358,8 +390,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { return ( diff --git a/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx b/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx index 0f632a0be2..f81d45fe29 100644 --- a/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx +++ b/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx @@ -1,15 +1,14 @@ 'use client' import { KeyboardEvent, useState } from 'react' -import { Loader2, Mail } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { OTPInputForm } from '@/components/ui/input-otp-form' -import { ChatHeader } from '../../header/header' interface EmailAuthProps { subdomain: string - starCount: string onAuthSuccess: () => void title?: string primaryColor?: string @@ -17,7 +16,6 @@ interface EmailAuthProps { export default function EmailAuth({ subdomain, - starCount, onAuthSuccess, title = 'chat', primaryColor = '#802FFF', @@ -40,14 +38,6 @@ export default function EmailAuth({ } } - // Handle OTP input key down - const handleOtpKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleVerifyOtp() - } - } - // Handle sending OTP const handleSendOtp = async () => { setAuthError(null) @@ -69,7 +59,6 @@ export default function EmailAuth({ return } - // OTP sent successfully, show OTP input setShowOtpVerification(true) } catch (error) { console.error('Error sending OTP:', error) @@ -79,8 +68,13 @@ export default function EmailAuth({ } } - // Handle verifying OTP - const handleVerifyOtp = async () => { + const handleVerifyOtp = async (otp?: string) => { + const codeToVerify = otp || otpValue + + if (!codeToVerify || codeToVerify.length !== 6) { + return + } + setAuthError(null) setIsVerifyingOtp(true) @@ -91,7 +85,7 @@ export default function EmailAuth({ 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, - body: JSON.stringify({ email, otp: otpValue }), + body: JSON.stringify({ email, otp: codeToVerify }), }) if (!response.ok) { @@ -100,7 +94,6 @@ export default function EmailAuth({ return } - // Reset auth state and notify parent onAuthSuccess() } catch (error) { console.error('Error verifying OTP:', error) @@ -110,7 +103,6 @@ export default function EmailAuth({ } } - // Handle resending OTP const handleResendOtp = async () => { setAuthError(null) setIsSendingOtp(true) @@ -131,7 +123,6 @@ export default function EmailAuth({ return } - // Show a message that OTP was sent setAuthError('Verification code sent. Please check your email.') } catch (error) { console.error('Error resending OTP:', error) @@ -142,163 +133,167 @@ export default function EmailAuth({ } return ( -
-
-
- - {}}> + + + -
-

{title}

-

- This chat requires email verification. Please enter your email to continue. -

-
- - {authError && ( -
- {authError} + + + + + + + + +
- )} + {title} + -
-
-
-
- -
-
- -

Email Verification

- - {!showOtpVerification ? ( - // Step 1: Email Input - <> -

- Enter your email address to access this chat +

+ {!showOtpVerification ? ( + <> +
+

+ This chat requires email verification. Please enter your email to continue.

+
-
-
- - setEmail(e.target.value)} - onKeyDown={handleEmailKeyDown} - disabled={isSendingOtp} - className="w-full" - /> -
- - {authError && ( -
{authError}
- )} - - + {authError && ( +
+ {authError}
- - ) : ( - // Step 2: OTP Verification with OTPInputForm - <> -

+ )} + +

{ + e.preventDefault() + handleSendOtp() + }} + className="space-y-4" + > +
+ setEmail(e.target.value)} + onKeyDown={handleEmailKeyDown} + disabled={isSendingOtp} + className="w-full" + autoFocus + autoComplete="off" + /> +
+ + +
+ + ) : ( +
+
+

Enter the verification code sent to

-

{email}

+

{email}

+
- { - setOtpValue(value) - handleVerifyOtp() - }} - isLoading={isVerifyingOtp} - error={authError} - /> - -
- - • - + {authError && ( +
+ {authError}
- - )} -
+ )} + + { + setOtpValue(value) + handleVerifyOtp(value) + }} + isLoading={isVerifyingOtp} + error={null} + /> + +
+ + • + +
+
+ )}
-
-
+ + ) } diff --git a/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx b/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx index e43105789d..73d3c9d99d 100644 --- a/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx +++ b/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx @@ -1,14 +1,13 @@ 'use client' import { KeyboardEvent, useState } from 'react' -import { Loader2, Lock } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { ChatHeader } from '../../header/header' interface PasswordAuthProps { subdomain: string - starCount: string onAuthSuccess: () => void title?: string primaryColor?: string @@ -16,7 +15,6 @@ interface PasswordAuthProps { export default function PasswordAuth({ subdomain, - starCount, onAuthSuccess, title = 'chat', primaryColor = '#802FFF', @@ -77,119 +75,115 @@ export default function PasswordAuth({ } return ( -
-
-
- - {}}> + + + -
-

{title}

-

- This chat is password-protected. Please enter the password to continue. -

-
+ + + + + + + + + +
+ {title} + -
-
-
-
- -
+
+
+

+ This chat is password-protected. Please enter the password to continue. +

+
+ + {authError && ( +
+ {authError} +
+ )} + +
{ + e.preventDefault() + handleAuthenticate() + }} + className="space-y-4" + > +
+ setPassword(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter password" + disabled={isAuthenticating} + autoComplete="new-password" + className="w-full" + autoFocus + />
-

Password Required

-

- Enter the password to access this chat -

- - { - e.preventDefault() - handleAuthenticate() - }} + -
- -
+ ) : ( + 'Continue' + )} + +
-
-
+ + ) } diff --git a/apps/sim/app/chat/[subdomain]/components/message-container/message-container.tsx b/apps/sim/app/chat/[subdomain]/components/message-container/message-container.tsx index cec2ff541b..2e75aed6de 100644 --- a/apps/sim/app/chat/[subdomain]/components/message-container/message-container.tsx +++ b/apps/sim/app/chat/[subdomain]/components/message-container/message-container.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { RefObject } from 'react' +import React, { memo, RefObject } from 'react' import { ArrowDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { ChatMessage, ClientChatMessage } from '../message/message' @@ -18,7 +18,7 @@ interface ChatMessageContainerProps { } | null } -export function ChatMessageContainer({ +export const ChatMessageContainer = memo(function ChatMessageContainer({ messages, isLoading, showScrollButton, @@ -85,4 +85,4 @@ export function ChatMessageContainer({ )}
) -} +}) diff --git a/apps/sim/app/chat/[subdomain]/components/markdown-renderer/markdown-renderer.tsx b/apps/sim/app/chat/[subdomain]/components/message/components/markdown-renderer.tsx similarity index 96% rename from apps/sim/app/chat/[subdomain]/components/markdown-renderer/markdown-renderer.tsx rename to apps/sim/app/chat/[subdomain]/components/message/components/markdown-renderer.tsx index c4ad63a206..2e8202eed6 100644 --- a/apps/sim/app/chat/[subdomain]/components/markdown-renderer/markdown-renderer.tsx +++ b/apps/sim/app/chat/[subdomain]/components/message/components/markdown-renderer.tsx @@ -42,17 +42,17 @@ export default function MarkdownRenderer({ // Headings h1: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h2: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h3: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), diff --git a/apps/sim/app/chat/[subdomain]/components/message/message.tsx b/apps/sim/app/chat/[subdomain]/components/message/message.tsx index 4be2b6a7a8..19d2b219ef 100644 --- a/apps/sim/app/chat/[subdomain]/components/message/message.tsx +++ b/apps/sim/app/chat/[subdomain]/components/message/message.tsx @@ -1,10 +1,10 @@ 'use client' -import { useMemo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { Check, Copy } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import MarkdownRenderer from '../markdown-renderer/markdown-renderer' +import MarkdownRenderer from './components/markdown-renderer' export interface ChatMessage { id: string @@ -23,87 +23,97 @@ function EnhancedMarkdownRenderer({ content }: { content: string }) { ) } -export function ClientChatMessage({ message }: { message: ChatMessage }) { - const [isCopied, setIsCopied] = useState(false) - const isJsonObject = useMemo(() => { - return typeof message.content === 'object' && message.content !== null - }, [message.content]) +export const ClientChatMessage = memo( + function ClientChatMessage({ message }: { message: ChatMessage }) { + const [isCopied, setIsCopied] = useState(false) + const isJsonObject = useMemo(() => { + return typeof message.content === 'object' && message.content !== null + }, [message.content]) - // For user messages (on the right) - if (message.type === 'user') { + // For user messages (on the right) + if (message.type === 'user') { + return ( +
+
+
+
+
+ {isJsonObject ? ( +
{JSON.stringify(message.content, null, 2)}
+ ) : ( + {message.content as string} + )} +
+
+
+
+
+ ) + } + + // For assistant messages (on the left) return ( -
+
-
-
-
+
+
+
{isJsonObject ? ( -
{JSON.stringify(message.content, null, 2)}
+
+                    {JSON.stringify(message.content, null, 2)}
+                  
) : ( - {message.content as string} + )}
+ {message.type === 'assistant' && + !isJsonObject && + !message.isInitialMessage && + !message.isStreaming && ( +
+ + + + + + + {isCopied ? 'Copied!' : 'Copy to clipboard'} + + + +
+ )}
) + }, + (prevProps, nextProps) => { + return ( + prevProps.message.id === nextProps.message.id && + prevProps.message.content === nextProps.message.content && + prevProps.message.isStreaming === nextProps.message.isStreaming && + prevProps.message.isInitialMessage === nextProps.message.isInitialMessage + ) } - - // For assistant messages (on the left) - return ( -
-
-
-
-
- {isJsonObject ? ( -
-                  {JSON.stringify(message.content, null, 2)}
-                
- ) : ( - - )} -
-
- {message.type === 'assistant' && - !isJsonObject && - !message.isInitialMessage && - !message.isStreaming && ( -
- - - - - - - {isCopied ? 'Copied!' : 'Copy to clipboard'} - - - -
- )} -
-
-
- ) -} +)