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).
This commit is contained in:
Krrish Dholakia 2026-07-03 15:35:52 -07:00
parent 0b3e327d02
commit afa739b423
18 changed files with 1538 additions and 1327 deletions

View file

@ -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 (
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
<KeysPanel accessToken={accessToken} userId={userId} premiumUser={premiumUser} />
</div>
);
}

View file

@ -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 (
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
<MCPCredentialsTab accessToken={accessToken} />
</div>
);
}

View file

@ -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 (
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
<MCPAppsPanel accessToken={accessToken} selectedServers={selectedMCPServers} onChange={setSelectedMCPServers} />
</div>
);
}
export default function IntegrationsPage() {
return (
<Suspense>
<IntegrationsPageContent />
</Suspense>
);
}

View file

@ -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: () => <div data-testid="navbar" /> }));
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 }) => <div data-testid="chat-shell">{children}</div>,
}));
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(
<ChatLayout>
<div data-testid="page-content" />
</ChatLayout>,
);
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(
<ChatLayout>
<div data-testid="page-content" />
</ChatLayout>,
);
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(
<ChatLayout>
<div data-testid="page-content" />
</ChatLayout>,
);
expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
});

View file

@ -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 (
<ThemeProvider accessToken={accessToken}>
<div className="flex h-screen flex-col">
<Navbar accessToken={accessToken} isPublicPage={false} />
<div className="min-h-0 flex-1">
<ChatShellProvider
accessToken={accessToken ?? ""}
userId={userId ?? ""}
userEmail={userEmail ?? ""}
userRole={userRole ?? ""}
premiumUser={premiumUser ?? false}
>
<ChatShell>{children}</ChatShell>
</ChatShellProvider>
</div>
</div>
</ThemeProvider>
);
}
export default function ChatLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<ChatLayoutContent>{children}</ChatLayoutContent>
</Suspense>
);
}

View file

@ -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: () => <div data-testid="chat-page" /> }));
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(<ChatPageRoute />);
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(<ChatPageRoute />);
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(<ChatPageRoute />);
expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument();
expect(mockReplace).toHaveBeenCalledWith("/ui/");
});
it("renders nothing while UI settings are still loading", () => {
state.isUISettingsLoading = true;
render(<ChatPageRoute />);
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(<ChatPageRoute />);
expect(mockReplace).toHaveBeenCalledWith("/api/v1/ui/");
});
});

View file

