Skip to content

Commit 3e65479

Browse files
committed
refactor(chat): clean up the deployed chat surface
Eight-angle cleanup pass over the full contents of the chat surface and the speech code that survived the voice-mode removal. Dead code - enforceChatRateLimit: added for the TTS relay in #6212, orphaned when #6215 deleted that route. Zero consumers. - ChatToolCallStatus, ChatErrorType, and six unused CHAT_ERROR_MESSAGES keys (only GENERIC_ERROR and CHAT_UNAVAILABLE are read). - scrollToMessage was declared and destructured by ChatMessageContainer but never used in its body; removing the prop also made the scrollToShowOnlyMessage branch unreachable, since the sole caller passed true. - permissionState and the language prop on useSpeechToText: both write-only across the repo. - The image branch in ChatFileDownload's renderIcon returned the same DefaultFileIcon at the same size as the fallback. - chatKeys.status/detail: aliases of deploymentKeys nothing imported, and misleading since they root under a different key namespace. Redundant state - password-auth and email-auth each kept a boolean in lockstep with `errors.length > 0`; email-auth also validated on every keystroke and then immediately hid the result. - file-download tracked hover in state to drive one opacity class; now group-hover. Verified emcn Button sets no `group` class of its own. Memoization - ChatMessageContainer's memo() could never bail: chat.tsx passes an inline arrow for scrollToBottom and displayMessages is a fresh array. Four of the five things that re-render ChatClient are its props anyway, so the memo is dropped rather than propped up. - ClientChatMessage keeps its memo — it blocks markdown re-parsing — but loses the custom comparator, which compared proxies (a key:status fingerprint, files by length) and ignored attachments and type entirely. Default shallow compare on its single prop is both simpler and stricter. - Six useCallbacks whose consumers are native DOM handlers or inline arrows, so nothing observed their identity. Effects - The scroll listener attached in an effect keyed on [chatConfig, authRequired] — values it never reads, standing in for "the container has mounted". It now attaches via a ref callback, so it no longer re-attaches on every config refetch. Design system and a11y - z-[100] -> z-[var(--z-dropdown)] (same value), shadow-lg -> shadow-medium, list styles from inline style to Tailwind classes, hover: -> hover-hover: on touch-reachable targets, Check sourced from emcn alongside its Duplicate pair. - Accessible names on the remove-attachment, stop, and send buttons, which announced only as "button". - Dropped a keyboard handler on a role='group' div with no tabIndex, where target === currentTarget was unreachable, and the Tooltip Provider wrappers and delayDuration, which emcn documents as no-op passthroughs.
1 parent 83988c1 commit 3e65479

16 files changed

Lines changed: 395 additions & 523 deletions

File tree

apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
import { type RefObject, useCallback, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { generateId } from '@sim/utils/id'
66
import {
@@ -26,6 +26,8 @@ import { useGitHubStars } from '@/hooks/queries/github-stars'
2626

2727
const logger = createLogger('ChatClient')
2828

29+
const NEAR_BOTTOM_THRESHOLD_PX = 100
30+
2931
interface ChatRequestFile {
3032
name: string
3133
size: number
@@ -87,13 +89,11 @@ export default function ChatClient({ identifier }: { identifier: string }) {
8789
const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
8890
useChatStreaming()
8991

90-
const NEAR_BOTTOM_THRESHOLD_PX = 100
91-
9292
/**
9393
* ChatGPT-style scroll. Without `force`, no-ops when the user has scrolled away.
9494
* With `force` (jump button), re-pins to bottom.
9595
*/
96-
const scrollToBottom = useCallback((options?: { behavior?: ScrollBehavior; force?: boolean }) => {
96+
const scrollToBottom = (options?: { behavior?: ScrollBehavior; force?: boolean }) => {
9797
const behavior = options?.behavior ?? 'smooth'
9898
const force = options?.force === true
9999
if (!force && !stickToBottomRef.current) return
@@ -112,52 +112,46 @@ export default function ChatClient({ identifier }: { identifier: string }) {
112112
},
113113
behavior === 'smooth' ? 400 : 50
114114
)
115-
}, [])
115+
}
116116

117-
const scrollToMessage = useCallback(
118-
(messageId: string, scrollToShowOnlyMessage = false) => {
119-
const messageElement = document.querySelector(`[data-message-id="${messageId}"]`)
120-
if (messageElement && messagesContainerRef.current) {
121-
const container = messagesContainerRef.current
122-
const containerRect = container.getBoundingClientRect()
123-
const messageRect = messageElement.getBoundingClientRect()
124-
125-
if (scrollToShowOnlyMessage) {
126-
const scrollTop = container.scrollTop + messageRect.top - containerRect.top
127-
128-
container.scrollTo({
129-
top: scrollTop,
130-
behavior: 'smooth',
131-
})
132-
} else {
133-
const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80
134-
135-
container.scrollTo({
136-
top: scrollTop,
137-
behavior: 'smooth',
138-
})
139-
}
140-
}
141-
},
142-
[messagesContainerRef]
143-
)
117+
const scrollToMessage = (messageId: string) => {
118+
const messageElement = document.querySelector(`[data-message-id="${messageId}"]`)
119+
if (!messageElement || !messagesContainerRef.current) return
144120

145-
useEffect(() => {
146121
const container = messagesContainerRef.current
147-
if (!container) return
122+
const containerRect = container.getBoundingClientRect()
123+
const messageRect = messageElement.getBoundingClientRect()
124+
125+
container.scrollTo({
126+
top: container.scrollTop + messageRect.top - containerRect.top,
127+
behavior: 'smooth',
128+
})
129+
}
130+
131+
/**
132+
* Attaches on mount via a ref callback rather than an effect: the container
133+
* renders only after the auth/loading early returns, so an effect would need
134+
* unrelated render values as a stand-in for "the node exists yet".
135+
*/
136+
const attachMessagesContainer = useCallback((node: HTMLDivElement | null) => {
137+
messagesContainerRef.current = node
138+
if (!node) return
148139

149140
const handleScroll = () => {
150141
if (ignoreScrollRef.current) return
151-
const { scrollTop, scrollHeight, clientHeight } = container
142+
const { scrollTop, scrollHeight, clientHeight } = node
152143
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
153144
const nearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_PX
154145
stickToBottomRef.current = nearBottom
155146
setShowScrollButton(!nearBottom)
156147
}
157148

158-
container.addEventListener('scroll', handleScroll, { passive: true })
159-
return () => container.removeEventListener('scroll', handleScroll)
160-
}, [chatConfig, authRequired])
149+
node.addEventListener('scroll', handleScroll, { passive: true })
150+
return () => {
151+
node.removeEventListener('scroll', handleScroll)
152+
messagesContainerRef.current = null
153+
}
154+
}, [])
161155

162156
const handleSendMessage = async (
163157
messageToSend: string,
@@ -199,7 +193,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
199193
setIsLoading(true)
200194

201195
setTimeout(() => {
202-
scrollToMessage(userMessage.id, true)
196+
scrollToMessage(userMessage.id)
203197
}, 100)
204198

205199
// One AbortController for fetch + SSE body reads so Stop cancels server work too.
@@ -314,18 +308,17 @@ export default function ChatClient({ identifier }: { identifier: string }) {
314308
}
315309

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

321315
<ChatMessageContainer
322316
messages={displayMessages}
323317
isLoading={isLoading}
324318
showScrollButton={showScrollButton}
325-
messagesContainerRef={messagesContainerRef as RefObject<HTMLDivElement>}
319+
messagesContainerRef={attachMessagesContainer}
326320
messagesEndRef={messagesEndRef as RefObject<HTMLDivElement>}
327321
scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })}
328-
scrollToMessage={scrollToMessage}
329322
chatConfig={chatConfig}
330323
/>
331324

apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
33

44
export default function ChatLoading() {
55
return (
6-
<div className='light desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
6+
<div className='light desktop-title-bar-page fixed inset-0 z-[var(--z-dropdown)] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
77
<DesktopTitleBarLane />
88
<div className='border-[var(--border-1)] border-b px-4 py-3'>
99
<div className='mx-auto flex max-w-3xl items-center justify-between'>

apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
3535
const [email, setEmail] = useState('')
3636
const [authError, setAuthError] = useState<string | null>(null)
3737
const [emailErrors, setEmailErrors] = useState<string[]>([])
38-
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
38+
const hasEmailError = emailErrors.length > 0
3939

4040
const [showOtpVerification, setShowOtpVerification] = useState(false)
4141
const [otpValue, setOtpValue] = useState('')
@@ -53,15 +53,12 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
5353
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
5454
const newEmail = e.target.value
5555
setEmail(newEmail)
56-
const errors = validateEmailField(newEmail)
57-
setEmailErrors(errors)
58-
setShowEmailValidationError(false)
56+
setEmailErrors([])
5957
}
6058

6159
const handleSendOtp = async () => {
6260
const emailValidationErrors = validateEmailField(email)
6361
setEmailErrors(emailValidationErrors)
64-
setShowEmailValidationError(emailValidationErrors.length > 0)
6562

6663
if (emailValidationErrors.length > 0) {
6764
return
@@ -75,7 +72,6 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
7572
} catch (error) {
7673
logger.error('Error sending OTP:', error)
7774
setEmailErrors([toError(error).message || 'Failed to send verification code'])
78-
setShowEmailValidationError(true)
7975
}
8076
}
8177

@@ -149,12 +145,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
149145
value={email}
150146
onChange={handleEmailChange}
151147
className={cn(
152-
showEmailValidationError &&
153-
emailErrors.length > 0 &&
154-
'border-[var(--text-error)] focus:border-[var(--text-error)]'
148+
hasEmailError && 'border-[var(--text-error)] focus:border-[var(--text-error)]'
155149
)}
156150
/>
157-
{showEmailValidationError && emailErrors.length > 0 && (
151+
{hasEmailError && (
158152
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
159153
{emailErrors.map((error) => (
160154
<p key={error}>{error}</p>

apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,19 @@ interface PasswordAuthProps {
1717
export default function PasswordAuth({ identifier }: PasswordAuthProps) {
1818
const [password, setPassword] = useState('')
1919
const [showPassword, setShowPassword] = useState(false)
20-
const [showValidationError, setShowValidationError] = useState(false)
2120
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
21+
const hasPasswordError = passwordErrors.length > 0
2222
const authenticate = useChatPasswordAuth(identifier)
2323

2424
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
2525
const newPassword = e.target.value
2626
setPassword(newPassword)
27-
setShowValidationError(false)
2827
setPasswordErrors([])
2928
}
3029

3130
const handleAuthenticate = async () => {
3231
if (!password.trim()) {
3332
setPasswordErrors(['Password is required'])
34-
setShowValidationError(true)
3533
return
3634
}
3735

@@ -41,7 +39,6 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) {
4139
} catch (error) {
4240
logger.error('Authentication error:', error)
4341
setPasswordErrors([toError(error).message || 'Invalid password. Please try again.'])
44-
setShowValidationError(true)
4542
}
4643
}
4744

@@ -84,15 +81,14 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) {
8481
onChange={handlePasswordChange}
8582
className={cn(
8683
'pr-10',
87-
showValidationError &&
88-
passwordErrors.length > 0 &&
84+
hasPasswordError &&
8985
'border-[var(--text-error)] focus:border-[var(--text-error)]'
9086
)}
9187
/>
9288
<button
9389
type='button'
9490
onClick={() => setShowPassword(!showPassword)}
95-
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
91+
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]'
9692
aria-label={showPassword ? 'Hide password' : 'Show password'}
9793
>
9894
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -101,9 +97,7 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) {
10197
<div
10298
className={cn(
10399
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
104-
showValidationError && passwordErrors.length > 0
105-
? 'grid-rows-[1fr]'
106-
: 'grid-rows-[0fr]'
100+
hasPasswordError ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
107101
)}
108102
aria-live='polite'
109103
>

apps/sim/app/(interfaces)/chat/components/header/header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
5252
href='https://github.com/simstudioai/sim'
5353
target='_blank'
5454
rel='noopener noreferrer'
55-
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
55+
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-primary)]'
5656
aria-label={`GitHub repository - ${starCount} stars`}
5757
>
5858
<GithubIcon className='size-[16px]' aria-hidden='true' />

0 commit comments

Comments
 (0)