Skip to content
Open
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
136 changes: 76 additions & 60 deletions interface/src/components/CortexChatPanel.tsx
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";
Expand Down Expand Up @@ -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);
Comment on lines +191 to +199
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only clear the Cortex draft after the send is accepted.

interface/src/hooks/useCortexChat.ts:81-103 still no-ops while threadId is null, but doSubmit() clears the textarea before this callback can reject. That makes the first message easy to lose during fresh-thread startup or right after newThread(). Please gate the input on threadId, or have onSubmit report 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
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/CortexChatPanel.tsx` around lines 191 - 199,
doSubmit currently clears the textarea and draft immediately, which can drop the
first message when the send is rejected while threadId is null; change doSubmit
(and the other similar handlers at the noted locations) to either (A) gate
submission by checking threadId/newThread readiness before calling onSubmit, or
(B) make onSubmit return a boolean/Promise that indicates success and only clear
textareaRef.current.value, call setHasText(false), and adjustHeight() after a
successful send; update callers of onSubmit as needed and reference doSubmit,
textareaRef, onSubmit, threadId, and the useCortexChat hook to locate and
coordinate the change.

};

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();
}
};

Expand All @@ -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..."
Expand All @@ -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
Expand All @@ -243,7 +249,7 @@ function CortexChatInput({
</div>
</div>
);
}
});

function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve multiline user prompts in the Cortex bubble.

The new textarea supports Shift+Enter, but this <p> still collapses \n into spaces, so multi-line prompts render as a single line after send. Matching the whitespace handling already used in interface/src/components/WebChatPanel.tsx:202-205 would keep the displayed message faithful to what the user entered.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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 className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2">
<p className="break-words whitespace-pre-wrap text-sm text-ink">
{message.content}
</p>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/CortexChatPanel.tsx` around lines 373 - 376, The
CortexChatPanel rendering collapses newlines because the message bubble uses a
<p> that renders message.content without preserving whitespace; update the
bubble that renders message.content in the CortexChatPanel component to use the
same whitespace handling as WebChatPanel (e.g., render message.content inside an
element/class that applies CSS whitespace: pre-wrap / Tailwind
"whitespace-pre-wrap") so user multi-line prompts (Shift+Enter) appear with line
breaks preserved. Ensure you change the element rendering message.content (the
<p className="text-sm text-ink">...) to the whitespace-preserving variant and
keep existing styling intact.

</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,
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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 && (
Expand Down Expand Up @@ -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}
/>
Expand Down
Loading
Loading