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
26 changes: 25 additions & 1 deletion web/src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof setTimeout> | 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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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={
Expand Down
10 changes: 8 additions & 2 deletions web/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -57,3 +57,9 @@ export function MessageList({ messages, streamingBlocks, isStreaming }: {
</div>
);
}

// 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);
Loading