ui: add collapsible Models section to sidebar

Move Models to the top of the sidebar as a collapsible item with each
model listed as a sub-item.

- add persistent modelsMenuOpen store for expand state
- show status dot per model (grey/yellow/green for stopped/changing/loaded)
- right-aligned load/unload button with Play/PowerOff/Loader2 icon
- button stops propagation so it doesn't trigger navigation
This commit is contained in:
Benson Wong
2026-06-27 23:54:18 +00:00
parent 749819ef47
commit 11f8afead8
2 changed files with 115 additions and 13 deletions
+112 -13
View File
@@ -1,16 +1,21 @@
<script lang="ts">
import { link } from "svelte-spa-router";
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor, ChevronRight } from "@lucide/svelte";
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor, ChevronRight, Play, PowerOff, Loader2 } 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 { performanceEnabled, models, loadModel, unloadSingleModel } from "../stores/api";
import { selectedPlaygroundTab, playgroundTabs, playgroundMenuOpen } from "../stores/playground";
import { modelsMenuOpen } from "../stores/sidebar";
import type { Model } from "../lib/types";
import ConnectionStatus from "./ConnectionStatus.svelte";
let pendingLoads = $state<Record<string, boolean>>({});
const loadControllers = new Map<string, AbortController>();
function handleTitleChange(newTitle: string): void {
const sanitized = newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap";
appTitle.set(sanitized);
@@ -33,6 +38,51 @@
function isActive(path: string, current: string): boolean {
return path === "/" ? current === "/" : current.startsWith(path);
}
type DotColor = "grey" | "yellow" | "green";
function statusDotColor(model: Model): DotColor {
if (pendingLoads[model.id] && model.state === "stopped") return "yellow";
if (model.state === "ready") return "green";
if (model.state === "starting" || model.state === "stopping") return "yellow";
return "grey";
}
const dotClass: Record<DotColor, string> = {
grey: "bg-muted-foreground/40",
yellow: "bg-warning",
green: "bg-success",
};
async function handleLoadModel(modelId: string): Promise<void> {
if (pendingLoads[modelId]) return;
const controller = new AbortController();
loadControllers.set(modelId, controller);
pendingLoads[modelId] = true;
try {
await loadModel(modelId, controller.signal);
} catch (e) {
console.error(e);
} finally {
loadControllers.delete(modelId);
delete pendingLoads[modelId];
}
}
function cancelLoad(modelId: string): void {
loadControllers.get(modelId)?.abort();
}
function onToggleLoad(e: MouseEvent, model: Model): void {
e.preventDefault();
e.stopPropagation();
if (model.state === "stopped" && pendingLoads[model.id]) {
cancelLoad(model.id);
} else if (model.state === "stopped") {
handleLoadModel(model.id);
} else if (model.state === "ready") {
unloadSingleModel(model.id);
}
}
</script>
<Sidebar.Root collapsible="icon">
@@ -58,6 +108,66 @@
<Sidebar.Group>
<Sidebar.GroupContent>
<Sidebar.Menu class="gap-1">
<Sidebar.MenuItem>
<Collapsible.Root
open={$modelsMenuOpen}
onOpenChange={(v) => modelsMenuOpen.set(v)}
class="gap-0"
>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
isActive={isActive("/models", $currentRoute)}
tooltipContent="Models"
>
<Boxes />
<span>Models</span>
<ChevronRight
class="ml-auto transition-transform duration-200 {$modelsMenuOpen ? 'rotate-90' : ''}"
/>
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each $models as model (model.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={isActive("/models", $currentRoute)}
>
{#snippet child({ props })}
<a href="/models" use:link {...props}>
<span class={`size-2 shrink-0 rounded-full ${dotClass[statusDotColor(model)]}`}></span>
<span class="flex-1 truncate">{model.id}</span>
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:opacity-50"
title={model.state === "ready" ? "Unload" : pendingLoads[model.id] ? "Cancel" : "Load"}
aria-label={model.state === "ready" ? "Unload model" : "Load model"}
disabled={model.state === "starting" || model.state === "stopping"}
onclick={(e) => onToggleLoad(e, model)}
>
{#if pendingLoads[model.id] && model.state === "stopped"}
<Loader2 class="size-3.5 animate-spin" />
{:else if model.state === "ready"}
<PowerOff class="size-3.5" />
{:else if model.state === "starting" || model.state === "stopping"}
<Loader2 class="size-3.5 animate-spin" />
{:else}
<Play class="size-3.5" />
{/if}
</button>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Collapsible.Root
open={$playgroundMenuOpen}
@@ -104,17 +214,6 @@
</Collapsible.Root>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/models", $currentRoute)} tooltipContent="Models">
{#snippet child({ props })}
<a href="/models" use:link {...props}>
<Boxes />
<span>Models</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/activity", $currentRoute)} tooltipContent="Activity">
{#snippet child({ props })}
+3
View File
@@ -0,0 +1,3 @@
import { persistentStore } from "./persistent";
export const modelsMenuOpen = persistentStore<boolean>("models-menu-open", true);