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
19 changes: 18 additions & 1 deletion ui/src/components/chat/ChatAgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@
import { createContext, useContext, type ReactNode } from "react";
import type { 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 = {
agentType: AgentType;
runInSandbox: boolean;
substrateSandbox: boolean;
modelInfo?: ChatAgentModelInfo;
};

const ChatAgentRuntimeContext = createContext<ChatAgentRuntimeContextValue | undefined>(undefined);
Expand All @@ -15,15 +25,17 @@ export function ChatAgentProvider({
agentType,
runInSandbox = false,
substrateSandbox = false,
modelInfo,
children,
}: {
agentType: AgentType;
runInSandbox?: boolean;
substrateSandbox?: boolean;
modelInfo?: ChatAgentModelInfo;
children: ReactNode;
}) {
return (
<ChatAgentRuntimeContext.Provider value={{ agentType, runInSandbox, substrateSandbox }}>
<ChatAgentRuntimeContext.Provider value={{ agentType, runInSandbox, substrateSandbox, modelInfo }}>
{children}
</ChatAgentRuntimeContext.Provider>
);
Expand All @@ -43,3 +55,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;
}
144 changes: 114 additions & 30 deletions ui/src/components/chat/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import type React from "react";
import { useState, useRef, useEffect, useMemo } 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,
Expand Down Expand Up @@ -52,6 +52,12 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
const substrateSandbox = useChatSubstrateSandbox();
const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<ChatStatus>("ready");
Expand Down Expand Up @@ -113,6 +119,18 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se

const allMessages = useMemo(() => [...storedMessages, ...streamingMessages], [storedMessages, streamingMessages]);

// 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<string, unknown> | undefined)?.tokenStats as
| TokenStats
| undefined;
if (tokenStats?.prompt) return tokenStats.prompt;
}
return undefined;
}, [allMessages]);

const { handleMessageEvent } = useMemo(() => createMessageHandlers({
setMessages: setStreamingMessages,
setIsStreaming,
Expand Down Expand Up @@ -214,16 +232,48 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, selectedAgentName, selectedNamespace, isFirstMessage]);

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 handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -260,6 +310,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
Expand Down Expand Up @@ -925,11 +977,15 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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);
}
};

Expand All @@ -950,8 +1006,8 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
return (
<div className="flex h-full w-full min-w-0 flex-col items-center justify-center transition-all duration-300 ease-in-out">
<div className="flex-1 w-full overflow-hidden relative">
<ScrollArea ref={containerRef} className="w-full h-full py-12">
<div className="flex flex-col space-y-5 px-4">
<ScrollArea ref={containerRef} className="w-full h-full pt-2 pb-6">
<div className="mx-auto w-full max-w-3xl flex flex-col space-y-4 px-4">
{/* Never show loading for first message/new session */}
{isLoading && sessionId && !isFirstMessage && !isCreatingSessionRef.current ? (
<div
Expand Down Expand Up @@ -1013,32 +1069,49 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
)}
</div>
</ScrollArea>

{showJumpToLatest && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={scrollToBottom}
className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full shadow-md border"
aria-label="Jump to latest messages"
>
<ArrowDown className="h-4 w-4 mr-1" aria-hidden /> Latest
</Button>
)}
</div>

<div className="w-full sticky bg-secondary bottom-0 md:bottom-2 rounded-none md:rounded-lg p-4 border overflow-hidden transition-all duration-300 ease-in-out">
<div className="flex items-center justify-between mb-4">
<StatusDisplay chatStatus={chatStatus} />
{sessionStats.total > 0 && <SessionTokenStatsDisplay stats={sessionStats} />}
</div>
<div className="w-full max-w-3xl mx-auto sticky bg-secondary bottom-0 md:bottom-2 rounded-none md:rounded-lg px-3 py-2 border overflow-hidden transition-all duration-300 ease-in-out">
{(chatStatus !== "ready" || sessionStats.total > 0) && (
<div className="flex items-center justify-between mb-1 min-h-5">
{chatStatus !== "ready" ? <StatusDisplay chatStatus={chatStatus} /> : <span />}
{sessionStats.total > 0 && <SessionTokenStatsDisplay stats={sessionStats} contextTokens={contextTokens} />}
</div>
)}

