Commit e168e041e for llama.cpp

commit e168e041ec4cfb9dc15811c546a5548aeab74906
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Sat Oct 24 17:34:11 2026 +0200

    ui: Models Manager as Route + UI improvements (#30988)

    * 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)

    * 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)

    * ui : round the models table family headers

    Assisted by: Claude (Opus 5.5 Medium)

    * 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)

    * ui : show model details in a drawer

    Open the models manager's detail pane in a drawer, from the right on
    desktop and from the bottom on a phone, instead of an inline side
    panel and a full-screen cover. Pad the pane on both sides, drop its
    close button on a phone, and assert that filtered rows are gone from
    the page rather than merely hidden.

    Assisted by: Claude (Opus 5.5 Medium)

    * 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)

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 397ddc322..91edac1d8 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 000000000..ed3d651d2
--- /dev/null
+++ b/tools/ui/src/lib/components/app/dialogs/DialogDiscoverModels.svelte
@@ -0,0 +1,53 @@
+<script lang="ts">
+	import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
+	import * as Dialog from '$lib/components/ui/dialog';
+	import { DISCOVER_ICON } from '$lib/constants';
+	import { uiStore } from '$lib/stores';
+	import { untrack } from 'svelte';
+
+	interface Props {
+		open?: boolean;
+		onOpenChange?: (open: boolean) => void;
+	}
+
+	let { onOpenChange, open = $bindable(false) }: Props = $props();
+
+	// the setting can be turned off while the dialog is open: it closes and drops the
+	// repo it was asked to show
+	$effect(() => {
+		if (open && !uiStore.isDiscoverEnabled) {
+			untrack(() => {
+				uiStore.discoverModelFocus = null;
+				handleOpenChange(false);
+			});
+		}
+	});
+
+	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 p-2 pr-8">
+			<Dialog.Title class="flex min-h-7 items-center gap-2">
+				<DISCOVER_ICON class="h-5 w-5" />
+
+				<span>Discover</span>
+			</Dialog.Title>
+
+			<Dialog.Description class="sr-only">
+				Find models on Hugging Face and download them to the server.
+			</Dialog.Description>
+		</Dialog.Header>
+
+		<div class="grid min-h-0 flex-1 overflow-hidden" style="grid-template-columns: auto 1fr;">
+			<ModelsDiscover />
+		</div>
+	</Dialog.Content>
+</Dialog.Root>
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 fbe711c25..000000000
--- a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte
+++ /dev/null
@@ -1,207 +0,0 @@
-<script lang="ts">
-	import { ArrowLeft } from '@lucide/svelte';
-	import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
-	import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
-	import { Button } from '$lib/components/ui/button';
-	import * as Dialog from '$lib/components/ui/dialog';
-	import {
-		DISCOVER_ICON,
-		MODEL_ICON,
-		MODELS_DIALOG_VIEW_FADE_MS,
-		SETTINGS_KEYS
-	} from '$lib/constants';
-	import { ModelsDialogView } from '$lib/enums';
-	import { modelsStore, settingsStore, uiStore } from '$lib/stores';
-	import { untrack } from 'svelte';
-
-	interface Props {
-		open?: boolean;
-		onOpenChange?: (open: boolean) => void;
-	}
-
-	let { onOpenChange, open = $bindable(false) }: Props = $props();
-
-	// what the user asked for, and what is actually rendered: a view change fades the
-	// current one out, swaps, then fades the next one in
-	let view = $state<ModelsDialogView>(ModelsDialogView.MANAGE);
-	let shownView = $state<ModelsDialogView>(ModelsDialogView.MANAGE);
-	let isSwapping = $state(false);
-	/** Set while an entry opens the dialog on an explicit view, so the open reset keeps hands off. */
-	let viewRequested = false;
-
-	// the discover view exists only while its setting is on
-	let discoverEnabled = $derived(
-		Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
-	);
-
-	$effect(() => {
-		const next = view;
-
-		if (next === shownView) return;
-
-		// nothing to fade while the dialog is closed, so the view is set before it opens
-		if (!open) {
-			untrack(() => (shownView = next));
-
-			return;
-		}
-
-		untrack(() => (isSwapping = true));
-
-		const timer = setTimeout(() => {
-			untrack(() => {
-				shownView = next;
-				isSwapping = false;
-			});
-		}, MODELS_DIALOG_VIEW_FADE_MS);
-
-		return () => clearTimeout(timer);
-	});
-
-	let title = $derived(view === ModelsDialogView.DISCOVER ? 'Discover' : 'Models');
-
-	// the sidebar's Discover entry opens this dialog on its Discover view
-	$effect(() => {
-		if (!uiStore.discoverModelsOpen) return;
-
-		untrack(() => {
-			uiStore.discoverModelsOpen = false;
-			view = discoverEnabled ? ModelsDialogView.DISCOVER : ModelsDialogView.MANAGE;
-
-			// the dialog may already be open, so no open transition will follow
-			if (open) return;
-
-			viewRequested = true;
-			handleOpenChange(true);
-		});
-	});
-
-	// opening always lands on the manager, unless an entry requested the discover view
-	$effect(() => {
-		if (!open) return;
-
-		untrack(() => {
-			if (viewRequested) {
-				viewRequested = false;
-			} else {
-				view = ModelsDialogView.MANAGE;
-			}
-
-			// sync point: the table opens on cached state and corrects itself when
-			// the list fetch lands; a no-op outside router mode
-			void modelsStore.fetchRouterModels();
-		});
-	});
-
-	// the discover view disappears with its setting; fall back to the manager
-	$effect(() => {
-		if (!discoverEnabled && view === ModelsDialogView.DISCOVER) {
-			untrack(() => (view = ModelsDialogView.MANAGE));
-		}
-	});
-
-	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="relative flex flex-row items-center justify-between p-2 pr-8">
-			<!--
-				The arrow is out of the flow and the title slides, so entering a sub-view
-				moves the title with a transform instead of reflowing the header. min-h
-				keeps the header's height fixed either way.
-			-->
-			<Button
-				aria-label="Back to models"
-				class="absolute top-1/2 left-1 h-7 w-7 -translate-y-1/2 transition-[opacity,visibility] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
-				ModelsDialogView.MANAGE
-					? 'invisible opacity-0'
-					: 'visible opacity-100'}"
-				onclick={() => (view = ModelsDialogView.MANAGE)}
-				size="icon"
-				variant="ghost"
-			>
-				<ArrowLeft class="h-4 w-4" />
-			</Button>
-
-			<Dialog.Title
-				class="flex min-h-7 items-center gap-2 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
-				ModelsDialogView.MANAGE
-					? 'translate-x-0'
-					: 'translate-x-8'}"
-			>
-				<!-- the marks cross-fade in one grid cell, so the title never shifts -->
-				<span class="grid h-5 w-5 shrink-0 place-items-center">
-					<MODEL_ICON
-						class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
-						ModelsDialogView.MANAGE
-							? 'opacity-100'
-							: 'opacity-0'}"
-					/>
-
-					<DISCOVER_ICON
-						class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
-						ModelsDialogView.DISCOVER
-							? 'opacity-100'
-							: 'opacity-0'}"
-					/>
-				</span>
-
-				<span>{title}</span>
-			</Dialog.Title>
-
-			<Dialog.Description class="sr-only">
-				Browse, load and delete the models the server can serve.
-			</Dialog.Description>
-		</Dialog.Header>
-
-		<div class="dialog-view min-h-0 flex-1" data-visible={!isSwapping}>
-			{#if shownView === ModelsDialogView.MANAGE}
-				<ModelsManager class="h-full">
-					{#snippet toolbarEnd()}
-						{#if discoverEnabled}
-							<Button
-								class="gap-1.5"
-								onclick={() => (view = ModelsDialogView.DISCOVER)}
-								size="sm"
-								variant="tertiary"
-							>
-								<DISCOVER_ICON class="h-3.5 w-3.5" />
-
-								Discover Models
-							</Button>
-						{/if}
-					{/snippet}
-				</ModelsManager>
-			{:else if shownView === ModelsDialogView.DISCOVER}
-				<div class="grid h-full overflow-hidden" style="grid-template-columns: auto 1fr;">
-					<ModelsDiscover />
-				</div>
-			{/if}
-		</div>
-	</Dialog.Content>
-</Dialog.Root>
-
-<style>
-	/* a view change reads as one surface swapping, not as content teleporting */
-	.dialog-view {
-		opacity: 0;
-		transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);
-	}
-
-	.dialog-view[data-visible='true'] {
-		opacity: 1;
-	}
-
-	@media (prefers-reduced-motion: reduce) {
-		.dialog-view {
-			transition-duration: 100ms;
-		}
-	}
-</style>
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index 45cc9b2e6..5c6a56f00 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 9853bafe5..002b283fb 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
@@ -10,9 +10,10 @@
 		type ModelsTableGroup,
 		splitHiddenQuants
 	} from './utils';
+	import * as Drawer from '$lib/components/ui/drawer';
 	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 { conversationsStore, deviceStore, modelsStore, serverStore, uiStore } from '$lib/stores';
 	import type { ModelOption } from '$lib/types/models';
 	import { filterModelOptions } from '$lib/utils';
 	import { type Snippet, untrack } from 'svelte';
@@ -48,8 +49,8 @@
 			downloads.find((group) => group.base.id === selectedId)?.base ??
 			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.
+	// The pane renders the selection, else the model it last showed, so its copy stays
+	// in place while it closes.
 	let lastPicked = $state<ModelOption | null>(null);
 	let target = $derived(selected ?? lastPicked ?? allModels[0] ?? null);
 	let shownId = $state<string | null>(null);
@@ -254,10 +255,31 @@
 		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();
+	}
+
+	/** Overlays that handle their own Escape, so the page leaves them alone. */
+	const OPEN_OVERLAY_SELECTOR = [
+		'[role="dialog"][data-state="open"]',
+		'[role="alertdialog"][data-state="open"]',
+		'[role="menu"][data-state="open"]',
+		'[role="listbox"][data-state="open"]'
+	].join(', ');
+
+	// Escape steps back the way the dialog's did: the pane's drawer closes itself, then
+	// the next Escape leaves the page
+	function handleKeydown(event: KeyboardEvent): void {
+		if (event.key !== 'Escape' || event.defaultPrevented) return;
+
+		if (document.querySelector(OPEN_OVERLAY_SELECTOR)) return;
+
+		// a typed filter is not thrown away by a stray Escape
+		if (event.target instanceof HTMLInputElement && event.target.value) return;
+
+		returnToChat();
 	}

 	/** Load the model when the server can take load requests, without waiting for it. */
