Merge pull request #1587 from AnishSarkar22/fix/ci-ui-changes

feat(playground): improve API Playground navigation and sidebar UX
This commit is contained in:
Anish Sarkar 2026-07-08 10:31:35 +05:30 committed by GitHub
commit 7faa58a953
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 301 additions and 99 deletions

View file

@ -91,12 +91,13 @@ export function ApiReference({
<div>
<h2 className="text-base font-semibold">API reference</h2>
<p className="mt-1 text-sm text-muted-foreground">
Create an API key, enable API access for this workspace, then use the examples below to call this endpoint.
Create an API key, enable API access for this workspace, then use the examples below to
call this endpoint.
</p>
</div>
<Tabs defaultValue="curl">
<TabsList className="h-auto flex-wrap">
<TabsList className="flex h-auto w-full flex-nowrap justify-start overflow-x-auto overflow-y-hidden">
{snippets.map((snippet) => (
<TabsTrigger key={snippet.id} value={snippet.id}>
{snippet.label}

View file

@ -1,13 +1,6 @@
"use client";
import {
Check,
Copy,
Hash,
Info,
Coins,
Timer,
} from "lucide-react";
import { Check, Coins, Copy, Hash, Info, Timer } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@ -99,9 +92,9 @@ function EndpointCopyButton({ endpoint }: { endpoint: string }) {
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-auto justify-start gap-2 rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
className="h-auto max-w-full items-start justify-start gap-2 whitespace-normal rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground sm:whitespace-nowrap"
>
<code>{endpoint}</code>
<code className="min-w-0 break-all text-left sm:break-normal">{endpoint}</code>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<span className="sr-only">{copied ? "Copied endpoint" : "Copy endpoint"}</span>
</Button>
@ -243,8 +236,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Read docs
</Link>
{" "}for more info.
</Link>{" "}
for more info.
</>
) : null}
</p>
@ -280,17 +273,12 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
</Button>
)}
</div>
</div>
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Output</h2>
{isRunning ? (
<RunProgressPanel
latest={run.latest}
events={run.events}
elapsedMs={run.elapsedMs}
/>
<RunProgressPanel latest={run.latest} events={run.events} elapsedMs={run.elapsedMs} />
) : run.status === "cancelled" ? (
<div className="flex h-64 items-center justify-center rounded-md border border-border/60 px-4 text-center text-sm text-muted-foreground">
Run cancelled.

View file

@ -1,15 +1,14 @@
"use client";
import { History, LayoutGrid } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import type React from "react";
import { useMemo } from "react";
import {
type RoutedSectionGroup,
type RoutedSectionItem,
getPlaygroundNavGroups,
getPlaygroundNavItems,
getPlaygroundSelectedLabel,
RoutedSectionShell,
} from "@/components/layout";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
interface PlaygroundLayoutShellProps {
workspaceId: string;
@ -20,41 +19,8 @@ export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayou
const segments = useSelectedLayoutSegments();
const base = `/dashboard/${workspaceId}/playground`;
const topLevelItems = useMemo<RoutedSectionItem[]>(
() => [
{
value: "overview",
label: "Overview",
href: base,
icon: <LayoutGrid className="h-4 w-4" />,
},
{
value: "runs",
label: "API Runs",
href: `${base}/runs`,
icon: <History className="h-4 w-4" />,
},
],
[base]
);
const providerGroups = useMemo<RoutedSectionGroup[]>(
() =>
PLAYGROUND_PLATFORMS.map((platform) => {
const Icon = platform.icon;
return {
value: platform.id,
label: platform.label,
icon: <Icon className="h-4 w-4 shrink-0" />,
items: platform.verbs.map((verb) => ({
value: `${platform.id}/${verb.verb}`,
label: verb.label,
href: `${base}/${platform.id}/${verb.verb}`,
})),
};
}),
[base]
);
const topLevelItems = useMemo(() => getPlaygroundNavItems(base), [base]);
const providerGroups = useMemo(() => getPlaygroundNavGroups(base), [base]);
const activeValue =
segments.length >= 2
@ -63,7 +29,7 @@ export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayou
? segments[0]
: "overview";
const selectedLabel = getSelectedLabel(activeValue, topLevelItems, providerGroups);
const selectedLabel = getPlaygroundSelectedLabel(activeValue, topLevelItems, providerGroups);
return (
<RoutedSectionShell
@ -73,23 +39,9 @@ export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayou
activeValue={activeValue}
selectedLabel={selectedLabel}
mobileNav="drawer"
desktopNav={false}
>
{children}
</RoutedSectionShell>
);
}
function getSelectedLabel(
activeValue: string,
items: RoutedSectionItem[],
groups: RoutedSectionGroup[]
): string {
const topLevelItem = items.find((item) => item.value === activeValue);
if (topLevelItem) return topLevelItem.label;
const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
const child = group?.items.find((item) => item.value === activeValue);
if (group && child) return `${group.label}: ${child.label}`;
return "API Playground";
}

View file

@ -14,6 +14,10 @@ export {
ChatListItem,
CreateWorkspaceDialog,
CreditBalanceDisplay,
getPlaygroundActiveValue,
getPlaygroundNavGroups,
getPlaygroundNavItems,
getPlaygroundSelectedLabel,
Header,
IconRail,
LayoutShell,
@ -21,6 +25,7 @@ export {
MobileSidebarTrigger,
NavIcon,
NavSection,
PlaygroundSidebar,
RoutedSectionShell,
Sidebar,
SidebarCollapseButton,

View file

@ -51,6 +51,7 @@ import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, Workspace } from "../types/layout.types";
import { CreateWorkspaceDialog } from "../ui/dialogs";
import { PlaygroundSidebar } from "../ui/playground/PlaygroundSidebar";
import { LayoutShell } from "../ui/shell";
interface LayoutDataProviderProps {
@ -689,9 +690,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
: undefined
}
workspacePanelContentClassName={
useWorkspacePanel ? "max-w-5xl select-none" : undefined
}
workspacePanelContentClassName={useWorkspacePanel ? "max-w-5xl select-none" : undefined}
isLoadingChats={isLoadingThreads}
notifications={{
totalUnreadCount,
@ -720,6 +719,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
}}
onTabSwitch={handleTabSwitch}
onTabPrefetch={handleTabPrefetch}
playgroundSidebar={<PlaygroundSidebar workspaceId={workspaceId} />}
>
<Fragment key={chatResetKey}>{children}</Fragment>
</LayoutShell>

View file

@ -38,6 +38,7 @@ interface RoutedSectionShellProps {
groups?: RoutedSectionGroup[];
mobileNav?: "scroll" | "drawer";
contentClassName?: string;
desktopNav?: boolean;
}
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
@ -142,6 +143,7 @@ export function RoutedSectionShell({
groups = [],
mobileNav = "scroll",
contentClassName,
desktopNav = true,
}: RoutedSectionShellProps) {
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const [drawerOpen, setDrawerOpen] = useState(false);
@ -196,12 +198,17 @@ export function RoutedSectionShell({
);
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<section
className={cn(
"flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col",
desktopNav ? "gap-6 md:flex-row" : "gap-4 md:block"
)}
>
<div className={cn("md:w-[220px] md:shrink-0", !desktopNav && "md:hidden")}>
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
{title}
</h1>
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
{desktopNav ? <nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav> : null}
{mobileNav === "drawer" ? (
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
<DrawerTrigger asChild>
@ -256,11 +263,15 @@ export function RoutedSectionShell({
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4 bg-border" />
{desktopNav ? (
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4 bg-border" />
</div>
) : null}
<div className={cn("min-w-0", desktopNav ? "pt-4" : "pt-4 md:pt-0", contentClassName)}>
{children}
</div>
<div className={cn("min-w-0 pt-4", contentClassName)}>{children}</div>
</div>
</section>
);

View file

@ -1,6 +1,13 @@
export { CreateWorkspaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
export {
getPlaygroundActiveValue,
getPlaygroundNavGroups,
getPlaygroundNavItems,
getPlaygroundSelectedLabel,
PlaygroundSidebar,
} from "./playground/PlaygroundSidebar";
export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
export { RoutedSectionShell } from "./RoutedSectionShell";
export { LayoutShell } from "./shell";

View file

@ -0,0 +1,215 @@
"use client";
import { ChevronRight, History, LayoutGrid } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { cn } from "@/lib/utils";
import type { RoutedSectionGroup, RoutedSectionItem } from "../RoutedSectionShell";
interface PlaygroundSidebarProps {
workspaceId: string;
className?: string;
}
export function getPlaygroundNavItems(base: string): RoutedSectionItem[] {
return [
{
value: "overview",
label: "Overview",
href: base,
icon: <LayoutGrid className="h-4 w-4" />,
},
{
value: "runs",
label: "API Runs",
href: `${base}/runs`,
icon: <History className="h-4 w-4" />,
},
];
}
export function getPlaygroundNavGroups(base: string): RoutedSectionGroup[] {
return PLAYGROUND_PLATFORMS.map((platform) => {
const Icon = platform.icon;
return {
value: platform.id,
label: platform.label,
icon: <Icon className="h-4 w-4 shrink-0" />,
items: platform.verbs.map((verb) => ({
value: `${platform.id}/${verb.verb}`,
label: verb.label,
href: `${base}/${platform.id}/${verb.verb}`,
})),
};
});
}
export function getPlaygroundActiveValue(
pathname: string | null,
base: string,
items: RoutedSectionItem[]
): string {
if (!pathname?.startsWith(base)) return "";
const rest = pathname.slice(base.length).replace(/^\/+/, "");
if (!rest) return "overview";
const [first, second] = rest.split("/");
if (second) return `${first}/${second}`;
if (items.some((item) => item.value === first)) return first;
return "overview";
}
export function getPlaygroundSelectedLabel(
activeValue: string,
items: RoutedSectionItem[],
groups: RoutedSectionGroup[]
): string {
const topLevelItem = items.find((item) => item.value === activeValue);
if (topLevelItem) return topLevelItem.label;
const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
const child = group?.items.find((item) => item.value === activeValue);
if (group && child) return `${group.label}: ${child.label}`;
return "API Playground";
}
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
return (
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
);
}
function PlaygroundNavLink({
item,
activeValue,
}: {
item: RoutedSectionItem;
activeValue: string;
}) {
const isActive = activeValue === item.value;
return (
<Link
href={item.href}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
isActive
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
<span className="min-w-0 truncate">{item.label}</span>
</Link>
);
}
function PlaygroundNavGroup({
group,
activeValue,
isExpanded,
onToggle,
}: {
group: RoutedSectionGroup;
activeValue: string;
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<div className="flex flex-col gap-0.5">
<button
type="button"
aria-expanded={isExpanded}
onClick={onToggle}
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
isExpanded
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{group.icon}
<span className="min-w-0 truncate">{group.label}</span>
<ChevronRight
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
/>
</button>
<div
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
aria-hidden={!isExpanded}
>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-col gap-0.5 pl-10">
{group.items.map((item) => (
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
))}
</div>
</div>
</div>
</div>
);
}
export function PlaygroundSidebar({ workspaceId, className }: PlaygroundSidebarProps) {
const pathname = usePathname();
const base = `/dashboard/${workspaceId}/playground`;
const items = useMemo(() => getPlaygroundNavItems(base), [base]);
const groups = useMemo(() => getPlaygroundNavGroups(base), [base]);
const activeValue = getPlaygroundActiveValue(pathname, base, items);
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
findActiveGroupValue(groups, activeValue)
);
useEffect(() => {
const activeGroup = findActiveGroupValue(groups, activeValue);
if (activeGroup) {
setExpandedGroup(activeGroup);
}
}, [activeValue, groups]);
return (
<aside
className={cn(
"flex h-full w-[240px] shrink-0 flex-col overflow-hidden border-r bg-panel text-sidebar-foreground select-none",
className
)}
>
<div className="flex h-12 shrink-0 items-center px-3">
<h1 className="truncate text-lg font-semibold tracking-tight text-foreground">
API Playground
</h1>
</div>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-4 pt-1.5">
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<PlaygroundNavLink key={item.value} item={item} activeValue={activeValue} />
))}
<Separator className="my-3 bg-border" />
{groups.map((group) => (
<PlaygroundNavGroup
key={group.value}
group={group}
activeValue={activeValue}
isExpanded={expandedGroup === group.value}
onToggle={() =>
setExpandedGroup((current) => (current === group.value ? null : group.value))
}
/>
))}
</div>
</nav>
</aside>
);
}

View file

@ -24,12 +24,7 @@ import {
RightPanelExpandButton,
RightPanelToggleButton,
} from "../right-panel/RightPanel";
import {
MobileSidebar,
MobileSidebarTrigger,
Sidebar,
SidebarCollapseButton,
} from "../sidebar";
import { MobileSidebar, MobileSidebarTrigger, Sidebar, SidebarCollapseButton } from "../sidebar";
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
import { TabBar } from "../tabs/TabBar";
import { WorkspacePanel } from "./WorkspacePanel";
@ -117,6 +112,7 @@ interface LayoutShellProps {
isLoadingChats?: boolean;
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
playgroundSidebar?: React.ReactNode;
}
function MainContentPanel({
@ -217,11 +213,13 @@ export function LayoutShell({
isLoadingChats = false,
onTabSwitch,
onTabPrefetch,
playgroundSidebar,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const electronAPI = useElectronAPI();
const isMacDesktop = electronAPI?.versions.platform === "darwin";
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(false);
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
const {
sidebarWidth,
@ -234,6 +232,9 @@ export function LayoutShell({
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
const handlePlaygroundSidebarToggle = () => {
setIsPlaygroundSidebarCollapsed((collapsed) => !collapsed);
};
// Mobile layout
if (isMobile) {
@ -348,6 +349,12 @@ export function LayoutShell({
onToggleCollapse={toggleCollapsed}
navItems={navItems}
onNavItemClick={onNavItemClick}
onPlaygroundItemClick={
playgroundSidebar ? handlePlaygroundSidebarToggle : undefined
}
isPlaygroundSidebarOpen={
playgroundSidebar ? !isPlaygroundSidebarCollapsed : undefined
}
chats={chats}
activeChatId={activeChatId}
onNewChat={onNewChat}
@ -375,10 +382,7 @@ export function LayoutShell({
<Logo disableLink priority className="h-7 w-7 rounded-md" />
) : undefined
}
className={cn(
"flex shrink-0",
isMacDesktop && "rounded-tl-xl"
)}
className={cn("flex shrink-0", isMacDesktop && "rounded-tl-xl")}
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
isResizing={isResizing}
@ -403,6 +407,21 @@ export function LayoutShell({
)}
</div>
{playgroundSidebar ? (
<div
aria-hidden={isPlaygroundSidebarCollapsed}
className={cn(
"hidden md:flex shrink-0 overflow-hidden -mr-2 bg-panel transition-[width,opacity] duration-200 ease-out",
isPlaygroundSidebarCollapsed
? "w-0 opacity-0 pointer-events-none"
: "w-[240px] opacity-100",
isMacDesktop && !isPlaygroundSidebarCollapsed && "border-t"
)}
>
<div className="w-[240px] shrink-0">{playgroundSidebar}</div>
</div>
) : null}
<DesktopWorkspaceRegion>
{useWorkspacePanel ? (
<WorkspacePanel

View file

@ -4,7 +4,7 @@ import { useQuery } from "@rocicorp/zero/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
Check,
FolderInput,
FilePlus,
FolderPlus,
FolderSync,
ListFilter,
@ -287,7 +287,7 @@ export function EmbeddedImportMenu({
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Import documents"
>
<FolderInput className="size-3.5" />
<FilePlus className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">

View file

@ -52,6 +52,8 @@ interface SidebarProps {
onToggleCollapse?: () => void;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
onPlaygroundItemClick?: (item: NavItem) => void;
isPlaygroundSidebarOpen?: boolean;
chats: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
@ -89,6 +91,8 @@ export function Sidebar({
onToggleCollapse,
navItems,
onNavItemClick,
onPlaygroundItemClick,
isPlaygroundSidebarOpen,
chats,
activeChatId,
onNewChat,
@ -242,9 +246,9 @@ export function Sidebar({
<SidebarButton
icon={playgroundItem.icon}
label={playgroundItem.title}
onClick={() => onNavItemClick?.(playgroundItem)}
onClick={() => (onPlaygroundItemClick ?? onNavItemClick)?.(playgroundItem)}
isCollapsed={isCollapsed}
isActive={playgroundItem.isActive}
isActive={isPlaygroundSidebarOpen ?? playgroundItem.isActive}
badge={<SidebarButtonBadge>New</SidebarButtonBadge>}
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
/>