<form onSubmit={handleSendMessage}>
<form onSubmit={handleSendMessage} className="flex items-end gap-2">
<Textarea
ref={textareaRef}
rows={1}
value={currentInputMessage}
onChange={(e) => setCurrentInputMessage(e.target.value)}
placeholder={getStatusPlaceholder(chatStatus)}
onKeyDown={handleKeyDown}
className={`min-h-[100px] border-0 shadow-none p-0 focus-visible:ring-0 resize-none ${chatStatus !== "ready" ? "opacity-50 cursor-not-allowed" : ""}`}
className={`flex-1 min-h-[36px] max-h-[200px] overflow-y-auto border-0 shadow-none p-1 focus-visible:ring-0 resize-none ${chatStatus !== "ready" ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={chatStatus !== "ready"}
/>

<div className="flex items-center justify-end gap-2 mt-4">
<div className="flex items-center gap-1.5 pb-0.5">
{isVoiceSupported && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={isListening ? "destructive" : "default"}
variant={isListening ? "destructive" : "ghost"}
size="icon"
onClick={isListening ? stopListening : startListening}
disabled={chatStatus !== "ready"}
Expand All @@ -1062,13 +1135,24 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
</Tooltip>
</TooltipProvider>
)}
<Button type="submit" className={""} disabled={!currentInputMessage.trim() || chatStatus !== "ready"}>
Send
<ArrowBigUp className="h-4 w-4 ml-2" />
</Button>
{chatStatus !== "ready" && chatStatus !== "error" && (
<Button type="button" variant="outline" onClick={handleCancel}>
<X className="h-4 w-4 mr-2" /> Cancel
{chatStatus !== "ready" && chatStatus !== "error" ? (
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCancel}
aria-label="Cancel"
>
<X className="h-4 w-4" aria-hidden />
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!currentInputMessage.trim() || chatStatus !== "ready"}
aria-label="Send message"
>
<ArrowBigUp className="h-4 w-4" aria-hidden />
</Button>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions ui/src/components/chat/ChatLayoutUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ export default function ChatLayoutUI({
agentType={currentAgent.agent.spec.type}
runInSandbox={currentAgent.workloadMode === "sandbox"}
substrateSandbox={isSubstrateSandboxAgent(currentAgent)}
modelInfo={{
model: currentAgent.model,
modelProvider: currentAgent.modelProvider,
modelConfigRef: currentAgent.modelConfigRef,
}}
>
{children}
</ChatAgentProvider>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr
<KagentLogo className="w-4 h-4" />
<div className="text-xs font-bold">{displayName}</div>
</div> : <div className="text-xs font-bold">{displayName}</div>}
<TruncatableText content={String(content)} className="break-all text-primary-foreground" />
<TruncatableText content={String(content)} className="break-words text-primary-foreground" />
{source !== "user" && (
<div className="flex mt-2 justify-end items-center gap-2">
{tokenStats && <TokenStatsTooltip stats={tokenStats} />}
Expand Down
44 changes: 39 additions & 5 deletions ui/src/components/chat/TokenStats.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
"use client";

import { ArrowLeft, ArrowRightFromLine } from "lucide-react";
import { TokenStats } from "@/types";
import { formatTokens, getModelContextWindow } from "@/lib/tokenUtils";
import { useChatModelInfo } from "@/components/chat/ChatAgentContext";

interface SessionTokenStatsDisplayProps {
stats: TokenStats;
/** Prompt tokens of the latest turn — approximates the current context size. */
contextTokens?: number;
}

export default function SessionTokenStatsDisplay({ stats }: SessionTokenStatsDisplayProps) {
export default function SessionTokenStatsDisplay({ stats, contextTokens }: SessionTokenStatsDisplayProps) {
const modelInfo = useChatModelInfo();
const contextWindow = getModelContextWindow(modelInfo?.model);

const usagePercent =
contextWindow && contextTokens ? Math.min(100, Math.round((contextTokens / contextWindow) * 100)) : undefined;
const barColor =
usagePercent === undefined
? ""
: usagePercent > 95
? "bg-red-500"
: usagePercent > 80
? "bg-amber-500"
: "bg-emerald-500";

return (
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-2 text-xs" title={`Total ${stats.total} · prompt ${stats.prompt} · completion ${stats.completion}`}>
<span>Usage: </span>
<span>{stats.total}</span>
<span>{formatTokens(stats.total)}</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ArrowLeft className="h-3 w-3" />
<span>{stats.prompt}</span>
<span>{formatTokens(stats.prompt)}</span>
</div>
<div className="flex items-center gap-1">
<ArrowRightFromLine className="h-3 w-3" />
<span>{stats.completion}</span>
<span>{formatTokens(stats.completion)}</span>
</div>
</div>
{contextWindow && contextTokens !== undefined && contextTokens > 0 && (
<div
className="flex items-center gap-1.5"
title={`Approximate context: ${contextTokens} of ${contextWindow} tokens (${usagePercent}%)`}
>
<span className="text-muted-foreground">·</span>
<span>
ctx {formatTokens(contextTokens)}/{formatTokens(contextWindow)}
</span>
<span className="inline-block h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<span className={`block h-full rounded-full ${barColor}`} style={{ width: `${usagePercent}%` }} />
</span>
</div>
)}
</div>
);
}
7 changes: 4 additions & 3 deletions ui/src/components/chat/TokenStatsTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BarChart2 } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { TokenStats } from "@/types";
import { formatTokens } from "@/lib/tokenUtils";

interface TokenStatsTooltipProps {
stats: TokenStats;
Expand All @@ -16,9 +17,9 @@ export default function TokenStatsTooltip({ stats }: TokenStatsTooltipProps) {
</TooltipTrigger>
<TooltipContent side="top">
<div className="flex flex-col gap-1 text-xs">
<span>Total: {stats.total}</span>
<span>Prompt: {stats.prompt}</span>
<span>Completion: {stats.completion}</span>
<span>Total: {formatTokens(stats.total)} ({stats.total})</span>
<span>Prompt: {formatTokens(stats.prompt)} ({stats.prompt})</span>
<span>Completion: {formatTokens(stats.completion)} ({stats.completion})</span>
</div>
</TooltipContent>
</Tooltip>
Expand Down
Loading