@@ -292,6 +314,8 @@
 	}
 </script>

+<svelte:window onkeydown={handleKeydown} />
+
 <div class={['relative flex min-h-0 flex-1', className]}>
 	<div class="min-h-0 min-w-0 flex-1">
 		<ModelsManagerModelsTable
@@ -306,16 +330,29 @@
 			{toolbarEnd}
 		/>
 	</div>
+</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 pane is a drawer over the table, from the right on desktop and from the bottom
+     on a phone: Escape, the overlay and the pane's own close button clear the selection -->
+<Drawer.Root
+	direction={deviceStore.isMobile ? 'bottom' : 'right'}
+	onOpenChange={(open) => {
+		if (!open) selectedId = null;
+	}}
+	open={selected !== null}
+	shouldScaleBackground={false}
+>
+	<Drawer.Content
+		class="data-[vaul-drawer-direction=bottom]:h-[85dvh] data-[vaul-drawer-direction=bottom]:max-h-[85dvh] data-[vaul-drawer-direction=right]:w-[30rem] data-[vaul-drawer-direction=right]:max-w-[calc(100vw-2rem)] data-[vaul-drawer-direction=right]:sm:max-w-[30rem]"
 	>
-		<!-- the content box keeps the open width, so it never reflows with the drawer -->
+		<Drawer.Title class="sr-only">Model details</Drawer.Title>
+
+		<Drawer.Description class="sr-only">
+			Configuration, actions and metadata of the selected model.
+		</Drawer.Description>
+
 		<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"
