Commit 4a096b8ff for llama.cpp

commit 4a096b8ff671968009b760a5d857aee5d09250f8
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Wed Sep 30 12:42:18 2026 +0200

    ui : shared model display primitives (#29644)

    * ui : shared model display primitives

    Extract ModelCapabilityIcons (canonical Tools/Reasoning/Vision/Video/Audio
    order) out of ModelId and reuse it there, add the shared DialogConfirmDownload
    for destructive download actions, the discover org avatar with dark-mode
    inversion and the thin download progress bar, and rework ModelId badges to take
    thinking/tool-use support directly.

    Assisted-by: pi:GLM-5.3-Flash

    * ui : remember hub avatars that failed to load

    Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash

    * ui : render shared model row hints as native titles

    Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash

    * ui : fix badge guard for draft sidecars, keep parameter precision

    hasBadges now counts draft sidecar badges, so a sidecar-only model still
    renders. Billions keep one decimal for hub counts and stay bare for whole
    values. Avatar failures track the org instead of the instance, and the
    download progress bar no longer pulses while determinate.

    Assisted-by: pi:zai-org/GLM-5.3-Flash

diff --git a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte
index 0ed22d932..3116fc530 100644
--- a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte
+++ b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte
@@ -15,8 +15,9 @@
 		size?: ButtonSize;
 		stopPropagationOnClick?: boolean;
 		tooltip?: string;
-		variant?: ButtonVariant;
+		tooltipAsTitle?: boolean;
 		tooltipSide?: TooltipSide;
+		variant?: ButtonVariant;
 	}

 	let {
@@ -30,12 +31,13 @@
 		size = 'sm',
 		stopPropagationOnClick = false,
 		tooltip,
+		tooltipAsTitle = false,
 		tooltipSide = TooltipSide.TOP,
 		variant = 'ghost'
 	}: Props = $props();

 	let innerWidth = $state(0);
-	const showTooltip = $derived(!!tooltip && innerWidth > 768);
+	const showTooltip = $derived(!!tooltip && !tooltipAsTitle && innerWidth > 768);
 </script>

 {#snippet button(props = {})}
@@ -51,6 +53,7 @@
 			onclick?.(e);
 		}}
 		{size}
