Skip to content

Commit 2307d34

Browse files
TheDarkSkyXDclaude
andcommitted
fix: resolve chat input lag caused by SSE-driven React re-renders
Convert WebChatPanel and CortexChatPanel text inputs from controlled (useState) to uncontrolled (ref-based) textareas so keystrokes bypass React's render cycle entirely. Wrap input components and message lists in React.memo to prevent expensive re-renders of long chat histories on every SSE event. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5903c7b commit 2307d34

2 files changed

Lines changed: 166 additions & 123 deletions

File tree

interface/src/components/CortexChatPanel.tsx

Lines changed: 76 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {useCallback, useEffect, useRef, useState} from "react";
1+
import {memo, useCallback, useEffect, useRef, useState} from "react";
22
import {useCortexChat, type ToolActivity} from "@/hooks/useCortexChat";
33
import {Markdown} from "@/components/Markdown";
44
import {ToolCall, type ToolCallPair} from "@/components/ToolCall";
5-
import {api, type CortexChatToolCall, type CortexChatThread} from "@/api/client";
5+
import {api, type CortexChatMessage, type CortexChatToolCall, type CortexChatThread} from "@/api/client";
66
import {Button} from "@/ui";
77
import {Popover, PopoverContent, PopoverTrigger} from "@/ui/Popover";
88
import {PlusSignIcon, Cancel01Icon, Clock01Icon, Delete02Icon} from "@hugeicons/core-free-icons";
@@ -164,44 +164,51 @@ function ThinkingIndicator() {
164164
);
165165
}
166166