+			class="pane-content flex min-h-0 flex-1 flex-col"
 			data-fade={fade}
 			data-visible={selected !== null && !isSwapping}
 		>
@@ -331,33 +368,10 @@
 				{/key}
 			{/if}
 		</div>
-	</div>
-</div>
+	</Drawer.Content>
+</Drawer.Root>

 <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);
@@ -376,11 +390,4 @@
 	.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/ModelsManagerFilters.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte
index 8b4c3bf37..99fe92072 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 @@
 </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 -->
+     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 -->
 <ScrollCarousel
 	alwaysShowArrows
 	class="min-w-0 flex-1 max-md:w-full max-md:flex-none"
+	containerClass="-m-1 p-1"
 	gapSize="2"
 	innerClass="items-center"
 >
@@ -115,11 +118,10 @@
 	</Select.Root>

 	<ToggleGroup.Root
-		class="border border-border/30 bg-muted/60 shadow-sm dark:border-border/20 dark:bg-muted/75"
+		class={FILTER_TOGGLE_GROUP_CLASS}
 		onValueChange={setToggles}
 		type="multiple"
 		value={[...capabilities, ...modalities]}
-		variant="outline"
 	>
 		{#each toggles as toggle (toggle.value)}
 			<ToggleGroup.Item
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 40a7565ed..0bf3ed491 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}
 	/>