+		title={tooltipAsTitle ? tooltip : undefined}
 		{variant}
 	>
 		{#if icon}
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConfirmDownload.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConfirmDownload.svelte
new file mode 100644
index 000000000..e772810e9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/dialogs/DialogConfirmDownload.svelte
@@ -0,0 +1,60 @@
+<script lang="ts">
+	import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
+	import { ModelDownloadConfirmAction } from '$lib/enums';
+	import { modelsStore } from '$lib/stores';
+
+	interface Props {
+		/** Action being confirmed; drives the wording. */
+		action: ModelDownloadConfirmAction;
+		/** `<repo>:<tag>` the action targets. */
+		repoWithTag: string;
+		onClose: () => void;
+		/** Overrides the default store removal; defaults to removing the entry. */
+		onConfirm?: (repoWithTag: string) => void;
+		open?: boolean;
+	}
+
+	let { action, onClose, onConfirm, open = true, repoWithTag }: Props = $props();
+
+	// Both actions resolve through the same store removal (cancelDownload drops a
+	// running download's partial files or a cached model's files); only the copy
+	// differs. One component so the discover chips and the selector rows word the
+	// destructive confirmations identically.
+	const COPY = {
+		[ModelDownloadConfirmAction.CANCEL]: {
+			cancelText: 'Keep downloading',
+			confirmText: 'Cancel download',
+			description: (name: string) =>
+				`This stops the download of ${name} and removes the partial files. Pause it instead to keep the progress.`,
+			title: 'Cancel download'
+		},
+		[ModelDownloadConfirmAction.DELETE]: {
+			cancelText: 'Keep model',
+			confirmText: 'Delete',
+			description: (name: string) =>
+				`This permanently removes ${name} from disk. You can download it again later.`,
+			title: 'Delete model'
+		}
+	} as const;
+
+	let copy = $derived(COPY[action]);
+	let displayName = $derived(modelsStore.toDisplayName(repoWithTag));
+
+	function confirm() {
+		if (onConfirm) onConfirm(repoWithTag);
+		else void modelsStore.status.cancelDownload(repoWithTag);
+
+		onClose();
+	}
+</script>
+
+<DialogConfirmation
+	cancelText={copy.cancelText}
+	confirmText={copy.confirmText}
+	description={copy.description(displayName)}
+	onCancel={onClose}
+	onConfirm={confirm}
+	{open}
+	title={copy.title}
+	variant="destructive"
+/>
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index 1ffa7a2b9..9b0b62bd0 100644
--- a/tools/ui/src/lib/components/app/dialogs/index.ts
+++ b/tools/ui/src/lib/components/app/dialogs/index.ts
@@ -108,6 +108,17 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
  */
 export { default as DialogConfirmation } from './DialogConfirmation.svelte';

+/**
+ * **DialogConfirmDownload** - Confirm a destructive download action
+ *
+ * Shared confirmation for stopping/cancelling an in-flight download or deleting
+ * a downloaded model, used by the discover quant chips and the model selector's
+ * download rows so both word the action identically. Owns the copy and the
+ * default store removal; render one instance per surface keyed by the acted-on
+ * repo:tag.
+ */
+export { default as DialogConfirmDownload } from './DialogConfirmDownload.svelte';
+
 /**
  * **DialogConversationRename** - Rename a conversation
  *
diff --git a/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
new file mode 100644
index 000000000..c9505a95e
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/ModelCapabilityIcons.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+	import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
+	import * as Tooltip from '$lib/components/ui/tooltip';
+	import type { ModelModalities } from '$lib/types/models';
+
+	interface Props {
+		modalities?: ModelModalities;
+		supportsThinking?: boolean;
+		supportsToolUse?: boolean;
+		hideCapabilities?: boolean;
+		hideModalities?: boolean;
+		iconSize?: string;
+		gapClass?: string;
+	}
+
+	let {
+		gapClass = 'gap-1.25',
+		hideCapabilities = false,
+		hideModalities = false,
+		iconSize = 'h-3 w-3',
+		modalities,
+		supportsThinking = false,
+		supportsToolUse = false
+	}: Props = $props();
+
+	let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
+</script>
+
+<span class="inline-flex items-center {gapClass}">
+	{#if supportsToolUse && !hideCapabilities}
+		<Tooltip.Root>
+			<Tooltip.Trigger>
+				<Wrench class="{iconSize} text-muted-foreground" />
+			</Tooltip.Trigger>
+
+			<Tooltip.Content>
+				<p>Tool use</p>
+			</Tooltip.Content>
+		</Tooltip.Root>
+	{/if}
+
+	{#if supportsThinking && !hideCapabilities}
+		<Tooltip.Root>
+			<Tooltip.Trigger>
+				<Lightbulb class="{iconSize} text-muted-foreground" />
+			</Tooltip.Trigger>
+
+			<Tooltip.Content>
+				<p>Reasoning</p>
+			</Tooltip.Content>
+		</Tooltip.Root>
+	{/if}
+
+	{#if hasModalityIcons && !hideModalities}
+		<span class="inline-flex items-center text-muted-foreground">
+			{#if modalities?.vision}
+				<Tooltip.Root>
+					<Tooltip.Trigger>
+						<Image class={iconSize} />
+					</Tooltip.Trigger>
+
+					<Tooltip.Content>
+						<p>Vision</p>
+					</Tooltip.Content>
+				</Tooltip.Root>
+			{/if}
+
+			{#if modalities?.video}
+				<Tooltip.Root>
+					<Tooltip.Trigger>
+						<Video class={iconSize} />
+					</Tooltip.Trigger>
+
+					<Tooltip.Content>
+						<p>Video</p>
+					</Tooltip.Content>
+				</Tooltip.Root>
+			{/if}
+
+			{#if modalities?.audio}
+				<Tooltip.Root>
+					<Tooltip.Trigger>
+						<Mic class={iconSize} />
+					</Tooltip.Trigger>
+
+					<Tooltip.Content>
+						<p>Audio</p>
+					</Tooltip.Content>
+				</Tooltip.Root>
+			{/if}
+		</span>
+	{/if}
+</span>
diff --git a/tools/ui/src/lib/components/app/models/ModelId.svelte b/tools/ui/src/lib/components/app/models/ModelId.svelte
index 0b9722a73..ba3a976ae 100644
--- a/tools/ui/src/lib/components/app/models/ModelId.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelId.svelte
@@ -1,45 +1,69 @@
 <script lang="ts">
+	import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
+	import { Database, ScrollText } from '@lucide/svelte';
 	import { TruncatedText } from '$lib/components/app';
 	import * as Tooltip from '$lib/components/ui/tooltip';
-	import {
-		CAPABILITY_FLAG_KEYS,
-		CAPABILITY_ICONS,
-		CAPABILITY_LABELS,
-		MODALITY_FLAG_KEYS,
-		MODALITY_ICONS,
-		MODALITY_LABELS
-	} from '$lib/constants';
-	import { ModelCapability, ModelModality } from '$lib/enums';
+	import { type ModelSidecar } from '$lib/constants';
+	import { HuggingFaceService } from '$lib/services';
 	import { ModelsService } from '$lib/services/models.service';
 	import { settingsStore } from '$lib/stores';
-	import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
+	import type { ModelModalities } from '$lib/types/models';
+	import { isAuxSidecar } from '$lib/utils';
+	import { formatParameters } from '$lib/utils';

 	interface Props {
 		modelId: string;
 		hideOrgName?: boolean;
+		hideName?: boolean;
+		hideModalities?: boolean;
+		hideCapabilities?: boolean;
+		hideParameters?: boolean;
 		showRaw?: boolean;
 		showRawTooltip?: boolean;
 		hideQuantization?: boolean;
 		hideTags?: boolean;
 		aliases?: string[];
 		tags?: string[];
+		/** Render the capability/modality/context icons on a second row. */
+		iconsOnNewLine?: boolean;
 		modalities?: ModelModalities;
-		capabilities?: ModelCapabilities;
+		supportsThinking?: boolean;
+		supportsToolUse?: boolean;
+		/** Native title for the root element; keeps long lists light where a floating tooltip per row is too costly. */
+		title?: string;
+		/** Context length in tokens; renders a context icon when set. */
+		contextLength?: number;
+		/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
+		sizeRange?: { min: number; max: number } | null;
+		draftSidecars?: ModelSidecar[];
+		/** Allow badges to wrap onto new lines instead of truncating. */
+		wrap?: boolean;
 		class?: string;
 	}

 	let {
 		aliases,
-		capabilities,
 		class: className = '',
+		contextLength,
+		draftSidecars = [],
+		hideCapabilities = false,
+		hideModalities = false,
+		hideName = false,
 		hideOrgName = false,
+		hideParameters = false,
 		hideQuantization,
 		hideTags,
+		iconsOnNewLine = false,
 		modalities,
 		modelId,
 		showRaw = undefined,
 		showRawTooltip = false,
+		sizeRange,
+		supportsThinking = false,
+		supportsToolUse = false,
 		tags,
+		title,
+		wrap = false,
 		...rest
 	}: Props = $props();

