-
Notifications
You must be signed in to change notification settings - Fork 293
fix: resolve chat input lag during active SSE streaming #490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,8 @@ | ||||||||||||||||||||||
| import {useCallback, useEffect, useRef, useState} from "react"; | ||||||||||||||||||||||
| import {memo, useCallback, useEffect, useRef, useState} from "react"; | ||||||||||||||||||||||
| import {useCortexChat, type ToolActivity} from "@/hooks/useCortexChat"; | ||||||||||||||||||||||
| import {Markdown} from "@/components/Markdown"; | ||||||||||||||||||||||
| import {ToolCall, type ToolCallPair} from "@/components/ToolCall"; | ||||||||||||||||||||||
| import {api, type CortexChatToolCall, type CortexChatThread} from "@/api/client"; | ||||||||||||||||||||||
| import {api, type CortexChatMessage, type CortexChatToolCall, type CortexChatThread} from "@/api/client"; | ||||||||||||||||||||||
| import {Button} from "@/ui"; | ||||||||||||||||||||||
| import {Popover, PopoverContent, PopoverTrigger} from "@/ui/Popover"; | ||||||||||||||||||||||
| import {PlusSignIcon, Cancel01Icon, Clock01Icon, Delete02Icon} from "@hugeicons/core-free-icons"; | ||||||||||||||||||||||
|
|
@@ -164,44 +164,51 @@ function ThinkingIndicator() { | |||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function CortexChatInput({ | ||||||||||||||||||||||
| value, | ||||||||||||||||||||||
| onChange, | ||||||||||||||||||||||
| const CortexChatInput = memo(function CortexChatInput({ | ||||||||||||||||||||||
| onSubmit, | ||||||||||||||||||||||
| isStreaming, | ||||||||||||||||||||||
| }: { | ||||||||||||||||||||||
| value: string; | ||||||||||||||||||||||
| onChange: (value: string) => void; | ||||||||||||||||||||||
| onSubmit: () => void; | ||||||||||||||||||||||
| onSubmit: (text: string) => void; | ||||||||||||||||||||||
| isStreaming: boolean; | ||||||||||||||||||||||
| }) { | ||||||||||||||||||||||
| const textareaRef = useRef<HTMLTextAreaElement>(null); | ||||||||||||||||||||||
| const [hasText, setHasText] = useState(false); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||
| textareaRef.current?.focus(); | ||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||
| const adjustHeight = () => { | ||||||||||||||||||||||
| const textarea = textareaRef.current; | ||||||||||||||||||||||
| if (!textarea) return; | ||||||||||||||||||||||
| textarea.style.height = "auto"; | ||||||||||||||||||||||
| const scrollHeight = textarea.scrollHeight; | ||||||||||||||||||||||
| const maxHeight = 160; | ||||||||||||||||||||||
| textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; | ||||||||||||||||||||||
| textarea.style.overflowY = scrollHeight > maxHeight ? "auto" : "hidden"; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const adjustHeight = () => { | ||||||||||||||||||||||
| textarea.style.height = "auto"; | ||||||||||||||||||||||
| const scrollHeight = textarea.scrollHeight; | ||||||||||||||||||||||
| const maxHeight = 160; | ||||||||||||||||||||||
| textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; | ||||||||||||||||||||||
| textarea.style.overflowY = scrollHeight > maxHeight ? "auto" : "hidden"; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| const doSubmit = () => { | ||||||||||||||||||||||
| const textarea = textareaRef.current; | ||||||||||||||||||||||
| if (!textarea) return; | ||||||||||||||||||||||
| const trimmed = textarea.value.trim(); | ||||||||||||||||||||||
| if (!trimmed) return; | ||||||||||||||||||||||
| textarea.value = ""; | ||||||||||||||||||||||
| setHasText(false); | ||||||||||||||||||||||
| adjustHeight(); | ||||||||||||||||||||||
| onSubmit(trimmed); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleInput = () => { | ||||||||||||||||||||||
| const value = textareaRef.current?.value ?? ""; | ||||||||||||||||||||||
| setHasText(value.trim().length > 0); | ||||||||||||||||||||||
| adjustHeight(); | ||||||||||||||||||||||
| textarea.addEventListener("input", adjustHeight); | ||||||||||||||||||||||
| return () => textarea.removeEventListener("input", adjustHeight); | ||||||||||||||||||||||
| }, [value]); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { | ||||||||||||||||||||||
| if (event.key === "Enter" && !event.shiftKey) { | ||||||||||||||||||||||
| event.preventDefault(); | ||||||||||||||||||||||
| onSubmit(); | ||||||||||||||||||||||
| doSubmit(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -210,8 +217,7 @@ function CortexChatInput({ | |||||||||||||||||||||
| <div className="flex items-end gap-2 p-2.5"> | ||||||||||||||||||||||
| <textarea | ||||||||||||||||||||||
| ref={textareaRef} | ||||||||||||||||||||||
| value={value} | ||||||||||||||||||||||
| onChange={(event) => onChange(event.target.value)} | ||||||||||||||||||||||
| onInput={handleInput} | ||||||||||||||||||||||
| onKeyDown={handleKeyDown} | ||||||||||||||||||||||
| placeholder={ | ||||||||||||||||||||||
| isStreaming ? "Waiting for response..." : "Message the cortex..." | ||||||||||||||||||||||
|
|
@@ -223,8 +229,8 @@ function CortexChatInput({ | |||||||||||||||||||||
| /> | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| onClick={onSubmit} | ||||||||||||||||||||||
| disabled={isStreaming || !value.trim()} | ||||||||||||||||||||||
| onClick={doSubmit} | ||||||||||||||||||||||
| disabled={isStreaming || !hasText} | ||||||||||||||||||||||
| 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" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <svg | ||||||||||||||||||||||
|
|
@@ -243,7 +249,7 @@ function CortexChatInput({ | |||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function formatRelativeTime(dateStr: string): string { | ||||||||||||||||||||||
| const date = new Date(dateStr); | ||||||||||||||||||||||
|
|
@@ -354,6 +360,43 @@ function ThreadList({ | |||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const CortexMessageList = memo(function CortexMessageList({ | ||||||||||||||||||||||
| messages, | ||||||||||||||||||||||
| }: { | ||||||||||||||||||||||
| messages: CortexChatMessage[]; | ||||||||||||||||||||||
| }) { | ||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <> | ||||||||||||||||||||||
| {messages.map((message) => ( | ||||||||||||||||||||||
| <div key={message.id}> | ||||||||||||||||||||||
| {message.role === "user" ? ( | ||||||||||||||||||||||
| <div className="flex justify-end"> | ||||||||||||||||||||||
| <div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2"> | ||||||||||||||||||||||
| <p className="text-sm text-ink">{message.content}</p> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
|
Comment on lines
+373
to
+376
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve multiline user prompts in the Cortex bubble. The new textarea supports Shift+Enter, but this Possible fix- <p className="text-sm text-ink">{message.content}</p>
+ <p className="break-words whitespace-pre-wrap text-sm text-ink">
+ {message.content}
+ </p>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||
| <div className="flex flex-col gap-2"> | ||||||||||||||||||||||
| {message.tool_calls && message.tool_calls.length > 0 && ( | ||||||||||||||||||||||
| <div className="flex flex-col gap-1.5"> | ||||||||||||||||||||||
| {message.tool_calls.map((call) => ( | ||||||||||||||||||||||
| <ToolCall key={call.id} pair={toToolCallPair(call)} /> | ||||||||||||||||||||||
| ))} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| {message.content && ( | ||||||||||||||||||||||
| <div className="text-sm text-ink-dull"> | ||||||||||||||||||||||
| <Markdown>{message.content}</Markdown> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ))} | ||||||||||||||||||||||
| </> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function CortexChatPanel({ | ||||||||||||||||||||||
| agentId, | ||||||||||||||||||||||
| channelId, | ||||||||||||||||||||||
|
|
@@ -371,7 +414,6 @@ export function CortexChatPanel({ | |||||||||||||||||||||
| newThread, | ||||||||||||||||||||||
| loadThread, | ||||||||||||||||||||||
| } = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt}); | ||||||||||||||||||||||
| const [input, setInput] = useState(""); | ||||||||||||||||||||||
| const [threadListOpen, setThreadListOpen] = useState(false); | ||||||||||||||||||||||
| const messagesEndRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||||||
| const initialPromptSentRef = useRef(false); | ||||||||||||||||||||||
|
|
@@ -394,12 +436,13 @@ export function CortexChatPanel({ | |||||||||||||||||||||
| messagesEndRef.current?.scrollIntoView({behavior: "smooth"}); | ||||||||||||||||||||||
| }, [messages.length, isStreaming, toolActivity.length]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleSubmit = () => { | ||||||||||||||||||||||
| const trimmed = input.trim(); | ||||||||||||||||||||||
| if (!trimmed || isStreaming) return; | ||||||||||||||||||||||
| setInput(""); | ||||||||||||||||||||||
| sendMessage(trimmed); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| const handleSubmit = useCallback( | ||||||||||||||||||||||
| (text: string) => { | ||||||||||||||||||||||
| if (isStreaming) return; | ||||||||||||||||||||||
| sendMessage(text); | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| [isStreaming, sendMessage], | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleStarterPrompt = (prompt: string) => { | ||||||||||||||||||||||
| if (isStreaming || !threadId) return; | ||||||||||||||||||||||
|
|
@@ -478,32 +521,7 @@ export function CortexChatPanel({ | |||||||||||||||||||||
| {/* Messages */} | ||||||||||||||||||||||
| <div className="min-h-0 flex-1 overflow-y-auto"> | ||||||||||||||||||||||
| <div className="flex flex-col gap-5 p-3 pb-4"> | ||||||||||||||||||||||
| {messages.map((message) => ( | ||||||||||||||||||||||
| <div key={message.id}> | ||||||||||||||||||||||
| {message.role === "user" ? ( | ||||||||||||||||||||||
| <div className="flex justify-end"> | ||||||||||||||||||||||
| <div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2"> | ||||||||||||||||||||||
| <p className="text-sm text-ink">{message.content}</p> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||
| <div className="flex flex-col gap-2"> | ||||||||||||||||||||||
| {message.tool_calls && message.tool_calls.length > 0 && ( | ||||||||||||||||||||||
| <div className="flex flex-col gap-1.5"> | ||||||||||||||||||||||
| {message.tool_calls.map((call) => ( | ||||||||||||||||||||||
| <ToolCall key={call.id} pair={toToolCallPair(call)} /> | ||||||||||||||||||||||
| ))} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| {message.content && ( | ||||||||||||||||||||||
| <div className="text-sm text-ink-dull"> | ||||||||||||||||||||||
| <Markdown>{message.content}</Markdown> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ))} | ||||||||||||||||||||||
| <CortexMessageList messages={messages} /> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| {/* Streaming state */} | ||||||||||||||||||||||
| {isStreaming && ( | ||||||||||||||||||||||
|
|
@@ -537,8 +555,6 @@ export function CortexChatPanel({ | |||||||||||||||||||||
| {/* Input */} | ||||||||||||||||||||||
| <div className="border-t border-app-line/50 p-3"> | ||||||||||||||||||||||
| <CortexChatInput | ||||||||||||||||||||||
| value={input} | ||||||||||||||||||||||
| onChange={setInput} | ||||||||||||||||||||||
| onSubmit={handleSubmit} | ||||||||||||||||||||||
| isStreaming={isStreaming} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only clear the Cortex draft after the send is accepted.
interface/src/hooks/useCortexChat.ts:81-103still no-ops whilethreadIdis null, butdoSubmit()clears the textarea before this callback can reject. That makes the first message easy to lose during fresh-thread startup or right afternewThread(). Please gate the input onthreadId, or haveonSubmitreport whether the send was accepted and only clear on success.Possible fix
const CortexChatInput = memo(function CortexChatInput({ onSubmit, isStreaming, + canSubmit, }: { - onSubmit: (text: string) => void; + onSubmit: (text: string) => boolean; isStreaming: boolean; + canSubmit: boolean; }) { @@ const doSubmit = () => { const textarea = textareaRef.current; if (!textarea) return; const trimmed = textarea.value.trim(); if (!trimmed) return; - textarea.value = ""; - setHasText(false); - adjustHeight(); - onSubmit(trimmed); + if (!onSubmit(trimmed)) return; + textarea.value = ""; + setHasText(false); + adjustHeight(); }; @@ - disabled={isStreaming} + disabled={isStreaming || !canSubmit} @@ - disabled={isStreaming || !hasText} + disabled={isStreaming || !hasText || !canSubmit}const handleSubmit = useCallback( (text: string) => { - if (isStreaming) return; + if (isStreaming || !threadId) return false; sendMessage(text); + return true; }, - [isStreaming, sendMessage], + [isStreaming, threadId, sendMessage], ); @@ <CortexChatInput onSubmit={handleSubmit} isStreaming={isStreaming} + canSubmit={!!threadId} />Also applies to: 439-445, 557-560
🤖 Prompt for AI Agents