-	<div class="min-h-0 flex-1 overflow-y-auto py-4 pl-4 max-md:pr-4">
+	<div class="min-h-0 flex-1 overflow-y-auto p-4">
 		{#if isDownload}
 			<!-- nothing is on disk yet, so the pane keeps to the download itself -->
 			<div class="space-y-2 py-1">
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
index affab3421..30b3becdf 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationActions.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationActions.svelte
@@ -11,8 +11,7 @@
 		Trash2
 	} from '@lucide/svelte';
 	import { Button } from '$lib/components/ui/button';
-	import { SETTINGS_KEYS } from '$lib/constants';
-	import { conversationsStore, deviceStore, settingsStore } from '$lib/stores';
+	import { conversationsStore, deviceStore, uiStore } from '$lib/stores';

 	interface Props {
 		/** A provider that cannot load or unload has no button to offer. */
@@ -48,10 +47,7 @@
 		size = 'sm'
 	}: Props = $props();

-	// the discover view exists only while its setting is on
-	let discoverEnabled = $derived(
-		Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
-	);
+	let discoverEnabled = $derived(uiStore.isDiscoverEnabled);

 	// a chat with anything in it takes the model, a new chat is not what its user asked for
 	let hasChat = $derived(
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 d2cc2d5b8..5cb3bad91 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 @@
 <!-- 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">
+<header class="space-y-2.5 px-4 pt-3 max-md:space-y-4 max-md:pt-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 -->
@@ -108,15 +108,18 @@
 			</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>
+		<!-- a phone's bottom drawer closes by its handle and overlay, it needs no button -->
+		{#if !isMobile}
+			<Button
+				aria-label="Close details"
+				class="h-7 w-7"
+				onclick={onClose}
+				size="icon"
+				variant="ghost"
+			>
+				<X class="h-4 w-4" />
+			</Button>
+		{/if}
 	</div>

 	<!-- a phone floats the actions at the bottom of the pane, where the thumb is -->
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 204d746d8..8383d418a 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 @@
 	}
 </script>

+<!-- the whole row opens the model for a pointer; keyboard users reach the same action
+     through the model's own button -->
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 <div
 	class={[
 		MODEL_ROW_GRID_CLASS,
-		'group relative rounded-md px-2 py-3 transition max-md:px-3 max-md:py-4',
+		'group relative cursor-pointer 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'
 	]}
+	onclick={(event) => {
+		if (!isRowControlClick(event)) activate();
+	}}
 >
 	<div class="flex min-w-0 flex-col">
 		<!-- the row holds a load control and an actions menu, so only the model itself is
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 7883cfa7d..e19d5c0ab 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte
@@ -29,9 +29,11 @@
 		ModelsSection
 	} from '$lib/components/app';
 	import { DialogConfirmDownload } from '$lib/components/app/dialogs';
