ui: consolidate playground nav into sidebar

Move Playground tabs into the sidebar as collapsible sub-items and make
the sidebar the sole navigation for playground interfaces.

- add collapsible UI primitive (bits-ui wrapper)
- add playground store with selected tab and menu open state (persistent)
- make Playground menu item collapsible; whole button toggles expand state
- move playground sub-items (Chat/Images/Speech/etc) under Playground
- remove in-page Tabs from Playground.svelte
- update sectionTitle breadcrumb to reflect active sub-item
- remove bg-sidebar panel background so items sit on page background
- remove persistent data-active background tint on menu items

fixes #123
This commit is contained in:
Benson Wong
2026-06-27 16:46:10 +00:00
parent 0ab9e74333
commit 749819ef47
11 changed files with 154 additions and 49 deletions
+8 -1
View File
@@ -13,6 +13,7 @@
import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api";
import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme";
import { currentRoute } from "./stores/route";
import { selectedPlaygroundTab, playgroundTabs } from "./stores/playground";
const routes = {
"/": PlaygroundStub,
@@ -31,7 +32,13 @@
"/performance": "Performance",
};
let sectionTitle = $derived(routeTitles[$currentRoute] ?? "Playground");
let sectionTitle = $derived.by(() => {
if ($currentRoute === "/") {
const tab = playgroundTabs.find((t) => t.id === $selectedPlaygroundTab);
return `Playground / ${tab?.label ?? ""}`;
}
return routeTitles[$currentRoute] ?? "Playground";
});
function handleRouteLoaded(event: { detail: { route: string | RegExp } }) {
const route = event.detail.route;
+47 -10
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { link } from "svelte-spa-router";
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor } from "@lucide/svelte";
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor, ChevronRight } from "@lucide/svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { toggleTheme, themeMode, appTitle } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import { performanceEnabled } from "../stores/api";
import { selectedPlaygroundTab, playgroundTabs, playgroundMenuOpen } from "../stores/playground";
import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void {
@@ -55,16 +57,51 @@
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupContent>
<Sidebar.Menu>
<Sidebar.Menu class="gap-1">
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/", $currentRoute)} tooltipContent="Playground">
{#snippet child({ props })}
<a href="/" use:link {...props}>
<House />
<span class={$playgroundActivity ? "activity-link" : ""}>Playground</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<Collapsible.Root
open={$playgroundMenuOpen}
onOpenChange={(v) => playgroundMenuOpen.set(v)}
class="gap-0"
>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
isActive={isActive("/", $currentRoute)}
tooltipContent="Playground"
>
<House />
<span class={$playgroundActivity ? "activity-link" : ""}>Playground</span>
<ChevronRight
class="ml-auto transition-transform duration-200 {$playgroundMenuOpen ? 'rotate-90' : ''}"
/>
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each playgroundTabs as tab (tab.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={isActive("/", $currentRoute) && $selectedPlaygroundTab === tab.id}
>
{#snippet child({ props })}
<a
href="/"
use:link
{...props}
onclick={() => selectedPlaygroundTab.set(tab.id)}
>
<span>{tab.label}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content
bind:ref
data-slot="collapsible-content"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Content>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger
bind:ref
data-slot="collapsible-trigger"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Trigger>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root
bind:ref
data-slot="collapsible"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Root>
@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Trigger,
Content,
//
Root as Collapsible,
Trigger as CollapsibleTrigger,
Content as CollapsibleContent,
};
@@ -2,7 +2,7 @@
import { tv, type VariantProps } from "tailwind-variants";
export const sidebarMenuButtonVariants = tv({
base: "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
base: "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:font-medium data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
@@ -19,7 +19,7 @@
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:font-medium data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
className
),
"data-slot": "sidebar-menu-sub-button",
@@ -25,7 +25,7 @@
{#if collapsible === "none"}
<div
class={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
"bg-background text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
bind:this={ref}
@@ -44,7 +44,7 @@
data-slot="sidebar"
data-mobile="true"
class={cn(
"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden",
"bg-background text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden",
className
)}
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
@@ -99,7 +99,7 @@
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
class="bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
class="bg-background group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
>
{@render children?.()}
</div>
+8 -33
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { persistentStore } from "../stores/persistent";
import ChatInterface from "../components/playground/ChatInterface.svelte";
import ImageInterface from "../components/playground/ImageInterface.svelte";
import AudioInterface from "../components/playground/AudioInterface.svelte";
@@ -7,52 +6,28 @@
import RerankInterface from "../components/playground/RerankInterface.svelte";
import ConcurrencyInterface from "../components/playground/ConcurrencyInterface.svelte";
import * as Card from "$lib/components/ui/card/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
type Tab = "chat" | "images" | "speech" | "audio" | "rerank" | "concurrency";
const selectedTabStore = persistentStore<Tab>("playground-selected-tab", "chat");
const tabs: { id: Tab; label: string }[] = [
{ id: "chat", label: "Chat" },
{ id: "images", label: "Images" },
{ id: "speech", label: "Speech" },
{ id: "audio", label: "Transcription" },
{ id: "rerank", label: "Rerank" },
{ id: "concurrency", label: "Load Test" },
];
import { selectedPlaygroundTab } from "../stores/playground";
</script>
<Card.Root class="flex h-full flex-col gap-0 overflow-hidden p-4">
<!-- Tab navigation: triggers update the store; content stays mounted to preserve state -->
<div class="mb-4 shrink-0">
<Tabs.Root bind:value={() => $selectedTabStore, (v) => selectedTabStore.set(v as Tab)}>
<Tabs.List class="h-auto flex-wrap">
{#each tabs as tab (tab.id)}
<Tabs.Trigger value={tab.id}>{tab.label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</div>
<!-- Tab content (all interfaces stay mounted) -->
<!-- Tab content (all interfaces stay mounted); navigation via the sidebar -->
<div class="relative flex-1 overflow-hidden">
<div class="h-full" class:hidden={$selectedTabStore !== "chat"}>
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "chat"}>
<ChatInterface />
</div>
<div class="h-full" class:hidden={$selectedTabStore !== "images"}>
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "images"}>
<ImageInterface />
</div>
<div class="h-full" class:hidden={$selectedTabStore !== "speech"}>
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "speech"}>
<SpeechInterface />
</div>
<div class="h-full" class:hidden={$selectedTabStore !== "audio"}>
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "audio"}>
<AudioInterface />
</div>
<div class="h-full" class:hidden={$selectedTabStore !== "rerank"}>
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "rerank"}>
<RerankInterface />
</div>
<div class="h-full" class:hidden={$selectedTabStore !== "concurrency"}>
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "concurrency"}>
<ConcurrencyInterface />
</div>
</div>
+16
View File
@@ -0,0 +1,16 @@
import { persistentStore } from "./persistent";
export type PlaygroundTab = "chat" | "images" | "speech" | "audio" | "rerank" | "concurrency";
export const playgroundTabs: { id: PlaygroundTab; label: string }[] = [
{ id: "chat", label: "Chat" },
{ id: "images", label: "Images" },
{ id: "speech", label: "Speech" },
{ id: "audio", label: "Transcription" },
{ id: "rerank", label: "Rerank" },
{ id: "concurrency", label: "Load Test" },
];
export const selectedPlaygroundTab = persistentStore<PlaygroundTab>("playground-selected-tab", "chat");
export const playgroundMenuOpen = persistentStore<boolean>("playground-menu-open", true);