feat(ui): playground page tabs + multi_export_dropdown + conversation_panel + TableHeaderSortDropdown

- app/playground/page: @tremor TabGroup/TabList/Tab/TabPanel \u2192
  shadcn Tabs/TabsList/TabsTrigger/TabsContent (default=chat).
  h-full routing preserved via h-full class on each TabsContent.
- multi_export_dropdown: @tremor Button + @ant-design Download/
  FilePdf/FileExcel \u2192 shadcn DropdownMenu + Button + lucide
  Download/FileText/FileSpreadsheet. Click-outside handler removed
  \u2014 Radix DropdownMenu handles dismissal.
- conversation_panel/index: @tremor Button + @ant-design ClearOutlined
  \u2192 shadcn Button variant=outline + lucide Eraser. Semantic tokens
  bg-background/border-border.
- TableHeaderSortDropdown: antd Dropdown + @heroicons SwitchVertical/
  ChevronUp/ChevronDown/X \u2192 shadcn DropdownMenu + lucide ArrowUpDown/
  ChevronUp/ChevronDown/X. Current sort state drives icon choice.

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 11:56:40 +00:00
parent 2d876bfb8f
commit 2980e65157
No known key found for this signature in database
4 changed files with 146 additions and 165 deletions

View file

@ -5,7 +5,12 @@ import AgentBuilderView from "@/components/playground/chat_ui/AgentBuilderView";
import ChatUI from "@/components/playground/chat_ui/ChatUI";
import CompareUI from "@/components/playground/compareUI/CompareUI";
import ComplianceUI from "@/components/playground/complianceUI/ComplianceUI";
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchProxySettings } from "@/utils/proxyUtils";
@ -15,8 +20,16 @@ interface ProxySettings {
}
export default function PlaygroundPage() {
const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized();
const [proxySettings, setProxySettings] = useState<ProxySettings | undefined>(undefined);
const {
accessToken,
userRole,
userId,
disabledPersonalKeyCreation,
token,
} = useAuthorized();
const [proxySettings, setProxySettings] = useState<ProxySettings | undefined>(
undefined,
);
useEffect(() => {
const initializeProxySettings = async () => {
@ -36,15 +49,19 @@ export default function PlaygroundPage() {
return (
<div className="h-full w-full flex flex-col">
<TabGroup className="w-full" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<TabList className="mb-0">
<Tab>Chat</Tab>
<Tab>Compare</Tab>
<Tab>Compliance</Tab>
<Tab>Agent Builder (Experimental)</Tab>
</TabList>
<TabPanels className="h-full">
<TabPanel className="h-full">
<Tabs
defaultValue="chat"
className="w-full flex-1 flex flex-col min-h-0"
>
<TabsList>
<TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="compare">Compare</TabsTrigger>
<TabsTrigger value="compliance">Compliance</TabsTrigger>
<TabsTrigger value="agent-builder">
Agent Builder (Experimental)
</TabsTrigger>
</TabsList>
<TabsContent value="chat" className="h-full">
<ChatUI
accessToken={accessToken}
token={token}
@ -53,14 +70,20 @@ export default function PlaygroundPage() {
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
/>
</TabPanel>
<TabPanel className="h-full">
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
</TabPanel>
<TabPanel className="h-full">
</TabsContent>
<TabsContent value="compare" className="h-full">
<CompareUI
accessToken={accessToken}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
/>
</TabsContent>
<TabsContent value="compliance" className="h-full">
<ComplianceUI
accessToken={accessToken}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
/>
</TabsContent>
<TabsContent value="agent-builder" className="h-full">
<AgentBuilderView
accessToken={accessToken}
token={token}
@ -68,11 +91,13 @@ export default function PlaygroundPage() {
userRole={userRole}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
proxySettings={proxySettings}
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
customProxyBaseUrl={
proxySettings?.LITELLM_UI_API_DOC_BASE_URL ??
proxySettings?.PROXY_BASE_URL
}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -1,6 +1,12 @@
import React, { useState, useRef, useEffect } from "react";
import { Button } from "@tremor/react";
import { DownloadOutlined, FilePdfOutlined, FileExcelOutlined } from "@ant-design/icons";
import React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Download, FileSpreadsheet, FileText } from "lucide-react";
import { MultiModelResult } from "./types";
import { exportMultiToPDF, exportMultiToCSV } from "./multi_export_utils";
@ -8,70 +14,35 @@ interface MultiExportDropdownProps {
multiResult: MultiModelResult;
}
const MultiExportDropdown: React.FC<MultiExportDropdownProps> = ({ multiResult }) => {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const MultiExportDropdown: React.FC<MultiExportDropdownProps> = ({
multiResult,
}) => {
const hasResults = multiResult.entries.some((e) => e.result !== null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
if (!hasResults) {
return null;
}
return (
<div className="relative inline-block" ref={menuRef}>
<Button
size="xs"
variant="secondary"
icon={DownloadOutlined}
onClick={() => setIsOpen(!isOpen)}
>
Export
</Button>
{isOpen && (
<div className="absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => {
exportMultiToPDF(multiResult);
setIsOpen(false);
}}
>
<FilePdfOutlined className="mr-3 text-red-500" />
Export as PDF
</button>
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => {
exportMultiToCSV(multiResult);
setIsOpen(false);
}}
>
<FileExcelOutlined className="mr-3 text-green-600" />
Export as CSV
</button>
</div>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm">
<Download className="h-3.5 w-3.5" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => exportMultiToPDF(multiResult)}>
<FileText className="mr-3 h-4 w-4 text-destructive" />
Export as PDF
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportMultiToCSV(multiResult)}>
<FileSpreadsheet className="mr-3 h-4 w-4 text-emerald-600 dark:text-emerald-400" />
Export as CSV
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default MultiExportDropdown;

View file

@ -1,85 +1,62 @@
import React from "react";
import { Button, Dropdown, MenuProps } from "antd";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, XIcon } from "@heroicons/react/outline";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ArrowUpDown, ChevronDown, ChevronUp, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type SortState = "asc" | "desc" | false;
interface TableHeaderSortDropdownProps {
/**
* Current sort state: "asc", "desc", or false for neutral
*/
sortState: SortState;
/**
* Callback when sort state changes
* @param newState - The new sort state: "asc", "desc", or false
*/
onSortChange: (newState: SortState) => void;
/**
* Optional column ID for identification
*/
columnId?: string;
}
export const TableHeaderSortDropdown: React.FC<TableHeaderSortDropdownProps> = ({
sortState,
onSortChange,
}) => {
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
if (key === "asc") {
onSortChange("asc");
} else if (key === "desc") {
onSortChange("desc");
} else if (key === "reset") {
onSortChange(false);
}
};
const menuItems: MenuProps["items"] = [
{
key: "asc",
label: "Ascending",
icon: <ChevronUpIcon className="h-4 w-4" />,
},
{
key: "desc",
label: "Descending",
icon: <ChevronDownIcon className="h-4 w-4" />,
},
{
key: "reset",
label: "Reset",
icon: <XIcon className="h-4 w-4" />,
},
];
// Determine which icon to display based on current sort state
export const TableHeaderSortDropdown: React.FC<
TableHeaderSortDropdownProps
> = ({ sortState, onSortChange }) => {
const renderIcon = () => {
if (sortState === "asc") {
return <ChevronUpIcon className="h-4 w-4" />;
} else if (sortState === "desc") {
return <ChevronDownIcon className="h-4 w-4" />;
} else {
return <SwitchVerticalIcon className="h-4 w-4" />;
}
if (sortState === "asc") return <ChevronUp className="h-4 w-4" />;
if (sortState === "desc") return <ChevronDown className="h-4 w-4" />;
return <ArrowUpDown className="h-4 w-4" />;
};
return (
<Dropdown
menu={{
items: menuItems,
onClick: handleMenuClick,
selectable: true,
selectedKeys: sortState ? [sortState] : [],
}}
trigger={["click"]}
autoAdjustOverflow
>
<Button
type="text"
onClick={(e) => e.stopPropagation()}
icon={renderIcon()}
className={sortState ? "text-blue-500 hover:text-blue-600" : "text-gray-400 hover:text-blue-500"}
/>
</Dropdown>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={(e) => e.stopPropagation()}
className={cn(
"h-6 w-6",
sortState
? "text-primary hover:text-primary"
: "text-muted-foreground hover:text-primary",
)}
>
{renderIcon()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onSortChange("asc")}>
<ChevronUp className="mr-2 h-4 w-4" />
Ascending
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSortChange("desc")}>
<ChevronDown className="mr-2 h-4 w-4" />
Descending
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSortChange(false)}>
<X className="mr-2 h-4 w-4" />
Reset
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -1,6 +1,6 @@
import React from "react";
import { ClearOutlined } from "@ant-design/icons";
import { Button as TremorButton } from "@tremor/react";
import { Eraser } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConversationPanelProps } from "./types";
import { useConversation } from "./useConversation";
import VariableInput from "./VariableInput";
@ -8,7 +8,10 @@ import MessageList from "./MessageList";
import VariableWarning from "./VariableWarning";
import MessageInput from "./MessageInput";
const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessToken }) => {
const ConversationPanel: React.FC<ConversationPanelProps> = ({
prompt,
accessToken,
}) => {
const {
isLoading,
messages,
@ -27,7 +30,7 @@ const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessTok
} = useConversation(prompt, accessToken);
return (
<div className="flex flex-col h-full bg-white">
<div className="flex flex-col h-full bg-background">
{!variablesFilled && (
<VariableInput
extractedVariables={extractedVariables}
@ -37,14 +40,15 @@ const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessTok
)}
{messages.length > 0 && (
<div className="p-3 border-b border-gray-200 bg-white flex justify-end">
<TremorButton
<div className="p-3 border-b border-border bg-background flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleClearConversation}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300"
icon={ClearOutlined}
>
<Eraser className="h-3.5 w-3.5" />
Clear Chat
</TremorButton>
</Button>
</div>
)}
@ -55,14 +59,19 @@ const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessTok
messagesEndRef={messagesEndRef}
/>
<div className="p-4 border-t border-gray-200 bg-white">
<VariableWarning extractedVariables={extractedVariables} variables={variables} />
<div className="p-4 border-t border-border bg-background">
<VariableWarning
extractedVariables={extractedVariables}
variables={variables}
/>
<MessageInput
inputMessage={inputMessage}
isLoading={isLoading}
isDisabled={
isLoading || !inputMessage.trim() || (extractedVariables.length > 0 && !allVariablesFilled)
isLoading ||
!inputMessage.trim() ||
(extractedVariables.length > 0 && !allVariablesFilled)
}
onInputChange={setInputMessage}
onSend={handleSendMessage}
@ -75,4 +84,3 @@ const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessTok
};
export default ConversationPanel;