167-
function CortexChatInput({
168-
value,
169-
onChange,
167+
const CortexChatInput = memo(function CortexChatInput({
170168
onSubmit,
171169
isStreaming,
172170
}: {
173-
value: string;
174-
onChange: (value: string) => void;
175-
onSubmit: () => void;
171+
onSubmit: (text: string) => void;
176172
isStreaming: boolean;
177173
}) {
178174
const textareaRef = useRef<HTMLTextAreaElement>(null);
175+
const [hasText, setHasText] = useState(false);
179176

180177
useEffect(() => {
181178
textareaRef.current?.focus();
182179
}, []);
183180

184-
useEffect(() => {
181+
const adjustHeight = () => {
185182
const textarea = textareaRef.current;
186183
if (!textarea) return;
184+
textarea.style.height = "auto";
185+
const scrollHeight = textarea.scrollHeight;
186+
const maxHeight = 160;
187+
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
188+
textarea.style.overflowY = scrollHeight > maxHeight ? "auto" : "hidden";
189+
};
187190

188-
const adjustHeight = () => {
189-
textarea.style.height = "auto";
190-
const scrollHeight = textarea.scrollHeight;
191-
const maxHeight = 160;
192-
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
193-
textarea.style.overflowY = scrollHeight > maxHeight ? "auto" : "hidden";
194-
};
191+
const doSubmit = () => {
192+
const textarea = textareaRef.current;
193+
if (!textarea) return;
194+
const trimmed = textarea.value.trim();
195+
if (!trimmed) return;
196+
textarea.value = "";
197+
setHasText(false);
198+
adjustHeight();
199+
onSubmit(trimmed);
200+
};
195201

202+
const handleInput = () => {
203+
const value = textareaRef.current?.value ?? "";
204+
setHasText(value.trim().length > 0);
196205
adjustHeight();
197-
textarea.addEventListener("input", adjustHeight);
198-
return () => textarea.removeEventListener("input", adjustHeight);
199-
}, [value]);
206+
};
200207

201208
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
202209
if (event.key === "Enter" && !event.shiftKey) {
203210
event.preventDefault();
204-
onSubmit();
211+
doSubmit();
205212
}
206213
};
207214

@@ -210,8 +217,7 @@ function CortexChatInput({
210217
<div className="flex items-end gap-2 p-2.5">
211218
<textarea
212219
ref={textareaRef}
213-
value={value}
214-
onChange={(event) => onChange(event.target.value)}
220+
onInput={handleInput}
215221
onKeyDown={handleKeyDown}
216222
placeholder={
217223
isStreaming ? "Waiting for response..." : "Message the cortex..."
@@ -223,8 +229,8 @@ function CortexChatInput({
223229
/>
224230
<button
225231
type="button"
226-
onClick={onSubmit}
227-
disabled={isStreaming || !value.trim()}
232+
onClick={doSubmit}
233+
disabled={isStreaming || !hasText}
228234
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent text-white transition-all duration-150 hover:bg-accent-deep disabled:opacity-30 disabled:hover:bg-accent"
229235
>
230236
<svg
@@ -243,7 +249,7 @@ function CortexChatInput({
243249
</div>
244250
</div>
245251
);
246-
}
252+
});
247253

248254
function formatRelativeTime(dateStr: string): string {
249255
const date = new Date(dateStr);
@@ -354,6 +360,43 @@ function ThreadList({
354360
);
355361
}
356362

363+
const CortexMessageList = memo(function CortexMessageList({
364+
messages,
365+
}: {
366+
messages: CortexChatMessage[];
367+
}) {
368+
return (
369+
<>
370+
{messages.map((message) => (
371+
<div key={message.id}>
372+
{message.role === "user" ? (
373+
<div className="flex justify-end">
374+
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2">
375+
<p className="text-sm text-ink">{message.content}</p>
376+
</div>
377+
</div>
378+
) : (
379+
<div className="flex flex-col gap-2">
380+
{message.tool_calls && message.tool_calls.length > 0 && (
381+
<div className="flex flex-col gap-1.5">
382+
{message.tool_calls.map((call) => (
383+
<ToolCall key={call.id} pair={toToolCallPair(call)} />
384+
))}
385+
</div>
386+
)}
387+
{message.content && (
388+
<div className="text-sm text-ink-dull">
389+
<Markdown>{message.content}</Markdown>
390+
</div>
391+
)}
392+
</div>
393+
)}
394+
</div>
395+
))}
396+
</>
397+
);
398+
});
399+
357400
export function CortexChatPanel({
358401
agentId,
359402
channelId,
@@ -371,7 +414,6 @@ export function CortexChatPanel({
371414
newThread,
372415
loadThread,
373416
} = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt});
374-
const [input, setInput] = useState("");
375417
const [threadListOpen, setThreadListOpen] = useState(false);
376418
const messagesEndRef = useRef<HTMLDivElement>(null);
377419
const initialPromptSentRef = useRef(false);
@@ -394,12 +436,13 @@ export function CortexChatPanel({
394436
messagesEndRef.current?.scrollIntoView({behavior: "smooth"});
395437
}, [messages.length, isStreaming, toolActivity.length]);
396438

397-
const handleSubmit = () => {
398-
const trimmed = input.trim();
399-
if (!trimmed || isStreaming) return;
400-
setInput("");
401-
sendMessage(trimmed);
402-
};
439+
const handleSubmit = useCallback(
440+
(text: string) => {
441+
if (isStreaming) return;
442+
sendMessage(text);
443+
},
444+
[isStreaming, sendMessage],
445+
);
403446

404447
const handleStarterPrompt = (prompt: string) => {
405448
if (isStreaming || !threadId) return;
@@ -478,32 +521,7 @@ export function CortexChatPanel({
478521
{/* Messages */}
479522
<div className="min-h-0 flex-1 overflow-y-auto">
480523
<div className="flex flex-col gap-5 p-3 pb-4">
481-
{messages.map((message) => (
482-
<div key={message.id}>
483-
{message.role === "user" ? (
484-
<div className="flex justify-end">
485-
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2">
486-
<p className="text-sm text-ink">{message.content}</p>
487-
</div>
488-
</div>
489-
) : (
490-
<div className="flex flex-col gap-2">
491-
{message.tool_calls && message.tool_calls.length > 0 && (
492-
<div className="flex flex-col gap-1.5">
493-
{message.tool_calls.map((call) => (
494-
<ToolCall key={call.id} pair={toToolCallPair(call)} />
495-
))}
496-
</div>
497-
)}
498-
{message.content && (
499-
<div className="text-sm text-ink-dull">
500-
<Markdown>{message.content}</Markdown>
501-
</div>
502-
)}
503-
</div>
504-
)}
505-
</div>
506-
))}
524+
<CortexMessageList messages={messages} />
507525

508526
{/* Streaming state */}
509527
{isStreaming && (
@@ -537,8 +555,6 @@ export function CortexChatPanel({
537555
{/* Input */}
538556
<div className="border-t border-app-line/50 p-3">
539557
<CortexChatInput
540-
value={input}
541-
onChange={setInput}
542558
onSubmit={handleSubmit}
543559
isStreaming={isStreaming}
544560
/>

0 commit comments

Comments
 (0)