Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 35 additions & 42 deletions apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -314,18 +308,17 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}

return (
<div className='light desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
<div className='light desktop-title-bar-page fixed inset-0 z-[var(--z-dropdown)] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
<DesktopTitleBarLane />
<ChatHeader chatConfig={chatConfig} starCount={starCount} />

<ChatMessageContainer
messages={displayMessages}
isLoading={isLoading}
showScrollButton={showScrollButton}
messagesContainerRef={messagesContainerRef as RefObject<HTMLDivElement>}
messagesContainerRef={attachMessagesContainer}
messagesEndRef={messagesEndRef as RefObject<HTMLDivElement>}
scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })}
scrollToMessage={scrollToMessage}
chatConfig={chatConfig}
/>

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'

export default function ChatLoading() {
return (
<div className='light desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
<div className='light desktop-title-bar-page fixed inset-0 z-[var(--z-dropdown)] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
<DesktopTitleBarLane />
<div className='border-[var(--border-1)] border-b px-4 py-3'>
<div className='mx-auto flex max-w-3xl items-center justify-between'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
const [email, setEmail] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const hasEmailError = emailErrors.length > 0

const [showOtpVerification, setShowOtpVerification] = useState(false)
const [otpValue, setOtpValue] = useState('')
Expand All @@ -53,15 +53,12 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 && (
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
{emailErrors.map((error) => (
<p key={error}>{error}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([])
const hasPasswordError = passwordErrors.length > 0
const authenticate = useChatPasswordAuth(identifier)

const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
setShowValidationError(false)
setPasswordErrors([])
}

const handleAuthenticate = async () => {
if (!password.trim()) {
setPasswordErrors(['Password is required'])
setShowValidationError(true)
return
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)]'
)}
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
Expand All @@ -101,9 +97,7 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) {
<div
className={cn(
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
showValidationError && passwordErrors.length > 0
? 'grid-rows-[1fr]'
: 'grid-rows-[0fr]'
hasPasswordError ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-live='polite'
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-primary)]'
aria-label={`GitHub repository - ${starCount} stars`}
>
<GithubIcon className='size-[16px]' aria-hidden='true' />
Expand Down
Loading
Loading