@@ -443,20 +506,21 @@ export function ChatMessageContent({
actions,
}: ChatMessageContentProps) {
if (message.role === "user") {
- return
;
+ return
;
}
if (message.role === "tool") {
return
;
}
+ const isEmptyResponse = message.content === "" && !message.generatedImages?.length;
return (
diff --git a/ui/litellm-dashboard/src/components/chat/ChatModelPicker.tsx b/ui/litellm-dashboard/src/components/chat/ChatModelPicker.tsx
new file mode 100644
index 00000000000..5c2119e7336
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/ChatModelPicker.tsx
@@ -0,0 +1,122 @@
+import { useState } from "react";
+import { Check, ChevronDown, ImageIcon, MessageSquare, Search } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { ModelGroup } from "@/components/llm_calls/fetch_models";
+
+export type ChatMode = "chat" | "image";
+
+export function modelChatMode(model: ModelGroup): ChatMode {
+ return model.mode === "image_generation" ? "image" : "chat";
+}
+
+export function isChatModel(model: ModelGroup): boolean {
+ return !model.mode || ["chat", "responses", "completion", "image_generation"].includes(model.mode);
+}
+
+export default function ChatModelPicker({
+ models,
+ selectedModel,
+ loading,
+ disabled,
+ onSelect,
+}: {
+ models: ModelGroup[];
+ selectedModel: string | null;
+ loading: boolean;
+ disabled: boolean;
+ onSelect: (model: ModelGroup) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [filter, setFilter] = useState<"all" | ChatMode>("all");
+ const selectModel = (model: ModelGroup) => {
+ onSelect(model);
+ setOpen(false);
+ setSearch("");
+ };
+ const visibleModels = models.filter(
+ (model) =>
+ (filter === "all" || modelChatMode(model) === filter) &&
+ `${model.model_group} ${(model.providers ?? []).join(" ")}`.toLowerCase().includes(search.toLowerCase()),
+ );
+
+ if (loading) return
;
+
+ return (
+
+
+ {selectedModel ?? "Choose a model"}
+
+
+ }
+ />
+
+
+
+
Model Hub
+
Choose a model to try it here.
+
+
+
+ setSearch(event.target.value)}
+ className="pl-8"
+ />
+
+
+ {(
+ [
+ ["all", "All"],
+ ["chat", "Chat"],
+ ["image", "Images"],
+ ] as const
+ ).map(([value, label]) => (
+
+ ))}
+
+
+
+ {visibleModels.length === 0 && No models found.
}
+ {visibleModels.map((model) => {
+ const image = modelChatMode(model) === "image";
+ const Icon = image ? ImageIcon : MessageSquare;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.integration.test.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.integration.test.tsx
new file mode 100644
index 00000000000..c1af91845ee
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.integration.test.tsx
@@ -0,0 +1,173 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders as render } from "@/../tests/test-utils";
+import ChatShell from "./ChatShell";
+
+const { mockPush, mockUsePathname, mockUseChatShell, mockUseMediaQuery } = vi.hoisted(() => ({
+ mockPush: vi.fn(),
+ mockUsePathname: vi.fn(() => "/ui/chat"),
+ mockUseMediaQuery: vi.fn(() => true),
+ mockUseChatShell: vi.fn(() => ({
+ accessToken: "test-token",
+ userEmail: "developer@example.com",
+ userId: "test-user",
+ 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/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
+vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell }));
+vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
+ useUISettings: () => ({ data: { values: { enable_chat_ui: true } } }),
+}));
+vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({ useLogout: () => vi.fn() }));
+vi.mock("usehooks-ts", () => ({ useMediaQuery: mockUseMediaQuery }));
+vi.mock("./ConversationList", () => ({ default: () =>
}));
+
+describe("ChatShell", () => {
+ afterEach(() => {
+ mockPush.mockClear();
+ mockUsePathname.mockReturnValue("/ui/chat");
+ mockUseMediaQuery.mockReturnValue(true);
+ });
+
+ it("should render the workspace and mark Chats active on the base chat route", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Chats" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "Models" })).not.toHaveAttribute("aria-current");
+ expect(screen.getByRole("button", { name: "Workspace" })).not.toHaveAttribute("aria-current");
+ expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Switch workspace, current: Chat" })).toBeInTheDocument();
+ expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
+ });
+
+ it("should mark Models active on its route and tolerate a trailing slash", () => {
+ mockUsePathname.mockReturnValue("/ui/chat/models/");
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "Models" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
+ expect(screen.getByRole("button", { name: "Workspace" })).not.toHaveAttribute("aria-current");
+ });
+
+ it("should mark API Keys active while still showing the conversation list", () => {
+ mockUsePathname.mockReturnValue("/ui/chat/api-keys");
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Workspace" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
+ expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
+ });
+
+ it("should navigate through the workspace menu and retain all chat destinations", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Integrations" }));
+ expect(mockPush).toHaveBeenCalledWith("/ui/chat/integrations");
+ await user.click(screen.getByRole("button", { name: "Models" }));
+ expect(mockPush).toHaveBeenCalledWith("/ui/chat/models");
+ for (const [label, href] of [
+ ["Credentials", "/ui/chat/credentials"],
+ ["API Keys", "/ui/chat/api-keys"],
+ ["Usage", "/ui/chat/usage"],
+ ["Logs", "/ui/chat/logs"],
+ ]) {
+ await user.click(screen.getByRole("button", { name: "Workspace" }));
+ await user.click(await screen.findByRole("menuitem", { name: label, exact: true }));
+ expect(mockPush).toHaveBeenCalledWith(href);
+ }
+ });
+
+ it("should mark the active workspace destination and tolerate a trailing slash", async () => {
+ mockUsePathname.mockReturnValue("/ui/chat/usage/");
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Workspace" })).toHaveAttribute("aria-current", "page");
+ await user.click(screen.getByRole("button", { name: "Workspace" }));
+ expect(await screen.findByRole("menuitem", { name: "Usage", exact: true })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("menuitem", { name: "Logs", exact: true })).not.toHaveAttribute("aria-current");
+ });
+
+ it("should collapse and reopen the desktop rail", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Close sidebar" }));
+ expect(screen.getByRole("button", { name: "Open sidebar" })).toHaveAttribute("aria-expanded", "false");
+ await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
+ expect(await screen.findByRole("menuitem", { name: "AI Gateway", exact: true })).toBeInTheDocument();
+ await user.keyboard("{Escape}");
+ await user.click(screen.getByRole("button", { name: "Open sidebar" }));
+ expect(screen.getByRole("button", { name: "Close sidebar" })).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("should open mobile navigation and close it after selecting Models", async () => {
+ mockUseMediaQuery.mockReturnValue(false);
+ const user = userEvent.setup();
+ render(
+
+ Conversation content
+ ,
+ );
+
+ expect(screen.queryByRole("navigation", { name: "Chat workspace" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Switch workspace, current: Chat" })).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
+ expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Models" }));
+
+ expect(mockPush).toHaveBeenCalledWith("/ui/chat/models");
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ expect(screen.getByText("Conversation content")).toBeInTheDocument();
+ });
+
+ it("should close mobile navigation with Escape and restore trigger focus", async () => {
+ mockUseMediaQuery.mockReturnValue(false);
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole("button", { name: "Open chat navigation" });
+ await user.click(trigger);
+ expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
+ await user.keyboard("{Escape}");
+
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ await waitFor(() => expect(trigger).toHaveFocus());
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts b/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts
index de1f9d2aa50..42304f0a3ef 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts
@@ -17,7 +17,7 @@ describe("getChatRoutes under server_root_path", () => {
vi.unstubAllEnvs();
});
- it("reflects a server root path applied after the module is loaded", async () => {
+ it("should reflect a server root path applied after the module is loaded", async () => {
const { getChatRoutes } = await import("./ChatShell");
const { setServerRootPath } = await import("@/lib/serverRootPath");
@@ -25,6 +25,7 @@ describe("getChatRoutes under server_root_path", () => {
const routes = getChatRoutes();
expect(routes.chats).toBe("/gw/ui/chat");
+ expect(routes.models).toBe("/gw/ui/chat/models");
expect(routes.integrations).toBe("/gw/ui/chat/integrations");
expect(routes.credentials).toBe("/gw/ui/chat/credentials");
expect(routes.apiKeys).toBe("/gw/ui/chat/api-keys");
@@ -32,13 +33,14 @@ describe("getChatRoutes under server_root_path", () => {
expect(routes.usage).toBe("/gw/ui/chat/usage");
});
- it("builds /ui-rooted paths when no server root path is set", async () => {
+ it("should build /ui-rooted paths when no server root path is set", async () => {
const { getChatRoutes } = await import("./ChatShell");
const { setServerRootPath } = await import("@/lib/serverRootPath");
setServerRootPath("/");
expect(getChatRoutes().chats).toBe("/ui/chat");
+ expect(getChatRoutes().models).toBe("/ui/chat/models");
expect(getChatRoutes().integrations).toBe("/ui/chat/integrations");
});
});
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
deleted file mode 100644
index ac92bb15071..00000000000
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { fireEvent, screen } from "@testing-library/react";
-import { renderWithProviders as render } from "@/../tests/test-utils";
-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/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
-vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell }));
-vi.mock("./ConversationList", () => ({ default: () =>
}));
-
-describe("ChatShell", () => {
- afterEach(() => {
- mockPush.mockClear();
- mockUsePathname.mockReturnValue("/ui/chat");
- });
-
- it("marks Chats active and shows the conversation list on the base chat route", () => {
- render(
-
-
- ,
- );
- 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(
-
-
- ,
- );
- 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(
-
-
- ,
- );
- 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");
-
- fireEvent.click(screen.getByRole("button", { name: "Logs" }));
- expect(mockPush).toHaveBeenCalledWith("/ui/chat/logs");
- });
-
- it("marks Logs active on the logs route", () => {
- mockUsePathname.mockReturnValue("/ui/chat/logs");
- render(
-
-
- ,
- );
- expect(screen.getByRole("button", { name: "Logs" })).toHaveAttribute("aria-current", "page");
- expect(screen.getByRole("button", { name: "Usage" })).not.toHaveAttribute("aria-current");
- });
-
- it("tolerates a trailing slash on the current pathname when matching the active route", () => {
- mockUsePathname.mockReturnValue("/ui/chat/usage/");
- render(
-
-
- ,
- );
- expect(screen.getByRole("button", { name: "Usage" })).toHaveAttribute("aria-current", "page");
- });
-});
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
index 7d144944d64..c089a709b64 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
@@ -1,18 +1,47 @@
"use client";
-import React from "react";
+import React, { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
-import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3, ScrollText } from "lucide-react";
+import {
+ Plus,
+ MessageSquare,
+ Network,
+ LayoutGrid,
+ KeyRound,
+ Lock,
+ BarChart3,
+ ScrollText,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Search,
+ Settings2,
+ ChevronDown,
+ Info,
+ Settings,
+ LogOut,
+} from "lucide-react";
+import { useMediaQuery } from "usehooks-ts";
import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
+import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
import { uiHref } from "@/utils/uiHref";
import { useChatShell } from "@/contexts/ChatShellContext";
+import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import ConversationList from "./ConversationList";
export function getChatRoutes() {
const base = uiHref("chat");
return {
chats: base,
+ models: `${base}/models`,
integrations: `${base}/integrations`,
credentials: `${base}/credentials`,
apiKeys: `${base}/api-keys`,
@@ -38,110 +67,209 @@ function NavItem({ icon, label, onClick, active = false }: NavItemProps) {
onClick={onClick}
variant="ghost"
aria-current={active ? "page" : undefined}
- className={`w-full justify-start gap-2.5 px-2.5 font-medium hover:bg-sidebar-accent ${
- active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-muted-foreground"
- }`}
+ className={`chat-nav-item moyai-chat-rail-button w-full justify-start ${active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground"}`}
>
-
{icon}
+ {icon}
{label}
);
}
-interface ChatShellProps {
- children: React.ReactNode;
-}
-
-const ChatShell: React.FC
= ({ children }) => {
+const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const router = useRouter();
const pathname = stripTrailingSlash(usePathname() ?? "");
- const { conversations, activeConversationId, deleteConversation, renameConversation } = useChatShell();
-
+ const {
+ accessToken,
+ userEmail,
+ userId,
+ conversations,
+ activeConversationId,
+ deleteConversation,
+ renameConversation,
+ } = useChatShell();
+ const handleLogout = useLogout(accessToken);
+ const isDesktop = useMediaQuery("(min-width: 601px)", { initializeWithValue: false });
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
+ const [searchOpen, setSearchOpen] = useState(false);
+ if (isDesktop && sidebarOpen) setSidebarOpen(false);
+ const collapsed = isDesktop && sidebarCollapsed;
const routes = getChatRoutes();
const isChatsRoute = pathname === routes.chats;
+ const accountName = userEmail?.split("@")[0] || userId || "Account";
+ const workspaceItems = [
+ { label: "Credentials", route: routes.credentials, Icon: KeyRound },
+ { label: "API Keys", route: routes.apiKeys, Icon: Lock },
+ { label: "Logs", route: routes.logs, Icon: ScrollText },
+ { label: "Usage", route: routes.usage, Icon: BarChart3 },
+ ];
+ const workspaceActive = workspaceItems.some((item) => item.route === pathname);
+ const navigate = (href: string) => {
+ setSidebarOpen(false);
+ setSearchOpen(false);
+ router.push(href);
+ };
+
+ const sidebar = (
+ <>
+
+
+
+
+
+
+
+
+ navigate(`${routes.chats}?id=${encodeURIComponent(id)}`)}
+ onDelete={(id) => {
+ deleteConversation(id);
+ if (id === activeConversationId) navigate(routes.chats);
+ }}
+ onRename={renameConversation}
+ searchOpen={searchOpen}
+ onSearchOpenChange={setSearchOpen}
+ hideSearchButton
+ />
+
+
+ >
+ );
return (
-
-
- This is a pre-v0 feature. Do not use in production, it may change unexpectedly. Please share feedback{" "}
-
- here
-
- .
-
-
-
-
-
+
+ {isDesktop ? (
+
+ ) : (
+
+
-
-
-
-
- }
- label="Chats"
- onClick={() => router.push(routes.chats)}
- active={isChatsRoute}
- />
- }
- label="Integrations"
- onClick={() => router.push(routes.integrations)}
- active={pathname === routes.integrations}
- />
- }
- label="Credentials"
- onClick={() => router.push(routes.credentials)}
- active={pathname === routes.credentials}
- />
- }
- label="API Keys"
- onClick={() => router.push(routes.apiKeys)}
- active={pathname === routes.apiKeys}
- />
- }
- label="Logs"
- onClick={() => router.push(routes.logs)}
- active={pathname === routes.logs}
- />
- }
- label="Usage"
- onClick={() => router.push(routes.usage)}
- active={pathname === routes.usage}
- />
-
-
-
-
-
- router.push(`${routes.chats}?id=${id}`)}
- onDelete={(id) => {
- deleteConversation(id);
- if (id === activeConversationId) router.push(routes.chats);
- }}
- onRename={renameConversation}
- />
-
-
-
-
{children}
-
+
+
+ LiteLLM Chat
+ Conversations and chat workspace navigation
+
+ {sidebar}
+
+
+ )}
+
{children}
);
};
diff --git a/ui/litellm-dashboard/src/components/chat/ConversationList.integration.test.tsx b/ui/litellm-dashboard/src/components/chat/ConversationList.integration.test.tsx
new file mode 100644
index 00000000000..06961348cea
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/ConversationList.integration.test.tsx
@@ -0,0 +1,89 @@
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import ConversationList from "./ConversationList";
+import type { Conversation } from "./types";
+
+const conversations: Conversation[] = [
+ {
+ id: "design",
+ title: "Design review",
+ model: "test-model",
+ messages: [],
+ mcpServerNames: [],
+ createdAt: 1,
+ updatedAt: 1,
+ },
+ {
+ id: "photos",
+ title: "Photo ideas",
+ model: "test-model",
+ messages: [],
+ mcpServerNames: [],
+ createdAt: 2,
+ updatedAt: 2,
+ },
+];
+
+describe("ConversationList navigation", () => {
+ it("should render named conversation actions and the active selection", () => {
+ render(
+
,
+ );
+
+ expect(screen.getByRole("button", { name: "Design review", exact: true })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "Rename Design review" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Delete Design review" })).toBeInTheDocument();
+ });
+
+ it("should select a conversation with the keyboard", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ render(
+
,
+ );
+
+ await user.tab();
+ expect(screen.getByRole("button", { name: "Search chats" })).toHaveFocus();
+ await user.tab();
+ expect(screen.getByRole("button", { name: "Design review", exact: true })).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(onSelect).toHaveBeenCalledWith("design");
+ });
+
+ it("should filter chats from visible search and select a result", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ render(
+
,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Search chats" }));
+ const dialog = await screen.findByRole("dialog", { name: "Search conversations" });
+ act(() =>
+ fireEvent.change(screen.getByRole("textbox", { name: "Search conversations" }), { target: { value: "photo" } }),
+ );
+ expect(within(dialog).queryByRole("button", { name: /Design review/ })).not.toBeInTheDocument();
+ await user.click(within(dialog).getByRole("button", { name: /Photo ideas/ }));
+ expect(onSelect).toHaveBeenCalledWith("photos");
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/chat/ConversationList.tsx b/ui/litellm-dashboard/src/components/chat/ConversationList.tsx
index 0f513ad4381..a4336ab8027 100644
--- a/ui/litellm-dashboard/src/components/chat/ConversationList.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ConversationList.tsx
@@ -1,10 +1,10 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
-import { Pencil, Trash2, Search, MessageSquare } from "lucide-react";
+import { Pencil, Trash2, Search, MessageSquare, Clock3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { Dialog, DialogContent } from "@/components/ui/dialog";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -27,6 +27,9 @@ interface Props {
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, newTitle: string) => void;
+ searchOpen?: boolean;
+ onSearchOpenChange?: (open: boolean) => void;
+ hideSearchButton?: boolean;
}
type DateGroup = "Recents" | "Yesterday" | "Last 7 Days" | "Older";
@@ -109,18 +112,16 @@ const ConversationRow: React.FC
= ({ conv, isActive, onSel
}
};
- const truncatedTitle = conv.title.length > 40 ? conv.title.slice(0, 40) + "\u2026" : conv.title;
-
return (
!editing && onSelect(conv.id)}
- className={`group flex items-center px-2 py-1.5 rounded-md cursor-pointer transition-colors min-h-[34px] relative ${
- isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
+ className={`chat-conversation-row group flex items-center rounded-lg transition-colors min-h-9 relative ${
+ isActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "hover:bg-sidebar-accent"
}`}
>
{editing ? (
setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -130,24 +131,39 @@ const ConversationRow: React.FC
= ({ conv, isActive, onSel
/>
) : (
<>
- onSelect(conv.id)}
+ aria-label={conv.title}
+ aria-current={isActive ? "page" : undefined}
+ className={`min-w-0 flex-1 justify-start text-[13px] hover:bg-sidebar-accent ${
isActive ? "font-medium" : ""
}`}
title={conv.title}
>
- {truncatedTitle}
-
+
+ {conv.title}
+
+ {conv.model} · {dayjs(conv.updatedAt).format("MMM D")}
+
+
+
e.stopPropagation()}
>
+
}
@@ -168,6 +184,7 @@ const ConversationRow: React.FC = ({ conv, isActive, onSel
-
+
Delete this conversation?
This action cannot be undone
@@ -231,11 +248,13 @@ const SearchModal: React.FC = ({ open, conversations, onSelect
return (