From 4d4407419519f443cfe1f9771512a6766c660ad5 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:09:08 +0000 Subject: [PATCH] fix(web): stop chat composer lag on long chats Typing in the composer stalled on long chats: dozens of keystrokes would surface seconds later. Two things compounded: - Every keystroke wrote the draft into the global store (setDraft), and ChatPage subscribes to the whole store, so each character re-rendered ChatPage. - MessageList was not memoized, so it re-rendered the entire message list on every ChatPage render. Memoize MessageList (all its props are stable store slices, unchanged while typing) and debounce the draft write (400ms, flushed on blur and on send). The composer stays responsive and drafts still persist across session switches. --- web/src/components/Chat/ChatInput.tsx | 26 ++++++++++++++++++++++++- web/src/components/Chat/MessageList.tsx | 10 ++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 6ebb1e5d5..da26b2ad4 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -61,6 +61,28 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { const activeSession = useChatStore(s => s.activeSession); const ensureRealSession = useChatStore(s => s.ensureRealSession); const isNewChat = useChatStore(s => s.messages.length === 0); + + // Persist the composer draft, but NOT on every keystroke. Writing to the + // global store per character re-renders every store subscriber (the message + // list included), which stalls typing on long chats. Debounce the write and + // flush it on blur / send so no draft is lost. + const draftTimerRef = useRef | null>(null); + const cancelDraftFlush = useCallback(() => { + if (draftTimerRef.current !== null) { + clearTimeout(draftTimerRef.current); + draftTimerRef.current = null; + } + }, []); + const scheduleDraft = useCallback((sessionId: string, text: string) => { + cancelDraftFlush(); + draftTimerRef.current = setTimeout(() => { + draftTimerRef.current = null; + setDraft(sessionId, text); + }, 400); + }, [cancelDraftFlush, setDraft]); + // Clear any pending draft write when the composer unmounts. + useEffect(() => cancelDraftFlush, [cancelDraftFlush]); + // Backend selector renders only while the chat is virtual (unsent): // the choice binds at server-side session creation and is sticky. const isVirtualChat = useChatStore( @@ -259,6 +281,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { })); onSend(message, fileIds.length > 0 ? fileIds : undefined, imageBlocks.length > 0 ? imageBlocks : undefined); + cancelDraftFlush(); setInput(''); setDraft(activeSession, ''); clearQuotes(); @@ -527,7 +550,8 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { id="nerve-chat-input" ref={textareaRef} value={input} - onChange={(e) => { setInput(e.target.value); setDraft(activeSession, e.target.value); }} + onChange={(e) => { const v = e.target.value; setInput(v); scheduleDraft(activeSession, v); }} + onBlur={() => { cancelDraftFlush(); setDraft(activeSession, input); }} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={ diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 54ad00867..81f638648 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -1,11 +1,11 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, memo } from 'react'; import type { ChatMessage, MessageBlock } from '../../types/chat'; import { UserMessage } from './UserMessage'; import { AssistantMessage } from './AssistantMessage'; import { StreamingMessage } from './StreamingMessage'; import { SelectionToolbar } from './SelectionToolbar'; -export function MessageList({ messages, streamingBlocks, isStreaming }: { +function MessageListImpl({ messages, streamingBlocks, isStreaming }: { messages: ChatMessage[]; streamingBlocks: MessageBlock[]; isStreaming: boolean; @@ -57,3 +57,9 @@ export function MessageList({ messages, streamingBlocks, isStreaming }: { ); } + +// Memoized so unrelated store updates (notably the per-keystroke draft write +// from the composer) don't re-render the whole message list. Every prop is a +// stable store slice, so the list re-renders only when messages, +// streamingBlocks, or isStreaming actually change. +export const MessageList = memo(MessageListImpl);