@ -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<string, string>; // 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<void> {
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<string[]>([]);
const [models, setModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSearchText, setModelSearchText] = useState("");
const [responsesSessionId, setResponsesSessionId] = useState<string | null>(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<ComparisonExchange[]>([]);
const [comparisonStreamingSet, setComparisonStreamingSet] = useState<Set<string>>(new Set());
const comparisonAbortControllersRef = useRef<Record<string, AbortController>>({});
const abortControllerRef = useRef<AbortController | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const streamScrollLock = useRef<number | null>(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<string, AbortController> = {};
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<HTMLTextAreaElement>) => {
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 = (
<div className="w-[280px] max-h-[400px] flex flex-col overflow-hidden">
<div className="p-2 pb-1">
<input
autoFocus
value={modelSearchText}
onChange={(e) => 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"
/>
</div>
{selectedModels.length >= MAX_COMPARISON_MODELS && (
<div className="px-3 py-1 text-xs text-muted-foreground">
Max {MAX_COMPARISON_MODELS} models selected; deselect one to change
</div>
)}
<ScrollArea className="flex-1 min-h-0">
{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 (
<button
key={m}
disabled={disabled}
onClick={() => toggleModel(m)}
className={`flex items-center gap-2 w-full px-3 py-[7px] border-none text-left rounded transition-colors ${
checked ? "bg-accent" : "bg-transparent hover:bg-accent/50"
} ${disabled ? "opacity-45 cursor-not-allowed" : "cursor-pointer"}`}
>
<span
className={`w-4 h-4 rounded-[3px] flex items-center justify-center shrink-0 transition-all ${
checked ? "bg-primary border-primary" : "bg-background border-border"
}`}
style={{ border: `1.5px solid ${checked ? "var(--color-primary)" : "var(--color-border)"}` }}
>
{checked && <Check className="h-2.5 w-2.5 text-primary-foreground" />}
</span>
{logo ? (
<img
src={logo}
alt=""
className="w-4 h-4 object-contain shrink-0"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<span className="w-4 shrink-0" />
)}
<span className="text-[13px] text-foreground overflow-hidden text-ellipsis whitespace-nowrap">{m}</span>
</button>
);
})}
</ScrollArea>
</div>
);
const modelSelectorTrigger = isLoadingModels ? (
<Skeleton className="w-40 h-7" />
) : (
<Popover
open={modelSelectorOpen}
onOpenChange={(open) => {
setModelSelectorOpen(open);
if (!open) setModelSearchText("");
}}
>
<PopoverTrigger asChild>
<button className="flex items-center gap-1.5 px-2.5 py-[5px] rounded-md border border-transparent cursor-pointer bg-transparent text-foreground text-sm font-medium max-w-[480px] overflow-hidden hover:bg-accent/50 transition-colors">
{selectedModels.length === 0 ? (
<span className="text-muted-foreground">Select model</span>
) : selectedModels.length === 1 ? (
<>
{(() => {
const provider = getProviderFromModelName(selectedModels[0]);
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
return logo ? (
<img
src={logo}
alt=""
className="w-[18px] h-[18px] object-contain shrink-0"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : null;
})()}
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[240px]">{selectedModels[0]}</span>
</>
) : (
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
{selectedModels.map((m) => {
const provider = getProviderFromModelName(m);
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
return (
<span
key={m}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary/10 rounded-[10px] text-xs text-primary font-medium shrink-0"
>
{logo && (
<img
src={logo}
alt=""
className="w-[13px] h-[13px] object-contain"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap">{m}</span>
</span>
);
})}
</div>
)}
<ChevronDown className="h-2.5 w-2.5 text-muted-foreground shrink-0 ml-0.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0 w-auto">
{modelSelectorContent}
</PopoverContent>
</Popover>
);
const inputBar = (inConversation: boolean) => (
<div className="bg-background rounded-xl border shadow-[0_1px_6px_rgba(0,0,0,0.06)] overflow-hidden">
<textarea
ref={textareaRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={inConversation ? "Send a message..." : "How can I help you today?"}
className="w-full border-none outline-none resize-none text-[15px] text-foreground bg-transparent font-[inherit] box-border"
style={{
minHeight: inConversation ? 52 : 80,
padding: inConversation ? "16px 20px 8px" : "20px 20px 8px",
}}
/>
<div
className="flex items-center justify-between border-t"
style={{ padding: inConversation ? "4px 12px 10px" : "8px 12px 12px" }}
>
<Popover open={mcpPopoverOpen} onOpenChange={setMcpPopoverOpen}>
<PopoverTrigger asChild>
<button className="border rounded-md px-2.5 py-[5px] cursor-pointer text-sm text-muted-foreground flex items-center gap-1 bg-transparent hover:bg-accent/50 transition-colors">
<Plus className="h-3.5 w-3.5" />
{selectedMCPServers.length > 0 && (
<span className="text-xs text-primary font-medium">{selectedMCPServers.length}</span>
)}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="p-0 w-auto">
<MCPConnectPicker
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
</PopoverContent>
</Popover>
<div className="flex items-center gap-2">
{!isComparisonMode && (
<span className="text-xs text-muted-foreground max-w-[160px] overflow-hidden text-ellipsis whitespace-nowrap">
{inConversation
? selectedMCPServers.length > 0
? `${selectedMCPServers.length} tool${selectedMCPServers.length > 1 ? "s" : ""} connected`
: ""
: selectedModels[0] || "No model"}
</span>
)}
{isAnyStreaming ? (
<button
onClick={handleStop}
className="w-8 h-8 rounded-full border-[1.5px] flex items-center justify-center shrink-0 cursor-pointer transition-colors hover:border-muted-foreground bg-transparent text-foreground"
>
<div className="w-2.5 h-2.5 bg-foreground rounded-[2px]" />
</button>
) : (
<button
onClick={() => handleSubmit(inputText)}
disabled={!inputText.trim() || isLoadingModels || selectedModels.length === 0}
className={`border-none rounded-md px-4 py-[7px] text-sm font-medium transition-colors ${
inputText.trim() && selectedModels.length > 0
? "bg-primary text-primary-foreground cursor-pointer hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
>
Send
</button>
)}
</div>
</div>
</div>
);
return (
<ChatPage
accessToken={accessToken ?? ""}
userRole={userRole ?? ""}
userId={userId ?? ""}
userEmail={userEmail ?? ""}
premiumUser={premiumUser ?? false}
/>
<>
<div className="flex items-center px-4 py-2 shrink-0 border-b bg-background h-12">
<div className="flex items-center gap-2 min-w-0 flex-1">{modelSelectorTrigger}</div>
</div>
{storageUnavailable && !storageBannerDismissed && (
<div className="bg-amber-50 border-b border-amber-200 px-5 py-1.5 text-[13px] text-amber-800 flex justify-between items-center">
<span>Chat history won&apos;t be saved in this browser session</span>
<button
onClick={() => setStorageBannerDismissed(true)}
className="text-amber-800 text-base cursor-pointer"
style={{ background: "none", border: "none" }}
>
&times;
</button>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden flex flex-col bg-background">
{showBlankState ? (
<div className="flex-1 flex flex-col items-center justify-center px-6 pb-20">
<h1 className="m-0 mb-8 text-[28px] font-semibold text-foreground tracking-tight text-center">
{isComparisonMode ? `Compare ${selectedModels.length} models` : greeting}
</h1>
{isComparisonMode ? (
<p className="-mt-4 mb-6 text-sm text-muted-foreground text-center">
Send a message to see responses side-by-side
</p>
) : (
<p className="-mt-4 mb-7 text-sm text-muted-foreground text-center max-w-[520px] leading-relaxed">
Chat with 100+ LLMs + MCP tools; authenticate once, use them here.{" "}
<button
onClick={() => router.push(CHAT_ROUTES.integrations)}
className="text-primary text-sm font-medium cursor-pointer hover:underline"
style={{ background: "none", border: "none", padding: 0 }}
>
Open Integrations -&gt;
</button>
</p>
)}
<div className="w-full max-w-[680px]">{inputBar(false)}</div>
{!isComparisonMode && (
<div className="flex gap-2 mt-3.5 flex-wrap justify-center">
{SUGGESTIONS.map((s) => (
<button
key={s}
onClick={() => setInputText(s + ": ")}
className="bg-secondary border rounded-full px-4 py-[7px] text-sm text-foreground/70 cursor-pointer hover:bg-accent transition-colors"
>
{s}
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 min-h-0 flex flex-col mx-auto w-full px-6 relative"
style={{ maxWidth: isComparisonMode ? (selectedModels.length >= 3 ? 1200 : 960) : 760 }}
>
<div
ref={messagesScrollRef}
className="flex-1 min-h-0 overflow-auto pt-6"
style={{ overflowAnchor: "none" }}
>
{isComparisonMode ? (
<div className="pb-2">
{comparisonExchanges.map((exchange, exchangeIdx) => {
const isLastExchange = exchangeIdx === comparisonExchanges.length - 1;
return (
<div key={exchangeIdx} className="mb-8">
<div className="flex justify-end mb-5">
<div className="bg-muted rounded-2xl px-4 py-2.5 max-w-[75%] text-sm text-foreground leading-relaxed">
{exchange.userMessage}
</div>
</div>
<div className="flex gap-3.5 items-start">
{selectedModels.map((model, idx) => {
const provider = getProviderFromModelName(model);
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
const responseText = exchange.responses[model] ?? "";
const isModelStreaming = isLastExchange && comparisonStreamingSet.has(model);
return (
<div key={model} className="flex-1 border rounded-xl overflow-hidden min-w-0">
{exchangeIdx === 0 && (
<div className="px-3.5 py-2.5 border-b flex items-center gap-2 bg-muted/50">
{logo ? (
<img
src={logo}
alt=""
className="w-[18px] h-[18px] object-contain shrink-0"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-[18px] h-[18px] rounded-full bg-border shrink-0" />
)}
<span className="font-semibold text-xs text-foreground">Response {idx + 1}</span>
<span className="text-[11px] text-muted-foreground overflow-hidden text-ellipsis whitespace-nowrap flex-1 min-w-0">
{model}
</span>
</div>
)}
<div className="px-4 py-3.5 min-h-[60px] relative">
{isModelStreaming && (
<span className="absolute top-2.5 right-3 text-[9px] text-primary">&#9679;</span>
)}
{responseText ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => (
<p className="mb-2.5 leading-relaxed text-sm text-foreground">{children}</p>
),
code: ({ className, children }) => {
const isBlock = /language-(\w+)/.exec(className || "");
if (isBlock) {
return (
<pre className="bg-muted px-3 py-2.5 rounded-md overflow-auto text-[13px] my-2">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-[13px]">
{children}
</code>
);
},
}}
>
{responseText}
</ReactMarkdown>
) : isModelStreaming ? (
<span className="text-muted-foreground text-sm">Generating&#8230;</span>
) : (
<span className="text-muted-foreground text-sm">&#8212;</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<ChatMessages
messages={activeConversation!.messages}
isStreaming={isStreaming}
onEditMessage={handleEditAndResend}
/>
)}
</div>
{showScrollButton && (
<button
onClick={() => {
const el = messagesScrollRef.current;
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
if (streamScrollLock.current !== null) {
streamScrollLock.current = el.scrollHeight;
}
}
}}
className="absolute bottom-[100px] left-1/2 -translate-x-1/2 w-[34px] h-[34px] rounded-full bg-background/75 backdrop-blur-md border border-black/10 shadow-sm cursor-pointer flex items-center justify-center text-muted-foreground z-10 transition-colors hover:bg-background/95"
aria-label="Scroll to bottom"
>
<ChevronDown className="h-3 w-3" />
</button>
)}
<div className="py-3 pb-6">{inputBar(true)}</div>
</div>
)}
</div>
</>
);
};
const ChatPageRoute = () => (
<Suspense>
<ChatPageContent />
</Suspense>
);
export default ChatPageRoute;
}

View file

@ -0,0 +1,14 @@
"use client";
import { useChatShell } from "@/contexts/ChatShellContext";
import UsagePanel from "@/components/chat/UsagePanel";
export default function UsagePage() {
const { accessToken, userId } = useChatShell();
return (
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
<UsagePanel accessToken={accessToken} userId={userId} />
</div>
);
}

View file

@ -1,4 +1,5 @@
import React from "react";
import { usePathname } from "next/navigation";
import { Dropdown } from "antd";
import { AppstoreOutlined, CheckOutlined, DownOutlined } from "@ant-design/icons";
import type { MenuProps } from "antd";
@ -12,12 +13,17 @@ const CHAT = "chat";
export default function ViewSwitcher() {
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
const pathname = usePathname();
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
if (plugins.length === 0 && !chatEnabled) return null;
const activeLabel = plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
const chatHref = migratedHref(CHAT);
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`));
const activeLabel = isChatRoute ? "Chat" : plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
const modeEntries = [
{ key: GATEWAY, label: "AI Gateway" },
@ -30,7 +36,7 @@ export default function ViewSwitcher() {
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">{e.label}</span>
{e.key === mode && <CheckOutlined className="text-blue-600" />}
{!isChatRoute && e.key === mode && <CheckOutlined className="text-blue-600" />}
</div>
),
})),
@ -41,6 +47,7 @@ export default function ViewSwitcher() {
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">Chat</span>
{isChatRoute && <CheckOutlined className="text-blue-600" />}
</div>
),
},
@ -57,7 +64,7 @@ export default function ViewSwitcher() {
};
return (
<Dropdown menu={{ items, onClick, selectedKeys: [mode] }} trigger={["click"]}>
<Dropdown menu={{ items, onClick, selectedKeys: [isChatRoute ? CHAT : mode] }} trigger={["click"]}>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-gray-200 px-2.5 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import ChatShell from "./ChatShell";
const { mockPush, mockUsePathname, mockUseChatShell } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockUsePathname: vi.fn(() => "/ui/chat"),
mockUseChatShell: vi.fn(() => ({
conversations: [],
activeConversationId: null,
deleteConversation: vi.fn(),
renameConversation: vi.fn(),
})),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
usePathname: mockUsePathname,
}));
// Deterministic hrefs so navigation/active-state assertions don't depend on server_root_path.
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell }));
vi.mock("./ConversationList", () => ({ default: () => <div data-testid="conversation-list" /> }));
describe("ChatShell", () => {
afterEach(() => {
mockPush.mockClear();
mockUsePathname.mockReturnValue("/ui/chat");
});
it("marks Chats active and shows the conversation list on the base chat route", () => {
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Chats" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "API Keys" })).not.toHaveAttribute("aria-current");
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
});
it("marks API Keys active while still showing the conversation list", () => {
mockUsePathname.mockReturnValue("/ui/chat/api-keys");
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "API Keys" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
});
it("navigates to the dedicated route for each nav item", () => {
render(
<ChatShell>
<div />
</ChatShell>,
);
fireEvent.click(screen.getByRole("button", { name: "Integrations" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/integrations");
fireEvent.click(screen.getByRole("button", { name: "Usage" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/usage");
});
it("tolerates a trailing slash on the current pathname when matching the active route", () => {
mockUsePathname.mockReturnValue("/ui/chat/usage/");
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Usage" })).toHaveAttribute("aria-current", "page");
});
});

View file

@ -0,0 +1,186 @@
"use client";
import React, { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
Pencil,
PanelLeftClose,
PanelLeftOpen,
Search,
MessageSquare,
LayoutGrid,
KeyRound,
Lock,
BarChart3,
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import { migratedHref } from "@/utils/migratedPages";
import { useChatShell } from "@/contexts/ChatShellContext";
import ConversationList from "./ConversationList";
const CHAT_BASE = migratedHref("chat");
export const CHAT_ROUTES = {
chats: CHAT_BASE,
integrations: `${CHAT_BASE}/integrations`,
credentials: `${CHAT_BASE}/credentials`,
apiKeys: `${CHAT_BASE}/api-keys`,
usage: `${CHAT_BASE}/usage`,
};
function stripTrailingSlash(path: string): string {
return path.length > 1 ? path.replace(/\/+$/, "") : path;
}
interface NavItemProps {
icon: React.ReactNode;
label: string;
onClick: () => void;
active?: boolean;
collapsed: boolean;
}
function NavItem({ icon, label, onClick, active = false, collapsed }: NavItemProps) {
const btn = (
<button
onClick={onClick}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2.5 px-2.5 py-2 w-full rounded-md border-none text-sm text-left transition-colors ${
collapsed ? "justify-center" : "justify-start"
} ${active ? "bg-accent text-accent-foreground" : "text-foreground/70 hover:bg-accent/50"}`}
style={{ cursor: "pointer" }}
>
<span className="shrink-0">{icon}</span>
{!collapsed && <span className="flex-1 text-left font-medium">{label}</span>}
</button>
);
if (!collapsed) return btn;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
<TooltipContent side="right">
<p>{label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
interface ChatShellProps {
children: React.ReactNode;
}
const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
const router = useRouter();
const pathname = stripTrailingSlash(usePathname() ?? "");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const { conversations, activeConversationId, deleteConversation, renameConversation } = useChatShell();
const isChatsRoute = pathname === CHAT_ROUTES.chats;
return (
<div className="flex h-full w-full bg-background overflow-hidden">
<div
className="shrink-0 bg-secondary border-r flex flex-col overflow-hidden"
style={{ width: sidebarCollapsed ? 56 : 260, transition: "width 0.2s cubic-bezier(0.4, 0, 0.2, 1)" }}
>
<div className="flex items-center justify-start px-2.5 py-3 shrink-0">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setSidebarCollapsed((v) => !v)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground flex items-center cursor-pointer transition-colors"
style={{ background: "none", border: "none" }}
>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="px-2 pb-1 shrink-0">
<NavItem
icon={<Pencil className="h-4 w-4" />}
label="New chat"
onClick={() => router.push(CHAT_ROUTES.chats)}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<Search className="h-4 w-4" />}
label="Search chats"
onClick={() => router.push(CHAT_ROUTES.chats)}
collapsed={sidebarCollapsed}
/>
</div>
<Separator className="mx-2 shrink-0" />
<div className="px-2 py-1 shrink-0">
<NavItem
icon={<MessageSquare className="h-4 w-4" />}
label="Chats"
onClick={() => router.push(CHAT_ROUTES.chats)}
active={isChatsRoute}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<LayoutGrid className="h-4 w-4" />}
label="Integrations"
onClick={() => router.push(CHAT_ROUTES.integrations)}
active={pathname === CHAT_ROUTES.integrations}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<KeyRound className="h-4 w-4" />}
label="Credentials"
onClick={() => router.push(CHAT_ROUTES.credentials)}
active={pathname === CHAT_ROUTES.credentials}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<Lock className="h-4 w-4" />}
label="API Keys"
onClick={() => router.push(CHAT_ROUTES.apiKeys)}
active={pathname === CHAT_ROUTES.apiKeys}
collapsed={sidebarCollapsed}
/>
<NavItem
icon={<BarChart3 className="h-4 w-4" />}
label="Usage"
onClick={() => router.push(CHAT_ROUTES.usage)}
active={pathname === CHAT_ROUTES.usage}
collapsed={sidebarCollapsed}
/>
</div>
<Separator className="mx-2 shrink-0" />
{!sidebarCollapsed && (
<div className="flex-1 overflow-hidden flex flex-col">
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`${CHAT_ROUTES.chats}?id=${id}`)}
onDelete={(id) => {
deleteConversation(id);
if (id === activeConversationId) router.push(CHAT_ROUTES.chats);
}}
onNewChat={() => router.push(CHAT_ROUTES.chats)}
onRename={renameConversation}
/>
</div>
)}
</div>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">{children}</div>
</div>
);
};
export default ChatShell;

View file

@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Pencil, Trash2, Plus, Search, User, MessageSquare } from "lucide-react";
import { Pencil, Trash2, Plus, Search, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent } from "@/components/ui/dialog";
@ -338,15 +338,6 @@ const ConversationList: React.FC<Props> = ({
))
)}
</ScrollArea>
<div className="px-3 py-2.5 border-t flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center shrink-0">
<User className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<span className="text-[13px] text-muted-foreground overflow-hidden whitespace-nowrap text-ellipsis">
My Account
</span>
</div>
</div>
<SearchModal

View file

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import MessageManager from "@/components/molecules/message_manager";
@ -170,8 +171,43 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-semibold uppercase tracking-wide">Key</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">Spend</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">Expires</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">Created</TableHead>
{premiumUser && (
<TableHead className="text-xs font-semibold uppercase tracking-wide text-right w-[80px]" />
)}
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
{premiumUser && (
<TableCell className="text-right">
<Skeleton className="h-4 w-16 ml-auto" />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
) : keys.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border border-dashed rounded-lg">
@ -221,15 +257,10 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
</TableCell>
{premiumUser && (
<TableCell className="text-right">
<button
onClick={() => openRotateModal(record)}
title="Rotate key"
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
style={{ background: "none" }}
>
<Button variant="outline" size="xs" onClick={() => openRotateModal(record)} title="Rotate key">
<RefreshCw className="h-3 w-3" />
Rotate
</button>
</Button>
</TableCell>
)}
</TableRow>

View file

@ -421,17 +421,11 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
</div>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabKey)} className="mb-4">
<TabsList className="bg-transparent border-b rounded-none w-full justify-start gap-0 h-auto p-0">
<TabsTrigger
value="all"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2 text-[13px]"
>
<TabsList variant="line" className="border-b rounded-none w-full justify-start h-auto p-0">
<TabsTrigger value="all" className="rounded-none px-4 py-2 text-[13px]">
All
</TabsTrigger>
<TabsTrigger
value="connected"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2 text-[13px]"
>
<TabsTrigger value="connected" className="rounded-none px-4 py-2 text-[13px]">
Connected{connectedCount > 0 ? ` (${connectedCount})` : ""}
</TabsTrigger>
</TabsList>

View file

@ -98,8 +98,8 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
<Link className="h-6 w-6 mb-3 mx-auto text-muted-foreground/50" />
<p className="m-0">No connections yet</p>
<p className="m-0 mt-1 text-xs">
Go to <span className="font-medium">Apps</span> and click <span className="font-medium">Connect</span> to
authorize an MCP server
Go to <span className="font-medium">Integrations</span> and click{" "}
<span className="font-medium">Connect</span> to authorize an MCP server
</p>
</div>
) : (

View file

@ -1,9 +1,11 @@
"use client";
import React, { useState } from "react";
import { Loader2, BarChart3 } from "lucide-react";
import { BarChart3 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { userDailyActivityAggregatedCall } from "../networking";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
const USAGE_QUERY_KEY = "chat-user-usage";
@ -105,7 +107,7 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
const maxSpend = Math.max(...dailySpend, 0);
const maxRequests = Math.max(...dailyRequests, 0);
const statCards: Array<{ label: string; value: string; sub?: string }> = meta
const statCards: Array<{ label: string; value: string; sub?: string; subVariant?: "error" }> = meta
? [
{ label: "Total Spend", value: `$${meta.total_spend.toFixed(2)}` },
{ label: "API Requests", value: formatNumber(meta.total_api_requests) },
@ -121,6 +123,7 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
? `${((meta.total_successful_requests / meta.total_api_requests) * 100).toFixed(1)}%`
: "N/A",
sub: meta.total_failed_requests > 0 ? `${meta.total_failed_requests} failed` : undefined,
subVariant: meta.total_failed_requests > 0 ? "error" : undefined,
},
]
: [];
@ -132,27 +135,28 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
<h2 className="text-base font-semibold text-foreground mb-0.5">Your Usage</h2>
<p className="text-sm text-muted-foreground m-0">Spend and request activity</p>
</div>
<div className="flex rounded-md border overflow-hidden">
<div className="flex gap-1">
{TIME_RANGE_OPTIONS.map((opt) => (
<button
<Button
key={opt.value}
variant={timeRange === opt.value ? "default" : "outline"}
size="sm"
onClick={() => setTimeRange(opt.value)}
className={`px-3 py-1.5 text-[13px] transition-colors ${
timeRange === opt.value
? "bg-primary text-primary-foreground font-medium"
: "bg-background text-muted-foreground hover:bg-accent"
}`}
style={{ border: "none" }}
>
{opt.label}
</button>
</Button>
))}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<div className="grid grid-cols-2 gap-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="border rounded-lg px-4 py-3.5 flex flex-col gap-2">
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-5 w-2/3" />
</div>
))}
</div>
) : !meta || meta.total_api_requests === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border border-dashed rounded-lg">
@ -166,7 +170,15 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
<div key={card.label} className="border rounded-lg px-4 py-3.5">
<div className="text-xs text-muted-foreground mb-1">{card.label}</div>
<div className="text-xl font-semibold text-foreground">{card.value}</div>
{card.sub && <div className="text-xs text-muted-foreground mt-0.5">{card.sub}</div>}
{card.sub && (
<div
className={`text-xs mt-0.5 ${
card.subVariant === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground"
}`}
>
{card.sub}
</div>
)}
</div>
))}
</div>

View file

@ -0,0 +1,102 @@
"use client";
import React, { createContext, useContext, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useChatHistory } from "@/components/chat/useChatHistory";
import type { ChatMessage, Conversation } from "@/components/chat/types";
interface ChatShellContextValue {
accessToken: string;
userId: string;
userEmail: string;
userRole: string;
premiumUser: boolean;
selectedMCPServers: string[];
setSelectedMCPServers: (servers: string[]) => void;
conversations: Conversation[];
activeConversation: Conversation | null;
activeConversationId: string | null;
storageUnavailable: boolean;
staleId: boolean;
createConversation: (model: string) => string;
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
updateLastAssistantMessage: (
conversationId: string,
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>,
) => void;
truncateFromMessage: (conversationId: string, messageId: string) => void;
deleteConversation: (id: string) => void;
renameConversation: (id: string, newTitle: string) => void;
}
const ChatShellContext = createContext<ChatShellContextValue | null>(null);
export function useChatShell(): ChatShellContextValue {
const ctx = useContext(ChatShellContext);
if (!ctx) {
throw new Error("useChatShell must be used within a ChatShellProvider");
}
return ctx;
}
interface ChatShellProviderProps {
accessToken: string;
userId: string;
userEmail: string;
userRole: string;
premiumUser: boolean;
children: React.ReactNode;
}
export function ChatShellProvider({
accessToken,
userId,
userEmail,
userRole,
premiumUser,
children,
}: ChatShellProviderProps) {
const searchParams = useSearchParams();
const activeConversationId = searchParams.get("id");
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
const {
conversations,
activeConversation,
storageUnavailable,
staleId,
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateFromMessage,
deleteConversation,
renameConversation,
} = useChatHistory(activeConversationId, userId);
return (
<ChatShellContext.Provider
value={{
accessToken,
userId,
userEmail,
userRole,
premiumUser,
selectedMCPServers,
setSelectedMCPServers,
conversations,
activeConversation,
activeConversationId,
storageUnavailable,
staleId,
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateFromMessage,
deleteConversation,
renameConversation,
}}
>
{children}
</ChatShellContext.Provider>
);
}