From 82cad1b84ea7f5c4d5000d3397f220f3cb04e61c Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Sun, 28 Jun 2026 03:04:04 +0000 Subject: [PATCH] ui: add ModelsDash route, clickable sidebar headings, and dialog tweaks - Add /models route (ModelsDash) with unload-all, model list with start/stop buttons, and show-unlisted toggle - Make sidebar Models and Playground headings navigate to their pages while the chevron independently expands/collapses the section - Extract shared model load/unload orchestration into modelLoad store - Left-align model names in the ConcurrencyInterface load-test list - Widen CaptureDialog to 90% with flex-based scroll overflow - Use sm:max-w-[90%] to override the shadcn dialog's sm:max-w-sm cap --- ui-svelte/src/App.svelte | 6 + ui-svelte/src/components/AppSidebar.svelte | 72 ++++++--- ui-svelte/src/components/CaptureDialog.svelte | 4 +- .../playground/ConcurrencyInterface.svelte | 26 ++-- ui-svelte/src/routes/ModelDetail.svelte | 51 +----- ui-svelte/src/routes/ModelsDash.svelte | 146 ++++++++++++++++++ ui-svelte/src/stores/modelLoad.ts | 53 +++++++ 7 files changed, 276 insertions(+), 82 deletions(-) create mode 100644 ui-svelte/src/routes/ModelsDash.svelte create mode 100644 ui-svelte/src/stores/modelLoad.ts diff --git a/ui-svelte/src/App.svelte b/ui-svelte/src/App.svelte index 89b1463a..88699541 100644 --- a/ui-svelte/src/App.svelte +++ b/ui-svelte/src/App.svelte @@ -4,6 +4,7 @@ import AppSidebar from "./components/AppSidebar.svelte"; import LogViewer from "./routes/LogViewer.svelte"; import ModelDetail from "./routes/ModelDetail.svelte"; + import ModelsDash from "./routes/ModelsDash.svelte"; import Activity from "./routes/Activity.svelte"; import Performance from "./routes/Performance.svelte"; import Playground from "./routes/Playground.svelte"; @@ -17,6 +18,7 @@ const routes = { "/": PlaygroundStub, + "/models": ModelsDash, "/models/:id": ModelDetail, "/logs": LogViewer, "/activity": Activity, @@ -26,6 +28,7 @@ const routeTitles: Record = { "/": "Playground", + "/models": "Models", "/activity": "Activity", "/logs": "Logs", "/performance": "Performance", @@ -40,6 +43,9 @@ const id = $currentRoute.slice("/models/".length); return id ? `Models / ${decodeURIComponent(id)}` : "Models"; } + if ($currentRoute === "/models") { + return "Models"; + } return routeTitles[$currentRoute] ?? "Playground"; }); diff --git a/ui-svelte/src/components/AppSidebar.svelte b/ui-svelte/src/components/AppSidebar.svelte index c8d6718c..930ef672 100644 --- a/ui-svelte/src/components/AppSidebar.svelte +++ b/ui-svelte/src/components/AppSidebar.svelte @@ -79,21 +79,37 @@ onOpenChange={(v) => modelsMenuOpen.set(v)} class="gap-0" > - + {#snippet child({ props })} - + Models - - + role="button" + tabindex="0" + aria-label="Toggle models section" + onclick={(e) => { + e.preventDefault(); + e.stopPropagation(); + modelsMenuOpen.update((v) => !v); + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + modelsMenuOpen.update((v) => !v); + } + }} + > + + + {/snippet} - + {#each $models as model (model.id)} @@ -121,21 +137,37 @@ onOpenChange={(v) => playgroundMenuOpen.set(v)} class="gap-0" > - + {#snippet child({ props })} - + Playground - - + role="button" + tabindex="0" + aria-label="Toggle playground section" + onclick={(e) => { + e.preventDefault(); + e.stopPropagation(); + playgroundMenuOpen.update((v) => !v); + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + playgroundMenuOpen.update((v) => !v); + } + }} + > + + + {/snippet} - + {#each playgroundTabs as tab (tab.id)} diff --git a/ui-svelte/src/components/CaptureDialog.svelte b/ui-svelte/src/components/CaptureDialog.svelte index 13ee6054..8ec014b8 100644 --- a/ui-svelte/src/components/CaptureDialog.svelte +++ b/ui-svelte/src/components/CaptureDialog.svelte @@ -184,7 +184,7 @@ if (!v) onclose(); }} > - + {#if capture} @@ -193,7 +193,7 @@ -
+
No models configured.
{:else} -
    +
    {#each availableModels as m (m.id)} -
  • - -
  • + {/each} -
+
{/if} diff --git a/ui-svelte/src/routes/ModelDetail.svelte b/ui-svelte/src/routes/ModelDetail.svelte index 27d0525f..9e7e4a51 100644 --- a/ui-svelte/src/routes/ModelDetail.svelte +++ b/ui-svelte/src/routes/ModelDetail.svelte @@ -1,6 +1,7 @@
@@ -84,12 +43,12 @@ +
+ + + + + + Show unlisted models + + showUnlisted.set(v)} + /> + + {$models.filter((m) => m.unlisted).length} unlisted + + + + + + + {#if visibleModels.length === 0} +
+ No models available +
+ {:else} +
+ {#each visibleModels as model (model.id)} +
+ + +
{model.name || model.id}
+
+ {model.id} + {#if model.aliases && model.aliases.length > 0} + ยท {model.aliases.join(", ")} + {/if} +
+
+ + {model.state} + + {#if model.unlisted} + + unlisted + + {/if} + + + + +
+ {/each} +
+ {/if} +
+
+ diff --git a/ui-svelte/src/stores/modelLoad.ts b/ui-svelte/src/stores/modelLoad.ts new file mode 100644 index 00000000..6e1372f8 --- /dev/null +++ b/ui-svelte/src/stores/modelLoad.ts @@ -0,0 +1,53 @@ +import { writable } from "svelte/store"; +import { loadModel, unloadSingleModel } from "./api"; +import type { Model } from "../lib/types"; + +export const pendingLoads = writable>({}); +const loadControllers = new Map(); + +export async function handleLoadModel(id: string): Promise { + if (isPending(id)) return; + + const controller = new AbortController(); + loadControllers.set(id, controller); + pendingLoads.update((p) => ({ ...p, [id]: true })); + try { + await loadModel(id, controller.signal); + } catch (e) { + console.error(e); + } finally { + loadControllers.delete(id); + pendingLoads.update((p) => { + const next = { ...p }; + delete next[id]; + return next; + }); + } +} + +export function cancelLoad(id: string): void { + loadControllers.get(id)?.abort(); +} + +export function isPending(id: string): boolean { + let val = false; + pendingLoads.subscribe((p) => (val = !!p[id]))(); + return val; +} + +export function onToggleLoad(m: Model): void { + if (m.state === "stopped" && isPending(m.id)) { + cancelLoad(m.id); + } else if (m.state === "stopped") { + void handleLoadModel(m.id); + } else if (m.state === "ready") { + void unloadSingleModel(m.id); + } +} + +export function statusDotColor(m: Model | undefined): string { + if (!m) return "bg-muted-foreground/40"; + if (m.state === "ready") return "bg-success"; + if (m.state === "starting" || m.state === "stopping") return "bg-warning"; + return "bg-muted-foreground/40"; +}