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:
@@ -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 })}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { persistentStore } from "./persistent";
|
||||
|
||||
export const modelsMenuOpen = persistentStore<boolean>("models-menu-open", true);
|
||||
Reference in New Issue
Block a user