@@ -47,6 +71,11 @@
 		'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
 	const tagBadgeClass =
 		'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
+	const variantBadgeClass =
+		'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
+
+	/** Alias badges beyond this many collapse into a single `+x more` badge. */
+	const MAX_ALIAS_BADGES = 2;

 	let parsed = $derived(ModelsService.parseModelId(modelId));
 	let resolvedShowRaw = $derived(
@@ -59,104 +88,149 @@

 	let uniqueAliases = $derived([...new Set(aliases ?? [])]);
 	let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
-
-	const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
-	const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
-
-	let activeModalities = $derived(
-		allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
-	);
-	let activeCapabilities = $derived(
-		allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
-	);
+	let uniqueDraftSidecars = $derived([...new Set(draftSidecars)].filter((s) => !isAuxSidecar(s)));

 	let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
 	let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
+
+	let hasBadges = $derived(
+		parsed.sidecar ||
+			uniqueDraftSidecars.length > 0 ||
+			(parsed.params && !hideParameters) ||
+			(parsed.quantization && !resolvedHideQuantization) ||
+			primaryAlias ||
+			uniqueAliases.length > 1 ||
+			(uniqueTags.length > 0 && !resolvedHideTags)
+	);
 </script>

 {#if resolvedShowRaw}
 	<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
 {:else}
 	{#snippet nameAndBadges()}
-		<span class="min-w-0 truncate font-medium">
-			{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
-		</span>
-
-		<span class="inline-flex items-center gap-1">
-			{#if parsed.params}
-				<span class={badgeClass}>
-					{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
-				</span>
-			{/if}
+		{#if !hideName}
+			<span class="min-w-0 truncate font-medium">
+				{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
+			</span>
+		{/if}

-			{#if parsed.quantization && !resolvedHideQuantization}
-				<span class={badgeClass}>
-					{parsed.quantization}
-				</span>
-			{/if}
+		{#if hasBadges}
+			<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
+				{#if parsed.sidecar}
+					<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
+						{parsed.sidecar}
+					</span>
+				{/if}

-			{#if primaryAlias}
-				{#if primaryAlias !== parsed.modelName}
-					<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
+				{#if parsed.params && !hideParameters}
+					<span class={badgeClass}>
+						{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
+					</span>
 				{/if}
-			{:else if uniqueAliases.length > 1}
-				{#each uniqueAliases as alias (alias)}
-					<span class={badgeClass}>{alias}</span>
-				{/each}
-			{/if}

-			{#if uniqueTags.length > 0 && !resolvedHideTags}
-				{#each uniqueTags as tag (tag)}
-					<span class={tagBadgeClass}>{tag}</span>
+				{#each uniqueDraftSidecars as sidecar (sidecar)}
+					<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
+						{sidecar}
+					</span>
 				{/each}
-			{/if}
-		</span>
-	{/snippet}

-	<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
-		{#if showRawTooltip}
-			<Tooltip.Root>
-				<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
-					{@render nameAndBadges()}
-				</Tooltip.Trigger>
-
-				<Tooltip.Content>
-					<p>{modelId}</p>
-				</Tooltip.Content>
-			</Tooltip.Root>
-		{:else}
-			{@render nameAndBadges()}
+				{#if parsed.quantization && !resolvedHideQuantization}
+					<span class={badgeClass}>
+						{parsed.quantization}
+					</span>
+				{/if}
+
+				{#if primaryAlias}
+					{#if primaryAlias !== parsed.modelName}
+						<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
+							{parsed.modelName ?? modelId}
+						</span>
+					{/if}
+				{:else if uniqueAliases.length > 1}
+					{#each uniqueAliases.slice(0, MAX_ALIAS_BADGES) as alias (alias)}
+						<span class="{badgeClass} max-w-32 truncate" title={alias}>
+							{alias}
+						</span>
+					{/each}
+
+					{#if uniqueAliases.length > MAX_ALIAS_BADGES}
+						<span class={badgeClass} title={uniqueAliases.slice(MAX_ALIAS_BADGES).join(', ')}>
+							+{uniqueAliases.length - MAX_ALIAS_BADGES} more
+						</span>
+					{/if}
+				{/if}
+
+				{#if uniqueTags.length > 0 && !resolvedHideTags}
+					{#each uniqueTags as tag (tag)}
+						<span class={tagBadgeClass}>{tag}</span>
+					{/each}
+				{/if}
+			</span>
 		{/if}
+	{/snippet}

-		{#if activeCapabilities.length > 0 || activeModalities.length > 0}
-			<span class="inline-flex items-center gap-1.25 text-muted-foreground">
-				{#each activeCapabilities as capability (capability)}
-					{@const CapabilityIcon = CAPABILITY_ICONS[capability]}
+	<span
+		class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
+			? 'flex-col items-start'
+			: ''} {className}"
+		{title}
+		{...rest}
+	>
+		<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
+			{#if showRawTooltip}
+				<Tooltip.Root>
+					<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
+						{@render nameAndBadges()}
+					</Tooltip.Trigger>
+
+					<Tooltip.Content>
+						<p>{modelId}</p>
+					</Tooltip.Content>
+				</Tooltip.Root>
+			{:else}
+				{@render nameAndBadges()}
+			{/if}

-					<Tooltip.Root>
-						<Tooltip.Trigger>
-							<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
-						</Tooltip.Trigger>
+			{#if !iconsOnNewLine}
+				<ModelCapabilityIcons
+					{hideCapabilities}
+					{hideModalities}
+					{modalities}
+					{supportsThinking}
+					{supportsToolUse}
+				/>
+			{/if}
+		</span>

-						<Tooltip.Content>
-							<p>{CAPABILITY_LABELS[capability]}</p>
-						</Tooltip.Content>
-					</Tooltip.Root>
-				{/each}
+		{#if iconsOnNewLine || contextLength || sizeRange}
+			<span class="inline-flex items-center gap-1.5">
+				{#if iconsOnNewLine}
+					<ModelCapabilityIcons
+						{hideCapabilities}
+						{hideModalities}
+						{modalities}
+						{supportsThinking}
+						{supportsToolUse}
+					/>
+				{/if}

-				{#each activeModalities as modality (modality)}
-					{@const ModalityIcon = MODALITY_ICONS[modality]}
+				{#if contextLength}
+					<span class="inline-flex items-center gap-1 text-muted-foreground">
+						<ScrollText class="h-3 w-3" />

-					<Tooltip.Root>
-						<Tooltip.Trigger>
-							<ModalityIcon class="h-3 w-3 text-muted-foreground" />
-						</Tooltip.Trigger>
+						<span class="text-xs">{formatParameters(contextLength)}</span>
+					</span>
+				{/if}

-						<Tooltip.Content>
-							<p>{MODALITY_LABELS[modality]}</p>
-						</Tooltip.Content>
-					</Tooltip.Root>
-				{/each}
+				{#if sizeRange}
+					<span class="inline-flex items-center gap-1 text-muted-foreground">
+						<Database class="h-3 w-3" />
+
+						<span class="text-xs"
+							>{HuggingFaceService.formatSizeRange(sizeRange.min, sizeRange.max)}</span
+						>
+					</span>
+				{/if}
 			</span>
 		{/if}
 	</span>
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
index 63b8ac664..cee7d0c3b 100644
--- a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
@@ -86,12 +86,13 @@
 >
 	<ModelId
 		aliases={option.aliases}
-		{capabilities}
 		class="flex-1"
 		{hideOrgName}
 		{modalities}
 		modelId={option.model}
 		showRawTooltip
+		supportsThinking={capabilities.reasoning}
+		supportsToolUse={capabilities.tools}
 		tags={option.tags}
 	/>

diff --git a/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte b/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte
new file mode 100644
index 000000000..6f4973c12
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+	interface Props {
+		downloadedBytes: number;
+		totalBytes: number;
+		overlay?: boolean;
+	}
+
+	let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
+
+	let fraction = $derived.by(() => {
+		if (totalBytes <= 0) return 0;
+
+		return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
+	});
+	let percent = $derived(Math.round(fraction * 100));
+</script>
+
+{#if overlay}
+	<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
+		<div
+			class="h-full bg-primary transition-[width] duration-200 ease-out"
+			style="width: {percent}%"
+		></div>
+	</div>
+{:else}
+	<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
+		<div
+			class="h-full bg-primary transition-[width] duration-200 ease-out"
+			style="width: {percent}%"
+		></div>
+	</div>
+{/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte
new file mode 100644
index 000000000..f78e74586
--- /dev/null
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte
@@ -0,0 +1,109 @@
+<script lang="ts">
+	import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
+	import { HuggingFaceService } from '$lib/services';
+	import { SvelteSet } from 'svelte/reactivity';
+
+	// Orgs whose avatar failed before. A row remounting (scrolling, reopening
+	// the selector) must not re-request them; the monogram renders instead.
+	const failedAvatarOrgs = new SvelteSet<string>();
+
+	interface Props {
+		class?: string;
+		org: string;
+		quantOrg?: string;
+		size?: string;
+		baseImageClass?: string;
+		quantImageClass?: string;
+		quantPositionClass?: string;
+		quantSize?: string;
+	}
+
+	let {
+		baseImageClass = '',
+		class: className = '',
+		org,
+		quantImageClass = 'h-full w-full',
+		quantOrg,
+		quantPositionClass = '-bottom-0.75 -right-0.75',
+		quantSize = 'h-4.25 w-4.25',
+		size = 'h-9 w-9'
+	}: Props = $props();
+
+	let orgAvatarFailed = $derived(failedAvatarOrgs.has(org));
+	let quantAvatarFailed = $derived(failedAvatarOrgs.has(quantOrg ?? ''));
+
+	let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
+	let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
+
+	// Monogram fallback: org initial on a hue derived from its name, so each org
+	// gets a stable distinct color.
+	let hue = $derived.by(() => {
+		let h = 0;
+
+		for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
+
+		return h % 360;
+	});
+
+	let quantHue = $derived.by(() => {
+		const name = quantOrg ?? '';
+
+		let h = 0;
+
+		for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
+
+		return h % 360;
+	});
+</script>
+
+<span class="relative mt-0.5 inline-flex shrink-0 {className}">
+	{#if orgAvatarFailed}
+		<span
+			aria-hidden="true"
+			class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
+			style="background-color: hsl({hue} 60% 45%)"
+		>
+			{org.charAt(0).toUpperCase()}
+		</span>
+	{:else}
+		<div class="rounded-md">
+			<img
+				alt=""
+				class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
+				loading="lazy"
+				onerror={() => {
+					failedAvatarOrgs.add(org);
+				}}
+				src={HuggingFaceService.getAvatarUrl(org)}
+			/>
+		</div>
+	{/if}
+
+	{#if quantOrg && quantOrg !== org}
+		<!-- native title instead of a floating tooltip: long model lists mount one badge per row -->
+		<span
+			class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted"
+			title={quantOrg}
+		>
+			{#if quantAvatarFailed}
+				<span
+					aria-hidden="true"
+					class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
+					style="background-color: hsl({quantHue} 60% 45%)"
+				>
+					{quantOrg.charAt(0).toUpperCase()}
+				</span>
+			{:else}
+				<img
+					alt=""
+					class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
+					loading="lazy"
+					onerror={() => {
+						failedAvatarOrgs.add(quantOrg ?? '');
+					}}
+					src={HuggingFaceService.getAvatarUrl(quantOrg)}
+				/>
+			{/if}
+		</span>
+	{/if}
+</span>
diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts
index 3ac6ecb67..f6a9f1f9b 100644
--- a/tools/ui/src/lib/components/app/models/index.ts
+++ b/tools/ui/src/lib/components/app/models/index.ts
@@ -109,3 +109,12 @@ export { default as ModelBadge } from './ModelBadge.svelte';
  * Respects the user's `showRawModelNames` setting.
  */
 export { default as ModelId } from './ModelId.svelte';
+
+/**
+ * **ModelCapabilityIcons** - Capability and modality icon row
+ *
+ * The shared tool-use / reasoning / vision / video / audio icon cluster with
+ * tooltips, used by ModelId and the discover details header so the order and
+ * styling stay consistent across every model-id surface.
+ */
+export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts
index f40cecfb6..20696f987 100644
--- a/tools/ui/src/lib/constants/ui.constants.ts
+++ b/tools/ui/src/lib/constants/ui.constants.ts
@@ -44,6 +44,9 @@ export const STATS_UNITS = {

 export const DEFAULT_MOBILE_BREAKPOINT = 768;

+/** Orgs whose avatar is dark and needs inverting in dark mode. */
+export const DARK_INVERT_AVATAR_ORGS = ['openai'];
+
 /** Icon used for the model selector and the `/model` slash command. */
 export const MODEL_SELECTOR_ICON = Package;

diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 947d1c757..583187d96 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -71,7 +71,7 @@ export {

 export { ModelAuxSidecar, ModelCapability, ModelDraftSidecar, ModelModality } from './model.enums';

-export { ModelDownloadStopRequest } from './model.enums';
+export { ModelDownloadConfirmAction, ModelDownloadStopRequest } from './model.enums';

 export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';

diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts
index 2440dc007..f2a0cc355 100644
--- a/tools/ui/src/lib/enums/model.enums.ts
+++ b/tools/ui/src/lib/enums/model.enums.ts
@@ -45,3 +45,13 @@ export enum ModelDownloadStopRequest {
 	CANCEL = 'cancel',
 	PAUSE = 'pause'
 }
+
+/**
+ * Destructive download action the user is asked to confirm: stop and discard an
+ * in-flight download, or delete an already-downloaded model from disk. Both
+ * resolve through the same store removal call, differing only in the copy.
+ */
+export enum ModelDownloadConfirmAction {
+	CANCEL = 'cancel',
+	DELETE = 'delete'
+}
diff --git a/tools/ui/src/lib/utils/formatters.ts b/tools/ui/src/lib/utils/formatters.ts
index 27555a47b..03e627984 100644
--- a/tools/ui/src/lib/utils/formatters.ts
+++ b/tools/ui/src/lib/utils/formatters.ts
@@ -28,6 +28,9 @@ export function formatFileSize(bytes: number | unknown): string {
 /**
  * Format parameter count to human-readable format (B, M, K)
  *
+ * Billions keep one decimal for hub counts (`15.2B`) and none for whole
+ * values (id-parsed counts are integers anyway, e.g. `8B`).
+ *
  * @param params - Parameter count
  * @returns Human-readable parameter count
  */
@@ -35,7 +38,9 @@ export function formatParameters(params: number | unknown): string {
 	if (typeof params !== 'number') return 'Unknown';

 	if (params >= 1e9) {
-		return `${(params / 1e9).toFixed(2)}B`;
+		const billions = params / 1e9;
+
+		return `${Number.isInteger(billions) ? billions : billions.toFixed(1)}B`;
 	}

 	if (params >= 1e6) {