Commit b013e56a7 for llama.cpp
commit b013e56a7147dbff5ec03175828442a94086507c
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Fri Oct 9 10:34:45 2026 +0200
ui : add the models manager (#29583)
* ui : add the drawer, sheet and grouped list primitives
Add the drawer and sheet overlay components, the toggle and toggle
group, the shared searchable input, the collapsible sections and the
grouped list, and the near viewport helper they position with.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : add the shared model components and data layer
Add the model row pieces shared by the selector and the manager, the
models store with its download status feed, the huggingface and
migration services, and the model utils, enums and constants they
read through.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : rework the models selector around its providers
Group the selector components under their own folder, rework the list
around the provider grouping, add the mobile trigger and the download
item, and derive the selection state in one hook.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : add the models manager
Add the manager table with its repo, quant and status rows, the
filters and the toolbar, the row actions in a drawer, the downloads
section, the manage models dialog and the stories and tests.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : rework the chat form actions and the mobile experience
Move the add actions into a drawer and a sheet, float the model
actions in a bar on a phone, open the context panel in a drawer, and
derive the attachment, reasoning and tools menus in hooks.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : move the mcp servers to the sidebar rail and tidy the dialogs
Move the mcp servers dialog to the sidebar rail with its menu
entries, even out the dialogs on a phone, and group the mcp and
settings stores behind their own modules.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : polish the shell
The secondary button gets its own look back and the chat add button its
own light surface. Pressable elements get a pointer cursor again, the
font rendering smooths on the app shell, and the agent skills stay out
of prettier's way.
The root layout props probe rework that came with this polish reads the
providers api url and stays with the providers change instead.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : wrap the model id classes
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* ui : load the model the new chat CTA picks
Start a new chat selected the model but left it unloaded, so the chat
opened against a server with nothing in memory. Both CTAs share a load
helper now.
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
* refactor: Post-review fixes
diff --git a/tools/ui/.prettierignore b/tools/ui/.prettierignore
index 635cf99c7..ba47fbe16 100644
--- a/tools/ui/.prettierignore
+++ b/tools/ui/.prettierignore
@@ -19,3 +19,7 @@ test-results
# Vendored third party sources, kept byte identical to upstream
src/lib/vendors/
+
+# Agent skills installed from upstream, kept as installed
+.agents/
+skills-lock.json
diff --git a/tools/ui/README.md b/tools/ui/README.md
index 99abfaa41..04a6ea016 100644
--- a/tools/ui/README.md
+++ b/tools/ui/README.md
@@ -54,10 +54,13 @@ Llama UI supports two server operation modes:
### Multi-Model Support (ROUTER mode)
+- **Models manager** - one table for every model the server can serve, split into loaded, downloading, favorites, local and hidden sections, with search, context/capability/modality filters, sorting, load/unload, delete from disk and hide. It opens from the sidebar, from a model row or from the selector.
- **Model selector** with Loaded/Available groups
- **Automatic loading** - Models load on selection
- **Modality validation** - Prevents sending images to non-vision models
- **LRU unloading** - Server auto-manages model cache
+- **Downloads** - In-flight downloads stay visible above the selector and can be paused, resumed or cancelled from the manager
+- **Hub metadata** - Avatars, context length and chat template come from the Hugging Face Hub only while the `Use Hugging Face Hub API for models metadata` setting is on. It is off by default, and the UI then shows what the server reports for `/v1/models`
### Keyboard Shortcuts
@@ -144,6 +147,7 @@ proxy: {
| ----------------- | ------------------------------- | -------------------------------------------------------- |
| **Framework** | SvelteKit + Svelte 5 | Reactive UI with runes (`$state`, `$derived`, `$effect`) |
| **UI Components** | shadcn-svelte + bits-ui | Accessible, customizable component library |
+| **Drawers** | vaul-svelte | Draggable drawer overlays (mobile pickers, model pane) |
| **Styling** | TailwindCSS 4 | Utility-first CSS with design tokens |
| **Database** | IndexedDB (Dexie) | Client-side storage for conversations and messages |
| **Build** | Vite | Fast bundling with static adapter |
@@ -350,94 +354,103 @@ Components are organized in `app/` (application-specific) and `ui/` (shadcn-svel
**Dialog Components** (`app/dialogs/`):
-| Component | Responsibility |
-| ------------------------------- | -------------------------------------------------------- |
-| `DialogChatSettings` | Full-screen settings configuration |
-| `DialogModelInformation` | Model details (context size, modalities, parallel slots) |
-| `DialogChatAttachmentPreview` | Full preview for images, PDFs (text or page view), code |
-| `DialogConfirmation` | Generic confirmation for destructive actions |
-| `DialogConversationTitleUpdate` | Edit conversation title |
+| Component | Responsibility |
+| ------------------------------ | -------------------------------------------------------- |
+| `DialogChatSettings` | Full-screen settings configuration |
+| `DialogManageModels` | Models manager: browse, load, download and delete models |
+| `DialogModelInformation` | Model details (context size, modalities, parallel slots) |
+| `DialogChatAttachmentsPreview` | Full preview for images, PDFs (text or page view), code |
+| `DialogConfirmation` | Generic confirmation for destructive actions |
+| `DialogConversationRename` | Edit conversation title |
**Server/Model Components** (`app/server/`, `app/models/`):
-| Component | Responsibility |
-| ------------------- | --------------------------------------------------------- |
-| `ServerErrorSplash` | Error display when server is unreachable |
-| `ModelsSelector` | Model dropdown with Loaded/Available groups (ROUTER mode) |
+| Component | Responsibility |
+| ----------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| `ServerErrorSplash` | Error display when server is unreachable |
+| `ModelsSelector/` | Model dropdown and mobile sheet: favorites, loaded and local sections, rows windowed and folded into families |
+| `ModelsManager/` | Models manager table and model pane: sections, filters, sorting, quants folded per repo, downloads |
+| `ModelAvatar` | Org avatar of a model, with the quantizer badge; follows the family grouping of the list |
+| `ModelId`, `ModelBadge` | Model name, aliases, tags, quantization and draft sidecar badges |
+| `ModelContext`, `ModelCapabilities` | Context window and capability icons, filled from the Hub when it is enabled |
**Shared UI Components** (`app/misc/`):
-| Component | Responsibility |
-| -------------------------------- | ---------------------------------------------------------------- |
-| `MarkdownContent` | Markdown rendering with KaTeX, syntax highlighting, copy buttons |
-| `SyntaxHighlightedCode` | Code blocks with language detection and highlighting |
-| `ActionButton`, `ActionDropdown` | Reusable action buttons and menus |
-| `BadgeModality`, `BadgeInfo` | Status and capability badges |
+| Component | Responsibility |
+| ----------------------------------------- | ----------------------------------------------------------------------- |
+| `MarkdownContent` | Markdown rendering with KaTeX, syntax highlighting, copy buttons |
+| `SyntaxHighlightedCode` | Code blocks with language detection and highlighting |
+| `ActionIcon`, `DropdownMenuActions` | Reusable action button and action menu |
+| `BadgesModality`, `BadgeInfo` | Status and capability badges |
+| `CollapsibleSection`, `CollapsibleRegion` | Section header with a trigger that expands in place |
+| `GroupedList` | Grouped rows with foldable groups, per-group windows and show-more rows |
+| `ScrollCarousel`, `TruncatedText` | Horizontal scroll strip and single-line truncation with tooltip |
#### Hooks (`src/lib/hooks/`)
Hooks are the thin view-layer between components and stores: they own UI concerns (scroll, drag-and-drop, keyboard shortcuts, pickers, selection) and translate store state into view state.
-| Hook | Responsibility |
-| ------------------------------- | -------------------------------------------------------------- |
-| `use-chat-screen-active-model` | Active model resolution + modality capability detection |
-| `use-processing-state` | View over `chatStore.processing` for streaming progress/tokens |
-| `use-context-gauge` | View over `contextStatsStore` for the context usage gauge |
-| `use-models-selector` | Model selector dropdown state (loaded/available groups) |
-| `use-tools-panel` | Tools panel state |
-| `use-reasoning-menu` | Reasoning-effort menu state |
-| `use-attachment-menu` | Attachment menu + modality flags |
-| `use-draft-messages` | Per-chat draft message/files persistence |
-| `use-chat-form-pickers` | Chat form pickers (commands, mentions) |
-| `use-debounced-search` | Shared debounced async search for pickers |
-| `use-picker-navigation` | Picker keyboard navigation |
-| `use-chat-message-edit-context` | Message edit context (content + extras) |
-| `use-chat-screen-drag-and-drop` | Drag-and-drop state machine |
-| `use-chat-screen-file-upload` | File upload queue + capability validation |
-| `use-chat-screen-scroll` | Scroll container binding + navigation guard |
-| `use-auto-scroll` | Auto-scroll controller for streaming |
-| `use-marquee-selection` | Shift+click / marquee range selection |
-| `use-keyboard-shortcuts` | Global keyboard shortcuts |
-| `use-settings-navigation` | Settings section navigation |
-| `use-pwa` | PWA install/update + version mismatch detection |
+| Hook | Responsibility |
+| ------------------------------- | ----------------------------------------------------------------------------------------------- |
+| `use-chat-screen-active-model` | Active model resolution + modality capability detection |
+| `use-processing-state` | View over `chatStore.processing` for streaming progress/tokens |
+| `use-context-gauge` | View over `contextStatsStore` for the context usage gauge |
+| `use-models-selector` | Model selector state: hidden models, family grouping, windowed rows, selection and load on pick |
+| `use-tools-panel` | Tools panel state |
+| `use-reasoning-menu` | Reasoning-effort menu state |
+| `use-attachment-menu` | Attachment menu + modality flags |
+| `use-draft-messages` | Per-chat draft message/files persistence |
+| `use-chat-form-pickers` | Chat form pickers (commands, mentions) |
+| `use-debounced-search` | Shared debounced async search for pickers |
+| `use-picker-navigation` | Picker keyboard navigation |
+| `use-chat-message-edit-context` | Message edit context (content + extras) |
+| `use-chat-screen-drag-and-drop` | Drag-and-drop state machine |
+| `use-chat-screen-file-upload` | File upload queue + capability validation |
+| `use-chat-screen-scroll` | Scroll container binding + navigation guard |
+| `use-auto-scroll` | Auto-scroll controller for streaming |
+| `use-marquee-selection` | Shift+click / marquee range selection |
+| `use-keyboard-shortcuts` | Global keyboard shortcuts |
+| `use-settings-navigation` | Settings section navigation |
+| `use-pwa` | PWA install/update + version mismatch detection |
#### Stores (`src/lib/stores/`)
Stores own reactive application state as Svelte 5 runes. Larger stores are split into directories and compose focused sub-stores behind a narrow host interface (see Architectural Patterns).
-| Store | Responsibility |
-| -------------------- | --------------------------------------------------------------------------------------------------------------- |
-| `chatStore` | Chat lifecycle, streaming, abort control, error handling; composes `processing`, `activity`, `streams`, `flows` |
-| `conversationsStore` | Conversation CRUD, message branching, navigation, import/export; composes `preferences` |
-| `modelsStore` | Model list, selection, loading/unloading (ROUTER); composes `props`, `status` |
-| `mcpStore` | MCP host role: multi-server lifecycle, tool routing; composes `health`, `resources` |
-| `agenticStore` | Multi-turn agentic loop orchestration, tool execution; composes `gates` |
-| `serverStore` | Server connection state, `/props`, role detection, modalities |
-| `settingsStore` | User preferences, theme, parameter sync with server defaults |
-| `toolsStore` | Tool registry: server + MCP tools, enabled set for the LLM |
-| `permissionsStore` | Persisted tool permission grants |
-| `contextStatsStore` | Context window usage for the active conversation |
-| `draftMessagesStore` | Per-chat draft message/files |
-| `deviceStore` | Browser environment signals (mobile, OS, theme) |
-| `versionStore` | Build version information |
+| Store | Responsibility |
+| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `chatStore` | Chat lifecycle, streaming, abort control, error handling; composes `processing`, `activity`, `streams`, `flows` |
+| `conversationsStore` | Conversation CRUD, message branching, navigation, import/export; composes `preferences` |
+| `modelsStore` | Model list, selection, loading/unloading (ROUTER), recent picks, favorites, hidden models and list open state; composes `props`, `status` |
+| `mcpStore` | MCP host role: multi-server lifecycle, tool routing, server icons; composes `health`, `resources` |
+| `agenticStore` | Multi-turn agentic loop orchestration, tool execution; composes `gates` |
+| `serverStore` | Server connection state, `/props`, role detection, modalities |
+| `settingsStore` | User preferences, theme, parameter sync with server defaults |
+| `toolsStore` | Tool registry: server + MCP tools, enabled set for the LLM |
+| `permissionsStore` | Persisted tool permission grants |
+| `contextStatsStore` | Context window usage for the active conversation |
+| `draftMessagesStore` | Per-chat draft message/files |
+| `deviceStore` | Browser environment signals (mobile, OS, theme) |
+| `versionStore` | Build version information |
#### Services (`src/lib/services/`)
Services are a stateless protocol layer: static methods, pure I/O, no reactive state. Stores consume them for all API and storage access.
-| Service | Responsibility |
-| ----------------------------- | ------------------------------------------------------------------------- |
-| `ChatService` | `/v1/chat/completions` streaming + SSE parsing, message format conversion |
-| `ModelsService` | `/models`, `/models/load`, `/models/unload` |
-| `PropsService` | `/props`, `/props?model=` |
-| `DatabaseService` | IndexedDB operations via Dexie |
-| `MCPService` | MCP protocol: transports, connect, list/execute tools, prompts, resources |
-| `ToolsService` | Server tool list/execute/stream (`/tools`) |
-| `SandboxService` | Browser JS execution in a sandboxed worker |
-| `ParameterSyncService` | Syncs settings with server defaults |
-| `ConversationTransferService` | Conversation import/export JSONL + ZIP format |
-| `MigrationService` | Non-destructive localStorage/IndexedDB migrations |
-| `RouterService` | Dynamic route URL construction |
+| Service | Responsibility |
+| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `ChatService` | `/v1/chat/completions` streaming + SSE parsing, message format conversion |
+| `ModelsService` | `/models`, `/models/load`, `/models/unload` |
+| `PropsService` | `/props`, `/props?model=` |
+| `HuggingFaceService` | Hugging Face Hub metadata: model details, avatars, README and file tree, cached per repo and off unless the Hub setting is on |
+| `DatabaseService` | IndexedDB operations via Dexie |
+| `MCPService` | MCP protocol: transports, connect, list/execute tools, prompts, resources |
+| `ToolsService` | Server tool list/execute/stream (`/tools`) |
+| `SandboxService` | Browser JS execution in a sandboxed worker |
+| `ParameterSyncService` | Syncs settings with server defaults |
+| `ConversationTransferService` | Conversation import/export JSONL + ZIP format |
+| `MigrationService` | Non-destructive localStorage/IndexedDB migrations |
+| `RouterService` | Dynamic route URL construction |
---
@@ -660,8 +673,17 @@ flowchart TB
```
- **IndexedDB**: Conversations and messages (large, structured data)
-- **LocalStorage**: Settings, user parameter overrides, theme (small key-value data)
-- **Memory only**: Server props, model list (fetched fresh on each session)
+- **LocalStorage**: Settings, user parameter overrides, theme, model favorites, recent picks, hidden models and model list open state (small key-value data)
+- **Memory only**: Server props, model list (fetched fresh on each session), Hugging Face Hub details (cached per repo for the session)
+
+### 9. Remote Assets Under Cross-Origin Isolation
+
+llama-server serves the UI with `Cross-Origin-Embedder-Policy: require-corp` and `Cross-Origin-Opener-Policy: same-origin`, so a cross-origin subresource has to be allowed by the target origin. The UI handles that per asset kind:
+
+- **Hub avatars** are requested in CORS mode (`crossorigin="anonymous"`), which the Hub allows
+- **Hub metadata** is fetched with `fetch()`, which is a CORS request by default, and only while the Hub setting is on
+- **MCP server icons and web-search result favicons** have no CORS guarantee, so they go through the server's `/cors-proxy` when the user started llama-server with `--ui-mcp-proxy`; without it they fall back to the bundled glyph
+- **Per-row Hub lookups** wait until the row is near the viewport (the `nearViewport` action), so a long model list does not fire one request per row on mount
---
diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json
index 86d599e67..1c0e4ec98 100644
--- a/tools/ui/package-lock.json
+++ b/tools/ui/package-lock.json
@@ -12,7 +12,7 @@
"@eslint/compat": "1.4.1",
"@eslint/js": "9.39.2",
"@internationalized/date": "3.12.2",
- "@lucide/svelte": "1.25.0",
+ "@lucide/svelte": "1.46.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6",
@@ -79,6 +79,7 @@
"unified": "11.0.5",
"unist-util-visit": "5.1.0",
"uuid": "13.0.2",
+ "vaul-svelte": "1.0.0-next.7",
"vite": "7.3.6",
"vite-plugin-devtools-json": "0.2.1",
"vitest": "4.1.10",
@@ -3225,9 +3226,9 @@
}
},
"node_modules/@lucide/svelte": {
- "version": "1.25.0",
- "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
- "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
+ "version": "1.46.0",
+ "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.46.0.tgz",
+ "integrity": "sha512-Ru8jDjoCVLJ7J7pPRUdY+odSBV2BI6rj68JTmT+ENq/ZwYOPGPAiKVvKz+XLyAoK4HAwlRTzZHpsMvrHfsetqg==",
"dev": true,
"license": "ISC",
"peerDependencies": {
@@ -16998,6 +16999,61 @@
"node": ">= 0.8"
}
},
+ "node_modules/vaul-svelte": {
+ "version": "1.0.0-next.7",
+ "resolved": "https://registry.npmjs.org/vaul-svelte/-/vaul-svelte-1.0.0-next.7.tgz",
+ "integrity": "sha512-7zN7Bi3dFQixvvbUJY9uGDe7Ws/dGZeBQR2pXdXmzQiakjrxBvWo0QrmsX3HK+VH+SZOltz378cmgmCS9f9rSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "runed": "^0.23.2",
+ "svelte-toolbelt": "^0.7.1"
+ },
+ "engines": {
+ "node": ">=18",
+ "pnpm": ">=8.7.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ }
+ },
+ "node_modules/vaul-svelte/node_modules/runed": {
+ "version": "0.23.4",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
+ "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "dependencies": {
+ "esm-env": "^1.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.7.0"
+ }
+ },
+ "node_modules/vaul-svelte/node_modules/svelte-toolbelt": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
+ "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte"
+ ],
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "runed": "^0.23.2",
+ "style-to-object": "^1.0.8"
+ },
+ "engines": {
+ "node": ">=18",
+ "pnpm": ">=8.7.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ }
+ },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
diff --git a/tools/ui/package.json b/tools/ui/package.json
index 401760474..7b15f3343 100644
--- a/tools/ui/package.json
+++ b/tools/ui/package.json
@@ -32,7 +32,7 @@
"@eslint/compat": "1.4.1",
"@eslint/js": "9.39.2",
"@internationalized/date": "3.12.2",
- "@lucide/svelte": "1.25.0",
+ "@lucide/svelte": "1.46.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6",
@@ -99,6 +99,7 @@
"unified": "11.0.5",
"unist-util-visit": "5.1.0",
"uuid": "13.0.2",
+ "vaul-svelte": "1.0.0-next.7",
"vite": "7.3.6",
"vite-plugin-devtools-json": "0.2.1",
"vitest": "4.1.10",
diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index f9b544beb..ccf13fbec 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -6,6 +6,33 @@
@custom-variant dark (&:is(.dark *));
+/* shadcn components key their orientation styles off these, toggle-group among them */
+@custom-variant data-horizontal (&[data-orientation='horizontal']);
+@custom-variant data-vertical (&[data-orientation='vertical']);
+
+/*
+ * Tailwind v4 dropped the pointer cursor from buttons. Every control a user can
+ * press gets it back, and anything disabled keeps the cursor its own class asks for.
+ */
+@layer base {
+ button:not(:disabled),
+ [role='button']:not([data-disabled]),
+ [role='checkbox']:not([data-disabled]),
+ [role='combobox']:not([data-disabled]),
+ [role='menuitem']:not([data-disabled]),
+ [role='menuitemcheckbox']:not([data-disabled]),
+ [role='menuitemradio']:not([data-disabled]),
+ [role='option']:not([data-disabled]),
+ [role='radio']:not([data-disabled]),
+ [role='switch']:not([data-disabled]),
+ [role='tab']:not([data-disabled]),
+ label[for],
+ select,
+ summary {
+ cursor: pointer;
+ }
+}
+
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
@@ -140,7 +167,7 @@
}
body {
- @apply bg-background text-foreground;
+ @apply bg-background text-foreground antialiased;
scrollbar-width: thin;
scrollbar-gutter: stable;
}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
index 893f8077d..397ddc322 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -8,8 +8,7 @@
ChatFormInputFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
- DialogMcpResourcesBrowser,
- DialogMcpServers
+ DialogMcpResourcesBrowser
} from '$lib/components/app';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
@@ -34,7 +33,8 @@
modelsStore,
serverStore,
settingsStore,
- toolsStore
+ toolsStore,
+ uiStore
} from '$lib/stores';
import type {
FileMentionEntry,
@@ -193,7 +193,6 @@
let preSelectedResourceUri = $state<string | undefined>(undefined);
// MCP Servers Dialog State
- let isMcpServersDialogOpen = $state(false);
let currentConfig = $derived(settingsStore.config);
@@ -478,6 +477,19 @@
queueMicrotask(() => inputRef?.focus());
}
+ // A flow outside the chat, e.g. the models manager closing onto it, asks for the
+ // composer back. The dialog's focus scope is still tearing down, so focus on a
+ // frame of its own.
+ $effect(() => {
+ if (uiStore.manageModelsOpen) return;
+
+ if (!uiStore.composerFocusRequested) return;
+
+ uiStore.composerFocusRequested = false;
+
+ setTimeout(() => inputRef?.focus(), 0);
+ });
+
// Splice the mention link in place of the `@<query>` token. Uses the
// live cursor, not a stale snapshot - the token may have been edited.
function handleMentionSelect(entry: FileMentionEntry) {
@@ -628,7 +640,6 @@
isReasoning={chatStore.isReasoning}
{isRecording}
onFileUpload={handleFileUpload}
- onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
@@ -667,5 +678,3 @@
}}
preSelectedUri={preSelectedResourceUri}
/>
-
-<DialogMcpServers bind:open={isMcpServersDialogOpen} />
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte
index 60e7bd1d4..443046615 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte
@@ -15,11 +15,11 @@
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
- class="file-upload-button md:h-8 md:w-8 h-9 w-9 rounded-full p-0"
+ class="file-upload-button bg-background h-9 w-9 rounded-full p-0 shadow-sm md:h-8 md:w-8 dark:bg-muted-foreground/15"
{disabled}
{onclick}
type="button"
- variant="secondary"
+ variant="ghost"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
new file mode 100644
index 000000000..16a5880a0
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDrawer.svelte
@@ -0,0 +1,206 @@
+<script lang="ts">
+ import {
+ ChevronDown,
+ ChevronRight,
+ File,
+ Image,
+ MessageSquare,
+ Mic,
+ PencilRuler,
+ Video
+ } from '@lucide/svelte';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import * as Collapsible from '$lib/components/ui/collapsible';
+ import * as Drawer from '$lib/components/ui/drawer';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import {
+ ATTACHMENT_FILE_ITEMS,
+ ATTACHMENT_MENU_TEXT,
+ ATTACHMENT_MODALITY_TEXT,
+ ICON_CLASS_DEFAULT
+ } from '$lib/constants';
+ import { getChatFormActionsContext } from '$lib/contexts';
+ import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums/attachment.enums';
+ import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
+ import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
+ import type { ToolGroup } from '$lib/types';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ class?: string;
+ trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>;
+ }
+
+ let { class: className = '', trigger }: Props = $props();
+
+ const chatFormActions = getChatFormActionsContext();
+
+ let drawerOpen = $state(false);
+ let toolsExpanded = $state(false);
+
+ const attachmentMenu = useAttachmentMenu(
+ () => ({
+ hasAudioModality: chatFormActions.hasAudioModality,
+ hasVideoModality: chatFormActions.hasVideoModality,
+ hasVisionModality: chatFormActions.hasVisionModality
+ }),
+ () => ({
+ onFileUpload: chatFormActions.onFileUpload,
+ onSystemPromptClick: chatFormActions.onSystemPromptClick
+ }),
+ () => {
+ drawerOpen = false;
+ }
+ );
+
+ const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
+ [AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: {
+ icon: Mic,
+ label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]
+ },
+ [AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: {
+ icon: Video,
+ label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]
+ },
+ [AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: {
+ icon: Image,
+ label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]
+ }
+ };
+
+ const supportedModalities = $derived.by(() =>
+ ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
+ .map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
+ .filter((modality) => modality !== undefined)
+ );
+
+ const toolsPanel = useToolsPanel();
+
+ const itemClass =
+ 'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
+
+ const itemRowClass =
+ 'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <Drawer.Root bind:open={drawerOpen}>
+ {@render trigger({ disabled: chatFormActions.disabled, onclick: () => (drawerOpen = true) })}
+
+ <Drawer.Content class="gap-0 overflow-y-auto">
+ <Drawer.Header>
+ <Drawer.Title>{ATTACHMENT_MENU_TEXT.ADD_TO_CHAT}</Drawer.Title>
+
+ <Drawer.Description class="sr-only">
+ {ATTACHMENT_MENU_TEXT.DESCRIPTION}
+ </Drawer.Description>
+ </Drawer.Header>
+
+ <div class="flex flex-col gap-1 px-1.5 pb-2">
+ <button
+ class={itemClass}
+ onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
+ type="button"
+ >
+ <File class="{ICON_CLASS_DEFAULT} shrink-0" />
+
+ <span class="flex min-w-0 items-center gap-2">
+ <span>{ATTACHMENT_MENU_TEXT.ADD_FILES}</span>
+
+ {#if supportedModalities.length > 0}
+ <span class="flex items-center gap-0.75 text-muted-foreground">
+ {#each supportedModalities as modality (modality.label)}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <modality.icon class="size-2.75" />
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{modality.label}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/each}
+ </span>
+ {/if}
+ </span>
+ </button>
+
+ <button
+ class={itemClass}
+ onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
+ type="button"
+ >
+ <MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
+
+ <span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
+ </button>
+
+ {#if toolsPanel.totalToolCount > 0}
+ <Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
+ <Collapsible.Trigger class={itemClass}>
+ {#if toolsExpanded}
+ <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
+ {:else}
+ <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
+ {/if}
+
+ <PencilRuler class="inline {ICON_CLASS_DEFAULT} shrink-0" />
+
+ <span class="flex-1">{ATTACHMENT_MENU_TEXT.TOOLS}</span>
+ </Collapsible.Trigger>
+
+ <Collapsible.Content>
+ <div class="flex flex-col gap-0.5 pl-4">
+ {#each toolsPanel.categoryGroups as group (group.key)}
+ {@render groupRow(group)}
+ {/each}
+
+ {#each toolsPanel.mcpGroups as group (group.key)}
+ {@render groupRow(group)}
+ {/each}
+ </div>
+ </Collapsible.Content>
+ </Collapsible.Root>
+ {/if}
+ </div>
+ </Drawer.Content>
+ </Drawer.Root>
+</div>
+
+{#snippet groupRow(group: ToolGroup)}
+ {@const checkState = toolsPanel.getGroupCheckState(group)}
+ {@const enabledCount = toolsPanel.getEnabledToolCount(group)}
+ {@const favicon = toolsPanel.getFavicon(group)}
+ {@const groupDisabled = toolsPanel.isGroupDisabled(group)}
+
+ <button
+ class="{itemRowClass} {groupDisabled ? 'pointer-events-none opacity-50' : ''}"
+ onclick={() => toolsPanel.toggleGroupByKey(group.key)}
+ type="button"
+ >
+ {#if favicon}
+ <img
+ alt=""
+ class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
+ onerror={(e) => {
+ (e.currentTarget as HTMLImageElement).style.display = 'none';
+ }}
+ src={favicon}
+ />
+ {/if}
+
+ <span class="min-w-0 flex-1 truncate text-sm font-medium">{group.label}</span>
+
+ <span class="shrink-0 text-xs text-muted-foreground">
+ {enabledCount}/{group.tools.length}
+ </span>
+
+ <Checkbox
+ checked={checkState.checked}
+ class="{ICON_CLASS_DEFAULT} shrink-0"
+ indeterminate={checkState.indeterminate}
+ onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
+ onclick={(e) => e.stopPropagation()}
+ />
+ </button>
+{/snippet}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
index 1ce731845..2cae6e8ca 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
@@ -1,23 +1,20 @@
<script lang="ts">
import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
- import {
- ChatFormActionAddReasoningSubmenu,
- ChatFormActionAddToolsSubmenu,
- McpLogo
- } from '$lib/components/app';
+ import { ChatFormActionAddToolsSubmenu } from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
import {
ATTACHMENT_FILE_ITEMS,
+ ATTACHMENT_MENU_TEXT,
+ ATTACHMENT_MODALITY_TEXT,
ATTACHMENT_TOOLTIP_TEXT,
ICON_CLASS_DEFAULT
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
- import { serverStore } from '$lib/stores';
interface Props {
class?: string;
@@ -48,9 +45,18 @@
);
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
- [AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: { icon: Mic, label: 'Audio' },
- [AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: { icon: Video, label: 'Video' },
- [AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: { icon: Image, label: 'Vision' }
+ [AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: {
+ icon: Mic,
+ label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]
+ },
+ [AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: {
+ icon: Video,
+ label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]
+ },
+ [AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: {
+ icon: Image,
+ label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]
+ }
};
const supportedModalities = $derived.by(() =>
@@ -70,8 +76,9 @@
<DropdownMenu.Trigger
{...props}
class={cn(
- buttonVariants({ variant: 'secondary' }),
- 'file-upload-button h-8 w-8 cursor-pointer rounded-full p-0'
+ // ghost brings no fill of its own for the muted composer surface to fight over
+ buttonVariants({ variant: 'ghost' }),
+ 'file-upload-button h-8 w-8 cursor-pointer rounded-full bg-background p-0 shadow-sm dark:bg-muted-foreground/15'
)}
disabled={chatFormActions.disabled}
>
@@ -97,13 +104,6 @@
}
}}
>
- <!-- in router mode the models selector owns the reasoning submenu -->
- {#if !serverStore.isRouterMode}
- <ChatFormActionAddReasoningSubmenu />
-
- <DropdownMenu.Separator />
- {/if}
-
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
@@ -111,7 +111,7 @@
<File class={ICON_CLASS_DEFAULT} />
<span class="flex min-w-0 items-center gap-2">
- <span>Add files</span>
+ <span>{ATTACHMENT_MENU_TEXT.ADD_FILES}</span>
{#if supportedModalities.length > 0}
<span class="flex items-center gap-0.75 text-muted-foreground">
@@ -140,19 +140,10 @@
>
<MessageSquare class={ICON_CLASS_DEFAULT} />
- <span>System Message</span>
+ <span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
</DropdownMenu.Item>
<ChatFormActionAddToolsSubmenu />
-
- <DropdownMenu.Item
- class="flex cursor-pointer items-center gap-2"
- onclick={chatFormActions.onMcpSettingsClick}
- >
- <McpLogo class={ICON_CLASS_DEFAULT} />
-
- <span>MCP Servers</span>
- </DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte
deleted file mode 100644
index 197d6c2e5..000000000
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte
+++ /dev/null
@@ -1,75 +0,0 @@
-<script lang="ts">
- import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
-
- const reasoning = useReasoningMenu();
-</script>
-
-<DropdownMenu.Sub>
- <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
- {#if reasoning.isReasoningActive}
- <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
- {:else if reasoning.isOff}
- <LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
- {:else}
- <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
- {/if}
-
- <span
- class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
- ? 'text-muted-foreground'
- : ''}"
- >
- Reasoning
-
- <span class="capitalize text-muted-foreground">
- {reasoning.currentEffort}
- </span>
- </span>
- </DropdownMenu.SubTrigger>
-
- <DropdownMenu.SubContent
- class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
- >
- {#each reasoning.levels as level (level.value)}
- {@const tokenLabel = reasoning.tokenLabel(level)}
- <DropdownMenu.Item
- class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
- level
- )
- ? 'bg-accent'
- : ''}"
- onclick={() => reasoning.select(level)}
- >
- {#if reasoning.isSelected(level)}
- <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
- {:else}
- <div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
- {/if}
-
- <span class="flex-1">{level.label}</span>
-
- {#if tokenLabel}
- <span class="text-[11px] text-muted-foreground opacity-60">
- {tokenLabel}
- </span>
- {/if}
-
- {#if level.hasInfo}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
- </Tooltip.Trigger>
-
- <Tooltip.Content side="left">
- <p>Maximum reasoning effort with extended context usage</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- </DropdownMenu.Item>
- {/each}
- </DropdownMenu.SubContent>
-</DropdownMenu.Sub>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
deleted file mode 100644
index acd0f4d21..000000000
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
+++ /dev/null
@@ -1,284 +0,0 @@
-<script lang="ts">
- import {
- Check,
- ChevronDown,
- ChevronRight,
- File,
- Lightbulb,
- LightbulbOff,
- MessageSquare,
- PencilRuler
- } from '@lucide/svelte';
- import { McpLogo } from '$lib/components/app';
- import { Checkbox } from '$lib/components/ui/checkbox';
- import * as Collapsible from '$lib/components/ui/collapsible';
- import * as Sheet from '$lib/components/ui/sheet';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import {
- ATTACHMENT_FILE_ITEMS,
- ICON_CLASS_DEFAULT,
- TOOLTIP_DELAY_DURATION
- } from '$lib/constants';
- import { getChatFormActionsContext } from '$lib/contexts';
- import { AttachmentAction } from '$lib/enums/attachment.enums';
- import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
- import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
- import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
- import type { ToolGroup } from '$lib/types';
- import type { Snippet } from 'svelte';
-
- interface Props {
- class?: string;
- trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>;
- }
-
- let { class: className = '', trigger }: Props = $props();
-
- const chatFormActions = getChatFormActionsContext();
-
- let sheetOpen = $state(false);
- let filesExpanded = $state(true);
- let reasoningExpanded = $state(false);
- let toolsExpanded = $state(false);
-
- const attachmentMenu = useAttachmentMenu(
- () => ({
- hasAudioModality: chatFormActions.hasAudioModality,
- hasVideoModality: chatFormActions.hasVideoModality,
- hasVisionModality: chatFormActions.hasVisionModality
- }),
- () => ({
- onFileUpload: chatFormActions.onFileUpload,
- onSystemPromptClick: chatFormActions.onSystemPromptClick
- }),
- () => {
- sheetOpen = false;
- }
- );
-
- const toolsPanel = useToolsPanel();
- const reasoning = useReasoningMenu();
-
- const sheetItemClass =
- 'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
-
- const sheetItemRowClass =
- 'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
-</script>
-
-<div class="flex items-center gap-1 {className}">
- <Sheet.Root bind:open={sheetOpen}>
- {@render trigger({ disabled: chatFormActions.disabled, onclick: () => (sheetOpen = true) })}
-
- <Sheet.Content class="max-h-[85vh] gap-0 overflow-y-auto" side="bottom">
- <Sheet.Header>
- <Sheet.Title>Add to chat</Sheet.Title>
-
- <Sheet.Description class="sr-only">
- Add files, system prompt or configure MCP servers
- </Sheet.Description>
- </Sheet.Header>
-
- <div class="flex flex-col gap-1 px-1.5 pb-2">
- {#if reasoning.modelSupportsThinking}
- <Collapsible.Root
- onOpenChange={(open) => (reasoningExpanded = open)}
- open={reasoningExpanded}
- >
- <Collapsible.Trigger class={sheetItemClass}>
- {#if reasoningExpanded}
- <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
- {:else}
- <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
- {/if}
-
- {#if reasoning.thinkingEnabled}
- <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
- {:else if reasoning.isOff}
- <LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
- {:else}
- <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
- {/if}
-
- <span class="flex-1">Reasoning</span>
-
- <span class="text-xs capitalize text-muted-foreground">
- {reasoning.currentEffort}
- </span>
- </Collapsible.Trigger>
-
- <Collapsible.Content>
- <div class="flex flex-col gap-0.5 pl-4">
- {#each reasoning.levels as level (level.value)}
- {@const tokenLabel = reasoning.tokenLabel(level)}
- <button
- class:bg-accent={reasoning.isSelected(level)}
- class={sheetItemRowClass}
- onclick={() => reasoning.select(level)}
- type="button"
- >
- <div class="flex min-w-0 items-center gap-3">
- {#if reasoning.isSelected(level)}
- <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
- {:else}
- <div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
- {/if}
-
- <span class="text-sm">{level.label}</span>
- </div>
-
- {#if tokenLabel}
- <span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
- {tokenLabel}
- </span>
- {/if}
- </button>
- {/each}
- </div>
- </Collapsible.Content>
- </Collapsible.Root>
- {/if}
-
- <Collapsible.Root onOpenChange={(open) => (filesExpanded = open)} open={filesExpanded}>
- <Collapsible.Trigger class={sheetItemClass}>
- {#if filesExpanded}
- <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
- {:else}
- <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
- {/if}
-
- <File class="{ICON_CLASS_DEFAULT} shrink-0" />
-
- <span class="flex-1">Add files</span>
- </Collapsible.Trigger>
-
- <Collapsible.Content>
- <div class="flex flex-col gap-0.5 pl-4">
- {#each ATTACHMENT_FILE_ITEMS as item (item.id)}
- {@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
- {#if enabled}
- <button
- class={sheetItemClass}
- onclick={() => attachmentMenu.callbacks[item.action]()}
- type="button"
- >
- <item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
-
- <span>{item.label}</span>
- </button>
- {:else if item.disabledTooltip}
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger>
- <button class={sheetItemClass} disabled type="button">
- <item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
-
- <span>{item.label}</span>
- </button>
- </Tooltip.Trigger>
-
- <Tooltip.Content side="right">
- <p>{item.disabledTooltip}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- {/each}
- </div>
- </Collapsible.Content>
- </Collapsible.Root>
-
- <button
- class={sheetItemClass}
- onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
- type="button"
- >
- <MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
-
- <span>System Message</span>
- </button>
-
- {#if toolsPanel.totalToolCount > 0}
- <Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
- <Collapsible.Trigger class={sheetItemClass}>
- {#if toolsExpanded}
- <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
- {:else}
- <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
- {/if}
-
- <PencilRuler class="inline {ICON_CLASS_DEFAULT} shrink-0" />
-
- <span class="flex-1">Tools</span>
-
- <span class="text-xs text-muted-foreground">
- {toolsPanel.totalToolCount} tool{toolsPanel.totalToolCount !== 1 ? 's' : ''}
- </span>
- </Collapsible.Trigger>
-
- <Collapsible.Content>
- <div class="flex flex-col gap-0.5 pl-4">
- {#each toolsPanel.categoryGroups as group (group.key)}
- {@render sheetGroupRow(group)}
- {/each}
-
- {#each toolsPanel.mcpGroups as group (group.key)}
- {@render sheetGroupRow(group)}
- {/each}
- </div>
- </Collapsible.Content>
- </Collapsible.Root>
- {/if}
-
- <button
- class={sheetItemClass}
- onclick={() => {
- sheetOpen = false;
- chatFormActions.onMcpSettingsClick?.();
- }}
- type="button"
- >
- <McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
-
- <span>MCP Servers</span>
- </button>
- </div>
- </Sheet.Content>
- </Sheet.Root>
-</div>
-
-{#snippet sheetGroupRow(group: ToolGroup)}
- {@const checkState = toolsPanel.getGroupCheckState(group)}
- {@const enabledCount = toolsPanel.getEnabledToolCount(group)}
- {@const favicon = toolsPanel.getFavicon(group)}
- {@const groupDisabled = toolsPanel.isGroupDisabled(group)}
-
- <button
- class="{sheetItemRowClass} {groupDisabled ? 'pointer-events-none opacity-50' : ''}"
- onclick={() => toolsPanel.toggleGroupByKey(group.key)}
- type="button"
- >
- {#if favicon}
- <img
- alt=""
- class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
- onerror={(e) => {
- (e.currentTarget as HTMLImageElement).style.display = 'none';
- }}
- src={favicon}
- />
- {/if}
-
- <span class="min-w-0 flex-1 truncate text-sm font-medium">{group.label}</span>
-
- <span class="shrink-0 text-xs text-muted-foreground">
- {enabledCount}/{group.tools.length}
- </span>
-
- <Checkbox
- checked={checkState.checked}
- class="{ICON_CLASS_DEFAULT} shrink-0"
- indeterminate={checkState.indeterminate}
- onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
- onclick={(e) => e.stopPropagation()}
- />
- </button>
-{/snippet}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
index f49544171..fa40339be 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
@@ -4,7 +4,7 @@
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
+ import { ATTACHMENT_MENU_TEXT, CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
@@ -17,7 +17,7 @@
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<PencilRuler class={ICON_CLASS_DEFAULT} />
- <span>Tools</span>
+ <span>{ATTACHMENT_MENU_TEXT.TOOLS}</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 p-0">
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
index b2581f11e..50eb9be1c 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
@@ -1,16 +1,16 @@
<script lang="ts">
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
+ import ChatFormActionAddDrawer from './ChatFormActionAddDrawer.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
- import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import { deviceStore } from '$lib/stores';
</script>
{#if deviceStore.isMobile}
- <ChatFormActionAddSheet>
+ <ChatFormActionAddDrawer>
{#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} />
{/snippet}
- </ChatFormActionAddSheet>
+ </ChatFormActionAddDrawer>
{:else}
<ChatFormActionAddDropdown />
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
deleted file mode 100644
index a4baa40bf..000000000
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
+++ /dev/null
@@ -1,170 +0,0 @@
-<script lang="ts">
- import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
- import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
- import { getConversationModel } from '$lib/utils';
-
- interface Props {
- disabled?: boolean;
- forceForegroundText?: boolean;
- hasAudioModality?: boolean;
- hasVideoModality?: boolean;
- hasVisionModality?: boolean;
- hasModelSelected?: boolean;
- isSelectedModelInCache?: boolean;
- submitTooltip?: string;
- useGlobalSelection?: boolean;
- }
-
- let {
- disabled = false,
- forceForegroundText = false,
- hasAudioModality = $bindable(false),
- hasModelSelected = $bindable(false),
- hasVideoModality = $bindable(false),
- hasVisionModality = $bindable(false),
- isSelectedModelInCache = $bindable(true),
- submitTooltip = $bindable(''),
- useGlobalSelection = false
- }: Props = $props();
-
- let isRouter = $derived(serverStore.isRouterMode);
- let isOffline = $derived(!!serverStore.error);
-
- let conversationModel = $derived(
- getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
- );
-
- let lastSyncedConversationModel: string | null = null;
-
- let selectorModel = $derived.by(() => {
- const storeModel = modelsStore.selectedModelName;
-
- if (storeModel && storeModel !== conversationModel) {
- return storeModel;
- }
-
- if (conversationModel) {
- return conversationModel;
- }
-
- return null;
- });
-
- $effect(() => {
- if (conversationModel && conversationModel !== lastSyncedConversationModel) {
- if (modelsStore.models.some((m) => m.model === conversationModel)) {
- modelsStore.selectedModelName = conversationModel;
- modelsStore.selectModelByName(conversationModel);
- } else {
- modelsStore.selectedModelName = null;
- modelsStore.clearSelection();
- }
-
- lastSyncedConversationModel = conversationModel;
- } else if (
- isRouter &&
- !modelsStore.selectedModelId &&
- modelsStore.loadedModelIds.length > 0 &&
- conversationsStore.activeMessages.length > 0 &&
- !conversationModel
- ) {
- lastSyncedConversationModel = null;
- const first = modelsStore.models.find((m) => modelsStore.loadedModelIds.includes(m.model));
-
- if (first) modelsStore.selectModelById(first.id);
- }
- });
-
- let activeModelId = $derived(modelsStore.activeModelId);
-
- let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
-
- $effect(() => {
- if (activeModelId) {
- const cached = modelsStore.props.getModelProps(activeModelId);
-
- if (!cached) {
- modelsStore.props.fetchModelProps(activeModelId).then(() => {
- modelPropsVersion++;
- });
- }
- }
- });
-
- $effect(() => {
- void modelPropsVersion;
-
- hasAudioModality = activeModelId ? modelsStore.props.modelSupportsAudio(activeModelId) : false;
- });
-
- $effect(() => {
- void modelPropsVersion;
-
- hasVideoModality = activeModelId ? modelsStore.props.modelSupportsVideo(activeModelId) : false;
- });
-
- $effect(() => {
- void modelPropsVersion;
-
- hasVisionModality = activeModelId
- ? modelsStore.props.modelSupportsVision(activeModelId)
- : false;
- });
-
- $effect(() => {
- hasModelSelected = !isRouter || !!conversationModel || !!modelsStore.selectedModelId;
- });
-
- $effect(() => {
- if (!isRouter) {
- isSelectedModelInCache = true;
- } else if (conversationModel) {
- isSelectedModelInCache = modelsStore.models.some(
- (option) => option.model === conversationModel
- );
- } else {
- const currentModelId = modelsStore.selectedModelId;
-
- if (!currentModelId) {
- isSelectedModelInCache = false;
- } else {
- isSelectedModelInCache = modelsStore.models.some((option) => option.id === currentModelId);
- }
- }
- });
-
- $effect(() => {
- if (!hasModelSelected) {
- submitTooltip = 'Please select a model first';
- } else if (!isSelectedModelInCache) {
- submitTooltip = 'Selected model is not available, please select another';
- } else {
- submitTooltip = '';
- }
- });
-
- let selectorModelRef: ModelsSelectorDropdown | ModelsSelectorSheet | undefined =
- $state(undefined);
-
- export function open() {
- selectorModelRef?.open();
- }
-</script>
-
-{#if deviceStore.isMobile}
- <ModelsSelectorSheet
- bind:this={selectorModelRef}
- currentModel={selectorModel}
- disabled={disabled || isOffline}
- {forceForegroundText}
- {useGlobalSelection}
- />
-{:else}
- <ModelsSelectorDropdown
- bind:this={selectorModelRef}
- currentModel={selectorModel}
- disabled={disabled || isOffline}
- {forceForegroundText}
- {useGlobalSelection}
- />
-{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte
new file mode 100644
index 000000000..29de5ec26
--- /dev/null
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte
@@ -0,0 +1,147 @@
+<script lang="ts">
+ import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Drawer from '$lib/components/ui/drawer';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants';
+ import { ReasoningEffort } from '$lib/enums';
+ import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
+ import { deviceStore } from '$lib/stores';
+ import type { ReasoningEffortLevel } from '$lib/types';
+
+ const reasoning = useReasoningMenu();
+
+ let isOpen = $state(false);
+
+ // "Default" is the resting state, where the bulb alone carries the meaning
+ let isDefault = $derived(reasoning.currentEffort === ReasoningEffort.DEFAULT);
+ // a phone picks the level in a drawer, where a thumb reaches it
+ let isMobile = $derived(deviceStore.isMobile);
+
+ const MAX_EFFORT_HINT = 'Maximum reasoning effort with extended context usage';
+
+ function select(level: ReasoningEffortLevel): void {
+ reasoning.select(level);
+ isOpen = false;
+ }
+</script>
+
+{#snippet triggerLabel()}
+ <span class="flex items-center gap-0.75 {reasoning.isOff ? 'text-muted-foreground' : ''}">
+ {#if reasoning.isOff}
+ <LightbulbOff class="size-3 max-md:size-4 shrink-0" />
+ {:else}
+ <Lightbulb class="size-3 max-md:size-4 shrink-0" />
+ {/if}
+
+ {#if !isDefault}
+ <span class="capitalize">{reasoning.currentEffort}</span>
+ {/if}
+ </span>
+{/snippet}
+
+{#snippet levelRow(level: ReasoningEffortLevel)}
+ {@const tokenLabel = reasoning.tokenLabel(level)}
+
+ <button
+ aria-pressed={reasoning.isSelected(level)}
+ class="flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-sm transition-colors hover:bg-accent"
+ onclick={() => select(level)}
+ type="button"
+ >
+ {#if reasoning.isSelected(level)}
+ <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
+ {:else}
+ <div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
+ {/if}
+
+ <span class="min-w-0 flex-1 truncate">{level.label}</span>
+
+ {#if tokenLabel}
+ <span class="shrink-0 text-[11px] text-muted-foreground opacity-60">{tokenLabel}</span>
+ {/if}
+
+ {#if level.hasInfo}
+ <!-- no hover on a phone: the hint rides on the icon's own title there -->
+ <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" title={MAX_EFFORT_HINT} />
+ {/if}
+ </button>
+{/snippet}
+
+<!-- the level belongs to the chat, not to the model list -->
+{#if isMobile}
+ <Button
+ aria-label="Reasoning effort"
+ class="h-auto gap-1 rounded-sm bg-transparent! backdrop-blur-none! {isDefault
+ ? 'px-1'
+ : 'px-1.75!'} py-1 text-xs max-md:h-8"
+ onclick={() => (isOpen = true)}
+ variant="ghost"
+ >
+ {@render triggerLabel()}
+ </Button>
+
+ <Drawer.Root bind:open={isOpen}>
+ <Drawer.Content>
+ <Drawer.Header>
+ <Drawer.Title>Reasoning effort</Drawer.Title>
+ </Drawer.Header>
+
+ <div class="flex flex-col px-2 pb-4">
+ {#each reasoning.levels as level (level.value)}
+ {@render levelRow(level)}
+ {/each}
+ </div>
+ </Drawer.Content>
+ </Drawer.Root>
+{:else}
+ <DropdownMenu.Root>
+ <DropdownMenu.Trigger>
+ {#snippet child({ props })}
+ <Button
+ {...props}
+ aria-label="Reasoning effort"
+ class="h-auto gap-1 rounded-sm bg-transparent! backdrop-blur-none! {isDefault
+ ? 'px-1'
+ : 'px-1.75!'} py-1 text-xs hover:bg-transparent!"
+ variant="ghost"
+ >
+ {@render triggerLabel()}
+ </Button>
+ {/snippet}
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content align="end" class="min-w-56">
+ {#each reasoning.levels as level (level.value)}
+ {@const tokenLabel = reasoning.tokenLabel(level)}
+
+ <DropdownMenu.Item class="gap-3" onSelect={() => reasoning.select(level)}>
+ {#if reasoning.isSelected(level)}
+ <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
+ {:else}
+ <div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
+ {/if}
+
+ <span class="min-w-0 flex-1 truncate">{level.label}</span>
+
+ {#if tokenLabel}
+ <span class="shrink-0 text-[11px] text-muted-foreground opacity-60">{tokenLabel}</span>
+ {/if}
+
+ {#if level.hasInfo}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>{MAX_EFFORT_HINT}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ </DropdownMenu.Item>
+ {/each}
+ </DropdownMenu.Content>
+ </DropdownMenu.Root>
+{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
index d1dd3fe46..7c305acbe 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
@@ -27,7 +27,7 @@
<Tooltip.Root>
<Tooltip.Trigger>
<Button
- class="h-8 w-8 rounded-full p-0 {isRecording
+ class="h-8 w-8 max-md:h-9 max-md:w-9 rounded-full p-0 {isRecording
? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
: ''}"
disabled={disabled || isLoading || !hasAudioModality}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
index 395f2cfbe..d78925141 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
@@ -2,16 +2,18 @@
import { SkipForward, Square } from '@lucide/svelte';
import { page } from '$app/state';
import {
- ChatFormActionModels,
+ ChatFormActionReasoning,
ChatFormActionRecord,
ChatFormActionsAdd,
ChatFormActionSubmit,
- ChatFormContextGauge
+ ChatFormContextGauge,
+ ModelsSelector
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums';
+ import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
import { ChatService } from '$lib/services';
import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
import { getFileTypeCategory } from '$lib/utils';
@@ -31,7 +33,6 @@
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
- onMcpSettingsClick?: () => void;
}
let {
@@ -43,7 +44,6 @@
isReasoning = false,
isRecording = false,
onFileUpload,
- onMcpSettingsClick,
onMicClick,
onStop,
onSystemPromptClick,
@@ -54,12 +54,20 @@
let currentConfig = $derived(settingsStore.config);
- let hasAudioModality = $state(false);
- let hasVideoModality = $state(false);
- let hasVisionModality = $state(false);
- let hasModelSelected = $state(false);
- let isSelectedModelInCache = $state(true);
- let submitTooltip = $state('');
+ const formModel = useChatFormModel();
+
+ let hasAudioModality = $derived(formModel.hasAudioModality);
+ let hasVideoModality = $derived(formModel.hasVideoModality);
+ let hasVisionModality = $derived(formModel.hasVisionModality);
+ let hasModelSelected = $derived(formModel.hasModelSelected);
+ let isSelectedModelInCache = $derived(formModel.isSelectedModelInCache);
+ let submitTooltip = $derived.by(() => {
+ if (!hasModelSelected) return 'Please select a model first';
+
+ if (!isSelectedModelInCache) return 'Selected model is not available, please select another';
+
+ return '';
+ });
let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
@@ -68,7 +76,7 @@
hasAudioModality && !canSubmit && !hasAudioAttachments && currentConfig.autoMicOnEmpty
);
- let selectorModelRef: ChatFormActionModels | undefined = $state(undefined);
+ let selectorModelRef: ModelsSelector | undefined = $state(undefined);
export function openModelSelector() {
selectorModelRef?.open();
@@ -135,9 +143,6 @@
get onFileUpload() {
return onFileUpload;
},
- get onMcpSettingsClick() {
- return onMcpSettingsClick;
- },
get onSystemPromptClick() {
return onSystemPromptClick;
}
@@ -160,13 +165,9 @@
{/if}
{#if showModelSelector}
- <ChatFormActionModels
- bind:hasAudioModality
- bind:hasModelSelected
- bind:hasVideoModality
- bind:hasVisionModality
- bind:isSelectedModelInCache
- bind:submitTooltip
+ <ChatFormActionReasoning />
+
+ <ModelsSelector
bind:this={selectorModelRef}
{disabled}
forceForegroundText
@@ -177,7 +178,7 @@
{#if isReasoning}
<Button
- class="group h-8 w-8 rounded-full p-0"
+ class="group h-8 w-8 rounded-full p-0 max-md:h-9 max-md:w-9"
onclick={() =>
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
title="Skip reasoning"
@@ -194,7 +195,7 @@
{#if isLoading && !canSubmit}
<Button
- class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
+ class="group h-8 w-8 rounded-full p-0 max-md:h-9 max-md:w-9 hover:bg-destructive/10!"
onclick={onStop}
type="button"
variant="secondary"
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
index d3bf0446c..cb3d07e11 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
@@ -8,7 +8,7 @@
gaugeTriggerPointerDown
} from './gauge-popup.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
- import { chatStore, conversationsStore } from '$lib/stores';
+ import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import { untrack } from 'svelte';
const gauge = useContextGauge();
@@ -43,7 +43,7 @@
<div
aria-label="Context usage"
- class="flex h-5 w-5 cursor-default items-center justify-center"
+ class="flex h-5 w-5 cursor-default items-center justify-center max-md:h-6 max-md:w-6"
data-context-gauge-trigger
onclick={gaugeTriggerClick}
onkeydown={gaugeTriggerKeydown}
@@ -53,5 +53,10 @@
role="button"
tabindex="0"
>
- <ContextGaugeDial level={gauge.colorLevel} percent={gauge.contextPercent} />
+ <!-- a phone gets the larger dial: the trigger is a touch target there -->
+ <ContextGaugeDial
+ level={gauge.colorLevel}
+ percent={gauge.contextPercent}
+ size={deviceStore.isMobile ? 'md' : 'sm'}
+ />
</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte
index 572d4a42d..620bd874e 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte
@@ -16,6 +16,8 @@
</div>
{#if subtitle}
- <div class="text-[10px] leading-tight text-muted-foreground/70">{subtitle}</div>
+ <div class="text-[10px] leading-tight text-muted-foreground/70 max-md:text-[11px]">
+ {subtitle}
+ </div>
{/if}
</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte
index 0de508a61..34cf3d0bc 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte
@@ -1,8 +1,5 @@
<script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
- import { gaugePopup } from './gauge-popup.svelte';
- import { ChevronDown } from '@lucide/svelte';
- import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants';
interface Props {
@@ -35,21 +32,15 @@
const hasCurrent = $derived(currentRead > 0 || currentOutput > 0);
</script>
-<Collapsible.Root bind:open={gaugePopup.detailsOpen} class="mt-3 border-t border-border/50 pt-4">
- <Collapsible.Trigger
- class="flex w-full cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
- >
- <span>Token usage details</span>
+<div class="mt-3 border-t border-border/50 pt-4">
+ <p class="text-xs text-muted-foreground max-md:text-[13px]">Token usage details</p>
- <ChevronDown
- class={'ml-auto h-3 w-3 transition-transform' + (gaugePopup.detailsOpen ? ' rotate-180' : '')}
- />
- </Collapsible.Trigger>
-
- <Collapsible.Content class="flex flex-col gap-4 text-xs pt-4">
+ <div class="flex flex-col gap-4 pt-4 text-xs max-md:gap-5 max-md:text-[13px]">
{#if hasCumulative}
<div>
- <h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
+ <h3
+ class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2 max-md:text-xs"
+ >
Across all turns
</h3>
@@ -76,7 +67,9 @@
{#if hasCurrent}
<div>
- <h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
+ <h3
+ class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2 max-md:text-xs"
+ >
This turn · KV cache
</h3>
@@ -121,5 +114,5 @@
{#each transientDetails as detail (detail)}
<div class="font-mono text-muted-foreground">{detail}</div>
{/each}
- </Collapsible.Content>
-</Collapsible.Root>
+ </div>
+</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
index 32d08323d..ca520862d 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
@@ -10,8 +10,9 @@
let { level, percent, size = 'sm' }: Props = $props();
- const RADIUS = 11;
- const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
+ // the md dial fills its box: the ring would sit inside a padding ring otherwise
+ const RADIUS = $derived(size === 'md' ? 13 : 11);
+ const CIRCUMFERENCE = $derived(2 * Math.PI * RADIUS);
const strokeLevelClass = $derived(colorLevelTextClass(level));
const dimensions = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte
index 8fa09cf70..de3d2f4ea 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte
@@ -8,7 +8,9 @@
gaugePopup,
gaugePopupClose
} from './gauge-popup.svelte';
+ import * as Drawer from '$lib/components/ui/drawer';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
+ import { deviceStore } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
@@ -24,9 +26,10 @@
// Any press outside the card and outside the dial closes the card.
// Presses on the dial are excluded because the dial handles its own
- // toggle; the listener only exists while the card is open.
+ // toggle; the listener only exists while the card is open. A phone shows
+ // the panel in a drawer instead, which brings its own dismissal.
$effect(() => {
- if (!gaugePopup.open) return;
+ if (!gaugePopup.open || deviceStore.isMobile) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
@@ -52,7 +55,78 @@
);
</script>
-{#if gaugePopup.open}
+{#snippet usage()}
+ <span class="font-mono text-muted-foreground">
+ {formatParameters(gauge.contextUsed)}
+ / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
+ </span>
+{/snippet}
+
+{#snippet body()}
+ {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
+ <ContextGaugeLoadModel
+ isLoading={gauge.isActiveModelLoading}
+ modelId={gauge.activeModelId}
+ onLoad={gauge.loadModel}
+ />
+ {:else if showProgressBar}
+ <div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
+ <div
+ class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
+ gauge.colorLevel
+ )}"
+ style="width: {gauge.contextPercent}%"
+ ></div>
+ </div>
+
+ <div class="flex justify-between text-xs text-muted-foreground max-md:text-[13px]">
+ <span>
+ <span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
+ </span>
+
+ <span>
+ {formatParameters(gauge.contextAvailable ?? 0)} remaining
+ </span>
+ </div>
+ {:else}
+ <div class="text-xs text-muted-foreground">No context info available</div>
+ {/if}
+
+ {#if gauge.hasAnyUsage}
+ <ContextGaugeDetails
+ averageTokensPerSecond={gauge.averageTokensPerSecond}
+ cumulativeCacheTotal={gauge.cumulativeCacheTotal}
+ cumulativeOutput={gauge.cumulativeOutput}
+ cumulativeRead={gauge.cumulativeRead}
+ currentCache={gauge.currentCache}
+ currentFresh={gauge.currentFresh}
+ currentOutput={gauge.currentOutput}
+ currentRead={gauge.currentRead}
+ kvTotal={gauge.kvTotal}
+ transientDetails={gauge.transientDetails}
+ />
+ {/if}
+{/snippet}
+
+{#if deviceStore.isMobile}
+ <Drawer.Root bind:open={gaugePopup.open}>
+ <Drawer.Content>
+ <Drawer.Header>
+ <Drawer.Title>Context</Drawer.Title>
+
+ <Drawer.Description class="sr-only">
+ Context window usage of the model the chat runs
+ </Drawer.Description>
+ </Drawer.Header>
+
+ <div class="flex flex-col gap-2 px-4 pb-4 max-md:gap-3.5">
+ {@render usage()}
+
+ {@render body()}
+ </div>
+ </Drawer.Content>
+ </Drawer.Root>
+{:else if gaugePopup.open}
<div
bind:this={cardEl}
class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
@@ -67,55 +141,10 @@
<span class="text-muted-foreground">·</span>
- <span class="font-mono text-muted-foreground">
- {formatParameters(gauge.contextUsed)}
- / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
- </span>
+ {@render usage()}
</div>
- {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
- <ContextGaugeLoadModel
- isLoading={gauge.isActiveModelLoading}
- modelId={gauge.activeModelId}
- onLoad={gauge.loadModel}
- />
- {:else if showProgressBar}
- <div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
- <div
- class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
- gauge.colorLevel
- )}"
- style="width: {gauge.contextPercent}%"
- ></div>
- </div>
-
- <div class="flex justify-between text-xs text-muted-foreground">
- <span>
- <span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
- </span>
-
- <span>
- {formatParameters(gauge.contextAvailable ?? 0)} remaining
- </span>
- </div>
- {:else}
- <div class="text-xs text-muted-foreground">No context info available</div>
- {/if}
-
- {#if gauge.hasAnyUsage}
- <ContextGaugeDetails
- averageTokensPerSecond={gauge.averageTokensPerSecond}
- cumulativeCacheTotal={gauge.cumulativeCacheTotal}
- cumulativeOutput={gauge.cumulativeOutput}
- cumulativeRead={gauge.cumulativeRead}
- currentCache={gauge.currentCache}
- currentFresh={gauge.currentFresh}
- currentOutput={gauge.currentOutput}
- currentRead={gauge.currentRead}
- kvTotal={gauge.kvTotal}
- transientDetails={gauge.transientDetails}
- />
- {/if}
+ {@render body()}
</div>
</div>
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/gauge-popup.svelte.ts b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/gauge-popup.svelte.ts
index 34f25283f..654dc9f77 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/gauge-popup.svelte.ts
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/gauge-popup.svelte.ts
@@ -16,7 +16,7 @@ import {
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let lastPointerType = '';
-export const gaugePopup = $state({ bottom: 0, centerX: 0, detailsOpen: false, open: false });
+export const gaugePopup = $state({ bottom: 0, centerX: 0, open: false });
function openFrom(trigger: HTMLElement): void {
clearTimeout(closeTimer);
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte
index dec9e5b1c..e3ff9d780 100644
--- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte
@@ -1,12 +1,10 @@
<script lang="ts">
- import { FolderOpen, Sparkles } from '@lucide/svelte';
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
- import { MODEL_SELECTOR_ICON } from '$lib/constants';
- import { ChatFormCommandAction } from '$lib/enums';
+ import { CHAT_FORM_COMMAND_ICONS } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { ChatFormCommand } from '$lib/types';
@@ -26,12 +24,6 @@
let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
- const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
- [ChatFormCommandAction.CWD]: FolderOpen,
- [ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
- [ChatFormCommandAction.PROMPT]: Sparkles
- };
-
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
const filteredCommands = $derived(
@@ -119,7 +111,7 @@
showSearchInput={false}
>
{#snippet item(command, index, isSelected)}
- {@const Icon = commandIcon[command.action]}
+ {@const Icon = CHAT_FORM_COMMAND_ICONS[command.action]}
<ChatFormPickerListItem
dataIndex={index}
disabled={command.disabled}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte
index c5b80f156..90efbc4aa 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
+ import { ModelBadge, ModelsSelector } from '$lib/components/app';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import { copyToClipboard } from '$lib/utils';
@@ -13,28 +13,22 @@
let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
- let pendingModel = $state<string | null>(null);
-
function handleCopyModel() {
void copyToClipboard(displayedModel ?? '');
}
</script>
{#if isRouter}
- <ModelsSelectorDropdown
- currentModel={pendingModel ?? displayedModel}
+ <ModelsSelector
+ currentModel={displayedModel}
disabled={isLoading}
onModelChange={async (modelId: string, modelName: string) => {
const status = modelsStore.getModelStatus(modelId);
+ // the picker's own selection applies before this resolves, so a model
+ // that is not loaded yet gets its load request in first
if (status !== ServerModelStatus.LOADED) {
- pendingModel = modelId;
-
- try {
- await modelsStore.status.load(modelId);
- } finally {
- pendingModel = null;
- }
+ await modelsStore.status.load(modelId);
}
onRegenerate(modelName);
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte
index b5d712337..5a1681ebe 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte
@@ -7,6 +7,7 @@
import { mcpStore } from '$lib/stores';
import type { AgenticSection, SearchResult } from '$lib/types';
import {
+ buildProxiedIconUrl,
extractSearchQuery,
extractSearchResults,
faviconForUrl,
@@ -82,7 +83,7 @@
</script>
{#snippet pill(result: SearchResult)}
- {@const faviconUrl = faviconForUrl(result.url)}
+ {@const faviconUrl = buildProxiedIconUrl(faviconForUrl(result.url), mcpStore.isProxyAvailable)}
{@const safeUrl = sanitizeExternalUrl(result.url)}
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
{#if safeUrl}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte
index 51cc4b029..01fb39b29 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte
@@ -208,7 +208,7 @@
</div>
{/if}
- <div class="flex items-center gap-1 px-2">
+ <div class="flex items-center gap-1 max-md:pt-1 md:px-1">
{#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
<ChatMessageStatisticsBadge
class="bg-transparent"
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
index 962b6774f..121d9f9ab 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
@@ -33,6 +33,7 @@
}: Props = $props();
let chatFormRef: ChatForm | undefined = $state(undefined);
+
let formWrapperEl: HTMLDivElement | undefined = $state();
let chatId = $derived(page.params.id as string | undefined);
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte
index cf9f55fd7..796ae6360 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte
@@ -8,7 +8,7 @@
let isLoadingModel = $derived(serverStore.status === 503);
</script>
-{#if hasError}
+{#if hasError || isLoadingModel}
<div class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1">
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
{#if isLoadingModel}
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts
index d7d7745df..e5c7e1c6b 100644
--- a/tools/ui/src/lib/components/app/chat/index.ts
+++ b/tools/ui/src/lib/components/app/chat/index.ts
@@ -165,7 +165,7 @@ export { default as ChatForm } from './ChatForm/ChatForm.svelte';
/**
* Wrapper component for the "add to chat" button (Plus icon).
* Exposes a `button` snippet that can be used inside DropdownMenu.Trigger (desktop)
- * or Sheet.Root (mobile) to maintain consistent styling while allowing
+ * or Drawer.Root (mobile) to maintain consistent styling while allowing
* platform-specific trigger wrappers.
*/
export { default as ChatFormActionsAdd } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte';
@@ -192,11 +192,11 @@ export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormA
export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
/**
- * Model selector component for the chat form action bar. Renders either a dropdown
- * (desktop) or bottom sheet (mobile) for selecting the conversation model in router mode.
+ * Model selector component for the chat form action bar and the assistant message
+ * model badge. Renders a dropdown on desktop and a bottom drawer on a phone.
* Exposes an `open` method for programmatically opening the selector.
*/
-export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/ChatFormActionModels.svelte';
+export { default as ChatFormActionReasoning } from './ChatForm/ChatFormActions/ChatFormActionReasoning.svelte';
/**
* Dropdown submenu for managing tool permissions in the chat form.
@@ -220,15 +220,6 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
-/**
- * Dropdown submenu for selecting reasoning effort level.
- *
- * Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
- * thinking status, and a nested list of effort levels.
- * Only visible when the current model supports thinking.
- */
-export { default as ChatFormActionAddReasoningSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte';
-
/**
* Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip.
*/
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
new file mode 100644
index 000000000..45b73dc72
--- /dev/null
+++ b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { MODEL_ICON } from '$lib/constants';
+
+ interface Props {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { onOpenChange, open = $bindable(false) }: Props = $props();
+
+ function handleOpenChange(value: boolean) {
+ open = value;
+ onOpenChange?.(value);
+ }
+</script>
+
+<Dialog.Root onOpenChange={handleOpenChange} {open}>
+ <Dialog.Content
+ class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380! flex flex-col p-4 pb-0"
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+ <Dialog.Header class="flex flex-row items-center pr-8">
+ <Dialog.Title class="flex min-h-7 items-center gap-2">
+ <MODEL_ICON class="h-5 w-5 shrink-0" />
+
+ <span>Models</span>
+ </Dialog.Title>
+
+ <Dialog.Description class="sr-only">
+ Browse, load and delete the models the server can serve.
+ </Dialog.Description>
+ </Dialog.Header>
+
+ <div class="min-h-0 flex-1">
+ <ModelsManager class="h-full" />
+ </div>
+ </Dialog.Content>
+</Dialog.Root>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte
index a5f9674ed..e09a9aba0 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte
@@ -5,7 +5,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import { DEFAULT_RESOURCE_FILENAME, MIME_TYPE_SUBSTRINGS } from '$lib/constants';
import { MimeTypeText } from '$lib/enums';
- import { mcpStore } from '$lib/stores';
+ import { deviceStore, mcpStore } from '$lib/stores';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import {
downloadResourceContent,
@@ -51,7 +51,10 @@
</script>
<Dialog.Root bind:open {onOpenChange}>
- <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
+ <Dialog.Content
+ class="grid max-h-[90vh] max-w-5xl overflow-hidden p-4 sm:w-auto sm:max-w-6xl"
+ onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
+ >
<Dialog.Header>
<Dialog.Title class="pr-8">{extra.name}</Dialog.Title>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte
index 82a07477d..be91e752b 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { Braces, FolderOpen, Loader2, Plus } from '@lucide/svelte';
+ import { Braces, FolderOpen, Loader2, Plus, X } from '@lucide/svelte';
import {
McpResourcePreview,
McpResourcesBrowser,
@@ -7,8 +7,8 @@
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
- import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { mcpStore } from '$lib/stores';
+ import { ICON_CLASS_DEFAULT, PANEL_CLOSE_MOBILE_CLASS } from '$lib/constants';
+ import { deviceStore, mcpStore } from '$lib/stores';
import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
@@ -252,8 +252,11 @@
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
- <Dialog.Content class="max-h-[80vh] md:max-w-4xl! w-full! overflow-hidden p-0">
- <Dialog.Header class="border-b border-border/30 px-6 py-4">
+ <Dialog.Content
+ class="max-h-[80vh] md:max-w-4xl! w-full! overflow-hidden p-0"
+ onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
+ >
+ <Dialog.Header class="border-b border-border/30 px-4 py-3" showCloseButton={false}>
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="h-5 w-5" />
@@ -267,6 +270,15 @@
<Dialog.Description>
Browse and attach resources from connected MCP servers to your chat context.
</Dialog.Description>
+
+ <!-- the body is flush with the dialog, so the corner close needs its own inset -->
+ <Dialog.Close
+ class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden max-md:top-1 max-md:right-1 {PANEL_CLOSE_MOBILE_CLASS}"
+ >
+ <X class="size-4" />
+
+ <span class="sr-only">Close</span>
+ </Dialog.Close>
</Dialog.Header>
<div class="flex h-[500px] min-w-0">
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
index bc28a754c..9fd68838e 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
@@ -10,7 +10,7 @@
RECOMMENDED_MCP_SERVERS
} from '$lib/constants';
import { BooleanString, HealthCheckStatus } from '$lib/enums';
- import { mcpStore } from '$lib/stores';
+ import { deviceStore, mcpStore } from '$lib/stores';
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
interface Props {
@@ -244,7 +244,10 @@
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
- <Dialog.Content class="max-w-2xl!">
+ <Dialog.Content
+ class="max-w-2xl! p-4"
+ onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
+ >
<Dialog.Header>
<Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
</Dialog.Header>
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte
index 2eaaa0a65..1f814d6b8 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServers.svelte
@@ -2,6 +2,7 @@
import { McpLogo } from '$lib/components/app';
import { SettingsMcpServers } from '$lib/components/app/settings';
import * as Dialog from '$lib/components/ui/dialog';
+ import { deviceStore } from '$lib/stores';
interface Props {
open?: boolean;
@@ -18,7 +19,8 @@
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
- class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col"
+ class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col p-4"
+ onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte
index e200c004e..7780b67d2 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte
@@ -1,369 +1,85 @@
<script lang="ts">
- import { ActionIconCopyToClipboard, BadgesModality } from '$lib/components/app';
+ import ModelsManagerModelConfigurationInformation from '$lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationInformation.svelte';
import * as Dialog from '$lib/components/ui/dialog';
- import * as Table from '$lib/components/ui/table';
- import { modelsStore, serverStore } from '$lib/stores';
- import type { ApiLlamaCppServerProps } from '$lib/types';
- import { formatFileSize, formatNumber, formatParameters } from '$lib/utils';
+ import { MODEL_ICON } from '$lib/constants';
+ import { HuggingFaceService } from '$lib/services';
+ import { modelsStore, uiStore } from '$lib/stores';
+ import type { HfModelDetailInfo } from '$lib/types/huggingface';
+ import { repoOf } from '$lib/utils';
+
+ // the props cache is a plain Map, so the read has to name its version to stay reactive
+ let serverProps = $derived.by(() => {
+ void modelsStore.props.cacheVersion;
+
+ return uiStore.modelInformation
+ ? modelsStore.props.getModelProps(uiStore.modelInformation.model)
+ : null;
+ });
- interface Props {
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
- // when set, fetch props from the child process (router mode)
- modelId?: string | null;
- }
+ // The server reports the full metadata only once a model is loaded. With the
+ // Hub enabled, it fills those gaps instead.
+ let hubDetails = $state<HfModelDetailInfo | null>(null);
- let { modelId = null, onOpenChange, open = $bindable() }: Props = $props();
+ $effect(() => {
+ const option = uiStore.modelInformation;
- let isRouter = $derived(serverStore.isRouterMode);
+ if (!option) return;
- // per-model props fetched from the child process
- let routerModelProps = $state<ApiLlamaCppServerProps | null>(null);
- let isLoadingRouterProps = $state(false);
+ const repo = repoOf(option.model);
- // in router mode use per-model props, otherwise use global props
- let serverProps = $derived(isRouter && modelId ? routerModelProps : serverStore.props);
+ let cancelled = false;
- let modelName = $derived(isRouter && modelId ? modelId : modelsStore.singleModelName);
- let models = $derived(modelsStore.models);
- let isLoadingModels = $derived(modelsStore.loading);
+ hubDetails = null;
- // in router mode, find the model option matching modelId
- // in single mode, use the first model as before
- let firstModel = $derived.by(() => {
- if (isRouter && modelId) {
- return models.find((m) => m.model === modelId) ?? null;
+ if (!HuggingFaceService.isEnabled() || !repo.includes('/')) {
+ return;
}
- return models[0] ?? null;
- });
+ void HuggingFaceService.getDetails(repo)
+ .then((details) => {
+ if (!cancelled) hubDetails = details;
+ })
+ .catch(() => {});
- // Get modalities from modelStore using the model ID from the first model
- let modalities = $derived.by(() => {
- if (!firstModel?.id) return [];
-
- return modelsStore.props.getModelModalitiesArray(firstModel.id);
- });
-
- // Ensure models are fetched when dialog opens
- $effect(() => {
- if (open && models.length === 0) {
- modelsStore.fetch();
- }
- });
-
- // fetch per-model props from child process when dialog opens in router mode
- $effect(() => {
- if (open && isRouter && modelId) {
- isLoadingRouterProps = true;
- modelsStore.props
- .fetchModelProps(modelId)
- .then((props) => {
- routerModelProps = props;
- })
- .catch(() => {
- routerModelProps = null;
- })
- .finally(() => {
- isLoadingRouterProps = false;
- });
- }
-
- if (!open) {
- routerModelProps = null;
- }
+ return () => {
+ cancelled = true;
+ };
});
</script>
-<Dialog.Root bind:open {onOpenChange}>
+<Dialog.Root
+ onOpenChange={(open) => {
+ if (!open) uiStore.modelInformation = null;
+ }}
+ open={uiStore.modelInformation !== null}
+>
<Dialog.Content
- class="z-9999 max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! md:w-[calc(100vw-4rem)]! md:max-w-[60rem]! md:max-h-[80dvh]!"
+ class="z-[70] flex max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:w-[calc(100vw-4rem)]! md:max-w-md! flex-col"
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ overlayClass="z-[70]"
>
- <!-- sticky header holds only the close button; the title scrolls with the body -->
- <Dialog.Header />
-
- <div class="min-w-0 space-y-6 md:py-4 -mt-4! md:mt-0 pb-4">
- <div class="min-w-0 space-y-2">
- <Dialog.Title>Model Information</Dialog.Title>
-
- <Dialog.Description>Current model details and capabilities</Dialog.Description>
- </div>
-
- {#if isLoadingModels || isLoadingRouterProps}
- <div class="flex items-center justify-center py-8">
- <div class="text-sm text-muted-foreground">Loading model information...</div>
- </div>
- {:else if firstModel}
- {@const modelMeta = firstModel.meta}
-
- {#if serverProps}
- <!-- Desktop: fixed-layout table, long values scroll inside their cell -->
- <Table.Root class="hidden table-fixed md:table">
- <Table.Header>
- <Table.Row>
- <Table.Head class="w-[10rem]">Model</Table.Head>
-
- <Table.Head>
- <div class="flex min-w-0 items-center gap-2">
- <span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
- {modelName}
- </span>
-
- <ActionIconCopyToClipboard
- ariaLabel="Copy model name to clipboard"
- canCopy={!!modelName}
- text={modelName || ''}
- />
- </div>
- </Table.Head>
- </Table.Row>
- </Table.Header>
-
- <Table.Body>
- <!-- Model Path -->
- <Table.Row>
- <Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
-
- <Table.Cell class="h-10 align-middle font-mono text-xs">
- <div class="flex min-w-0 items-center gap-2">
- <span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
- {serverProps.model_path}
- </span>
-
- <ActionIconCopyToClipboard
- ariaLabel="Copy model path to clipboard"
- text={serverProps.model_path}
- />
- </div>
- </Table.Cell>
- </Table.Row>
-
- <!-- Context Size -->
- {#if serverProps?.default_generation_settings?.n_ctx}
- <Table.Row>
- <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
-
- <Table.Cell
- >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
- >
- </Table.Row>
- {:else}
- <Table.Row>
- <Table.Cell class="h-10 align-middle font-medium text-red-500"
- >Context Size</Table.Cell
- >
-
- <Table.Cell class="text-red-500">Not available</Table.Cell>
- </Table.Row>
- {/if}
-
- <!-- Training Context -->
- {#if modelMeta?.n_ctx_train}
- <Table.Row>
- <Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
-
- <Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
- </Table.Row>
- {/if}
-
- <!-- Model Size -->
- {#if modelMeta?.size}
- <Table.Row>
- <Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
-
- <Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
- </Table.Row>
- {/if}
-
- <!-- Parameters -->
- {#if modelMeta?.n_params}
- <Table.Row>
- <Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
-
- <Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
- </Table.Row>
- {/if}
-
- <!-- Embedding Size -->
- {#if modelMeta?.n_embd}
- <Table.Row>
- <Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
-
- <Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
- </Table.Row>
- {/if}
+ <Dialog.Header class="flex flex-row items-center pr-8">
+ <Dialog.Title class="flex min-w-0 items-center gap-2 text-base">
+ <MODEL_ICON class="h-4 w-4 shrink-0 text-muted-foreground" />
- <!-- Vocabulary Size -->
- {#if modelMeta?.n_vocab}
- <Table.Row>
- <Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
-
- <Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
- </Table.Row>
- {/if}
-
- <!-- Vocabulary Type -->
- {#if modelMeta?.vocab_type}
- <Table.Row>
- <Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell>
-
- <Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell>
- </Table.Row>
- {/if}
-
- <!-- Total Slots -->
- <Table.Row>
- <Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
-
- <Table.Cell>{serverProps.total_slots}</Table.Cell>
- </Table.Row>
-
- <!-- Modalities -->
- {#if modalities.length > 0}
- <Table.Row>
- <Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
-
- <Table.Cell>
- <div class="flex flex-wrap gap-1">
- <BadgesModality {modalities} />
- </div>
- </Table.Cell>
- </Table.Row>
- {/if}
-
- <!-- Build Info -->
- <Table.Row>
- <Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
-
- <Table.Cell class="align-middle font-mono text-xs"
- >{serverProps.build_info}</Table.Cell
- >
- </Table.Row>
-
- <!-- Chat Template -->
- {#if serverProps.chat_template}
- <Table.Row>
- <Table.Cell class="py-4" colspan={2}>
- <div class="flex flex-col gap-2">
- <span class="font-medium">Chat Template</span>
-
- <div class="overflow-x-auto rounded-md bg-muted p-4">
- <pre
- class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
- </div>
- </div>
- </Table.Cell>
- </Table.Row>
- {/if}
- </Table.Body>
- </Table.Root>
-
- <!-- Mobile: stacked layout; long values wrap instead of scrolling the page -->
- <div class="flex min-w-0 flex-col gap-4 md:hidden">
- <div class="min-w-0 space-y-1">
- <div class="text-xs font-medium text-muted-foreground">Model</div>
-
- <div class="flex min-w-0 items-start gap-2">
- <span class="min-w-0 flex-1 break-all font-mono text-xs">{modelName}</span>
-
- <ActionIconCopyToClipboard
- ariaLabel="Copy model name to clipboard"
- canCopy={!!modelName}
- text={modelName || ''}
- />
- </div>
- </div>
-
- <div class="min-w-0 space-y-1">
- <div class="text-xs font-medium text-muted-foreground">File Path</div>
-
- <div class="flex min-w-0 items-start gap-2">
- <span class="min-w-0 flex-1 break-all font-mono text-xs"
- >{serverProps.model_path}</span
- >
-
- <ActionIconCopyToClipboard
- ariaLabel="Copy model path to clipboard"
- text={serverProps.model_path}
- />
- </div>
- </div>
-
- {#if serverProps?.default_generation_settings?.n_ctx}
- {@render infoRow(
- 'Context Size',
- `${formatNumber(serverProps.default_generation_settings.n_ctx)} tokens`
- )}
- {:else}
- {@render infoRow('Context Size', 'Not available', 'text-red-500')}
- {/if}
-
- {#if modelMeta?.n_ctx_train}
- {@render infoRow('Training Context', `${formatNumber(modelMeta.n_ctx_train)} tokens`)}
- {/if}
-
- {#if modelMeta?.size}
- {@render infoRow('Model Size', formatFileSize(modelMeta.size))}
- {/if}
-
- {#if modelMeta?.n_params}
- {@render infoRow('Parameters', formatParameters(modelMeta.n_params))}
- {/if}
-
- {#if modelMeta?.n_embd}
- {@render infoRow('Embedding Size', formatNumber(modelMeta.n_embd))}
- {/if}
-
- {#if modelMeta?.n_vocab}
- {@render infoRow('Vocabulary Size', `${formatNumber(modelMeta.n_vocab)} tokens`)}
- {/if}
-
- {#if modelMeta?.vocab_type}
- {@render infoRow('Vocabulary Type', modelMeta.vocab_type, 'capitalize')}
- {/if}
-
- {@render infoRow('Parallel Slots', `${serverProps.total_slots}`)}
-
- {#if modalities.length > 0}
- <div class="min-w-0 space-y-1">
- <div class="text-xs font-medium text-muted-foreground">Modalities</div>
-
- <div class="flex flex-wrap gap-1">
- <BadgesModality {modalities} />
- </div>
- </div>
- {/if}
-
- <div class="min-w-0 space-y-1">
- <div class="text-xs font-medium text-muted-foreground">Build Info</div>
-
- <span class="block break-all font-mono text-xs">{serverProps.build_info}</span>
- </div>
-
- {#if serverProps.chat_template}
- <div class="min-w-0 space-y-2">
- <div class="text-xs font-medium text-muted-foreground">Chat Template</div>
-
- <div class="overflow-x-auto rounded-md bg-muted p-4">
- <pre class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
- </div>
- </div>
- {/if}
- </div>
+ {#if uiStore.modelInformation}
+ <span class="min-w-0 truncate">{uiStore.modelInformation.model}</span>
{/if}
- {:else if !isLoadingModels}
- <div class="flex items-center justify-center py-8">
- <div class="text-sm text-muted-foreground">No model information available</div>
- </div>
+ </Dialog.Title>
+
+ <Dialog.Description class="sr-only">
+ Details of the model, from the server or the Hub.
+ </Dialog.Description>
+ </Dialog.Header>
+
+ <div class="min-h-0 flex-1 overflow-y-auto">
+ {#if uiStore.modelInformation}
+ <ModelsManagerModelConfigurationInformation
+ hub={hubDetails}
+ option={uiStore.modelInformation}
+ {serverProps}
+ />
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
-
-{#snippet infoRow(label: string, value: string, valueClass: string = '')}
- <div class="flex items-center justify-between gap-3">
- <span class="shrink-0 text-xs font-medium text-muted-foreground {valueClass}">{label}</span>
-
- <span class="text-sm {valueClass}">{value}</span>
- </div>
-{/snippet}
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte b/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte
index 5e0046aae..56efc47cf 100644
--- a/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte
+++ b/tools/ui/src/lib/components/app/dialogs/DialogSettingsChat.svelte
@@ -1,7 +1,9 @@
<script lang="ts">
- import { Settings } from '@lucide/svelte';
+ import { Settings, X } from '@lucide/svelte';
import { SettingsChat } from '$lib/components/app/settings';
import * as Dialog from '$lib/components/ui/dialog';
+ import { PANEL_CLOSE_MOBILE_CLASS } from '$lib/constants';
+ import { deviceStore } from '$lib/stores';
interface Props {
open?: boolean;
@@ -19,14 +21,24 @@
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
- class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
+ class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
+ onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
>
- <Dialog.Header class="md:p-0 p-4">
+ <Dialog.Header class="md:p-0 p-4" showCloseButton={false}>
<Dialog.Title class="flex items-center gap-2">
<Settings class="h-5 w-5" />
<span>Settings</span>
</Dialog.Title>
+
+ <!-- the body is flush with the dialog, so the corner close needs its own inset -->
+ <Dialog.Close
+ class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden max-md:top-1 max-md:right-1 {PANEL_CLOSE_MOBILE_CLASS}"
+ >
+ <X class="size-4" />
+
+ <span class="sr-only">Close</span>
+ </Dialog.Close>
</Dialog.Header>
<SettingsChat {initialSection} onClose={() => (open = false)} onSectionChange={() => {}} />
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index 9b0b62bd0..45cc9b2e6 100644
--- a/tools/ui/src/lib/components/app/dialogs/index.ts
+++ b/tools/ui/src/lib/components/app/dialogs/index.ts
@@ -411,45 +411,7 @@ export { default as DialogConversationSelection } from './DialogConversationSele
*
*/
-/**
- * **DialogModelInformation** - Model details display
- *
- * Dialog showing comprehensive information about the currently loaded model
- * and server configuration. Displays model metadata, capabilities, and
- * server settings in a structured table format.
- *
- * **Architecture:**
- * - Uses ShadCN Dialog with wide layout for table display
- * - Fetches data from serverStore (props) and modelsStore (metadata)
- * - Auto-fetches models when dialog opens if not loaded
- *
- * **Information Displayed:**
- * - **Model**: Name with copy button
- * - **File Path**: Full path to model file with copy button
- * - **Context Size**: Current context window size
- * - **Training Context**: Original training context (if available)
- * - **Model Size**: File size in human-readable format
- * - **Parameters**: Parameter count (e.g., "7B", "70B")
- * - **Embedding Size**: Embedding dimension
- * - **Vocabulary Size**: Token vocabulary size
- * - **Vocabulary Type**: Tokenizer type (BPE, etc.)
- * - **Parallel Slots**: Number of concurrent request slots
- * - **Modalities**: Supported input types (text, vision, audio)
- * - **Build Info**: Server build information
- * - **Chat Template**: Full Jinja template in scrollable code block
- *
- * **Features:**
- * - Copy buttons for model name and path
- * - Modality badges with icons
- * - Responsive table layout with container queries
- * - Loading state while fetching model info
- * - Scrollable chat template display
- *
- * @example
- * ```svelte
- * <DialogModelInformation bind:open={showModelInfo} />
- * ```
- */
+/** **DialogModelInformation** - model details a phone opens instead of the manager pane. */
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
/**
@@ -537,3 +499,6 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
* ```
*/
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
+
+/** **DialogManageModels** - shell for the models manager. */
+export { default as DialogManageModels } from './DialogManageModels.svelte';
diff --git a/tools/ui/src/lib/components/app/forms/SearchInput.svelte b/tools/ui/src/lib/components/app/forms/SearchInput.svelte
index 99d261208..0280a801d 100644
--- a/tools/ui/src/lib/components/app/forms/SearchInput.svelte
+++ b/tools/ui/src/lib/components/app/forms/SearchInput.svelte
@@ -11,6 +11,8 @@
onClose?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
class?: string;
+ /** Input height, matching the small size of the other controls. */
+ size?: 'sm' | 'default';
id?: string;
ref?: HTMLInputElement | null;
isCancelAlwaysVisible?: boolean;
@@ -26,6 +28,7 @@
onKeyDown,
placeholder = 'Search...',
ref = $bindable(null),
+ size = 'default',
value = $bindable('')
}: Props = $props();
@@ -58,7 +61,7 @@
bind:ref
bind:value
{autofocus}
- class="pl-9 {showClearButton ? 'pr-9' : ''}"
+ class="max-md:h-10 pl-9 {showClearButton ? 'pr-9' : ''} {size === 'sm' ? 'h-8' : ''}"
{id}
oninput={handleInput}
onkeydown={onKeyDown}
diff --git a/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
new file mode 100644
index 000000000..edbb5f774
--- /dev/null
+++ b/tools/ui/src/lib/components/app/misc/CollapsibleRegion.svelte
@@ -0,0 +1,83 @@
+<script lang="ts">
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ children: Snippet;
+ open: boolean;
+ }
+
+ let { children, open }: Props = $props();
+
+ // rows stay mounted through the collapse transition so it can play
+ const EXPAND_TRANSITION_MS = 200;
+ // the initial state only: the effect below owns it afterwards
+ // svelte-ignore state_referenced_locally
+ let contentMounted = $state(open);
+
+ $effect(() => {
+ if (open) {
+ contentMounted = true;
+
+ return;
+ }
+
+ const timer = setTimeout(() => (contentMounted = false), EXPAND_TRANSITION_MS);
+
+ return () => clearTimeout(timer);
+ });
+</script>
+
+<div class="collapsible-region" data-expanded={open}>
+ {#if contentMounted}
+ <div class="collapsible-region-content">
+ {@render children()}
+ </div>
+ {/if}
+</div>
+
+<style>
+ /* `interpolate-size` lets `height: auto` take part in the transition, so the content
+ needs no measured height. Older engines fall back to an interpolating grid row. */
+ .collapsible-region {
+ height: 0;
+ /* clip, not hidden: hidden would make the region a scrollport, and the
+ sticky rows inside it would then never leave their opening position */
+ overflow: clip;
+ visibility: hidden;
+ interpolate-size: allow-keywords;
+ transition:
+ height 200ms cubic-bezier(0.23, 1, 0.32, 1),
+ visibility 200ms;
+ }
+
+ .collapsible-region[data-expanded='true'] {
+ height: auto;
+ visibility: visible;
+ }
+
+ @supports not (interpolate-size: allow-keywords) {
+ .collapsible-region {
+ display: grid;
+ /* minmax(0, 1fr) pins the column to the region width: a plain auto
+ column would size to the content and push wide rows out of the list */
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: 0fr;
+ /* the row owns the height here: leaving height: 0 in place would snap
+ the region shut before the row could interpolate */
+ height: auto;
+ transition:
+ grid-template-rows 200ms cubic-bezier(0.23, 1, 0.32, 1),
+ visibility 200ms;
+ }
+
+ .collapsible-region[data-expanded='true'] {
+ grid-template-rows: 1fr;
+ }
+
+ .collapsible-region-content {
+ min-height: 0;
+ min-width: 0;
+ overflow: clip;
+ }
+ }
+</style>
diff --git a/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte b/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte
new file mode 100644
index 000000000..84276a06a
--- /dev/null
+++ b/tools/ui/src/lib/components/app/misc/CollapsibleSection.svelte
@@ -0,0 +1,111 @@
+<script lang="ts">
+ import CollapsibleRegion from './CollapsibleRegion.svelte';
+ import { ChevronDown, ChevronUp } from '@lucide/svelte';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import { ICON_CLASS_DEFAULT } from '$lib/constants';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ children: Snippet;
+ /** Trigger content; the chevron is appended. */
+ trigger: Snippet;
+ /** Render the trigger as a dropdown menu item, so menu keyboard navigation reaches it. */
+ inMenu?: boolean;
+ /** Start expanded. The section owns the state, so a parent rebuild cannot snap
+ * it back open; bind `open` to control it from outside. */
+ defaultOpen?: boolean;
+ /** Controlled open state, for a caller that binds it. */
+ open?: boolean;
+ /** Called on every toggle, for a caller that persists the state. */
+ ontoggle?: ((open: boolean) => void) | null;
+ /** Extra classes for the chevron, to line it up with a row's own control. */
+ chevronClass?: string;
+ /** Hide the chevron while expanded, until the trigger is hovered. */
+ revealChevronOnHover?: boolean;
+ /** Where the trigger sits relative to the content. */
+ triggerPosition?: 'bottom' | 'top';
+ triggerClass?: string;
+ triggerStyle?: string;
+ }
+
+ let {
+ chevronClass = '',
+ children,
+ defaultOpen = true,
+ inMenu = false,
+ ontoggle = null,
+ open = $bindable(defaultOpen),
+ revealChevronOnHover = false,
+ trigger,
+ triggerClass = '',
+ triggerPosition = 'top',
+ triggerStyle = ''
+ }: Props = $props();
+
+ function toggle(): void {
+ open = !open;
+ ontoggle?.(open);
+ }
+</script>
+
+{#snippet chevron()}
+ <span
+ class="ml-auto shrink-0 text-muted-foreground {chevronClass} {open && revealChevronOnHover
+ ? 'opacity-0 group-hover:opacity-100'
+ : ''}"
+ >
+ {#if open}
+ <ChevronUp class={ICON_CLASS_DEFAULT} />
+ {:else}
+ <ChevronDown class={ICON_CLASS_DEFAULT} />
+ {/if}
+ </span>
+{/snippet}
+
+{#snippet triggerButton()}
+ {#if inMenu}
+ <!-- A menu item (for keyboard nav) wrapping the trigger via `child`;
+ closeOnSelect keeps the menu open while the section toggles. -->
+ <DropdownMenu.Item
+ class="group w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left text-sm"
+ closeOnSelect={false}
+ >
+ {#snippet child({ props })}
+ <!-- No `class` here: a static attribute would override the spread props.class. -->
+ <button {...props} aria-expanded={open} onclick={toggle} style={triggerStyle} type="button">
+ {@render trigger()}
+
+ {@render chevron()}
+ </button>
+ {/snippet}
+ </DropdownMenu.Item>
+ {:else}
+ <button
+ aria-expanded={open}
+ class="group {triggerClass}"
+ onclick={toggle}
+ style={triggerStyle}
+ type="button"
+ >
+ {@render trigger()}
+
+ {@render chevron()}
+ </button>
+ {/if}
+{/snippet}
+
+{#snippet region()}
+ <!-- Custom expand region instead of bits-ui Collapsible (whose conditional
+ rendering kills the transition). -->
+ <CollapsibleRegion {open}>{@render children()}</CollapsibleRegion>
+{/snippet}
+
+{#if triggerPosition === 'top'}
+ {@render triggerButton()}
+
+ {@render region()}
+{:else}
+ {@render region()}
+
+ {@render triggerButton()}
+{/if}
diff --git a/tools/ui/src/lib/components/app/misc/GroupedList.svelte b/tools/ui/src/lib/components/app/misc/GroupedList.svelte
new file mode 100644
index 000000000..e249918c5
--- /dev/null
+++ b/tools/ui/src/lib/components/app/misc/GroupedList.svelte
@@ -0,0 +1,188 @@
+<script lang="ts" module>
+ /** One group of a grouped list, rendered under its own heading. */
+ export interface GroupedListGroup<G, E> {
+ entries: E[];
+ group: G;
+ key: string;
+ }
+
+ /** What a show-more row counts: rows of one group, or whole groups of the list. */
+ export const GroupedListUnit = {
+ ENTRIES: 'entries',
+ GROUPS: 'families'
+ } as const;
+
+ export type GroupedListUnit = (typeof GroupedListUnit)[keyof typeof GroupedListUnit];
+</script>
+
+<script generics="G, E" lang="ts">
+ import CollapsibleRegion from './CollapsibleRegion.svelte';
+ import type { Snippet } from 'svelte';
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+
+ interface Props {
+ /** Heading of one group. Omit it for a list whose rows need no heading. */
+ group?: Snippet<
+ [{ depth: number; expanded: boolean; group: G; key: string; toggle: () => void }]
+ >;
+ /** Groups to render. Omit to render `items` as a flat list. */
+ groups?: GroupedListGroup<G, E>[] | null;
+ /** Entries of one group shown before its show more row. 0 shows every entry. */
+ groupWindow?: number;
+ /** Group ids folded away, read once so a caller can restore what it persisted. */
+ initialCollapsed?: string[];
+ /** One row. Depth is 1 under a group, 0 in a flat list. */
+ item: Snippet<[{ depth: number; entry: E }]>;
+ /** Identity of a row, used for the keyed each. */
+ keyOf: (entry: E) => string;
+ /** The show more row. Without it the rest of a window stays hidden. */
+ more?: Snippet<[{ count: number; onMore: () => void; unit: GroupedListUnit }]>;
+ /** Flat entries, rendered when the list has no groups. */
+ items?: E[];
+ /** Entries of the list shown before its show more row. 0 shows every entry. */
+ sectionWindow?: number;
+ /** Called on every group toggle, for a caller that persists the state. */
+ onCollapsedChange?: ((key: string, collapsed: boolean) => void) | null;
+ /** Sticky class of a group heading, so a surface can shade it differently. */
+ stickyClass?: string;
+ /** Sticky offset of a group heading, e.g. `top: 2.25rem`. Empty keeps it scrolling. */
+ stickyStyle?: string;
+ /** Weight of one entry against a window, e.g. the rows it renders. */
+ weightOf?: (entry: E) => number;
+ }
+
+ let {
+ group,
+ groups = null,
+ groupWindow = 0,
+ initialCollapsed = [],
+ item,
+ items = [],
+ keyOf,
+ more,
+ onCollapsedChange = null,
+ sectionWindow = 0,
+ stickyClass = 'bg-popover',
+ stickyStyle = '',
+ weightOf
+ }: Props = $props();
+
+ // groups start open: this tracks the ones the user folded away, seeded once from
+ // whatever the caller persisted
+ // svelte-ignore state_referenced_locally
+ const collapsed = new SvelteSet<string>(initialCollapsed);
+ // windows grow one step at a time, per group and per list
+ const groupSteps = new SvelteMap<string, number>();
+ let sectionSteps = $state(0);
+
+ function toggleGroup(key: string): void {
+ if (collapsed.has(key)) {
+ collapsed.delete(key);
+ } else {
+ collapsed.add(key);
+ }
+
+ onCollapsedChange?.(key, collapsed.has(key));
+ }
+
+ function growGroup(key: string): void {
+ groupSteps.set(key, (groupSteps.get(key) ?? 0) + 1);
+ }
+
+ let groupCap = $derived((key: string) =>
+ groupWindow > 0 ? groupWindow * (1 + (groupSteps.get(key) ?? 0)) : Infinity
+ );
+ let weight = $derived(weightOf ?? (() => 1));
+
+ /** Groups with their entries cut to the window, and what each cut leaves behind. */
+ let windowed = $derived.by((): Array<{ group: G; hidden: number; key: string; rows: E[] }> => {
+ const shown: Array<{ group: G; hidden: number; key: string; rows: E[] }> = [];
+ const cap = sectionWindow > 0 ? sectionWindow + sectionSteps : Infinity;
+
+ let used = 0;
+
+ for (const entry of groups ?? []) {
+ if (used >= cap) break;
+
+ const rows = entry.entries.slice(0, groupCap(entry.key));
+
+ shown.push({
+ group: entry.group,
+ hidden: entry.entries.length - rows.length,
+ key: entry.key,
+ rows
+ });
+ used += rows.reduce((sum, row) => sum + weight(row), 0);
+ }
+
+ return shown;
+ });
+
+ let flatRows = $derived(sectionWindow > 0 ? items.slice(0, sectionWindow + sectionSteps) : items);
+
+ /** Rows one group weighs against the window. */
+ const groupWeight = (entry: GroupedListGroup<G, E>) =>
+ entry.entries.reduce((sum, row) => sum + weight(row), 0);
+
+ /** The groups one page reveals, and the rows they weigh against the window. */
+ let groupPage = $derived.by(() => {
+ const page = (groups ?? []).slice(windowed.length, windowed.length + sectionWindow);
+
+ return {
+ count: page.length,
+ weight: page.reduce((sum, entry) => sum + groupWeight(entry), 0)
+ };
+ });
+</script>
+
+{#if groups}
+ {#each windowed as entry (entry.key)}
+ {@const expanded = !collapsed.has(entry.key)}
+
+ {#if group}
+ <div class="sticky z-10 {stickyClass}" style={stickyStyle}>
+ {@render group({
+ depth: 0,
+ expanded,
+ group: entry.group,
+ key: entry.key,
+ toggle: () => toggleGroup(entry.key)
+ })}
+ </div>
+ {/if}
+
+ <CollapsibleRegion open={expanded}>
+ {#each entry.rows as row (keyOf(row))}
+ {@render item({ depth: 1, entry: row })}
+ {/each}
+
+ {#if entry.hidden > 0 && more}
+ {@render more({
+ count: Math.min(groupWindow, entry.hidden),
+ onMore: () => growGroup(entry.key),
+ unit: GroupedListUnit.ENTRIES
+ })}
+ {/if}
+ </CollapsibleRegion>
+ {/each}
+
+ {#if windowed.length < (groups?.length ?? 0) && more}
+ {@render more({
+ count: groupPage.count,
+ onMore: () => (sectionSteps += Math.max(1, groupPage.weight)),
+ unit: GroupedListUnit.GROUPS
+ })}
+ {/if}
+{:else}
+ {#each flatRows as row (keyOf(row))}
+ {@render item({ depth: 0, entry: row })}
+ {/each}
+
+ {#if flatRows.length < items.length && more}
+ {@render more({
+ count: Math.min(sectionWindow, items.length - flatRows.length),
+ onMore: () => (sectionSteps += Math.min(sectionWindow, items.length - flatRows.length)),
+ unit: GroupedListUnit.ENTRIES
+ })}
+ {/if}
+{/if}
diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts
index a10410ef9..a4ee2ebc3 100644
--- a/tools/ui/src/lib/components/app/misc/index.ts
+++ b/tools/ui/src/lib/components/app/misc/index.ts
@@ -59,3 +59,12 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
* `class` and `style` props and inherits color via `currentColor`.
*/
export { default as Logo } from './Logo.svelte';
+
+/** Section with a trigger that expands in place. */
+export { default as CollapsibleSection } from './CollapsibleSection.svelte';
+export {
+ default as GroupedList,
+ type GroupedListGroup,
+ GroupedListUnit
+} from './GroupedList.svelte';
+export { default as CollapsibleRegion } from './CollapsibleRegion.svelte';
diff --git a/tools/ui/src/lib/components/app/models/ModelAvatar.svelte b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte
new file mode 100644
index 000000000..5e1234a27
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelAvatar.svelte
@@ -0,0 +1,106 @@
+<script lang="ts">
+ import ModelOrgAvatar from './ModelOrgAvatar.svelte';
+ import { HF_BASE_MODEL_TAG_REGEX, SETTINGS_KEYS } from '$lib/constants';
+ import { HuggingFaceService, ModelsService } from '$lib/services';
+ import { settingsStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+ import { nearViewport, orgOf } from '$lib/utils';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ class?: string;
+ /** Rendered when the model has neither an org avatar nor a provider mark. */
+ fallback?: Snippet;
+ option: ModelOption;
+ quantPositionClass?: string;
+ quantSize?: string;
+ /** Show the base model's org as the main image, the repo (quantizer) org as the
+ * corner badge. The base org costs one Hub request per repo. Left unset with
+ * {@link showRepoOrgAvatar}, the row follows the family grouping. */
+ showBaseModelAvatar?: boolean;
+ /** Show the repo's own org as the main image, skipping the base model.
+ * Used inside a heading that already carries the base org. */
+ showRepoOrgAvatar?: boolean;
+ /** Keep the quantizer badge off, e.g. when a row stands for a whole family. */
+ showQuantBadge?: boolean;
+ size?: string;
+ }
+
+ let {
+ class: className = '',
+ fallback,
+ option,
+ quantPositionClass = '-bottom-1 -right-1',
+ quantSize = 'h-3 w-3',
+ showBaseModelAvatar,
+ showQuantBadge = true,
+ showRepoOrgAvatar,
+ size = 'size-5'
+ }: Props = $props();
+
+ let parsedId = $derived(ModelsService.parseModelId(option.model));
+ let orgName = $derived(parsedId.orgName);
+ // A row names neither flag: a family heading already carries the base org, so a
+ // grouped list shows the repo's own org, and an ungrouped one the base org with the
+ // quantizer badge. A caller that names one of the two keeps control of the avatar.
+ let followsGrouping = $derived(
+ showBaseModelAvatar === undefined && showRepoOrgAvatar === undefined
+ );
+ let groupedByFamily = $derived(
+ settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY] ?? false
+ );
+ let baseOrgMain = $derived(showBaseModelAvatar ?? (followsGrouping && !groupedByFamily));
+ let repoOrgMain = $derived(showRepoOrgAvatar ?? (followsGrouping && groupedByFamily));
+ // Avatars come from the Hub, so with the metadata setting off they are
+ // hidden entirely and no base-model lookup runs.
+ let hubEnabled = $derived(HuggingFaceService.isEnabled());
+ let tagBaseModel = $derived(
+ (option.tags ?? [])
+ .find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
+ ?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
+ );
+ // long lists mount hundreds of avatars at once, so the base org lookup waits
+ // until the row is near the viewport
+ let fetchedBaseModelOrg = $state<string | null>(null);
+ let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
+ let isNearViewport = $state(false);
+
+ $effect(() => {
+ fetchedBaseModelOrg = null;
+
+ if (!isNearViewport || !baseOrgMain || !orgName || tagBaseModel) return;
+
+ if (!hubEnabled) return;
+
+ let cancelled = false;
+
+ void HuggingFaceService.getBaseModel(option.model)
+ .then((base) => {
+ if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
+ })
+ // best-effort lookup: offline or unknown repos keep the repo org
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ });
+</script>
+
+{#if orgName && hubEnabled}
+ <span
+ use:nearViewport={() => (isNearViewport = true)}
+ class={['inline-flex shrink-0', className]}
+ >
+ <ModelOrgAvatar
+ class="mt-0"
+ org={repoOrgMain ? orgName : (baseModelOrg ?? orgName)}
+ quantOrg={baseOrgMain && showQuantBadge && !repoOrgMain ? orgName : undefined}
+ {quantPositionClass}
+ {quantSize}
+ {size}
+ />
+ </span>
+{:else}
+ {@render fallback?.()}
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelBadge.svelte b/tools/ui/src/lib/components/app/models/ModelBadge.svelte
index d5b723ff9..53cdcc26a 100644
--- a/tools/ui/src/lib/components/app/models/ModelBadge.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelBadge.svelte
@@ -1,8 +1,8 @@
<script lang="ts">
import ModelId from './ModelId.svelte';
- import { Package } from '@lucide/svelte';
import { ActionIconCopyToClipboard, BadgeInfo } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import { MODEL_ICON } from '$lib/constants';
import { modelsStore, serverStore } from '$lib/stores';
interface Props {
@@ -29,7 +29,7 @@
{#snippet badgeContent(triggerProps?: Record<string, unknown>)}
<BadgeInfo {...triggerProps ?? {}} class={className} {onclick}>
{#snippet icon()}
- <Package class="h-3 w-3" />
+ <MODEL_ICON class="h-3 w-3" />
{/snippet}
{#if model}
diff --git a/tools/ui/src/lib/components/app/models/ModelCapabilities.svelte b/tools/ui/src/lib/components/app/models/ModelCapabilities.svelte
new file mode 100644
index 000000000..4d158efbb
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelCapabilities.svelte
@@ -0,0 +1,63 @@
+<script lang="ts">
+ import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
+ import { MODEL_ID } from '$lib/constants';
+ import { ModelCapability } from '$lib/enums';
+ import { HuggingFaceService } from '$lib/services';
+ import type { ModelOption } from '$lib/types/models';
+ import { detectThinkingSupport, detectToolUseSupport, nearViewport, repoOf } from '$lib/utils';
+
+ interface Props {
+ class?: string;
+ /** Hide the modality icons, keeping the capability ones. */
+ hideModalities?: boolean;
+ option: ModelOption;
+ }
+
+ let { class: className = '', hideModalities = false, option }: Props = $props();
+
+ // a listing that declares its capabilities (Ollama-compatible backends) needs no lookup
+ let declared = $derived(option.capabilities);
+ let isNearViewport = $state(false);
+ let template = $state('');
+
+ $effect(() => {
+ template = '';
+
+ if (!isNearViewport) return;
+
+ // only the chat template says whether tools and reasoning work, and the Hub
+ // carries it for a local GGUF whether the model is loaded or not
+ const repo = repoOf(option.model);
+
+ if (!repo.includes(MODEL_ID.ORG_SEPARATOR)) return;
+
+ let cancelled = false;
+
+ void HuggingFaceService.getDetails(repo)
+ .then((details) => {
+ if (!cancelled) template = details?.gguf?.chat_template ?? '';
+ })
+ // best-effort: offline or a repo we cannot read leaves the icons off
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ });
+
+ let supportsThinking = $derived(
+ declared.includes(ModelCapability.REASONING) || detectThinkingSupport(template)
+ );
+ let supportsToolUse = $derived(
+ declared.includes(ModelCapability.TOOL_USE) || detectToolUseSupport(template)
+ );
+</script>
+
+<span use:nearViewport={() => (isNearViewport = true)} class={className}>
+ <ModelCapabilityIcons
+ {hideModalities}
+ modalities={option.modalities}
+ {supportsThinking}
+ {supportsToolUse}
+ />
+</span>
diff --git a/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
index c9505a95e..3743a158f 100644
--- a/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
@@ -1,6 +1,14 @@
<script lang="ts">
- import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import {
+ CAPABILITY_ICONS,
+ CAPABILITY_LABELS,
+ MODALITY_FLAG_KEYS,
+ MODALITY_ICONS,
+ MODALITY_LABELS,
+ MODALITY_ORDER
+ } from '$lib/constants';
+ import { ModelCapability } from '$lib/enums';
import type { ModelModalities } from '$lib/types/models';
interface Props {
@@ -23,71 +31,57 @@
supportsToolUse = false
}: Props = $props();
- let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
-</script>
-
-<span class="inline-flex items-center {gapClass}">
- {#if supportsToolUse && !hideCapabilities}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Wrench class="{iconSize} text-muted-foreground" />
- </Tooltip.Trigger>
+ let capabilities = $derived([
+ ...(supportsToolUse ? [ModelCapability.TOOL_USE] : []),
+ ...(supportsThinking ? [ModelCapability.REASONING] : [])
+ ]);
- <Tooltip.Content>
- <p>Tool use</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
+ let shownModalities = $derived(
+ MODALITY_ORDER.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
+ );
- {#if supportsThinking && !hideCapabilities}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Lightbulb class="{iconSize} text-muted-foreground" />
- </Tooltip.Trigger>
+ // an icon-less box still takes the gap where its icons would sit, so a caller with
+ // nothing to show renders no box at all
+ let hasIcons = $derived(
+ (!hideCapabilities && capabilities.length > 0) ||
+ (shownModalities.length > 0 && !hideModalities)
+ );
+</script>
- <Tooltip.Content>
- <p>Reasoning</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
+{#if hasIcons}
+ <span class="inline-flex items-center {gapClass}">
+ {#if !hideCapabilities}
+ {#each capabilities as capability (capability)}
+ {@const Icon = CAPABILITY_ICONS[capability]}
- {#if hasModalityIcons && !hideModalities}
- <span class="inline-flex items-center text-muted-foreground">
- {#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
- <Image class={iconSize} />
+ <Icon class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
- <p>Vision</p>
+ <p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
- {/if}
-
- {#if modalities?.video}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Video class={iconSize} />
- </Tooltip.Trigger>
+ {/each}
+ {/if}
- <Tooltip.Content>
- <p>Video</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
+ {#if shownModalities.length > 0 && !hideModalities}
+ <span class="inline-flex items-center {gapClass} text-muted-foreground">
+ {#each shownModalities as modality (modality)}
+ {@const Icon = MODALITY_ICONS[modality]}
- {#if modalities?.audio}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Mic class={iconSize} />
- </Tooltip.Trigger>
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Icon class={iconSize} />
+ </Tooltip.Trigger>
- <Tooltip.Content>
- <p>Audio</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- </span>
- {/if}
-</span>
+ <Tooltip.Content>
+ <p>{MODALITY_LABELS[modality]}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/each}
+ </span>
+ {/if}
+ </span>
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelContext.svelte b/tools/ui/src/lib/components/app/models/ModelContext.svelte
new file mode 100644
index 000000000..6d5395fc4
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelContext.svelte
@@ -0,0 +1,58 @@
+<script lang="ts">
+ import { MODEL_ID } from '$lib/constants';
+ import { HuggingFaceService } from '$lib/services';
+ import type { ModelOption } from '$lib/types/models';
+ import { nearViewport, repoOf } from '$lib/utils';
+ import { formatContextLength } from '$lib/utils/formatters';
+
+ interface Props {
+ class?: string;
+ /** Context the model is set to run with; paired with the supported one. */
+ configured?: number | null;
+ option: ModelOption;
+ }
+
+ let { class: className = '', configured = null, option }: Props = $props();
+
+ // a listing that reports a context window (OpenRouter, Groq, HF) needs no lookup
+ let reported = $derived(option.contextLength ?? null);
+ let isNearViewport = $state(false);
+ let fetched = $state<number | null>(null);
+
+ $effect(() => {
+ fetched = null;
+
+ if (reported || !isNearViewport) return;
+
+ // a local GGUF carries its trained context in the model metadata
+ const repo = repoOf(option.model);
+
+ // a local file path has no repo to read, and the Hub would answer 404
+ if (!repo.includes(MODEL_ID.ORG_SEPARATOR)) return;
+
+ let cancelled = false;
+
+ void HuggingFaceService.getDetails(repo)
+ .then((details) => {
+ if (!cancelled) fetched = details?.gguf?.context_length ?? null;
+ })
+ // best-effort: offline or a repo we cannot read keeps the dash
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ });
+
+ let context = $derived(reported ?? fetched);
+ let values = $derived(
+ [configured, context].filter((value): value is number => typeof value === 'number')
+ );
+</script>
+
+<span
+ use:nearViewport={() => (isNearViewport = true)}
+ class={['text-sm text-muted-foreground', className]}
+>
+ {formatContextLength(values)}
+</span>
diff --git a/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte b/tools/ui/src/lib/components/app/models/ModelDownloadProgressBar.svelte
similarity index 80%
rename from tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte
rename to tools/ui/src/lib/components/app/models/ModelDownloadProgressBar.svelte
index 6f4973c12..c3a89c0ba 100644
--- a/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelDownloadProgressBar.svelte
@@ -18,14 +18,14 @@
{#if overlay}
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
- class="h-full bg-primary transition-[width] duration-200 ease-out"
+ class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{:else}
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
- class="h-full bg-primary transition-[width] duration-200 ease-out"
+ class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte b/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte
new file mode 100644
index 000000000..9b655f843
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelDraftSidecars.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
+ import type { ModelSidecarBadge } from '$lib/types/models';
+ import { isAuxSidecar } from '$lib/utils';
+ import { SvelteSet } from 'svelte/reactivity';
+
+ interface Props {
+ /** Draft sidecars available for the model, each with its own quant. */
+ draftSidecars?: ModelSidecarBadge[];
+ }
+
+ let { draftSidecars = [] }: Props = $props();
+
+ // the router listing and the --model-draft args both report a model's drafts, so
+ // the same sidecar can arrive twice
+ let badges = $derived.by(() => {
+ const seen = new SvelteSet<string>();
+
+ return draftSidecars.filter((badge) => {
+ if (isAuxSidecar(badge.kind)) return false;
+
+ const key = `${badge.kind}:${badge.quant ?? ''}`;
+
+ if (seen.has(key)) return false;
+
+ seen.add(key);
+
+ return true;
+ });
+ });
+</script>
+
+{#each badges as badge (badge.kind + (badge.quant ?? ''))}
+ <span aria-hidden="true" class="text-muted-foreground">+</span>
+
+ <span
+ class={MODEL_VARIANT_BADGE_CLASS}
+ title="{badge.kind.toUpperCase()} draft model{badge.quant ? ` at ${badge.quant}` : ''}"
+ >
+ {badge.kind}
+ </span>
+{/each}
diff --git a/tools/ui/src/lib/components/app/models/ModelId.svelte b/tools/ui/src/lib/components/app/models/ModelId.svelte
index ba3a976ae..26d72f662 100644
--- a/tools/ui/src/lib/components/app/models/ModelId.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelId.svelte
@@ -1,13 +1,15 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
+ import ModelDraftSidecars from './ModelDraftSidecars.svelte';
import { Database, ScrollText } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { type ModelSidecar } from '$lib/constants';
+ import { MODEL_BADGE_CLASS, MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelModalities } from '$lib/types/models';
+ import { type ModelSidecarBadge } from '$lib/types/models';
import { isAuxSidecar } from '$lib/utils';
import { formatParameters } from '$lib/utils';
@@ -35,7 +37,10 @@
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
- draftSidecars?: ModelSidecar[];
+ /** Draft sidecars available for the model, badged with their own quant. */
+ draftSidecars?: ModelSidecarBadge[];
+ /** Give the id a row of its own, with its badges and icons together under it. */
+ stackId?: boolean;
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
class?: string;
@@ -59,6 +64,7 @@
showRaw = undefined,
showRawTooltip = false,
sizeRange,
+ stackId = false,
supportsThinking = false,
supportsToolUse = false,
tags,
@@ -67,12 +73,10 @@
...rest
}: Props = $props();
- const badgeClass =
- 'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
+ const badgeClass = MODEL_BADGE_CLASS;
const tagBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
- const variantBadgeClass =
- 'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
+ const variantBadgeClass = MODEL_VARIANT_BADGE_CLASS;
/** Alias badges beyond this many collapse into a single `+x more` badge. */
const MAX_ALIAS_BADGES = 2;
@@ -88,7 +92,7 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
- let uniqueDraftSidecars = $derived([...new Set(draftSidecars)].filter((s) => !isAuxSidecar(s)));
+ let uniqueDraftSidecars = $derived(draftSidecars.filter((badge) => !isAuxSidecar(badge.kind)));
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
@@ -109,13 +113,19 @@
{:else}
{#snippet nameAndBadges()}
{#if !hideName}
- <span class="min-w-0 truncate font-medium">
+ <span class="min-w-0 truncate font-medium {stackId ? 'basis-full' : ''}">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{/if}
{#if hasBadges}
- <span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
+ <!-- the badges keep their width, so a long id truncates around them instead of
+ a label being cut in half -->
+ <span
+ class="inline-flex min-w-0 items-center gap-1 overflow-hidden {wrap
+ ? 'flex-wrap'
+ : 'shrink-0'}"
+ >
{#if parsed.sidecar}
<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
{parsed.sidecar}
@@ -128,18 +138,14 @@
</span>
{/if}
- {#each uniqueDraftSidecars as sidecar (sidecar)}
- <span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
- {sidecar}
- </span>
- {/each}
-
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
+ <ModelDraftSidecars {draftSidecars} />
+
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
@@ -169,14 +175,17 @@
{/if}
{/snippet}
+ <!-- badges, tags and icons do not shrink, so overflow-hidden bounds the id -->
<span
- class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
- ? 'flex-col items-start'
- : ''} {className}"
+ class="flex min-w-0 items-center gap-1.5 overflow-hidden {wrap
+ ? 'flex-wrap'
+ : ''} {iconsOnNewLine ? 'flex-col items-start' : ''} {className}"
{title}
{...rest}
>
- <span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
+ <span
+ class="flex min-w-0 items-center gap-1.5 max-md:gap-0.5 {wrap || stackId ? 'flex-wrap' : ''}"
+ >
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
diff --git a/tools/ui/src/lib/components/app/models/ModelLoadControl.svelte b/tools/ui/src/lib/components/app/models/ModelLoadControl.svelte
new file mode 100644
index 000000000..6d1ea5a93
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelLoadControl.svelte
@@ -0,0 +1,107 @@
+<script lang="ts">
+ import { ActionIcon } from '$lib/components/app';
+ import { ICON_CLASS_DEFAULT, MODEL_LOAD_ICONS } from '$lib/constants';
+ import { modelsStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ class?: string;
+ isFailed?: boolean;
+ isLoaded: boolean;
+ isLoading?: boolean;
+ isSleeping?: boolean;
+ option: ModelOption;
+ /** Table rows keep the load action visible, selector rows reveal it on hover. */
+ revealOnHover?: boolean;
+ /** Renders the state alone, for a caller that moves load and unload elsewhere. */
+ showAction?: boolean;
+ }
+
+ let {
+ class: className = '',
+ isFailed = false,
+ isLoaded,
+ isLoading = false,
+ isSleeping = false,
+ option,
+ revealOnHover = true,
+ showAction = true
+ }: Props = $props();
+</script>
+
+<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
+ {#if isLoading}
+ <MODEL_LOAD_ICONS.loading class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
+ {:else}
+ <!-- the state dot is what the row shows at rest; the action takes its place on hover -->
+ {#if showAction && revealOnHover}
+ {#if isFailed}
+ <MODEL_LOAD_ICONS.failed
+ class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
+ />
+ {:else}
+ <span
+ class="h-2 w-2 rounded-full group-hover:hidden [@media(pointer:coarse)]:hidden {isSleeping
+ ? 'bg-orange-400'
+ : isLoaded
+ ? 'bg-green-500'
+ : 'bg-muted-foreground/50'}"
+ ></span>
+ {/if}
+ {:else}
+ {#if isFailed}
+ <MODEL_LOAD_ICONS.failed class="h-3.5 w-3.5 text-red-500" />
+ {:else}
+ <span
+ class="h-2 w-2 rounded-full {isSleeping
+ ? 'bg-orange-400'
+ : isLoaded
+ ? 'bg-green-500'
+ : 'bg-muted-foreground/50'}"
+ ></span>
+ {/if}
+ {/if}
+
+ <!-- the wrapper has to pick one display value: flex and hidden together
+ leave the winner to stylesheet order -->
+ <div
+ class={!showAction
+ ? 'hidden'
+ : revealOnHover
+ ? 'hidden group-hover:flex [@media(pointer:coarse)]:flex'
+ : 'flex'}
+ >
+ {#if isFailed}
+ <ActionIcon
+ class="h-5 w-5 text-red-500 hover:text-foreground"
+ icon={MODEL_LOAD_ICONS.retry}
+ iconSize="h-4 w-4"
+ onclick={() => modelsStore.status.load(option.model)}
+ stopPropagationOnClick
+ tooltip="Retry loading model"
+ tooltipAsTitle
+ />
+ {:else if isLoaded || isSleeping}
+ <ActionIcon
+ class="h-5 w-5 hover:text-foreground"
+ icon={MODEL_LOAD_ICONS.unload}
+ iconSize="h-4 w-4"
+ onclick={() => modelsStore.status.unload(option.model)}
+ stopPropagationOnClick
+ tooltip="Unload model"
+ tooltipAsTitle
+ />
+ {:else}
+ <ActionIcon
+ class="h-5 w-5 hover:text-foreground"
+ icon={MODEL_LOAD_ICONS.load}
+ iconSize="h-4 w-4"
+ onclick={() => modelsStore.status.load(option.model)}
+ stopPropagationOnClick
+ tooltip="Load model"
+ tooltipAsTitle
+ />
+ {/if}
+ </div>
+ {/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelOrgAvatar.svelte b/tools/ui/src/lib/components/app/models/ModelOrgAvatar.svelte
new file mode 100644
index 000000000..5befb9306
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelOrgAvatar.svelte
@@ -0,0 +1,130 @@
+<script lang="ts">
+ import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
+ import { HuggingFaceService } from '$lib/services';
+ import { SvelteMap } from 'svelte/reactivity';
+
+ // Avatars a failed request is cooling down for. A failure hides the image
+ // immediately, but the org gets another chance on a later mount: the cooldown
+ // expires on its own, so a transient network blip is not permanent for the
+ // session. A row remounting inside the cooldown renders the monogram instead
+ // of re-requesting the same failing URL.
+ const AVATAR_RETRY_DELAY_MS = 60_000;
+ const failedAvatarOrgs = new SvelteMap<string, number>();
+
+ /** True while an org's failed avatar is still inside its retry cooldown. */
+ function isAvatarCoolingDown(org: string): boolean {
+ const failedAt = failedAvatarOrgs.get(org);
+
+ // a stale entry reads as not failed and is dropped on the next onerror
+ return failedAt !== undefined && Date.now() - failedAt < AVATAR_RETRY_DELAY_MS;
+ }
+
+ interface Props {
+ class?: string;
+ org: string;
+ quantOrg?: string;
+ size?: string;
+ baseImageClass?: string;
+ quantImageClass?: string;
+ quantPositionClass?: string;
+ quantSize?: string;
+ }
+
+ let {
+ baseImageClass = '',
+ class: className = '',
+ org,
+ quantImageClass = 'h-full w-full',
+ quantOrg,
+ quantPositionClass = '-bottom-0.75 -right-0.75',
+ quantSize = 'h-4.25 w-4.25',
+ size = 'h-9 w-9'
+ }: Props = $props();
+
+ // With the Hub metadata setting off there is nothing to fetch and nothing to
+ // show: the avatar is hidden entirely instead of falling back to a monogram.
+ let hubEnabled = $derived(HuggingFaceService.isEnabled());
+ let orgAvatarFailed = $derived(isAvatarCoolingDown(org));
+ let quantAvatarFailed = $derived(isAvatarCoolingDown(quantOrg ?? ''));
+
+ let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
+ let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
+
+ // Monogram fallback: org initial on a hue derived from its name, so each org
+ // gets a stable distinct color.
+ let hue = $derived.by(() => {
+ let h = 0;
+
+ for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
+
+ return h % 360;
+ });
+
+ let quantHue = $derived.by(() => {
+ const name = quantOrg ?? '';
+
+ let h = 0;
+
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
+
+ return h % 360;
+ });
+</script>
+
+{#if hubEnabled}
+ <span class="relative inline-flex shrink-0 {className}">
+ {#if orgAvatarFailed}
+ <span
+ aria-hidden="true"
+ class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
+ style="background-color: hsl({hue} 60% 45%)"
+ >
+ {org.charAt(0).toUpperCase()}
+ </span>
+ {:else}
+ <div class="rounded-md">
+ <!-- the server serves the app under COEP require-corp, so a cross-origin
+ image has to be fetched in CORS mode to be allowed -->
+ <img
+ alt=""
+ class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
+ crossorigin="anonymous"
+ loading="lazy"
+ onerror={() => {
+ failedAvatarOrgs.set(org, Date.now());
+ }}
+ src={HuggingFaceService.getAvatarUrl(org)}
+ />
+ </div>
+ {/if}
+
+ {#if quantOrg && quantOrg !== org}
+ <!-- native title instead of a floating tooltip: long model lists mount one badge per row -->
+ <span
+ class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted"
+ title={quantOrg}
+ >
+ {#if quantAvatarFailed}
+ <span
+ aria-hidden="true"
+ class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
+ style="background-color: hsl({quantHue} 60% 45%)"
+ >
+ {quantOrg.charAt(0).toUpperCase()}
+ </span>
+ {:else}
+ <img
+ alt=""
+ class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
+ crossorigin="anonymous"
+ loading="lazy"
+ onerror={() => {
+ failedAvatarOrgs.set(quantOrg ?? '', Date.now());
+ }}
+ src={HuggingFaceService.getAvatarUrl(quantOrg)}
+ />
+ {/if}
+ </span>
+ {/if}
+ </span>
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelRowActions.svelte b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte
new file mode 100644
index 000000000..8f173174a
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+ import { Heart, HeartOff, Info } from '@lucide/svelte';
+ import { ActionIcon } from '$lib/components/app';
+ import { deviceStore, modelsStore, uiStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ isFav: boolean;
+ option: ModelOption;
+ /** Selector rows reveal the actions on hover, table rows keep them visible. */
+ revealOnHover?: boolean;
+ }
+
+ let { isFav, option, revealOnHover = true }: Props = $props();
+</script>
+
+<div
+ class={[
+ 'flex items-center justify-center gap-1 max-md:gap-2.5',
+ revealOnHover
+ ? 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100'
+ : ''
+ ]}
+ onclick={(event) => event.stopPropagation()}
+ onkeydown={(event) => event.stopPropagation()}
+ role="presentation"
+>
+ <ActionIcon
+ class="h-5 w-5 hover:text-foreground"
+ icon={Info}
+ iconSize="h-4 w-4"
+ onclick={() =>
+ // a phone has no manager: its information dialog takes the icon
+ deviceStore.isMobile
+ ? uiStore.openModelInformation(option)
+ : uiStore.openModelsManager(option.id)}
+ tooltip="Manage model"
+ tooltipAsTitle
+ />
+
+ {#if isFav}
+ <span class="flex h-5 w-5 items-center justify-center">
+ <span class="flex group-hover:hidden [@media(pointer:coarse)]:hidden">
+ <ActionIcon
+ class="h-5 w-5 text-rose-500 hover:text-foreground"
+ icon={Heart}
+ iconSize="h-4 w-4"
+ onclick={() => modelsStore.toggleFavorite(option.model)}
+ tooltip="Remove from favorites"
+ tooltipAsTitle
+ />
+ </span>
+
+ <span class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
+ <ActionIcon
+ class="h-5 w-5 hover:text-foreground"
+ icon={HeartOff}
+ iconSize="h-4 w-4"
+ onclick={() => modelsStore.toggleFavorite(option.model)}
+ tooltip="Remove from favorites"
+ tooltipAsTitle
+ />
+ </span>
+ </span>
+ {:else}
+ <ActionIcon
+ class="h-5 w-5 hover:text-foreground"
+ icon={Heart}
+ iconSize="h-4 w-4"
+ onclick={() => modelsStore.toggleFavorite(option.model)}
+ tooltip="Add to favorites"
+ tooltipAsTitle
+ />
+ {/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
new file mode 100644
index 000000000..e6020e917
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
@@ -0,0 +1,357 @@
+<script lang="ts">
+ import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
+ import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
+ import {
+ downloadGroups,
+ groupModelQuants,
+ modelCapability,
+ modelContextLength,
+ type ModelQuantGroup,
+ type ModelsTableGroup,
+ splitHiddenQuants
+ } from './utils';
+ import { LOCAL_BACKEND_ID, type ModalityKey, MODELS_TABLE_GROUP_LABELS } from '$lib/constants';
+ import { ModelCapability, ModelsTableGroupKind } from '$lib/enums';
+ import { conversationsStore, modelsStore, serverStore, uiStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+ import { filterModelOptions } from '$lib/utils';
+ import { type Snippet, untrack } from 'svelte';
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+
+ interface Props {
+ class?: string;
+ /** Forwarded to the table's toolbar right end. */
+ toolbarEnd?: Snippet;
+ }
+
+ let { class: className, toolbarEnd }: Props = $props();
+
+ let filter = $state('');
+ let contextLimit = $state(0);
+ let modalityFilter = $state<ModalityKey[]>([]);
+ let capabilityFilter = $state<ModelCapability[]>([]);
+ let selectedId = $state<string | null>(null);
+
+ let allModels = $derived(modelsStore.models);
+
+ let selected = $derived(allModels.find((option) => option.id === selectedId) ?? null);
+ // The pane is laid out before it is ever opened, so the first open only slides a
+ // finished panel in. It renders the selection, else the model it last showed.
+ let lastPicked = $state<ModelOption | null>(null);
+ let target = $derived(selected ?? lastPicked ?? allModels[0] ?? null);
+ let shownId = $state<string | null>(null);
+ let isSwapping = $state(false);
+ let fade = $state<'open' | 'swap'>('open');
+ let shownOption = $derived(allModels.find((option) => option.id === shownId) ?? null);
+
+ $effect(() => {
+ const id = selectedId;
+
+ if (!id) return;
+
+ // untracked: the effect must not track the state it writes
+ untrack(() => {
+ lastPicked = allModels.find((option) => option.id === id) ?? null;
+ });
+ });
+
+ /** How long the panel takes to fade out before it swaps to another model. */
+ const SWAP_FADE_MS = 120;
+
+ // Another model fades the panel out, swaps it, then fades it back in.
+ $effect(() => {
+ const next = target?.id ?? null;
+
+ if (!next) return;
+
+ if (shownId === null) {
+ untrack(() => (shownId = next));
+
+ return;
+ }
+
+ if (next === shownId) {
+ if (selected) {
+ untrack(() => {
+ isSwapping = false;
+ fade = 'open';
+ });
+ }
+
+ return;
+ }
+
+ untrack(() => {
+ isSwapping = true;
+ fade = 'swap';
+ });
+
+ const timer = setTimeout(() => {
+ untrack(() => {
+ shownId = next;
+ isSwapping = false;
+ });
+ }, SWAP_FADE_MS);
+
+ return () => clearTimeout(timer);
+ });
+
+ let isFavorite = $derived((option: ModelOption) =>
+ modelsStore.favoriteModelIds.has(option.model)
+ );
+ let matching = $derived.by(() => {
+ // the term matches what the selector search matches: name, model, aliases and tags
+ const searched = filterModelOptions(allModels, filter);
+
+ return searched.filter((option) => {
+ // every capability asked for has to be there, but a model whose chat template
+ // has not been read yet stays listed: the row fetches the record as it comes
+ // near the viewport, so hiding it would keep it from ever answering
+ if (
+ capabilityFilter.length > 0 &&
+ !capabilityFilter.every((capability) => modelCapability(option, capability) !== false)
+ ) {
+ return false;
+ }
+
+ // a model whose modalities are unknown cannot be shown to match
+ if (modalityFilter.length > 0 && !modalityFilter.some((key) => option.modalities?.[key])) {
+ return false;
+ }
+
+ if (contextLimit === 0) return true;
+
+ // the context of a row arrives with its Hub record, so a model we cannot read
+ // yet stays listed; only a known context below the limit rules it out
+ const context = modelContextLength(option);
+
+ return context === null || context >= contextLimit;
+ });
+ });
+
+ // recently used models lead their section, the rest keep the server's order
+ let rank = $derived.by(() => {
+ const map = new SvelteMap<string, number>();
+
+ modelsStore.recentModelIds.forEach((id, index) => map.set(id, index));
+
+ return map;
+ });
+
+ const rankOf = (entry: ModelQuantGroup) =>
+ Math.min(...entry.quants.map((quant) => rank.get(quant.id) ?? Number.MAX_SAFE_INTEGER));
+ const byRecency = (list: ModelQuantGroup[]) =>
+ rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
+ // one entry per repo, so a model with several quants takes a single table row
+ let entries = $derived(byRecency(groupModelQuants(matching)));
+ // tracked downloads get their own section, listed like any other model
+ let downloads = $derived(
+ downloadGroups(modelsStore.status.getDownloadEntries(), modelsStore.models)
+ );
+ let groups = $derived.by(() => {
+ // a loaded quant is a model of its own: its repo keeps the quants left behind
+ const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
+ // a tracked download stands in its own section, so it is not listed twice
+ const isDownload = (option: ModelOption) =>
+ modelsStore.status.isDownloadInProgress(option.model) ||
+ modelsStore.status.isDownloadPaused(option.model);
+ const loaded: ModelQuantGroup[] = [];
+ const rest: ModelQuantGroup[] = [];
+
+ for (const entry of entries) {
+ const remaining = entry.quants.filter((quant) => !isLoaded(quant) && !isDownload(quant));
+
+ for (const quant of entry.quants) {
+ if (!isLoaded(quant)) continue;
+
+ loaded.push({ ...entry, base: quant, key: quant.id, quants: [quant] });
+ }
+
+ if (remaining.length > 0) rest.push({ ...entry, base: remaining[0], quants: remaining });
+ }
+
+ const claimed = new SvelteSet<string>();
+ const favorites = rest.filter((entry) =>
+ entry.quants.some((q) => modelsStore.favoriteModelIds.has(q.model))
+ );
+
+ for (const entry of favorites) claimed.add(entry.key);
+
+ const { hidden, local } = splitHiddenQuants(
+ rest.filter((entry) => !claimed.has(entry.key)),
+ (option) => modelsStore.isHidden(option.id)
+ );
+ const ordered: ModelsTableGroup[] = [];
+ // loaded models lead the table, then favorites, then the local block
+ const pushSection = (kind: ModelsTableGroupKind, items: ModelQuantGroup[]): void => {
+ if (items.length === 0) return;
+
+ ordered.push({
+ items,
+ key: kind === ModelsTableGroupKind.LOCAL ? LOCAL_BACKEND_ID : kind,
+ kind,
+ label: MODELS_TABLE_GROUP_LABELS[kind]
+ });
+ };
+
+ pushSection(ModelsTableGroupKind.LOADED, loaded);
+ pushSection(ModelsTableGroupKind.DOWNLOADING, downloads);
+ pushSection(ModelsTableGroupKind.FAVORITES, favorites);
+ pushSection(ModelsTableGroupKind.LOCAL, local);
+ pushSection(ModelsTableGroupKind.HIDDEN, hidden);
+
+ return ordered;
+ });
+
+ // a caller can ask for one model to be revealed, the download rows do
+ $effect(() => {
+ const focus = uiStore.manageModelFocus;
+
+ if (!focus) return;
+
+ const option = allModels.find((model) => model.id === focus || model.model === focus);
+
+ if (option) selectedId = option.id;
+
+ uiStore.manageModelFocus = null;
+ });
+ async function toggleLoad(option: ModelOption): Promise<void> {
+ if (modelsStore.isModelLoaded(option.model)) {
+ await modelsStore.status.unload(option.model);
+
+ return;
+ }
+
+ await modelsStore.status.load(option.model);
+ }
+
+ /** The chat sits behind the dialog, so the dialog closes and the composer takes focus. */
+ function returnToChat(): void {
+ uiStore.manageModelsOpen = false;
+ uiStore.requestComposerFocus();
+ }
+
+ /** Load the model when the server can take load requests, without waiting for it. */
+ function loadInBackground(option: ModelOption): void {
+ // only the built-in server loads on request, and only in router mode
+ if (serverStore.isRouterMode && !modelsStore.isModelLoaded(option.model)) {
+ modelsStore.status
+ .load(option.model)
+ .catch((error) => console.error('Failed to load model:', error));
+ }
+ }
+
+ /** Switch the open chat to this model, the way the desktop model dropdown does. */
+ async function useInChat(option: ModelOption): Promise<void> {
+ // a deliberate pick records the recency; useInNewChat skips it because a new
+ // conversation is where the pick is exercised, not a return to a model the
+ // user went looking for
+ await modelsStore.selectModelById(option.id, { recordRecent: true });
+
+ loadInBackground(option);
+
+ returnToChat();
+ }
+
+ async function useInNewChat(option: ModelOption): Promise<void> {
+ await modelsStore.selectModelById(option.id);
+ await conversationsStore.openNewChat();
+
+ loadInBackground(option);
+
+ returnToChat();
+ }
+</script>
+
+<div class={['relative flex min-h-0 flex-1', className]}>
+ <div class="min-h-0 min-w-0 flex-1">
+ <ModelsManagerModelsTable
+ bind:capabilities={capabilityFilter}
+ bind:contextLimit
+ bind:filter
+ bind:modalities={modalityFilter}
+ {groups}
+ {isFavorite}
+ onSelect={(option) => (selectedId = option.id)}
+ {selectedId}
+ {toolbarEnd}
+ />
+ </div>
+
+ <!-- on a phone the pane covers the whole dialog: the manager header would only
+ repeat what the pane's own header says -->
+ <div
+ class="pane-drawer max-md:fixed max-md:inset-0 max-md:z-[60] shrink-0"
+ data-open={selected !== null}
+ >
+ <!-- the content box keeps the open width, so it never reflows with the drawer -->
+ <div
+ class="pane-content flex h-full min-h-0 w-[30rem] max-w-[30rem] flex-col border-l border-border/40 max-md:w-full max-md:max-w-none max-md:border-l-0 max-md:bg-background"
+ data-fade={fade}
+ data-visible={selected !== null && !isSwapping}
+ >
+ {#if shownOption}
+ {#key shownId}
+ <ModelsManagerModelConfiguration
+ onClose={() => (selectedId = null)}
+ onToggleLoad={() => void toggleLoad(shownOption)}
+ onUseInChat={() => void useInChat(shownOption)}
+ onUseInNewChat={() => void useInNewChat(shownOption)}
+ option={shownOption}
+ />
+ {/key}
+ {/if}
+ </div>
+ </div>
+</div>
+
+<style>
+ /*
+ * The drawer moves by width because the table behind it gets that space back, so
+ * the content box inside holds the open width and only the container changes.
+ * Opening takes the iOS-like drawer curve; closing is the system responding, so
+ * it snaps back on the stronger ease-out.
+ */
+ .pane-drawer {
+ width: 0;
+ overflow: clip;
+ visibility: hidden;
+ transition:
+ width 120ms cubic-bezier(0.23, 1, 0.32, 1),
+ visibility 120ms;
+ }
+
+ .pane-drawer[data-open='true'] {
+ width: 30rem;
+ visibility: visible;
+ transition:
+ width 200ms cubic-bezier(0.32, 0.72, 0, 1),
+ visibility 200ms;
+ }
+
+ .pane-content {
+ opacity: 0;
+ transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
+ }
+
+ .pane-content[data-visible='true'] {
+ opacity: 1;
+ }
+
+ /* opening: the fade waits for the drawer to move */
+ .pane-content[data-visible='true'][data-fade='open'] {
+ transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1) 80ms;
+ }
+
+ /* swapping models: out, then in, with no pause */
+ .pane-content[data-visible='true'][data-fade='swap'] {
+ transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
+ }
+
+ /* a phone has no room beside the table: the pane covers it instead */
+ @media (max-width: 767px) {
+ .pane-drawer[data-open='true'] {
+ width: auto;
+ }
+ }
+</style>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerDownloadControl.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerDownloadControl.svelte
new file mode 100644
index 000000000..0e81500f2
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerDownloadControl.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import { downloadProgressFor } from './utils';
+ import { ActionIcon } from '$lib/components/app';
+ import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
+ import { ModelRowDownloadState } from '$lib/enums';
+ import { modelsStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ class?: string;
+ option: ModelOption;
+ /** Download state the row stands for. */
+ state: ModelRowDownloadState;
+ }
+
+ let { class: className = '', option, state }: Props = $props();
+
+ let isPaused = $derived(state === ModelRowDownloadState.PAUSED);
+ let progress = $derived(downloadProgressFor(option.model));
+ let percent = $derived(
+ progress && progress.totalBytes > 0
+ ? Math.round((progress.downloadedBytes / progress.totalBytes) * 100)
+ : null
+ );
+</script>
+
+<!-- the percent is what the row shows at rest; the action takes its place on hover,
+ the way the load control does in the other sections -->
+<div class={['flex items-center justify-center', className]}>
+ <span
+ class="text-xs text-muted-foreground tabular-nums group-hover:hidden [@media(pointer:coarse)]:hidden"
+ >
+ {percent !== null ? `${percent}%` : isPaused ? 'Paused' : ''}
+ </span>
+
+ <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
+ <ActionIcon
+ class="h-5 w-5 hover:text-foreground"
+ icon={isPaused ? MODEL_DOWNLOAD_ICONS.resume : MODEL_DOWNLOAD_ICONS.pause}
+ iconSize="h-4 w-4"
+ onclick={() =>
+ void (isPaused
+ ? modelsStore.status.downloadModel(option.model)
+ : modelsStore.status.pauseDownload(option.model))}
+ stopPropagationOnClick
+ tooltip={isPaused ? 'Resume downloading' : 'Pause downloading'}
+ tooltipAsTitle
+ />
+ </div>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
new file mode 100644
index 000000000..8b4c3bf37
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
@@ -0,0 +1,135 @@
+<script lang="ts">
+ import { ScrollCarousel } from '$lib/components/app';
+ import * as Select from '$lib/components/ui/select';
+ import * as ToggleGroup from '$lib/components/ui/toggle-group';
+ import {
+ CAPABILITY_ICONS,
+ CAPABILITY_LABELS,
+ FILTER_TOGGLE_ITEM_CLASS,
+ FILTER_TRIGGER_CLASS,
+ MODALITY_FLAG_KEYS,
+ MODALITY_ICONS,
+ MODALITY_KEYS,
+ MODALITY_LABELS,
+ MODALITY_ORDER,
+ type ModalityKey
+ } from '$lib/constants';
+ import { ModelCapability } from '$lib/enums';
+ import { modelsStore } from '$lib/stores';
+ import { SvelteSet } from 'svelte/reactivity';
+
+ interface Props {
+ /** Capabilities a model must have every one of. */
+ capabilities?: ModelCapability[];
+ /** Smallest context a model must support; 0 keeps every model. */
+ contextLimit?: number;
+ /** Modalities a model must support at least one of. */
+ modalities?: ModalityKey[];
+ }
+
+ let {
+ capabilities = $bindable<ModelCapability[]>([]),
+ contextLimit = $bindable(0),
+ modalities = $bindable<ModalityKey[]>([])
+ }: Props = $props();
+
+ const CONTEXT_STEPS: { label: string; value: number }[] = [
+ { label: 'Any context', value: 0 },
+ { label: '8K or more', value: 8192 },
+ { label: '32K or more', value: 32768 },
+ { label: '128K or more', value: 131_072 },
+ { label: '256K or more', value: 262_144 },
+ { label: '1M or more', value: 1_048_576 }
+ ];
+
+ const CAPABILITY_TOGGLES = [ModelCapability.TOOL_USE, ModelCapability.REASONING].map((value) => ({
+ icon: CAPABILITY_ICONS[value],
+ label: CAPABILITY_LABELS[value],
+ value
+ }));
+
+ // only the modalities the models at hand actually carry: a toggle for something
+ // none of them supports could only ever empty the table
+ let detectedModalities = $derived.by(() => {
+ const keys = new SvelteSet<ModalityKey>();
+
+ for (const option of modelsStore.models) {
+ for (const key of MODALITY_KEYS) {
+ if (option.modalities?.[key]) keys.add(key);
+ }
+ }
+
+ return keys;
+ });
+ let modalityToggles = $derived(
+ MODALITY_ORDER.filter((modality) => detectedModalities.has(MODALITY_FLAG_KEYS[modality])).map(
+ (modality) => ({
+ icon: MODALITY_ICONS[modality],
+ label: MODALITY_LABELS[modality],
+ value: MODALITY_FLAG_KEYS[modality]
+ })
+ )
+ );
+
+ // one group holds what a model can do and what it can accept
+ let toggles = $derived([...CAPABILITY_TOGGLES, ...modalityToggles]);
+ const CAPABILITY_VALUES = new Set<string>(CAPABILITY_TOGGLES.map((entry) => entry.value));
+ // every flag value, so a choice made before a model disappeared still round-trips
+ const MODALITY_VALUES = new Set<string>(MODALITY_KEYS);
+
+ // the group holds one flat list, so a change splits back into the two filters
+ function setToggles(values: string[]): void {
+ capabilities = values.filter((value): value is ModelCapability => CAPABILITY_VALUES.has(value));
+ modalities = values.filter((value): value is ModalityKey => MODALITY_VALUES.has(value));
+ }
+
+ let contextLabel = $derived(
+ CONTEXT_STEPS.find((step) => step.value === contextLimit)?.label ?? CONTEXT_STEPS[0].label
+ );
+</script>
+
+<!-- below md the carousel keeps a row of its own: a toolbar that also holds a call to
+ action would otherwise squeeze the filters out of sight -->
+<ScrollCarousel
+ alwaysShowArrows
+ class="min-w-0 flex-1 max-md:w-full max-md:flex-none"
+ gapSize="2"
+ innerClass="items-center"
+>
+ <Select.Root
+ onValueChange={(value) => (contextLimit = Number(value))}
+ type="single"
+ value={String(contextLimit)}
+ >
+ <Select.Trigger class={FILTER_TRIGGER_CLASS} size="sm">
+ <span class="text-muted-foreground">Context:</span>
+
+ {contextLabel}
+ </Select.Trigger>
+
+ <Select.Content>
+ {#each CONTEXT_STEPS as step (step.value)}
+ <Select.Item label={step.label} value={String(step.value)}>{step.label}</Select.Item>
+ {/each}
+ </Select.Content>
+ </Select.Root>
+
+ <ToggleGroup.Root
+ class="border border-border/30 bg-muted/60 shadow-sm dark:border-border/20 dark:bg-muted/75"
+ onValueChange={setToggles}
+ type="multiple"
+ value={[...capabilities, ...modalities]}
+ variant="outline"
+ >
+ {#each toggles as toggle (toggle.value)}
+ <ToggleGroup.Item
+ aria-label={toggle.label}
+ class={FILTER_TOGGLE_ITEM_CLASS}
+ title={toggle.label}
+ value={toggle.value}
+ >
+ <toggle.icon class="h-3.5 w-3.5" />
+ </ToggleGroup.Item>
+ {/each}
+ </ToggleGroup.Root>
+</ScrollCarousel>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte
new file mode 100644
index 000000000..8a109a8db
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte
@@ -0,0 +1,110 @@
+<script lang="ts">
+ import ModelsManagerModelConfigurationActions from './ModelsManagerModelConfigurationActions.svelte';
+ import ModelsManagerModelConfigurationHeader from './ModelsManagerModelConfigurationHeader.svelte';
+ import ModelsManagerModelConfigurationInformation from './ModelsManagerModelConfigurationInformation.svelte';
+ import { Trash2 } from '@lucide/svelte';
+ import { DialogConfirmDownload } from '$lib/components/app/dialogs';
+ import { Button } from '$lib/components/ui/button';
+ import { ModelDownloadConfirmAction, ServerModelStatus } from '$lib/enums';
+ import { HuggingFaceService } from '$lib/services';
+ import { deviceStore, modelsStore } from '$lib/stores';
+ import type { HfModelDetailInfo } from '$lib/types/huggingface';
+ import type { ModelOption } from '$lib/types/models';
+ import { repoOf } from '$lib/utils';
+
+ interface Props {
+ onClose: () => void;
+ onToggleLoad: () => void;
+ onUseInChat: () => void;
+ onUseInNewChat: () => void;
+ option: ModelOption;
+ }
+
+ let { onClose, onToggleLoad, onUseInChat, onUseInNewChat, option }: Props = $props();
+
+ // the same removal the table row offers, confirmed by the same dialog
+ let deleteOpen = $state(false);
+
+ // the props cache is a plain Map, so the read has to name its version to stay reactive
+ let serverProps = $derived.by(() => {
+ void modelsStore.props.cacheVersion;
+
+ return modelsStore.props.getModelProps(option.model);
+ });
+ let status = $derived(modelsStore.getModelStatus(option.model));
+ let isLoaded = $derived(modelsStore.isModelRunning(option.model));
+
+ // The server reports the full metadata only once a model is loaded, which loads it.
+ // With the Hub enabled, it fills those gaps instead.
+ let hubDetails = $state<HfModelDetailInfo | null>(null);
+
+ $effect(() => {
+ const repo = repoOf(option.model);
+
+ let cancelled = false;
+
+ hubDetails = null;
+
+ if (!HuggingFaceService.isEnabled() || !repo.includes('/')) {
+ return;
+ }
+
+ void HuggingFaceService.getDetails(repo)
+ .then((details) => {
+ if (!cancelled) hubDetails = details;
+ })
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ });
+</script>
+
+<div class="flex h-full min-h-0 flex-col">
+ <ModelsManagerModelConfigurationHeader
+ {isLoaded}
+ {onClose}
+ {onToggleLoad}
+ {onUseInChat}
+ {onUseInNewChat}
+ {option}
+ {status}
+ />
+
+ <div class="min-h-0 flex-1 overflow-y-auto py-4 pl-4 max-md:pr-4">
+ <ModelsManagerModelConfigurationInformation hub={hubDetails} {option} {serverProps} />
+
+ <div class="mt-4 border-t border-border/30 pt-3 pb-2">
+ <Button
+ class="w-full justify-start gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20"
+ onclick={() => (deleteOpen = true)}
+ size="sm"
+ variant="ghost"
+ >
+ <Trash2 class="h-4 w-4" />
+ Delete this model from disk
+ </Button>
+ </div>
+ </div>
+
+ <!-- a phone keeps the actions at the bottom of its screen, in reach of the thumb -->
+ {#if deviceStore.isMobile}
+ <div class="shrink-0 border-t border-border/40 px-4 py-3">
+ <ModelsManagerModelConfigurationActions
+ {isLoaded}
+ isLoading={status === ServerModelStatus.LOADING}
+ {onToggleLoad}
+ {onUseInChat}
+ {onUseInNewChat}
+ />
+ </div>
+ {/if}
+
+ <DialogConfirmDownload
+ action={ModelDownloadConfirmAction.DELETE}
+ onClose={() => (deleteOpen = false)}
+ open={deleteOpen}
+ repoWithTag={option.model}
+ />
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationActions.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationActions.svelte
new file mode 100644
index 000000000..b7fc10997
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationActions.svelte
@@ -0,0 +1,85 @@
+<script lang="ts">
+ import { Eject, Loader2, MessageSquare, Power, SquarePen } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { conversationsStore, deviceStore } from '$lib/stores';
+
+ interface Props {
+ /** A provider that cannot load or unload has no button to offer. */
+ canToggleLoad?: boolean;
+ isLoaded: boolean;
+ isLoading: boolean;
+ onToggleLoad: () => void;
+ onUseInChat: () => void;
+ onUseInNewChat: () => void;
+ /** The header gives the actions room to breathe, the phone bar keeps them small. */
+ size?: 'sm' | 'default';
+ }
+
+ let {
+ canToggleLoad = true,
+ isLoaded,
+ isLoading,
+ onToggleLoad,
+ onUseInChat,
+ onUseInNewChat,
+ size = 'sm'
+ }: Props = $props();
+
+ // a chat with anything in it takes the model, a new chat is not what its user asked for
+ let hasChat = $derived(
+ conversationsStore.activeConversation !== null ||
+ (conversationsStore.activeMessages as DatabaseMessage[]).length > 0
+ );
+ let isMobile = $derived(deviceStore.isMobile);
+</script>
+
+<div class="my-3 flex flex-col gap-2">
+ {#if isMobile}
+ <!-- a phone picks its model in the manager, so pointing this chat at it leads -->
+ <Button class="w-full gap-1.5" onclick={hasChat ? onUseInChat : onUseInNewChat} {size}>
+ {#if hasChat}
+ <MessageSquare class="h-3.5 w-3.5" />
+
+ Use in this chat
+ {:else}
+ <SquarePen class="h-3.5 w-3.5" />
+
+ Start a new chat
+ {/if}
+ </Button>
+ {/if}
+
+ <div class="flex gap-3 mt-2">
+ {#if !isMobile}
+ <Button class="flex-1 gap-1.5" onclick={onUseInNewChat} {size}>
+ <SquarePen class="h-3.5 w-3.5" />
+
+ Start a new chat
+ </Button>
+ {/if}
+
+ {#if canToggleLoad}
+ <Button
+ class="flex-1 gap-1.5"
+ disabled={isLoading}
+ onclick={onToggleLoad}
+ {size}
+ variant="secondary"
+ >
+ {#if isLoading}
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
+
+ Loading...
+ {:else if isLoaded}
+ <Eject class="h-3.5 w-3.5" />
+
+ Unload model
+ {:else}
+ <Power class="h-3.5 w-3.5" />
+
+ Load model
+ {/if}
+ </Button>
+ {/if}
+ </div>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationHeader.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationHeader.svelte
new file mode 100644
index 000000000..40f5d0d54
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationHeader.svelte
@@ -0,0 +1,111 @@
+<script lang="ts">
+ import ModelsManagerModelConfigurationActions from './ModelsManagerModelConfigurationActions.svelte';
+ import { X } from '@lucide/svelte';
+ import { ModelAvatar, ModelId } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { ServerModelStatus } from '$lib/enums';
+ import { deviceStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ isLoaded: boolean;
+ onClose: () => void;
+ onToggleLoad: () => void;
+ onUseInChat: () => void;
+ onUseInNewChat: () => void;
+ option: ModelOption;
+ /** Load state reported by the server, null when it does not report one. */
+ status: ServerModelStatus | null;
+ }
+
+ let { isLoaded, onClose, onToggleLoad, onUseInChat, onUseInNewChat, option, status }: Props =
+ $props();
+
+ let isMobile = $derived(deviceStore.isMobile);
+ let isLoading = $derived(status === ServerModelStatus.LOADING);
+
+ let statusLabel = $derived.by(() => {
+ if (status === ServerModelStatus.LOADING) return 'Loading';
+
+ if (status === ServerModelStatus.FAILED) return 'Failed to load';
+
+ if (status === ServerModelStatus.SLEEPING) return 'Sleeping';
+
+ if (isLoaded) return 'Loaded';
+
+ return 'Not loaded';
+ });
+
+ let statusDot = $derived.by(() => {
+ if (status === ServerModelStatus.FAILED) return 'bg-red-500';
+
+ if (status === ServerModelStatus.LOADING) return 'bg-muted-foreground/50 animate-pulse';
+
+ if (status === ServerModelStatus.SLEEPING) return 'bg-orange-400';
+
+ if (isLoaded) return 'bg-green-500';
+
+ return 'bg-muted-foreground/50';
+ });
+</script>
+
+<!-- only the desktop pane leaves the right edge to the panel border: on a phone the
+ pane covers the screen, so its two sides carry the same padding, and it takes a
+ little more room around the model it names -->
+<header class="space-y-2.5 pt-3 pl-4 max-md:space-y-4 max-md:pt-4 max-md:pr-4 max-md:pb-4">
+ <div class="flex items-start justify-between gap-3">
+ <div class="flex min-w-0 items-center gap-3">
+ <!-- same geometry as the discover details header: base org, quant org badge -->
+ <ModelAvatar
+ {option}
+ quantPositionClass="-bottom-1.5 -right-1.5"
+ quantSize="h-6 w-6"
+ showBaseModelAvatar
+ size="h-12 w-12"
+ />
+
+ <div class="min-w-0">
+ <!-- a phone gives the id a row of its own, with its badges and icons under it -->
+ <ModelId
+ aliases={option.aliases}
+ class="min-w-0"
+ modalities={option.modalities}
+ modelId={option.model}
+ stackId={deviceStore.isMobile}
+ tags={option.tags}
+ title={option.model}
+ />
+
+ <p class="mt-1 flex items-center gap-x-2 text-xs text-muted-foreground">
+ <span class="flex shrink-0 items-center gap-1">
+ <span class="h-2 w-2 shrink-0 rounded-full {statusDot}"></span>
+
+ {statusLabel}
+ </span>
+ </p>
+ </div>
+ </div>
+
+ <Button
+ aria-label="Close details"
+ class="h-7 w-7 max-md:-mt-3 max-md:-mr-3 max-md:h-10 max-md:w-10 max-md:rounded-full"
+ onclick={onClose}
+ size="icon"
+ variant="ghost"
+ >
+ <X class="h-4 w-4" />
+ </Button>
+ </div>
+
+ <!-- a phone floats the actions at the bottom of the pane, where the thumb is -->
+ {#if !isMobile}
+ <ModelsManagerModelConfigurationActions
+ {isLoaded}
+ {isLoading}
+ {onToggleLoad}
+ {onUseInChat}
+ {onUseInNewChat}
+ size="default"
+ />
+ {/if}
+</header>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationInformation.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationInformation.svelte
new file mode 100644
index 000000000..7eed08516
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationInformation.svelte
@@ -0,0 +1,197 @@
+<script lang="ts">
+ import {
+ ActionIconCopyToClipboard,
+ BadgesModality,
+ CollapsibleSection
+ } from '$lib/components/app';
+ import { Badge } from '$lib/components/ui/badge';
+ import { HuggingFaceService } from '$lib/services';
+ import { modelsStore } from '$lib/stores';
+ import type { ApiLlamaCppServerProps } from '$lib/types/api';
+ import type { HfModelDetailInfo } from '$lib/types/huggingface';
+ import type { ModelOption } from '$lib/types/models';
+ import { formatFileSize, formatNumber, formatParameters } from '$lib/utils/formatters';
+
+ interface Props {
+ /** Hub details, when the Hub is enabled and the server reports none. */
+ hub?: HfModelDetailInfo | null;
+ option: ModelOption;
+ serverProps: ApiLlamaCppServerProps | null | undefined;
+ }
+
+ let { hub = null, option, serverProps }: Props = $props();
+
+ let meta = $derived(option.meta);
+ let gguf = $derived(hub?.gguf ?? null);
+ let quant = $derived(option.parsedId?.quantization ?? null);
+ // a GGUF carries the count in its metadata, a transformers repo in its SafeTensors index
+ let hubParameters = $derived(HuggingFaceService.parameterCount(hub));
+ let draftSidecars = $derived(option.draftSidecars ?? []);
+
+ let modalities = $derived.by(() => {
+ void modelsStore.props.cacheVersion;
+
+ return modelsStore.props.getModelModalitiesArray(option.id);
+ });
+
+ let rows = $derived([
+ {
+ isCopyable: true,
+ isMono: true,
+ label: 'File Path',
+ value: serverProps?.model_path ?? null
+ },
+ {
+ label: 'Context Size',
+ // the server reports the context it runs with once the model is loaded; until
+ // then the listing, or the Hub, says what the model can take
+ value: serverProps
+ ? `${formatNumber(serverProps.default_generation_settings?.n_ctx ?? 0)} tokens`
+ : option.contextLength
+ ? `${formatNumber(option.contextLength)} tokens`
+ : gguf?.context_length
+ ? `${formatNumber(gguf.context_length)} tokens`
+ : null
+ },
+ { label: 'Model Size', value: meta?.size ? formatFileSize(meta.size) : null },
+ {
+ label: 'Parameters',
+ value: meta?.n_params
+ ? formatParameters(meta.n_params)
+ : (option.parsedId?.params ?? (hubParameters ? formatParameters(hubParameters) : null))
+ },
+ { label: 'Embedding Size', value: meta?.n_embd ? formatNumber(meta.n_embd) : null },
+ {
+ label: 'Vocabulary Size',
+ value: meta?.n_vocab ? `${formatNumber(meta.n_vocab)} tokens` : null
+ },
+ { isBadge: true, label: 'Quantization', value: quant },
+ {
+ isBadge: true,
+ label: 'Architecture',
+ value: (meta?.architecture as string) ?? gguf?.architecture ?? null
+ },
+ {
+ isBadge: true,
+ label: 'Draft sidecars',
+ value:
+ draftSidecars.length > 0
+ ? draftSidecars.map((badge) => badge.kind.toUpperCase()).join(', ')
+ : null
+ },
+ {
+ isCopyable: true,
+ label: 'Draft repo',
+ value: draftSidecars[0]?.repo ?? null
+ },
+ {
+ isBadge: true,
+ label: 'Draft quant',
+ value: draftSidecars[0]?.quant ?? null
+ },
+ {
+ label: 'Parallel Slots',
+ value: serverProps?.total_slots != null ? String(serverProps.total_slots) : null
+ },
+ { isMono: true, label: 'Build Info', value: serverProps?.build_info ?? null }
+ ] satisfies Array<{
+ isBadge?: boolean;
+ isCopyable?: boolean;
+ isMono?: boolean;
+ label: string;
+ value: string | null;
+ }>);
+
+ // rows without a value say nothing; a loaded model fills the rest in from /props
+ let visibleRows = $derived(rows.filter((row) => row.value !== null));
+
+ const sectionTrigger = 'flex w-full cursor-pointer items-center gap-2 py-2 text-left';
+</script>
+
+<div class="space-y-0">
+ <div class="flex items-center gap-3 border-b border-border/30 py-2.5">
+ <span class="text-sm text-muted-foreground">Model</span>
+
+ <span class="ml-auto flex min-w-0 flex-1 items-center gap-2">
+ <span class="min-w-0 flex-1 overflow-x-auto font-mono text-xs whitespace-nowrap">
+ {option.model}
+ </span>
+
+ <ActionIconCopyToClipboard
+ ariaLabel="Copy model name to clipboard"
+ canCopy
+ text={option.model}
+ />
+ </span>
+ </div>
+
+ {#each visibleRows as row (row.label)}
+ <div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
+ <span class="text-sm text-muted-foreground">{row.label}</span>
+
+ <span class="ml-auto flex min-w-0 items-center gap-2 {row.isCopyable ? 'flex-1' : ''}">
+ {#if row.value === null}
+ <span class="text-muted-foreground">-</span>
+ {:else}
+ <span
+ class={[
+ 'min-w-0',
+ // a copyable value scrolls instead of clipping, the icon stays put
+ row.isCopyable ? 'flex-1 overflow-x-auto whitespace-nowrap' : 'truncate',
+ row.isBadge ? '' : 'text-sm',
+ row.isMono ? 'font-mono text-xs' : ''
+ ]}
+ >
+ {#if row.isBadge}
+ <Badge class="h-5 px-1.5 text-[10px]" variant="secondary">{row.value}</Badge>
+ {:else}
+ {row.value}
+ {/if}
+ </span>
+
+ {#if row.isCopyable}
+ <ActionIconCopyToClipboard
+ ariaLabel="Copy value to clipboard"
+ canCopy
+ text={row.value}
+ />
+ {/if}
+ {/if}
+ </span>
+ </div>
+ {/each}
+
+ {#if visibleRows.length === 0}
+ <p class="py-2.5 text-sm text-muted-foreground">
+ The model reports its metadata once it is loaded.
+ </p>
+ {/if}
+
+ {#if modalities.length > 0}
+ <div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
+ <span class="text-sm text-muted-foreground">Modalities</span>
+
+ <span class="ml-auto flex flex-wrap gap-1"><BadgesModality {modalities} /></span>
+ </div>
+ {/if}
+</div>
+
+{#if !serverProps}
+ <p class="pt-2 text-xs text-muted-foreground">
+ /props values - file path, slots, build info - appear once the model is loaded. Reading this
+ page never loads a model.
+ </p>
+{/if}
+
+<div class="mt-4">
+ <CollapsibleSection triggerClass={sectionTrigger}>
+ {#snippet trigger()}
+ <span class="text-sm font-medium">Chat Template</span>
+ {/snippet}
+
+ <pre
+ class="mt-1 rounded-md bg-muted/50 p-2 text-xs whitespace-pre-wrap">{serverProps?.chat_template ??
+ gguf?.chat_template ??
+ 'Shown once the model is loaded.'}</pre>
+ </CollapsibleSection>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
new file mode 100644
index 000000000..f4d86b729
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte
@@ -0,0 +1,104 @@
+<script lang="ts">
+ import ModelAvatar from '../ModelAvatar.svelte';
+ import ModelCapabilities from '../ModelCapabilities.svelte';
+ import ModelContext from '../ModelContext.svelte';
+ import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
+ import ModelId from '../ModelId.svelte';
+ import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
+ import { modelRowActions } from './row-actions';
+ import { configuredContext, downloadProgressFor } from './utils';
+ import { MoreHorizontal } from '@lucide/svelte';
+ import { DropdownMenuActions } from '$lib/components/app';
+ import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
+ import { ModelRowDownloadState } from '$lib/enums';
+ import { deviceStore, modelsStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ isFavorite: (option: ModelOption) => boolean;
+ option: ModelOption;
+ onDelete: (option: ModelOption) => void;
+ onSelect: (option: ModelOption) => void;
+ selected: boolean;
+ /** Left padding in px, from the nesting depth. */
+ indent?: number;
+ }
+
+ let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
+
+ let favorite = $derived(isFavorite(option));
+ let isHidden = $derived(modelsStore.isHidden(option.id));
+ // live while the download runs, frozen while it is paused
+ let downloadProgress = $derived(downloadProgressFor(option.model));
+ // a tracked download takes over the status column while it runs
+ let download = $derived(
+ modelsStore.status.isDownloadPaused(option.model)
+ ? ModelRowDownloadState.PAUSED
+ : modelsStore.status.isDownloadInProgress(option.model)
+ ? ModelRowDownloadState.DOWNLOADING
+ : null
+ );
+</script>
+
+<div
+ class={[
+ MODEL_ROW_GRID_CLASS,
+ 'group relative rounded-md px-2 py-3 transition max-md:px-3 max-md:py-4',
+ isHidden && 'opacity-60',
+ selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
+ ]}
+>
+ <!-- the row holds a load control and an actions menu, so only the model itself is
+ the button: a button nested in a role="button" row is invalid -->
+ <button
+ aria-pressed={selected}
+ class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:gap-2 max-md:pr-9"
+ onclick={() => onSelect(option)}
+ style="padding-left: {indent}px"
+ type="button"
+ >
+ <ModelAvatar {option} size="size-7 md:size-9" />
+
+ <span class="flex min-w-0 items-center gap-1.25">
+ <ModelId
+ aliases={option.aliases}
+ class="min-w-0 flex-1 max-md:text-sm"
+ draftSidecars={option.draftSidecars}
+ hideCapabilities
+ hideModalities
+ modalities={option.modalities}
+ modelId={option.model}
+ tags={option.tags}
+ title={option.model}
+ />
+
+ <!-- a phone has no width for the modality icons, the id needs it more -->
+ <ModelCapabilities hideModalities={deviceStore.isMobile} {option} />
+ </span>
+ </button>
+
+ <ModelContext
+ class="justify-self-end max-md:hidden"
+ configured={configuredContext(option)}
+ {option}
+ />
+
+ <ModelsManagerStatusCell class="max-md:hidden" {download} {option} />
+
+ {#if download}
+ <ModelDownloadProgressBar
+ downloadedBytes={downloadProgress?.downloadedBytes ?? 0}
+ overlay
+ totalBytes={downloadProgress?.totalBytes ?? 0}
+ />
+ {/if}
+
+ <div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
+ <DropdownMenuActions
+ actions={modelRowActions(option, favorite, isHidden, onDelete, download)}
+ align="end"
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="Model actions"
+ />
+ </div>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
new file mode 100644
index 000000000..2a3c2206b
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
@@ -0,0 +1,422 @@
+<script lang="ts">
+ import ModelsManagerModelRow from './ModelsManagerModelRow.svelte';
+ import ModelsManagerQuantRow from './ModelsManagerQuantRow.svelte';
+ import ModelsManagerRepoRow from './ModelsManagerRepoRow.svelte';
+ import ModelsManagerTableToolbar from './ModelsManagerTableToolbar.svelte';
+ import {
+ hasActiveFilters,
+ modelContextLength,
+ type ModelQuantGroup,
+ type ModelsTableGroup,
+ statusRank
+ } from './utils';
+ import {
+ ArrowDown,
+ ArrowUp,
+ ChevronDown,
+ ChevronUp,
+ Download,
+ EyeOff,
+ Heart,
+ Power
+ } from '@lucide/svelte';
+ import {
+ CollapsibleRegion,
+ GroupedList,
+ type GroupedListGroup,
+ GroupedListUnit,
+ Logo,
+ ModelAvatar,
+ ModelsSection
+ } from '$lib/components/app';
+ import { DialogConfirmDownload } from '$lib/components/app/dialogs';
+ import {
+ FAMILY_ROW_WINDOW,
+ type ModalityKey,
+ MODEL_ROW_GRID_CLASS,
+ MODEL_ROW_TRAILING_CELL_CLASS,
+ MODEL_ROW_WINDOW,
+ MODELS_TABLE_GROUP_STICKY_OFFSET,
+ SETTINGS_KEYS
+ } from '$lib/constants';
+ import {
+ KeyboardKey,
+ ModelCapability,
+ ModelDownloadConfirmAction,
+ ModelsTableGroupKind,
+ ModelsTableSortKey
+ } from '$lib/enums';
+ import { modelsStore, settingsStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+ import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
+ import type { Snippet } from 'svelte';
+ import { SvelteSet } from 'svelte/reactivity';
+
+ interface Props {
+ /** Capabilities a model must have every one of. */
+ capabilities?: ModelCapability[];
+ /** Smallest context a model must support; 0 keeps every model. */
+ contextLimit?: number;
+ filter?: string;
+ groups: ModelsTableGroup[];
+ isFavorite: (option: ModelOption) => boolean;
+ onSelect: (option: ModelOption) => void;
+ /** Modalities a model must support at least one of. */
+ modalities?: ModalityKey[];
+ selectedId: string | null;
+ /** Rendered at the toolbar's right end, past the filters. */
+ toolbarEnd?: Snippet;
+ }
+
+ let {
+ capabilities = $bindable<ModelCapability[]>([]),
+ contextLimit = $bindable(0),
+ filter = $bindable(''),
+ groups,
+ isFavorite,
+ modalities = $bindable<ModalityKey[]>([]),
+ onSelect,
+ selectedId,
+ toolbarEnd
+ }: Props = $props();
+
+ let isEmpty = $derived(groups.every((group) => group.items.length === 0));
+ let hasFilters = $derived(hasActiveFilters(contextLimit, modalities, capabilities));
+
+ /** Noun the show-more row counts in, per unit of the grouped list. */
+ const SHOW_MORE_NOUNS: Record<GroupedListUnit, string> = {
+ [GroupedListUnit.ENTRIES]: 'models',
+ [GroupedListUnit.GROUPS]: 'families'
+ };
+ let pendingDelete = $state('');
+ let deleteOpen = $state(false);
+ /** Repos whose quants are folded away; the rest show them. */
+ const collapsedQuants = new SvelteSet<string>();
+ /** Sections that list their models straight, without folding them into families. */
+ const FLAT_SECTIONS = new Set<ModelsTableGroupKind>([
+ ModelsTableGroupKind.DOWNLOADING,
+ ModelsTableGroupKind.FAVORITES,
+ ModelsTableGroupKind.LOADED
+ ]);
+ let sections = $derived(
+ groups.map((group) => {
+ // a flat section lists its models straight, families or not
+ const flat =
+ FLAT_SECTIONS.has(group.kind) ||
+ !settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY];
+ // downloads keep the feed's order: their progress, not their name, moves
+ const items =
+ group.kind === ModelsTableGroupKind.DOWNLOADING ? group.items : sortEntries(group.items);
+
+ return {
+ ...group,
+ families: flat ? [] : groupModelFamilies(items, (entry) => entry.base.model),
+ flat,
+ items
+ };
+ })
+ );
+
+ function familyGroups(
+ group: (typeof sections)[number]
+ ): GroupedListGroup<ModelFamilyGroup<ModelQuantGroup>, ModelQuantGroup>[] {
+ return group.families.map((family) => ({
+ entries: family.entries,
+ group: family,
+ key: `${group.key}::${family.key}`
+ }));
+ }
+
+ // cancel is confirmed once for the whole list, so one dialog serves every row
+ function requestDelete(option: ModelOption): void {
+ pendingDelete = option.model;
+ deleteOpen = true;
+ }
+
+ function toggleQuants(key: string): void {
+ if (collapsedQuants.has(key)) {
+ collapsedQuants.delete(key);
+ } else {
+ collapsedQuants.add(key);
+ }
+ }
+
+ /** Column the table is ordered by; unset keeps the manager's own order. */
+ let sortKey = $state<ModelsTableSortKey | null>(null);
+ let sortAsc = $state(true);
+
+ function compareEntries(left: ModelQuantGroup, right: ModelQuantGroup): number {
+ const a = left.base;
+ const b = right.base;
+
+ switch (sortKey) {
+ case ModelsTableSortKey.CONTEXT:
+ // a context we cannot read yet sorts with the smallest ones
+ return (modelContextLength(a) ?? 0) - (modelContextLength(b) ?? 0);
+ case ModelsTableSortKey.NAME:
+ return a.model.localeCompare(b.model);
+ case ModelsTableSortKey.STATUS:
+ // a running model leads, then one that is being worked on (loading,
+ // sleeping), then the rest; the reported status sorts the row's own cell
+ return statusRank(b) - statusRank(a);
+ default:
+ return 0;
+ }
+ }
+
+ function sortEntries(entries: ModelQuantGroup[]): ModelQuantGroup[] {
+ if (!sortKey) return entries;
+
+ const direction = sortAsc ? 1 : -1;
+
+ return [...entries].sort((a, b) => direction * compareEntries(a, b));
+ }
+
+ /** A click on a new column sorts lowest first, then highest first, then clears. */
+ function toggleSort(key: ModelsTableSortKey): void {
+ if (sortKey !== key) {
+ sortKey = key;
+ sortAsc = true;
+
+ return;
+ }
+
+ if (sortAsc) {
+ sortAsc = false;
+
+ return;
+ }
+
+ sortKey = null;
+ }
+
+ function sortTitle(key: ModelsTableSortKey, label: string): string {
+ const name = label.toLowerCase();
+
+ if (sortKey !== key) return `Sort by ${name}, lowest first`;
+
+ return sortAsc ? `Sort by ${name}, highest first` : `Stop sorting by ${name}`;
+ }
+
+ function handleFamilyKeydown(event: KeyboardEvent, toggle: () => void): void {
+ if (event.key === KeyboardKey.SPACE) event.preventDefault();
+
+ if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) toggle();
+ }
+</script>
+
+{#snippet sortHeader(key: ModelsTableSortKey, label: string)}
+ <button
+ class="inline-flex cursor-pointer items-center gap-1 uppercase transition hover:text-foreground focus:outline-none"
+ onclick={() => toggleSort(key)}
+ title={sortTitle(key, label)}
+ type="button"
+ >
+ {label}
+
+ {#if sortKey === key}
+ {#if sortAsc}
+ <ArrowUp class="h-3 w-3" />
+ {:else}
+ <ArrowDown class="h-3 w-3" />
+ {/if}
+ {/if}
+ </button>
+{/snippet}
+
+{#snippet entryTree(entry: ModelQuantGroup, indent = 0)}
+ {#if entry.quants.length > 1}
+ <ModelsManagerRepoRow
+ {entry}
+ expanded={!collapsedQuants.has(entry.key)}
+ {indent}
+ onToggle={() => toggleQuants(entry.key)}
+ />
+
+ <CollapsibleRegion open={!collapsedQuants.has(entry.key)}>
+ {#each entry.quants as quant (quant.id)}
+ <ModelsManagerQuantRow
+ indent={indent + 24}
+ {isFavorite}
+ onDelete={requestDelete}
+ {onSelect}
+ option={quant}
+ selected={selectedId === quant.id}
+ />
+ {/each}
+ </CollapsibleRegion>
+ {:else}
+ <ModelsManagerModelRow
+ {indent}
+ {isFavorite}
+ onDelete={requestDelete}
+ {onSelect}
+ option={entry.base}
+ selected={selectedId === entry.base.id}
+ />
+ {/if}
+{/snippet}
+
+{#snippet familyRow({
+ expanded,
+ group: family,
+ toggle
+}: {
+ expanded: boolean;
+ group: ModelFamilyGroup<ModelQuantGroup>;
+ toggle: () => void;
+})}
+ {@const countLabel = `${family.entries.length} model${family.entries.length === 1 ? '' : 's'}`}
+
+ <div
+ class="{MODEL_ROW_GRID_CLASS} relative group cursor-pointer rounded-md px-2 py-1 transition hover:bg-muted/40 max-md:px-3 max-md:py-2.5"
+ onclick={toggle}
+ onkeydown={(event) => handleFamilyKeydown(event, toggle)}
+ role="button"
+ tabindex="0"
+ >
+ <span class="flex min-w-0 items-center gap-3 max-md:pr-9">
+ <ModelAvatar
+ option={family.entries[0].base}
+ showBaseModelAvatar
+ showQuantBadge={false}
+ size="size-6"
+ />
+
+ <span class="truncate text-sm font-medium">{family.label}</span>
+
+ <span class="text-sm text-muted-foreground">{countLabel}</span>
+ </span>
+
+ <!-- the context and status columns stay empty here: the fold control is this
+ row's last cell, which a phone floats at the row's end -->
+ <span class="max-md:hidden"></span>
+
+ <span class="max-md:hidden"></span>
+
+ <span
+ class="flex justify-center {MODEL_ROW_TRAILING_CELL_CLASS} {expanded
+ ? 'opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100'
+ : ''}"
+ >
+ {#if expanded}
+ <ChevronUp class="h-3.5 w-3.5 text-muted-foreground" />
+ {:else}
+ <ChevronDown class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </span>
+ </div>
+{/snippet}
+
+{#snippet listItem({ depth, entry }: { depth: number; entry: ModelQuantGroup })}
+ {@render entryTree(entry, depth > 0 ? 16 : 0)}
+{/snippet}
+
+{#snippet showMore({
+ count,
+ onMore,
+ unit
+}: {
+ count: number;
+ onMore: () => void;
+ unit: GroupedListUnit;
+})}
+ <div class="px-2 max-md:px-3">
+ <button
+ class="w-full cursor-pointer rounded-md px-2 py-2 text-left text-xs text-muted-foreground transition hover:bg-muted/40"
+ onclick={onMore}
+ type="button"
+ >
+ Show {count} more {SHOW_MORE_NOUNS[unit]}
+ </button>
+ </div>
+{/snippet}
+
+<DialogConfirmDownload
+ action={ModelDownloadConfirmAction.DELETE}
+ onClose={() => (deleteOpen = false)}
+ open={deleteOpen}
+ repoWithTag={pendingDelete}
+/>
+
+<div class="flex h-full min-h-0 flex-col">
+ <ModelsManagerTableToolbar
+ bind:capabilities
+ bind:contextLimit
+ bind:filter
+ bind:modalities
+ {toolbarEnd}
+ />
+
+ <div
+ class="{MODEL_ROW_GRID_CLASS} shrink-0 border-y border-border/40 px-2 py-2 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase max-md:px-3"
+ >
+ <span>{@render sortHeader(ModelsTableSortKey.NAME, 'Model')}</span>
+
+ <span class="text-right whitespace-nowrap max-md:hidden">
+ {@render sortHeader(ModelsTableSortKey.CONTEXT, 'Context')}
+ </span>
+
+ <span class="justify-self-center max-md:hidden">
+ {@render sortHeader(ModelsTableSortKey.STATUS, 'Status')}
+ </span>
+
+ <span class="text-center max-md:hidden">Actions</span>
+ </div>
+
+ <div class="min-h-0 flex-1 overflow-y-auto">
+ {#each sections as group (group.key)}
+ {#if group.items.length > 0}
+ {#snippet groupIcon()}
+ {#if group.kind === ModelsTableGroupKind.DOWNLOADING}
+ <Download class="h-3.5 w-3.5 shrink-0" />
+ {:else if group.kind === ModelsTableGroupKind.FAVORITES}
+ <Heart class="h-3.5 w-3.5 shrink-0" />
+ {:else if group.kind === ModelsTableGroupKind.LOADED}
+ <Power class="h-3.5 w-3.5 shrink-0" />
+ {:else if group.kind === ModelsTableGroupKind.HIDDEN}
+ <EyeOff class="h-3.5 w-3.5 shrink-0" />
+ {:else if group.kind === ModelsTableGroupKind.LOCAL}
+ <Logo class="shrink-0" style="--size: 0.875rem" />
+ {/if}
+ {/snippet}
+
+ <ModelsSection
+ chevronClass="mr-7"
+ count={group.items.length}
+ defaultOpen={group.kind !== ModelsTableGroupKind.HIDDEN}
+ icon={groupIcon}
+ label={group.label}
+ persistKey={group.key}
+ revealChevronOnHover
+ sectionHeaderClass="m-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none max-md:px-3"
+ sticky
+ stickyClass="sticky z-10 bg-muted/90 backdrop-blur-lg"
+ >
+ <GroupedList
+ group={familyRow}
+ groupWindow={FAMILY_ROW_WINDOW}
+ groups={group.flat ? null : familyGroups(group)}
+ initialCollapsed={modelsStore.collapsedGroupsUnder(group.key)}
+ item={listItem}
+ items={group.flat ? group.items : []}
+ keyOf={(entry) => entry.key}
+ more={showMore}
+ onCollapsedChange={(key, collapsed) =>
+ modelsStore.setGroupCollapsed(group.key, key, collapsed)}
+ sectionWindow={MODEL_ROW_WINDOW}
+ stickyClass="bg-muted/30 backdrop-blur-lg"
+ stickyStyle={MODELS_TABLE_GROUP_STICKY_OFFSET}
+ weightOf={(entry) => entry.quants.length}
+ />
+ </ModelsSection>
+ {/if}
+ {/each}
+
+ {#if isEmpty}
+ <p class="px-4 py-10 text-center text-sm text-muted-foreground">
+ {hasFilters ? 'No models match these filters.' : 'No models found.'}
+ </p>
+ {/if}
+ </div>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
new file mode 100644
index 000000000..6d76251cd
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
@@ -0,0 +1,71 @@
+<script lang="ts">
+ import ModelContext from '../ModelContext.svelte';
+ import ModelDraftSidecars from '../ModelDraftSidecars.svelte';
+ import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
+ import { modelRowActions } from './row-actions';
+ import { configuredContext } from './utils';
+ import { MoreHorizontal } from '@lucide/svelte';
+ import { DropdownMenuActions } from '$lib/components/app';
+ import { Badge } from '$lib/components/ui/badge';
+ import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
+ import { modelsStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ isFavorite: (option: ModelOption) => boolean;
+ option: ModelOption;
+ onDelete: (option: ModelOption) => void;
+ onSelect: (option: ModelOption) => void;
+ selected: boolean;
+ /** Left padding in px, from the nesting depth. */
+ indent?: number;
+ }
+
+ let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
+
+ let favorite = $derived(isFavorite(option));
+ let isHidden = $derived(modelsStore.isHidden(option.id));
+ let quant = $derived(option.parsedId?.quantization ?? option.model);
+</script>
+
+<div
+ class={[
+ MODEL_ROW_GRID_CLASS,
+ 'group relative rounded-md px-2 py-2 transition max-md:px-3 max-md:py-3',
+ isHidden && 'opacity-60',
+ selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
+ ]}
+>
+ <!-- the row holds a load control and an actions menu, so only the quant itself is
+ the button: a button nested in a role="button" row is invalid -->
+ <button
+ aria-pressed={selected}
+ class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:pr-9"
+ onclick={() => onSelect(option)}
+ style="padding-left: {indent}px"
+ type="button"
+ >
+ <Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
+
+ <ModelDraftSidecars draftSidecars={option.draftSidecars} />
+
+ <span class="truncate text-sm text-muted-foreground">{option.model}</span>
+ </button>
+
+ <ModelContext
+ class="justify-self-end max-md:hidden"
+ configured={configuredContext(option)}
+ {option}
+ />
+
+ <ModelsManagerStatusCell class="max-md:hidden" {option} />
+
+ <div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
+ <DropdownMenuActions
+ actions={modelRowActions(option, favorite, isHidden, onDelete)}
+ align="end"
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="Model actions"
+ />
+ </div>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte
new file mode 100644
index 000000000..8c26b201f
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte
@@ -0,0 +1,95 @@
+<script lang="ts">
+ import ModelAvatar from '../ModelAvatar.svelte';
+ import ModelCapabilities from '../ModelCapabilities.svelte';
+ import ModelContext from '../ModelContext.svelte';
+ import ModelId from '../ModelId.svelte';
+ import type { ModelQuantGroup } from './utils';
+ import { configuredContext } from './utils';
+ import { ChevronDown, ChevronUp } from '@lucide/svelte';
+ import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
+ import { KeyboardKey, ModelGroupKind } from '$lib/enums';
+ import { deviceStore, modelsStore } from '$lib/stores';
+
+ interface Props {
+ entry: ModelQuantGroup;
+ expanded: boolean;
+ onToggle: () => void;
+ /** Left padding in px, from the nesting depth. */
+ indent?: number;
+ }
+
+ let { entry, expanded, indent = 0, onToggle }: Props = $props();
+
+ let groupLabel = $derived(
+ entry.kind === ModelGroupKind.VARIANTS
+ ? `${entry.quants.length} variants`
+ : `${entry.quants.length} quants available`
+ );
+ let anyLoaded = $derived(entry.quants.some((quant) => modelsStore.isModelRunning(quant.model)));
+ // a repo row stands for its quants, so it reports what they agree on
+ let contextSource = $derived(entry.quants.find((quant) => quant.contextLength) ?? entry.base);
+ let mediaSource = $derived(entry.quants.find((quant) => quant.modalities) ?? entry.base);
+
+ function handleKeydown(event: KeyboardEvent): void {
+ if (event.key === KeyboardKey.SPACE) event.preventDefault();
+
+ if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) onToggle();
+ }
+</script>
+
+<div
+ class={[
+ MODEL_ROW_GRID_CLASS,
+ 'relative cursor-pointer rounded-md px-2 py-2.5 transition hover:bg-muted/40 max-md:px-3 max-md:py-3.5'
+ ]}
+ onclick={onToggle}
+ onkeydown={handleKeydown}
+ role="button"
+ tabindex="0"
+>
+ <span class="flex min-w-0 items-center gap-3 max-md:pr-9" style="padding-left: {indent}px">
+ <ModelAvatar option={entry.base} size="size-9" />
+
+ <span class="min-w-0">
+ <span class="flex min-w-0 items-center gap-1.25">
+ <ModelId
+ aliases={entry.base.aliases}
+ class="min-w-0"
+ hideCapabilities
+ hideModalities
+ hideQuantization
+ modalities={mediaSource.modalities}
+ modelId={entry.base.model}
+ tags={entry.base.tags}
+ title={entry.base.model}
+ />
+
+ <ModelCapabilities hideModalities={deviceStore.isMobile} option={entry.base} />
+ </span>
+
+ <span class="block text-xs text-muted-foreground">{groupLabel}</span>
+ </span>
+ </span>
+
+ <ModelContext
+ class="justify-self-end max-md:hidden"
+ configured={configuredContext(contextSource)}
+ option={contextSource}
+ />
+
+ <span class="justify-self-center max-md:hidden">
+ <span
+ class="block h-2.5 w-2.5 rounded-full {anyLoaded
+ ? 'bg-emerald-500'
+ : 'border border-muted-foreground/50'}"
+ ></span>
+ </span>
+
+ <span class="flex justify-center {MODEL_ROW_TRAILING_CELL_CLASS}">
+ {#if expanded}
+ <ChevronUp class="h-3.5 w-3.5 text-muted-foreground" />
+ {:else}
+ <ChevronDown class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </span>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
new file mode 100644
index 000000000..a56f50518
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte
@@ -0,0 +1,43 @@
+<script lang="ts">
+ import ModelLoadControl from '../ModelLoadControl.svelte';
+ import ModelsManagerDownloadControl from './ModelsManagerDownloadControl.svelte';
+ import type { ModelRowDownloadState } from '$lib/enums';
+ import { ServerModelStatus } from '$lib/enums';
+ import { modelsStore, serverStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ class?: string;
+ /** Download state, when the row stands for a tracked download. */
+ download?: ModelRowDownloadState | null;
+ option: ModelOption;
+ }
+
+ let { class: className = '', download = null, option }: Props = $props();
+
+ let status = $derived(modelsStore.getModelStatus(option.model));
+ let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
+ let isLoaded = $derived(modelsStore.isModelRunning(option.model));
+ // a MODEL server serves the model it was started with and answers no load route
+ let isRouter = $derived(serverStore.isRouterMode);
+</script>
+
+{#if download}
+ <ModelsManagerDownloadControl class="justify-self-center {className}" {option} state={download} />
+{:else if isRouter}
+ <ModelLoadControl
+ class="justify-self-center {className}"
+ isFailed={status === ServerModelStatus.FAILED}
+ {isLoaded}
+ isLoading={status === ServerModelStatus.LOADING || isOperationInProgress}
+ isSleeping={status === ServerModelStatus.SLEEPING}
+ {option}
+ />
+{:else}
+ <span
+ class="justify-self-center text-sm text-muted-foreground {className}"
+ title="Served by this server"
+ >
+ -
+ </span>
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
new file mode 100644
index 000000000..fef282fcc
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
@@ -0,0 +1,88 @@
+<script lang="ts">
+ import ModelsManagerFilters from './ModelsManagerFilters.svelte';
+ import { hasActiveFilters } from './utils';
+ import { X } from '@lucide/svelte';
+ import { SearchInput } from '$lib/components/app/forms';
+ import { Button } from '$lib/components/ui/button';
+ import { type ModalityKey } from '$lib/constants';
+ import { ModelCapability } from '$lib/enums';
+ import { deviceStore, uiStore } from '$lib/stores';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ /** Capabilities a model must have every one of. */
+ capabilities?: ModelCapability[];
+ /** Smallest context a model must support; 0 keeps every model. */
+ contextLimit?: number;
+ filter?: string;
+ /** Modalities a model must support at least one of. */
+ modalities?: ModalityKey[];
+ /** Rendered at the toolbar's right end, past the filters. */
+ toolbarEnd?: Snippet;
+ }
+
+ let {
+ capabilities = $bindable<ModelCapability[]>([]),
+ contextLimit = $bindable(0),
+ filter = $bindable(''),
+ modalities = $bindable<ModalityKey[]>([]),
+ toolbarEnd
+ }: Props = $props();
+
+ let hasFilters = $derived(hasActiveFilters(contextLimit, modalities, capabilities));
+ let filterInput = $state<HTMLInputElement | null>(null);
+
+ // The search takes the focus the dialog would give its first control: the input
+ // mounts a frame or two after the dialog opens (the toolbar renders below the
+ // table in the DOM), so the poll retries until the ref lands or gives up.
+ $effect(() => {
+ if (!uiStore.manageModelsOpen) return;
+
+ let frames = 0;
+ let handle = requestAnimationFrame(function focusFilter() {
+ if (filterInput) {
+ filterInput.focus({ preventScroll: true });
+
+ return;
+ }
+
+ if (frames++ < 20) handle = requestAnimationFrame(focusFilter);
+ });
+
+ return () => cancelAnimationFrame(handle);
+ });
+</script>
+
+<!-- Below md the search takes a row of its own and the filters follow it. -->
+<div class="flex shrink-0 flex-wrap items-center gap-2 pb-4 max-md:gap-3 md:flex-nowrap">
+ <SearchInput
+ bind:ref={filterInput}
+ bind:value={filter}
+ class="w-full md:w-auto md:max-w-64"
+ placeholder="Search your models"
+ size={deviceStore.isMobile ? 'default' : 'sm'}
+ />
+
+ <ModelsManagerFilters bind:capabilities bind:contextLimit bind:modalities />
+
+ {#if hasFilters}
+ <Button
+ class="gap-1.5 text-muted-foreground"
+ onclick={() => {
+ contextLimit = 0;
+ modalities = [];
+ capabilities = [];
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ <X class="h-3.5 w-3.5" />
+
+ Clear filters
+ </Button>
+ {/if}
+
+ <div class="ml-auto flex items-center gap-2">
+ {@render toolbarEnd?.()}
+ </div>
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
new file mode 100644
index 000000000..9931dd3f4
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
@@ -0,0 +1,61 @@
+import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
+import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
+import { ModelRowDownloadState } from '$lib/enums';
+import { modelsStore } from '$lib/stores';
+import type { ModelOption } from '$lib/types/models';
+
+/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
+export function modelRowActions(
+ option: ModelOption,
+ favorite: boolean,
+ isHidden: boolean,
+ onDelete: (option: ModelOption) => void,
+ /** Download state, when the row stands for a tracked download. */
+ download?: ModelRowDownloadState | null
+) {
+ // a tracked download is paused, resumed or dropped: it is not loaded, hidden or
+ // drafted. Deleting it stops the download and removes what is on disk, which is
+ // what the same wording offers on a cached model
+ if (download) {
+ const isPaused = download === ModelRowDownloadState.PAUSED;
+
+ return [
+ {
+ icon: isPaused ? MODEL_DOWNLOAD_ICONS.resume : MODEL_DOWNLOAD_ICONS.pause,
+ label: isPaused ? 'Resume downloading' : 'Pause downloading',
+ onclick: () =>
+ void (isPaused
+ ? modelsStore.status.downloadModel(option.model)
+ : modelsStore.status.pauseDownload(option.model))
+ },
+ {
+ icon: Trash2,
+ label: 'Delete from disk',
+ onclick: () => onDelete(option),
+ separator: true,
+ variant: 'destructive' as const
+ }
+ ];
+ }
+
+ return [
+ {
+ icon: favorite ? HeartOff : Heart,
+ label: favorite ? 'Remove from favorites' : 'Add to favorites',
+ onclick: () => modelsStore.toggleFavorite(option.model)
+ },
+ {
+ icon: Trash2,
+ label: 'Delete from disk',
+ onclick: () => onDelete(option),
+ separator: true,
+ variant: 'destructive' as const
+ },
+ {
+ icon: isHidden ? Eye : EyeOff,
+ label: isHidden ? 'Unhide model' : 'Hide model',
+ onclick: () => modelsStore.toggleHidden(option.id),
+ separator: true
+ }
+ ];
+}
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
new file mode 100644
index 000000000..3721ceca1
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
@@ -0,0 +1,202 @@
+import { LOCAL_BACKEND_ID, type ModalityKey } from '$lib/constants';
+import {
+ ModelCapability,
+ ModelGroupKind,
+ ModelsTableGroupKind,
+ ServerModelStatus
+} from '$lib/enums';
+import { HuggingFaceService, ModelsService } from '$lib/services';
+import { modelsStore } from '$lib/stores';
+import type { ModelDownloadEntry, ModelDownloadProgress, ModelOption } from '$lib/types/models';
+import { detectThinkingSupport, detectToolUseSupport, repoOf } from '$lib/utils';
+
+/** One repo of the table, with the rows it ships as. */
+export interface ModelQuantGroup {
+ base: ModelOption;
+ key: string;
+ kind: ModelGroupKind;
+ quants: ModelOption[];
+}
+
+/** True when any of the manager's filters narrows the table. */
+export function hasActiveFilters(
+ contextLimit: number,
+ modalities: ModalityKey[],
+ capabilities: ModelCapability[]
+): boolean {
+ return contextLimit > 0 || modalities.length > 0 || capabilities.length > 0;
+}
+
+/**
+ * Order a status sorts behind: loaded first, then a model being worked on
+ * (loading, sleeping), then the rest.
+ */
+export function statusRank(option: ModelOption): number {
+ const status = modelsStore.getModelStatus(option.model);
+
+ if (modelsStore.isModelRunning(option.model)) return 2;
+
+ if (status === ServerModelStatus.LOADING || status === ServerModelStatus.SLEEPING) return 1;
+
+ return 0;
+}
+
+/** Byte counts of a tracked download: live while it runs, frozen while paused. */
+export function downloadProgressFor(repoWithTag: string): ModelDownloadProgress | null {
+ return (
+ modelsStore.status.getDownloadProgress(repoWithTag) ??
+ modelsStore.status.getPausedDownloadProgress(repoWithTag)
+ );
+}
+
+/**
+ * One table row per tracked download, so the table lists them the way it lists
+ * any other model. A paused download is already in the router's listing, so its
+ * own option is reused; a fresh one stands for its tag alone.
+ */
+export function downloadGroups(
+ entries: ModelDownloadEntry[],
+ models: ModelOption[]
+): ModelQuantGroup[] {
+ return entries.map((entry) => {
+ const option = models.find((candidate) => candidate.model === entry.repoWithTag) ?? {
+ backendId: LOCAL_BACKEND_ID,
+ capabilities: [],
+ id: entry.repoWithTag,
+ model: entry.repoWithTag,
+ name: entry.repoWithTag
+ };
+
+ return {
+ base: option,
+ key: `download::${entry.repoWithTag}`,
+ kind: ModelGroupKind.QUANTS,
+ quants: [option]
+ };
+ });
+}
+
+/** One collapsible block of the manager's table. */
+export interface ModelsTableGroup {
+ /** One entry per repo, its quants hanging off it. */
+ items: ModelQuantGroup[];
+ key: string;
+ kind: ModelsTableGroupKind;
+ label: string;
+}
+
+/** Context the model runs with: what a loaded model reports. */
+export function configuredContext(option: ModelOption): number | null {
+ return modelsStore.isModelRunning(option.model)
+ ? modelsStore.props.getModelContextSize(option.model)
+ : null;
+}
+
+/**
+ * Capability a model reports: true or false once its listing or its chat template
+ * answers, null while the Hub record that carries the template is not read yet.
+ */
+export function modelCapability(option: ModelOption, capability: ModelCapability): boolean | null {
+ if (option.capabilities.includes(capability)) return true;
+
+ const details = HuggingFaceService.cachedDetails(option.model);
+
+ // only the chat template answers this, and it arrives with the Hub record
+ if (details === undefined) return null;
+
+ const template = details?.gguf?.chat_template ?? '';
+
+ return capability === ModelCapability.TOOL_USE
+ ? detectToolUseSupport(template)
+ : detectThinkingSupport(template);
+}
+
+/**
+ * Context a model reports: the provider listing first, then the cached Hub record.
+ * Null while neither of them has answered.
+ */
+export function modelContextLength(option: ModelOption): number | null {
+ return (
+ option.contextLength ??
+ HuggingFaceService.cachedDetails(option.model)?.gguf?.context_length ??
+ null
+ );
+}
+
+/** Fold the quants of one repo into a single entry, so the table shows one row per model. */
+export function groupModelQuants(models: ModelOption[]): ModelQuantGroup[] {
+ const groups = new Map<string, ModelQuantGroup>();
+
+ for (const option of models) {
+ const key = repoOf(option.model);
+ const group = groups.get(key);
+
+ if (group) {
+ group.quants.push(option);
+
+ continue;
+ }
+
+ groups.set(key, { base: option, key, kind: ModelGroupKind.QUANTS, quants: [option] });
+ }
+
+ return Array.from(groups.values()).flatMap((group) => {
+ const kind = groupKind(group.quants);
+ const modelIds = group.quants.map((option) => option.model);
+
+ // the very same id twice is not a quant set; keep those rows apart
+ if (modelIds.length > 1 && modelIds.every((model) => model === modelIds[0])) {
+ return group.quants.map((option) => ({
+ ...group,
+ base: option,
+ key: `${group.key}::${option.id}`,
+ kind: ModelGroupKind.VARIANTS,
+ quants: [option]
+ }));
+ }
+
+ return [{ ...group, kind }];
+ });
+}
+
+/** What a group folds: quants or variants of one repo. */
+function groupKind(quants: ModelOption[]): ModelGroupKind {
+ const isQuant = quants.every(
+ (option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization
+ );
+
+ return isQuant ? ModelGroupKind.QUANTS : ModelGroupKind.VARIANTS;
+}
+
+/**
+ * Split the repos of a section into the quants that are hidden and the ones that
+ * are not, so a partly hidden repo lists in both blocks instead of dragging its
+ * visible quants into the hidden one.
+ */
+export function splitHiddenQuants(
+ entries: ModelQuantGroup[],
+ isHidden: (option: ModelOption) => boolean
+): { hidden: ModelQuantGroup[]; local: ModelQuantGroup[] } {
+ const hidden: ModelQuantGroup[] = [];
+ const local: ModelQuantGroup[] = [];
+
+ for (const entry of entries) {
+ const hiddenQuants = entry.quants.filter((quant) => isHidden(quant));
+ const localQuants = entry.quants.filter((quant) => !isHidden(quant));
+
+ if (hiddenQuants.length > 0) {
+ hidden.push({
+ ...entry,
+ base: hiddenQuants[0],
+ key: `${entry.key}::hidden`,
+ quants: hiddenQuants
+ });
+ }
+
+ if (localQuants.length > 0) {
+ local.push({ ...entry, base: localQuants[0], quants: localQuants });
+ }
+ }
+
+ return { hidden, local };
+}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSection.svelte b/tools/ui/src/lib/components/app/models/ModelsSection.svelte
new file mode 100644
index 000000000..749dc5b07
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSection.svelte
@@ -0,0 +1,97 @@
+<script lang="ts">
+ import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
+ import { CollapsibleSection } from '$lib/components/app';
+ import { modelsStore } from '$lib/stores';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ children: Snippet;
+ /** Extra classes for the chevron, to line it up with a row's own control. */
+ chevronClass?: string;
+ /** Number shown next to the label, omitted when undefined. */
+ count?: number;
+ error?: boolean;
+ /** Overrides the backend logo, used for sections without a backend. */
+ icon?: Snippet;
+ label: string;
+ loading?: boolean;
+ /** Renders the back control, for a drilled-in provider. */
+ onBack?: () => void;
+ /** Persists the open state under this id, e.g. a section key. */
+ persistKey?: string;
+ /** Start expanded; the manager collapses its hidden block. */
+ defaultOpen?: boolean;
+ revealChevronOnHover?: boolean;
+ sectionHeaderClass?: string;
+ /** Sticky class of the header, so a surface can shade it differently. */
+ stickyClass?: string;
+ sticky?: boolean;
+ }
+
+ let {
+ chevronClass = '',
+ children,
+ count,
+ defaultOpen = true,
+ error = false,
+ icon,
+ label,
+ loading = false,
+ onBack,
+ persistKey,
+ revealChevronOnHover = false,
+ sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none',
+ sticky = false,
+ stickyClass = 'sticky z-10 bg-popover'
+ }: Props = $props();
+
+ let triggerClass = $derived(
+ `${sectionHeaderClass} flex w-full cursor-pointer items-center gap-1.5 text-left${sticky ? ` ${stickyClass}` : ''}`
+ );
+ // surfaces without a search block fall back to a 0 sticky height
+ let triggerStyle = $derived(sticky ? 'top: var(--dropdown-sticky-height, 0px)' : '');
+</script>
+
+<CollapsibleSection
+ {chevronClass}
+ defaultOpen={persistKey ? modelsStore.isGroupOpen(persistKey, defaultOpen) : defaultOpen}
+ ontoggle={persistKey ? (open: boolean) => modelsStore.setGroupOpen(persistKey, open) : null}
+ {revealChevronOnHover}
+ {triggerClass}
+ {triggerStyle}
+>
+ {#snippet trigger()}
+ {#if onBack}
+ <button
+ aria-label="Back to all providers"
+ class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
+ onclick={(event) => {
+ // the back control must not collapse the list it is leaving
+ event.stopPropagation();
+ onBack?.();
+ }}
+ type="button"
+ >
+ <ChevronLeft class="h-3.5 w-3.5" />
+ </button>
+ {/if}
+
+ {#if icon}
+ {@render icon()}
+ {/if}
+
+ <span class="truncate">{label}</span>
+
+ {#if loading}
+ <Loader2 class="h-3 w-3 shrink-0 animate-spin" />
+ {:else if error}
+ <CircleAlert class="h-3 w-3 shrink-0 text-destructive" />
+ {/if}
+
+ {#if count !== undefined}
+ <span class="shrink-0 text-xs text-muted-foreground/70">{count}</span>
+ {/if}
+ {/snippet}
+
+ {@render children()}
+</CollapsibleSection>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
new file mode 100644
index 000000000..33b3b2f86
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
@@ -0,0 +1,405 @@
+<script lang="ts">
+ import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
+ import { ChevronDown, Loader2 } from '@lucide/svelte';
+ import {
+ ModelId,
+ ModelsSelectorDrawer,
+ ModelsSelectorDropdown,
+ ModelsSelectorList,
+ ModelsSelectorOption,
+ ModelsSelectorTriggerIcon
+ } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR, MODEL_ICON, SETTINGS_KEYS } from '$lib/constants';
+ import { KeyboardKey, ServerModelStatus } from '$lib/enums';
+ import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
+ import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
+ import { deviceStore, modelsStore, serverStore, settingsStore } from '$lib/stores';
+ import { type ModelItem, modelLoadFraction } from '$lib/utils';
+
+ interface Props {
+ /** Model to show, when the caller owns it, e.g. the model of one message. */
+ currentModel?: string | null;
+ disabled?: boolean;
+ forceForegroundText?: boolean;
+ onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
+ useGlobalSelection?: boolean;
+ }
+
+ let {
+ currentModel = null,
+ disabled = false,
+ forceForegroundText = false,
+ onModelChange,
+ useGlobalSelection = false
+ }: Props = $props();
+
+ // one setting for every model id in the selector: the trigger and the rows
+ let showOrgName = $derived(settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] ?? true);
+
+ // a phone opens the picker in a drawer, a desktop keeps the anchored dropdown
+ let isMobile = $derived(deviceStore.isMobile);
+ let isOffline = $derived(!!serverStore.error);
+
+ let isOpen = $state(false);
+ let highlightedId = $state<string | null>(null);
+
+ const formModel = useChatFormModel();
+
+ // the store's pick wins while it differs from the conversation's model: the
+ // trigger follows the model the user just picked, whose load state and
+ // progress it reports, not the one the last answer came from
+ let selectorModel = $derived.by(() => {
+ const storeModel = modelsStore.selectedModelName;
+
+ if (storeModel && storeModel !== formModel.conversationModel) {
+ return storeModel;
+ }
+
+ return formModel.conversationModel;
+ });
+
+ // the caller's model comes first, then the pick, then the conversation's
+ let displayModel = $derived(currentModel ?? selectorModel);
+
+ const ms = useModelsSelector({
+ currentModel: () => displayModel,
+ onModelChange: () => onModelChange,
+ onOpenChange: (open) => {
+ isOpen = open;
+ highlightedId = null;
+ },
+ useGlobalSelection: () => useGlobalSelection
+ });
+
+ const selectedOption = $derived(ms.getDisplayOption());
+ const triggerModel = $derived(selectedOption?.model ?? null);
+
+ let triggerStatus = $derived(
+ triggerModel
+ ? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
+ : undefined
+ );
+ let triggerLoading = $derived(
+ !!triggerModel &&
+ (triggerStatus === ServerModelStatus.LOADING ||
+ modelsStore.status.isOperationInProgress(triggerModel))
+ );
+ let triggerLoadPercent = $derived(
+ triggerModel && triggerLoading
+ ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
+ : 0
+ );
+
+ $effect(() => {
+ void ms.searchTerm;
+ highlightedId = null;
+ });
+
+ // Keyboard navigation follows the on-screen row order, not the flat option list order.
+ let visualOrder = $derived.by(() => {
+ const order: string[] = [];
+
+ for (const item of ms.favoriteItems) order.push(item.option.id);
+ for (const group of ms.groupedFilteredOptions.available) {
+ for (const item of group.items) order.push(item.option.id);
+ }
+
+ return order;
+ });
+
+ let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
+
+ function moveHighlight(direction: 1 | -1) {
+ const len = visualOrder.length;
+
+ if (len === 0) {
+ highlightedId = null;
+
+ return;
+ }
+
+ let index = highlightedIndex;
+
+ if (index === -1) {
+ index = direction === 1 ? 0 : len - 1;
+ } else {
+ index = (index + direction + len) % len;
+ }
+
+ highlightedId = visualOrder[index];
+ }
+
+ // Alt+Enter only unloads and keeps the picker open.
+ async function handleModelKeyAction(modelId: string, unload: boolean) {
+ if (!unload) {
+ void ms.handleSelect(modelId);
+
+ return;
+ }
+
+ const status = modelsStore.getModelStatus(modelId);
+
+ if (status === ServerModelStatus.LOADING) return;
+
+ await modelsStore.status.unload(modelId);
+ }
+
+ function handleSearchKeyDown(event: KeyboardEvent) {
+ if (event.isComposing) return;
+
+ if (event.key === KeyboardKey.ARROW_DOWN) {
+ event.preventDefault();
+ moveHighlight(1);
+ } else if (event.key === KeyboardKey.ARROW_UP) {
+ event.preventDefault();
+ moveHighlight(-1);
+ } else if (event.key === KeyboardKey.ENTER) {
+ event.preventDefault();
+
+ if (highlightedId) {
+ void handleModelKeyAction(highlightedId, event.altKey);
+ } else if (visualOrder.length > 0) {
+ highlightedId = visualOrder[0];
+ }
+ }
+ }
+
+ // bits-ui auto-focuses the opened content, which can yank the page scroll: the
+ // content prevents that and this focuses the search input instead
+ $effect(() => {
+ if (!isOpen || isMobile) return;
+
+ let frames = 0;
+ let handle = requestAnimationFrame(function focusSearch() {
+ const input = document.querySelector<HTMLElement>(DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR);
+
+ if (input) {
+ input.focus({ preventScroll: true });
+
+ return;
+ }
+
+ if (frames++ < 20) handle = requestAnimationFrame(focusSearch);
+ });
+
+ return () => cancelAnimationFrame(handle);
+ });
+
+ export function open() {
+ ms.handleOpenChange(true);
+ }
+</script>
+
+{#snippet selectorTriggerInner()}
+ <ModelsSelectorTriggerIcon
+ class="mr-0.375 size-3.5 max-md:size-4 shrink-0"
+ option={selectedOption}
+ />
+
+ <span class="flex min-w-0 items-center gap-0.5">
+ {#if selectedOption}
+ <ModelId
+ class="min-w-0 overflow-hidden"
+ hideOrgName={!showOrgName}
+ hideQuantization
+ modelId={selectedOption.model}
+ />
+ {:else}
+ <span class="min-w-0 font-medium">Select model</span>
+ {/if}
+ </span>
+
+ {#if ms.updating || ms.isLoadingModel || triggerLoading}
+ <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
+ {:else}
+ <ChevronDown class="h-3 w-3.5 shrink-0" />
+ {/if}
+
+ {#if triggerLoading}
+ <ModelLoadHighlight percent={triggerLoadPercent} />
+ {/if}
+{/snippet}
+
+{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
+ {@const { option } = item}
+ {@const isSelected = ms.activeId === option.id}
+ {@const isHighlighted = option.id === highlightedId}
+ {@const isFav = ms.isFavorite(option.model)}
+
+ <ModelsSelectorOption
+ {hideOrgName}
+ {isFav}
+ {isHighlighted}
+ {isSelected}
+ onKeyDown={(event) => {
+ if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
+ event.preventDefault();
+ void handleModelKeyAction(option.id, event.altKey);
+ }
+ }}
+ onMouseEnter={() => (highlightedId = option.id)}
+ onSelect={ms.handleSelect}
+ {option}
+ />
+{/snippet}
+
+{#snippet pickerList(listClass: string)}
+ <div class={['models-list', listClass]}>
+ {#if !ms.isCurrentModelInCache && displayModel}
+ <!-- Show unavailable model as first option (disabled) -->
+ <button
+ aria-disabled="true"
+ aria-selected="true"
+ class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
+ disabled
+ role="option"
+ type="button"
+ >
+ <ModelId
+ class="flex-1"
+ hideOrgName={!showOrgName}
+ hideQuantization
+ modelId={displayModel}
+ />
+
+ <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
+ </button>
+ {/if}
+
+ {#if ms.isEmpty}
+ <p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
+ {/if}
+
+ <ModelsSelectorList
+ activeId={ms.activeId}
+ currentModel={displayModel}
+ favorites={ms.favoriteItems}
+ groups={ms.groupedFilteredOptions}
+ loaded={ms.loadedItems}
+ onSelect={ms.handleSelect}
+ renderOption={modelOption}
+ sectionHeaderClass="[&:not(:first-child)]:mt-2 mb-1 px-2 py-2.5 text-sm font-semibold text-foreground/80 select-none"
+ {showOrgName}
+ />
+ </div>
+{/snippet}
+
+{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
+ <div class="flex items-center gap-2 text-xs text-muted-foreground">
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
+
+ Loading models...
+ </div>
+{:else if ms.options.length === 0 && ms.isMultiModel}
+ {#if displayModel}
+ <span
+ class="inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground"
+ style="max-width: min(calc(100cqw - 10rem), 48rem)"
+ >
+ <MODEL_ICON class="h-3.5 w-3.5 shrink-0" />
+ </span>
+ {:else}
+ <span class="text-xs text-muted-foreground">No models yet.</span>
+ {/if}
+{:else if ms.isMultiModel}
+ {#if isMobile}
+ <button
+ class="relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 max-md:gap-1.5 max-md:h-7 max-md:px-2.25 max-md:py-1 max-md:text-[13px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground"
+ disabled={disabled || ms.updating}
+ onclick={() => ms.handleOpenChange(true)}
+ style="max-width: min(calc(100vw-4rem), 32rem)"
+ type="button"
+ >
+ {@render selectorTriggerInner()}
+ </button>
+
+ <ModelsSelectorDrawer
+ bind:open={isOpen}
+ emptyMessage={ms.emptyMessage}
+ isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
+ onOpenChange={ms.handleOpenChange}
+ onSearchChange={(v) => ms.setSearchTerm(v)}
+ onSearchKeyDown={handleSearchKeyDown}
+ searchTerm={ms.searchTerm}
+ >
+ {@render pickerList('px-2.5')}
+ </ModelsSelectorDrawer>
+ {:else}
+ <ModelsSelectorDropdown
+ bind:open={isOpen}
+ currentModel={displayModel}
+ disabled={disabled || isOffline}
+ {highlightedId}
+ {ms}
+ onHighlight={(id) => (highlightedId = id)}
+ onModelKeyAction={(id, unload) => void handleModelKeyAction(id, unload)}
+ onSearchKeyDown={handleSearchKeyDown}
+ {showOrgName}
+ />
+ {/if}
+{:else if isMobile}
+ <!-- a phone has no manager: the single model still gets the drawer, so its
+ load control, actions and information stay reachable -->
+ <button
+ class="relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 max-md:gap-1.5 max-md:h-7 max-md:px-2.25 max-md:py-1 max-md:text-[13px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground"
+ disabled={disabled || ms.updating}
+ onclick={() => ms.handleOpenChange(true)}
+ style="max-width: min(calc(100vw-4rem), 32rem)"
+ type="button"
+ >
+ {@render selectorTriggerInner()}
+ </button>
+
+ <ModelsSelectorDrawer
+ bind:open={isOpen}
+ emptyMessage={ms.emptyMessage}
+ isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
+ onOpenChange={ms.handleOpenChange}
+ onSearchChange={(v) => ms.setSearchTerm(v)}
+ onSearchKeyDown={handleSearchKeyDown}
+ searchTerm={ms.searchTerm}
+ >
+ {@render pickerList('px-2.5')}
+ </ModelsSelectorDrawer>
+{:else}
+ <!-- a lone llama.cpp server has nothing to choose from: the trigger opens the
+ manager instead, so the button is its own affordance -->
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <!-- prevent another nested button element -->
+ {#snippet child({ props })}
+ <button
+ {...props}
+ class={[
+ `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
+ !ms.isCurrentModelInCache
+ ? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
+ : forceForegroundText
+ ? 'text-foreground'
+ : 'text-foreground',
+ isOpen && 'text-foreground'
+ ]}
+ disabled={disabled || ms.updating}
+ onclick={() => ms.handleOpenChange(true)}
+ style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
+ >
+ <ModelsSelectorTriggerIcon class="mr-0.375 size-3.5 shrink-0" option={selectedOption} />
+
+ {#if selectedOption}
+ <ModelId
+ class="min-w-0 overflow-hidden"
+ hideOrgName={!showOrgName}
+ hideQuantization
+ modelId={selectedOption.model}
+ />
+ {/if}
+
+ {#if ms.updating}
+ <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
+ {/if}
+ </button>
+ {/snippet}
+ </Tooltip.Trigger>
+ </Tooltip.Root>
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
new file mode 100644
index 000000000..6ef1433b9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
@@ -0,0 +1,130 @@
+<script lang="ts">
+ import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
+ import ModelOrgAvatar from '../ModelOrgAvatar.svelte';
+ import { Loader2, Pause, Play, X } from '@lucide/svelte';
+ import { ActionIcon, ModelId } from '$lib/components/app';
+ import { HuggingFaceService, ModelsService } from '$lib/services';
+ import { modelsStore } from '$lib/stores';
+ import type { ModelDownloadEntry } from '$lib/types';
+
+ interface Props {
+ /** One entry from the status feed: an in-flight or paused download. */
+ entry: ModelDownloadEntry;
+ /** Ask the list to confirm cancelling; the list owns the single dialog. */
+ onRequestCancel?: (repoWithTag: string) => void;
+ /** Show the organization name in the repo id, as the other selector rows do. */
+ showOrgName?: boolean;
+ }
+
+ let { entry, onRequestCancel, showOrgName = true }: Props = $props();
+
+ let percent = $derived(
+ entry.progress && entry.progress.totalBytes > 0
+ ? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
+ : null
+ );
+
+ let actionText = $derived(entry.isPaused ? 'Resume downloading' : 'Pause downloading');
+
+ // the base model org resolves lazily via HF when unknown
+ let orgName = $derived(ModelsService.parseModelId(entry.repoWithTag).orgName);
+ let fetchedBaseModelOrg = $state<string | null>(null);
+
+ $effect(() => {
+ const repoWithTag = entry.repoWithTag;
+
+ fetchedBaseModelOrg = null;
+
+ // avatars hidden with the Hub metadata setting off: no lookups at all
+ if (!HuggingFaceService.isEnabled()) return;
+
+ let cancelled = false;
+
+ void HuggingFaceService.getBaseModel(repoWithTag)
+ .then((base) => {
+ if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
+ })
+ // best-effort lookup: offline or unknown repos keep the repo org
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ });
+</script>
+
+<!-- One in-flight download; same actions as the discover quant chips: the row
+ itself pauses / resumes, the trailing X cancels (stops and discards the
+ partial files). Both affordances fade in on hover, the slots are reserved
+ so the list never reflows. -->
+<div
+ class="group relative flex items-center gap-2 rounded-sm p-2 text-left text-sm transition-colors hover:bg-accent/50"
+>
+ <button
+ aria-label={actionText}
+ class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
+ onclick={() => {
+ if (entry.isPaused) {
+ void modelsStore.status
+ .downloadModel(entry.repoWithTag)
+ .catch((error) => console.warn('Failed to resume the download:', error));
+ } else {
+ void modelsStore.status.pauseDownload(entry.repoWithTag);
+ }
+ }}
+ type="button"
+ >
+ {#if orgName}
+ <ModelOrgAvatar
+ org={fetchedBaseModelOrg ?? orgName}
+ quantOrg={orgName}
+ quantPositionClass="-bottom-1 -right-1"
+ quantSize="h-3 w-3"
+ size="h-6 w-6"
+ />
+ {/if}
+
+ <ModelId class="flex-1" hideOrgName={!showOrgName} modelId={entry.repoWithTag} showRawTooltip />
+
+ {#if percent !== null}
+ <span class="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
+ {:else if entry.isPaused}
+ <span class="shrink-0 text-xs text-muted-foreground">Paused</span>
+ {/if}
+
+ <!-- status action: spinner -> pause on hover while in flight, play on hover
+ when paused; opacity only for the spinner, the spin owns the transform -->
+ <span class="relative inline-flex h-5 w-5 shrink-0 items-center justify-center">
+ {#if entry.isPaused}
+ <Play
+ class="absolute h-4 w-4 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
+ />
+ {:else}
+ <Loader2
+ class="absolute h-4 w-4 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
+ />
+
+ <Pause
+ class="absolute h-4 w-4 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
+ />
+ {/if}
+ </span>
+ </button>
+
+ <!-- the row actions keep the sizing and reveal of the other selector rows -->
+ <ActionIcon
+ ariaLabel="Cancel downloading"
+ class="pointer-events-none h-5 w-5 shrink-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 hover:text-destructive [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
+ icon={X}
+ iconSize="h-4 w-4"
+ onclick={() => onRequestCancel?.(entry.repoWithTag)}
+ />
+
+ {#if entry.progress && entry.progress.totalBytes > 0}
+ <ModelDownloadProgressBar
+ downloadedBytes={entry.progress.downloadedBytes}
+ overlay
+ totalBytes={entry.progress.totalBytes}
+ />
+ {/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDrawer.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDrawer.svelte
new file mode 100644
index 000000000..1a2e8d3b8
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDrawer.svelte
@@ -0,0 +1,53 @@
+<script lang="ts">
+ import { DropdownMenuSearchable } from '$lib/components/app';
+ import { Drawer, DrawerContent } from '$lib/components/ui/drawer';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ children: Snippet;
+ emptyMessage?: string;
+ /** Sticky picker footer: it sticks to the bottom of the drawer's scrollport. */
+ footer?: Snippet;
+ isEmpty?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onSearchChange?: (value: string) => void;
+ onSearchKeyDown?: (event: KeyboardEvent) => void;
+ open?: boolean;
+ placeholder?: string;
+ searchTerm?: string;
+ }
+
+ let {
+ children,
+ emptyMessage = 'No items found',
+ footer,
+ isEmpty = false,
+ onOpenChange,
+ onSearchChange,
+ onSearchKeyDown,
+ open = $bindable(false),
+ placeholder = 'Search models...',
+ searchTerm = ''
+ }: Props = $props();
+</script>
+
+<Drawer bind:open {onOpenChange}>
+ <DrawerContent class="overflow-hidden">
+ <!-- the wrapper is the scrollport: the search header and the footer stick to it -->
+ <div class="max-h-[min(40rem,calc(100dvh-5rem))] min-h-0 overflow-y-auto">
+ <DropdownMenuSearchable
+ {emptyMessage}
+ {footer}
+ headerClass="p-2.5 pt-4"
+ {isEmpty}
+ {onSearchChange}
+ {onSearchKeyDown}
+ {placeholder}
+ searchClass="bg-transparent"
+ searchValue={searchTerm}
+ >
+ {@render children()}
+ </DropdownMenuSearchable>
+ </div>
+ </DrawerContent>
+</Drawer>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte
new file mode 100644
index 000000000..a7a54e860
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte
@@ -0,0 +1,248 @@
+<script lang="ts">
+ import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
+ import { ChevronDown, Loader2 } from '@lucide/svelte';
+ import {
+ DropdownMenuSearchable,
+ ModelId,
+ ModelsSelectorList,
+ ModelsSelectorOption,
+ ModelsSelectorTriggerIcon
+ } from '$lib/components/app';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { MODEL_ICON } from '$lib/constants';
+ import { KeyboardKey, ServerModelStatus } from '$lib/enums';
+ import type { UseModelsSelectorReturn } from '$lib/hooks/use-models-selector.svelte';
+ import { ModelsService } from '$lib/services/models.service';
+ import { modelsStore, uiStore } from '$lib/stores';
+ import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
+ import type { ModelItem } from '$lib/utils';
+ import { modelLoadFraction, repoOf } from '$lib/utils';
+
+ interface Props {
+ /** The selector state this shell renders; the owner derives it once. */
+ ms: UseModelsSelectorReturn;
+ currentModel?: string | null;
+ disabled?: boolean;
+ /** Model id the list highlights from the keyboard; the owner tracks it. */
+ highlightedId?: string | null;
+ /** Bind the menu's open state, so the owner sees the close too. */
+ open?: boolean;
+ showOrgName?: boolean;
+ onHighlight?: (id: string | null) => void;
+ onManageModels?: () => void;
+ onModelKeyAction?: (modelId: string, unload: boolean) => void;
+ onSearchKeyDown?: (event: KeyboardEvent) => void;
+ }
+
+ let {
+ currentModel = null,
+ disabled = false,
+ highlightedId = null,
+ ms,
+ onHighlight,
+ onManageModels,
+ onModelKeyAction,
+ onSearchKeyDown,
+ open = $bindable(false),
+ showOrgName = true
+ }: Props = $props();
+
+ const selectedOption = $derived(ms.getDisplayOption());
+ const triggerModel = $derived(selectedOption?.model ?? null);
+
+ let triggerStatus = $derived(
+ triggerModel
+ ? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
+ : undefined
+ );
+ let triggerLoading = $derived(
+ !!triggerModel &&
+ (triggerStatus === ServerModelStatus.LOADING ||
+ modelsStore.status.isOperationInProgress(triggerModel))
+ );
+ let triggerLoadPercent = $derived(
+ triggerModel && triggerLoading
+ ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
+ : 0
+ );
+
+ /** Draft sidecar as it reads in the trigger tooltip, with its own quant. */
+ function draftSidecarLabel(baseModel: string, badge: ModelSidecarBadge): string {
+ const baseRepo = repoOf(baseModel);
+
+ // a sidecar of the model's own repo reads as a bare tag, a foreign one keeps its id
+ if (badge.repo === baseRepo) {
+ return `${badge.kind.toUpperCase()}${badge.quant ? `:${badge.quant}` : ''}`;
+ }
+
+ return ModelsService.buildDownloadTag(badge.repo, badge.quant, badge.kind);
+ }
+
+ /** Raw id of the selected model, plus every draft sidecar it pulls. */
+ function triggerTooltipLabel(option: ModelOption): string {
+ const drafts = (option.draftSidecars ?? []).map((badge) =>
+ draftSidecarLabel(option.model, badge)
+ );
+
+ return [option.model, ...drafts].join(' + ');
+ }
+
+ function handleManageModels() {
+ open = false;
+
+ // let the menu finish closing before the dialog takes focus
+ setTimeout(() => (onManageModels ? onManageModels() : uiStore.openModelsManager()), 0);
+ }
+</script>
+
+{#snippet selectorTriggerInner()}
+ <ModelsSelectorTriggerIcon class="mr-0.375 size-3.5 shrink-0" option={selectedOption} />
+
+ <span class="flex min-w-0 items-center gap-0.5">
+ {#if selectedOption}
+ <ModelId
+ class="min-w-0 overflow-hidden"
+ hideOrgName={!showOrgName}
+ hideQuantization
+ modelId={selectedOption.model}
+ />
+ {:else}
+ <span class="min-w-0 font-medium">Select model</span>
+ {/if}
+ </span>
+
+ {#if ms.updating || ms.isLoadingModel || triggerLoading}
+ <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
+ {:else}
+ <ChevronDown class="h-3 w-3.5 shrink-0" />
+ {/if}
+
+ {#if triggerLoading}
+ <ModelLoadHighlight percent={triggerLoadPercent} />
+ {/if}
+{/snippet}
+
+{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
+ {@const { option } = item}
+ {@const isSelected = currentModel === option.model || ms.activeId === option.id}
+ {@const isHighlighted = option.id === highlightedId}
+ {@const isFav = ms.isFavorite(option.model)}
+
+ <ModelsSelectorOption
+ {hideOrgName}
+ {isFav}
+ {isHighlighted}
+ {isSelected}
+ onKeyDown={(event) => {
+ if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
+ event.preventDefault();
+ onModelKeyAction?.(option.id, event.altKey);
+ }
+ }}
+ onMouseEnter={() => onHighlight?.(option.id)}
+ onSelect={ms.handleSelect}
+ {option}
+ />
+{/snippet}
+
+<DropdownMenu.Root bind:open onOpenChange={ms.handleOpenChange}>
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <!-- prevent another nested button element -->
+ {#snippet child({ props })}
+ <DropdownMenu.Trigger
+ {...props}
+ class={[
+ `relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 max-md:h-8 max-md:px-2.25 max-md:py-1.25 max-md:text-[13px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
+ !ms.isCurrentModelInCache
+ ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+ : 'text-foreground',
+ open && 'text-foreground',
+ 'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
+ ]}
+ disabled={disabled || ms.updating}
+ >
+ {@render selectorTriggerInner()}
+ </DropdownMenu.Trigger>
+ {/snippet}
+ </Tooltip.Trigger>
+
+ {#if selectedOption}
+ <Tooltip.Content>
+ <p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+
+ <DropdownMenu.Content
+ align="end"
+ class="w-full md:min-w-80 md:w-112 max-w-[calc(100vw-2rem)] p-0! max-h-[min(40rem,calc(var(--bits-dropdown-menu-content-available-height)-1rem))]"
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+ <DropdownMenuSearchable
+ emptyMessage={ms.emptyMessage}
+ isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
+ onSearchChange={(v) => ms.setSearchTerm(v)}
+ {onSearchKeyDown}
+ placeholder="Search models..."
+ searchClass="bg-transparent"
+ searchValue={ms.searchTerm}
+ >
+ <!-- Option list; the search header sticks to the top and the actions
+ footer to the bottom of the content scrollport. -->
+ {@render pickerContent('px-1.5')}
+
+ {#snippet footer()}
+ <DropdownMenu.Group class="px-2">
+ <DropdownMenu.Item class="gap-2" onSelect={handleManageModels}>
+ <MODEL_ICON class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+
+ Manage models
+ </DropdownMenu.Item>
+ </DropdownMenu.Group>
+ {/snippet}
+ </DropdownMenuSearchable>
+ </DropdownMenu.Content>
+</DropdownMenu.Root>
+
+{#snippet pickerContent(listClass: string)}
+ <div class={['models-list', listClass]}>
+ {#if !ms.isCurrentModelInCache && currentModel}
+ <!-- Show unavailable model as first option (disabled) -->
+ <button
+ aria-disabled="true"
+ aria-selected="true"
+ class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
+ disabled
+ role="option"
+ type="button"
+ >
+ <ModelId
+ class="flex-1"
+ hideOrgName={!showOrgName}
+ hideQuantization
+ modelId={currentModel}
+ />
+
+ <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
+ </button>
+ {/if}
+
+ {#if ms.isEmpty}
+ <p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
+ {/if}
+
+ <ModelsSelectorList
+ activeId={ms.activeId}
+ {currentModel}
+ favorites={ms.favoriteItems}
+ groups={ms.groupedFilteredOptions}
+ loaded={ms.loadedItems}
+ onSelect={ms.handleSelect}
+ renderOption={modelOption}
+ sectionHeaderClass="[&:not(:first-child)]:mt-1 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground/80 select-none"
+ {showOrgName}
+ />
+ </div>
+{/snippet}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte
new file mode 100644
index 000000000..6857e3018
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte
@@ -0,0 +1,235 @@
+<script lang="ts">
+ import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
+ import { Heart, Power } from '@lucide/svelte';
+ import {
+ GroupedList,
+ ModelAvatar,
+ ModelsSection,
+ ModelsSelectorOption
+ } from '$lib/components/app';
+ import { DialogConfirmDownload } from '$lib/components/app/dialogs';
+ import Logo from '$lib/components/app/misc/Logo.svelte';
+ import {
+ LOCAL_BACKEND_ID,
+ MODEL_ROW_WINDOW,
+ SELECTOR_GROUP_STICKY_OFFSET,
+ SETTINGS_KEYS
+ } from '$lib/constants';
+ import { ModelDownloadConfirmAction } from '$lib/enums';
+ import { modelsStore, settingsStore } from '$lib/stores';
+ import {
+ type GroupedModelOptions,
+ groupModelFamilies,
+ type ModelFamilyGroup,
+ type ModelItem,
+ windowLocalGroups
+ } from '$lib/utils';
+
+ interface Props {
+ groups: GroupedModelOptions;
+ currentModel: string | null;
+ activeId: string | null;
+ sectionHeaderClass?: string;
+ onSelect: (modelId: string) => void;
+ renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
+ /** Favorite models of every backend, listed in their own section. */
+ favorites?: ModelItem[];
+ /** Loaded models of every llama-compat backend, leading the list. */
+ loaded?: ModelItem[];
+ /** Show the organization name in every model id of the list. */
+ showOrgName?: boolean;
+ }
+
+ let {
+ activeId,
+ currentModel,
+ favorites = [],
+ groups,
+ loaded = [],
+ onSelect,
+ renderOption,
+ sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
+ showOrgName = true
+ }: Props = $props();
+ let render = $derived(renderOption ?? defaultOption);
+ // a large catalog mounts a window at a time: the sentinel at the end of the list
+ // grows the window when it scrolls into view
+ let visibleCount = $state(MODEL_ROW_WINDOW);
+ let sentinelEl = $state<HTMLElement | null>(null);
+ const localGroups = $derived(windowLocalGroups(groups, visibleCount));
+ const localRowCount = $derived(
+ groups.available.reduce((count, group) => count + group.items.length, 0)
+ );
+ const hasMoreLocal = $derived(localGroups.shown < localRowCount);
+
+ $effect(() => {
+ const sentinel = sentinelEl;
+
+ if (!sentinel || !hasMoreLocal) return;
+
+ // scroll does not bubble, so listen in the capture phase and ask the
+ // sentinel where it is instead of guessing which ancestor scrolls
+ const onScroll = () => {
+ const rect = sentinel.getBoundingClientRect();
+
+ // grow only when the end of the list is on screen: otherwise every scroll
+ // event of the page would mount the whole catalog
+ if (rect.top > window.innerHeight || rect.bottom < 0) return;
+
+ visibleCount += MODEL_ROW_WINDOW;
+ };
+
+ document.addEventListener('scroll', onScroll, { capture: true, passive: true });
+ onScroll();
+
+ return () => document.removeEventListener('scroll', onScroll, { capture: true });
+ });
+ // section headers stick below the dropdown's search block: `--dropdown-sticky-height`
+ // comes from DropdownMenuSearchable and falls back to 0 in the mobile sheet
+ let headerClass = $derived(`${sectionHeaderClass} sticky z-10 bg-popover`);
+ const headerStyle = 'top: var(--dropdown-sticky-height, 0px)';
+
+ /** In-flight / paused downloads, tracked by the status feed. */
+ // only a running download belongs in the selector; a paused one waits in the
+ // manager table, where its partial files are resumed or dropped
+ let downloadEntries = $derived(
+ modelsStore.status.getDownloadEntries().filter((entry) => !entry.isPaused)
+ );
+
+ // cancel is confirmed once for the whole list, so one dialog serves every row
+ // The target is kept while the dialog closes so its copy stays rendered.
+ let pendingCancel = $state('');
+ let cancelOpen = $state(false);
+
+ function requestCancel(repoWithTag: string) {
+ pendingCancel = repoWithTag;
+ cancelOpen = true;
+ }
+</script>
+
+{#snippet familyHeading({ group: family }: { group: ModelFamilyGroup<ModelItem> })}
+ <div class="flex items-center gap-2 px-2 py-1.5 select-none max-md:py-2.5">
+ <ModelAvatar
+ option={family.entries[0].option}
+ showBaseModelAvatar
+ showQuantBadge={false}
+ size="size-6"
+ />
+
+ <span class="truncate text-[13px] font-semibold text-muted-foreground/70">
+ {family.label}
+ </span>
+
+ <span class="text-xs text-muted-foreground">
+ {family.entries.length} model{family.entries.length === 1 ? '' : 's'}
+ </span>
+ </div>
+{/snippet}
+
+{#snippet listItem({ depth, entry }: { depth: number; entry: ModelItem })}
+ <div style="padding-left: {depth * 16}px">{@render render(entry, !showOrgName)}</div>
+{/snippet}
+
+{#snippet listRows(items: ModelItem[], prefix: string)}
+ {#if settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY]}
+ <GroupedList
+ group={familyHeading}
+ groups={groupModelFamilies(items, (row) => row.option.model).map((family) => ({
+ entries: family.entries,
+ group: family,
+ key: `${prefix}-${family.key}`
+ }))}
+ initialCollapsed={modelsStore.collapsedGroupsUnder(prefix)}
+ item={listItem}
+ keyOf={(row) => `${prefix}-${row.option.id}`}
+ onCollapsedChange={(key, collapsed) => modelsStore.setGroupCollapsed(prefix, key, collapsed)}
+ stickyStyle={SELECTOR_GROUP_STICKY_OFFSET}
+ />
+ {:else}
+ <GroupedList item={listItem} {items} keyOf={(row) => `${prefix}-${row.option.id}`} />
+ {/if}
+{/snippet}
+
+{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
+ {@const { option } = item}
+ {@const isSelected = currentModel === option.model || activeId === option.id}
+ {@const isFav = modelsStore.favoriteModelIds.has(option.model)}
+
+ <ModelsSelectorOption
+ {hideOrgName}
+ {isFav}
+ isHighlighted={false}
+ {isSelected}
+ onKeyDown={() => {}}
+ onMouseEnter={() => {}}
+ {onSelect}
+ {option}
+ />
+{/snippet}
+
+{#if loaded.length > 0}
+ <ModelsSection
+ count={loaded.length}
+ label="Loaded models"
+ persistKey="loaded"
+ revealChevronOnHover
+ sticky
+ >
+ {#snippet icon()}
+ <Power class="h-3.5 w-3.5 shrink-0" />
+ {/snippet}
+
+ {#each loaded as item (item.option.id)}
+ {@render render(item, !showOrgName)}
+ {/each}
+ </ModelsSection>
+{/if}
+
+{#if favorites.length > 0}
+ <!-- Favorites come first; the sections below skip them -->
+ <ModelsSection label="Favorites" persistKey="favorites" revealChevronOnHover sticky>
+ {#snippet icon()}
+ <Heart class="h-3.5 w-3.5 shrink-0" />
+ {/snippet}
+
+ {#each favorites as item (`fav-${item.option.id}`)}
+ {@render render(item, !showOrgName)}
+ {/each}
+ </ModelsSection>
+{/if}
+
+{#if downloadEntries.length > 0}
+ <p class={headerClass} style={headerStyle}>Downloading</p>
+
+ {#each downloadEntries as entry (entry.repoWithTag)}
+ <ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} {showOrgName} />
+ {/each}
+{/if}
+
+{#snippet localRows()}
+ {@render listRows(
+ localGroups.available.flatMap((group) => group.items),
+ 'local'
+ )}
+
+ {#if hasMoreLocal}
+ <div bind:this={sentinelEl} aria-hidden="true" class="h-px"></div>
+ {/if}
+{/snippet}
+
+{#if groups.available.length > 0}
+ <ModelsSection label="Local models" persistKey={LOCAL_BACKEND_ID} revealChevronOnHover sticky>
+ {#snippet icon()}
+ <Logo class="shrink-0" style="--size: 0.875rem" />
+ {/snippet}
+
+ {@render localRows()}
+ </ModelsSection>
+{/if}
+
+<DialogConfirmDownload
+ action={ModelDownloadConfirmAction.CANCEL}
+ onClose={() => (cancelOpen = false)}
+ open={cancelOpen}
+ repoWithTag={pendingCancel}
+/>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte
new file mode 100644
index 000000000..2734573ca
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte
@@ -0,0 +1,105 @@
+<script lang="ts">
+ import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
+ import {
+ ModelAvatar,
+ ModelCapabilities,
+ ModelId,
+ ModelLoadControl,
+ ModelRowActions
+ } from '$lib/components/app';
+ import { SETTINGS_KEYS } from '$lib/constants';
+ import { ServerModelStatus } from '$lib/enums';
+ import { modelsStore, settingsStore } from '$lib/stores';
+ import type { ModelOption } from '$lib/types/models';
+ import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
+
+ interface Props {
+ option: ModelOption;
+ isSelected: boolean;
+ isHighlighted: boolean;
+ isFav: boolean;
+ hideOrgName?: boolean;
+ onSelect: (modelId: string) => void;
+ onMouseEnter: () => void;
+ onKeyDown: (e: KeyboardEvent) => void;
+ }
+
+ let {
+ hideOrgName = false,
+ isFav,
+ isHighlighted,
+ isSelected,
+ onKeyDown,
+ onMouseEnter,
+ onSelect,
+ option
+ }: Props = $props();
+
+ let serverStatus = $derived(modelsStore.getModelStatus(option.model));
+ let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
+ let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
+ let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
+ let isLoaded = $derived(modelsStore.isModelRunning(option.model));
+ let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
+
+ let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
+ let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
+ let loadTitle = $derived(modelLoadProgressText(loadProgress));
+ let modalities = $derived(option.modalities);
+ let showCapabilities = $derived(
+ settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_CAPABILITIES_IN_SELECTOR] ?? false
+ );
+</script>
+
+<div
+ aria-selected={isSelected || isHighlighted}
+ class={[
+ 'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
+ 'cursor-pointer',
+ // skip layout and paint for rows scrolled out of the long lists
+ '[content-visibility:auto] [contain-intrinsic-size:auto_2.25rem] max-md:[contain-intrinsic-size:auto_3rem]',
+ // a phone gives the row and its tappable area room to breathe
+ 'max-md:gap-2.5 max-md:px-2.5 max-md:py-2.5',
+ isSelected && !isHighlighted && 'bg-accent/50',
+ isHighlighted && 'bg-accent',
+ (isSelected || isHighlighted) && 'text-accent-foreground',
+ 'hover:bg-accent',
+ 'focus:bg-accent',
+ isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
+ ]}
+ onclick={() => onSelect(option.id)}
+ onkeydown={onKeyDown}
+ onmouseenter={onMouseEnter}
+ role="option"
+ tabindex="0"
+ title={loadTitle}
+>
+ <ModelAvatar {option} size="size-5 max-md:size-7" />
+
+ <ModelId
+ aliases={option.aliases}
+ class="min-w-0 flex-1"
+ draftSidecars={option.draftSidecars}
+ hideCapabilities
+ hideModalities
+ {hideOrgName}
+ {modalities}
+ modelId={option.model}
+ tags={option.tags}
+ title={option.model}
+ />
+
+ {#if showCapabilities}
+ <ModelCapabilities {option} />
+ {/if}
+
+ <div class="flex shrink-0 items-center gap-1 max-md:gap-2.5">
+ <ModelRowActions {isFav} {option} />
+
+ <ModelLoadControl {isFailed} {isLoaded} {isLoading} {isSleeping} {option} />
+ </div>
+
+ {#if isLoading}
+ <ModelLoadHighlight percent={loadPercent} />
+ {/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTriggerIcon.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTriggerIcon.svelte
new file mode 100644
index 000000000..6c9621cdd
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTriggerIcon.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import ModelAvatar from '../ModelAvatar.svelte';
+ import { MODEL_ICON } from '$lib/constants';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ class?: string;
+ /** Selected model; its avatar decides the mark, the generic icon when absent. */
+ option?: ModelOption | null;
+ }
+
+ let { class: className = 'h-3.5 w-3.5', option }: Props = $props();
+</script>
+
+{#if option}
+ <ModelAvatar class={className} {option} showQuantBadge={false} showRepoOrgAvatar size={className}>
+ {#snippet fallback()}
+ <MODEL_ICON class={className} />
+ {/snippet}
+ </ModelAvatar>
+{:else}
+ <MODEL_ICON class={className} />
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts b/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts
new file mode 100644
index 000000000..e234fbac3
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts
@@ -0,0 +1,10 @@
+/** Model selection UI: a desktop serves an anchored dropdown menu and a phone a
+ * bottom drawer, over their shared parts. */
+
+export { default as ModelsSelector } from './ModelsSelector.svelte';
+export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
+export { default as ModelsSelectorDrawer } from './ModelsSelectorDrawer.svelte';
+export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
+export { default as ModelsSelectorDownloadItem } from './ModelsSelectorDownloadItem.svelte';
+export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
+export { default as ModelsSelectorTriggerIcon } from './ModelsSelectorTriggerIcon.svelte';
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
deleted file mode 100644
index 30dedcbae..000000000
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
+++ /dev/null
@@ -1,417 +0,0 @@
-<script lang="ts">
- import ModelLoadHighlight from './ModelLoadHighlight.svelte';
- import type { ModelItem } from './utils';
- import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
- import {
- ChatFormActionAddReasoningSubmenu,
- DialogModelInformation,
- DropdownMenuSearchable,
- ModelId,
- ModelsSelectorList,
- ModelsSelectorOption
- } from '$lib/components/app';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
- import { KeyboardKey, ServerModelStatus } from '$lib/enums';
- import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
- import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
- import { modelsStore, settingsStore } from '$lib/stores';
- import { modelLoadFraction } from '$lib/utils';
-
- interface Props {
- class?: string;
- currentModel?: string | null;
- disabled?: boolean;
- forceForegroundText?: boolean;
- onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
- useGlobalSelection?: boolean;
- }
-
- let {
- class: className = '',
- currentModel = null,
- disabled = false,
- forceForegroundText = false,
- onModelChange,
- useGlobalSelection = false
- }: Props = $props();
-
- let isOpen = $state(false);
- let highlightedId = $state<string | null>(null);
- // The model submenu opens together with the menu so the list and its search
- // box are immediately available, as before the submenu was introduced
- let modelSubOpen = $state(false);
-
- const ms = useModelsSelector({
- currentModel: () => currentModel,
- onModelChange: () => onModelChange,
- onOpenChange: (open) => {
- isOpen = open;
- highlightedId = null;
-
- if (open) {
- // Defer submenu open so the Sub component is mounted first;
- // setting bind:open synchronously can be lost if the Sub hasn't
- // rendered yet.
- queueMicrotask(() => {
- if (isOpen) modelSubOpen = true;
- });
- } else {
- modelSubOpen = false;
- }
- },
- useGlobalSelection: () => useGlobalSelection
- });
-
- const reasoning = useReasoningMenu();
-
- const showOrgNameInTrigger = $derived(
- settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
- );
-
- $effect(() => {
- void ms.searchTerm;
- highlightedId = null;
- });
-
- // Focus the model submenu's search box without scrolling the page. bits-ui
- // auto-focuses the opened content by default, which can yank the page
- // scroll; we prevent that on the Content and refocus the search here.
- $effect(() => {
- if (!isOpen || !modelSubOpen) return;
-
- requestAnimationFrame(() => {
- const search = document.querySelector<HTMLElement>(
- '[data-slot="dropdown-menu-sub-content"] input'
- );
-
- search?.focus({ preventScroll: true });
- });
- });
-
- // Keyboard navigation follows the on-screen row order, not the flat option list order.
- let visualOrder = $derived.by(() => {
- const order: string[] = [];
-
- for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
- for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
- for (const group of ms.groupedFilteredOptions.available) {
- for (const item of group.items) order.push(item.option.id);
- }
-
- return order;
- });
-
- let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
-
- function moveHighlight(direction: 1 | -1) {
- const len = visualOrder.length;
-
- if (len === 0) {
- highlightedId = null;
-
- return;
- }
-
- let index = highlightedIndex;
-
- if (index === -1) {
- index = direction === 1 ? 0 : len - 1;
- } else {
- index = (index + direction + len) % len;
- }
-
- highlightedId = visualOrder[index];
- }
-
- // Alt+Enter only unloads and keeps the dropdown open.
- async function handleModelKeyAction(modelId: string, unload: boolean) {
- if (!unload) {
- void ms.handleSelect(modelId);
-
- return;
- }
-
- const model = modelsStore.routerModels.find((m) => m.id === modelId);
- const status = model?.status?.value as ServerModelStatus | undefined;
-
- if (status === ServerModelStatus.LOADING) return;
-
- await modelsStore.status.unload(modelId);
- }
-
- export function open() {
- ms.handleOpenChange(true);
- }
-
- function handleSearchKeyDown(event: KeyboardEvent) {
- if (event.isComposing) return;
-
- if (event.key === KeyboardKey.ARROW_DOWN) {
- event.preventDefault();
- moveHighlight(1);
- } else if (event.key === KeyboardKey.ARROW_UP) {
- event.preventDefault();
- moveHighlight(-1);
- } else if (event.key === KeyboardKey.ENTER) {
- event.preventDefault();
-
- if (highlightedId) {
- void handleModelKeyAction(highlightedId, event.altKey);
- } else if (visualOrder.length > 0) {
- highlightedId = visualOrder[0];
- }
- }
- }
-</script>
-
-<div class={['relative inline-flex flex-col items-end gap-1', className]}>
- {#if ms.loading && ms.options.length === 0 && ms.isRouter}
- <div class="flex items-center gap-2 text-xs text-muted-foreground">
- <Loader2 class="h-3.5 w-3.5 animate-spin" />
-
- Loading models…
- </div>
- {:else if ms.options.length === 0 && ms.isRouter}
- {#if currentModel}
- <span
- class={[
- 'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
- className
- ]}
- style="max-width: min(calc(100cqw - 10rem), 20rem)"
- >
- <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
- </span>
- {:else}
- <p class="text-xs text-muted-foreground">No models available.</p>
- {/if}
- {:else}
- {@const selectedOption = ms.getDisplayOption()}
- {@const triggerModel = selectedOption?.model}
- {@const triggerStatus = triggerModel
- ? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
- : undefined}
- {@const triggerLoading =
- !!triggerModel &&
- (triggerStatus === ServerModelStatus.LOADING ||
- modelsStore.status.isOperationInProgress(triggerModel))}
- {@const triggerLoadPercent = triggerLoading
- ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
- : 0}
-
- {#if ms.isRouter}
- <DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
- <Tooltip.Root>
- <Tooltip.Trigger>
- <!-- prevent another nested button element -->
- {#snippet child({ props })}
- <DropdownMenu.Trigger
- {...props}
- class={[
- `relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
- !ms.isCurrentModelInCache
- ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
- : forceForegroundText
- ? 'text-foreground'
- : ms.isHighlightedCurrentModelActive
- ? 'text-foreground'
- : 'text-foreground',
- isOpen && 'text-foreground',
- 'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
- ]}
- disabled={disabled || ms.updating}
- >
- <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
-
- <span class="flex min-w-0 items-center gap-1">
- {#if selectedOption}
- <ModelId
- class="min-w-0 overflow-hidden"
- hideOrgName={!showOrgNameInTrigger}
- hideQuantization
- modelId={selectedOption.model}
- />
- {:else}
- <span class="min-w-0 font-medium">Select model</span>
- {/if}
-
- {#if reasoning.isReasoningActive}
- <Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
- {/if}
- </span>
-
- {#if ms.updating || ms.isLoadingModel}
- <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
- {:else}
- <ChevronDown class="h-3 w-3.5 shrink-0" />
- {/if}
-
- {#if triggerLoading}
- <ModelLoadHighlight percent={triggerLoadPercent} />
- {/if}
- </DropdownMenu.Trigger>
- {/snippet}
- </Tooltip.Trigger>
-
- {#if selectedOption}
- <Tooltip.Content>
- <p class="font-mono">{selectedOption.model}</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
-
- <DropdownMenu.Content
- align="end"
- class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
- onOpenAutoFocus={(event) => event.preventDefault()}
- >
- <DropdownMenu.Sub bind:open={modelSubOpen}>
- <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
- <MODEL_SELECTOR_ICON class="h-4 w-4" />
-
- {#if selectedOption}
- <ModelId
- class="min-w-0 flex-1 overflow-hidden"
- hideOrgName={!showOrgNameInTrigger}
- hideQuantization
- modelId={selectedOption.model}
- />
- {:else}
- <span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
- {/if}
- </DropdownMenu.SubTrigger>
-
- <DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
- <DropdownMenuSearchable
- emptyMessage="No models found."
- isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
- onSearchChange={(v) => ms.setSearchTerm(v)}
- onSearchKeyDown={handleSearchKeyDown}
- placeholder="Search models..."
- searchValue={ms.searchTerm}
- >
- <div class="models-list">
- {#if !ms.isCurrentModelInCache && currentModel}
- <!-- Show unavailable model as first option (disabled) -->
- <button
- aria-disabled="true"
- aria-selected="true"
- class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
- disabled
- role="option"
- type="button"
- >
- <ModelId class="flex-1" hideQuantization modelId={currentModel} />
-
- <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
- </button>
- {/if}
-
- {#if ms.filteredOptions.length === 0}
- <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
- {/if}
-
- {#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
- {@const { option } = item}
- {@const isSelected = currentModel === option.model || ms.activeId === option.id}
- {@const isHighlighted = option.id === highlightedId}
- {@const isFav = ms.isFavorite(option.model)}
-
- <ModelsSelectorOption
- {hideOrgName}
- {isFav}
- {isHighlighted}
- {isSelected}
- onInfoClick={ms.handleInfoClick}
- onKeyDown={(event) => {
- if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
- event.preventDefault();
- void handleModelKeyAction(option.id, event.altKey);
- }
- }}
- onMouseEnter={() => (highlightedId = option.id)}
- onSelect={ms.handleSelect}
- {option}
- />
- {/snippet}
-
- <ModelsSelectorList
- activeId={ms.activeId}
- {currentModel}
- groups={ms.groupedFilteredOptions}
- onInfoClick={ms.handleInfoClick}
- onSelect={ms.handleSelect}
- renderOption={modelOption}
- sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
- />
- </div>
- </DropdownMenuSearchable>
- </DropdownMenu.SubContent>
- </DropdownMenu.Sub>
-
- <ChatFormActionAddReasoningSubmenu />
- </DropdownMenu.Content>
- </DropdownMenu.Root>
- {:else}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <!-- prevent another nested button element -->
- {#snippet child({ props })}
- <button
- {...props}
- class={[
- `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
- !ms.isCurrentModelInCache
- ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
- : forceForegroundText
- ? 'text-foreground'
- : ms.isHighlightedCurrentModelActive
- ? 'text-foreground'
- : 'text-foreground',
- isOpen && 'text-foreground'
- ]}
- disabled={disabled || ms.updating}
- onclick={() => ms.handleOpenChange(true)}
- style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
- >
- <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
-
- {#if selectedOption}
- <ModelId
- class="min-w-0 overflow-hidden"
- hideOrgName={!showOrgNameInTrigger}
- hideQuantization
- modelId={selectedOption.model}
- />
- {/if}
-
- {#if reasoning.isReasoningActive}
- <Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
- {/if}
-
- {#if ms.updating}
- <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
- {/if}
- </button>
- {/snippet}
- </Tooltip.Trigger>
-
- {#if selectedOption}
- <Tooltip.Content>
- <p class="font-mono">{selectedOption.model}</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
- {/if}
- {/if}
-</div>
-
-{#if ms.showModelDialog}
- <DialogModelInformation
- modelId={ms.infoModelId}
- onOpenChange={(v) => ms.setShowModelDialog(v)}
- open={ms.showModelDialog}
- />
-{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte
deleted file mode 100644
index e40e33d0c..000000000
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte
+++ /dev/null
@@ -1,76 +0,0 @@
-<script lang="ts">
- import type { GroupedModelOptions, ModelItem } from './utils';
- import { ModelsSelectorOption } from '$lib/components/app';
- import { modelsStore } from '$lib/stores';
-
- interface Props {
- groups: GroupedModelOptions;
- currentModel: string | null;
- activeId: string | null;
- sectionHeaderClass?: string;
- orgHeaderClass?: string;
- onSelect: (modelId: string) => void;
- onInfoClick: (modelName: string) => void;
- renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
- }
-
- let {
- activeId,
- currentModel,
- groups,
- onInfoClick,
- onSelect,
- orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
- renderOption,
- sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
- }: Props = $props();
- let render = $derived(renderOption ?? defaultOption);
-</script>
-
-{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
- {@const { option } = item}
- {@const isSelected = currentModel === option.model || activeId === option.id}
- {@const isFav = modelsStore.favoriteModelIds.has(option.model)}
-
- <ModelsSelectorOption
- {hideOrgName}
- {isFav}
- isHighlighted={false}
- {isSelected}
- {onInfoClick}
- onKeyDown={() => {}}
- onMouseEnter={() => {}}
- {onSelect}
- {option}
- />
-{/snippet}
-
-{#if groups.loaded.length > 0}
- <p class={sectionHeaderClass}>Loaded models</p>
-
- {#each groups.loaded as item (`loaded-${item.option.id}`)}
- {@render render(item, false)}
- {/each}
-{/if}
-
-{#if groups.favorites.length > 0}
- <p class={sectionHeaderClass}>Favorite models</p>
-
- {#each groups.favorites as item (`fav-${item.option.id}`)}
- {@render render(item, true)}
- {/each}
-{/if}
-
-{#if groups.available.length > 0}
- <p class={sectionHeaderClass}>Available models</p>
-
- {#each groups.available as group (group.orgName)}
- {#if group.orgName}
- <p class={orgHeaderClass}>{group.orgName}</p>
- {/if}
-
- {#each group.items as item (item.option.id)}
- {@render render(item, true)}
- {/each}
- {/each}
-{/if}
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
deleted file mode 100644
index cee7d0c3b..000000000
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
+++ /dev/null
@@ -1,216 +0,0 @@
-<script lang="ts">
- import ModelLoadHighlight from './ModelLoadHighlight.svelte';
- import {
- CircleAlert,
- Heart,
- HeartOff,
- Info,
- Loader2,
- Power,
- PowerOff,
- RotateCw
- } from '@lucide/svelte';
- import { ActionIcon, ModelId } from '$lib/components/app';
- import { ICON_CLASS_DEFAULT } from '$lib/constants';
- import { ModelCapability, ServerModelStatus } from '$lib/enums';
- import { modelsStore } from '$lib/stores';
- import type { ModelOption } from '$lib/types/models';
- import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
-
- interface Props {
- option: ModelOption;
- isSelected: boolean;
- isHighlighted: boolean;
- isFav: boolean;
- hideOrgName?: boolean;
- onSelect: (modelId: string) => void;
- onMouseEnter: () => void;
- onKeyDown: (e: KeyboardEvent) => void;
- onInfoClick?: (modelName: string) => void;
- }
-
- let {
- hideOrgName = false,
- isFav,
- isHighlighted,
- isSelected,
- onInfoClick,
- onKeyDown,
- onMouseEnter,
- onSelect,
- option
- }: Props = $props();
-
- let currentRouterModels = $derived(modelsStore.routerModels);
- let serverStatus = $derived.by(() => {
- const model = currentRouterModels.find((m) => m.id === option.model);
-
- return (model?.status?.value as ServerModelStatus) ?? null;
- });
- let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
- let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
- let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
- let isLoaded = $derived(
- (serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
- );
- let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
-
- let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
- let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
- let loadTitle = $derived(modelLoadProgressText(loadProgress));
- let modalities = $derived(option.modalities);
- let capabilities = $derived.by(() => ({
- reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
- tools: option.capabilities.includes(ModelCapability.TOOL_USE)
- }));
-</script>
-
-<div
- aria-selected={isSelected || isHighlighted}
- class={[
- 'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
- 'cursor-pointer',
- isSelected && !isHighlighted && 'bg-accent/50',
- isHighlighted && 'bg-accent',
- (isSelected || isHighlighted) && 'text-accent-foreground',
- 'hover:bg-accent',
- 'focus:bg-accent',
- isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
- ]}
- onclick={() => onSelect(option.id)}
- onkeydown={onKeyDown}
- onmouseenter={onMouseEnter}
- role="option"
- tabindex="0"
- title={loadTitle}
->
- <ModelId
- aliases={option.aliases}
- class="flex-1"
- {hideOrgName}
- {modalities}
- modelId={option.model}
- showRawTooltip
- supportsThinking={capabilities.reasoning}
- supportsToolUse={capabilities.tools}
- tags={option.tags}
- />
-
- <div class="flex shrink-0 items-center gap-1">
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <!-- svelte-ignore a11y_click_events_have_key_events -->
- <div
- class="pointer-events-none flex items-center justify-center gap-0.75 pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
- onclick={(e) => e.stopPropagation()}
- >
- {#if isFav}
- <ActionIcon
- class="h-3 w-3 hover:text-foreground"
- icon={HeartOff}
- iconSize="h-2.5 w-2.5"
- onclick={() => modelsStore.toggleFavorite(option.model)}
- tooltip="Remove from favorites"
- />
- {:else}
- <ActionIcon
- class="h-3 w-3 hover:text-foreground"
- icon={Heart}
- iconSize="h-2.5 w-2.5"
- onclick={() => modelsStore.toggleFavorite(option.model)}
- tooltip="Add to favorites"
- />
- {/if}
-
- <!-- info button: only shown when model is loaded and callback is provided -->
- {#if isLoaded && onInfoClick}
- <ActionIcon
- class="h-3 w-3 hover:text-foreground"
- icon={Info}
- iconSize="h-2.5 w-2.5"
- onclick={() => onInfoClick(option.model)}
- tooltip="Model information"
- />
- {/if}
- </div>
-
- {#if isLoading}
- <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
- <Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
- </div>
- {:else if isFailed}
- <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
- <CircleAlert
- class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
- />
-
- <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
- <ActionIcon
- class="h-3 w-3 text-red-500 hover:text-foreground"
- icon={RotateCw}
- iconSize="h-2.5 w-2.5"
- onclick={() => modelsStore.status.load(option.model)}
- stopPropagationOnClick
- tooltip="Retry loading model"
- />
- </div>
- </div>
- {:else if isSleeping}
- <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
- <span
- class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden [@media(pointer:coarse)]:hidden"
- ></span>
-
- <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
- <ActionIcon
- class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
- icon={PowerOff}
- iconSize="h-2.5 w-2.5"
- onclick={(e) => {
- e?.stopPropagation();
- modelsStore.status.unload(option.model);
- }}
- tooltip="Unload model"
- />
- </div>
- </div>
- {:else if isLoaded}
- <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
- <span
- class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
- ></span>
-
- <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
- <ActionIcon
- class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
- icon={PowerOff}
- iconSize="h-2.5 w-2.5"
- onclick={() => modelsStore.status.unload(option.model)}
- stopPropagationOnClick
- tooltip="Unload model"
- />
- </div>
- </div>
- {:else}
- <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
- <span
- class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden [@media(pointer:coarse)]:hidden"
- ></span>
-
- <div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
- <ActionIcon
- class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
- icon={Power}
- iconSize="h-2.5 w-2.5"
- onclick={() => modelsStore.status.load(option.model)}
- stopPropagationOnClick
- tooltip="Load model"
- />
- </div>
- </div>
- {/if}
- </div>
-
- {#if isLoading}
- <ModelLoadHighlight percent={loadPercent} />
- {/if}
-</div>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte
deleted file mode 100644
index c89ca186e..000000000
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte
+++ /dev/null
@@ -1,210 +0,0 @@
-<script lang="ts">
- import ModelLoadHighlight from './ModelLoadHighlight.svelte';
- import { ChevronDown, Loader2, Package } from '@lucide/svelte';
- import {
- DialogModelInformation,
- ModelId,
- ModelsSelectorList,
- SearchInput
- } from '$lib/components/app';
- import * as Sheet from '$lib/components/ui/sheet';
- import { ServerModelStatus } from '$lib/enums';
- import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
- import { modelsStore } from '$lib/stores';
- import { modelLoadFraction } from '$lib/utils';
-
- interface Props {
- class?: string;
- currentModel?: string | null;
- /** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
- onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
- disabled?: boolean;
- forceForegroundText?: boolean;
- /** When true, user's global selection takes priority over currentModel (for form selector) */
- useGlobalSelection?: boolean;
- }
-
- let {
- class: className = '',
- currentModel = null,
- disabled = false,
- forceForegroundText = false,
- onModelChange,
- useGlobalSelection = false
- }: Props = $props();
-
- let sheetOpen = $state(false);
-
- const ms = useModelsSelector({
- currentModel: () => currentModel,
- onModelChange: () => onModelChange,
- onOpenChange: (open) => {
- sheetOpen = open;
- },
- useGlobalSelection: () => useGlobalSelection
- });
-
- export function open() {
- ms.handleOpenChange(true);
- }
-
- function handleSheetOpenChange(open: boolean) {
- if (!open) {
- ms.handleOpenChange(false);
- }
- }
-</script>
-
-<div class={['relative inline-flex flex-col items-end gap-1', className]}>
- {#if ms.loading && ms.options.length === 0 && ms.isRouter}
- <div class="flex items-center gap-2 text-xs text-muted-foreground">
- <Loader2 class="h-3.5 w-3.5 animate-spin" />
- Loading models…
- </div>
- {:else if ms.options.length === 0 && ms.isRouter}
- <p class="text-xs text-muted-foreground">No models available.</p>
- {:else}
- {@const selectedOption = ms.getDisplayOption()}
- {@const triggerModel = selectedOption?.model}
- {@const triggerStatus = triggerModel
- ? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
- : undefined}
- {@const triggerLoading =
- !!triggerModel &&
- (triggerStatus === ServerModelStatus.LOADING ||
- modelsStore.status.isOperationInProgress(triggerModel))}
- {@const triggerLoadPercent = triggerLoading
- ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
- : 0}
-
- {#if ms.isRouter}
- <button
- class={[
- `relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
- !ms.isCurrentModelInCache
- ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
- : forceForegroundText
- ? 'text-foreground'
- : ms.isHighlightedCurrentModelActive
- ? 'text-foreground'
- : 'text-foreground',
- sheetOpen && 'text-foreground'
- ]}
- disabled={disabled || ms.updating}
- onclick={() => ms.handleOpenChange(true)}
- style="max-width: min(calc(100cqw - 9rem), 20rem)"
- type="button"
- >
- <Package class="h-3.5 w-3.5 shrink-0" />
-
- {#if !selectedOption}
- <span class="min-w-0 font-medium">Select model</span>
- {:else}
- <ModelId
- class="text-xs"
- hideOrgName
- hideQuantization
- hideTags
- modelId={selectedOption?.model || ''}
- />
- {/if}
-
- {#if ms.updating || ms.isLoadingModel}
- <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
- {:else}
- <ChevronDown class="h-3 w-3.5 shrink-0" />
- {/if}
-
- {#if triggerLoading}
- <ModelLoadHighlight percent={triggerLoadPercent} />
- {/if}
- </button>
-
- <Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
- <Sheet.Content class="max-h-[85vh] gap-1" side="bottom">
- <Sheet.Header>
- <Sheet.Title>Select Model</Sheet.Title>
-
- <Sheet.Description class="sr-only">
- Choose a model to use for the conversation
- </Sheet.Description>
- </Sheet.Header>
-
- <div class="flex flex-col gap-1 pb-4">
- <div class="mb-3 px-4">
- <SearchInput
- onInput={(v) => ms.setSearchTerm(v)}
- placeholder="Search models..."
- value={ms.searchTerm}
- />
- </div>
-
- <div class="max-h-[60vh] overflow-y-auto px-2">
- {#if !ms.isCurrentModelInCache && currentModel}
- <button
- class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
- disabled
- type="button"
- >
- <span class="min-w-0 flex-1 truncate">
- {selectedOption?.name || currentModel}
- </span>
-
- <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
- </button>
-
- <div class="my-1 h-px bg-border"></div>
- {/if}
-
- {#if ms.filteredOptions.length === 0}
- <p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
- {/if}
-
- <ModelsSelectorList
- activeId={ms.activeId}
- {currentModel}
- groups={ms.groupedFilteredOptions}
- onInfoClick={ms.handleInfoClick}
- onSelect={ms.handleSelect}
- orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
- sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
- />
- </div>
- </div>
- </Sheet.Content>
- </Sheet.Root>
- {:else}
- <button
- class={[
- `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
- !ms.isCurrentModelInCache
- ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
- : forceForegroundText
- ? 'text-foreground'
- : ms.isHighlightedCurrentModelActive
- ? 'text-foreground'
- : 'text-foreground'
- ]}
- disabled={disabled || ms.updating}
- onclick={() => ms.handleOpenChange(true)}
- style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
- >
- <Package class="h-3.5 w-3.5 shrink-0" />
-
- <ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
-
- {#if ms.updating}
- <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
- {/if}
- </button>
- {/if}
- {/if}
-</div>
-
-{#if ms.showModelDialog}
- <DialogModelInformation
- modelId={ms.infoModelId}
- onOpenChange={(v) => ms.setShowModelDialog(v)}
- open={ms.showModelDialog}
- />
-{/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte
deleted file mode 100644
index f78e74586..000000000
--- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte
+++ /dev/null
@@ -1,109 +0,0 @@
-<script lang="ts">
- import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
- import { HuggingFaceService } from '$lib/services';
- import { SvelteSet } from 'svelte/reactivity';
-
- // Orgs whose avatar failed before. A row remounting (scrolling, reopening
- // the selector) must not re-request them; the monogram renders instead.
- const failedAvatarOrgs = new SvelteSet<string>();
-
- interface Props {
- class?: string;
- org: string;
- quantOrg?: string;
- size?: string;
- baseImageClass?: string;
- quantImageClass?: string;
- quantPositionClass?: string;
- quantSize?: string;
- }
-
- let {
- baseImageClass = '',
- class: className = '',
- org,
- quantImageClass = 'h-full w-full',
- quantOrg,
- quantPositionClass = '-bottom-0.75 -right-0.75',
- quantSize = 'h-4.25 w-4.25',
- size = 'h-9 w-9'
- }: Props = $props();
-
- let orgAvatarFailed = $derived(failedAvatarOrgs.has(org));
- let quantAvatarFailed = $derived(failedAvatarOrgs.has(quantOrg ?? ''));
-
- let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
- let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
-
- // Monogram fallback: org initial on a hue derived from its name, so each org
- // gets a stable distinct color.
- let hue = $derived.by(() => {
- let h = 0;
-
- for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
-
- return h % 360;
- });
-
- let quantHue = $derived.by(() => {
- const name = quantOrg ?? '';
-
- let h = 0;
-
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
-
- return h % 360;
- });
-</script>
-
-<span class="relative mt-0.5 inline-flex shrink-0 {className}">
- {#if orgAvatarFailed}
- <span
- aria-hidden="true"
- class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
- style="background-color: hsl({hue} 60% 45%)"
- >
- {org.charAt(0).toUpperCase()}
- </span>
- {:else}
- <div class="rounded-md">
- <img
- alt=""
- class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
- loading="lazy"
- onerror={() => {
- failedAvatarOrgs.add(org);
- }}
- src={HuggingFaceService.getAvatarUrl(org)}
- />
- </div>
- {/if}
-
- {#if quantOrg && quantOrg !== org}
- <!-- native title instead of a floating tooltip: long model lists mount one badge per row -->
- <span
- class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted"
- title={quantOrg}
- >
- {#if quantAvatarFailed}
- <span
- aria-hidden="true"
- class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
- style="background-color: hsl({quantHue} 60% 45%)"
- >
- {quantOrg.charAt(0).toUpperCase()}
- </span>
- {:else}
- <img
- alt=""
- class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
- loading="lazy"
- onerror={() => {
- failedAvatarOrgs.add(quantOrg ?? '');
- }}
- src={HuggingFaceService.getAvatarUrl(quantOrg)}
- />
- {/if}
- </span>
- {/if}
-</span>
diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts
index f6a9f1f9b..dbfb5c60e 100644
--- a/tools/ui/src/lib/components/app/models/index.ts
+++ b/tools/ui/src/lib/components/app/models/index.ts
@@ -10,70 +10,6 @@
*
*/
-/**
- * **ModelsSelectorDropdown** - Model selection dropdown (desktop)
- *
- * Dropdown for selecting AI models with status indicators,
- * search, and model information display. Adapts UI based on server mode.
- *
- * **Architecture:**
- * - Uses DropdownMenuSearchable for model list
- * - Integrates with modelsStore for model options and selection
- * - Detects router vs single mode from serverStore
- * - Opens DialogModelInformation for model details
- *
- * **Features:**
- * - Searchable model list with keyboard navigation
- * - Model status indicators (loading/ready/error/updating)
- * - Model capabilities badges (vision, tools, etc.)
- * - Current/active model highlighting
- * - Model information dialog on info button click
- * - Router mode: shows all available models with status
- * - Single mode: shows current model name only
- * - Loading/updating skeleton states
- * - Global selection support for form integration
- *
- * @example
- * ```svelte
- * <ModelsSelectorDropdown
- * currentModel={conversation.modelId}
- * onModelChange={(id, name) => updateModel(id)}
- * useGlobalSelection
- * />
- * ```
- */
-export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
-
-/**
- * **ModelsSelectorList** - Grouped model options list
- *
- * Renders grouped model options (loaded, favorites, available) with section
- * headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
- * to avoid template duplication.
- *
- * Accepts an optional `renderOption` snippet to customize how each option is
- * rendered (e.g., to add keyboard navigation or highlighting).
- */
-export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
-
-/**
- * **ModelsSelectorOption** - Single model option row
- *
- * Renders a single model option with selection state, favorite toggle,
- * load/unload actions, status indicators, and an info button.
- * Used inside ModelsSelectorList or directly in custom render snippets.
- */
-export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
-
-/**
- * **ModelsSelectorSheet** - Mobile model selection sheet
- *
- * Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
- * on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
- * instead of DropdownMenu.
- */
-export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
-
/** * **ModelBadge** - Model name display badge
*
* Compact badge showing current model name with package icon.
@@ -108,13 +44,19 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* aliases, and tags. Supports raw mode to show the unprocessed model name.
* Respects the user's `showRawModelNames` setting.
*/
+export { default as ModelCapabilities } from './ModelCapabilities.svelte';
+export { default as ModelContext } from './ModelContext.svelte';
export { default as ModelId } from './ModelId.svelte';
+export { default as ModelAvatar } from './ModelAvatar.svelte';
+export { default as ModelDownloadProgressBar } from './ModelDownloadProgressBar.svelte';
+export { default as ModelLoadControl } from './ModelLoadControl.svelte';
+export { default as ModelOrgAvatar } from './ModelOrgAvatar.svelte';
+export { default as ModelRowActions } from './ModelRowActions.svelte';
+export { default as ModelsSection } from './ModelsSection.svelte';
-/**
- * **ModelCapabilityIcons** - Capability and modality icon row
- *
- * The shared tool-use / reasoning / vision / video / audio icon cluster with
- * tooltips, used by ModelId and the discover details header so the order and
- * styling stay consistent across every model-id surface.
- */
+/** Capability and modality icon row, shared by every model-id surface. */
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
+
+/** Draft sidecar badges, rendered as `+ [KIND] [QUANT]` per sidecar. */
+export { default as ModelDraftSidecars } from './ModelDraftSidecars.svelte';
+export * from './ModelsSelector';
diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
index 20df04eb4..161462a04 100644
--- a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
@@ -1,7 +1,10 @@
<script lang="ts">
import { KeyboardShortcutInfo } from '$lib/components/app';
+ import * as Drawer from '$lib/components/ui/drawer';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import { Separator } from '$lib/components/ui/separator';
import * as Tooltip from '$lib/components/ui/tooltip';
+ import { deviceStore } from '$lib/stores';
import type { Component } from 'svelte';
interface ActionItem {
@@ -31,61 +34,125 @@
triggerIcon,
triggerTooltip
}: Props = $props();
-</script>
-<DropdownMenu.Root bind:open>
- <Tooltip.Root>
- <Tooltip.Trigger>
- <!-- prevent another nested button element -->
- {#snippet child({ props })}
- <DropdownMenu.Trigger
- {...props}
- class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
- onclick={(e) => e.stopPropagation()}
- >
- {@render iconComponent(triggerIcon, 'h-3 w-3')}
+ // a phone shows the same actions in a drawer, where a thumb reaches them and every
+ // row is a touch target of its own
+ let isMobile = $derived(deviceStore.isMobile);
- {#if triggerTooltip}
- <span class="sr-only">{triggerTooltip}</span>
- {/if}
- </DropdownMenu.Trigger>
- {/snippet}
- </Tooltip.Trigger>
+ function runAction(action: ActionItem, event: Event) {
+ open = false;
+
+ // let the drawer close before an action that moves the focus, such as a confirm dialog
+ setTimeout(() => action.onclick(event), 0);
+ }
+</script>
+
+{#if isMobile}
+ <button
+ class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
+ data-slot="dropdown-menu-trigger"
+ data-state={open ? 'open' : 'closed'}
+ onclick={(event) => {
+ event.stopPropagation();
+ open = true;
+ }}
+ type="button"
+ >
+ {@render iconComponent(triggerIcon, 'h-3 w-3')}
{#if triggerTooltip}
- <Tooltip.Content>
- <p>{triggerTooltip}</p>
- </Tooltip.Content>
+ <span class="sr-only">{triggerTooltip}</span>
{/if}
- </Tooltip.Root>
+ </button>
+
+ <Drawer.Root bind:open>
+ <Drawer.Content>
+ <Drawer.Header>
+ <Drawer.Title>{triggerTooltip ?? 'Actions'}</Drawer.Title>
+ </Drawer.Header>
+
+ <div class="flex flex-col px-2 pb-4">
+ {#each actions as action, index (action.label)}
+ {#if action.separator && index > 0}
+ <Separator class="my-1" />
+ {/if}
+
+ <button
+ class="flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50 {action.variant ===
+ 'destructive'
+ ? 'text-destructive'
+ : ''}"
+ disabled={action.disabled}
+ onclick={(event) => runAction(action, event)}
+ type="button"
+ >
+ {@render iconComponent(action.icon, 'h-4 w-4 shrink-0')}
+
+ <span class="flex-1">{action.label}</span>
+
+ {#if action.shortcut}
+ <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ </Drawer.Content>
+ </Drawer.Root>
+{:else}
+ <DropdownMenu.Root bind:open>
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <!-- prevent another nested button element -->
+ {#snippet child({ props })}
+ <DropdownMenu.Trigger
+ {...props}
+ class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
+ onclick={(e) => e.stopPropagation()}
+ >
+ {@render iconComponent(triggerIcon, 'h-3 w-3')}
+
+ {#if triggerTooltip}
+ <span class="sr-only">{triggerTooltip}</span>
+ {/if}
+ </DropdownMenu.Trigger>
+ {/snippet}
+ </Tooltip.Trigger>
- <DropdownMenu.Content {align} class="z-[999999] w-48">
- {#each actions as action, index (action.label)}
- {#if action.separator && index > 0}
- <DropdownMenu.Separator />
+ {#if triggerTooltip}
+ <Tooltip.Content>
+ <p>{triggerTooltip}</p>
+ </Tooltip.Content>
{/if}
+ </Tooltip.Root>
- <DropdownMenu.Item
- class="flex items-center justify-between hover:[&>kbd]:opacity-100"
- disabled={action.disabled}
- onclick={action.onclick}
- variant={action.variant}
- >
- <div class="flex items-center gap-2">
- {@render iconComponent(
- action.icon,
- `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
- )}
- {action.label}
- </div>
-
- {#if action.shortcut}
- <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
+ <DropdownMenu.Content {align} class="z-[999999] w-48">
+ {#each actions as action, index (action.label)}
+ {#if action.separator && index > 0}
+ <DropdownMenu.Separator />
{/if}
- </DropdownMenu.Item>
- {/each}
- </DropdownMenu.Content>
-</DropdownMenu.Root>
+
+ <DropdownMenu.Item
+ class="flex items-center justify-between hover:[&>kbd]:opacity-100"
+ disabled={action.disabled}
+ onclick={action.onclick}
+ variant={action.variant}
+ >
+ <div class="flex items-center gap-2">
+ {@render iconComponent(
+ action.icon,
+ `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
+ )}
+ {action.label}
+ </div>
+
+ {#if action.shortcut}
+ <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
+ {/if}
+ </DropdownMenu.Item>
+ {/each}
+ </DropdownMenu.Content>
+ </DropdownMenu.Root>
+{/if}
{#snippet iconComponent(IconComponent: Component, className: string)}
<IconComponent class={className} />
diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte
index f14db5f23..79336cf29 100644
--- a/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte
+++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte
@@ -1,50 +1,79 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import { cn } from '$lib/components/ui/utils.js';
import type { Snippet } from 'svelte';
interface Props {
+ /** Extra classes for the sticky search header. */
+ headerClass?: string;
placeholder?: string;
searchValue?: string;
onSearchChange?: (value: string) => void;
onSearchKeyDown?: (event: KeyboardEvent) => void;
emptyMessage?: string;
isEmpty?: boolean;
+ /** Extra classes for the wrapper around the option list. */
+ contentClass?: string;
+ /** Extra classes for the search input. */
+ searchClass?: string;
children: Snippet;
+ /**
+ * Optional sticky footer: it sticks to the bottom of DropdownMenu.Content's own
+ * scrollport, which must stay the scroll container (overflow-y-auto + max-height).
+ */
footer?: Snippet;
}
let {
children,
+ contentClass = '',
emptyMessage = 'No items found',
footer,
+ headerClass = '',
isEmpty = false,
onSearchChange,
onSearchKeyDown,
placeholder = 'Search...',
+ searchClass = '',
searchValue = $bindable('')
}: Props = $props();
+
+ // the search height is published so list headers can stick right below it
+ let stickyHeaderHeight = $state(0);
</script>
-<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
+<div
+ bind:clientHeight={stickyHeaderHeight}
+ class={cn('sticky top-0 z-20 bg-popover p-1.5', headerClass)}
+>
<SearchInput
bind:value={searchValue}
+ class={searchClass}
onInput={onSearchChange}
onKeyDown={onSearchKeyDown}
{placeholder}
/>
</div>
-<div class="overflow-y-auto">
- {@render children()}
+<div class={contentClass}>
+ <!-- wrapper carries the sticky offset: bits-ui owns the style of the scrollport -->
+ <div style="--dropdown-sticky-height: {stickyHeaderHeight}px">
+ {@render children()}
- {#if isEmpty}
- <div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
- {/if}
+ {#if isEmpty}
+ <div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
+ {/if}
+ </div>
</div>
{#if footer}
- <DropdownMenu.Separator />
+ <div class="sticky bottom-0 z-20 bg-popover py-1.5">
+ <div
+ aria-orientation="horizontal"
+ class="h-px bg-border/20 mb-1.5 mx-1.5"
+ role="separator"
+ ></div>
- {@render footer()}
+ {@render footer()}
+ </div>
{/if}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
index 8bf12279e..2ce4824cf 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -5,6 +5,9 @@
import {
ActionIcon,
DialogConversationRename,
+ DialogManageModels,
+ DialogMcpServers,
+ DialogModelInformation,
DialogSettingsChat,
Logo,
SidebarNavigationActions,
@@ -93,6 +96,7 @@
let renameDialogOpen = $state(false);
let settingsDialogOpen = $state(false);
+ let mcpDialogOpen = $state(false);
let renameTargetConversationId = $state<string | null>(null);
let renameDraft = $state('');
let renameOriginalTitle = $state('');
@@ -306,6 +310,15 @@
pendingCollapse = null;
}
}
+
+ /** A dialog owns the screen on a phone, so the expanded list steps aside for it. */
+ function openDialog(open: () => void): void {
+ if (deviceStore.isMobile) {
+ scheduleMobileCollapse();
+ }
+
+ open();
+ }
</script>
<svelte:window bind:innerWidth onkeydown={handleKeydown} />
@@ -365,9 +378,9 @@
>
<ActionIcon
ariaLabel="Collapse navigation"
- class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
+ class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 max-md:-mr-1 max-md:-mt-1 hover:bg-accent!"
icon={deviceStore.isMobile ? X : PanelLeftClose}
- iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
+ iconSize="h-4 w-4"
onclick={toggleExpandedMode}
size="lg"
tooltip="Close Sidebar"
@@ -389,6 +402,8 @@
bind:searchQuery
class="px-2"
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
+ onManageModelsClick={() => openDialog(() => uiStore.openModelsManager())}
+ onMcpClick={() => openDialog(() => (mcpDialogOpen = true))}
onNewChat={() => {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
@@ -402,7 +417,7 @@
isSearchModeActive = false;
searchQuery = '';
}}
- onSettingsClick={() => (settingsDialogOpen = true)}
+ onSettingsClick={() => openDialog(() => (settingsDialogOpen = true))}
/>
{#if uiStore.isSidebarExpanded || isOnMobile}
@@ -452,6 +467,12 @@
<DialogSettingsChat bind:open={settingsDialogOpen} />
+<DialogMcpServers bind:open={mcpDialogOpen} />
+
+<DialogManageModels bind:open={uiStore.manageModelsOpen} />
+
+<DialogModelInformation />
+
<style>
aside {
@media (max-width: 768px) {
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
index 6bc2ca2d7..95efa5372 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
@@ -13,6 +13,7 @@
} from '$lib/constants';
import { SidebarAction, TooltipSide } from '$lib/enums';
import { conversationsStore, deviceStore } from '$lib/stores';
+ import type { DesktopIconStripItem } from '$lib/types';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
@@ -25,6 +26,8 @@
searchQuery: string;
onSearchDeactivated?: () => void;
onSearchClick?: () => void;
+ onManageModelsClick?: () => void;
+ onMcpClick?: () => void;
onNewChat?: () => void;
onSettingsClick?: () => void;
}
@@ -33,6 +36,8 @@
class: className,
isExpandedMode = false,
isSearchModeActive = $bindable(false),
+ onManageModelsClick,
+ onMcpClick,
onNewChat,
onSearchClick,
onSearchDeactivated,
@@ -40,6 +45,12 @@
searchQuery = $bindable('')
}: Props = $props();
+ // the models manager stays desktop-only: a phone picks its models in the selector
+ const actions = $derived(
+ SIDEBAR_ACTIONS_ITEMS.filter(
+ (item) => item.action !== SidebarAction.MANAGE_MODELS || !deviceStore.isMobile
+ )
+ );
let initialized = $state(false);
let showIcons = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null);
@@ -57,9 +68,40 @@
setTimeout(() => {
initialized = true;
- }, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
+ }, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actions.length);
});
+ /** Click handler of one icon strip item: a sidebar action, a route, or the search. */
+ function itemClickHandler(
+ item: DesktopIconStripItem,
+ isSearchOnMobile: boolean
+ ): (() => void) | undefined {
+ switch (item.action) {
+ case SidebarAction.NEW_CHAT:
+ return () => {
+ onNewChat?.();
+ void conversationsStore.openNewChat();
+ };
+ case SidebarAction.MANAGE_MODELS:
+ return () => onManageModelsClick?.();
+ case SidebarAction.MCP:
+ return () => onMcpClick?.();
+ case SidebarAction.SETTINGS:
+ return () => onSettingsClick?.();
+ default:
+ break;
+ }
+
+ if (item.route) {
+ return () => {
+ onNewChat?.();
+ goto(item.route!);
+ };
+ }
+
+ return isSearchOnMobile ? undefined : onSearchClick;
+ }
+
function handleSearchModeDeactivate() {
isSearchModeActive = false;
searchQuery = '';
@@ -107,26 +149,11 @@
? 'hidden pointer-events-none'
: ''}"
>
- {#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
+ {#each actions as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
- {@const itemOnClick =
- item.action === SidebarAction.NEW_CHAT
- ? () => {
- onNewChat?.();
- void conversationsStore.openNewChat();
- }
- : item.action === SidebarAction.SETTINGS
- ? () => onSettingsClick?.()
- : item.route
- ? () => {
- onNewChat?.();
- goto(item.route!);
- }
- : isSearchOnMobile
- ? undefined
- : onSearchClick}
+ {@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -164,25 +191,10 @@
</div>
{:else}
<div class="{className} flex-col gap-1 hidden md:flex">
- {#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
+ {#each actions as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
- {@const itemOnClick =
- item.action === SidebarAction.NEW_CHAT
- ? () => {
- onNewChat?.();
- void conversationsStore.openNewChat();
- }
- : item.action === SidebarAction.SETTINGS
- ? () => onSettingsClick?.()
- : item.route
- ? () => {
- onNewChat?.();
- goto(item.route!);
- }
- : isSearchOnMobile
- ? undefined
- : onSearchClick}
+ {@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
diff --git a/tools/ui/src/lib/components/ui/button/button.svelte b/tools/ui/src/lib/components/ui/button/button.svelte
index 8d89f1b95..b5642098f 100644
--- a/tools/ui/src/lib/components/ui/button/button.svelte
+++ b/tools/ui/src/lib/components/ui/button/button.svelte
@@ -27,7 +27,9 @@
outline:
'shadow-sm hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
secondary:
- 'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25'
+ 'bg-muted/30 dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm border-muted border text-foreground hover:bg-muted dark:hover:bg-muted-foreground/25',
+ tertiary:
+ 'bg-muted/60 dark:bg-muted/75 shadow-sm border border-border/30 text-foreground hover:bg-muted/80 dark:border-border/20 dark:hover:bg-muted'
}
}
});
diff --git a/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte b/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte
index 8a1a5909d..3f633df5b 100644
--- a/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte
+++ b/tools/ui/src/lib/components/ui/dialog/dialog-content.svelte
@@ -8,11 +8,13 @@
let {
children,
class: className,
+ overlayClass,
portalProps,
ref = $bindable(null),
showCloseButton = false,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
+ overlayClass?: string;
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
@@ -20,7 +22,7 @@
</script>
<Dialog.Portal {...portalProps}>
- <Dialog.Overlay />
+ <Dialog.Overlay class={overlayClass} />
<DialogPrimitive.Content
bind:ref
diff --git a/tools/ui/src/lib/components/ui/dialog/dialog-header.svelte b/tools/ui/src/lib/components/ui/dialog/dialog-header.svelte
index fd590e186..40c3f2feb 100644
--- a/tools/ui/src/lib/components/ui/dialog/dialog-header.svelte
+++ b/tools/ui/src/lib/components/ui/dialog/dialog-header.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import { PANEL_CLOSE_MOBILE_CLASS } from '$lib/constants';
import { Dialog as DialogPrimitive } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
@@ -33,7 +34,7 @@
{#if showCloseButton}
<DialogPrimitive.Close
- class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
+ class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none max-md:-top-3 max-md:-right-3 {PANEL_CLOSE_MOBILE_CLASS} [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-close.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-close.svelte
new file mode 100644
index 000000000..aea8d1e87
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-close.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
+</script>
+
+<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-content.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-content.svelte
new file mode 100644
index 000000000..a864e813d
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-content.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+ import DrawerOverlay from './drawer-overlay.svelte';
+ import DrawerPortal from './drawer-portal.svelte';
+ import type { WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import { cn } from '$lib/components/ui/utils.js';
+ import type { ComponentProps } from 'svelte';
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let {
+ children,
+ class: className,
+ portalProps,
+ ref = $bindable(null),
+ ...restProps
+ }: DrawerPrimitive.ContentProps & {
+ portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
+ } = $props();
+</script>
+
+<DrawerPortal {...portalProps}>
+ <DrawerOverlay />
+
+ <DrawerPrimitive.Content
+ bind:ref
+ class={cn(
+ 'bg-popover text-popover-foreground flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b group/drawer-content fixed z-50 data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
+ className
+ )}
+ data-slot="drawer-content"
+ {...restProps}
+ >
+ <div
+ class="bg-muted mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
+ ></div>
+
+ {@render children?.()}
+ </DrawerPrimitive.Content>
+</DrawerPortal>
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-description.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-description.svelte
new file mode 100644
index 000000000..ca95150a4
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-description.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let {
+ class: className,
+ ref = $bindable(null),
+ ...restProps
+ }: DrawerPrimitive.DescriptionProps = $props();
+</script>
+
+<DrawerPrimitive.Description
+ bind:ref
+ class={cn('text-muted-foreground text-sm', className)}
+ data-slot="drawer-description"
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-header.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-footer.svelte
similarity index 81%
rename from tools/ui/src/lib/components/ui/sheet/sheet-header.svelte
rename to tools/ui/src/lib/components/ui/drawer/drawer-footer.svelte
index 86c4f17c3..915c58387 100644
--- a/tools/ui/src/lib/components/ui/sheet/sheet-header.svelte
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-footer.svelte
@@ -12,8 +12,8 @@
<div
bind:this={ref}
- class={cn('flex flex-col gap-1.5 p-4', className)}
- data-slot="sheet-header"
+ class={cn('gap-2 p-4 mt-auto flex flex-col', className)}
+ data-slot="drawer-footer"
{...restProps}
>
{@render children?.()}
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-footer.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-header.svelte
similarity index 60%
rename from tools/ui/src/lib/components/ui/sheet/sheet-footer.svelte
rename to tools/ui/src/lib/components/ui/drawer/drawer-header.svelte
index 0ce246ebb..c78c31de4 100644
--- a/tools/ui/src/lib/components/ui/sheet/sheet-footer.svelte
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-header.svelte
@@ -12,8 +12,11 @@
<div
bind:this={ref}
- class={cn('mt-auto flex flex-col gap-2 p-4', className)}
- data-slot="sheet-footer"
+ class={cn(
+ 'gap-0.5 p-4 md:gap-0.5 md:text-left flex flex-col group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center',
+ className
+ )}
+ data-slot="drawer-header"
{...restProps}
>
{@render children?.()}
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-nested.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-nested.svelte
new file mode 100644
index 000000000..bf86decce
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-nested.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let {
+ activeSnapPoint = $bindable(null),
+ open = $bindable(false),
+ shouldScaleBackground = true,
+ ...restProps
+ }: DrawerPrimitive.RootProps = $props();
+</script>
+
+<DrawerPrimitive.NestedRoot bind:activeSnapPoint bind:open {shouldScaleBackground} {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-overlay.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-overlay.svelte
new file mode 100644
index 000000000..8d464f90a
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-overlay.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let {
+ class: className,
+ ref = $bindable(null),
+ ...restProps
+ }: DrawerPrimitive.OverlayProps = $props();
+</script>
+
+<DrawerPrimitive.Overlay
+ bind:ref
+ class={cn(
+ 'bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
+ className
+ )}
+ data-slot="drawer-overlay"
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-portal.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-portal.svelte
new file mode 100644
index 000000000..5a2b942b7
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-portal.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let { ...restProps }: DrawerPrimitive.PortalProps = $props();
+</script>
+
+<DrawerPrimitive.Portal {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-title.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-title.svelte
new file mode 100644
index 000000000..bfea79132
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-title.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let {
+ class: className,
+ ref = $bindable(null),
+ ...restProps
+ }: DrawerPrimitive.TitleProps = $props();
+</script>
+
+<DrawerPrimitive.Title
+ bind:ref
+ class={cn('text-foreground text-base font-medium', className)}
+ data-slot="drawer-title"
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-trigger.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-trigger.svelte
new file mode 100644
index 000000000..672d356ea
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-trigger.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
+</script>
+
+<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer.svelte b/tools/ui/src/lib/components/ui/drawer/drawer.svelte
new file mode 100644
index 000000000..1ae11bcde
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/drawer.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import { Drawer as DrawerPrimitive } from 'vaul-svelte';
+
+ let {
+ activeSnapPoint = $bindable(null),
+ open = $bindable(false),
+ shouldScaleBackground = true,
+ ...restProps
+ }: DrawerPrimitive.RootProps = $props();
+</script>
+
+<DrawerPrimitive.Root bind:activeSnapPoint bind:open {shouldScaleBackground} {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/drawer/index.ts b/tools/ui/src/lib/components/ui/drawer/index.ts
new file mode 100644
index 000000000..40964961a
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/drawer/index.ts
@@ -0,0 +1,38 @@
+import Root from './drawer.svelte';
+import Close from './drawer-close.svelte';
+import Content from './drawer-content.svelte';
+import Description from './drawer-description.svelte';
+import Footer from './drawer-footer.svelte';
+import Header from './drawer-header.svelte';
+import NestedRoot from './drawer-nested.svelte';
+import Overlay from './drawer-overlay.svelte';
+import Portal from './drawer-portal.svelte';
+import Title from './drawer-title.svelte';
+import Trigger from './drawer-trigger.svelte';
+
+export {
+ Root,
+ NestedRoot,
+ Content,
+ Description,
+ Overlay,
+ Footer,
+ Header,
+ Title,
+ Trigger,
+ Portal,
+ Close,
+
+ //
+ Root as Drawer,
+ NestedRoot as DrawerNestedRoot,
+ Content as DrawerContent,
+ Description as DrawerDescription,
+ Overlay as DrawerOverlay,
+ Footer as DrawerFooter,
+ Header as DrawerHeader,
+ Title as DrawerTitle,
+ Trigger as DrawerTrigger,
+ Portal as DrawerPortal,
+ Close as DrawerClose
+};
diff --git a/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
index 014e85b58..83f54958f 100644
--- a/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
+++ b/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -17,7 +17,7 @@
<DropdownMenuPrimitive.Content
bind:ref
class={cn(
- 'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
+ 'z-50 max-h-[calc(var(--bits-dropdown-menu-content-available-height)-1rem)] min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
className
)}
data-slot="dropdown-menu-content"
diff --git a/tools/ui/src/lib/components/ui/sheet/index.ts b/tools/ui/src/lib/components/ui/sheet/index.ts
deleted file mode 100644
index cfb0178d9..000000000
--- a/tools/ui/src/lib/components/ui/sheet/index.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import Close from './sheet-close.svelte';
-import Content from './sheet-content.svelte';
-import Description from './sheet-description.svelte';
-import Footer from './sheet-footer.svelte';
-import Header from './sheet-header.svelte';
-import Overlay from './sheet-overlay.svelte';
-import Title from './sheet-title.svelte';
-import Trigger from './sheet-trigger.svelte';
-import { Dialog as SheetPrimitive } from 'bits-ui';
-
-const Root = SheetPrimitive.Root;
-const Portal = SheetPrimitive.Portal;
-
-export {
- Root,
- Close,
- Trigger,
- Portal,
- Overlay,
- Content,
- Header,
- Footer,
- Title,
- Description,
- //
- Root as Sheet,
- Close as SheetClose,
- Trigger as SheetTrigger,
- Portal as SheetPortal,
- Overlay as SheetOverlay,
- Content as SheetContent,
- Header as SheetHeader,
- Footer as SheetFooter,
- Title as SheetTitle,
- Description as SheetDescription
-};
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-close.svelte b/tools/ui/src/lib/components/ui/sheet/sheet-close.svelte
deleted file mode 100644
index b0180c0c1..000000000
--- a/tools/ui/src/lib/components/ui/sheet/sheet-close.svelte
+++ /dev/null
@@ -1,7 +0,0 @@
-<script lang="ts">
- import { Dialog as SheetPrimitive } from 'bits-ui';
-
- let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
-</script>
-
-<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-content.svelte b/tools/ui/src/lib/components/ui/sheet/sheet-content.svelte
deleted file mode 100644
index ddb2ddd07..000000000
--- a/tools/ui/src/lib/components/ui/sheet/sheet-content.svelte
+++ /dev/null
@@ -1,64 +0,0 @@
-<script lang="ts" module>
- import { tv, type VariantProps } from 'tailwind-variants';
- export const sheetVariants = tv({
- base: `border-border/30 dark:border-border/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fill-mode-forwards fixed z-50 flex flex-col gap-4 shadow-sm transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 ${PANEL_CLASSES}`,
- defaultVariants: {
- side: 'right'
- },
- variants: {
- side: {
- bottom:
- 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
- left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
- right:
- 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
- top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b'
- }
- }
- });
-
- export type Side = VariantProps<typeof sheetVariants>['side'];
-</script>
-
-<script lang="ts">
- import SheetOverlay from './sheet-overlay.svelte';
- import XIcon from '@lucide/svelte/icons/x';
- import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
- import { PANEL_CLASSES } from '$lib/constants';
- import { Dialog as SheetPrimitive } from 'bits-ui';
- import type { Snippet } from 'svelte';
-
- let {
- children,
- class: className,
- portalProps,
- ref = $bindable(null),
- side = 'right',
- ...restProps
- }: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
- portalProps?: SheetPrimitive.PortalProps;
- side?: Side;
- children: Snippet;
- } = $props();
-</script>
-
-<SheetPrimitive.Portal {...portalProps}>
- <SheetOverlay />
-
- <SheetPrimitive.Content
- bind:ref
- class={cn(sheetVariants({ side }), className)}
- data-slot="sheet-content"
- {...restProps}
- >
- {@render children?.()}
-
- <SheetPrimitive.Close
- class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
- >
- <XIcon class="size-4" />
-
- <span class="sr-only">Close</span>
- </SheetPrimitive.Close>
- </SheetPrimitive.Content>
-</SheetPrimitive.Portal>
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-description.svelte b/tools/ui/src/lib/components/ui/sheet/sheet-description.svelte
deleted file mode 100644
index 44e188c87..000000000
--- a/tools/ui/src/lib/components/ui/sheet/sheet-description.svelte
+++ /dev/null
@@ -1,17 +0,0 @@
-<script lang="ts">
- import { cn } from '$lib/components/ui/utils.js';
- import { Dialog as SheetPrimitive } from 'bits-ui';
-
- let {
- class: className,
- ref = $bindable(null),
- ...restProps
- }: SheetPrimitive.DescriptionProps = $props();
-</script>
-
-<SheetPrimitive.Description
- bind:ref
- class={cn('text-sm text-muted-foreground', className)}
- data-slot="sheet-description"
- {...restProps}
-/>
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-overlay.svelte b/tools/ui/src/lib/components/ui/sheet/sheet-overlay.svelte
deleted file mode 100644
index fde98e97c..000000000
--- a/tools/ui/src/lib/components/ui/sheet/sheet-overlay.svelte
+++ /dev/null
@@ -1,20 +0,0 @@
-<script lang="ts">
- import { cn } from '$lib/components/ui/utils.js';
- import { Dialog as SheetPrimitive } from 'bits-ui';
-
- let {
- class: className,
- ref = $bindable(null),
- ...restProps
- }: SheetPrimitive.OverlayProps = $props();
-</script>
-
-<SheetPrimitive.Overlay
- bind:ref
- class={cn(
- 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=open]:animate-in data-[state=open]:fade-in-0',
- className
- )}
- data-slot="sheet-overlay"
- {...restProps}
-/>
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-title.svelte b/tools/ui/src/lib/components/ui/sheet/sheet-title.svelte
deleted file mode 100644
index a2ac4041c..000000000
--- a/tools/ui/src/lib/components/ui/sheet/sheet-title.svelte
+++ /dev/null
@@ -1,17 +0,0 @@
-<script lang="ts">
- import { cn } from '$lib/components/ui/utils.js';
- import { Dialog as SheetPrimitive } from 'bits-ui';
-
- let {
- class: className,
- ref = $bindable(null),
- ...restProps
- }: SheetPrimitive.TitleProps = $props();
-</script>
-
-<SheetPrimitive.Title
- bind:ref
- class={cn('font-semibold text-foreground', className)}
- data-slot="sheet-title"
- {...restProps}
-/>
diff --git a/tools/ui/src/lib/components/ui/sheet/sheet-trigger.svelte b/tools/ui/src/lib/components/ui/sheet/sheet-trigger.svelte
deleted file mode 100644
index d95719a60..000000000
--- a/tools/ui/src/lib/components/ui/sheet/sheet-trigger.svelte
+++ /dev/null
@@ -1,7 +0,0 @@
-<script lang="ts">
- import { Dialog as SheetPrimitive } from 'bits-ui';
-
- let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
-</script>
-
-<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/toggle-group/index.ts b/tools/ui/src/lib/components/ui/toggle-group/index.ts
new file mode 100644
index 000000000..73160fc4c
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/toggle-group/index.ts
@@ -0,0 +1,10 @@
+import Root from './toggle-group.svelte';
+import Item from './toggle-group-item.svelte';
+
+export {
+ Root,
+ Item,
+ //
+ Root as ToggleGroup,
+ Item as ToggleGroupItem
+};
diff --git a/tools/ui/src/lib/components/ui/toggle-group/toggle-group-item.svelte b/tools/ui/src/lib/components/ui/toggle-group/toggle-group-item.svelte
new file mode 100644
index 000000000..e9474f61d
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/toggle-group/toggle-group-item.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ import { getToggleGroupCtx } from './toggle-group.svelte';
+ import { type ToggleVariants, toggleVariants } from '$lib/components/ui/toggle/index.js';
+ import { cn } from '$lib/components/ui/utils.js';
+ import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
+
+ let {
+ class: className,
+ ref = $bindable(null),
+ size,
+ value = $bindable(),
+ variant,
+ ...restProps
+ }: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
+
+ const ctx = getToggleGroupCtx();
+</script>
+
+<ToggleGroupPrimitive.Item
+ bind:ref
+ class={cn(
+ 'group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-md group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t',
+ toggleVariants({
+ size: ctx.size || size,
+ variant: ctx.variant || variant
+ }),
+ className
+ )}
+ data-size={ctx.size || size}
+ data-slot="toggle-group-item"
+ data-spacing={ctx.spacing}
+ data-variant={ctx.variant || variant}
+ {value}
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/toggle-group/toggle-group.svelte b/tools/ui/src/lib/components/ui/toggle-group/toggle-group.svelte
new file mode 100644
index 000000000..b89537619
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/toggle-group/toggle-group.svelte
@@ -0,0 +1,75 @@
+<script lang="ts" module>
+ import { toggleVariants } from '$lib/components/ui/toggle/index.js';
+ import { getContext, setContext } from 'svelte';
+ import type { VariantProps } from 'tailwind-variants';
+
+ type ToggleVariants = VariantProps<typeof toggleVariants>;
+
+ interface ToggleGroupContext extends ToggleVariants {
+ spacing?: number;
+ orientation?: 'horizontal' | 'vertical';
+ }
+
+ export function setToggleGroupCtx(props: ToggleGroupContext) {
+ setContext('toggleGroup', props);
+ }
+
+ export function getToggleGroupCtx() {
+ return getContext<Required<ToggleGroupContext>>('toggleGroup');
+ }
+</script>
+
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
+
+ let {
+ class: className,
+ orientation = 'horizontal',
+ ref = $bindable(null),
+ size = 'default',
+ spacing = 0,
+ value = $bindable(),
+ variant = 'default',
+ ...restProps
+ }: ToggleGroupPrimitive.RootProps &
+ ToggleVariants & {
+ spacing?: number;
+ orientation?: 'horizontal' | 'vertical';
+ } = $props();
+
+ setToggleGroupCtx({
+ get orientation() {
+ return orientation;
+ },
+ get size() {
+ return size;
+ },
+ get spacing() {
+ return spacing;
+ },
+ get variant() {
+ return variant;
+ }
+ });
+</script>
+
+<!--
+Discriminated Unions + Destructing (required for bindable) do not
+get along, so we shut typescript up by casting `value` to `never`.
+-->
+<ToggleGroupPrimitive.Root
+ bind:ref
+ bind:value={value as never}
+ class={cn(
+ 'rounded-md group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch',
+ className
+ )}
+ data-size={size}
+ data-slot="toggle-group"
+ data-spacing={spacing}
+ data-variant={variant}
+ {orientation}
+ style={`--gap: ${spacing}`}
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/components/ui/toggle/index.ts b/tools/ui/src/lib/components/ui/toggle/index.ts
new file mode 100644
index 000000000..0f18d3be8
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/toggle/index.ts
@@ -0,0 +1,13 @@
+import Root from './toggle.svelte';
+export {
+ toggleVariants,
+ type ToggleSize,
+ type ToggleVariant,
+ type ToggleVariants
+} from './toggle.svelte';
+
+export {
+ Root,
+ //
+ Root as Toggle
+};
diff --git a/tools/ui/src/lib/components/ui/toggle/toggle.svelte b/tools/ui/src/lib/components/ui/toggle/toggle.svelte
new file mode 100644
index 000000000..0d74d04d4
--- /dev/null
+++ b/tools/ui/src/lib/components/ui/toggle/toggle.svelte
@@ -0,0 +1,54 @@
+<script lang="ts" module>
+ import { tv, type VariantProps } from 'tailwind-variants';
+
+ export const toggleVariants = tv({
+ base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive gap-1 rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] inline-flex items-center justify-center whitespace-nowrap disabled:pointer-events-none disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 group/toggle",
+ defaultVariants: {
+ size: 'default',
+ variant: 'default'
+ },
+ variants: {
+ size: {
+ default:
+ 'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ sm: "h-7 min-w-7 rounded-md px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5"
+ },
+ variant: {
+ default:
+ 'bg-transparent hover:bg-muted-foreground/10 hover:text-accent-foreground backdrop-blur-sm data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
+ outline:
+ 'border bg-background shadow-sm hover:bg-muted-foreground/10 hover:text-accent-foreground backdrop-blur-sm data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:border-input'
+ }
+ }
+ });
+
+ export type ToggleVariant = VariantProps<typeof toggleVariants>['variant'];
+ export type ToggleSize = VariantProps<typeof toggleVariants>['size'];
+ export type ToggleVariants = VariantProps<typeof toggleVariants>;
+</script>
+
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { Toggle as TogglePrimitive } from 'bits-ui';
+
+ let {
+ class: className,
+ pressed = $bindable(false),
+ ref = $bindable(null),
+ size = 'default',
+ variant = 'default',
+ ...restProps
+ }: TogglePrimitive.RootProps & {
+ variant?: ToggleVariant;
+ size?: ToggleSize;
+ } = $props();
+</script>
+
+<TogglePrimitive.Root
+ bind:pressed
+ bind:ref
+ class={cn(toggleVariants({ size, variant }), className)}
+ data-slot="toggle"
+ {...restProps}
+/>
diff --git a/tools/ui/src/lib/constants/attachment-menu.constants.ts b/tools/ui/src/lib/constants/attachment-menu.constants.ts
index 143450a75..dc9db7478 100644
--- a/tools/ui/src/lib/constants/attachment-menu.constants.ts
+++ b/tools/ui/src/lib/constants/attachment-menu.constants.ts
@@ -53,3 +53,20 @@ export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
];
export const ATTACHMENT_TOOLTIP_TEXT = 'Add files, prompts, tools or MCP Servers';
+
+/** Text shared by the add menu's two surfaces: the desktop dropdown and the mobile drawer. */
+export const ATTACHMENT_MENU_TEXT = {
+ ADD_FILES: 'Add files',
+ ADD_TO_CHAT: 'Add to chat',
+ DESCRIPTION: 'Add files, system prompt or pick the tools the model may call',
+ SYSTEM_MESSAGE: 'System Message',
+ TOOLS: 'Tools'
+} as const;
+
+/** Modality labels behind the file-upload row's enabled-when flags, both surfaces. */
+export const ATTACHMENT_MODALITY_TEXT: Record<AttachmentItemEnabledWhen, string> = {
+ [AttachmentItemEnabledWhen.ALWAYS]: '',
+ [AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: 'Audio',
+ [AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: 'Video',
+ [AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: 'Vision'
+};
diff --git a/tools/ui/src/lib/constants/backend.constants.ts b/tools/ui/src/lib/constants/backend.constants.ts
new file mode 100644
index 000000000..57f5c4213
--- /dev/null
+++ b/tools/ui/src/lib/constants/backend.constants.ts
@@ -0,0 +1,2 @@
+/** Marks the built-in local llama.cpp backend. */
+export const LOCAL_BACKEND_ID = 'local';
diff --git a/tools/ui/src/lib/constants/css-classes.constants.ts b/tools/ui/src/lib/constants/css-classes.constants.ts
index 07dd77ff5..045dce6ac 100644
--- a/tools/ui/src/lib/constants/css-classes.constants.ts
+++ b/tools/ui/src/lib/constants/css-classes.constants.ts
@@ -19,10 +19,64 @@ export const PANEL_CLASSES = `
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
export const DIALOG_SUBMENU_CONTENT = 'w-60';
+/** Close control of a dialog, a panel or the navigation rail on a phone: one tap target,
+ * one cross, one look. Each site adds the offsets that keep the cross on the 16px inset
+ * the titles sit on. */
+export const PANEL_CLOSE_MOBILE_CLASS =
+ 'max-md:grid max-md:size-10 max-md:place-items-center max-md:rounded-full max-md:text-muted-foreground max-md:opacity-100 max-md:hover:bg-accent max-md:hover:text-accent-foreground';
+
/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
+/** Search input of an open dropdown-menu, focused after the menu mounts. */
+export const DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR = '[data-slot="dropdown-menu-content"] input';
+
+/**
+ * Sticky offset of a model-list group heading under the dropdown's search block:
+ * the search header exposes its height as `--dropdown-sticky-height`, and one
+ * section-header row (`py-2` over `text-[13px]` line height) minus the header's
+ * bottom border stacks the two sticky rows without a gap or an overlap.
+ */
+export const SELECTOR_GROUP_STICKY_OFFSET =
+ 'top: calc(var(--dropdown-sticky-height, 0px) + 2.25rem - 1px)';
+
+/** Filter controls above the model table: one box, one height, one fill. */
+export const FILTER_TRIGGER_CLASS = `
+ h-8
+ gap-1.5
+ rounded-md
+ px-3
+ text-sm
+ font-medium
+ transition-colors
+ hover:bg-muted/80 dark:hover:bg-muted
+ ${INPUT_CLASSES}
+`;
+
+/** One filter toggle of the models manager toolbar. */
+export const FILTER_TOGGLE_ITEM_CLASS =
+ 'bg-muted! border-border/30! shadow-none! dark:border-border/20! data-[state=on]:bg-muted-foreground/15! data-[state=on]:text-foreground! dark:data-[state=on]:bg-muted-foreground/25!';
+
+/** Column grid shared by every row of the models manager table. Below md the context,
+ * status and actions columns are dropped, so a phone gives the whole width to the
+ * model id and floats the row's last cell at the row's end. */
+export const MODEL_ROW_GRID_CLASS =
+ 'grid grid-cols-[minmax(0,1fr)] items-center gap-3 md:grid-cols-[minmax(0,1fr)_11rem_3rem_4.5rem] md:gap-4';
+
+/** Last cell of a row: its actions menu, or its fold control. A phone floats it at the
+ * row's end, inside the clearance the first cell leaves in front of it. */
+export const MODEL_ROW_TRAILING_CELL_CLASS =
+ 'max-md:absolute max-md:top-1/2 max-md:right-3 max-md:-translate-y-1/2';
+
+/** Neutral model badge: params, quantization, tags. */
+export const MODEL_BADGE_CLASS =
+ 'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
+
+/** Emphasis model badge: draft sidecars and other markers that must stand out. */
+export const MODEL_VARIANT_BADGE_CLASS =
+ 'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
+
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
diff --git a/tools/ui/src/lib/constants/icons.constants.ts b/tools/ui/src/lib/constants/icons.constants.ts
index ce32f0e1e..54a143330 100644
--- a/tools/ui/src/lib/constants/icons.constants.ts
+++ b/tools/ui/src/lib/constants/icons.constants.ts
@@ -1,19 +1,34 @@
/**
- * Icon mappings for file types and model modalities
+ * Icon and modality mappings for file types and models
* Centralized configuration to ensure consistent icon usage across the app
*/
import {
+ CircleAlert as LoadFailedIcon,
+ Eject as UnloadIcon,
Eye as VisionIcon,
File as FileIcon,
FileText as FileTextIcon,
+ FolderOpen as CwdCommandIcon,
Image as ImageIcon,
Lightbulb as ReasoningIcon,
+ Loader2 as LoadingIcon,
Mic as AudioIcon,
+ Package as ModelIcon,
+ Pause as PauseDownloadIcon,
+ Play as ResumeDownloadIcon,
+ Power as LoadIcon,
+ RotateCw as RetryIcon,
+ Sparkles as PromptCommandIcon,
Video as VideoIcon,
Wrench as ToolUseIcon
} from '@lucide/svelte';
-import { FileTypeCategory, ModelCapability, ModelModality } from '$lib/enums';
+import {
+ ChatFormCommandAction,
+ FileTypeCategory,
+ ModelCapability,
+ ModelModality
+} from '$lib/enums';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
import type { Component } from 'svelte';
@@ -27,6 +42,9 @@ export const FILE_TYPE_ICONS = {
export const DEFAULT_FILE_ICON = FileIcon;
+/** The mark for a model, wherever one is listed. */
+export const MODEL_ICON = ModelIcon;
+
export const MODALITY_ICONS = {
[ModelModality.AUDIO]: AudioIcon,
[ModelModality.VIDEO]: VideoIcon,
@@ -49,6 +67,21 @@ export const MODALITY_FLAG_KEYS: Record<
[ModelModality.VISION]: 'vision'
};
+/** Modalities in the order the filters and the model cards list them. */
+export const MODALITY_ORDER: Exclude<ModelModality, ModelModality.TEXT>[] = [
+ ModelModality.VISION,
+ ModelModality.VIDEO,
+ ModelModality.AUDIO
+];
+
+/** Boolean flag a modality drives on the ModelModalities type. */
+export type ModalityKey = (typeof MODALITY_FLAG_KEYS)[keyof typeof MODALITY_FLAG_KEYS];
+
+/** Modality flags a model can carry, in display order. */
+export const MODALITY_KEYS: ModalityKey[] = MODALITY_ORDER.map(
+ (modality) => MODALITY_FLAG_KEYS[modality]
+);
+
export const CAPABILITY_ICONS: Record<ModelCapability, Component> = {
[ModelCapability.REASONING]: ReasoningIcon,
[ModelCapability.TOOL_USE]: ToolUseIcon
@@ -65,6 +98,28 @@ export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabiliti
[ModelCapability.TOOL_USE]: 'tools'
};
+/** Icons of the slash-command picker, one per chat-form command. */
+export const CHAT_FORM_COMMAND_ICONS: Record<ChatFormCommandAction, Component> = {
+ [ChatFormCommandAction.CWD]: CwdCommandIcon,
+ [ChatFormCommandAction.MODEL]: MODEL_ICON,
+ [ChatFormCommandAction.PROMPT]: PromptCommandIcon
+};
+
+/** Icons of the download control a manager row shows while it downloads. */
+export const MODEL_DOWNLOAD_ICONS = {
+ pause: PauseDownloadIcon,
+ resume: ResumeDownloadIcon
+} as const;
+
+/** Icons the model load control shows for its states and actions. */
+export const MODEL_LOAD_ICONS = {
+ failed: LoadFailedIcon,
+ load: LoadIcon,
+ loading: LoadingIcon,
+ retry: RetryIcon,
+ unload: UnloadIcon
+} as const;
+
// Shared SVG icon strings for copy and preview buttons
export const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
index 8d99180ab..6f795634b 100644
--- a/tools/ui/src/lib/constants/index.ts
+++ b/tools/ui/src/lib/constants/index.ts
@@ -4,6 +4,7 @@
export * from './agentic.constants';
export * from './api-endpoints.constants';
export * from './app.constants';
+export * from './backend.constants';
export * from './chat-tabs.constants';
export * from './database.constants';
export * from './reasoning-effort.constants';
@@ -45,6 +46,7 @@ export * from './message-export.constants';
export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
+export * from './models-manager.constants';
export * from './models-discover.constants';
export * from './model-compatibility.constants';
export * from './huggingface.constants';
diff --git a/tools/ui/src/lib/constants/model-loading.constants.ts b/tools/ui/src/lib/constants/model-loading.constants.ts
index 0d0ca3263..13f69a742 100644
--- a/tools/ui/src/lib/constants/model-loading.constants.ts
+++ b/tools/ui/src/lib/constants/model-loading.constants.ts
@@ -12,3 +12,25 @@ export const MODEL_LOAD_STAGE_LABELS: Record<ApiModelLoadStage, string> = {
* text_model fills the rest, so a plain model reaches 100% on its own.
*/
export const MODEL_LOAD_TAIL_SHARE = 0.1;
+
+/** Load parameters a model falls back to when the server reports nothing. */
+export const LOAD_DEFAULTS = {
+ batchSize: 2048,
+ contextLength: 8192,
+ cpuThreads: 13,
+ gpuOffload: 42,
+ speculativeDecoding: 'off',
+ ubatchSize: 512
+};
+
+/** Sampling parameters a model falls back to. */
+export const SAMPLING_DEFAULTS = {
+ minP: 0.05,
+ repeatPenalty: 1.1,
+ temperature: 1,
+ topK: 64,
+ topP: 0.95
+};
+
+/** Speculative decoding modes the load form offers. */
+export const SPECULATIVE_OPTIONS = ['off', 'draft-model'];
diff --git a/tools/ui/src/lib/constants/models-manager.constants.ts b/tools/ui/src/lib/constants/models-manager.constants.ts
new file mode 100644
index 000000000..bbcf912dd
--- /dev/null
+++ b/tools/ui/src/lib/constants/models-manager.constants.ts
@@ -0,0 +1,18 @@
+/** Labels of the models manager table sections. */
+
+import { ModelsTableGroupKind } from '$lib/enums';
+
+export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
+ [ModelsTableGroupKind.DOWNLOADING]: 'Downloading',
+ [ModelsTableGroupKind.FAVORITES]: 'Favorites',
+ [ModelsTableGroupKind.HIDDEN]: 'Hidden models',
+ [ModelsTableGroupKind.LOADED]: 'Loaded models',
+ [ModelsTableGroupKind.LOCAL]: 'Local models'
+};
+
+/**
+ * Sticky offset of a group heading under its section header: one section-header
+ * row (`py-2` over `text-[13px]` line height) minus the header's bottom border,
+ * so the two stacked sticky rows do not show a gap or overlap by a pixel.
+ */
+export const MODELS_TABLE_GROUP_STICKY_OFFSET = 'top: calc(2.25rem - 1px)';
diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts
index 2c923cee3..0cbda109d 100644
--- a/tools/ui/src/lib/constants/settings-keys.constants.ts
+++ b/tools/ui/src/lib/constants/settings-keys.constants.ts
@@ -29,6 +29,7 @@ export const SETTINGS_KEYS = {
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
FREQUENCY_PENALTY: 'frequency_penalty',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
+ GROUP_MODELS_BY_FAMILY: 'groupModelsByFamily',
JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
MAX_IMAGE_RESOLUTION: 'maxImageMPixels',
MAX_TOKENS: 'max_tokens',
@@ -37,6 +38,7 @@ export const SETTINGS_KEYS = {
MCP_SERVERS: 'mcpServers',
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
MIN_P: 'min_p',
+ // Display
PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen',
PDF_AS_IMAGE: 'pdfAsImage',
// Performance
@@ -54,7 +56,8 @@ export const SETTINGS_KEYS = {
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
- SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
+ SHOW_MODEL_CAPABILITIES_IN_SELECTOR: 'showModelCapabilitiesInSelector',
+ SHOW_MODEL_ORG_NAME: 'showModelOrgName',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
@@ -73,6 +76,8 @@ export const SETTINGS_KEYS = {
TOP_K: 'top_k',
TOP_P: 'top_p',
TYP_P: 'typ_p',
+ // General
+ USE_HUGGING_FACE_HUB: 'useHuggingFaceHub',
XTC_PROBABILITY: 'xtc_probability',
XTC_THRESHOLD: 'xtc_threshold'
} as const;
diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts
index 7140bbcc4..6642a4958 100644
--- a/tools/ui/src/lib/constants/settings.constants.ts
+++ b/tools/ui/src/lib/constants/settings.constants.ts
@@ -125,6 +125,15 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Enable "Continue" button',
type: SettingsFieldType.CHECKBOX
},
+ {
+ // Deliberately off for now: the natural place to turn it on is the first
+ // run experience, once onboarding exists to ask the user about it.
+ defaultValue: false,
+ help: 'Fetch model metadata (avatars, context length, chat template, file sizes) from the Hugging Face Hub. When off, the UI only shows what the server reports for /v1/models and hides the org avatars.',
+ key: SETTINGS_KEYS.USE_HUGGING_FACE_HUB,
+ label: 'Use Hugging Face Hub API for models metadata',
+ type: SettingsFieldType.CHECKBOX
+ },
{
defaultValue: true,
help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
@@ -284,9 +293,16 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
},
{
defaultValue: false,
- help: 'Display the organization name in the model selector trigger button.',
- key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
- label: 'Show organization name in model selector trigger',
+ help: 'Show the modality and capability icons (vision, audio, reasoning, tools) on the models selector rows.',
+ key: SETTINGS_KEYS.SHOW_MODEL_CAPABILITIES_IN_SELECTOR,
+ label: 'Show model modalities & capabilities in models selector',
+ type: SettingsFieldType.CHECKBOX
+ },
+ {
+ defaultValue: true,
+ help: 'Display the organization name alongside model names in the model selector, in the trigger and in the model rows.',
+ key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME,
+ label: 'Show organization name in model selector',
type: SettingsFieldType.CHECKBOX
},
{
@@ -302,6 +318,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
label: 'Show full path in mentions',
type: SettingsFieldType.CHECKBOX
+ },
+ {
+ defaultValue: true,
+ help: 'Group models into families (e.g. "Qwen") in the model picker and the models manager. Turn it off to list every model on its own.',
+ key: SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY,
+ label: 'Group models by family',
+ type: SettingsFieldType.CHECKBOX
}
],
slug: SETTINGS_SECTION_SLUGS.DISPLAY,
diff --git a/tools/ui/src/lib/constants/storage.constants.ts b/tools/ui/src/lib/constants/storage.constants.ts
index 5e6610d85..c81b30613 100644
--- a/tools/ui/src/lib/constants/storage.constants.ts
+++ b/tools/ui/src/lib/constants/storage.constants.ts
@@ -27,6 +27,15 @@ export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabled
/** Default disabled tool categories, seeded into newly created conversations */
export const DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolCategories`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
+
+/** Open state the user set for a model list section or one of its families, by id. */
+export const MODEL_GROUP_OPEN_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.modelGroupOpen`;
+
+/** Recently used model ids, most recent first, backend-qualified. */
+export const RECENT_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.recentModels`;
+
+/** Model ids kept out of the selector, backend-qualified. */
+export const HIDDEN_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.hiddenModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index 20696f987..4b43cceee 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -1,4 +1,6 @@
-import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
+import { MODEL_ICON } from './icons.constants';
+import { Search, Settings, SquarePen } from '@lucide/svelte';
+import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import { SidebarAction, ToolSource } from '$lib/enums';
import type { DesktopIconStripItem } from '$lib/types';
@@ -44,11 +46,20 @@ export const STATS_UNITS = {
export const DEFAULT_MOBILE_BREAKPOINT = 768;
+/** Models listed per remote provider before the "+ X more" line; search covers the rest. */
+export const REMOTE_PROVIDER_MODEL_LIMIT = 12;
+
/** Orgs whose avatar is dark and needs inverting in dark mode. */
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
-/** Icon used for the model selector and the `/model` slash command. */
-export const MODEL_SELECTOR_ICON = Package;
+/** Model rows mounted before the list scrolls; a local catalog can hold hundreds. */
+export const MODEL_ROW_WINDOW = 50;
+
+/** Models of one family shown before the rest fold behind a "show more" row. */
+export const FAMILY_ROW_WINDOW = 6;
+
+/** Recently used models kept per browser, most recent first. */
+export const RECENT_MODEL_LIMIT = 20;
export const ICON_STRIP_TRANSITION_DURATION = 150;
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
@@ -64,6 +75,16 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
tooltip: 'New chat'
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
+ {
+ action: SidebarAction.MANAGE_MODELS,
+ icon: MODEL_ICON,
+ tooltip: 'Models'
+ },
+ {
+ action: SidebarAction.MCP,
+ icon: McpLogo,
+ tooltip: 'MCP'
+ },
{
action: SidebarAction.SETTINGS,
icon: Settings,
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 583187d96..6843b0a19 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -71,7 +71,14 @@ export {
export { ModelAuxSidecar, ModelCapability, ModelDraftSidecar, ModelModality } from './model.enums';
-export { ModelDownloadConfirmAction, ModelDownloadStopRequest } from './model.enums';
+export {
+ ModelDownloadConfirmAction,
+ ModelDownloadStopRequest,
+ ModelGroupKind,
+ ModelRowDownloadState,
+ ModelsTableGroupKind,
+ ModelsTableSortKey
+} from './model.enums';
export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts
index f2a0cc355..8e912129f 100644
--- a/tools/ui/src/lib/enums/model.enums.ts
+++ b/tools/ui/src/lib/enums/model.enums.ts
@@ -55,3 +55,31 @@ export enum ModelDownloadConfirmAction {
CANCEL = 'cancel',
DELETE = 'delete'
}
+
+/** What a table row group folds: the quants of one repo, or its variants. */
+export enum ModelGroupKind {
+ QUANTS = 'quants',
+ VARIANTS = 'variants'
+}
+
+/** Kind of one collapsible block of the models manager table. */
+export enum ModelsTableGroupKind {
+ DOWNLOADING = 'downloading',
+ FAVORITES = 'favorites',
+ HIDDEN = 'hidden',
+ LOADED = 'loaded',
+ LOCAL = 'local'
+}
+
+/** Download state a manager row reports in place of its load state. */
+export enum ModelRowDownloadState {
+ DOWNLOADING = 'downloading',
+ PAUSED = 'paused'
+}
+
+/** Column the models manager table can be ordered by. */
+export enum ModelsTableSortKey {
+ CONTEXT = 'context',
+ NAME = 'name',
+ STATUS = 'status'
+}
diff --git a/tools/ui/src/lib/enums/ui.enums.ts b/tools/ui/src/lib/enums/ui.enums.ts
index 4eda1d691..20cfe47ff 100644
--- a/tools/ui/src/lib/enums/ui.enums.ts
+++ b/tools/ui/src/lib/enums/ui.enums.ts
@@ -23,6 +23,8 @@ export enum ScrollCarouselVariant {
* Sidebar icon strip actions handled directly by the sidebar.
*/
export enum SidebarAction {
+ MANAGE_MODELS = 'manage-models',
+ MCP = 'mcp',
NEW_CHAT = 'new-chat',
SETTINGS = 'settings'
}
diff --git a/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts b/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts
index c738c266a..d3f3ec1d2 100644
--- a/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts
@@ -23,7 +23,7 @@ export interface UseAttachmentMenuReturn {
*
* Encapsulates the modality-flag checks and callback wrapping that is
* identical across the desktop dropdown (`ChatFormActionAddDropdown`)
- * and the mobile sheet (`ChatFormActionAddSheet`).
+ * and the mobile drawer (`ChatFormActionAddDrawer`).
*
* @param getFlags - Getter returning the current modality capability flags.
* @param getCallbacks - Getter returning the raw action callbacks from props.
diff --git a/tools/ui/src/lib/hooks/use-chat-form-model.svelte.ts b/tools/ui/src/lib/hooks/use-chat-form-model.svelte.ts
new file mode 100644
index 000000000..a32acba6a
--- /dev/null
+++ b/tools/ui/src/lib/hooks/use-chat-form-model.svelte.ts
@@ -0,0 +1,121 @@
+/**
+ * Model state for the chat form's action bar.
+ *
+ * Syncs the conversation's model into the global selection (a conversation
+ * opened from history names the model that produced it), falls back to the
+ * first loaded model on a router, and reactively tracks which modalities
+ * (vision / audio / video) the active model supports - fetching model props
+ * from the server on demand if they are not cached yet.
+ */
+
+import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
+import type { DatabaseMessage } from '$lib/types/database';
+import { getConversationModel } from '$lib/utils';
+
+export function useChatFormModel() {
+ const isRouter = $derived(serverStore.isRouterMode);
+ const conversationModel = $derived(
+ getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
+ );
+
+ let lastSyncedConversationModel: string | null = null;
+
+ // the conversation's model wins while it names one: opening a history chat
+ // points the selection at the model that produced it
+ $effect(() => {
+ if (conversationModel && conversationModel !== lastSyncedConversationModel) {
+ if (modelsStore.models.some((m) => m.model === conversationModel)) {
+ modelsStore.selectedModelName = conversationModel;
+ modelsStore.selectModelByName(conversationModel);
+ } else {
+ modelsStore.selectedModelName = null;
+ modelsStore.clearSelection();
+ }
+
+ lastSyncedConversationModel = conversationModel;
+ } else if (
+ isRouter &&
+ !modelsStore.selectedModelId &&
+ modelsStore.loadedModelIds.length > 0 &&
+ conversationsStore.activeMessages.length > 0 &&
+ !conversationModel
+ ) {
+ lastSyncedConversationModel = null;
+ const first = modelsStore.models.find((m) => modelsStore.loadedModelIds.includes(m.model));
+
+ if (first) modelsStore.selectModelById(first.id);
+ }
+ });
+
+ const activeModelId = $derived(modelsStore.activeModelId);
+
+ let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
+
+ $effect(() => {
+ if (activeModelId) {
+ const cached = modelsStore.props.getModelProps(activeModelId);
+
+ if (!cached) {
+ modelsStore.props.fetchModelProps(activeModelId).then(() => {
+ modelPropsVersion++;
+ });
+ }
+ }
+ });
+
+ const hasAudioModality = $derived.by(() => {
+ void modelPropsVersion;
+
+ return activeModelId ? modelsStore.props.modelSupportsAudio(activeModelId) : false;
+ });
+ const hasVideoModality = $derived.by(() => {
+ void modelPropsVersion;
+
+ return activeModelId ? modelsStore.props.modelSupportsVideo(activeModelId) : false;
+ });
+ const hasVisionModality = $derived.by(() => {
+ void modelPropsVersion;
+
+ return activeModelId ? modelsStore.props.modelSupportsVision(activeModelId) : false;
+ });
+ const hasModelSelected = $derived(
+ !isRouter || !!conversationModel || !!modelsStore.selectedModelId
+ );
+ const isSelectedModelInCache = $derived.by(() => {
+ if (!isRouter) return true;
+
+ if (conversationModel) {
+ return modelsStore.models.some((option) => option.model === conversationModel);
+ }
+
+ const currentModelId = modelsStore.selectedModelId;
+
+ if (!currentModelId) return false;
+
+ return modelsStore.models.some((option) => option.id === currentModelId);
+ });
+
+ return {
+ get conversationModel() {
+ return conversationModel;
+ },
+ get hasAudioModality() {
+ return hasAudioModality;
+ },
+ get hasModelSelected() {
+ return hasModelSelected;
+ },
+ get hasVideoModality() {
+ return hasVideoModality;
+ },
+ get hasVisionModality() {
+ return hasVisionModality;
+ },
+ get isRouter() {
+ return isRouter;
+ },
+ get isSelectedModelInCache() {
+ return isSelectedModelInCache;
+ }
+ };
+}
diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts
index 7d2770a26..35a85dcd8 100644
--- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts
@@ -1,8 +1,15 @@
-import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
-import { modelsStore, serverStore } from '$lib/stores';
+import { deviceStore, modelsStore, serverStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
+import {
+ filterModelOptions,
+ groupFavoriteOptions,
+ groupModelOptions,
+ type ModelItem
+} from '$lib/utils';
+import { nameOf } from '$lib/utils/model-names';
import { onMount } from 'svelte';
+import { SvelteSet } from 'svelte/reactivity';
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
@@ -18,19 +25,20 @@ export interface UseModelsSelectorReturn {
readonly loading: boolean;
readonly updating: boolean;
readonly activeId: string | null;
+ readonly emptyMessage: string;
+ readonly isMultiModel: boolean;
readonly isRouter: boolean;
readonly serverModel: string | null;
readonly isHighlightedCurrentModelActive: boolean;
readonly isCurrentModelInCache: boolean;
+ readonly favoriteItems: ModelItem[];
+ readonly loadedItems: ModelItem[];
readonly filteredOptions: ModelOption[];
+ readonly isEmpty: boolean;
readonly groupedFilteredOptions: ReturnType<typeof groupModelOptions>;
readonly isLoadingModel: boolean;
readonly searchTerm: string;
- readonly showModelDialog: boolean;
- readonly infoModelId: string | null;
setSearchTerm(value: string): void;
- setShowModelDialog(value: boolean): void;
- handleInfoClick(modelName: string): void;
handleSelect(modelId: string): Promise<void>;
handleOpenChange(open: boolean): void;
isFavorite(model: string): boolean;
@@ -40,11 +48,14 @@ export interface UseModelsSelectorReturn {
/**
* Shared reactive state and logic for model selection.
*
- * Used by both the desktop dropdown (`ModelsSelectorDropdown`)
- * and the mobile sheet (`ModelsSelectorSheet`) to avoid
- * duplicating store derivations, selection handling, and model loading.
+ * Used by the model selector dropdown, which serves the desktop an anchored menu
+ * and the phone a bottom drawer, to avoid duplicating store derivations,
+ * selection handling, and model loading.
*/
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
+ let isLoadingModel = $state(false);
+ let searchTerm = $state('');
+
const options = $derived(
modelsStore.models.filter((option) => {
const modelProps = modelsStore.props.getModelProps(option.model);
@@ -55,6 +66,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const loading = $derived(modelsStore.loading);
const updating = $derived(modelsStore.updating);
const activeId = $derived(modelsStore.selectedModelId);
+ // a lone llama.cpp server without a router has nothing to choose from
const isRouter = $derived(serverStore.isRouterMode);
const serverModel = $derived(modelsStore.singleModelName);
const currentModel = $derived(opts.currentModel());
@@ -71,23 +83,43 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return options.some((option) => option.model === currentModel);
});
-
- let isLoadingModel = $state(false);
- let searchTerm = $state('');
- let showModelDialog = $state(false);
- let infoModelId = $state<string | null>(null);
-
- const filteredOptions = $derived(filterModelOptions(options, searchTerm));
- const groupedFilteredOptions = $derived(
- groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
- modelsStore.isModelLoaded(m)
+ // the search, the rows and the sections all read the visible set; only the current
+ // model resolves against `options`, since it can be hidden and still selected
+ const visibleOptions = $derived(options.filter((option) => !modelsStore.isHidden(option.id)));
+ // one filter pass feeds the favorites, the loaded rows and the sections alike
+ const filteredOptions = $derived(filterModelOptions(visibleOptions, searchTerm));
+ const loadedItems = $derived(
+ filteredOptions
+ .filter((option) => modelsStore.isModelLoaded(option.model))
+ .map((option) => ({ option }))
+ );
+ const loadedIds = $derived(new SvelteSet(loadedItems.map((item) => item.option.id)));
+ // loaded models lead the list: their own sections list them once, so a
+ // loaded favorite shows there and not twice
+ const favoriteItems = $derived(
+ groupFavoriteOptions(
+ filteredOptions.filter((option) => !loadedIds.has(option.id)),
+ modelsStore.favoriteModelIds
+ )
+ );
+ // loaded models and favorites are listed once, at the top: the sections skip both
+ const sectionOptions = $derived(
+ filteredOptions.filter(
+ (option) => !modelsStore.favoriteModelIds.has(option.model) && !loadedIds.has(option.id)
)
);
+ const groupedFilteredOptions = $derived(groupModelOptions(sectionOptions));
+ const isEmpty = $derived(
+ filteredOptions.length === 0 && favoriteItems.length === 0 && loadedItems.length === 0
+ );
+ const emptyMessage = $derived(searchTerm ? 'No models found.' : 'No models yet.');
- function handleInfoClick(modelName: string) {
- infoModelId = modelName;
- showModelDialog = true;
- }
+ // the manager takes the focus, so the selector closes instead of sitting behind it
+ $effect(() => {
+ if (!uiStore.manageModelsOpen) return;
+
+ opts.onOpenChange?.(false);
+ });
onMount(() => {
modelsStore.fetch().catch((error) => {
@@ -98,19 +130,21 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function handleOpenChange(open: boolean) {
if (loading || updating) return;
- if (isRouter) {
- searchTerm = '';
+ // a single-model desktop server has no list: the trigger opens the manager
+ // instead. a phone has no manager, so its drawer opens with the one model
+ if (!isRouter && !deviceStore.isMobile) {
+ if (open) uiStore.openModelsManager();
- if (open) {
- modelsStore.fetchRouterModels().then(() => {
- modelsStore.props.fetchModalitiesForLoadedModels();
- });
- }
+ return;
+ }
- opts.onOpenChange?.(open);
- } else {
- showModelDialog = open;
+ searchTerm = '';
+
+ if (open && isRouter) {
+ modelsStore.props.fetchModalitiesForLoadedModels();
}
+
+ opts.onOpenChange?.(open);
}
async function handleSelect(modelId: string) {
@@ -127,7 +161,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
shouldCloseMenu = false;
}
} else {
- await modelsStore.selectModelById(option.id);
+ await modelsStore.selectModelById(option.id, { recordRecent: true });
}
if (shouldCloseMenu) {
@@ -140,6 +174,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
});
}
+ // only the built-in server loads on request, and only in router mode
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
@@ -159,7 +194,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
capabilities: [],
id: serverModel ? 'current' : 'offline-current',
model: displayModel,
- name: displayModel.split('/').pop() || displayModel
+ name: nameOf(displayModel)
};
}
@@ -172,7 +207,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
capabilities: [],
id: 'not-in-cache',
model: currentModel,
- name: currentModel.split('/').pop() || currentModel
+ name: nameOf(currentModel)
};
}
@@ -191,6 +226,13 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return activeId;
},
+ get emptyMessage() {
+ return emptyMessage;
+ },
+
+ get favoriteItems() {
+ return favoriteItems;
+ },
get filteredOptions() {
return filteredOptions;
},
@@ -201,20 +243,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return groupedFilteredOptions;
},
- handleInfoClick,
-
handleOpenChange,
handleSelect,
- get infoModelId() {
- return infoModelId;
- },
-
get isCurrentModelInCache() {
return isCurrentModelInCache;
},
+ get isEmpty() {
+ return isEmpty;
+ },
+
isFavorite(model: string) {
return modelsStore.favoriteModelIds.has(model);
},
@@ -227,10 +267,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return isLoadingModel;
},
+ get isMultiModel() {
+ return isRouter;
+ },
+
get isRouter() {
return isRouter;
},
+ get loadedItems() {
+ return loadedItems;
+ },
+
get loading() {
return loading;
},
@@ -251,14 +299,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
searchTerm = value;
},
- setShowModelDialog(value: boolean) {
- showModelDialog = value;
- },
-
- get showModelDialog() {
- return showModelDialog;
- },
-
get updating() {
return updating;
}
diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts
index 3c52d7ab2..ad3a63dbf 100644
--- a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts
@@ -20,9 +20,7 @@ export interface UseReasoningMenuReturn {
/**
* Shared reactive state and helpers for the reasoning effort menu.
*
- * Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`)
- * and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
- * thinking-support derivation and the effort selection logic.
+ * Shared by the dropdown footer and the mobile sheet.
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
const conversationModel = $derived(
diff --git a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
index cb361aad8..21ab693fe 100644
--- a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
@@ -28,7 +28,7 @@ export interface UseToolsPanelReturn {
* Shared reactive state and helpers for the tools panel UI.
*
* Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`)
- * and the mobile sheet (`ChatFormActionAddSheet`) to avoid
+ * and the mobile drawer (`ChatFormActionAddDrawer`) to avoid
* duplicating group filtering, checked-state derivation, and favicon logic.
*
* All toggle state routes through `conversationsStore.preferences`: with an
diff --git a/tools/ui/src/lib/services/huggingface.service.ts b/tools/ui/src/lib/services/huggingface.service.ts
index 346da82b5..2d153f718 100644
--- a/tools/ui/src/lib/services/huggingface.service.ts
+++ b/tools/ui/src/lib/services/huggingface.service.ts
@@ -1,4 +1,4 @@
-import { PATH_SEPARATOR } from '$lib/constants';
+import { PATH_SEPARATOR, SETTINGS_KEYS } from '$lib/constants';
import {
BYTE,
BYTE_LABEL,
@@ -64,6 +64,7 @@ import {
} from '$lib/constants';
import { MODEL_ID, type ModelSidecar } from '$lib/constants';
import { HfEntryType, HfModelSort, SidecarForm } from '$lib/enums';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
import type {
HfCatalogEntry,
HfModelDetailInfo,
@@ -72,6 +73,7 @@ import type {
HfModelSibling
} from '$lib/types/huggingface';
import { sidecarFromFileToken } from '$lib/utils';
+import { SvelteMap } from 'svelte/reactivity';
/** Fetch failure carrying the HTTP status, so retry logic tests the code instead of the message. */
class HfHttpStatusError extends Error {
@@ -96,6 +98,13 @@ export class HuggingFaceService {
Promise<{ org: string; name: string } | null>
>();
+ // Model details fetched this session, keyed by repo id: the Hub rate limits
+ // aggressively, so each repo costs at most one request per load. Failures stay
+ // uncached so the next mount can retry.
+ private static detailsCache = new SvelteMap<string, HfModelDetailInfo | null>();
+
+ private static detailsPending = new Map<string, Promise<HfModelDetailInfo | null>>();
+
/**
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
*/
@@ -135,6 +144,14 @@ export class HuggingFaceService {
Q8_0: 8
};
+ /** Details already fetched, for a caller that cannot await (a table sorting its rows). */
+ static cachedDetails(modelId: string): HfModelDetailInfo | null | undefined {
+ // llama.cpp model ids carry the quant tag after a colon
+ const [hfRepoId] = modelId.split(MODEL_ID.QUANTIZATION_SEPARATOR);
+
+ return HuggingFaceService.detailsCache.get(hfRepoId);
+ }
+
/**
* Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first
* shard, summing every shard's size so the kept entry reflects the whole
@@ -370,19 +387,23 @@ export class HuggingFaceService {
/**
* Resolve the original (non-GGUF) base model `{ org, name }` for a GGUF repo
* from its HF card (`cardData.base_model`). Returns null when the card has no
- * base model. Results are cached per repo.
+ * base model. A resolved repo is cached for the session.
*/
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
- const cached = this.baseModelCache.get(repoId);
+ if (!HuggingFaceService.isEnabled()) return Promise.resolve(null);
+
+ // all quants of one repo share the cached lookup, so key it by the repo id
+ const [hfRepoId] = repoId.split(MODEL_ID.QUANTIZATION_SEPARATOR);
+ const cached = this.baseModelCache.get(hfRepoId);
if (cached !== undefined) return Promise.resolve(cached);
- const pending = this.baseModelPending.get(repoId);
+ const pending = this.baseModelPending.get(hfRepoId);
if (pending) return pending;
const promise = (async () => {
- const details = await this.getDetails(repoId);
+ const details = await this.getDetails(hfRepoId);
const base = this.getBaseModels(details)[0];
if (!base) return null;
@@ -392,11 +413,15 @@ export class HuggingFaceService {
return { name: rest.join(PATH_SEPARATOR), org };
})();
- this.baseModelPending.set(repoId, promise);
+ this.baseModelPending.set(hfRepoId, promise);
promise
- .then((result) => this.baseModelCache.set(repoId, result))
- .finally(() => this.baseModelPending.delete(repoId));
+ // a repo with no base model stays uncached: the details behind it are cached
+ // already, so a retry costs nothing and a failed lookup can try again
+ .then((result) => {
+ if (result) this.baseModelCache.set(hfRepoId, result);
+ })
+ .finally(() => this.baseModelPending.delete(hfRepoId));
return promise;
}
@@ -445,6 +470,8 @@ export class HuggingFaceService {
}
static async getCatalog(): Promise<HfCatalogEntry[]> {
+ if (!HuggingFaceService.isEnabled()) return [];
+
const response = await fetch(MODELS_DISCOVER_CATALOG_URL);
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
@@ -452,38 +479,71 @@ export class HuggingFaceService {
return (await response.json()) as HfCatalogEntry[];
}
- static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
- // Do not encode the modelId, it contains slashes for author/name.
- // `full=true` includes cardData (description, base_model) and safetensors.
- const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
+ static getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
+ if (!HuggingFaceService.isEnabled()) return Promise.resolve(null);
- try {
- const response = await fetch(url);
+ const cached = HuggingFaceService.detailsCache.get(modelId);
- if (response.status === HF_HTTP_NOT_FOUND) return null;
+ if (cached !== undefined) return Promise.resolve(cached);
- if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
+ const pending = HuggingFaceService.detailsPending.get(modelId);
- const data = (await response.json()) as HfModelDetailInfo;
+ if (pending) return pending;
- return data;
- } catch (error) {
- console.error(`Error fetching details for ${modelId}:`, error);
+ const promise = (async () => {
+ // Do not encode the modelId, it contains slashes for author/name.
+ // `full=true` includes cardData (description, base_model) and safetensors.
+ const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
- return null;
- }
+ try {
+ const response = await fetch(url);
+
+ // a missing model is a definitive answer, cache it
+ if (response.status === HF_HTTP_NOT_FOUND) {
+ HuggingFaceService.detailsCache.set(modelId, null);
+
+ return null;
+ }
+
+ if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
+
+ // the hub answers with a list of provider entries for one repo; the gguf and
+ // card data sit on the entry that carries them
+ const payload = (await response.json()) as HfModelDetailInfo | HfModelDetailInfo[];
+ const entries = Array.isArray(payload) ? payload : [payload];
+ const data = entries.find((entry) => entry?.gguf ?? entry?.cardData) ?? entries[0];
+
+ if (!data) return null;
+
+ HuggingFaceService.detailsCache.set(modelId, data);
+
+ return data;
+ } catch (error) {
+ // not cached: a rate limited or failed fetch retries on the next mount
+ // instead of hiding the model for the session
+ console.error(`Error fetching details for ${modelId}:`, error);
+
+ return null;
+ } finally {
+ HuggingFaceService.detailsPending.delete(modelId);
+ }
+ })();
+
+ HuggingFaceService.detailsPending.set(modelId, promise);
+
+ return promise;
}
static getModelUrl(modelId: string): string {
return `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}`;
}
- // Utility Methods
-
static async getMostLiked(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.LIKES });
}
+ // Utility Methods
+
static async getNew(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.CREATED_AT });
}
@@ -496,6 +556,8 @@ export class HuggingFaceService {
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
*/
static async getReadme(modelId: string): Promise<string | null> {
+ if (!HuggingFaceService.isEnabled()) return null;
+
// Do not encode the modelId, it contains slashes for author/name
const url = `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_RAW_PATH}${PATH_SEPARATOR}${HF_MAIN_BRANCH}${PATH_SEPARATOR}${HF_README_FILENAME}`;
@@ -520,6 +582,8 @@ export class HuggingFaceService {
* are included; follows cursor pagination for repos over one page.
*/
static async getTree(modelId: string): Promise<HfModelSibling[]> {
+ if (!HuggingFaceService.isEnabled()) return [];
+
const files: HfModelSibling[] = [];
const firstUrl =
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
@@ -550,6 +614,23 @@ export class HuggingFaceService {
return this.search({ limit, sort: HfModelSort.TRENDING_SCORE });
}
+ /**
+ * True when the UI may read model metadata from the Hugging Face Hub. Off by
+ * default: with it off, callers only see what the server's /v1/models reports
+ * and the org avatars stay hidden.
+ */
+ static isEnabled(): boolean {
+ return settingsStore.config[SETTINGS_KEYS.USE_HUGGING_FACE_HUB] ?? false;
+ }
+
+ /**
+ * Parameter count a repo reports: a GGUF repo spells it out in its metadata,
+ * a transformers repo reports it in its SafeTensors index.
+ */
+ static parameterCount(details?: HfModelDetailInfo | null): number | null {
+ return details?.gguf?.total ?? details?.safetensors?.total ?? null;
+ }
+
/**
* Parse a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
@@ -629,6 +710,8 @@ export class HuggingFaceService {
* catalog entry; caller-provided `expand` entries are merged in.
*/
static async search(params: HfModelSearchParams = {}): Promise<HfModelInfo[]> {
+ if (!HuggingFaceService.isEnabled()) return [];
+
const { expand, limit = HF_DEFAULT_LIMIT, ...restParams } = params;
const url = this.buildUrl({
...restParams,
diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts
index f78f3be62..d52d67d7d 100644
--- a/tools/ui/src/lib/services/migration.service.ts
+++ b/tools/ui/src/lib/services/migration.service.ts
@@ -654,6 +654,39 @@ const renderKeysMigration: Migration = {
console.log('[Migration] Render keys: unfolded the raw text toggle');
}
};
+const LEGACY_MODEL_ORG_NAME_KEY = 'showModelOrgNameInTrigger';
+const MODEL_ORG_NAME_KEY_MIGRATION_ID = 'model-org-name-key-v1';
+/**
+ * Renames the trigger-only org name key onto the selector-wide one. The persisted
+ * config always carries the legacy key, so a value it holds carries over; a config
+ * that already set the new key keeps its own value.
+ */
+const modelOrgNameKeyMigration: Migration = {
+ description: 'Rename the trigger-only org name key onto the selector-wide one',
+ id: MODEL_ORG_NAME_KEY_MIGRATION_ID,
+
+ async run(): Promise<void> {
+ const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+
+ if (configRaw === null) return;
+
+ const config = JSON.parse(configRaw);
+
+ if (!(LEGACY_MODEL_ORG_NAME_KEY in config)) return;
+
+ if (!(SETTINGS_KEYS.SHOW_MODEL_ORG_NAME in config)) {
+ config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] = config[LEGACY_MODEL_ORG_NAME_KEY] === true;
+ }
+
+ // Dropped rather than preserved: the new key covers the trigger too, so leaving the
+ // old one behind would let a stale value fight the restored one.
+ delete config[LEGACY_MODEL_ORG_NAME_KEY];
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
+
+ if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+ console.log('[Migration] Model org name: renamed the trigger key');
+ }
+};
const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
/**
@@ -804,6 +837,7 @@ const migrations: Migration[] = [
mcpDefaultOverridesMergeMigration,
configTypesMigration,
renderKeysMigration,
+ modelOrgNameKeyMigration,
mcpServerOverridesToToolPolicyMigration
];
diff --git a/tools/ui/src/lib/stores/mcp/index.svelte.ts b/tools/ui/src/lib/stores/mcp/index.svelte.ts
index 768e2c1aa..967288c90 100644
--- a/tools/ui/src/lib/stores/mcp/index.svelte.ts
+++ b/tools/ui/src/lib/stores/mcp/index.svelte.ts
@@ -40,6 +40,7 @@ import type {
import type { DatabaseMessageExtraMcpResource } from '$lib/types/database';
import type { SettingsConfigType } from '$lib/types/settings';
import {
+ buildProxiedIconUrl,
detectMcpTransportFromUrl,
getMcpIconUrl,
getMcpServerFaviconFallback,
@@ -675,11 +676,11 @@ class MCPStore implements McpHealthHost {
const mcpIconUrl = getMcpIconUrl(healthState.serverInfo.icons, isDark);
if (mcpIconUrl) {
- return mcpIconUrl;
+ return this.toRenderableIconUrl(mcpIconUrl);
}
}
- return getMcpServerFaviconFallback(server.url);
+ return this.toRenderableIconUrl(getMcpServerFaviconFallback(server.url));
}
/**
@@ -1400,6 +1401,15 @@ class MCPStore implements McpHealthHost {
console.log(`[MCPStore][${serverName}] Session recovered successfully`);
}
+ /**
+ * Icons come from the MCP server's own origin, which the browser cannot load
+ * under the UI's cross-origin isolation. With the proxy enabled the server
+ * fetches them instead; data URLs need no help and stay as they are.
+ */
+ private toRenderableIconUrl(iconUrl: string | null): string | null {
+ return buildProxiedIconUrl(iconUrl, this.isProxyAvailable);
+ }
+
private updateState(state: {
isInitializing?: boolean;
error?: string | null;
diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts
index d367ad987..69f010b70 100644
--- a/tools/ui/src/lib/stores/models/index.svelte.ts
+++ b/tools/ui/src/lib/stores/models/index.svelte.ts
@@ -7,23 +7,114 @@
* {@link ModelsStore.status}; tracks which conversations use which models.
*/
-import { FAVORITE_MODELS_LOCALSTORAGE_KEY } from '$lib/constants';
+import { browser } from '$app/environment';
+import {
+ FAVORITE_MODELS_LOCALSTORAGE_KEY,
+ HIDDEN_MODELS_LOCALSTORAGE_KEY,
+ MODEL_GROUP_OPEN_LOCALSTORAGE_KEY,
+ RECENT_MODEL_LIMIT,
+ RECENT_MODELS_LOCALSTORAGE_KEY
+} from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
+import { HuggingFaceService } from '$lib/services/huggingface.service';
import { ModelsService } from '$lib/services/models.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte';
import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte';
import { serverStore } from '$lib/stores/server.svelte';
+import type { ModelSidecarBadge, ParsedModelId } from '$lib/types/models';
import { getConversationModel } from '$lib/utils/conversation-utils';
-import { SvelteSet } from 'svelte/reactivity';
+import { repoOf } from '$lib/utils/model-names';
+import { isAuxSidecar } from '$lib/utils/sidecars';
+import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
+/** Group open states kept before the oldest ones fall off; the map only ever grows. */
+const MAX_GROUP_OPEN_ENTRIES = 200;
+/**
+ * Repos whose Hub details are prefetched on load. The Hub rate limits an
+ * unauthenticated client aggressively, so the warm slice stays far below the
+ * list window and the rest of the rows fetch as they scroll near the viewport.
+ */
+const HUB_WARM_LIMIT = 12;
+
+/** Union of the draft sidecar badges two sources report for one repo. */
+function mergedDraftSidecars(
+ fromListing: ModelSidecarBadge[] | undefined,
+ fromArgs: ModelSidecarBadge[]
+): ModelSidecarBadge[] {
+ if (!fromListing || fromListing.length === 0) return fromArgs;
+
+ if (fromArgs.length === 0) return fromListing;
+
+ const merged = [...fromListing];
+
+ for (const badge of fromArgs) {
+ if (!merged.some((b) => b.kind === badge.kind && b.repo === badge.repo)) merged.push(badge);
+ }
+
+ return merged;
+}
+
+/** Models kept out of the selector. */
+function loadHiddenModels(): Set<string> {
+ if (!browser) return new SvelteSet<string>();
+
+ try {
+ const raw = localStorage.getItem(HIDDEN_MODELS_LOCALSTORAGE_KEY);
+
+ return raw ? new SvelteSet(JSON.parse(raw) as string[]) : new SvelteSet<string>();
+ } catch {
+ return new SvelteSet<string>();
+ }
+}
+
+/** Open state the user set for a section or family of the model lists. */
+function loadGroupOpenState(): SvelteMap<string, boolean> {
+ if (!browser) return new SvelteMap<string, boolean>();
+
+ try {
+ const raw = localStorage.getItem(MODEL_GROUP_OPEN_LOCALSTORAGE_KEY);
+ const parsed = raw ? (JSON.parse(raw) as unknown) : null;
+ const entries =
+ parsed && typeof parsed === 'object' ? Object.entries(parsed as Record<string, unknown>) : [];
+
+ return new SvelteMap(
+ entries.filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean')
+ );
+ } catch {
+ return new SvelteMap<string, boolean>();
+ }
+}
+
+/** Recently used backend-qualified ids, most recent first. */
+function loadRecentModels(): string[] {
+ if (!browser) return [];
+
+ try {
+ const raw = localStorage.getItem(RECENT_MODELS_LOCALSTORAGE_KEY);
+
+ if (!raw) return [];
+
+ const parsed = JSON.parse(raw) as unknown;
+
+ return Array.isArray(parsed)
+ ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MODEL_LIMIT)
+ : [];
+ } catch {
+ return [];
+ }
+}
+
class ModelsStore implements ModelPropsHost, ModelStatusHost {
error = $state<string | null>(null);
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
+ groupOpenState = $state<SvelteMap<string, boolean>>(loadGroupOpenState());
+ hiddenModelIds = $state<Set<string>>(loadHiddenModels());
loading = $state(false);
models = $state<ModelOption[]>([]);
+ recentModelIds = $state<string[]>(loadRecentModels());
routerModels = $state<ApiModelDataEntry[]>([]);
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
@@ -36,7 +127,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
/** Load/unload operations and the /models/sse status feed, composed here. */
private _status = new ModelStatusManager(this);
- // Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
+ // every caller awaits the same inflight promise
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
private inflightFetch: Promise<void> | null = null;
@@ -119,6 +210,15 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
this.selectedModelName = null;
}
+ /** Family keys folded away under one section, for a list that restores them. */
+ collapsedGroupsUnder(prefix: string): string[] {
+ const head = `${prefix}-`;
+
+ return [...this.groupOpenState]
+ .filter(([id, open]) => !open && id.startsWith(head))
+ .map(([id]) => id.slice(head.length));
+ }
+
/**
* Auto-selects the first available model if none is selected.
* Prioritizes:
@@ -162,11 +262,14 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return;
}
- // Try loading a favorite model
+ // favorites are shared across backends, so a stored id may belong to another one
const favorite = this.favoriteModelIds.values().next()?.value;
+ const favoriteOption = favorite
+ ? availableModels.find((m) => m.id === favorite || m.model === favorite)
+ : undefined;
- if (favorite) {
- await this.selectModelById(favorite);
+ if (favoriteOption) {
+ await this.selectModelById(favoriteOption.id);
return;
}
@@ -207,6 +310,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
// keep the selector options in sync: a downloaded / deleted model shows
// up here too, not only in the router model rows
this.models = this.buildModelOptions(response);
+ this.warmHubDetails();
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
@@ -254,7 +358,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this.routerModels.find((m) => m.id === modelId);
- return model?.status.value ?? null;
+ return (model?.status?.value as ServerModelStatus) ?? null;
}
hasModel(modelName: string): boolean {
@@ -265,19 +369,36 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return this.favoriteModelIds.has(modelId);
}
+ isGroupOpen(id: string, fallbackOpen: boolean): boolean {
+ return this.groupOpenState.get(id) ?? fallbackOpen;
+ }
+
+ isHidden(modelId: string): boolean {
+ return this.hiddenModelIds.has(modelId);
+ }
+
isModelLoaded(modelId: string): boolean {
- const model = this.routerModels.find((m) => m.id === modelId);
+ const status = this.getModelStatus(modelId);
- return (
- model?.status.value === ServerModelStatus.LOADED ||
- model?.status.value === ServerModelStatus.SLEEPING
- );
+ return status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING;
+ }
+
+ /** Loaded, or sleeping, and not mid-operation: what a row shows as running. */
+ isModelRunning(modelId: string): boolean {
+ return this.isModelLoaded(modelId) && !this._status.isOperationInProgress(modelId);
}
- async selectModelById(modelId: string): Promise<void> {
+ /**
+ * Select a model. `recordRecent` keeps automatic picks out of the recency list.
+ */
+ async selectModelById(modelId: string, options?: { recordRecent?: boolean }): Promise<void> {
if (!modelId || this.updating) return;
- if (this.selectedModelId === modelId) return;
+ if (this.selectedModelId === modelId) {
+ if (options?.recordRecent) this.recordRecentModel(modelId);
+
+ return;
+ }
const option = this.models.find((model) => model.id === modelId);
@@ -289,6 +410,8 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
try {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
+
+ if (options?.recordRecent) this.recordRecentModel(modelId);
} finally {
this.updating = false;
}
@@ -301,8 +424,9 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const option = this.models.find((model) => model.model === modelName);
if (option) {
- this.selectedModelId = option.id;
- this.selectedModelName = option.model;
+ // the updating guard inside selectModelById cannot refuse this call: that
+ // call is the only one that raises the flag, and it clears it before it returns
+ void this.selectModelById(option.id);
}
}
@@ -331,6 +455,59 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
+ /** Add or remove several models at once, e.g. every quant of a family. */
+ setFavorites(modelIds: string[], favorite: boolean): void {
+ const next = new SvelteSet(this.favoriteModelIds);
+
+ for (const modelId of modelIds) {
+ if (favorite) {
+ next.add(modelId);
+ } else {
+ next.delete(modelId);
+ }
+ }
+
+ this.favoriteModelIds = next;
+
+ try {
+ localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
+ } catch {
+ toast.error('Failed to save favorite models to local storage');
+ }
+ }
+
+ /** Persist one family of a section, e.g. a folded `Qwen` under the local models. */
+ setGroupCollapsed(prefix: string, key: string, collapsed: boolean): void {
+ this.setGroupOpen(`${prefix}-${key}`, !collapsed);
+ }
+
+ setGroupOpen(id: string, open: boolean): void {
+ const next = new SvelteMap(this.groupOpenState);
+
+ // re-set moves the id to the end, so the oldest states fall off first
+ next.delete(id);
+ next.set(id, open);
+
+ while (next.size > MAX_GROUP_OPEN_ENTRIES) {
+ const oldest = next.keys().next();
+
+ if (oldest.done) break;
+
+ next.delete(oldest.value);
+ }
+
+ this.groupOpenState = next;
+
+ try {
+ localStorage.setItem(
+ MODEL_GROUP_OPEN_LOCALSTORAGE_KEY,
+ JSON.stringify(Object.fromEntries(next))
+ );
+ } catch {
+ toast.error('Failed to save the model list state to local storage');
+ }
+ }
+
toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
@@ -339,7 +516,12 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
toggleFavorite(modelId: string): void {
- const next = new SvelteSet(this.favoriteModelIds);
+ this.setFavorites([modelId], !this.favoriteModelIds.has(modelId));
+ }
+
+ /** Models hidden from the selector stay in the manager, flagged and unhideable. */
+ toggleHidden(modelId: string): void {
+ const next = new SvelteSet(this.hiddenModelIds);
if (next.has(modelId)) {
next.delete(modelId);
@@ -347,12 +529,35 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
next.add(modelId);
}
- this.favoriteModelIds = next;
+ this.hiddenModelIds = next;
try {
- localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
+ localStorage.setItem(HIDDEN_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
- toast.error('Failed to save favorite models to local storage');
+ toast.error('Failed to save hidden models to local storage');
+ }
+ }
+
+ /**
+ * Warm the Hub record of the local repos, so a list can read the context and size
+ * the server reports only once a model is loaded.
+ */
+ warmHubDetails(): void {
+ if (!HuggingFaceService.isEnabled()) return;
+
+ const repos: string[] = [];
+
+ for (const option of this.models) {
+ const repo = repoOf(option.model);
+
+ if (repo?.includes('/') && !repos.includes(repo)) repos.push(repo);
+ }
+
+ // the Hub rate limits aggressively and an unauthenticated burst of requests
+ // leaves every detail uncached for a retry on the next mount, so warm only the
+ // rows the lists show at rest and let the rest arrive near the viewport
+ for (const repo of repos.slice(0, HUB_WARM_LIMIT)) {
+ void HuggingFaceService.getDetails(repo).catch(() => {});
}
}
@@ -365,15 +570,43 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const entries: {
details?: ApiModelsListResponse['models'][number];
item: ApiModelDataEntry;
+ parsed: ParsedModelId;
}[] = response.data.map((item: ApiModelDataEntry, index: number) => ({
details: response.models?.[index],
- item
+ item,
+ parsed: ModelsService.parseModelId(item.id)
}));
+ // sidecar entries mark downloaded sidecar files, not loadable models, and the router
+ // lists them under their own `<repo>:<quant>-<sidecar>` id: pair each draft with its
+ // repo here and drop the entries below
+ const draftSidecarsByRepo = new SvelteMap<string, ModelSidecarBadge[]>();
+
+ for (const { item, parsed } of entries) {
+ if (!parsed.sidecar || isAuxSidecar(parsed.sidecar)) continue;
+
+ const repo = repoOf(item.id);
+ // the entry id's quant is the sidecar file's own quant
+ const badge: ModelSidecarBadge = {
+ kind: parsed.sidecar,
+ quant: parsed.quantization,
+ repo
+ };
+ const kinds = draftSidecarsByRepo.get(repo);
+
+ if (kinds) {
+ if (!kinds.some((b) => b.kind === badge.kind && b.repo === badge.repo)) {
+ kinds.push(badge);
+ }
+ } else {
+ draftSidecarsByRepo.set(repo, [badge]);
+ }
+ }
return (
entries
- // sidecar entries mark downloaded sidecar files, not loadable models
- .filter(({ item }) => !ModelsService.isSidecarEntry(item.id))
+ // the router also lists projector files by filename, which carry the
+ // sidecar token inside the id rather than after a colon
+ .filter(({ item, parsed }) => !parsed.sidecar && !ModelsService.isSidecarEntry(item.id))
// in-flight downloads are not usable models yet; the selector tracks
// them in its "Download in progress" section instead
.filter(({ item }) => item.status?.value !== ServerModelStatus.DOWNLOADING)
@@ -382,6 +615,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const modelId = details?.model || item.id;
+ const repo = repoOf(modelId);
return {
aliases: item.aliases ?? [],
@@ -390,6 +624,12 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
),
description: details?.description,
details: details?.details,
+ draftSidecars: mergedDraftSidecars(
+ draftSidecarsByRepo.get(repo),
+ // models the router serves with a draft record it in their
+ // --model-draft args instead of a listing entry
+ this._status.getDraftSidecars(repo)
+ ),
id: item.id,
meta: item.meta ?? null,
modalities: this.props.buildArchitectureModalities(item.architecture),
@@ -427,6 +667,22 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
+ /** Move a model to the front of the recently used list. */
+ private recordRecentModel(qualifiedId: string): void {
+ this.recentModelIds = [
+ qualifiedId,
+ ...this.recentModelIds.filter((id) => id !== qualifiedId)
+ ].slice(0, RECENT_MODEL_LIMIT);
+
+ if (!browser) return;
+
+ try {
+ localStorage.setItem(RECENT_MODELS_LOCALSTORAGE_KEY, JSON.stringify(this.recentModelIds));
+ } catch {
+ console.warn('[ModelsStore] Failed to persist the recently used models');
+ }
+ }
+
private async runFetch(): Promise<void> {
this.loading = true;
this.error = null;
diff --git a/tools/ui/src/lib/stores/models/status.svelte.ts b/tools/ui/src/lib/stores/models/status.svelte.ts
index b0ac08366..fc2d52b12 100644
--- a/tools/ui/src/lib/stores/models/status.svelte.ts
+++ b/tools/ui/src/lib/stores/models/status.svelte.ts
@@ -19,7 +19,14 @@ import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
-import type { ApiModelsSseDownloadProgressData, ModelDownloadProgress } from '$lib/types';
+import type {
+ ApiModelsSseDownloadProgressData,
+ ModelDownloadEntry,
+ ModelDownloadProgress
+} from '$lib/types';
+import type { ModelSidecarBadge } from '$lib/types/models';
+import { repoOf } from '$lib/utils/model-names';
+import { isAuxSidecar } from '$lib/utils/sidecars';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -84,7 +91,54 @@ export class ModelStatusManager {
return result;
});
+
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
+ /**
+ * Draft sidecars the registered models pull with them, keyed by the repo of the model
+ * that pulls them: the router records them in the model's `--model-draft` args.
+ */
+ private draftSidecarsByRepo = $derived.by(() => {
+ const result = new SvelteMap<string, ModelSidecarBadge[]>();
+
+ for (const m of this.host.routerModels) {
+ const args = m.status?.args;
+
+ if (!args) continue;
+
+ const baseRepo = repoOf(m.id);
+
+ for (let i = 0; i < args.length - 1; i++) {
+ if (args[i] !== CLI_FLAGS.MODEL_DRAFT && args[i] !== CLI_FLAGS.MODEL_DRAFT_SHORT) {
+ continue;
+ }
+
+ const parsed = HuggingFaceService.parseCachePath(args[i + 1]);
+
+ if (!parsed) continue;
+
+ const meta = HuggingFaceService.extractQuantMeta(parsed.file);
+
+ if (!meta?.sidecar || isAuxSidecar(meta.sidecar)) continue;
+
+ const badge: ModelSidecarBadge = {
+ kind: meta.sidecar,
+ quant: meta.quant,
+ repo: parsed.repo
+ };
+ const kinds = result.get(baseRepo);
+
+ if (kinds) {
+ if (!kinds.some((b) => b.kind === badge.kind && b.repo === badge.repo)) {
+ kinds.push(badge);
+ }
+ } else {
+ result.set(baseRepo, [badge]);
+ }
+ }
+ }
+
+ return result;
+ });
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
private failedDownloads = new SvelteSet<string>();
private loadingStates = new SvelteMap<string, boolean>();
@@ -102,8 +156,10 @@ export class ModelStatusManager {
private stopRequests = new SvelteMap<string, ModelDownloadStopRequest>();
/**
- * Cancel an in-flight download or remove a downloaded/failed entry from
- * the cache (ROUTER mode only).
+ * Cancel an in-flight download, or remove a downloaded/failed entry from the
+ * cache (ROUTER mode only). The server's remove endpoint covers both: dropping
+ * a running download discards its partial files, dropping a cached model
+ * deletes the files on disk, so the delete action resolves through this too.
*/
async cancelDownload(repoWithTag: string): Promise<boolean> {
if (!serverStore.isRouterMode) {
@@ -230,11 +286,7 @@ export class ModelStatusManager {
* Tracked downloads (in flight or paused) for the selector's
* "Download in progress" section.
*/
- getDownloadEntries(): {
- isPaused: boolean;
- progress: ModelDownloadProgress | null;
- repoWithTag: string;
- }[] {
+ getDownloadEntries(): ModelDownloadEntry[] {
const inFlight = Array.from(this.downloadProgress, ([repoWithTag, progress]) => ({
isPaused: false,
progress,
@@ -253,6 +305,11 @@ export class ModelStatusManager {
return this.downloadProgress.get(repoWithTag) ?? null;
}
+ /** Draft sidecars a repo pulls with its model, e.g. `mtp` at `Q8_0`. */
+ getDraftSidecars(repoId: string): ModelSidecarBadge[] {
+ return this.draftSidecarsByRepo.get(repoId) ?? [];
+ }
+
getLoadProgress(modelId: string): ModelLoadProgress | null {
return this.loadProgress.get(modelId) ?? null;
}
diff --git a/tools/ui/src/lib/stores/settings/index.svelte.ts b/tools/ui/src/lib/stores/settings/index.svelte.ts
index 90e2ea2c3..765f2e302 100644
--- a/tools/ui/src/lib/stores/settings/index.svelte.ts
+++ b/tools/ui/src/lib/stores/settings/index.svelte.ts
@@ -212,7 +212,7 @@ class SettingsStore {
/**
* Initialize the settings store by loading from localStorage.
- * Called by initStores() after migrations have run.
+ * Called by hydrateStores() after migrations have run.
*/
initialize() {
if (!browser) return;
diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts
index 57fcfb91e..2578a6c17 100644
--- a/tools/ui/src/lib/stores/ui.svelte.ts
+++ b/tools/ui/src/lib/stores/ui.svelte.ts
@@ -6,9 +6,36 @@
* controls and the chat tab bar reacts to).
*/
+import type { ModelOption } from '$lib/types/models';
+
class UiStore {
+ /** Set when a flow outside the chat wants the composer focused once it can take it. */
+ composerFocusRequested = $state(false);
+
/** Whether the desktop sidebar is expanded (open). */
isSidebarExpanded = $state(false);
+ /** Model the manager reveals when it opens, a qualified id or a raw model name. */
+ manageModelFocus = $state<string | null>(null);
+ /** Open state of the models manager, driven from the sidebar and from model rows. */
+ manageModelsOpen = $state(false);
+ /** Model the information dialog shows; a phone opens it instead of the manager. */
+ modelInformation = $state<ModelOption | null>(null);
+
+ /** Open the model information dialog, a phone's stand-in for the manager pane. */
+ openModelInformation(option: ModelOption): void {
+ this.modelInformation = option;
+ }
+
+ /** Open the models manager, optionally focused on one model. */
+ openModelsManager(focus?: string): void {
+ this.manageModelFocus = focus ?? null;
+ this.manageModelsOpen = true;
+ }
+
+ /** Ask the composer to take focus, e.g. after a dialog closes onto the chat. */
+ requestComposerFocus(): void {
+ this.composerFocusRequested = true;
+ }
}
export const uiStore = new UiStore();
diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts
index 86a868c33..068662a63 100644
--- a/tools/ui/src/lib/types/chat.d.ts
+++ b/tools/ui/src/lib/types/chat.d.ts
@@ -325,8 +325,8 @@ export type ChatMessageEditContext = ChatMessageEditState &
/**
* Actions and capability flags for the ChatForm add-menu. Set once in
- * ChatFormActions.svelte and consumed by its deep descendants (the add sheet,
- * dropdown and MCP servers submenu) to avoid relaying them through props.
+ * ChatFormActions.svelte and consumed by its deep descendants (the add sheet
+ * and dropdown) to avoid relaying them through props.
*/
export interface ChatFormActionsContext {
readonly disabled: boolean;
@@ -335,5 +335,4 @@ export interface ChatFormActionsContext {
readonly hasVisionModality: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
- onMcpSettingsClick?: () => void;
}
diff --git a/tools/ui/src/lib/types/huggingface.d.ts b/tools/ui/src/lib/types/huggingface.d.ts
index 675c86b33..5821097cb 100644
--- a/tools/ui/src/lib/types/huggingface.d.ts
+++ b/tools/ui/src/lib/types/huggingface.d.ts
@@ -154,6 +154,13 @@ export interface HfModelDetailInfo extends HfModelInfo {
cardData?: HfModelCardData;
/** GGUF metadata (only present when full=true for GGUF repos) */
gguf?: HfModelGguf;
+ /** SafeTensors counts (only present when full=true for transformers repos) */
+ safetensors?: {
+ /** Total parameter count */
+ total?: number;
+ /** Per dtype parameter counts */
+ parameters?: Record<string, number>;
+ };
/** Model config (only present when full=true) */
config?: Record<string, unknown>;
/** Total repo storage in bytes (only present when full=true) */
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index 453af9bb8..bda7371cb 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -105,6 +105,7 @@ export type {
ModelCapabilities,
ModelModalities,
ModelOption,
+ ModelDownloadEntry,
ModelDownloadFileProgress,
ModelDownloadProgress,
ModelLoadProgress,
diff --git a/tools/ui/src/lib/types/models.d.ts b/tools/ui/src/lib/types/models.d.ts
index ca53ca880..579ea2327 100644
--- a/tools/ui/src/lib/types/models.d.ts
+++ b/tools/ui/src/lib/types/models.d.ts
@@ -18,6 +18,10 @@ export interface ModelOption {
model: string;
description?: string;
capabilities: string[];
+ /** Context size reported by the provider's model listing, when it reports one. */
+ contextLength?: number;
+ /** Draft sidecars downloaded alongside the model, e.g. `mtp` or `dflash`. */
+ draftSidecars?: ModelSidecarBadge[];
modalities?: ModelModalities;
details?: ApiModelDetails['details'];
meta?: ApiModelDataEntry['meta'];
@@ -47,6 +51,13 @@ export interface ModelDownloadProgress {
files: Record<string, ModelDownloadFileProgress>;
}
+/** One tracked download of the status feed, in flight or paused. */
+export interface ModelDownloadEntry {
+ isPaused: boolean;
+ progress: ModelDownloadProgress | null;
+ repoWithTag: string;
+}
+
export interface ParsedModelId {
raw: string;
orgName: string | null;
@@ -58,9 +69,30 @@ export interface ParsedModelId {
tags: string[];
}
+/** Draft sidecar of a model, shown as a `+ [KIND] [QUANT]` badge pair. */
+export interface ModelSidecarBadge {
+ kind: ModelSidecar;
+ /** Quantization of the sidecar file itself; null when the name carries none. */
+ quant: string | null;
+ /** Repo the sidecar file belongs to, which may differ from the model's. */
+ repo: string;
+}
+
/** Modality capabilities for file validation. */
export interface ModalityCapabilities {
hasVision: boolean;
hasAudio: boolean;
hasVideo: boolean;
}
+
+/** Sidecar file a listing reports as its own model entry, paired back to its model. */
+export interface ModelSidecarFile {
+ id: string;
+ kind: ModelSidecar;
+ /** Repo the sidecar belongs to, e.g. `ggml-org/Qwen3.6-35B-A3B-GGUF`. */
+ model: string;
+ /** Parameter count the sidecar reports, e.g. `35B-A3B`. */
+ params: string | null;
+ /** Quantization of the sidecar file, e.g. `Q4_0`. */
+ quant: string | null;
+}
diff --git a/tools/ui/src/lib/utils/cors-proxy.ts b/tools/ui/src/lib/utils/cors-proxy.ts
index 58423b7e7..10db51d83 100644
--- a/tools/ui/src/lib/utils/cors-proxy.ts
+++ b/tools/ui/src/lib/utils/cors-proxy.ts
@@ -4,6 +4,7 @@
import { base } from '$app/paths';
import { CORS_PROXY, CORS_PROXY_ENDPOINT } from '$lib/constants';
+import { UrlProtocol } from '$lib/enums';
/**
* Build a proxied URL that routes through llama-server's CORS proxy.
@@ -19,6 +20,20 @@ export function buildProxiedUrl(targetUrl: string): URL {
return proxyUrl;
}
+/**
+ * Route a remote icon through the CORS proxy so it renders inside the UI's
+ * cross-origin isolation. Inline data URLs are left alone, and with the proxy
+ * off the origin URL stands in, so the caller keeps its own fallback.
+ */
+export function buildProxiedIconUrl(
+ iconUrl: string | null,
+ proxyAvailable: boolean
+): string | null {
+ if (!iconUrl || !proxyAvailable || iconUrl.startsWith(UrlProtocol.DATA)) return iconUrl;
+
+ return buildProxiedUrl(iconUrl).toString();
+}
+
/**
* Wrap original headers for proxying through the CORS proxy. This avoids issues with duplicated llama.cpp-specific and target headers when using the CORS proxy.
* @param headers - The original headers to be proxied to target
diff --git a/tools/ui/src/lib/utils/formatters.ts b/tools/ui/src/lib/utils/formatters.ts
index 03e627984..20abde51c 100644
--- a/tools/ui/src/lib/utils/formatters.ts
+++ b/tools/ui/src/lib/utils/formatters.ts
@@ -54,6 +54,25 @@ export function formatParameters(params: number | unknown): string {
return params.toString();
}
+/** Separator between the context values shown for one model, e.g. `8192 / 4096`. */
+const CONTEXT_VALUE_SEPARATOR = ' / ';
+/** Unit the context values are shown in. */
+const CONTEXT_UNIT = 'tokens';
+/** Shown where a value is unknown. */
+const UNKNOWN_VALUE = '-';
+
+/**
+ * Context window of a model: `configured / supported tokens`, or whichever is known.
+ * Equal values collapse to one, `131 072 tokens` instead of `131 072 / 131 072 tokens`.
+ */
+export function formatContextLength(values: number[]): string {
+ const unique = [...new Set(values)];
+
+ return unique.length
+ ? `${unique.map((value) => formatNumber(value)).join(CONTEXT_VALUE_SEPARATOR)} ${CONTEXT_UNIT}`
+ : UNKNOWN_VALUE;
+}
+
/**
* Format number with locale-specific thousands separators
*
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 21d84dc8d..4d7bca4de 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -42,11 +42,22 @@ export {
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
// CORS Proxy
-export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
+export { buildProxiedIconUrl, buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
// URL utilities
export { extractRootDomain, sanitizeExternalUrl, canonicalizeServerUrl } from './url';
+// Model list helpers
+export {
+ filterModelOptions,
+ groupFavoriteOptions,
+ groupModelOptions,
+ windowLocalGroups,
+ type GroupedModelOptions,
+ type ModelItem,
+ type OrgGroup
+} from './model-list';
+
// Progress helpers
export { modelLoadFraction, modelLoadProgressText } from './progress';
@@ -105,7 +116,7 @@ export {
} from './modality-file-validation';
// Model name utilities
-export { normalizeModelName, isValidModelName } from './model-names';
+export { isValidModelName, normalizeModelName, orgOf, repoOf } from './model-names';
// Sidecar token utilities
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken, sidecarFromTag } from './sidecars';
@@ -360,3 +371,7 @@ export { remToPx } from './css';
// Audio format helper (used by agentic store and chat service)
export { getAudioInputFormat } from './audio-format';
+export { groupModelFamilies, modelFamilyKey, type ModelFamilyGroup } from './model-families';
+
+// Svelte actions
+export { nearViewport } from './near-viewport';
diff --git a/tools/ui/src/lib/utils/model-families.ts b/tools/ui/src/lib/utils/model-families.ts
new file mode 100644
index 000000000..ba71340c1
--- /dev/null
+++ b/tools/ui/src/lib/utils/model-families.ts
@@ -0,0 +1,47 @@
+import { nameOf } from '$lib/utils/model-names';
+
+/** One family of a model list, and the entries it covers. */
+export interface ModelFamilyGroup<T> {
+ entries: T[];
+ key: string;
+ label: string;
+}
+
+/** Leading letters of a repo name, e.g. the `Qwen` of `Qwen3.8-27B`. */
+const FAMILY_LETTERS = /^[A-Za-z]+/;
+/** Separators between the segments of a repo name, e.g. the `Qwen3` of `Qwen3-8B`. */
+const FAMILY_SEPARATORS = /[-_.]/;
+
+/**
+ * Family a repo belongs to: the leading letters of its name, so `Qwen3.5` and
+ * `Qwen3.8-27B` both read as `Qwen`.
+ */
+export function modelFamilyKey(repo: string): string {
+ const name = nameOf(repo);
+ const letters = name.match(FAMILY_LETTERS);
+
+ return letters ? letters[0] : name.split(FAMILY_SEPARATORS)[0] || name;
+}
+
+/** Fold entries into families, so `Qwen` collects its sizes and variants. */
+export function groupModelFamilies<T>(
+ entries: T[],
+ modelOf: (entry: T) => string
+): ModelFamilyGroup<T>[] {
+ const families = new Map<string, ModelFamilyGroup<T>>();
+
+ for (const entry of entries) {
+ const key = modelFamilyKey(modelOf(entry));
+ const family = families.get(key);
+
+ if (family) {
+ family.entries.push(entry);
+
+ continue;
+ }
+
+ families.set(key, { entries: [entry], key, label: key });
+ }
+
+ return Array.from(families.values());
+}
diff --git a/tools/ui/src/lib/components/app/models/utils.ts b/tools/ui/src/lib/utils/model-list.ts
similarity index 54%
rename from tools/ui/src/lib/components/app/models/utils.ts
rename to tools/ui/src/lib/utils/model-list.ts
index 35bfb58a2..ba3c67ae6 100644
--- a/tools/ui/src/lib/components/app/models/utils.ts
+++ b/tools/ui/src/lib/utils/model-list.ts
@@ -1,10 +1,10 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
-import { SvelteMap } from 'svelte/reactivity';
+
+/** Model list helpers: search, grouping and windowing of the selector rows. */
export interface ModelItem {
option: ModelOption;
- flatIndex: number;
}
export interface OrgGroup {
@@ -13,8 +13,6 @@ export interface OrgGroup {
}
export interface GroupedModelOptions {
- loaded: ModelItem[];
- favorites: ModelItem[];
available: OrgGroup[];
}
@@ -50,52 +48,60 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
);
}
-export function groupModelOptions(
- filteredOptions: ModelOption[],
- favoriteIds: Set<string>,
- isModelLoaded: (model: string) => boolean
-): GroupedModelOptions {
- // Loaded models
- const loaded: ModelItem[] = [];
-
- for (let i = 0; i < filteredOptions.length; i++) {
- if (isModelLoaded(filteredOptions[i].model)) {
- loaded.push({ flatIndex: i, option: filteredOptions[i] });
- }
- }
+/**
+ * Favorite models across every backend, in list order: the favorites tab spans
+ * all backends, so they come from the full option list.
+ */
+export function groupFavoriteOptions(
+ options: ModelOption[],
+ favoriteIds: Set<string>
+): ModelItem[] {
+ return options.filter((option) => favoriteIds.has(option.model)).map((option) => ({ option }));
+}
- // Favorites (excluding loaded)
- const loadedModelIds = new Set(loaded.map((item) => item.option.model));
- const favorites: ModelItem[] = [];
-
- for (let i = 0; i < filteredOptions.length; i++) {
- if (
- favoriteIds.has(filteredOptions[i].model) &&
- !loadedModelIds.has(filteredOptions[i].model)
- ) {
- favorites.push({ flatIndex: i, option: filteredOptions[i] });
- }
- }
+/**
+ * Cut the local groups down to a window of rows, so the caller can grow it as
+ * the list scrolls.
+ */
+export function windowLocalGroups(
+ groups: GroupedModelOptions,
+ limit: number
+): { available: OrgGroup[]; shown: number } {
+ let budget = limit;
- // Available models grouped by org (excluding loaded and favorites)
const available: OrgGroup[] = [];
- const orgGroups = new SvelteMap<string, ModelItem[]>();
- for (let i = 0; i < filteredOptions.length; i++) {
- const option = filteredOptions[i];
+ let shown = 0;
+
+ for (const group of groups.available) {
+ if (budget <= 0) break;
+
+ const items = group.items.slice(0, budget);
- if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
+ budget -= items.length;
+ shown += items.length;
+
+ if (items.length > 0) available.push({ ...group, items });
+ }
+
+ return { available, shown };
+}
+
+export function groupModelOptions(filteredOptions: ModelOption[]): GroupedModelOptions {
+ const available: OrgGroup[] = [];
+ const orgGroups = new Map<string, ModelItem[]>();
+ for (const option of filteredOptions) {
const key = option.parsedId?.orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
- orgGroups.get(key)!.push({ flatIndex: i, option });
+ orgGroups.get(key)!.push({ option });
}
for (const [orgName, items] of orgGroups) {
available.push({ items, orgName: orgName || null });
}
- return { available, favorites, loaded };
+ return { available };
}
diff --git a/tools/ui/src/lib/utils/model-names.ts b/tools/ui/src/lib/utils/model-names.ts
index 1918eedc8..d6deb9107 100644
--- a/tools/ui/src/lib/utils/model-names.ts
+++ b/tools/ui/src/lib/utils/model-names.ts
@@ -67,3 +67,19 @@ export function orgOf(repoId: string | null | undefined): string {
return repoId.split(MODEL_ID.ORG_SEPARATOR)[0] || repoId;
}
+
+/**
+ * Repo of a quantized model id (`ggml-org/Qwen3-8B-GGUF:Q4_K_M` ->
+ * `ggml-org/Qwen3-8B-GGUF`), the id itself when it carries no quant tag.
+ */
+export function repoOf(modelId: string): string {
+ return modelId.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] || modelId;
+}
+
+/**
+ * Name of a repo id past its org (`ggml-org/Qwen3-8B-GGUF` -> `Qwen3-8B-GGUF`),
+ * the input itself when it carries no org separator.
+ */
+export function nameOf(repoId: string): string {
+ return repoId.split(MODEL_ID.ORG_SEPARATOR).pop() || repoId;
+}
diff --git a/tools/ui/src/lib/utils/near-viewport.ts b/tools/ui/src/lib/utils/near-viewport.ts
new file mode 100644
index 000000000..2af6a6bcf
--- /dev/null
+++ b/tools/ui/src/lib/utils/near-viewport.ts
@@ -0,0 +1,34 @@
+import type { ActionReturn } from 'svelte/action';
+
+/** Margin around the viewport that already counts as "near". */
+const NEAR_VIEWPORT_MARGIN = '200px';
+
+/**
+ * Calls back once the element comes near the viewport, so per-row work that costs
+ * a request (avatars, Hub details) does not fire on mount.
+ */
+export function nearViewport(node: HTMLElement, onNear: () => void): ActionReturn {
+ if (typeof IntersectionObserver === 'undefined') {
+ onNear();
+
+ return {};
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ observer.disconnect();
+ onNear();
+ }
+ },
+ { rootMargin: NEAR_VIEWPORT_MARGIN }
+ );
+
+ observer.observe(node);
+
+ return {
+ destroy() {
+ observer.disconnect();
+ }
+ };
+}
diff --git a/tools/ui/tests/client/components/DialogPanelWrapper.svelte b/tools/ui/tests/client/components/DialogPanelWrapper.svelte
new file mode 100644
index 000000000..a851e37c0
--- /dev/null
+++ b/tools/ui/tests/client/components/DialogPanelWrapper.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import DialogMcpServers from '$lib/components/app/dialogs/DialogMcpServers.svelte';
+ import DialogSettingsChat from '$lib/components/app/dialogs/DialogSettingsChat.svelte';
+
+ let { panel }: { panel: 'mcp' | 'settings' } = $props();
+</script>
+
+{#if panel === 'settings'}
+ <DialogSettingsChat open />
+{:else}
+ <DialogMcpServers open />
+{/if}
diff --git a/tools/ui/tests/client/components/ModelsManagerRowWrapper.svelte b/tools/ui/tests/client/components/ModelsManagerRowWrapper.svelte
new file mode 100644
index 000000000..d120a0b07
--- /dev/null
+++ b/tools/ui/tests/client/components/ModelsManagerRowWrapper.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import ModelsManagerModelRow from '$lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ option: ModelOption;
+ selected?: boolean;
+ onSelect: (option: ModelOption) => void;
+ }
+
+ let { onSelect, option, selected = false }: Props = $props();
+</script>
+
+<!-- The row actions mount a tooltip, which needs the provider +layout.svelte supplies. -->
+<Tooltip.Provider>
+ <ModelsManagerModelRow
+ isFavorite={() => false}
+ onDelete={() => {}}
+ {onSelect}
+ {option}
+ {selected}
+ />
+</Tooltip.Provider>
diff --git a/tools/ui/tests/client/components/ModelsManagerWrapper.svelte b/tools/ui/tests/client/components/ModelsManagerWrapper.svelte
new file mode 100644
index 000000000..926648cb1
--- /dev/null
+++ b/tools/ui/tests/client/components/ModelsManagerWrapper.svelte
@@ -0,0 +1,9 @@
+<script lang="ts">
+ import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+</script>
+
+<!-- The rows mount tooltips, which need the provider +layout.svelte supplies. -->
+<Tooltip.Provider>
+ <ModelsManager />
+</Tooltip.Provider>
diff --git a/tools/ui/tests/client/components/ModelsSelectorHookHarness.svelte b/tools/ui/tests/client/components/ModelsSelectorHookHarness.svelte
new file mode 100644
index 000000000..4734f18c9
--- /dev/null
+++ b/tools/ui/tests/client/components/ModelsSelectorHookHarness.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
+
+ interface Props {
+ currentModel?: () => string | null;
+ }
+
+ let { currentModel = () => null }: Props = $props();
+
+ // the caller seeds modelsStore.models before mounting, so the hook's own
+ // fetch() short-circuits on an already populated list; the hook keeps the getter
+ // and calls it later, so reading the prop here is intended
+ // svelte-ignore state_referenced_locally
+ const selector = useModelsSelector({ currentModel, onOpenChange: () => {} });
+
+ /** Every model id the selector lists, in the order the sections render them. */
+ export function listedIds(): string[] {
+ return [
+ ...selector.favoriteItems.map((item) => item.option.id),
+ ...selector.loadedItems.map((item) => item.option.id),
+ ...selector.groupedFilteredOptions.available.flatMap((group) =>
+ group.items.map((item) => item.option.id)
+ )
+ ];
+ }
+
+ export function shownModelId(): string | undefined {
+ return selector.getDisplayOption()?.id;
+ }
+
+ export function isEmpty(): boolean {
+ return selector.isEmpty;
+ }
+</script>
+
+<span data-testid="selector-hook"></span>
diff --git a/tools/ui/tests/client/context-panel-mobile.svelte.test.ts b/tools/ui/tests/client/context-panel-mobile.svelte.test.ts
new file mode 100644
index 000000000..a71816488
--- /dev/null
+++ b/tools/ui/tests/client/context-panel-mobile.svelte.test.ts
@@ -0,0 +1,42 @@
+// Guards the phone shape of the context panel: a phone shows it in a drawer, while a
+// wider screen keeps the card anchored above the dial.
+
+import ContextGaugePopup from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte';
+import { gaugePopup } from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/gauge-popup.svelte';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const PHONE = { height: 844, width: 390 };
+const DESKTOP = { height: 900, width: 1280 };
+
+describe('context panel on a phone', () => {
+ beforeEach(async () => {
+ gaugePopup.open = false;
+ });
+
+ afterEach(async () => {
+ gaugePopup.open = false;
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+ });
+
+ it('shows the panel in a drawer', async () => {
+ await page.viewport(PHONE.width, PHONE.height);
+
+ const screen = render(ContextGaugePopup);
+
+ gaugePopup.open = true;
+
+ await expect.element(screen.getByRole('heading', { name: 'Context' })).toBeVisible();
+ });
+
+ it('keeps the anchored card on a wider screen', async () => {
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+
+ const screen = render(ContextGaugePopup);
+
+ gaugePopup.open = true;
+
+ await expect.element(screen.getByRole('status')).toBeVisible();
+ });
+});
diff --git a/tools/ui/tests/client/dialog-focus-mobile.svelte.test.ts b/tools/ui/tests/client/dialog-focus-mobile.svelte.test.ts
new file mode 100644
index 000000000..6b5f0ec2d
--- /dev/null
+++ b/tools/ui/tests/client/dialog-focus-mobile.svelte.test.ts
@@ -0,0 +1,30 @@
+// Guards that opening a panel on a phone leaves its corner close alone: the tap that
+// opened the panel is already inside it, so a focus ring on that close reads as a stray
+// highlight.
+
+import DialogPanelWrapper from './components/DialogPanelWrapper.svelte';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const PHONE = { height: 844, width: 390 };
+const DESKTOP = { height: 900, width: 1280 };
+
+describe.each(['settings', 'mcp'] as const)('%s panel on a phone', (panel) => {
+ beforeEach(async () => {
+ await page.viewport(PHONE.width, PHONE.height);
+ });
+
+ afterEach(async () => {
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+ });
+
+ it('leaves the corner close unfocused', async () => {
+ const screen = render(DialogPanelWrapper, { panel });
+
+ await expect.element(screen.getByRole('dialog')).toBeVisible();
+
+ // the panel focuses its first button on open unless it is told not to
+ await expect.poll(() => document.activeElement?.tagName).not.toBe('BUTTON');
+ });
+});
diff --git a/tools/ui/tests/client/mcp-icon-proxy-url.svelte.test.ts b/tools/ui/tests/client/mcp-icon-proxy-url.svelte.test.ts
new file mode 100644
index 000000000..dcaad1095
--- /dev/null
+++ b/tools/ui/tests/client/mcp-icon-proxy-url.svelte.test.ts
@@ -0,0 +1,66 @@
+// MCP icons live on a remote origin, which the browser cannot load under the
+// UI's cross-origin isolation. When the user enabled the server's CORS proxy the
+// icons are fetched through it; with the proxy off the origin URL stands in and
+// the components fall back when it fails. Inline data: icons load under any
+// policy and are never proxied.
+
+import { mcpStore } from '$lib/stores/mcp/index.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
+import { buildProxiedIconUrl } from '$lib/utils/cors-proxy';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+// The favicon fallback resolves to the root domain of the server url, so the
+// icons of https://mcp.example.com/mcp are fetched from https://example.com.
+const FAVICON = 'https://example.com/favicon.ico';
+const DATA_ICON = 'data:image/png;base64,iVBORw0KGgo=';
+
+function mockProxyEnabled(enabled: boolean) {
+ Object.defineProperty(serverStore, 'props', {
+ configurable: true,
+ get: () => ({ cors_proxy_enabled: enabled }) as unknown as typeof serverStore.props
+ });
+}
+
+/** Target a proxied icon URL carries, or null when the URL is not proxied. */
+function proxiedTarget(iconUrl: string | null): string | null {
+ if (!iconUrl) return null;
+
+ const url = new URL(iconUrl, window.location.origin);
+
+ return url.pathname.endsWith('/cors-proxy') ? url.searchParams.get('url') : null;
+}
+
+describe('mcp server icon urls', () => {
+ beforeEach(() => {
+ settingsStore.updateConfig('mcpServers', '[]');
+ });
+
+ it('routes the favicon fallback through the proxy when it is enabled', () => {
+ mockProxyEnabled(true);
+
+ const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/mcp' });
+
+ expect(proxiedTarget(mcpStore.getServerFavicon(server.id))).toBe(FAVICON);
+ });
+
+ it('keeps the origin url when the proxy is off', () => {
+ mockProxyEnabled(false);
+
+ const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/mcp' });
+
+ expect(mcpStore.getServerFavicon(server.id)).toBe(FAVICON);
+ });
+
+ it('proxies an http icon url too', () => {
+ expect(proxiedTarget(buildProxiedIconUrl('http://example.com/favicon.ico', true))).toBe(
+ 'http://example.com/favicon.ico'
+ );
+ });
+
+ it('leaves a data url alone whichever way the proxy is set', () => {
+ expect(buildProxiedIconUrl(DATA_ICON, true)).toBe(DATA_ICON);
+ expect(buildProxiedIconUrl(DATA_ICON, false)).toBe(DATA_ICON);
+ expect(buildProxiedIconUrl(null, true)).toBeNull();
+ });
+});
diff --git a/tools/ui/tests/client/model-avatar-orgs.svelte.test.ts b/tools/ui/tests/client/model-avatar-orgs.svelte.test.ts
new file mode 100644
index 000000000..14206de20
--- /dev/null
+++ b/tools/ui/tests/client/model-avatar-orgs.svelte.test.ts
@@ -0,0 +1,60 @@
+// Guards which org a model avatar shows: a row that names neither flag follows the
+// family grouping (grouped lists leave the base org to the family heading), while a
+// caller that names one flag keeps control of what the avatar shows.
+
+import ModelAvatar from '$lib/components/app/models/ModelAvatar.svelte';
+import { SETTINGS_KEYS } from '$lib/constants';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
+import type { ModelOption } from '$lib/types/models';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+// the base-model tag resolves the base org without a Hub lookup
+const option: ModelOption = {
+ capabilities: [],
+ id: 'org/Qwen3-8B-GGUF:Q4_K_M',
+ model: 'org/Qwen3-8B-GGUF:Q4_K_M',
+ name: 'Qwen3-8B',
+ tags: ['base_model:Qwen/Qwen3-8B']
+};
+/** The main avatar plus the quantizer badge, when the badge differs from it. */
+const avatars = (container: HTMLElement) => container.querySelectorAll('img').length;
+
+function setGrouping(grouped: boolean) {
+ settingsStore.updateConfig(SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY, grouped);
+ settingsStore.updateConfig(SETTINGS_KEYS.USE_HUGGING_FACE_HUB, true);
+}
+
+beforeEach(() => {
+ setGrouping(false);
+});
+
+describe('model avatar orgs', () => {
+ it('shows the base org with the quantizer badge when the lists are flat', async () => {
+ const screen = render(ModelAvatar, { option });
+
+ expect(avatars(screen.container)).toBe(2);
+ });
+
+ it('leaves the base org to the family heading when the lists are grouped', async () => {
+ setGrouping(true);
+
+ const screen = render(ModelAvatar, { option });
+
+ expect(avatars(screen.container)).toBe(1);
+ });
+
+ it('keeps the base org of a caller that names it, grouped lists or not', async () => {
+ setGrouping(true);
+
+ const screen = render(ModelAvatar, { option, showBaseModelAvatar: true });
+
+ expect(avatars(screen.container)).toBe(2);
+ });
+
+ it('keeps the repo org of a caller that names it, flat lists or not', async () => {
+ const screen = render(ModelAvatar, { option, showRepoOrgAvatar: true });
+
+ expect(avatars(screen.container)).toBe(1);
+ });
+});
diff --git a/tools/ui/tests/client/models-manager-rows.svelte.test.ts b/tools/ui/tests/client/models-manager-rows.svelte.test.ts
new file mode 100644
index 000000000..7e44c3169
--- /dev/null
+++ b/tools/ui/tests/client/models-manager-rows.svelte.test.ts
@@ -0,0 +1,91 @@
+// Guards the row behaviour of the manager table: a row selects its model with a
+// click or from the keyboard, and a repo row toggles its quants the same way.
+
+import ModelsManagerRowWrapper from './components/ModelsManagerRowWrapper.svelte';
+import ModelsManagerRepoRow from '$lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte';
+import type { ModelQuantGroup } from '$lib/components/app/models/ModelsManager/utils';
+import { ModelGroupKind, ServerRole } from '$lib/enums';
+import { serverStore } from '$lib/stores/server.svelte';
+import type { ModelOption } from '$lib/types/models';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const option: ModelOption = {
+ capabilities: [],
+ id: 'org/Qwen3-8B:Q4_K_M',
+ model: 'org/Qwen3-8B:Q4_K_M',
+ name: 'Qwen3-8B'
+};
+const entry: ModelQuantGroup = {
+ base: option,
+ key: 'org/Qwen3-8B',
+ kind: ModelGroupKind.QUANTS,
+ quants: [
+ option,
+ { ...option, id: 'org/Qwen3-8B:Q8_0', model: 'org/Qwen3-8B:Q8_0', name: 'Qwen3-8B-Q8_0' }
+ ]
+};
+/** The name the row carries once the model id is split into its badges. */
+const ROW_NAME = /org\/Qwen3\s+8B/;
+
+describe('manager model row', () => {
+ let selected: ModelOption[] = [];
+
+ beforeEach(() => {
+ selected = [];
+ // the row only offers a load control on a router server
+ serverStore.role = ServerRole.ROUTER;
+ });
+
+ function row() {
+ return render(ModelsManagerRowWrapper, {
+ onSelect: (picked: ModelOption) => selected.push(picked),
+ option,
+ selected: false
+ });
+ }
+
+ it('selects the model from the row', async () => {
+ const screen = await row();
+
+ await screen.getByRole('button', { name: ROW_NAME }).click();
+
+ expect(selected).toEqual([option]);
+ });
+
+ it('selects the model from the keyboard', async () => {
+ const screen = await row();
+ const target = screen.getByRole('button', { name: ROW_NAME }).element();
+
+ target.focus();
+ await userEvent.keyboard('{Enter}');
+
+ expect(selected).toEqual([option]);
+ });
+
+ it('keeps the load and actions controls of the row', async () => {
+ const screen = await row();
+
+ await expect
+ .element(screen.getByRole('button', { exact: true, name: 'Model actions' }))
+ .toBeVisible();
+ expect(screen.container.querySelector('[aria-label="Load model"]')).not.toBeNull();
+ });
+});
+
+describe('manager repo row', () => {
+ it('toggles the quants from the row', async () => {
+ let toggles = 0;
+
+ const screen = await render(ModelsManagerRepoRow, {
+ entry,
+ expanded: true,
+ onToggle: () => (toggles += 1)
+ });
+
+ await screen.getByRole('button', { name: /2 quants available/ }).click();
+
+ expect(toggles).toBe(1);
+ });
+});
diff --git a/tools/ui/tests/client/models-manager-utils.svelte.test.ts b/tools/ui/tests/client/models-manager-utils.svelte.test.ts
new file mode 100644
index 000000000..42dd95ce5
--- /dev/null
+++ b/tools/ui/tests/client/models-manager-utils.svelte.test.ts
@@ -0,0 +1,164 @@
+// The manager table is built from the store's option list, so these run in the
+// browser project: how the quants fold into rows, what context a row reports and
+// how the tracked downloads become rows of their own.
+
+import {
+ downloadGroups,
+ groupModelQuants,
+ modelContextLength,
+ splitHiddenQuants
+} from '$lib/components/app/models/ModelsManager/utils';
+import { LOCAL_BACKEND_ID } from '$lib/constants';
+import { ModelGroupKind, ServerModelStatus } from '$lib/enums';
+import { modelsStore } from '$lib/stores/models/index.svelte';
+import type { ApiModelDataEntry, ModelOption } from '$lib/types';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+function option(model: string, id = model): ModelOption {
+ return { capabilities: [], id, model, name: model };
+}
+
+function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
+ return {
+ created: 0,
+ id,
+ in_cache: true,
+ object: 'model',
+ owned_by: 'llamacpp',
+ path: `/models/${id}`,
+ status: { value: status }
+ };
+}
+
+beforeEach(() => {
+ modelsStore.routerModels = [];
+});
+
+describe('groupModelQuants', () => {
+ it('folds the quants of one repo into a single entry', () => {
+ const groups = groupModelQuants([
+ option('org/Qwen3-8B:Q4_K_M'),
+ option('org/Qwen3-8B:Q8_0'),
+ option('org/Llama-3-8B:Q4_K_M')
+ ]);
+
+ expect(groups.map((group) => [group.key, group.kind, group.quants.length])).toEqual([
+ ['org/Qwen3-8B', ModelGroupKind.QUANTS, 2],
+ ['org/Llama-3-8B', ModelGroupKind.QUANTS, 1]
+ ]);
+ });
+
+ it('keeps the first quant of a repo as the row it renders', () => {
+ const groups = groupModelQuants([option('org/Qwen3-8B:Q4_K_M'), option('org/Qwen3-8B:Q8_0')]);
+
+ expect(groups[0].base.model).toBe('org/Qwen3-8B:Q4_K_M');
+ });
+
+ it('keeps rows of the same id apart as variants', () => {
+ const groups = groupModelQuants([
+ option('org/Qwen3-8B:Q4_K_M', 'first'),
+ option('org/Qwen3-8B:Q4_K_M', 'second')
+ ]);
+
+ expect(groups.map((group) => [group.key, group.kind])).toEqual([
+ ['org/Qwen3-8B::first', ModelGroupKind.VARIANTS],
+ ['org/Qwen3-8B::second', ModelGroupKind.VARIANTS]
+ ]);
+ });
+});
+
+describe('modelContextLength', () => {
+ it('reports the context of the listing', () => {
+ expect(modelContextLength({ ...option('org/Qwen3-8B:Q4_K_M'), contextLength: 8192 })).toBe(
+ 8192
+ );
+ });
+
+ it('reports nothing while no source knows the context', () => {
+ expect(modelContextLength(option('org/Qwen3-8B:Q4_K_M'))).toBeNull();
+ });
+});
+
+describe('downloadGroups', () => {
+ it('reuses the row of a download the router already lists', () => {
+ const listed = option('org/Qwen3-8B:Q4_K_M');
+ const groups = downloadGroups(
+ [{ isPaused: false, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' }],
+ [listed]
+ );
+
+ expect(groups[0].key).toBe('download::org/Qwen3-8B:Q4_K_M');
+ expect(groups[0].base).toBe(listed);
+ });
+
+ it('stands the download in for a row of its own when nothing is listed', () => {
+ const groups = downloadGroups(
+ [{ isPaused: true, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' }],
+ []
+ );
+
+ expect(groups[0].base).toMatchObject({
+ backendId: LOCAL_BACKEND_ID,
+ model: 'org/Qwen3-8B:Q4_K_M'
+ });
+ });
+});
+
+describe('modelsStore.isModelRunning', () => {
+ it('counts a loaded or sleeping model as running', () => {
+ modelsStore.routerModels = [
+ routerEntry('org/loaded', ServerModelStatus.LOADED),
+ routerEntry('org/sleeping', ServerModelStatus.SLEEPING)
+ ];
+
+ expect(modelsStore.isModelRunning('org/loaded')).toBe(true);
+ expect(modelsStore.isModelRunning('org/sleeping')).toBe(true);
+ });
+
+ it('does not count a loading, failed or unknown model as running', () => {
+ modelsStore.routerModels = [
+ routerEntry('org/loading', ServerModelStatus.LOADING),
+ routerEntry('org/failed', ServerModelStatus.FAILED)
+ ];
+
+ expect(modelsStore.isModelRunning('org/loading')).toBe(false);
+ expect(modelsStore.isModelRunning('org/failed')).toBe(false);
+ expect(modelsStore.isModelRunning('org/unknown')).toBe(false);
+ });
+});
+
+describe('splitHiddenQuants', () => {
+ const entry = (repo: string, quants: string[]) => ({
+ base: option(quants[0]),
+ key: repo,
+ kind: ModelGroupKind.QUANTS,
+ quants: quants.map((model) => option(model))
+ });
+
+ it('keeps the visible quants of a partly hidden repo in the local block', () => {
+ const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M', 'org/Qwen3-8B:Q8_0']);
+ const { hidden, local } = splitHiddenQuants([repo], (picked) => picked.model.endsWith('Q8_0'));
+
+ expect(hidden.map((group) => [group.key, group.quants.length])).toEqual([
+ ['org/Qwen3-8B::hidden', 1]
+ ]);
+ expect(local.map((group) => [group.key, group.quants.length])).toEqual([['org/Qwen3-8B', 1]]);
+ expect(local[0].base.model).toBe('org/Qwen3-8B:Q4_K_M');
+ });
+
+ it('moves a repo whose quants are all hidden', () => {
+ const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M', 'org/Qwen3-8B:Q8_0']);
+ const { hidden, local } = splitHiddenQuants([repo], () => true);
+
+ expect(hidden[0].quants).toHaveLength(2);
+ expect(local).toEqual([]);
+ });
+
+ it('leaves a repo with no hidden quant alone', () => {
+ const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M']);
+ const { hidden, local } = splitHiddenQuants([repo], () => false);
+
+ expect(hidden).toEqual([]);
+ expect(local[0].key).toBe('org/Qwen3-8B');
+ });
+});
diff --git a/tools/ui/tests/client/models-selector-hidden.svelte.test.ts b/tools/ui/tests/client/models-selector-hidden.svelte.test.ts
new file mode 100644
index 000000000..ebd427886
--- /dev/null
+++ b/tools/ui/tests/client/models-selector-hidden.svelte.test.ts
@@ -0,0 +1,77 @@
+// Guards the hidden-model rule of the selector: a hidden model leaves every
+// section, while the model a conversation already uses still resolves, since it
+// can be hidden and selected at the same time.
+
+import ModelsSelectorHookHarness from './components/ModelsSelectorHookHarness.svelte';
+import { ServerModelStatus, ServerRole } from '$lib/enums';
+import { modelsStore } from '$lib/stores/models/index.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
+import type { ApiModelDataEntry, ModelOption } from '$lib/types';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+function option(model: string): ModelOption {
+ return { capabilities: [], id: model, model, name: model };
+}
+
+function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
+ return {
+ created: 0,
+ id,
+ in_cache: true,
+ object: 'model',
+ owned_by: 'llamacpp',
+ path: `/models/${id}`,
+ status: { value: status }
+ };
+}
+
+const VISIBLE = option('org/visible:Q4_K_M');
+const HIDDEN = option('org/hidden:Q4_K_M');
+const HIDDEN_LOADED = option('org/hidden-loaded:Q4_K_M');
+const HIDDEN_FAVORITE = option('org/hidden-favorite:Q4_K_M');
+
+beforeEach(() => {
+ modelsStore.models = [VISIBLE, HIDDEN, HIDDEN_LOADED, HIDDEN_FAVORITE];
+ modelsStore.favoriteModelIds = new Set([HIDDEN_FAVORITE.model]);
+ modelsStore.hiddenModelIds = new Set([HIDDEN.id, HIDDEN_LOADED.id, HIDDEN_FAVORITE.id]);
+ modelsStore.routerModels = [routerEntry(HIDDEN_LOADED.model, ServerModelStatus.LOADED)];
+ serverStore.role = ServerRole.ROUTER;
+});
+
+describe('hidden models in the selector', () => {
+ it('lists what is not hidden', () => {
+ const screen = render(ModelsSelectorHookHarness);
+
+ expect(screen.component.listedIds()).toEqual([VISIBLE.id]);
+ });
+
+ it('keeps a hidden favorite and a hidden loaded model out of the sections', () => {
+ const screen = render(ModelsSelectorHookHarness);
+ const listed = screen.component.listedIds();
+
+ expect(listed).not.toContain(HIDDEN_FAVORITE.id);
+ expect(listed).not.toContain(HIDDEN_LOADED.id);
+ });
+
+ it('reports an empty list when everything is hidden', () => {
+ modelsStore.hiddenModelIds = new Set([
+ VISIBLE.id,
+ HIDDEN.id,
+ HIDDEN_LOADED.id,
+ HIDDEN_FAVORITE.id
+ ]);
+
+ const screen = render(ModelsSelectorHookHarness);
+
+ expect(screen.component.isEmpty()).toBe(true);
+ });
+
+ it('still resolves the model the conversation uses when it is hidden', () => {
+ const screen = render(ModelsSelectorHookHarness, {
+ currentModel: () => HIDDEN.model
+ });
+
+ expect(screen.component.shownModelId()).toBe(HIDDEN.id);
+ });
+});
diff --git a/tools/ui/tests/client/models-selector-mobile.svelte.test.ts b/tools/ui/tests/client/models-selector-mobile.svelte.test.ts
new file mode 100644
index 000000000..355410ad0
--- /dev/null
+++ b/tools/ui/tests/client/models-selector-mobile.svelte.test.ts
@@ -0,0 +1,56 @@
+// Guards the phone model picker: the selector serves a phone a bottom drawer
+// with the same search and sections the desktop menu shows.
+
+import ModelsSelector from '$lib/components/app/models/ModelsSelector/ModelsSelector.svelte';
+import { ServerRole } from '$lib/enums';
+import { ModelsService } from '$lib/services/models.service';
+import { modelsStore } from '$lib/stores/models/index.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
+import type { ModelOption } from '$lib/types/models';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const PHONE = { height: 844, width: 390 };
+const DESKTOP = { height: 900, width: 1280 };
+const option: ModelOption = {
+ capabilities: [],
+ id: 'org/Qwen3-8B:Q4_K_M',
+ model: 'org/Qwen3-8B:Q4_K_M',
+ name: 'Qwen3-8B'
+};
+const other: ModelOption = {
+ capabilities: [],
+ id: 'org/Qwen3-8B:Q8_0',
+ model: 'org/Qwen3-8B:Q8_0',
+ name: 'Qwen3-8B-Q8_0'
+};
+
+describe('model picker drawer on a phone', () => {
+ beforeEach(async () => {
+ serverStore.role = ServerRole.ROUTER;
+ // the picker mounts a fetch of its own: serve it the same two models, so
+ // the list it rebuilds does not clear the seeded one mid-test
+ vi.spyOn(ModelsService, 'list').mockResolvedValue({
+ data: [{ id: option.model }, { id: other.model }]
+ } as never);
+ modelsStore.models = [option, other];
+ await modelsStore.selectModelById(option.id);
+ await page.viewport(PHONE.width, PHONE.height);
+ });
+
+ afterEach(async () => {
+ modelsStore.models = [];
+ vi.restoreAllMocks();
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+ });
+
+ it('opens the picker in a drawer', async () => {
+ const screen = render(ModelsSelector);
+
+ await screen.getByRole('button').click();
+
+ await expect.element(screen.getByPlaceholder('Search models...')).toBeVisible();
+ await expect.element(screen.getByText('Local models')).toBeVisible();
+ });
+});
diff --git a/tools/ui/tests/client/reasoning-control-mobile.svelte.test.ts b/tools/ui/tests/client/reasoning-control-mobile.svelte.test.ts
new file mode 100644
index 000000000..9b1e4b28a
--- /dev/null
+++ b/tools/ui/tests/client/reasoning-control-mobile.svelte.test.ts
@@ -0,0 +1,41 @@
+// Guards the phone shape of the reasoning control: a phone picks the effort in a drawer,
+// since a dropdown of small rows is not a touch target.
+
+import ChatFormActionReasoning from '$lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte';
+import { ServerRole } from '$lib/enums';
+import { serverStore } from '$lib/stores/server.svelte';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { page } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const PHONE = { height: 844, width: 390 };
+const DESKTOP = { height: 900, width: 1280 };
+
+describe('reasoning control on a phone', () => {
+ beforeEach(async () => {
+ serverStore.role = ServerRole.ROUTER;
+ await page.viewport(PHONE.width, PHONE.height);
+ });
+
+ afterEach(async () => {
+ await page.viewport(DESKTOP.width, DESKTOP.height);
+ });
+
+ it('offers the effort levels in a drawer', async () => {
+ const screen = render(ChatFormActionReasoning);
+
+ await screen.getByRole('button', { name: 'Reasoning effort' }).click();
+
+ await expect.element(screen.getByRole('button', { name: /^Off$/ })).toBeVisible();
+ await expect.element(screen.getByRole('button', { name: /^Max/ })).toBeVisible();
+ });
+
+ it('closes the drawer once a level is picked', async () => {
+ const screen = render(ChatFormActionReasoning);
+
+ await screen.getByRole('button', { name: 'Reasoning effort' }).click();
+ await screen.getByRole('button', { name: 'Low' }).click();
+
+ await expect.poll(() => screen.getByRole('button', { name: 'Low' }).query()).toBeNull();
+ });
+});
diff --git a/tools/ui/tests/client/settings-model-org-name-key-migration.svelte.test.ts b/tools/ui/tests/client/settings-model-org-name-key-migration.svelte.test.ts
new file mode 100644
index 000000000..09c33888a
--- /dev/null
+++ b/tools/ui/tests/client/settings-model-org-name-key-migration.svelte.test.ts
@@ -0,0 +1,59 @@
+// Guards the rename of `showModelOrgNameInTrigger` onto `showModelOrgName`.
+// The persisted config always holds the legacy key, so a value it carries moves
+// over, while a config that already set the new key keeps its own value. The
+// legacy key is removed so it does not stay orphaned in localStorage.
+
+import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
+import { MigrationService } from '$lib/services/migration.service';
+import { settingsStore } from '$lib/stores/settings/index.svelte';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+const MODEL_ORG_NAME_KEY_MIGRATION_ID = 'model-org-name-key-v1';
+
+async function seedConfig(stored: Record<string, unknown>) {
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
+
+ const migration = MigrationService.getMigrations().find(
+ (m) => m.id === MODEL_ORG_NAME_KEY_MIGRATION_ID
+ );
+
+ await migration?.run();
+ settingsStore.initialize();
+}
+
+function persisted(): Record<string, unknown> {
+ return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
+}
+
+describe('showModelOrgNameInTrigger rename', () => {
+ beforeEach(() => {
+ localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
+ MigrationService.resetState();
+ settingsStore.initialize();
+ });
+
+ it('carries an enabled trigger over to the selector-wide key', async () => {
+ await seedConfig({ showModelOrgNameInTrigger: true });
+ expect(settingsStore.config.showModelOrgName).toBe(true);
+ });
+
+ it('carries a disabled trigger over to the selector-wide key', async () => {
+ await seedConfig({ showModelOrgNameInTrigger: false });
+ expect(settingsStore.config.showModelOrgName).toBe(false);
+ });
+
+ it('keeps an explicit user preference over the legacy key', async () => {
+ await seedConfig({ showModelOrgName: false, showModelOrgNameInTrigger: true });
+ expect(settingsStore.config.showModelOrgName).toBe(false);
+ });
+
+ it('drops the legacy key from the persisted config', async () => {
+ await seedConfig({ showModelOrgNameInTrigger: true });
+ expect(persisted().showModelOrgNameInTrigger).toBeUndefined();
+ });
+
+ it('leaves the selector-wide key on its default when nothing is stored', async () => {
+ await seedConfig({});
+ expect(settingsStore.config.showModelOrgName).toBe(true);
+ });
+});
diff --git a/tools/ui/tests/stories/ModelsManager.stories.svelte b/tools/ui/tests/stories/ModelsManager.stories.svelte
new file mode 100644
index 000000000..dd474cee5
--- /dev/null
+++ b/tools/ui/tests/stories/ModelsManager.stories.svelte
@@ -0,0 +1,110 @@
+<script lang="ts" module>
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+ import ModelsManagerModelsTable from '$lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte';
+ import type {
+ ModelQuantGroup,
+ ModelsTableGroup
+ } from '$lib/components/app/models/ModelsManager/utils';
+ import { MODELS_TABLE_GROUP_LABELS } from '$lib/constants';
+ import { ModelGroupKind, ModelsTableGroupKind } from '$lib/enums';
+ import { modelsStore } from '$lib/stores/models/index.svelte';
+ import type { ModelOption } from '$lib/types/models';
+
+ const { Story } = defineMeta({
+ parameters: {
+ layout: 'fullscreen'
+ },
+ title: 'Components/ModelsManager'
+ });
+
+ const option = (model: string, name: string, contextLength?: number): ModelOption => ({
+ capabilities: [],
+ contextLength,
+ id: model,
+ model,
+ name
+ });
+
+ const repo = (base: ModelOption, quants: ModelOption[]): ModelQuantGroup => ({
+ base,
+ key: base.model.split(':')[0],
+ kind: ModelGroupKind.QUANTS,
+ quants
+ });
+
+ const qwen = option('ggml-org/Qwen3.8-27B-GGUF:Q4_K_M', 'Qwen3.8-27B', 131_072);
+ const qwenQ8 = option('ggml-org/Qwen3.8-27B-GGUF:Q8_0', 'Qwen3.8-27B', 131_072);
+ const llama = option('unsloth/Llama-3.2-3B-GGUF:Q4_K_M', 'Llama-3.2-3B', 8192);
+ const hiddenRepo = option('org/Experimental-MoE-GGUF:Q4_K_M', 'Experimental-MoE', 32_768);
+
+ const section = (
+ kind: ModelsTableGroupKind,
+ items: ModelQuantGroup[],
+ key = kind
+ ): ModelsTableGroup => ({
+ items,
+ key,
+ kind,
+ label: MODELS_TABLE_GROUP_LABELS[kind]
+ });
+
+ // the rows read the store for favorites, hidden ids and the load status
+ modelsStore.models = [qwen, qwenQ8, llama, hiddenRepo];
+ modelsStore.favoriteModelIds = new Set([llama.model]);
+ modelsStore.hiddenModelIds = new Set([hiddenRepo.id]);
+ modelsStore.routerModels = [
+ {
+ created: 0,
+ id: qwen.model,
+ in_cache: true,
+ object: 'model',
+ owned_by: 'llamacpp',
+ path: `/models/${qwen.model}`,
+ status: { value: 'loaded' }
+ }
+ ];
+</script>
+
+<script lang="ts">
+ let selectedId = $state<string | null>(null);
+
+ const isFavorite = (picked: ModelOption) => modelsStore.favoriteModelIds.has(picked.model);
+</script>
+
+<Story name="Manager">
+ <div class="h-[36rem] bg-background p-4">
+ <ModelsManagerModelsTable
+ groups={[
+ section(ModelsTableGroupKind.LOADED, [repo(qwen, [qwen])]),
+ section(ModelsTableGroupKind.FAVORITES, [repo(llama, [llama])]),
+ section(ModelsTableGroupKind.LOCAL, [repo(qwen, [qwen, qwenQ8])]),
+ section(ModelsTableGroupKind.HIDDEN, [repo(hiddenRepo, [hiddenRepo])])
+ ]}
+ {isFavorite}
+ onSelect={(picked: ModelOption) => (selectedId = picked.id)}
+ {selectedId}
+ />
+ </div>
+</Story>
+
+<Story name="QuantsFolded">
+ <div class="h-[24rem] bg-background p-4">
+ <ModelsManagerModelsTable
+ groups={[section(ModelsTableGroupKind.LOCAL, [repo(qwen, [qwen, qwenQ8])])]}
+ {isFavorite}
+ onSelect={(picked: ModelOption) => (selectedId = picked.id)}
+ {selectedId}
+ />
+ </div>
+</Story>
+
+<Story name="Empty">
+ <div class="h-[24rem] bg-background p-4">
+ <ModelsManagerModelsTable
+ groups={[]}
+ {isFavorite}
+ onSelect={(picked: ModelOption) => (selectedId = picked.id)}
+ {selectedId}
+ />
+ </div>
+</Story>
diff --git a/tools/ui/tests/stories/ModelsSelector.stories.svelte b/tools/ui/tests/stories/ModelsSelector.stories.svelte
index e577226f2..5c59b21a2 100644
--- a/tools/ui/tests/stories/ModelsSelector.stories.svelte
+++ b/tools/ui/tests/stories/ModelsSelector.stories.svelte
@@ -1,10 +1,10 @@
<script lang="ts" module>
import { defineMeta } from '@storybook/addon-svelte-csf';
- import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
- import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
- import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
+ import ModelsSelectorList from '$lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte';
+ import ModelsSelectorOption from '$lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';
+ import type { GroupedModelOptions, ModelItem } from '$lib/utils';
const { Story } = defineMeta({
parameters: {
@@ -60,24 +60,23 @@
mockModelsStore();
const loadedModels: ModelItem[] = [
- { flatIndex: 0, option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta') },
- { flatIndex: 1, option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai') }
+ { option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta') },
+ { option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai') }
];
const favoriteModels: ModelItem[] = [
- { flatIndex: 2, option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen') },
- { flatIndex: 3, option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta') }
+ { option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen') },
+ { option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta') }
];
const availableModels: ModelItem[] = [
{
- flatIndex: 4,
option: mockModel('deepseek-coder-6.7b', 'DeepSeek-Coder-6.7B', 'deepseek', ['coding'])
},
- { flatIndex: 5, option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google') },
- { flatIndex: 6, option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft') },
- { flatIndex: 7, option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']) },
- { flatIndex: 8, option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel') }
+ { option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google') },
+ { option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft') },
+ { option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']) },
+ { option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel') }
];
const groupedOptions: GroupedModelOptions = {
@@ -102,9 +101,7 @@
items: [availableModels[4]],
orgName: 'intel'
}
- ],
- favorites: favoriteModels,
- loaded: loadedModels
+ ]
};
function handleSelect(modelId: string) {
@@ -124,8 +121,8 @@
<ModelsSelectorList
{activeId}
currentModel={selectedModel}
+ favorites={favoriteModels}
groups={groupedOptions}
- onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
onSelect={handleSelect}
/>
</div>
@@ -136,12 +133,8 @@
<ModelsSelectorList
activeId={null}
currentModel={null}
- groups={{
- available: [],
- favorites: [],
- loaded: [loadedModels[0]]
- }}
- onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
+ groups={{ available: [] }}
+ loaded={[loadedModels[0]]}
onSelect={handleSelect}
/>
</div>
@@ -152,12 +145,8 @@
<ModelsSelectorList
activeId={null}
currentModel={null}
- groups={{
- available: [],
- favorites: favoriteModels,
- loaded: []
- }}
- onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
+ favorites={favoriteModels}
+ groups={{ available: [] }}
onSelect={handleSelect}
/>
</div>
diff --git a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
index 826e0d4aa..c56bfd767 100644
--- a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
+++ b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
@@ -2,25 +2,8 @@
import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
- import { ServerRole } from '$lib/enums';
- import { serverStore } from '$lib/stores';
- import type { ApiLlamaCppServerProps } from '$lib/types';
import { expect, screen, waitFor } from 'storybook/test';
- /**
- * The add menu mounts the reasoning submenu only outside router mode, and the
- * dev server proxies /props to whichever server happens to be running, so pin
- * the mode this story asserts instead of inheriting it from the environment.
- */
- function pinSingleModelMode(): void {
- serverStore.props = {
- ...(serverStore.props ?? {}),
- role: ServerRole.MODEL
- } as ApiLlamaCppServerProps;
-
- serverStore.role = ServerRole.MODEL;
- }
-
const { Story } = defineMeta({
component: ChatScreenForm,
parameters: {
@@ -55,8 +38,6 @@
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
name="AddDropdownFocusesFirstEnabled"
play={async ({ canvas, userEvent }) => {
- pinSingleModelMode();
-
const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
trigger.focus();
@@ -64,7 +45,7 @@
await screen.findByRole('menu');
await waitFor(() => {
- expect(document.activeElement).toHaveTextContent('Reasoning');
+ expect(document.activeElement).toHaveTextContent('Add files');
});
}}
/>
diff --git a/tools/ui/tests/stories/a11y/ModelsManager.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ModelsManager.a11y.stories.svelte
new file mode 100644
index 000000000..3b981d063
--- /dev/null
+++ b/tools/ui/tests/stories/a11y/ModelsManager.a11y.stories.svelte
@@ -0,0 +1,52 @@
+<script lang="ts" module>
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+ import ModelsManagerModelRow from '$lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte';
+ import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
+ import type { ModelOption } from '$lib/types/models';
+ import { expect } from 'storybook/test';
+
+ const { Story } = defineMeta({
+ parameters: {
+ layout: 'centered'
+ },
+ tags: ['!dev'],
+ title: 'Components/ModelsManager/Accessibility'
+ });
+
+ const option: ModelOption = {
+ capabilities: [],
+ id: 'org/Qwen3-8B:Q4_K_M',
+ model: 'org/Qwen3-8B:Q4_K_M',
+ name: 'Qwen3-8B'
+ };
+
+ const ROW_NAME = /org\/Qwen3\s+8B/;
+</script>
+
+<script lang="ts">
+ let selected = $state(false);
+</script>
+
+<!-- The row answers Enter and Space on its own, so a keyboard user selects a model
+ without reaching for the pointer. -->
+<Story
+ name="RowKeyboardSelect"
+ play={async ({ canvas, userEvent }) => {
+ const row = await canvas.findByRole('button', { name: ROW_NAME });
+
+ row.focus();
+ await userEvent.keyboard('{Enter}');
+
+ await expect(row).toHaveAttribute('aria-pressed', 'true');
+ }}
+>
+ <div class={MODEL_ROW_GRID_CLASS + ' w-[40rem]'}>
+ <ModelsManagerModelRow
+ isFavorite={() => false}
+ onDelete={() => {}}
+ onSelect={() => (selected = true)}
+ {option}
+ {selected}
+ />
+ </div>
+</Story>
diff --git a/tools/ui/tests/unit/formatters.test.ts b/tools/ui/tests/unit/formatters.test.ts
new file mode 100644
index 000000000..3c4e69123
--- /dev/null
+++ b/tools/ui/tests/unit/formatters.test.ts
@@ -0,0 +1,24 @@
+import { formatContextLength, formatParameters } from '$lib/utils/formatters';
+import { describe, expect, it } from 'vitest';
+
+describe('formatContextLength', () => {
+ it('shows a dash when no value is known', () => {
+ expect(formatContextLength([])).toBe('-');
+ });
+
+ it('prints the value with thousands separators', () => {
+ expect(formatContextLength([8192])).toBe('8,192 tokens');
+ });
+
+ it('pairs the configured context with the supported one', () => {
+ expect(formatContextLength([8192, 131072])).toBe('8,192 / 131,072 tokens');
+ });
+});
+
+describe('formatParameters', () => {
+ it('shortens large parameter counts', () => {
+ expect(formatParameters(35_000_000_000)).toBe('35B');
+ expect(formatParameters(3_000_000)).toBe('3.00M');
+ expect(formatParameters(512)).toBe('512');
+ });
+});
diff --git a/tools/ui/tests/unit/model-families.test.ts b/tools/ui/tests/unit/model-families.test.ts
new file mode 100644
index 000000000..81efcdb4c
--- /dev/null
+++ b/tools/ui/tests/unit/model-families.test.ts
@@ -0,0 +1,54 @@
+import { groupModelFamilies, modelFamilyKey } from '$lib/utils/model-families';
+import { describe, expect, it } from 'vitest';
+
+describe('modelFamilyKey', () => {
+ it('folds the leading letters of the model name', () => {
+ expect(modelFamilyKey('Qwen3.5-27B')).toBe('Qwen');
+ expect(modelFamilyKey('Qwen3.8-27B-GGUF')).toBe('Qwen');
+ expect(modelFamilyKey('Llama-3.2-3B-Instruct')).toBe('Llama');
+ });
+
+ it('uses the model and not the org', () => {
+ expect(modelFamilyKey('ggml-org/Qwen3.8-27B-GGUF')).toBe('Qwen');
+ });
+
+ it('falls back to the first segment when the name starts with a digit', () => {
+ expect(modelFamilyKey('3.5-MoE-instruct')).toBe('3');
+ });
+
+ it('keeps a name with no letters whole', () => {
+ expect(modelFamilyKey('_internal')).toBe('_internal');
+ expect(modelFamilyKey('-leading-dash')).toBe('-leading-dash');
+ });
+});
+
+describe('groupModelFamilies', () => {
+ const rows = [
+ { name: 'ggml-org/Qwen3.5-9B-GGUF' },
+ { name: 'unsloth/Qwen3.8-27B-GGUF' },
+ { name: 'meta-llama/Llama-3.2-3B-Instruct' },
+ { name: 'org/tinyllama-1.1B' }
+ ];
+ const modelOf = (row: { name: string }) => row.name;
+
+ it('collects the entries of one family', () => {
+ const families = groupModelFamilies(rows, modelOf);
+
+ expect(families.map((family) => [family.key, family.entries.length])).toEqual([
+ ['Qwen', 2],
+ ['Llama', 1],
+ ['tinyllama', 1]
+ ]);
+ });
+
+ it('labels a family with its key', () => {
+ expect(groupModelFamilies(rows, modelOf)[0].label).toBe('Qwen');
+ });
+
+ it('keeps the input order of the entries', () => {
+ expect(groupModelFamilies(rows, modelOf)[0].entries.map(modelOf)).toEqual([
+ 'ggml-org/Qwen3.5-9B-GGUF',
+ 'unsloth/Qwen3.8-27B-GGUF'
+ ]);
+ });
+});
diff --git a/tools/ui/tests/unit/model-list.test.ts b/tools/ui/tests/unit/model-list.test.ts
new file mode 100644
index 000000000..3037cdaa1
--- /dev/null
+++ b/tools/ui/tests/unit/model-list.test.ts
@@ -0,0 +1,104 @@
+import type { ModelOption } from '$lib/types/models';
+import {
+ filterModelOptions,
+ type GroupedModelOptions,
+ groupModelOptions,
+ windowLocalGroups
+} from '$lib/utils/model-list';
+import { describe, expect, it } from 'vitest';
+
+function option(model: string, orgName: string | null = null): ModelOption {
+ return {
+ capabilities: [],
+ id: model,
+ model,
+ name: model,
+ parsedId: {
+ activatedParams: null,
+ modelName: model,
+ orgName,
+ params: null,
+ quantization: null,
+ raw: model,
+ sidecar: null,
+ tags: []
+ }
+ };
+}
+
+const row = (model: string) => ({ option: option(model) });
+
+function groups(): GroupedModelOptions {
+ return {
+ available: [
+ { items: [row('org/a'), row('org/b'), row('org/c')], orgName: 'org' },
+ { items: [row('other/d')], orgName: 'other' }
+ ]
+ };
+}
+
+describe('windowLocalGroups', () => {
+ it('fills the budget in listed order', () => {
+ const windowed = windowLocalGroups(groups(), 2);
+
+ expect(windowed.available.flatMap((group) => group.items).map((item) => item.option.model)) //
+ .toEqual(['org/a', 'org/b']);
+ expect(windowed.shown).toBe(2);
+ });
+
+ it('drops an org whose whole budget is spent', () => {
+ const windowed = windowLocalGroups(groups(), 3);
+
+ expect(windowed.available.map((group) => group.orgName)).toEqual(['org']);
+ expect(windowed.shown).toBe(3);
+ });
+
+ it('keeps every group when the budget covers the list', () => {
+ const windowed = windowLocalGroups(groups(), 99);
+
+ expect(windowed.available.map((group) => group.orgName)).toEqual(['org', 'other']);
+ expect(windowed.shown).toBe(4);
+ });
+
+ it('shows nothing on a zero budget', () => {
+ const windowed = windowLocalGroups(groups(), 0);
+
+ expect(windowed.available).toEqual([]);
+ expect(windowed.shown).toBe(0);
+ });
+});
+
+describe('filterModelOptions', () => {
+ const options = [
+ { ...option('org/Qwen3-8B'), aliases: ['qwen'], tags: ['vision'] },
+ option('org/Llama-3-8B')
+ ];
+
+ it('keeps every option without a term', () => {
+ expect(filterModelOptions(options, ' ')).toEqual(options);
+ });
+
+ it('matches the model id and the name', () => {
+ expect(filterModelOptions(options, 'llama').map((item) => item.model)).toEqual([
+ 'org/Llama-3-8B'
+ ]);
+ });
+
+ it('matches aliases and tags', () => {
+ expect(filterModelOptions(options, 'qwen').map((item) => item.model)).toEqual(['org/Qwen3-8B']);
+ });
+});
+
+describe('groupModelOptions', () => {
+ it('groups the options by org, unnamed last', () => {
+ const grouped = groupModelOptions([
+ option('org/a', 'org'),
+ option('org/b', 'org'),
+ option('other/c', 'other'),
+ option('plain')
+ ]);
+
+ expect(grouped.available.map((group) => group.orgName)).toEqual(['org', 'other', null]);
+ expect(grouped.available[0].items.map((item) => item.option.model)).toEqual(['org/a', 'org/b']);
+ });
+});