feat(chat-ui): responses API + MCP tool execution display

- Switch /chat from chat completions to responses API (previous_response_id session chaining)
- Add MCP server picker with search filter in chat input bar
- Show MCP tool call events (list_tools + call_tool) inline in chat via MCPEventsDisplay
- Add tool chip strip showing available tools when MCP servers are selected
- Non-blocking MCP toggle: server added immediately, verification in background (works for no-auth MCPs like deepwiki)
- Add truncateAfterMessage to useChatHistory for edit/retry
- Sync activeConversationId on URL change (fixes stale conversation on new chat)
- Add "Open Chat" shortcut button to sidebar
This commit is contained in:
Ishaan Jaffer 2026-03-10 16:16:25 -07:00
parent 7561cdc4cc
commit 414ed03bd7
5 changed files with 885 additions and 191 deletions

View file

@ -462,6 +462,42 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
/>
</ConfigProvider>
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
{/* Pinned "Open Chat" button at bottom */}
<div style={{
padding: collapsed ? "10px 8px" : "10px 12px",
borderTop: "1px solid #f0f0f0",
flexShrink: 0,
}}>
<a
href={toHref("chat")}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8,
padding: collapsed ? "8px 0" : "8px 10px",
borderRadius: 8,
background: "#1677ff",
color: "#fff",
textDecoration: "none",
fontSize: 13,
fontWeight: 600,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#0958d9";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#1677ff";
}}
>
<MessageOutlined style={{ fontSize: 16, flexShrink: 0 }} />
{!collapsed && <span>Open Chat</span>}
</a>
</div>
</Sider>
</Layout>
);

View file

