Commit bc262f33c for llama.cpp

commit bc262f33cc87b298c1f676b28a9aa05e47c6e9aa
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Tue Oct 27 06:46:25 2026 +0100

    ui: Open model details in a drawer without leaving the page (#31176)

    * ui: open model details in a global drawer without leaving the page

    The model info actions in the selector used to navigate to the models page
    to open its details pane, and a phone opened a separate dialog instead. The
    drawer now lives outside the models table, driven by uiStore, so the info
    icon opens it over whatever page is showing.

    The models dropdown and the phone picker stay open under the drawer's
    backdrop, and keep focus while the drawer opens and closes, so the trigger's
    tooltip no longer pops over the drawer.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: let the model details drawer reopen after a drag to dismiss

    Dragging the drawer down wrote vaul's own open state, which the one-way open prop then kept closed, so the info action could not open it again until a refresh. Bind open both ways through the store instead.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: close the models selector from the details drawer chat actions

    Assisted-by: Claude (Opus 5.5 Medium)

diff --git a/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte
deleted file mode 100644
index 7780b67d2..000000000
--- a/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte
+++ /dev/null
@@ -1,85 +0,0 @@
-<script lang="ts">
-	import ModelsManagerModelConfigurationInformation from '$lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationInformation.svelte';
-	import * as Dialog from '$lib/components/ui/dialog';
-	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;
-	});
-
-	// 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);
-
-	$effect(() => {
-		const option = uiStore.modelInformation;
-
-		if (!option) return;
-
-		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>
-
-<Dialog.Root
-	onOpenChange={(open) => {
-		if (!open) uiStore.modelInformation = null;
-	}}
-	open={uiStore.modelInformation !== null}
->
-	<Dialog.Content
-		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]"
-	>
-		<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" />
-
-				{#if uiStore.modelInformation}
-					<span class="min-w-0 truncate">{uiStore.modelInformation.model}</span>
-				{/if}
-			</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>
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index ff287ec18..f038c447b 100644
--- a/tools/ui/src/lib/components/app/dialogs/index.ts
+++ b/tools/ui/src/lib/components/app/dialogs/index.ts
@@ -420,17 +420,6 @@ export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.sv
  */
 export { default as DialogConversationSelection } from './DialogConversationSelection.svelte';

-/**
- *
- * MODEL INFORMATION DIALOGS
- *
- * Dialogs for displaying model and server information.
- *
- */
-
-/** **DialogModelInformation** - model details a phone opens instead of the manager pane. */
-export { default as DialogModelInformation } from './DialogModelInformation.svelte';
-
 /**
  * **DialogMcpResourcesBrowser** - MCP resources browser dialog
  *
diff --git a/tools/ui/src/lib/components/app/models/ModelRowActions.svelte b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte
index b40f88d83..5c2f321bd 100644
--- a/tools/ui/src/lib/components/app/models/ModelRowActions.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelRowActions.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
 	import { Heart, HeartOff, Info } from '@lucide/svelte';
 	import { ActionIcon } from '$lib/components/app';
-	import { deviceStore, modelsStore, uiStore } from '$lib/stores';
+	import { modelsStore, uiStore } from '$lib/stores';
 	import type { ModelOption } from '$lib/types/models';

 	interface Props {
@@ -29,11 +29,7 @@
 			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)}
+			onclick={() => uiStore.openModelDetails(option.id)}
 			tooltip="Manage model"
 			tooltipAsTitle
 		/>
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 002b283fb..a988b45b0 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte
@@ -1,8 +1,8 @@
 <script lang="ts">
-	import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
 	import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
 	import {
 		downloadGroups,
+		findModelOption,
 		groupModelQuants,
 		modelCapability,
 		modelContextLength,
@@ -10,13 +10,12 @@
 		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, deviceStore, modelsStore, serverStore, uiStore } from '$lib/stores';
+	import { modelsStore, uiStore } from '$lib/stores';
 	import type { ModelOption } from '$lib/types/models';
 	import { filterModelOptions } from '$lib/utils';
-	import { type Snippet, untrack } from 'svelte';
+	import type { Snippet } from 'svelte';
 	import { SvelteMap } from 'svelte/reactivity';

 	interface Props {
@@ -31,8 +30,6 @@
 	let contextLimit = $state(0);
 	let modalityFilter = $state<ModalityKey[]>([]);
 	let capabilityFilter = $state<ModelCapability[]>([]);
-	let selectedId = $state<string | null>(null);
-
 	// tracked downloads get their own section, listed like any other model
 	let downloads = $derived(
 		downloadGroups(modelsStore.status.getDownloadEntries(), modelsStore.models, (repo) =>
@@ -41,78 +38,10 @@
 	);

 	let allModels = $derived(modelsStore.models);
-
-	// the download groups stand in for rows the listing does not carry yet, so a
-	// click on them still finds its option and opens the pane
-	let selected = $derived(
-		allModels.find((option) => option.id === selectedId) ??
-			downloads.find((group) => group.base.id === selectedId)?.base ??
-			null
+	// the highlighted row is the model the global details drawer shows
+	let selectedId = $derived(
+		findModelOption(uiStore.modelDetailsFocus, allModels, downloads)?.id ?? null
 	);
-	// 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);
-	let isSwapping = $state(false);
-	let fade = $state<'open' | 'swap'>('open');
-	let shownOption = $derived(
-		allModels.find((option) => option.id === shownId) ??
-			downloads.find((group) => group.base.id === shownId)?.base ??
-			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)
@@ -230,31 +159,6 @@
 		return ordered;
 	});

-	// a caller can ask for one model to be revealed, the download rows do; a
-	// tracked download is not in the listing yet, so its own rows are searched too
-	$effect(() => {
-		const focus = uiStore.manageModelFocus;
-
-		if (!focus) return;
-
-		const matches = (candidate: ModelOption) => candidate.id === focus || candidate.model === focus;
-		const option =
-			allModels.find(matches) ?? downloads.find((group) => matches(group.base))?.base ?? null;
-
-		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);
-	}
-
 	/** Leave the models page for the chat it was opened from, with the composer focused. */
 	function returnToChat(): void {
 		uiStore.requestComposerFocus();
@@ -281,37 +185,6 @@

 		returnToChat();
 	}
-
-	/** 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>

 <svelte:window onkeydown={handleKeydown} />
@@ -325,69 +198,9 @@
 			bind:modalities={modalityFilter}
 			{groups}
 			{isFavorite}
-			onSelect={(option) => (selectedId = option.id)}
+			onSelect={(option) => uiStore.openModelDetails(option.id)}
 			{selectedId}
 			{toolbarEnd}
 		/>
 	</div>
 </div>
-
-<!-- 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]"
-	>
-		<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 min-h-0 flex-1 flex-col"
-			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>
-	</Drawer.Content>
-</Drawer.Root>
-
-<style>
-	.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);
-	}
-</style>
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelDrawer.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelDrawer.svelte
new file mode 100644
index 000000000..d9e7643ae
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelDrawer.svelte
@@ -0,0 +1,188 @@
+<script lang="ts">
+	import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
+	import { downloadGroups, findModelOption } from './utils';
+	import * as Drawer from '$lib/components/ui/drawer';
+	import { conversationsStore, deviceStore, modelsStore, serverStore, uiStore } from '$lib/stores';
+	import type { ModelOption } from '$lib/types/models';
+	import { untrack } from 'svelte';
+
+	// tracked downloads are not in the listing yet
+	let downloads = $derived(
+		downloadGroups(modelsStore.status.getDownloadEntries(), modelsStore.models, (repo) =>
+			modelsStore.status.getDraftSidecars(repo)
+		)
+	);
+
+	let selected = $derived(
+		findModelOption(uiStore.modelDetailsFocus, modelsStore.models, downloads)
+	);
+	// keep the last model on screen while the drawer closes
+	let lastPicked = $state<ModelOption | null>(null);
+	let target = $derived(selected ?? lastPicked);
+	let shownId = $state<string | null>(null);
+	let isSwapping = $state(false);
+	let fade = $state<'open' | 'swap'>('open');
+	let shownOption = $derived(findModelOption(shownId, modelsStore.models, downloads));
+
+	$effect(() => {
+		const option = selected;
+
+		if (!option) return;
+
+		// untracked: the effect must not track the state it writes
+		untrack(() => {
+			lastPicked = option;
+		});
+	});
+
+	/** 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);
+	});
+
+	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);
+	}
+
+	/** Close the drawer, the picker and the models page, then focus the composer. */
+	function returnToChat(): void {
+		uiStore.closeModelDetails();
+		uiStore.closeModelsSelector();
+		uiStore.requestComposerFocus();
+		void uiStore.closeModelsManager();
+	}
+
+	/** 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>
+
+<!-- bound both ways: vaul writes its own open state on a drag to dismiss, and a one-way prop would then keep it closed -->
+<Drawer.Root
+	bind:open={
+		() => selected !== null,
+		(open) => {
+			if (!open) uiStore.closeModelDetails();
+		}
+	}
+	direction={deviceStore.isMobile ? 'bottom' : 'right'}
+	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]"
+	>
+		<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 min-h-0 flex-1 flex-col"
+			data-fade={fade}
+			data-visible={selected !== null && !isSwapping}
+		>
+			{#if shownOption}
+				{#key shownId}
+					<ModelsManagerModelConfiguration
+						onClose={() => uiStore.closeModelDetails()}
+						onToggleLoad={() => void toggleLoad(shownOption)}
+						onUseInChat={() => void useInChat(shownOption)}
+						onUseInNewChat={() => void useInNewChat(shownOption)}
+						option={shownOption}
+					/>
+				{/key}
+			{/if}
+		</div>
+	</Drawer.Content>
+</Drawer.Root>
+
+<style>
+	.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);
+	}
+</style>
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 ccbe226c0..7241fc355 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts
@@ -72,6 +72,19 @@ export function downloadGroups(
 	});
 }

+/** Model with this qualified id or raw name, from the listing or else the tracked downloads. */
+export function findModelOption(
+	key: string | null,
+	models: ModelOption[],
+	downloads: ModelQuantGroup[]
+): ModelOption | null {
+	if (!key) return null;
+
+	const matches = (candidate: ModelOption) => candidate.id === key || candidate.model === key;
+
+	return models.find(matches) ?? downloads.find((group) => matches(group.base))?.base ?? null;
+}
+
 /** One collapsible block of the manager's table. */
 export interface ModelsTableGroup {
 	/** One entry per repo, its quants hanging off it. */
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
index 33b3b2f86..1ed74a3ea 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelector.svelte
@@ -14,8 +14,9 @@
 	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 { deviceStore, modelsStore, serverStore, settingsStore, uiStore } from '$lib/stores';
 	import { type ModelItem, modelLoadFraction } from '$lib/utils';
+	import { untrack } from 'svelte';

 	interface Props {
 		/** Model to show, when the caller owns it, e.g. the model of one message. */
@@ -165,6 +166,15 @@
 		}
 	}

+	// the details drawer's chat actions finish the pick, so an open picker closes too
+	$effect(() => {
+		if (!uiStore.modelsSelectorCloseSignal) return;
+
+		untrack(() => {
+			if (isOpen) ms.handleOpenChange(false);
+		});
+	});
+
 	// 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(() => {
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
index 7cc286f89..995bfe661 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte
@@ -6,8 +6,8 @@
 	import { ActionIcon, ModelId } from '$lib/components/app';
 	import { ModelRowDownloadState } from '$lib/enums';
 	import { HuggingFaceService, ModelsService } from '$lib/services';
-	import { deviceStore, modelsStore, uiStore } from '$lib/stores';
-	import type { ModelDownloadEntry, ModelOption, ModelSidecarBadge } from '$lib/types';
+	import { modelsStore, uiStore } from '$lib/stores';
+	import type { ModelDownloadEntry, ModelSidecarBadge } from '$lib/types';

 	interface Props {
 		/** One entry from the status feed: an in-flight or paused download. */
@@ -26,14 +26,6 @@
 	let pendingDrafts = $derived(sidecars.filter((badge) => badge.pending));
 	let onDiskDrafts = $derived(sidecars.filter((badge) => !badge.pending));

-	/** The download stands for its model, so the row's info action can open on it. */
-	let option = $derived<ModelOption>({
-		capabilities: [],
-		id: entry.repoWithTag,
-		model: entry.repoWithTag,
-		name: modelsStore.toDisplayName(entry.repoWithTag)
-	});
-
 	let percent = $derived(
 		entry.progress && entry.progress.totalBytes > 0
 			? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
@@ -81,11 +73,7 @@
 		<button
 			aria-label="Manage model"
 			class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
-			onclick={() =>
-				// a phone has no manager: its information dialog takes the row
-				deviceStore.isMobile
-					? uiStore.openModelInformation(option)
-					: uiStore.openModelsManager(entry.repoWithTag)}
+			onclick={() => uiStore.openModelDetails(entry.repoWithTag)}
 			type="button"
 		>
 			{#if orgName}
@@ -143,11 +131,7 @@
 			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-foreground [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
 			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(entry.repoWithTag)}
+			onclick={() => uiStore.openModelDetails(entry.repoWithTag)}
 			tooltip="Manage model"
 			tooltipAsTitle
 		/>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDrawer.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDrawer.svelte
index 1a2e8d3b8..c5d5a1377 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDrawer.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDrawer.svelte
@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { DropdownMenuSearchable } from '$lib/components/app';
 	import { Drawer, DrawerContent } from '$lib/components/ui/drawer';
+	import { uiStore } from '$lib/stores';
 	import type { Snippet } from 'svelte';

 	interface Props {
@@ -31,8 +32,12 @@
 	}: Props = $props();
 </script>

+<!-- the details drawer opens over the picker: the picker stays open under its backdrop -->
 <Drawer bind:open {onOpenChange}>
-	<DrawerContent class="overflow-hidden">
+	<DrawerContent
+		class={['overflow-hidden', uiStore.isModelDetailsOpen && 'z-40']}
+		overlayClass={uiStore.isModelDetailsOpen ? 'z-40' : undefined}
+	>
 		<!-- 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
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 d9e6be298..aeff24fd7 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte
@@ -175,9 +175,21 @@
 		{/if}
 	</Tooltip.Root>

+	<!-- the details drawer opens over the menu: the menu stays open under its backdrop -->
 	<DropdownMenu.Content
 		align="end"
 		class="w-full md:min-w-80 md:w-md max-w-[calc(100vw-2rem)] p-0! max-h-[min(40rem,calc(var(--bits-dropdown-menu-content-available-height)-1rem))]"
+		data-under-details={uiStore.isModelDetailsOpen || undefined}
+		onCloseAutoFocus={(event) => {
+			// bits-ui re-mounts the focus scope when the drawer opens or closes, so this runs with the menu still open; trigger focus would show its tooltip
+			if (open) event.preventDefault();
+		}}
+		onFocusOutside={(event) => {
+			if (uiStore.isModelDetailsOpen) event.preventDefault();
+		}}
+		onInteractOutside={(event) => {
+			if (uiStore.isModelDetailsOpen) event.preventDefault();
+		}}
 		onOpenAutoFocus={(event) => event.preventDefault()}
 	>
 		<DropdownMenuSearchable
@@ -246,3 +258,10 @@
 		/>
 	</div>
 {/snippet}
+
+<style>
+	/* bits-ui copies the content z-index to its floating wrapper only on mount, and the drawer portal is inserted before the wrapper, so at equal z-50 the menu covers the drawer; lower the wrapper while the drawer is open */
+	:global([data-bits-floating-content-wrapper]:has(> [data-under-details])) {
+		z-index: 40 !important;
+	}
+</style>
diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts
index 83f076fd1..c2736fb77 100644
--- a/tools/ui/src/lib/components/app/models/index.ts
+++ b/tools/ui/src/lib/components/app/models/index.ts
@@ -63,3 +63,6 @@ export { default as ModelDraftSidecars } from './ModelDraftSidecars.svelte';
 /** Compact draft download line, embedded in the item it belongs to. */
 export { default as ModelDraftSubline } from './ModelDraftSubline.svelte';
 export * from './ModelsSelector';
+
+/** Model details drawer, mounted once and opened over any page through `uiStore.openModelDetails`. */
+export { default as ModelsManagerModelDrawer } from './ModelsManager/ModelsManagerModelDrawer.svelte';
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 4430de50c..411862f0f 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
@@ -8,9 +8,9 @@
 		DialogConversationRename,
 		DialogDiscoverModels,
 		DialogMcpServers,
-		DialogModelInformation,
 		DialogSettingsChat,
 		Logo,
+		ModelsManagerModelDrawer,
 		SidebarNavigationActions,
 		SidebarNavigationConversationList
 	} from '$lib/components/app';
@@ -642,7 +642,7 @@

 <DialogDiscoverModels bind:open={uiStore.discoverModelsOpen} />

-<DialogModelInformation />
+<ModelsManagerModelDrawer />

 <style>
 	aside {
diff --git a/tools/ui/src/lib/components/ui/drawer/drawer-content.svelte b/tools/ui/src/lib/components/ui/drawer/drawer-content.svelte
index a864e813d..b4f85dd52 100644
--- a/tools/ui/src/lib/components/ui/drawer/drawer-content.svelte
+++ b/tools/ui/src/lib/components/ui/drawer/drawer-content.svelte
@@ -9,16 +9,18 @@
 	let {
 		children,
 		class: className,
+		overlayClass,
 		portalProps,
 		ref = $bindable(null),
 		...restProps
 	}: DrawerPrimitive.ContentProps & {
+		overlayClass?: string;
 		portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
 	} = $props();
 </script>

 <DrawerPortal {...portalProps}>
-	<DrawerOverlay />
+	<DrawerOverlay class={overlayClass} />

 	<DrawerPrimitive.Content
 		bind:ref
diff --git a/tools/ui/src/lib/stores/ui.svelte.ts b/tools/ui/src/lib/stores/ui.svelte.ts
index 354b31744..fb27e482f 100644
--- a/tools/ui/src/lib/stores/ui.svelte.ts
+++ b/tools/ui/src/lib/stores/ui.svelte.ts
@@ -20,7 +20,6 @@ import {
 } from '$lib/constants';
 import { deviceStore } from '$lib/stores/device.svelte';
 import { settingsStore } from '$lib/stores/settings/index.svelte';
-import type { ModelOption } from '$lib/types/models';
 import { remToPx } from '$lib/utils/css';

 class UiStore {
@@ -35,12 +34,12 @@ class UiStore {
 	/** Set while the user drags the sidebar edge, so width transitions can step aside. */
 	isSidebarResizing = $state(false);

-	/** Model the manager reveals when it opens, a qualified id or a raw model name. */
-	manageModelFocus = $state<string | null>(null);
-	/** Model the information dialog shows; a phone opens it instead of the manager. */
-	modelInformation = $state<ModelOption | null>(null);
+	/** Model the details drawer shows, a qualified id or a raw model name. */
+	modelDetailsFocus = $state<string | null>(null);
 	/** Route the models page returns to: the last page visited before it. */
 	modelsReturnRoute: string = ROUTES.START;
+	/** Bumped to close every open models selector; a counter, so no selector has to reset it. */
+	modelsSelectorCloseSignal = $state(0);
 	/** Expanded desktop sidebar width in px; null keeps the stylesheet default. */
 	sidebarWidth = $state<number | null>(this.loadSidebarWidth());

@@ -51,6 +50,11 @@ class UiStore {
 		return Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS));
 	}

+	/** Whether the model details drawer is open. */
+	get isModelDetailsOpen(): boolean {
+		return this.modelDetailsFocus !== null;
+	}
+
 	/** Whether the models manager route is the current page. */
 	get isModelsPageOpen(): boolean {
 		return page.route.id === MODELS_ROUTE_ID;
@@ -70,6 +74,11 @@ class UiStore {
 		localStorage.setItem(SIDEBAR_EXPANDED_LOCALSTORAGE_KEY, String(expanded));
 	}

+	/** Close the model details drawer. */
+	closeModelDetails(): void {
+		this.modelDetailsFocus = null;
+	}
+
 	/** Leave the models page for the route it was opened from. */
 	async closeModelsManager(): Promise<void> {
 		if (!this.isModelsPageOpen) return;
@@ -77,9 +86,14 @@ class UiStore {
 		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;
+	/** Close any open models selector. */
+	closeModelsSelector(): void {
+		this.modelsSelectorCloseSignal++;
+	}
+
+	/** Open the model details drawer over the current page, without leaving it. */
+	openModelDetails(focus: string): void {
+		this.modelDetailsFocus = focus;
 	}

 	/** Open the Discover dialog, focused on one repo; a no-op while Discover is off. */
@@ -90,10 +104,8 @@ class UiStore {
 		this.discoverModelsOpen = true;
 	}

-	/** Navigate to the models manager, optionally focused on one model. */
-	openModelsManager(focus?: string): void {
-		this.manageModelFocus = focus ?? null;
-
+	/** Navigate to the models manager. */
+	openModelsManager(): void {
 		if (this.isModelsPageOpen) return;

 		void goto(ROUTES.MODELS);