diff --git a/ui/src/components/chat/ChatAgentContext.tsx b/ui/src/components/chat/ChatAgentContext.tsx index 84f75d5a5..8dab58085 100644 --- a/ui/src/components/chat/ChatAgentContext.tsx +++ b/ui/src/components/chat/ChatAgentContext.tsx @@ -3,11 +3,21 @@ import { createContext, useContext, type ReactNode } from "react"; import type { AgentResponse, AgentType } from "@/types"; +export type ChatAgentModelInfo = { + /** Model name currently used by the agent (e.g. "gpt-4o"). */ + model: string; + /** Model provider (e.g. "OpenAI"). */ + modelProvider: string; + /** Ref ("namespace/name") of the ModelConfig backing the agent. */ + modelConfigRef: string; +}; + type ChatAgentRuntimeContextValue = { currentAgent: AgentResponse; agentType: AgentType; runInSandbox: boolean; substrateSandbox: boolean; + modelInfo?: ChatAgentModelInfo; }; const ChatAgentRuntimeContext = createContext(undefined); @@ -17,16 +27,18 @@ export function ChatAgentProvider({ agentType, runInSandbox = false, substrateSandbox = false, + modelInfo, children, }: { currentAgent: AgentResponse; agentType: AgentType; runInSandbox?: boolean; substrateSandbox?: boolean; + modelInfo?: ChatAgentModelInfo; children: ReactNode; }) { return ( - + {children} ); @@ -55,3 +67,8 @@ export function useChatRunInSandbox(): boolean { export function useChatSubstrateSandbox(): boolean { return useContext(ChatAgentRuntimeContext)?.substrateSandbox ?? false; } + +/** Model info (model, provider, ModelConfig ref) for the current chat agent. */ +export function useChatModelInfo(): ChatAgentModelInfo | undefined { + return useContext(ChatAgentRuntimeContext)?.modelInfo; +} diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index b3c92b3af..6e6d44819 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useState, useRef, useEffect, useMemo, useCallback } from "react"; -import { ArrowBigUp, X, Loader2, Mic, Square } from "lucide-react"; +import { ArrowBigUp, ArrowDown, X, Loader2, Mic, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -61,6 +61,12 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const substrateSandbox = useChatSubstrateSandbox(); const router = useRouter(); const containerRef = useRef(null); + const textareaRef = useRef(null); + // Tracks whether the user is scrolled near the bottom of the message list. + // Auto-scroll only follows new content while this is true, so reading + // history isn't interrupted by streaming updates. + const isNearBottomRef = useRef(true); + const [showJumpToLatest, setShowJumpToLatest] = useState(false); const [currentInputMessage, setCurrentInputMessage] = useState(""); const [chatStatus, setChatStatus] = useState("ready"); @@ -171,6 +177,18 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se // Shared call_id -> is_error lookup so each group summary is O(group size). const toolResultsByCallId = useMemo(() => buildToolCallResultsIndex(allMessages), [allMessages]); + // Prompt tokens of the latest turn — the closest available approximation of + // the current context size (used for the context-window meter). + const contextTokens = useMemo(() => { + for (let i = allMessages.length - 1; i >= 0; i--) { + const tokenStats = (allMessages[i].metadata as Record | undefined)?.tokenStats as + | TokenStats + | undefined; + if (tokenStats?.prompt) return tokenStats.prompt; + } + return undefined; + }, [allMessages]); + const { handleMessageEvent } = useMemo(() => createMessageHandlers({ setMessages: setStreamingMessages, setIsStreaming, @@ -286,15 +304,49 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, selectedAgentName, selectedNamespace, isFirstMessage, shareToken]); + const getScrollViewport = () => + containerRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null; + + const scrollToBottom = () => { + const viewport = getScrollViewport(); + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; + } + isNearBottomRef.current = true; + setShowJumpToLatest(false); + }; + + // Track scroll position so auto-scroll only engages when the user is + // already at (or near) the bottom of the conversation. useEffect(() => { - if (containerRef.current) { - const viewport = containerRef.current.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; - if (viewport) { - viewport.scrollTop = viewport.scrollHeight; - } + const viewport = getScrollViewport(); + if (!viewport) return; + const onScroll = () => { + const nearBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; + isNearBottomRef.current = nearBottom; + setShowJumpToLatest(!nearBottom); + }; + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", onScroll); + }, []); + + useEffect(() => { + if (!isNearBottomRef.current) return; + const viewport = getScrollViewport(); + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; } }, [storedMessages, streamingMessages, streamingContent]); + // Auto-grow the composer textarea with its content, capped so long drafts + // scroll internally instead of pushing the messages out of view. + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }, [currentInputMessage]); + const sendChatMessageText = async ( userMessageText: string, options: { @@ -338,6 +390,8 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se setCurrentInputMessage(""); setChatStatus("thinking"); + // Sending a message always re-engages follow-scrolling. + scrollToBottom(); setStoredMessages(prev => [...prev, ...streamingMessages]); setStreamingMessages([]); setStreamingContent(""); // Reset streaming content for new message @@ -984,11 +1038,15 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se }; const handleKeyDown = (e: React.KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault(); - if (currentInputMessage.trim() && selectedAgentName && selectedNamespace && chatStatus === "ready") { - handleSendMessage(e); - } + if (e.key !== "Enter") return; + // Don't send while an IME composition is in progress (e.g. Chinese/Japanese + // input) — Enter is confirming the composition, not submitting. + if (e.nativeEvent.isComposing) return; + // Shift+Enter inserts a newline. + if (e.shiftKey) return; + e.preventDefault(); + if (currentInputMessage.trim() && selectedAgentName && selectedNamespace && chatStatus === "ready") { + handleSendMessage(e); } }; @@ -1039,8 +1097,8 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se return (
- -
+ +
{/* Never show loading for first message/new session */} {isLoading && sessionId && !isFirstMessage && !isCreatingSessionRef.current ? (
+ {showJumpToLatest && ( + + )}
-
+
{shareReadOnly ? (

This is a read-only shared session. You can view the conversation but cannot send messages.

- {sessionStats.total > 0 && } + {sessionStats.total > 0 && }
) : ( <> -
- + {/* Status/token row only takes space while it has something to say. */} +
+ {chatStatus !== "ready" ? : }
- {sessionStats.total > 0 && } + {sessionStats.total > 0 && } {(session?.id ?? sessionId) && !shareToken && }
-
+