@ -1,8 +1,8 @@
"use client";
import { ToolOutlined } from "@ant-design/icons";
import { Collapse } from "antd";
import React, { useEffect, useRef } from "react";
import { ToolOutlined, CopyOutlined, CheckOutlined, EditOutlined } from "@ant-design/icons";
import { Collapse, Tooltip } from "antd";
import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -68,33 +68,157 @@ function MarkdownCodeRenderer({
interface UserBubbleProps {
message: ChatMessage;
onEdit?: (messageId: string, newContent: string) => void;
isStreaming?: boolean;
}
function UserBubble({ message }: UserBubbleProps) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
<div
style={{
maxWidth: "72%",
backgroundColor: "#f0f2f5",
borderRadius: 16,
padding: "10px 14px",
fontSize: 14,
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#111827",
}}
>
{message.content}
function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
const [hovered, setHovered] = useState(false);
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (editing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.value.length;
}
}, [editing]);
// Auto-resize textarea
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [editValue, editing]);
const handleSave = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== message.content && onEdit) {
onEdit(message.id, trimmed);
}
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSave();
}
if (e.key === "Escape") {
setEditValue(message.content);
setEditing(false);
}
};
if (editing) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
<div style={{
width: "72%",
background: "#fff",
border: "1.5px solid #1677ff",
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 0 0 3px rgba(22,119,255,0.1)",
}}>
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
style={{
width: "100%",
padding: "10px 14px",
border: "none",
outline: "none",
resize: "none",
fontSize: 14,
lineHeight: "1.6",
color: "#111827",
fontFamily: "inherit",
background: "transparent",
boxSizing: "border-box",
minHeight: 40,
}}
/>
<div style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
padding: "6px 10px 8px",
borderTop: "1px solid #f0f0f0",
}}>
<button
onClick={() => { setEditValue(message.content); setEditing(false); }}
style={{
padding: "4px 12px", borderRadius: 6, border: "1px solid #d1d5db",
background: "#fff", color: "#374151", fontSize: 13, cursor: "pointer",
}}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!editValue.trim()}
style={{
padding: "4px 12px", borderRadius: 6, border: "none",
background: editValue.trim() ? "#1677ff" : "#f3f4f6",
color: editValue.trim() ? "#fff" : "#9ca3af",
fontSize: 13, fontWeight: 500, cursor: editValue.trim() ? "pointer" : "not-allowed",
}}
>
Save &amp; Send
</button>
</div>
</div>
</div>
<span
style={{
fontSize: 11,
color: "#9ca3af",
marginTop: 4,
}}
>
);
}
return (
<div
style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", width: "100%" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, maxWidth: "72%" }}>
{/* Edit button — appears on hover, to the left of the bubble */}
{hovered && !isStreaming && onEdit && (
<Tooltip title="Edit message">
<button
onClick={() => { setEditValue(message.content); setEditing(true); }}
style={{
background: "none", border: "none", cursor: "pointer",
padding: "4px 6px", borderRadius: 5,
color: "#9ca3af", fontSize: 13, flexShrink: 0,
display: "flex", alignItems: "center",
transition: "color 0.15s",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#6b7280"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
>
<EditOutlined />
</button>
</Tooltip>
)}
<div
style={{
backgroundColor: "#f0f2f5",
borderRadius: 16,
padding: "10px 14px",
fontSize: 14,
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#111827",
}}
>
{message.content}
</div>
</div>
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{formatTimestamp(message.timestamp)}
</span>
</div>
@ -188,9 +312,49 @@ function AssistantBubble({
)}
</div>
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{formatTimestamp(message.timestamp)}
</span>
<CopyButton text={mainContent} />
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 6 }}>
<Tooltip title={copied ? "Copied!" : "Copy"}>
<button
onClick={handleCopy}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px 6px",
borderRadius: 5,
color: copied ? "#52c41a" : "#9ca3af",
fontSize: 13,
display: "flex",
alignItems: "center",
gap: 4,
transition: "color 0.15s",
}}
onMouseEnter={(e) => {
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#6b7280";
}}
onMouseLeave={(e) => {
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
}}
>
{copied ? <CheckOutlined /> : <CopyOutlined />}
</button>
</Tooltip>
</div>
);
}
@ -356,9 +520,10 @@ function ToolCard({ message }: ToolCardProps) {
interface Props {
messages: ChatMessage[];
isStreaming: boolean;
onEditMessage?: (messageId: string, newContent: string) => void;
}
const ChatMessages: React.FC<Props> = ({ messages, isStreaming }) => {
const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage }) => {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -379,7 +544,7 @@ const ChatMessages: React.FC<Props> = ({ messages, isStreaming }) => {
const isLastMessage = idx === lastIndex;
if (msg.role === "user") {
return <UserBubble key={msg.id} message={msg} />;
return <UserBubble key={msg.id} message={msg} onEdit={onEditMessage} isStreaming={isStreaming} />;
}
if (msg.role === "tool") {

View file

@ -2,14 +2,27 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Select, Tooltip, Skeleton, Popover, message } from "antd";
import { SettingOutlined, PlusOutlined, BorderOutlined } from "@ant-design/icons";
import {
SettingOutlined,
PlusOutlined,
BorderOutlined,
EditOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SearchOutlined,
MessageOutlined,
AppstoreOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
import { useRouter, useSearchParams } from "next/navigation";
import { useChatHistory } from "./useChatHistory";
import ConversationList from "./ConversationList";
import ChatMessages from "./ChatMessages";
import MCPConnectPicker from "./MCPConnectPicker";
import MCPAppsPanel from "./MCPAppsPanel";
import { fetchAvailableModels } from "../playground/llm_calls/fetch_models";
import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion";
import { serverRootPath } from "@/components/networking";
interface ChatPageProps {
accessToken: string;
@ -34,6 +47,19 @@ function getGreeting(): string {
const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model";
// Build the dashboard root URL
function getDashboardUrl(): string {
const base = process.env.NEXT_PUBLIC_BASE_URL ?? "";
const trimmed = base.replace(/^\/+|\/+$/g, "");
const uiPath = trimmed ? `/${trimmed}/` : "/";
if (serverRootPath && serverRootPath !== "/") {
const cleanRoot = serverRootPath.replace(/\/+$/, "");
const cleanUi = uiPath.replace(/^\/+/, "");
return `${cleanRoot}/${cleanUi}`;
}
return uiPath;
}
const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, userEmail }) => {
const router = useRouter();
const searchParams = useSearchParams();
@ -46,7 +72,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
const [isStreaming, setIsStreaming] = useState(false);
const [inputText, setInputText] = useState("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarView, setSidebarView] = useState<"chats" | "apps">("chats");
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
@ -60,6 +87,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateAfterMessage,
deleteConversation,
renameConversation,
} = useChatHistory(activeConversationId);
@ -70,7 +98,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
setIsLoadingModels(true);
fetchAvailableModels(accessToken)
.then((data) => {
const names = (data || []).map((m: { model_name?: string }) => m.model_name ?? "").filter(Boolean);
const names = (data || []).map((m: { model_group?: string }) => m.model_group ?? "").filter(Boolean);
setModels(names);
const saved = localStorage.getItem(LOCALSTORAGE_MODEL_KEY);
if (saved && names.includes(saved)) {
@ -80,9 +108,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
localStorage.setItem(LOCALSTORAGE_MODEL_KEY, names[0]);
}
})
.catch(() => {
message.error("Could not load models");
})
.catch(() => message.error("Could not load models"))
.finally(() => setIsLoadingModels(false));
}, [accessToken]);
@ -121,15 +147,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
{ role: "user" as const, content: trimmed },
];
let accumulatedContent = "";
let accumulatedReasoning = "";
try {
await makeOpenAIChatCompletionRequest(
history,
(chunk: string) => updateLastAssistantMessage(convId!, { content: chunk }),
(chunk: string) => {
accumulatedContent += chunk;
updateLastAssistantMessage(convId!, { content: accumulatedContent });
},
selectedModel,
accessToken,
undefined,
abortControllerRef.current.signal,
(rc: string) => updateLastAssistantMessage(convId!, { reasoningContent: rc }),
(rc: string) => {
accumulatedReasoning += rc;
updateLastAssistantMessage(convId!, { reasoningContent: accumulatedReasoning });
},
undefined, undefined, undefined, undefined, undefined, undefined,
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
);
@ -156,6 +191,17 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
abortControllerRef.current?.abort();
}, []);
const handleEditAndResend = useCallback(
(messageId: string, newContent: string) => {
if (!activeConversationId || isStreaming) return;
// Remove the edited message and everything after it
truncateAfterMessage(activeConversationId, messageId);
// Re-send with the new content (handleSend appends user msg + starts completion)
handleSend(newContent);
},
[activeConversationId, isStreaming, truncateAfterMessage, handleSend],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@ -172,8 +218,54 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
}, [inputText]);
const showBlankState = !activeConversation || activeConversation.messages.length === 0;
const displayName = userEmail?.split("@")[0] ?? userId ?? "there";
const greeting = `${getGreeting()}, ${displayName}`;
const displayName = userEmail?.split("@")[0] ?? userId ?? "";
const greeting = displayName ? `${getGreeting()}, ${displayName}` : getGreeting();
const dashboardUrl = getDashboardUrl();
// ---- Sidebar nav item renderer (inline, not a function-in-function) ----
const sidebarNavItem = (
icon: React.ReactNode,
label: string,
onClick: () => void,
active = false,
kbd?: string,
) => (
<Tooltip title={sidebarCollapsed ? label : undefined} placement="right" key={label}>
<button
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
width: "100%",
borderRadius: 7,
border: "none",
cursor: "pointer",
background: active ? "#e8f4ff" : "transparent",
color: active ? "#1677ff" : "#374151",
textAlign: "left",
fontSize: 14,
justifyContent: sidebarCollapsed ? "center" : "flex-start",
transition: "background 0.12s",
}}
onMouseEnter={(e) => {
if (!active) (e.currentTarget as HTMLButtonElement).style.background = "#f5f5f5";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = active ? "#e8f4ff" : "transparent";
}}
>
<span style={{ fontSize: 16, flexShrink: 0 }}>{icon}</span>
{!sidebarCollapsed && (
<>
<span style={{ flex: 1 }}>{label}</span>
{kbd && <span style={{ fontSize: 11, color: "#9ca3af" }}>{kbd}</span>}
</>
)}
</button>
</Tooltip>
);
return (
<div style={{
@ -185,56 +277,103 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
overflow: "hidden",
}}>
{/* Conversation sidebar — slides in */}
{sidebarOpen && (
{/* ===== LEFT SIDEBAR ===== */}
<div style={{
width: sidebarCollapsed ? 56 : 260,
flexShrink: 0,
background: "#f9fafb",
borderRight: "1px solid #e5e7eb",
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: "width 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
}}>
{/* Sidebar header: logo + collapse button */}
<div style={{
width: 260,
flexShrink: 0,
background: "#fafafa",
borderRight: "1px solid #f0f0f0",
display: "flex",
flexDirection: "column",
overflow: "hidden",
alignItems: "center",
padding: "12px 10px",
justifyContent: sidebarCollapsed ? "center" : "space-between",
flexShrink: 0,
}}>
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`/chat?id=${id}`)}
onDelete={deleteConversation}
onNewChat={() => router.push("/chat")}
onRename={renameConversation}
/>
{!sidebarCollapsed && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<img
src="/assets/logos/litellm_logo.jpg"
alt="LiteLLM"
width={26}
height={26}
style={{ borderRadius: 6, objectFit: "cover", flexShrink: 0 }}
/>
<span style={{ fontWeight: 700, fontSize: 15, color: "#111827", letterSpacing: "-0.01em" }}>
LiteLLM
</span>
</div>
)}
<Tooltip title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} placement="right">
<button
onClick={() => setSidebarCollapsed((v) => !v)}
style={{
background: "none", border: "none", cursor: "pointer",
padding: 6, borderRadius: 7, color: "#6b7280", fontSize: 16,
display: "flex", alignItems: "center",
}}
>
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
</Tooltip>
</div>
)}
{/* Main area */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Sidebar nav buttons */}
<div style={{ padding: "0 8px 4px", flexShrink: 0 }}>
{sidebarNavItem(<EditOutlined />, "New chat", () => router.push("/chat"))}
{sidebarNavItem(<SearchOutlined />, "Search chats", () => {}, false, "⌘K")}
</div>
{/* Top bar */}
<div style={{ height: 1, background: "#e5e7eb", margin: "4px 8px", flexShrink: 0 }} />
{/* Chats / Apps tabs */}
<div style={{ padding: "4px 8px", flexShrink: 0 }}>
{sidebarNavItem(<MessageOutlined />, "Chats", () => setSidebarView("chats"), sidebarView === "chats")}
{sidebarNavItem(<AppstoreOutlined />, "Apps", () => setSidebarView("apps"), sidebarView === "apps")}
</div>
<div style={{ height: 1, background: "#e5e7eb", margin: "4px 8px", flexShrink: 0 }} />
{/* Sidebar content — only conversation list, only when in chats view and expanded */}
{!sidebarCollapsed && sidebarView === "chats" && (
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`/chat?id=${id}`)}
onDelete={deleteConversation}
onNewChat={() => router.push("/chat")}
onRename={renameConversation}
/>
</div>
)}
</div>
{/* ===== MAIN AREA ===== */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{/* Top bar — clean, minimal like ChatGPT */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 20px",
padding: "8px 16px",
flexShrink: 0,
borderBottom: "1px solid #f0f0f0",
background: "#fff",
height: 48,
}}>
{/* Left: sidebar toggle + model selector */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
onClick={() => setSidebarOpen((v) => !v)}
style={{
background: "none", border: "none", cursor: "pointer",
padding: 6, borderRadius: 6, color: "#595959",
fontSize: 18, lineHeight: 1,
}}
title="Toggle chat history"
>
</button>
{/* Left: model selector */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{isLoadingModels ? (
<Skeleton.Input active style={{ width: 160, height: 32 }} />
<Skeleton.Input active style={{ width: 160, height: 28 }} />
) : (
<Select
value={selectedModel || undefined}
@ -243,7 +382,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
placeholder="Select model"
style={{ width: 220 }}
size="middle"
variant="filled"
variant="borderless"
options={models.map((m) => ({
value: m,
label: m.length > 35 ? m.slice(0, 35) + "…" : m,
@ -252,48 +391,80 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
)}
</div>
{/* Center: LiteLLM logo */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<img
src="/assets/logos/litellm_logo.jpg"
alt="LiteLLM"
width={28}
height={28}
style={{ borderRadius: 6, objectFit: "cover" }}
/>
<span style={{ fontWeight: 600, fontSize: 15, color: "#1f2937", letterSpacing: "-0.01em" }}>
LiteLLM Chat
</span>
{/* Right: back to dashboard + settings */}
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<Tooltip title="Back to Dashboard">
<a
href={dashboardUrl}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 7,
border: "1px solid #e5e7eb",
color: "#374151",
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
background: "#fff",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#f9fafb";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#fff";
}}
>
<ArrowLeftOutlined style={{ fontSize: 12 }} />
Dashboard
</a>
</Tooltip>
<Tooltip title="Settings">
<button style={{
background: "none", border: "none", cursor: "pointer",
padding: 7, borderRadius: 7, color: "#6b7280", fontSize: 16,
display: "flex", alignItems: "center",
}}>
<SettingOutlined />
</button>
</Tooltip>
</div>
{/* Right: settings */}
<Tooltip title="Settings">
<button style={{
background: "none", border: "none", cursor: "pointer",
padding: 6, borderRadius: 6, color: "#595959", fontSize: 18,
}}>
<SettingOutlined />
</button>
</Tooltip>
</div>
{/* Storage warning banner */}
{storageUnavailable && !storageBannerDismissed && (
<div style={{
background: "#fffbe6", borderBottom: "1px solid #ffe58f",
padding: "8px 20px", fontSize: 13, color: "#874d00",
padding: "6px 20px", fontSize: 13, color: "#874d00",
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
<span>Chat history won&apos;t be saved in this browser session.</span>
<button onClick={() => setStorageBannerDismissed(true)}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "#92400e" }}>
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "#874d00" }}>
×
</button>
</div>
)}
{/* Content area */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column", background: "#f9fafb" }}>
{showBlankState ? (
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", background: "#fff" }}>
{/* ---- Apps page view ---- */}
{sidebarView === "apps" ? (
<div style={{ flex: 1, maxWidth: 800, margin: "0 auto", width: "100%", padding: "32px 24px" }}>
<div style={{ marginBottom: 24 }}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700, color: "#111827" }}>MCP Servers</h1>
<p style={{ margin: "6px 0 0", fontSize: 14, color: "#6b7280" }}>
Connect tools to your chat. Toggled servers are active in every new message.
</p>
</div>
<MCPAppsPanel
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
</div>
) : showBlankState ? (
/* ---- Blank state ---- */
<div style={{
flex: 1,
@ -301,33 +472,20 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0 24px 60px",
padding: "0 24px 80px",
}}>
{/* Greeting */}
<div style={{
display: "flex",
alignItems: "center",
gap: 14,
marginBottom: 40,
<h1 style={{
margin: "0 0 32px",
fontSize: 28,
fontWeight: 600,
color: "#111827",
fontFamily: "inherit",
letterSpacing: "-0.01em",
textAlign: "center",
}}>
<img
src="/assets/logos/litellm_logo.jpg"
alt="LiteLLM"
width={40}
height={40}
style={{ borderRadius: 8, objectFit: "cover" }}
/>
<h1 style={{
margin: 0,
fontSize: 32,
fontWeight: 600,
color: "#1f2937",
fontFamily: "inherit",
letterSpacing: "-0.01em",
}}>
{greeting}
</h1>
</div>
{greeting}
</h1>
{/* Input card */}
<div style={{
@ -335,8 +493,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
maxWidth: 680,
background: "#fff",
borderRadius: 12,
border: "1px solid #e8e8e8",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
border: "1px solid #e5e7eb",
boxShadow: "0 1px 6px rgba(0,0,0,0.06)",
overflow: "hidden",
}}>
<textarea
@ -353,71 +511,72 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
outline: "none",
resize: "none",
fontSize: 15,
color: "#1f2937",
color: "#111827",
background: "transparent",
fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
{/* Card footer */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px 12px",
borderTop: "1px solid #f5f5f5",
borderTop: "1px solid #f3f4f6",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<Popover
open={mcpPopoverOpen}
onOpenChange={setMcpPopoverOpen}
content={
<MCPConnectPicker
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
}
trigger="click"
placement="topLeft"
>
<Popover
open={mcpPopoverOpen}
onOpenChange={setMcpPopoverOpen}
content={
<MCPConnectPicker
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
}
trigger="click"
placement="topLeft"
>
<Tooltip title="Attach tools">
<button style={{
background: "none", border: "1px solid #d9d9d9",
background: "none", border: "1px solid #d1d5db",
borderRadius: 6, padding: "5px 10px",
cursor: "pointer", fontSize: 16, color: "#595959",
display: "flex", alignItems: "center",
cursor: "pointer", fontSize: 14, color: "#6b7280",
display: "flex", alignItems: "center", gap: 4,
}}>
<PlusOutlined />
{selectedMCPServers.length > 0 && (
<span style={{ fontSize: 12, color: "#1677ff", fontWeight: 500 }}>
{selectedMCPServers.length}
</span>
)}
</button>
</Popover>
</div>
</Tooltip>
</Popover>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "#8c8c8c", maxWidth: 140, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<span style={{ fontSize: 12, color: "#9ca3af", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{selectedModel || "No model"}
</span>
{isStreaming ? (
<button
onClick={handleStop}
style={{
background: "#ff4d4f", border: "none", borderRadius: 6,
padding: "6px 8px", cursor: "pointer", color: "#fff",
display: "flex", alignItems: "center",
}}
>
<BorderOutlined />
<button onClick={handleStop} style={{
background: "#111827", border: "none", borderRadius: 7,
padding: "7px 10px", cursor: "pointer", color: "#fff",
display: "flex", alignItems: "center",
}}>
<BorderOutlined style={{ fontSize: 14 }} />
</button>
) : (
<button
onClick={() => handleSend(inputText)}
disabled={!inputText.trim() || isLoadingModels || !selectedModel}
style={{
background: inputText.trim() && selectedModel ? "#1677ff" : "#f0f0f0",
border: "none", borderRadius: 6,
padding: "6px 14px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
color: inputText.trim() && selectedModel ? "#fff" : "#bfbfbf",
display: "flex", alignItems: "center",
transition: "background 0.15s",
background: inputText.trim() && selectedModel ? "#1677ff" : "#f3f4f6",
border: "none", borderRadius: 7,
padding: "7px 16px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
color: inputText.trim() && selectedModel ? "#fff" : "#9ca3af",
fontSize: 14, fontWeight: 500,
transition: "background 0.15s",
}}
>
Send
@ -428,23 +587,29 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
</div>
{/* Suggestion chips */}
<div style={{ display: "flex", gap: 10, marginTop: 16, flexWrap: "wrap", justifyContent: "center" }}>
<div style={{ display: "flex", gap: 8, marginTop: 14, flexWrap: "wrap", justifyContent: "center" }}>
{SUGGESTIONS.map((s) => (
<button
key={s.label}
onClick={() => setInputText(s.label + ": ")}
style={{
background: "#fff",
border: "1px solid #e8e8e8",
borderRadius: 8,
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 20,
padding: "7px 16px",
fontSize: 14,
color: "#595959",
color: "#374151",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "#f3f4f6";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "#f9fafb";
}}
>
<span>{s.icon}</span> {s.label}
</button>
@ -458,6 +623,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
<ChatMessages
messages={activeConversation.messages}
isStreaming={isStreaming}
onEditMessage={handleEditAndResend}
/>
</div>
@ -466,8 +632,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
<div style={{
background: "#fff",
borderRadius: 12,
border: "1px solid #e8e8e8",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
border: "1px solid #e5e7eb",
boxShadow: "0 1px 6px rgba(0,0,0,0.06)",
overflow: "hidden",
}}>
<textarea
@ -475,16 +641,16 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Reply..."
placeholder="Send a message..."
style={{
width: "100%",
minHeight: 56,
minHeight: 52,
padding: "16px 20px 8px",
border: "none",
outline: "none",
resize: "none",
fontSize: 15,
color: "#1f2937",
color: "#111827",
background: "transparent",
fontFamily: "inherit",
boxSizing: "border-box",
@ -495,7 +661,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
alignItems: "center",
justifyContent: "space-between",
padding: "4px 12px 10px",
borderTop: "1px solid #f5f5f5",
borderTop: "1px solid #f3f4f6",
}}>
<Popover
open={mcpPopoverOpen}
@ -511,35 +677,40 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
placement="topLeft"
>
<button style={{
background: "none", border: "1px solid #d9d9d9",
background: "none", border: "1px solid #d1d5db",
borderRadius: 6, padding: "5px 10px",
cursor: "pointer", fontSize: 14, color: "#595959",
display: "flex", alignItems: "center",
cursor: "pointer", fontSize: 14, color: "#6b7280",
display: "flex", alignItems: "center", gap: 4,
}}>
<PlusOutlined />
{selectedMCPServers.length > 0 && (
<span style={{ fontSize: 12, color: "#1677ff", fontWeight: 500 }}>
{selectedMCPServers.length}
</span>
)}
</button>
</Popover>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "#8c8c8c" }}>
{selectedMCPServers.length > 0 ? `MCP (${selectedMCPServers.length})` : ""}
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{selectedMCPServers.length > 0 ? `${selectedMCPServers.length} tool${selectedMCPServers.length > 1 ? "s" : ""} connected` : ""}
</span>
{isStreaming ? (
<button onClick={handleStop} style={{
background: "#ff4d4f", border: "none", borderRadius: 6,
padding: "6px 8px", cursor: "pointer", color: "#fff",
background: "#111827", border: "none", borderRadius: 7,
padding: "7px 10px", cursor: "pointer", color: "#fff",
display: "flex", alignItems: "center",
}}>
<BorderOutlined />
<BorderOutlined style={{ fontSize: 14 }} />
</button>
) : (
<button
onClick={() => handleSend(inputText)}
disabled={!inputText.trim() || isLoadingModels || !selectedModel}
style={{
background: inputText.trim() && selectedModel ? "#1677ff" : "#f0f0f0",
border: "none", borderRadius: 6,
padding: "6px 14px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
color: inputText.trim() && selectedModel ? "#fff" : "#bfbfbf",
background: inputText.trim() && selectedModel ? "#1677ff" : "#f3f4f6",
border: "none", borderRadius: 7,
padding: "7px 16px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
color: inputText.trim() && selectedModel ? "#fff" : "#9ca3af",
fontSize: 14, fontWeight: 500,
transition: "background 0.15s",
}}

View file

@ -0,0 +1,296 @@
"use client";
import React, { useEffect, useState } from "react";
import { Switch, Spin, Input } from "antd";
import { SearchOutlined, RightOutlined } from "@ant-design/icons";
import { fetchMCPServers, listMCPTools } from "../networking";
import { MCPServer } from "../mcp_tools/types";
import { message } from "antd";
interface Props {
accessToken: string;
selectedServers: string[];
onChange: (servers: string[]) => void;
}
const AVATAR_COLORS = [
"#1677ff", "#52c41a", "#fa8c16", "#eb2f96", "#722ed1",
"#13c2c2", "#fa541c", "#2f54eb", "#a0d911", "#faad14",
];
function getAvatarColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
type TabKey = "all" | "connected";
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const [servers, setServers] = useState<MCPServer[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
const [expandedServer, setExpandedServer] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchMCPServers(accessToken)
.then((data) => {
if (cancelled) return;
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
setServers(list);
})
.catch(() => {
if (!cancelled) setServers([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [accessToken]);
const handleToggle = async (serverName: string, checked: boolean) => {
if (!checked) {
onChange(selectedServers.filter((s) => s !== serverName));
return;
}
setTogglingOn((prev) => new Set(prev).add(serverName));
try {
const result = await listMCPTools(accessToken, serverName);
if (result?.error) {
message.warning(`Could not load tools for ${serverName}`);
return;
}
onChange([...selectedServers, serverName]);
} catch {
message.warning(`Could not load tools for ${serverName}`);
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
next.delete(serverName);
return next;
});
}
};
const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id;
const filtered = servers.filter((s) => {
const name = nameOf(s);
const matchesQuery = !query.trim() ||
name.toLowerCase().includes(query.toLowerCase()) ||
(s.description ?? "").toLowerCase().includes(query.toLowerCase());
const matchesTab = activeTab === "all" || selectedServers.includes(name);
return matchesQuery && matchesTab;
});
const connectedCount = servers.filter((s) => selectedServers.includes(nameOf(s))).length;
return (
<div style={{ width: "100%", maxWidth: 800, margin: "0 auto" }}>
{/* ── Page header ── */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28, gap: 16, flexWrap: "wrap" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, color: "#111827", letterSpacing: "-0.02em" }}>
MCP Servers
</h1>
<span style={{
fontSize: 11, fontWeight: 600, color: "#1677ff",
background: "#e8f4ff", borderRadius: 4, padding: "2px 7px",
letterSpacing: "0.04em", textTransform: "uppercase",
}}>
BETA
</span>
</div>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>
Connect tools to your chat. Toggle servers to use them in every message.
</p>
</div>
{/* Search */}
<div style={{ width: 240, flexShrink: 0 }}>
<Input
prefix={<SearchOutlined style={{ color: "#9ca3af", fontSize: 14 }} />}
placeholder="Search servers..."
value={query}
onChange={(e) => setQuery(e.target.value)}
allowClear
style={{ borderRadius: 20, fontSize: 14, height: 38 }}
/>
</div>
</div>
{/* ── Hero banner ── */}
{!query && (
<div style={{
borderRadius: 16,
background: "linear-gradient(135deg, #1677ff 0%, #36cfc9 60%, #faad14 100%)",
padding: "28px 32px",
marginBottom: 24,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 24,
overflow: "hidden",
position: "relative",
}}>
<div style={{ zIndex: 1 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: "#fff", marginBottom: 6, letterSpacing: "-0.01em" }}>
Supercharge your chat with MCP tools
</div>
<div style={{ fontSize: 14, color: "rgba(255,255,255,0.85)", maxWidth: 400 }}>
MCP servers give Claude access to external data, APIs, and actions right inside your conversation.
</div>
{connectedCount > 0 && (
<div style={{
marginTop: 14,
display: "inline-flex", alignItems: "center", gap: 6,
background: "rgba(255,255,255,0.2)", borderRadius: 20,
padding: "5px 14px", fontSize: 13, color: "#fff", fontWeight: 500,
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#52c41a", flexShrink: 0, display: "inline-block" }} />
{connectedCount} server{connectedCount > 1 ? "s" : ""} connected
</div>
)}
</div>
{/* Decorative circles */}
<div style={{
position: "absolute", right: -20, top: -20,
width: 160, height: 160, borderRadius: "50%",
background: "rgba(255,255,255,0.08)",
}} />
<div style={{
position: "absolute", right: 60, bottom: -40,
width: 120, height: 120, borderRadius: "50%",
background: "rgba(255,255,255,0.06)",
}} />
</div>
)}
{/* ── Tabs ── */}
<div style={{ display: "flex", gap: 4, marginBottom: 16 }}>
{(["all", "connected"] as TabKey[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: "6px 16px",
borderRadius: 20,
border: "none",
cursor: "pointer",
fontSize: 14,
fontWeight: activeTab === tab ? 600 : 400,
background: activeTab === tab ? "#111827" : "transparent",
color: activeTab === tab ? "#fff" : "#6b7280",
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
if (activeTab !== tab) (e.currentTarget as HTMLButtonElement).style.background = "#f3f4f6";
}}
onMouseLeave={(e) => {
if (activeTab !== tab) (e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
{tab === "all" ? "All" : `Connected${connectedCount > 0 ? ` (${connectedCount})` : ""}`}
</button>
))}
</div>
{/* ── Server grid ── */}
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "60px 0" }}>
<Spin size="large" />
</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 14, padding: "60px 12px" }}>
{servers.length === 0
? "No MCP servers configured. Add servers in Tools → MCP Servers."
: activeTab === "connected"
? "No servers connected yet. Toggle a server below to connect it."
: "No servers match your search."}
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1, border: "1px solid #e5e7eb", borderRadius: 12, overflow: "hidden" }}>
{filtered.map((server) => {
const name = nameOf(server);
const isConnected = selectedServers.includes(name);
const isTogglingOn = togglingOn.has(name);
const isExpanded = expandedServer === name;
const color = getAvatarColor(name);
return (
<div
key={server.server_id}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "16px 20px",
background: isExpanded ? "#f9fafb" : "#fff",
cursor: "pointer",
borderBottom: "1px solid #f0f0f0",
transition: "background 0.12s",
}}
onClick={() => setExpandedServer(isExpanded ? null : name)}
onMouseEnter={(e) => {
if (!isExpanded) (e.currentTarget as HTMLDivElement).style.background = "#fafafa";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background = isExpanded ? "#f9fafb" : "#fff";
}}
>
{/* Avatar */}
<div style={{
width: 40, height: 40, borderRadius: 10,
background: color, display: "flex",
alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 16,
flexShrink: 0, boxShadow: "0 1px 4px rgba(0,0,0,0.12)",
}}>
{name.charAt(0).toUpperCase()}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 600, color: "#111827",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{name}
</div>
<div style={{
fontSize: 12, color: "#6b7280", marginTop: 2,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{server.description ?? "MCP server"}
</div>
</div>
{/* Right side: toggle (when expanded) or chevron */}
{isExpanded ? (
<div onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={isConnected}
loading={isTogglingOn}
onChange={(checked) => handleToggle(name, checked)}
/>
</div>
) : (
<RightOutlined style={{ fontSize: 12, color: "#9ca3af", flexShrink: 0 }} />
)}
</div>
);
})}
</div>
)}
</div>
);
};
export default MCPAppsPanel;

View file

@ -52,6 +52,7 @@ export function useChatHistory(activeConversationId: string | null): {
createConversation: (model: string) => string;
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>) => void;
truncateAfterMessage: (conversationId: string, messageId: string) => void;
deleteConversation: (id: string) => void;
renameConversation: (id: string, newTitle: string) => void;
setActiveConversationId: (id: string | null) => void;
@ -61,6 +62,12 @@ export function useChatHistory(activeConversationId: string | null): {
const [staleId, setStaleId] = useState(false);
const [currentActiveId, setCurrentActiveId] = useState<string | null>(activeConversationId);
// Sync internal active id whenever the URL-derived prop changes (e.g. "New chat" → null)
useEffect(() => {
setCurrentActiveId(activeConversationId);
setStaleId(false);
}, [activeConversationId]);
useEffect(() => {
const { conversations: loaded, storageUnavailable: unavailable } = loadFromStorage();
setConversations(loaded);
@ -200,6 +207,24 @@ export function useChatHistory(activeConversationId: string | null): {
[storageUnavailable],
);
const truncateAfterMessage = useCallback(
(conversationId: string, messageId: string) => {
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) return conv;
const idx = conv.messages.findIndex((m) => m.id === messageId);
if (idx === -1) return conv;
const messages = conv.messages.slice(0, idx);
return { ...conv, messages, updatedAt: Date.now() };
});
const trimmed = trimConversations(updated);
if (!storageUnavailable) saveToStorage(trimmed);
return trimmed;
});
},
[storageUnavailable],
);
const deleteConversation = useCallback(
(id: string) => {
const updated = conversations.filter((c) => c.id !== id);
@ -239,6 +264,7 @@ export function useChatHistory(activeConversationId: string | null): {
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateAfterMessage,
deleteConversation,
renameConversation,
setActiveConversationId,