From 85d6ac2ace44c5c7b9aca366210ac31ef8bc0e6e Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 22 Oct 2026 12:58:18 +0200 Subject: [PATCH 1/6] ui : move models manager to its own route, keep Discover as a dialog Models now lives at #/models instead of a dialog. Discover opens as a standalone dialog over it, and leaving the page returns to the chat it was opened from. Assisted by: Claude (Opus 5.5 Medium) --- .../app/chat/ChatForm/ChatForm.svelte | 7 +- .../app/dialogs/DialogDiscoverModels.svelte | 53 +++++ .../app/dialogs/DialogManageModels.svelte | 207 ------------------ .../src/lib/components/app/dialogs/index.ts | 4 +- .../models/ModelsManager/ModelsManager.svelte | 6 +- .../ModelsManagerTableToolbar.svelte | 8 +- .../ModelsSelectorDropdown.svelte | 2 +- .../SidebarNavigation.svelte | 4 +- .../ui/src/lib/constants/routes.constants.ts | 2 + tools/ui/src/lib/constants/ui.constants.ts | 4 +- tools/ui/src/lib/enums/index.ts | 1 - tools/ui/src/lib/enums/ui.enums.ts | 6 - .../lib/hooks/use-models-selector.svelte.ts | 2 +- tools/ui/src/lib/stores/ui.svelte.ts | 32 ++- tools/ui/src/routes/models/+page.svelte | 46 ++++ tools/ui/src/routes/models/+page.ts | 8 + 16 files changed, 151 insertions(+), 241 deletions(-) create mode 100644 tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte delete mode 100644 tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte create mode 100644 tools/ui/src/routes/models/+page.svelte create mode 100644 tools/ui/src/routes/models/+page.ts 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 397ddc322b1a..91edac1d8b88 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -477,12 +477,9 @@ 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. + // A flow outside the chat, e.g. the models page returning to it, asks for the + // composer back. Focus on a frame of its own, once the route has settled. $effect(() => { - if (uiStore.manageModelsOpen) return; - if (!uiStore.composerFocusRequested) return; uiStore.composerFocusRequested = false; diff --git a/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte new file mode 100644 index 000000000000..0aa0e5214ef1 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte @@ -0,0 +1,53 @@ + + + + event.preventDefault()} + > + + + + + Discover + + + + Find models on Hugging Face and download them to the server. + + + +
+ +
+
+
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte deleted file mode 100644 index fbe711c25cd0..000000000000 --- a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte +++ /dev/null @@ -1,207 +0,0 @@ - - - - event.preventDefault()} - > - - - - - - - - - - - - - {title} - - - - Browse, load and delete the models the server can serve. - - - -
- {#if shownView === ModelsDialogView.MANAGE} - - {#snippet toolbarEnd()} - {#if discoverEnabled} - - {/if} - {/snippet} - - {:else if shownView === ModelsDialogView.DISCOVER} -
- -
- {/if} -
-
-
- - diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts index 45cc9b2e6b98..5c6a56f00e79 100644 --- a/tools/ui/src/lib/components/app/dialogs/index.ts +++ b/tools/ui/src/lib/components/app/dialogs/index.ts @@ -500,5 +500,5 @@ 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'; +/** **DialogDiscoverModels** - shell for the Hugging Face model discovery view. */ +export { default as DialogDiscoverModels } from './DialogDiscoverModels.svelte'; diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte index 9853bafe51a0..af40760172d4 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte @@ -254,10 +254,10 @@ await modelsStore.status.load(option.model); } - /** The chat sits behind the dialog, so the dialog closes and the composer takes focus. */ + /** Leave the models page for the chat it was opened from, with the composer focused. */ function returnToChat(): void { - uiStore.manageModelsOpen = false; uiStore.requestComposerFocus(); + void uiStore.closeModelsManager(); } /** Load the model when the server can take load requests, without waiting for it. */ @@ -307,7 +307,7 @@ /> -
(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. + // The search takes the focus when the models page opens: the input mounts a frame + // or two after the page (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; + if (!uiStore.isModelsPageOpen) return; let frames = 0; let handle = requestAnimationFrame(function focusFilter() { diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte index 750e5f6a77ac..d9e6be298302 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte @@ -91,7 +91,7 @@ function handleManageModels() { open = false; - // let the menu finish closing before the dialog takes focus + // let the menu finish closing before the models page takes focus setTimeout(() => (onManageModels ? onManageModels() : uiStore.openModelsManager()), 0); } 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 2ce4824cf930..828c63623717 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte @@ -5,7 +5,7 @@ import { ActionIcon, DialogConversationRename, - DialogManageModels, + DialogDiscoverModels, DialogMcpServers, DialogModelInformation, DialogSettingsChat, @@ -469,7 +469,7 @@ - + diff --git a/tools/ui/src/lib/constants/routes.constants.ts b/tools/ui/src/lib/constants/routes.constants.ts index 1f518a758620..0731d05b70b2 100644 --- a/tools/ui/src/lib/constants/routes.constants.ts +++ b/tools/ui/src/lib/constants/routes.constants.ts @@ -13,6 +13,8 @@ export const ROUTES = { CHAT: '#/chat', /** MCP servers. */ MCP_SERVERS: '#/mcp-servers', + /** Models manager. */ + MODELS: '#/models', /** Search — mobile-only full-page conversation search. */ SEARCH: '#/search', /** Root — start of the app. */ diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts index 946793978b0b..837b8c5717c4 100644 --- a/tools/ui/src/lib/constants/ui.constants.ts +++ b/tools/ui/src/lib/constants/ui.constants.ts @@ -7,9 +7,6 @@ import type { DesktopIconStripItem } from '$lib/types'; /** Delay before a copied affordance resets from its check to the copy icon. */ export const COPY_FEEDBACK_RESET_MS = 1500; -/** How long a models dialog view fades out before the next one fades in. */ -export const MODELS_DIALOG_VIEW_FADE_MS = 150; - export const FORK_TREE_DEPTH_PADDING = 8; export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message'; @@ -83,6 +80,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [ { icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' }, { action: SidebarAction.MANAGE_MODELS, + activeRouteId: '/models', icon: MODEL_ICON, tooltip: 'Models' }, diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts index 9ab0be32e809..142962826d9e 100644 --- a/tools/ui/src/lib/enums/index.ts +++ b/tools/ui/src/lib/enums/index.ts @@ -95,7 +95,6 @@ export { ColorMode, HtmlInputType, McpPromptVariant, - ModelsDialogView, ScrollCarouselVariant, SidebarAction, TooltipSide, diff --git a/tools/ui/src/lib/enums/ui.enums.ts b/tools/ui/src/lib/enums/ui.enums.ts index 599fc2128beb..20cfe47ffaca 100644 --- a/tools/ui/src/lib/enums/ui.enums.ts +++ b/tools/ui/src/lib/enums/ui.enums.ts @@ -32,12 +32,6 @@ export enum SidebarAction { /** * MCP prompt display variant */ -/** View the models dialog renders. */ -export enum ModelsDialogView { - DISCOVER = 'discover', - MANAGE = 'manage' -} - export enum McpPromptVariant { ATTACHMENT = 'attachment', MESSAGE = 'message' 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 35a85dcd876c..deecbe34f54d 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -116,7 +116,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele // the manager takes the focus, so the selector closes instead of sitting behind it $effect(() => { - if (!uiStore.manageModelsOpen) return; + if (!uiStore.isModelsPageOpen) return; opts.onOpenChange?.(false); }); diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts index bd9e40323763..e0f60f71819a 100644 --- a/tools/ui/src/lib/stores/ui.svelte.ts +++ b/tools/ui/src/lib/stores/ui.svelte.ts @@ -6,40 +6,60 @@ * controls and the chat tab bar reacts to). */ +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import { ROUTES } from '$lib/constants'; 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); - /** Repo the Discover view selects when it opens, set by a model row. */ + /** Repo the Discover dialog selects when it opens, set by a model row. */ discoverModelFocus = $state(null); + /** Open state of the Discover dialog. */ discoverModelsOpen = $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(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(null); + /** Route the models page returns to, the one it was opened from. */ + modelsReturnRoute: string = ROUTES.START; + + /** Whether the models manager route is the current page. */ + get isModelsPageOpen(): boolean { + return page.route.id === '/models'; + } + + /** Leave the models page for the route it was opened from. */ + async closeModelsManager(): Promise { + if (!this.isModelsPageOpen) return; + + await goto(this.modelsReturnRoute); + } /** Open the model information dialog, a phone's stand-in for the manager pane. */ openModelInformation(option: ModelOption): void { this.modelInformation = option; } - /** Open the models dialog on Discover, focused on one repo. */ + /** Open the Discover dialog, focused on one repo. */ openModelsDiscover(focus?: string): void { this.discoverModelFocus = focus ?? null; this.discoverModelsOpen = true; } - /** Open the models manager, optionally focused on one model. */ + /** Navigate to the models manager, optionally focused on one model. */ openModelsManager(focus?: string): void { this.manageModelFocus = focus ?? null; - this.manageModelsOpen = true; + + if (this.isModelsPageOpen) return; + + this.modelsReturnRoute = page.url.hash || ROUTES.START; + void goto(ROUTES.MODELS); } /** Ask the composer to take focus, e.g. after a dialog closes onto the chat. */ diff --git a/tools/ui/src/routes/models/+page.svelte b/tools/ui/src/routes/models/+page.svelte new file mode 100644 index 000000000000..7b514734e627 --- /dev/null +++ b/tools/ui/src/routes/models/+page.svelte @@ -0,0 +1,46 @@ + + + + Models - {APP_NAME} + + +
+

+ + + Models +

+ + + {#snippet toolbarEnd()} + {#if discoverEnabled} + + {/if} + {/snippet} + +
diff --git a/tools/ui/src/routes/models/+page.ts b/tools/ui/src/routes/models/+page.ts new file mode 100644 index 000000000000..8c3559446844 --- /dev/null +++ b/tools/ui/src/routes/models/+page.ts @@ -0,0 +1,8 @@ +import type { PageLoad } from './$types'; +import { initStores } from '$lib/stores/init'; +import { validateApiKey } from '$lib/utils'; + +export const load: PageLoad = async ({ fetch }) => { + await initStores(); + await validateApiKey(fetch); +}; From 1d93f71f873dc56675f0fb6c0dc85e14af06993d Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 22 Oct 2026 13:14:54 +0200 Subject: [PATCH 2/6] ui : polish the models page layout and filters Give the models page a page header with the Discover action, calmer column labels, tinted section and family headers, and an empty state. Load the model list on a direct visit, slide the detail pane over the table below xl, and draw the filter toggles as one box matching the context select, with room for their shadow. Assisted by: Claude (Opus 5.5 Medium) --- .../models/ModelsManager/ModelsManager.svelte | 7 +- .../ModelsManager/ModelsManagerFilters.svelte | 8 ++- .../ModelsManagerModelsTable.svelte | 69 ++++++++++++------- .../lib/constants/css-classes.constants.ts | 12 +++- tools/ui/src/routes/models/+page.svelte | 59 ++++++++-------- 5 files changed, 94 insertions(+), 61 deletions(-) diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte index af40760172d4..df7f2b55b581 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte @@ -308,14 +308,15 @@
+ repeat what the pane's own header says. Below xl the table has no room to give + up, so the pane slides over its end instead of pushing it -->
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte index 8b4c3bf37e62..99fe920725ff 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte @@ -5,6 +5,7 @@ import { CAPABILITY_ICONS, CAPABILITY_LABELS, + FILTER_TOGGLE_GROUP_CLASS, FILTER_TOGGLE_ITEM_CLASS, FILTER_TRIGGER_CLASS, MODALITY_FLAG_KEYS, @@ -89,10 +90,12 @@ + action would otherwise squeeze the filters out of sight. The scroll box clips on + both axes, so it gets room for the controls' shadows and gives it back outside --> @@ -115,11 +118,10 @@ {#each toggles as toggle (toggle.value)} toggleSort(key)} title={sortTitle(key, label)} type="button" @@ -270,7 +272,7 @@ {@const countLabel = `${family.entries.length} model${family.entries.length === 1 ? '' : 's'}`}
handleFamilyKeydown(event, toggle)} role="button" @@ -286,7 +288,7 @@ {family.label} - {countLabel} + {countLabel} +
+ entry.key} + more={showMore} + onCollapsedChange={(key, collapsed) => + modelsStore.setGroupCollapsed(group.key, key, collapsed)} + sectionWindow={MODEL_ROW_WINDOW} + stickyClass="bg-muted/25 backdrop-blur-lg" + stickyStyle={MODELS_TABLE_GROUP_STICKY_OFFSET} + weightOf={(entry) => entry.quants.length} + /> +
{/if} {/each} {#if isEmpty} -

- {hasFilters ? 'No models match these filters.' : 'No models found.'} -

+ + + + + + + {hasFilters ? 'No matching models' : 'No models yet'} + + + {hasFilters + ? 'Try a different search or clear the filters.' + : 'Models the server can run show up here.'} + + + {/if}
diff --git a/tools/ui/src/lib/constants/css-classes.constants.ts b/tools/ui/src/lib/constants/css-classes.constants.ts index 045dce6ac1d6..a8f00df4cd3d 100644 --- a/tools/ui/src/lib/constants/css-classes.constants.ts +++ b/tools/ui/src/lib/constants/css-classes.constants.ts @@ -54,9 +54,17 @@ export const FILTER_TRIGGER_CLASS = ` ${INPUT_CLASSES} `; -/** One filter toggle of the models manager toolbar. */ +/** The filter toggles' shared box: the same fill, border and shadow as a filter trigger. */ +export const FILTER_TOGGLE_GROUP_CLASS = ` + h-8 + overflow-hidden + divide-x divide-border/30 dark:divide-border/20 + ${INPUT_CLASSES} +`; + +/** One filter toggle of the models manager toolbar; its group draws the box. */ 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!'; + 'h-full rounded-none hover:bg-muted/80 dark:hover:bg-muted 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 diff --git a/tools/ui/src/routes/models/+page.svelte b/tools/ui/src/routes/models/+page.svelte index 7b514734e627..d32858dc5edc 100644 --- a/tools/ui/src/routes/models/+page.svelte +++ b/tools/ui/src/routes/models/+page.svelte @@ -1,7 +1,7 @@ @@ -20,27 +23,29 @@ Models - {APP_NAME} -
-

- - - Models -

- - - {#snippet toolbarEnd()} - {#if discoverEnabled} - - {/if} - {/snippet} - +
+
+
+

Models

+ +

+ Load, configure and remove the models this server can run. +

+
+ + {#if discoverEnabled} + + {/if} +
+ +
From a206ae57064fd426b70d943c99c968940a14e867 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 22 Oct 2026 13:20:13 +0200 Subject: [PATCH 3/6] ui : round the models table family headers Assisted by: Claude (Opus 5.5 Medium) --- .../app/models/ModelsManager/ModelsManagerModelsTable.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte index 26cd0bd634ce..e19d5c0ab714 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte @@ -408,7 +408,7 @@ onCollapsedChange={(key, collapsed) => modelsStore.setGroupCollapsed(group.key, key, collapsed)} sectionWindow={MODEL_ROW_WINDOW} - stickyClass="bg-muted/25 backdrop-blur-lg" + stickyClass="rounded-lg bg-muted/25 backdrop-blur-lg" stickyStyle={MODELS_TABLE_GROUP_STICKY_OFFSET} weightOf={(entry) => entry.quants.length} /> From f64baf3a36174c64312cb337795635197ced6595 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 22 Oct 2026 16:09:28 +0200 Subject: [PATCH 4/6] ui : fix models page navigation and Discover gating Record the models page's way back after every navigation, so Back, a typed URL or a reload no longer return to a stale chat. Escape closes the detail pane, then leaves the page. Gate every Discover entry point on one shared check, recheck the API key on /models, skip the duplicate router fetch on a direct load, and share the route load between pages. Assisted by: Claude (Opus 5.5 Medium) --- .../app/dialogs/DialogDiscoverModels.svelte | 18 ++++++------ .../models/ModelsManager/ModelsManager.svelte | 28 +++++++++++++++++++ ...elsManagerModelConfigurationActions.svelte | 8 ++---- .../ModelsManagerTableToolbar.svelte | 4 +-- .../app/models/ModelsManager/row-actions.ts | 23 +++++++++------ .../ui/src/lib/constants/routes.constants.ts | 3 ++ tools/ui/src/lib/constants/ui.constants.ts | 3 +- .../lib/hooks/use-models-selector.svelte.ts | 7 ----- tools/ui/src/lib/stores/init.ts | 9 ++++++ tools/ui/src/lib/stores/ui.svelte.ts | 24 ++++++++++++---- tools/ui/src/routes/(chat)/+page.ts | 10 ++----- tools/ui/src/routes/(chat)/chat/[id]/+page.ts | 10 ++----- tools/ui/src/routes/+layout.svelte | 11 ++++++-- tools/ui/src/routes/models/+page.svelte | 26 ++++++++--------- tools/ui/src/routes/models/+page.ts | 8 ++---- 15 files changed, 115 insertions(+), 77 deletions(-) diff --git a/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte index 0aa0e5214ef1..ed3d651d26da 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte @@ -1,8 +1,8 @@ + +
{ - if (!uiStore.isModelsPageOpen) return; - let frames = 0; let handle = requestAnimationFrame(function focusFilter() { if (filterInput) { 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 index 3407634ec6f1..f3c4363c7c6d 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts +++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts @@ -13,13 +13,18 @@ export function modelRowActions( /** Download state, when the row stands for a tracked download. */ download?: ModelRowDownloadState | null ) { - const viewInDiscover = { - icon: Compass, - label: 'See in Discover', - // the details pane is keyed by repo, not by `:` - onclick: () => uiStore.openModelsDiscover(repoOf(option.model)), - separator: true - }; + // the details pane is keyed by repo, not by `:`; the entry exists only + // while Discover is on + const viewInDiscover = uiStore.isDiscoverEnabled + ? [ + { + icon: Compass, + label: 'See in Discover', + onclick: () => uiStore.openModelsDiscover(repoOf(option.model)), + separator: true + } + ] + : []; // a tracked download is paused, resumed or dropped: it is not loaded, hidden or // drafted. An in-flight row cancels, which stops the download and removes the @@ -40,7 +45,7 @@ export function modelRowActions( separator: true, variant: 'destructive' as const }, - viewInDiscover + ...viewInDiscover ]; } @@ -63,6 +68,6 @@ export function modelRowActions( onclick: () => modelsStore.toggleHidden(option.id), separator: true }, - viewInDiscover + ...viewInDiscover ]; } diff --git a/tools/ui/src/lib/constants/routes.constants.ts b/tools/ui/src/lib/constants/routes.constants.ts index 0731d05b70b2..9a475a1188ca 100644 --- a/tools/ui/src/lib/constants/routes.constants.ts +++ b/tools/ui/src/lib/constants/routes.constants.ts @@ -20,3 +20,6 @@ export const ROUTES = { /** Root — start of the app. */ START: '#/' } as const; + +/** SvelteKit route id of the models manager, as `page.route.id` reports it. */ +export const MODELS_ROUTE_ID = '/models'; diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts index 837b8c5717c4..9af1424fa25a 100644 --- a/tools/ui/src/lib/constants/ui.constants.ts +++ b/tools/ui/src/lib/constants/ui.constants.ts @@ -1,4 +1,5 @@ import { MODEL_ICON } from './icons.constants'; +import { MODELS_ROUTE_ID } from './routes.constants'; import { Search, Settings, SquarePen } from '@lucide/svelte'; import McpLogo from '$lib/components/app/mcp/McpLogo.svelte'; import { SidebarAction, ToolSource } from '$lib/enums'; @@ -80,7 +81,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [ { icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' }, { action: SidebarAction.MANAGE_MODELS, - activeRouteId: '/models', + activeRouteId: MODELS_ROUTE_ID, icon: MODEL_ICON, tooltip: 'Models' }, 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 deecbe34f54d..844a7d4fbfb8 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -114,13 +114,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele ); const emptyMessage = $derived(searchTerm ? 'No models found.' : 'No models yet.'); - // the manager takes the focus, so the selector closes instead of sitting behind it - $effect(() => { - if (!uiStore.isModelsPageOpen) return; - - opts.onOpenChange?.(false); - }); - onMount(() => { modelsStore.fetch().catch((error) => { console.error('Unable to load models:', error); diff --git a/tools/ui/src/lib/stores/init.ts b/tools/ui/src/lib/stores/init.ts index 37ea87b17c3b..ce2dcfbd0c5f 100644 --- a/tools/ui/src/lib/stores/init.ts +++ b/tools/ui/src/lib/stores/init.ts @@ -7,6 +7,7 @@ import { toolsStore } from './tools.svelte'; import { versionStore } from './version.svelte'; import { browser } from '$app/environment'; import { MigrationService } from '$lib/services/migration.service'; +import { validateApiKey } from '$lib/utils/api-key-validation'; let startup: Promise | null = null; @@ -30,3 +31,11 @@ export function initStores(): Promise { return startup; } + +/** Load shared by the app's pages: start the stores, then check the API key. */ +export async function loadAppRoute(fetch: typeof globalThis.fetch): Promise { + // loads run before the root layout script, so the stored API key reaches + // the probe only once the settings store has read localStorage + await initStores(); + await validateApiKey(fetch); +} diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts index e0f60f71819a..49ac2fbffa65 100644 --- a/tools/ui/src/lib/stores/ui.svelte.ts +++ b/tools/ui/src/lib/stores/ui.svelte.ts @@ -8,7 +8,8 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; -import { ROUTES } from '$lib/constants'; +import { MODELS_ROUTE_ID, ROUTES, SETTINGS_KEYS } from '$lib/constants'; +import { settingsStore } from '$lib/stores/settings/index.svelte'; import type { ModelOption } from '$lib/types/models'; class UiStore { @@ -26,12 +27,17 @@ class UiStore { manageModelFocus = $state(null); /** Model the information dialog shows; a phone opens it instead of the manager. */ modelInformation = $state(null); - /** Route the models page returns to, the one it was opened from. */ + /** Route the models page returns to: the last page visited before it. */ modelsReturnRoute: string = ROUTES.START; + /** Whether Discover is turned on; every Discover entry point hides without it. */ + get isDiscoverEnabled(): boolean { + return Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS)); + } + /** Whether the models manager route is the current page. */ get isModelsPageOpen(): boolean { - return page.route.id === '/models'; + return page.route.id === MODELS_ROUTE_ID; } /** Leave the models page for the route it was opened from. */ @@ -46,8 +52,10 @@ class UiStore { this.modelInformation = option; } - /** Open the Discover dialog, focused on one repo. */ + /** Open the Discover dialog, focused on one repo; a no-op while Discover is off. */ openModelsDiscover(focus?: string): void { + if (!this.isDiscoverEnabled) return; + this.discoverModelFocus = focus ?? null; this.discoverModelsOpen = true; } @@ -58,10 +66,16 @@ class UiStore { if (this.isModelsPageOpen) return; - this.modelsReturnRoute = page.url.hash || ROUTES.START; void goto(ROUTES.MODELS); } + /** Remember the current route as the models page's way back, unless it is that page. */ + rememberModelsReturnRoute(): void { + if (this.isModelsPageOpen) return; + + this.modelsReturnRoute = page.url.hash || ROUTES.START; + } + /** Ask the composer to take focus, e.g. after a dialog closes onto the chat. */ requestComposerFocus(): void { this.composerFocusRequested = true; diff --git a/tools/ui/src/routes/(chat)/+page.ts b/tools/ui/src/routes/(chat)/+page.ts index 0c46aaa8abb2..3be3d5a9567b 100644 --- a/tools/ui/src/routes/(chat)/+page.ts +++ b/tools/ui/src/routes/(chat)/+page.ts @@ -1,10 +1,4 @@ import type { PageLoad } from './$types'; -import { initStores } from '$lib/stores/init'; -import { validateApiKey } from '$lib/utils'; +import { loadAppRoute } from '$lib/stores/init'; -export const load: PageLoad = async ({ fetch }) => { - // loads run before the root layout script, so the stored API key reaches - // the probe only once the settings store has read localStorage - await initStores(); - await validateApiKey(fetch); -}; +export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch); diff --git a/tools/ui/src/routes/(chat)/chat/[id]/+page.ts b/tools/ui/src/routes/(chat)/chat/[id]/+page.ts index 0c46aaa8abb2..3be3d5a9567b 100644 --- a/tools/ui/src/routes/(chat)/chat/[id]/+page.ts +++ b/tools/ui/src/routes/(chat)/chat/[id]/+page.ts @@ -1,10 +1,4 @@ import type { PageLoad } from './$types'; -import { initStores } from '$lib/stores/init'; -import { validateApiKey } from '$lib/utils'; +import { loadAppRoute } from '$lib/stores/init'; -export const load: PageLoad = async ({ fetch }) => { - // loads run before the root layout script, so the stored API key reaches - // the probe only once the settings store has read localStorage - await initStores(); - await validateApiKey(fetch); -}; +export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch); diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index 5de4f82c1cbe..70d3b7d8b4be 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -1,7 +1,7 @@ @@ -33,7 +33,7 @@

- {#if discoverEnabled} + {#if uiStore.isDiscoverEnabled}
+
- -
+ { + if (!open) selectedId = null; + }} + open={selected !== null} + shouldScaleBackground={false} +> + - + Model details + + + Configuration, actions and metadata of the selected model. + +
@@ -360,33 +368,10 @@ {/key} {/if}
-
-
+ + 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 index 40a7565ed272..0bf3ed49140d 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte @@ -98,7 +98,7 @@ {status} /> -
+
{#if isDownload}
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 index d2cc2d5b8822..5cb3bad9138d 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationHeader.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationHeader.svelte @@ -74,7 +74,7 @@ -
+
@@ -108,15 +108,18 @@
- + + {#if !isMobile} + + {/if}
diff --git a/tools/ui/tests/client/models-manager-sort-filter.svelte.test.ts b/tools/ui/tests/client/models-manager-sort-filter.svelte.test.ts index 9e9e0c2468b4..65c8c7812c92 100644 --- a/tools/ui/tests/client/models-manager-sort-filter.svelte.test.ts +++ b/tools/ui/tests/client/models-manager-sort-filter.svelte.test.ts @@ -218,7 +218,7 @@ it('filters by search', async () => { await screen.getByPlaceholder('Search your models').fill('beta'); - await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeVisible(); + await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeInTheDocument(); await expect.element(screen.getByText(/beta\s+8B/)).toBeVisible(); }); @@ -231,7 +231,7 @@ it('filters by context and sorts from the Hub details cache', async () => { await screen.getByText('Context:').click(); await screen.getByText('32K or more').click(); - await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeVisible(); + await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeInTheDocument(); await expect.element(screen.getByText(/gamma\s+8B/)).toBeVisible(); // sorting re-runs once the cache answers From f01ebafdf4498e6ff5ef0ba3e1dfa6e6412c49b9 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 22 Oct 2026 17:16:57 +0200 Subject: [PATCH 6/6] ui : make whole models table rows clickable A click anywhere on a model or quant row opens it, unless it lands on one of the row's own controls (load toggle, actions menu). The name button stays the keyboard path to the same action. Assisted by: Claude (Opus 5.5 Medium) --- .../models/ModelsManager/ModelsManagerModelRow.svelte | 11 +++++++++-- .../models/ModelsManager/ModelsManagerQuantRow.svelte | 11 +++++++++-- .../lib/components/app/models/ModelsManager/utils.ts | 8 ++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte index 204d746d8cc0..8383d418ab4d 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte @@ -7,7 +7,7 @@ import ModelId from '../ModelId.svelte'; import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte'; import { modelRowActions } from './row-actions'; - import { configuredContext, downloadProgressFor } from './utils'; + import { configuredContext, downloadProgressFor, isRowControlClick } from './utils'; import { Heart, MoreHorizontal } from '@lucide/svelte'; import { DropdownMenuActions } from '$lib/components/app'; import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants'; @@ -55,13 +55,20 @@ } + + +
{ + if (!isRowControlClick(event)) activate(); + }} >
+ +
{ + if (!isRowControlClick(event)) onSelect(option); + }} > diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts index 435700a10394..ccbe226c05d6 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts +++ b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts @@ -81,6 +81,14 @@ export interface ModelsTableGroup { label: string; } +/** Controls in a model row that act on their own, so a row click passes them by. */ +const ROW_CONTROL_SELECTOR = 'button, a, input, [role="menuitem"]'; + +/** Whether a click on a model row landed on one of its own controls. */ +export function isRowControlClick(event: MouseEvent): boolean { + return event.target instanceof Element && event.target.closest(ROW_CONTROL_SELECTOR) !== null; +} + /** Context the model runs with: what a loaded model reports. */ export function configuredContext(option: ModelOption): number | null { return modelsStore.isModelRunning(option.model)