+	import * as Empty from '$lib/components/ui/empty';
 	import {
 		FAMILY_ROW_WINDOW,
 		type ModalityKey,
+		MODEL_ICON,
 		MODEL_ROW_GRID_CLASS,
 		MODEL_ROW_TRAILING_CELL_CLASS,
 		MODEL_ROW_WINDOW,
@@ -207,7 +209,7 @@

 {#snippet sortHeader(key: ModelsTableSortKey, label: string)}
 	<button
-		class="inline-flex cursor-pointer items-center gap-1 uppercase transition hover:text-foreground focus:outline-none"
+		class="inline-flex cursor-pointer items-center gap-1 transition-colors hover:text-foreground focus:outline-none"
 		onclick={() => toggleSort(key)}
 		title={sortTitle(key, label)}
 		type="button"
@@ -270,7 +272,7 @@
 	{@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"
+		class="{MODEL_ROW_GRID_CLASS} relative group cursor-pointer rounded-md px-2 py-1.5 transition hover:bg-muted/40 max-md:px-3 max-md:py-2.5"
 		onclick={toggle}
 		onkeydown={(event) => handleFamilyKeydown(event, toggle)}
 		role="button"
@@ -286,7 +288,7 @@

 			<span class="truncate text-sm font-medium">{family.label}</span>

-			<span class="text-sm text-muted-foreground">{countLabel}</span>
+			<span class="shrink-0 text-sm whitespace-nowrap text-muted-foreground">{countLabel}</span>
 		</span>

 		<!-- the context and status columns stay empty here: the fold control is this
@@ -350,7 +352,7 @@
 	/>

 	<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"
+		class="{MODEL_ROW_GRID_CLASS} shrink-0 border-b border-border/30 px-2 pb-2 text-xs font-medium text-muted-foreground max-md:px-3 dark:border-border/20"
 	>
 		<span>{@render sortHeader(ModelsTableSortKey.NAME, 'Model')}</span>

@@ -363,7 +365,7 @@
 		<span class="text-center max-md:hidden">Actions</span>
 	</div>

-	<div class="min-h-0 flex-1 overflow-y-auto">
+	<div class="mt-2 min-h-0 flex-1 overflow-y-auto">
 		{#each sections as group (group.key)}
 			{#if group.items.length > 0}
 				{#snippet groupIcon()}
@@ -388,34 +390,49 @@
 					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"
+					sectionHeaderClass="m-0 rounded-lg px-2 py-2 text-[13px] font-semibold text-foreground/85 select-none transition-colors hover:bg-muted max-md:px-3"
 					sticky
-					stickyClass="sticky z-10 bg-muted/90 backdrop-blur-lg"
+					stickyClass="sticky z-10 bg-muted/70 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}
-					/>
+					<!-- a little air before the next section's band -->
+					<div class="pb-3">
+						<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="rounded-lg bg-muted/25 backdrop-blur-lg"
+							stickyStyle={MODELS_TABLE_GROUP_STICKY_OFFSET}
+							weightOf={(entry) => entry.quants.length}
+						/>
+					</div>
 				</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>
+			<Empty.Root class="py-16">
+				<Empty.Header>
+					<Empty.Media variant="icon">
+						<MODEL_ICON />
+					</Empty.Media>
+
+					<Empty.Title>{hasFilters ? 'No matching models' : 'No models yet'}</Empty.Title>
+
+					<Empty.Description>
+						{hasFilters
+							? 'Try a different search or clear the filters.'
+							: 'Models the server can run show up here.'}
+					</Empty.Description>
+				</Empty.Header>
+			</Empty.Root>
 		{/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
index 6d76251cd..6838a85da 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerQuantRow.svelte
@@ -3,7 +3,7 @@
 	import ModelDraftSidecars from '../ModelDraftSidecars.svelte';
 	import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
 	import { modelRowActions } from './row-actions';
-	import { configuredContext } from './utils';
+	import { configuredContext, isRowControlClick } from './utils';
 	import { MoreHorizontal } from '@lucide/svelte';
 	import { DropdownMenuActions } from '$lib/components/app';
 	import { Badge } from '$lib/components/ui/badge';
@@ -28,13 +28,20 @@
 	let quant = $derived(option.parsedId?.quantization ?? option.model);
 </script>

+<!-- the whole row opens the quant for a pointer; keyboard users reach the same action
+     through the quant's own button -->
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 <div
 	class={[
 		MODEL_ROW_GRID_CLASS,
-		'group relative rounded-md px-2 py-2 transition max-md:px-3 max-md:py-3',
+		'group relative cursor-pointer 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'
 	]}
+	onclick={(event) => {
+		if (!isRowControlClick(event)) onSelect(option);
+	}}
 >
 	<!-- 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 -->
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
index fef282fcc..b2b10f58e 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerTableToolbar.svelte
@@ -6,7 +6,7 @@
 	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 { deviceStore } from '$lib/stores';
 	import type { Snippet } from 'svelte';

 	interface Props {
@@ -32,12 +32,10 @@
 	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.
+	// 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;
-
 		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 3407634ec..f3c4363c7 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 `<repo>:<quant>`
-		onclick: () => uiStore.openModelsDiscover(repoOf(option.model)),
-		separator: true
-	};
+	// the details pane is keyed by repo, not by `<repo>:<quant>`; 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/components/app/models/ModelsManager/utils.ts b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
index 435700a10..ccbe226c0 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)
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 750e5f6a7..d9e6be298 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);
 	}
 </script>
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 2ce4824cf..828c63623 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 @@

 <DialogMcpServers bind:open={mcpDialogOpen} />

-<DialogManageModels bind:open={uiStore.manageModelsOpen} />
+<DialogDiscoverModels bind:open={uiStore.discoverModelsOpen} />

 <DialogModelInformation />

diff --git a/tools/ui/src/lib/constants/css-classes.constants.ts b/tools/ui/src/lib/constants/css-classes.constants.ts
index 045dce6ac..a8f00df4c 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/lib/constants/routes.constants.ts b/tools/ui/src/lib/constants/routes.constants.ts
index 1f518a758..9a475a118 100644
--- a/tools/ui/src/lib/constants/routes.constants.ts
+++ b/tools/ui/src/lib/constants/routes.constants.ts
@@ -13,8 +13,13 @@ 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. */
 	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 946793978..9af1424fa 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';
@@ -7,9 +8,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 +81,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
 	{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
 	{
 		action: SidebarAction.MANAGE_MODELS,
+		activeRouteId: MODELS_ROUTE_ID,
 		icon: MODEL_ICON,
 		tooltip: 'Models'
 	},
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 9ab0be32e..142962826 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 599fc2128..20cfe47ff 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 35a85dcd8..844a7d4fb 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.manageModelsOpen) 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 37ea87b17..ce2dcfbd0 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<void> | null = null;

@@ -30,3 +31,11 @@ export function initStores(): Promise<void> {

 	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<void> {
+	// 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 bd9e40323..49ac2fbff 100644
--- a/tools/ui/src/lib/stores/ui.svelte.ts
+++ b/tools/ui/src/lib/stores/ui.svelte.ts
@@ -6,40 +6,74 @@
  * controls and the chat tab bar reacts to).
  */

+import { goto } from '$app/navigation';
+import { page } from '$app/state';
+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 {
 	/** 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<string | null>(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<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);
+	/** 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_ROUTE_ID;
+	}
+
+	/** Leave the models page for the route it was opened from. */
+	async closeModelsManager(): Promise<void> {
+		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; a no-op while Discover is off. */
 	openModelsDiscover(focus?: string): void {
+		if (!this.isDiscoverEnabled) return;
+
 		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;
+
+		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. */
diff --git a/tools/ui/src/routes/(chat)/+page.ts b/tools/ui/src/routes/(chat)/+page.ts
index 0c46aaa8a..3be3d5a95 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 0c46aaa8a..3be3d5a95 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 5de4f82c1..70d3b7d8b 100644
--- a/tools/ui/src/routes/+layout.svelte
+++ b/tools/ui/src/routes/+layout.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
 	import '../app.css';
 	import { browser } from '$app/environment';
-	import { goto } from '$app/navigation';
+	import { afterNavigate, goto } from '$app/navigation';
 	import { base } from '$app/paths';
 	import { page } from '$app/state';
 	import { SidebarNavigation } from '$lib/components/app';
@@ -11,6 +11,7 @@
 		FAVICON_PATHS,
 		FAVICON_SELECTORS,
 		HEADERS,
+		MODELS_ROUTE_ID,
 		NEW_CHAT_TAB_ID,
 		ROUTES,
 		SETTINGS_KEYS,
@@ -28,6 +29,7 @@
 		serverStore,
 		settingsStore,
 		tabsStore,
+		uiStore,
 		versionStore
 	} from '$lib/stores';
 	import { initStores } from '$lib/stores/init';
@@ -137,6 +139,9 @@
 			untrack(() => tabsStore.removeTabs([NEW_CHAT_TAB_ID]));
 		}
 	});
+	// the models page returns to wherever it was reached from, by any kind of navigation
+	afterNavigate(() => uiStore.rememberModelsReturnRoute());
+
 	// Global keyboard shortcuts
 	const { handleKeydown } = useKeyboardShortcuts({
 		editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
@@ -158,7 +163,9 @@

 		untrack(() => {
 			if (
-				(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
+				(page.route.id === '/(chat)' ||
+					page.route.id === '/(chat)/chat/[id]' ||
+					page.route.id === MODELS_ROUTE_ID) &&
 				page.status !== 401 &&
 				page.status !== 403
 			) {
diff --git a/tools/ui/src/routes/models/+page.svelte b/tools/ui/src/routes/models/+page.svelte
new file mode 100644
index 000000000..eb65c75dc
--- /dev/null
+++ b/tools/ui/src/routes/models/+page.svelte
@@ -0,0 +1,51 @@
+<script lang="ts">
+	import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
+	import { Button } from '$lib/components/ui/button';
+	import { APP_NAME, DISCOVER_ICON } from '$lib/constants';
+	import { modelsStore, uiStore } from '$lib/stores';
+	import { onMount } from 'svelte';
+
+	// a direct load lands here before any chat has listed the models, and the root
+	// layout fetches the router details once that list arrives. An already loaded
+	// list only needs the router fetch as a sync point (a no-op outside router mode)
+	onMount(() => {
+		if (modelsStore.models.length > 0) {
+			void modelsStore.fetchRouterModels();
+
+			return;
+		}
+
+		modelsStore.fetch().catch((error) => console.error('Unable to load models:', error));
+	});
+</script>
+
+<svelte:head>
+	<title>Models - {APP_NAME}</title>
+</svelte:head>
+
+<div class="mx-auto flex h-[100dvh] w-full max-w-7xl flex-col gap-6 px-4 pt-12 md:px-8 md:pt-6">
+	<div class="flex items-start gap-4">
+		<div class="flex min-w-0 flex-col gap-1">
+			<h1 class="text-2xl font-semibold tracking-tight">Models</h1>
+
+			<p class="text-sm text-muted-foreground">
+				Load, configure and remove the models this server can run.
+			</p>
+		</div>
+
+		{#if uiStore.isDiscoverEnabled}
+			<Button
+				class="ml-auto shrink-0 gap-1.5"
+				onclick={() => uiStore.openModelsDiscover()}
+				size="sm"
+				variant="tertiary"
+			>
+				<DISCOVER_ICON class="h-3.5 w-3.5" />
+
+				Discover Models
+			</Button>
+		{/if}
+	</div>
+
+	<ModelsManager class="min-h-0" />
+</div>
diff --git a/tools/ui/src/routes/models/+page.ts b/tools/ui/src/routes/models/+page.ts
new file mode 100644
index 000000000..3be3d5a95
--- /dev/null
+++ b/tools/ui/src/routes/models/+page.ts
@@ -0,0 +1,4 @@
+import type { PageLoad } from './$types';
+import { loadAppRoute } from '$lib/stores/init';
+
+export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
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 9e9e0c246..65c8c7812 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