From 3e654797130277c78681d3ff0dcf66b14b71086c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 12:37:00 -0700 Subject: [PATCH 1/2] refactor(chat): clean up the deployed chat surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../(interfaces)/chat/[identifier]/chat.tsx | 77 ++-- .../chat/[identifier]/loading.tsx | 2 +- .../chat/components/auth/email/email-auth.tsx | 14 +- .../auth/password/password-auth.tsx | 14 +- .../chat/components/header/header.tsx | 2 +- .../chat/components/input/input.tsx | 319 +++++++------- .../loading-state/loading-state.tsx | 2 +- .../message-container/message-container.tsx | 16 +- .../message/components/file-download.tsx | 11 +- .../message/components/markdown-renderer.tsx | 20 +- .../chat/components/message/message.tsx | 388 ++++++++---------- apps/sim/app/(interfaces)/chat/constants.ts | 11 +- apps/sim/app/api/speech/token/route.ts | 7 +- apps/sim/hooks/queries/chats.ts | 2 - apps/sim/hooks/use-speech-to-text.ts | 16 - .../lib/core/rate-limiter/route-helpers.ts | 17 - 16 files changed, 395 insertions(+), 523 deletions(-) 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}

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