From afa739b4232ca4a3f857736cc7b83f5cad8465cb Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 3 Jul 2026 15:35:52 -0700 Subject: [PATCH] fix(ui): design polish and per-tab routing for chat UI Moves Chats/Integrations/Credentials/API Keys/Usage from client-side tab state to real nested routes (/chat, /chat/integrations, /chat/credentials, /chat/api-keys, /chat/usage) so each is bookmarkable and survives a hard reload. Extracts the chat sidebar into ChatShell and shared state (MCP server selection, conversation history) into ChatShellContext, both consumed via the new app/chat/layout.tsx. Along the way: fixes conversation URLs pointing at the wrong path (/ui/chat instead of /chat in dev, which 404'd after sending the first message) by reusing the existing migratedHref helper instead of a one-off uiConfig-based path; fixes the topnav view-switcher always showing "AI Gateway" as selected even while on the chat route; and cleans up several shadcn/tailwind styling bugs introduced by the antd migration (boxed tab outline instead of underline, model-selector dropdown overflowing its popover, sidebar nav labels centered instead of left-aligned, duplicate logo, dead non-interactive controls). --- .../src/app/chat/api-keys/page.tsx | 14 + .../src/app/chat/credentials/page.tsx | 14 + .../src/app/chat/integrations/page.tsx | 38 + .../src/app/chat/layout.test.tsx | 86 ++ ui/litellm-dashboard/src/app/chat/layout.tsx | 54 + .../src/app/chat/page.test.tsx | 84 -- ui/litellm-dashboard/src/app/chat/page.tsx | 907 ++++++++++++- .../src/app/chat/usage/page.tsx | 14 + .../src/components/Navbar/ViewSwitcher.tsx | 13 +- .../src/components/chat/ChatPage.tsx | 1161 ----------------- .../src/components/chat/ChatShell.test.tsx | 76 ++ .../src/components/chat/ChatShell.tsx | 186 +++ .../src/components/chat/ConversationList.tsx | 11 +- .../src/components/chat/KeysPanel.tsx | 49 +- .../src/components/chat/MCPAppsPanel.tsx | 12 +- .../src/components/chat/MCPCredentialsTab.tsx | 4 +- .../src/components/chat/UsagePanel.tsx | 40 +- .../src/contexts/ChatShellContext.tsx | 102 ++ 18 files changed, 1538 insertions(+), 1327 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/chat/api-keys/page.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/credentials/page.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/integrations/page.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/layout.test.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/layout.tsx delete mode 100644 ui/litellm-dashboard/src/app/chat/page.test.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/usage/page.tsx delete mode 100644 ui/litellm-dashboard/src/components/chat/ChatPage.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatShell.tsx create mode 100644 ui/litellm-dashboard/src/contexts/ChatShellContext.tsx diff --git a/ui/litellm-dashboard/src/app/chat/api-keys/page.tsx b/ui/litellm-dashboard/src/app/chat/api-keys/page.tsx new file mode 100644 index 00000000000..ffba47479a1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/api-keys/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useChatShell } from "@/contexts/ChatShellContext"; +import KeysPanel from "@/components/chat/KeysPanel"; + +export default function ApiKeysPage() { + const { accessToken, userId, premiumUser } = useChatShell(); + + return ( +
+ +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/chat/credentials/page.tsx b/ui/litellm-dashboard/src/app/chat/credentials/page.tsx new file mode 100644 index 00000000000..d82ed603e04 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/credentials/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useChatShell } from "@/contexts/ChatShellContext"; +import MCPCredentialsTab from "@/components/chat/MCPCredentialsTab"; + +export default function CredentialsPage() { + const { accessToken } = useChatShell(); + + return ( +
+ +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/chat/integrations/page.tsx b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx new file mode 100644 index 00000000000..f85dd591199 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useChatShell } from "@/contexts/ChatShellContext"; +import MCPAppsPanel from "@/components/chat/MCPAppsPanel"; + +// useSearchParams() requires a Suspense boundary for static export. +function IntegrationsPageContent() { + const { accessToken, selectedMCPServers, setSelectedMCPServers } = useChatShell(); + const router = useRouter(); + const searchParams = useSearchParams(); + const oauthReturn = searchParams.get("mcpOauthReturn"); + + // Clean up the OAuth return param after it's been consumed — real routing means + // we no longer need it to pick a tab, but it should not linger in the address bar. + useEffect(() => { + if (oauthReturn) { + const url = new URL(window.location.href); + url.searchParams.delete("mcpOauthReturn"); + router.replace(url.pathname + url.search); + } + }, [oauthReturn, router]); + + return ( +
+ +
+ ); +} + +export default function IntegrationsPage() { + return ( + + + + ); +} diff --git a/ui/litellm-dashboard/src/app/chat/layout.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.test.tsx new file mode 100644 index 00000000000..642fb688057 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/layout.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ChatLayout from "./layout"; + +const { mockUseAuthorized, mockUseUISettings, mockReplace, mockMigratedHref, state } = vi.hoisted(() => { + const state = { + enableChatUI: false, + isUISettingsLoading: false, + }; + return { + state, + mockReplace: vi.fn(), + mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), + mockUseAuthorized: vi.fn(() => ({ + accessToken: "token-123", + userRole: "Internal User", + userId: "user-1", + userEmail: "user@example.com", + premiumUser: false, + })), + mockUseUISettings: vi.fn(() => ({ + data: { values: { enable_chat_ui: state.enableChatUI } }, + isLoading: state.isUISettingsLoading, + })), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); +vi.mock("@/utils/migratedPages", () => ({ migratedHref: mockMigratedHref })); +vi.mock("@/components/navbar", () => ({ default: () =>
})); +vi.mock("@/contexts/ThemeContext", () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/contexts/ChatShellContext", () => ({ + ChatShellProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/components/chat/ChatShell", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe("ChatLayout", () => { + afterEach(() => { + state.enableChatUI = false; + state.isUISettingsLoading = false; + mockReplace.mockClear(); + mockMigratedHref.mockClear(); + }); + + it("renders the chat shell when enable_chat_ui is on", () => { + state.enableChatUI = true; + render( + +
+ , + ); + expect(screen.getByTestId("chat-shell")).toBeInTheDocument(); + expect(screen.getByTestId("page-content")).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("redirects to the dashboard when enable_chat_ui is off", () => { + state.enableChatUI = false; + render( + +
+ , + ); + expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); + expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/"); + }); + + it("renders nothing while UI settings are still loading", () => { + state.isUISettingsLoading = true; + render( + +
+ , + ); + expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/chat/layout.tsx b/ui/litellm-dashboard/src/app/chat/layout.tsx new file mode 100644 index 00000000000..fe7c327d25b --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/layout.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import Navbar from "@/components/navbar"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import { ChatShellProvider } from "@/contexts/ChatShellContext"; +import ChatShell from "@/components/chat/ChatShell"; +import { migratedHref } from "@/utils/migratedPages"; + +// ChatShellProvider uses useSearchParams(), which requires a Suspense boundary for static export. +function ChatLayoutContent({ children }: { children: React.ReactNode }) { + const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized(); + const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings(); + const router = useRouter(); + + const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); + const blocked = !isUISettingsLoading && !chatEnabled; + + useEffect(() => { + if (blocked) router.replace(migratedHref("")); + }, [blocked, router]); + + if (isUISettingsLoading || blocked) return null; + + return ( + +
+ +
+ + {children} + +
+
+
+ ); +} + +export default function ChatLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/ui/litellm-dashboard/src/app/chat/page.test.tsx b/ui/litellm-dashboard/src/app/chat/page.test.tsx deleted file mode 100644 index af85b6b8c7f..00000000000 --- a/ui/litellm-dashboard/src/app/chat/page.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import ChatPageRoute from "./page"; - -const { mockUseAuthorized, mockUseUISettings, mockUseUIConfig, mockReplace, state } = vi.hoisted(() => { - const state = { - userRole: "Internal User" as string, - enableChatUI: false, - isUISettingsLoading: false, - serverRootPath: undefined as string | undefined, - }; - return { - state, - mockReplace: vi.fn(), - mockUseAuthorized: vi.fn(() => ({ - accessToken: "token-123", - userRole: state.userRole, - userId: "user-1", - userEmail: "user@example.com", - premiumUser: false, - })), - mockUseUISettings: vi.fn(() => ({ - data: { values: { enable_chat_ui: state.enableChatUI } }, - isLoading: state.isUISettingsLoading, - })), - mockUseUIConfig: vi.fn(() => ({ data: { server_root_path: state.serverRootPath } })), - }; -}); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ replace: mockReplace }), -})); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); -vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); -vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({ useUIConfig: mockUseUIConfig })); -vi.mock("@/components/chat/ChatPage", () => ({ default: () =>
})); - -describe("ChatPageRoute", () => { - afterEach(() => { - state.userRole = "Internal User"; - state.enableChatUI = false; - state.isUISettingsLoading = false; - state.serverRootPath = undefined; - mockReplace.mockClear(); - }); - - it("renders the chat page when enable_chat_ui is on", () => { - state.enableChatUI = true; - render(); - expect(screen.getByTestId("chat-page")).toBeInTheDocument(); - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it("redirects non-admins to the dashboard when enable_chat_ui is off", () => { - state.enableChatUI = false; - state.userRole = "Internal User"; - render(); - expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument(); - expect(mockReplace).toHaveBeenCalledWith("/ui/"); - }); - - it("redirects admins to the dashboard when enable_chat_ui is off", () => { - state.enableChatUI = false; - state.userRole = "Admin"; - render(); - expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument(); - expect(mockReplace).toHaveBeenCalledWith("/ui/"); - }); - - it("renders nothing while UI settings are still loading", () => { - state.isUISettingsLoading = true; - render(); - expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument(); - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it("respects a custom server_root_path when redirecting", () => { - state.enableChatUI = false; - state.userRole = "Internal User"; - state.serverRootPath = "/api/v1"; - render(); - expect(mockReplace).toHaveBeenCalledWith("/api/v1/ui/"); - }); -}); diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx index 21f34ba9f6e..ddf1bd37120 100644 --- a/ui/litellm-dashboard/src/app/chat/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -1,47 +1,884 @@ "use client"; -import { Suspense, useEffect } from "react"; +import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react"; +import { Plus, ChevronDown, Check } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import MessageManager from "@/components/molecules/message_manager"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { useRouter } from "next/navigation"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; -import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; -import ChatPage from "@/components/chat/ChatPage"; +import { useChatShell } from "@/contexts/ChatShellContext"; +import { CHAT_ROUTES } from "@/components/chat/ChatShell"; +import ChatMessages from "@/components/chat/ChatMessages"; +import MCPConnectPicker from "@/components/chat/MCPConnectPicker"; +import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; +import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion"; +import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api"; +import type { MCPEvent } from "@/components/chat/types"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; -// ChatPage uses useSearchParams() which requires a Suspense boundary for static export. -const ChatPageContent = () => { - const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized(); - const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings(); - const { data: uiConfig } = useUIConfig(); +const SUGGESTIONS = ["Write", "Learn", "Code", "Brainstorm"]; +const MAX_COMPARISON_MODELS = 3; +const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_models"; + +function getGreeting(): string { + const h = new Date().getHours(); + if (h >= 5 && h < 12) return "Good morning"; + if (h >= 12 && h < 17) return "Good afternoon"; + return "Good evening"; +} + +// Extract provider from model name for logo lookup. +// Handles prefixed models ("groq/llama-3"), and detects well-known providers by keyword. +function getProviderFromModelName(modelName: string): string { + if (!modelName) return ""; + const lower = modelName.toLowerCase(); + const slash = lower.indexOf("/"); + if (slash > 0) return lower.slice(0, slash); + // Keyword matching — order matters (more specific first) + if (lower.includes("claude")) return "anthropic"; + if (lower.includes("gemini")) return "gemini"; + if (lower.includes("gpt") || lower.includes("chatgpt") || /^o[0-9]/.test(lower)) return "openai"; + if (lower.includes("mistral") || lower.includes("codestral")) return "mistral"; + if (lower.includes("llama")) return "meta_llama"; + if (lower.includes("deepseek")) return "deepseek"; + if (lower.includes("grok")) return "xai"; + if (lower.includes("command")) return "cohere"; + if (lower.includes("nova") || lower.includes("titan")) return "bedrock"; + return ""; +} + +interface ComparisonExchange { + userMessage: string; + responses: Record; // model → accumulated response text +} + +interface StreamToModelArgs { + model: string; + messages: Array<{ role: "user" | "assistant"; content: string }>; + accessToken: string; + mcpServers: string[]; + signal: AbortSignal; + onChunk: (model: string, chunk: string) => void; + onDone: (model: string) => void; +} + +// Module-level async helper — each model gets its own independent Promise so they all run in parallel. +async function streamToModel({ + model, + messages, + accessToken, + mcpServers, + signal, + onChunk, + onDone, +}: StreamToModelArgs): Promise { + try { + await makeOpenAIChatCompletionRequest( + messages, + (chunk: string) => onChunk(model, chunk), + model, + accessToken, + undefined, // tags + signal, + undefined, // onReasoningContent + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, // positions 8-13 + mcpServers.length > 0 ? mcpServers : undefined, // position 14: selectedMCPServers + ); + } catch (err: unknown) { + // Surface real errors in the response card; ignore user-triggered aborts + if (!(err instanceof Error && err.name === "AbortError")) { + const msg = err instanceof Error ? err.message : String(err); + onChunk(model, `\n\n_Error: ${msg}_`); + } + } finally { + onDone(model); + } +} + +export default function ChatConversationPage() { const router = useRouter(); + const { + accessToken, + userId, + userEmail, + selectedMCPServers, + setSelectedMCPServers, + activeConversationId, + activeConversation, + storageUnavailable, + staleId, + createConversation, + appendMessage, + updateLastAssistantMessage, + truncateFromMessage, + } = useChatShell(); + const hadActiveConversationOnMountRef = useRef(activeConversationId !== null); - const uiRoot = - uiConfig?.server_root_path && uiConfig.server_root_path !== "/" - ? uiConfig.server_root_path.replace(/\/+$/, "") - : ""; - const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); - const blocked = !isUISettingsLoading && !chatEnabled; + const [selectedModels, setSelectedModels] = useState([]); + const [models, setModels] = useState([]); + const [isLoadingModels, setIsLoadingModels] = useState(true); + const [modelSelectorOpen, setModelSelectorOpen] = useState(false); + const [modelSearchText, setModelSearchText] = useState(""); + + const [responsesSessionId, setResponsesSessionId] = useState(null); + const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId); + const [isStreaming, setIsStreaming] = useState(false); + const [inputText, setInputText] = useState(""); + const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false); + const [storageBannerDismissed, setStorageBannerDismissed] = useState(false); + + // Comparison mode state (active when selectedModels.length > 1) + // Each exchange holds the user message + per-model responses so we can do multi-turn comparison. + const [comparisonExchanges, setComparisonExchanges] = useState([]); + const [comparisonStreamingSet, setComparisonStreamingSet] = useState>(new Set()); + const comparisonAbortControllersRef = useRef>({}); + + const abortControllerRef = useRef(null); + const textareaRef = useRef(null); + const messagesScrollRef = useRef(null); + const [showScrollButton, setShowScrollButton] = useState(false); + const streamScrollLock = useRef(null); useEffect(() => { - if (blocked) router.replace(`${uiRoot}/ui/`); - }, [blocked, uiRoot, router]); + if (staleId) router.replace(CHAT_ROUTES.chats); + }, [staleId, router]); - if (isUISettingsLoading || blocked) return null; + // Load models + useEffect(() => { + if (!accessToken) return; + fetchAvailableModels(accessToken) + .then((data) => { + const names = (data || []).map((m: { model_group?: string }) => m.model_group ?? "").filter(Boolean); + setModels(names); + try { + const saved = localStorage.getItem(LOCALSTORAGE_MODEL_KEY); + if (saved) { + const parsed: unknown = JSON.parse(saved); + if (Array.isArray(parsed)) { + const valid = (parsed as string[]).filter((m) => names.includes(m)); + if (valid.length > 0) { + setSelectedModels(hadActiveConversationOnMountRef.current ? [valid[0]] : valid); + return; + } + } + } + } catch { + // ignore parse errors + } + if (names.length > 0) { + setSelectedModels([names[0]]); + localStorage.setItem(LOCALSTORAGE_MODEL_KEY, JSON.stringify([names[0]])); + } + }) + .catch(() => MessageManager.error("Could not load models")) + .finally(() => setIsLoadingModels(false)); + }, [accessToken]); + + // Reset the responses session when switching between conversations so that + // previous_response_id from conversation A is never sent for conversation B. + if (activeConversationId !== prevConversationIdForSessionReset) { + setPrevConversationIdForSessionReset(activeConversationId); + setResponsesSessionId(null); + } + + const toggleModel = useCallback((model: string) => { + setSelectedModels((prev) => { + let next: string[]; + if (prev.includes(model)) { + next = prev.filter((m) => m !== model); + } else if (prev.length >= MAX_COMPARISON_MODELS) { + return prev; + } else { + next = [...prev, model]; + } + localStorage.setItem(LOCALSTORAGE_MODEL_KEY, JSON.stringify(next)); + return next; + }); + }, []); + + const isComparisonMode = selectedModels.length > 1; + const isAnyStreaming = isStreaming || comparisonStreamingSet.size > 0; + + const handleSend = useCallback( + async (text: string, historyOverride?: Array<{ role: "user" | "assistant"; content: string }>) => { + const trimmed = text.trim(); + if (!trimmed || selectedModels.length === 0 || isStreaming) return; + const model = selectedModels[0]; + setInputText(""); + + let convId = activeConversationId; + if (!convId) { + convId = createConversation(model); + setResponsesSessionId(null); // new conversation starts a fresh session + router.push(`${CHAT_ROUTES.chats}?id=${convId}`); + } + + appendMessage(convId, { role: "user", content: trimmed }); + appendMessage(convId, { role: "assistant", content: "" }); + + setIsStreaming(true); + abortControllerRef.current = new AbortController(); + + // When historyOverride is set (edit / retry), the existing server-side + // session chain covers messages that were just truncated and is no longer + // valid for the rewritten history. Eagerly clear the session so that a + // failed/aborted edit does not leave a stale session ID that contaminates + // the next regular send. + if (historyOverride) { + setResponsesSessionId(null); + } + + // On a normal continuation turn with an active session, the Responses API + // already holds the prior context server-side, so we only pass the new + // user message (sending the full history would double-count it). + // + // On the very first turn (no session yet), we send the full history. + const previousResponseId = historyOverride ? null : responsesSessionId; + + const history: Array<{ role: "user" | "assistant"; content: string }> = historyOverride + ? [...historyOverride, { role: "user" as const, content: trimmed }] + : previousResponseId + ? [{ role: "user" as const, content: trimmed }] + : [ + // Explicitly filter to only user/assistant roles — tool messages + // lack a required tool_call_id and would cause API errors. + ...(activeConversation?.messages ?? []) + .filter( + (m): m is typeof m & { role: "user" | "assistant" } => m.role === "user" || m.role === "assistant", + ) + .map((m) => ({ role: m.role, content: m.content })), + { role: "user" as const, content: trimmed }, + ]; + + let accumulatedContent = ""; + let accumulatedReasoning = ""; + // MCP events accumulated locally so we can persist them to the message + // without relying on component state (which would cause stale closures). + const accumulatedMCPEvents: MCPEvent[] = []; + // Track clean completion so partial events are not shown on error/abort. + let streamCompletedCleanly = false; + + try { + await makeOpenAIResponsesRequest( + history, + (_role: string, chunk: string) => { + accumulatedContent += chunk; + updateLastAssistantMessage(convId!, { content: accumulatedContent }); + }, + model, + accessToken, + undefined, // tags + abortControllerRef.current.signal, + (rc: string) => { + accumulatedReasoning += rc; + updateLastAssistantMessage(convId!, { reasoningContent: accumulatedReasoning }); + }, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + selectedMCPServers.length > 0 ? selectedMCPServers : undefined, + previousResponseId, + (id: string) => setResponsesSessionId(id), + (event: MCPEvent) => { + // Accumulate locally only — persisted once in finally to avoid + // one full localStorage write per MCP event during streaming. + accumulatedMCPEvents.push(event); + }, + ); + streamCompletedCleanly = true; + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + updateLastAssistantMessage(convId!, { + content: accumulatedContent + " [stopped]", + }); + } else { + updateLastAssistantMessage(convId!, { + content: "[Something went wrong. The partial response has been saved.]", + }); + } + } finally { + // Only persist MCP events on clean completion — partial events from an + // aborted or errored turn would show incomplete tool calls to the user. + if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) { + updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents }); + } + setIsStreaming(false); + abortControllerRef.current = null; + } + }, + [ + activeConversationId, + activeConversation, + selectedModels, + selectedMCPServers, + accessToken, + createConversation, + appendMessage, + updateLastAssistantMessage, + router, + isStreaming, + responsesSessionId, + ], + ); + + const handleSendComparison = useCallback( + (text: string, currentExchanges: ComparisonExchange[]) => { + const trimmed = text.trim(); + if (!trimmed || selectedModels.length === 0 || isAnyStreaming) return; + setInputText(""); + + // Append a new exchange with empty responses + const newExchange: ComparisonExchange = { userMessage: trimmed, responses: {} }; + const newExchangeIdx = currentExchanges.length; + setComparisonExchanges((prev) => [...prev, newExchange]); + setComparisonStreamingSet(new Set(selectedModels)); + + const controllers: Record = {}; + selectedModels.forEach((m) => { + controllers[m] = new AbortController(); + }); + comparisonAbortControllersRef.current = controllers; + + // Launch all model streams simultaneously — Promise.allSettled ensures they run in parallel + // (streamToModel handles its own errors internally so these promises won't reject) + void Promise.allSettled( + selectedModels.map((model) => { + // Build per-model history: each model's past responses become its own context + const history: Array<{ role: "user" | "assistant"; content: string }> = []; + for (const ex of currentExchanges) { + history.push({ role: "user", content: ex.userMessage }); + history.push({ role: "assistant", content: ex.responses[model] ?? "" }); + } + history.push({ role: "user", content: trimmed }); + + return streamToModel({ + model, + messages: history, + accessToken, + mcpServers: selectedMCPServers, + signal: controllers[model].signal, + onChunk: (m, chunk) => + setComparisonExchanges((prev) => { + const updated = [...prev]; + const ex = { ...updated[newExchangeIdx] }; + ex.responses = { ...ex.responses, [m]: (ex.responses[m] ?? "") + chunk }; + updated[newExchangeIdx] = ex; + return updated; + }), + onDone: (m) => + setComparisonStreamingSet((prev) => { + const next = new Set(prev); + next.delete(m); + return next; + }), + }); + }), + ); + }, + [selectedModels, accessToken, selectedMCPServers, isAnyStreaming], + ); + + const handleStop = useCallback(() => { + abortControllerRef.current?.abort(); + Object.values(comparisonAbortControllersRef.current).forEach((c) => c.abort()); + comparisonAbortControllersRef.current = {}; + }, []); + + const handleEditAndResend = useCallback( + (messageId: string, newContent: string) => { + if (!activeConversationId || isStreaming) return; + // Compute the truncated history synchronously before the async state update lands, + // so handleSend receives the correct pre-edit context rather than the stale closure value. + const msgs = activeConversation?.messages ?? []; + const idx = msgs.findIndex((m) => m.id === messageId); + const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx)) + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); + truncateFromMessage(activeConversationId, messageId); + handleSend(newContent, priorMessages); + }, + [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend], + ); + + const handleSubmit = useCallback( + (text: string) => { + if (isComparisonMode) { + handleSendComparison(text, comparisonExchanges); + } else { + handleSend(text); + } + }, + [isComparisonMode, handleSend, handleSendComparison, comparisonExchanges], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(inputText); + } + }; + + // Auto-resize textarea + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; + }, [inputText]); + + // Track scroll position to show/hide scroll-to-bottom button + useEffect(() => { + const el = messagesScrollRef.current; + if (!el) return; + const onScroll = () => { + const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + setShowScrollButton(distFromBottom > 120); + if (streamScrollLock.current !== null) { + streamScrollLock.current = el.scrollTop; // track user-initiated scroll + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [activeConversation]); + + // Start/stop the scroll lock when streaming begins/ends + useEffect(() => { + const el = messagesScrollRef.current; + if (isStreaming) { + streamScrollLock.current = el?.scrollTop ?? 0; + } else { + streamScrollLock.current = null; + } + }, [isStreaming]); + + // After every render during streaming, restore the locked scroll position + useLayoutEffect(() => { + if (streamScrollLock.current === null) return; + const el = messagesScrollRef.current; + if (!el) return; + el.scrollTop = streamScrollLock.current; + }); + + // Scroll to bottom only when message COUNT increases (new message added) + const prevMsgCountRef = useRef(0); + useLayoutEffect(() => { + const count = activeConversation?.messages?.length ?? 0; + const prev = prevMsgCountRef.current; + prevMsgCountRef.current = count; + if (count > prev) { + const el = messagesScrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + } + }, [activeConversation?.messages]); + + const showBlankState = !isComparisonMode + ? !activeConversation || activeConversation.messages.length === 0 + : comparisonExchanges.length === 0; + const displayName = userEmail?.split("@")[0] ?? userId ?? ""; + const greeting = displayName ? `${getGreeting()}, ${displayName}` : getGreeting(); + + // Filtered models: selected ones float to the top, then alphabetical + const filteredModels = ( + modelSearchText ? models.filter((m) => m.toLowerCase().includes(modelSearchText.toLowerCase())) : models + ).sort((a, b) => { + const aSelected = selectedModels.includes(a); + const bSelected = selectedModels.includes(b); + if (aSelected && !bSelected) return -1; + if (!aSelected && bSelected) return 1; + return 0; + }); + + const modelSelectorContent = ( +
+
+ setModelSearchText(e.target.value)} + placeholder="Search models..." + className="w-full px-2.5 py-1.5 border rounded-md text-[13px] outline-none bg-background text-foreground" + /> +
+ {selectedModels.length >= MAX_COMPARISON_MODELS && ( +
+ Max {MAX_COMPARISON_MODELS} models selected; deselect one to change +
+ )} + + {filteredModels.map((m) => { + const checked = selectedModels.includes(m); + const disabled = !checked && selectedModels.length >= MAX_COMPARISON_MODELS; + const provider = getProviderFromModelName(m); + const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" }; + return ( + + ); + })} + +
+ ); + + const modelSelectorTrigger = isLoadingModels ? ( + + ) : ( + { + setModelSelectorOpen(open); + if (!open) setModelSearchText(""); + }} + > + + + + + {modelSelectorContent} + + + ); + + const inputBar = (inConversation: boolean) => ( +
+