ui : mark favorites with a colored heart in the model list

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 12:10:11 +02:00
parent d360b87e24
commit 552dd6ab38
3 changed files with 64 additions and 31 deletions
@@ -323,7 +323,11 @@
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{#snippet modelOption(
item: ModelItem,
hideOrgName: boolean,
showFavIndicator: boolean
)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
@@ -345,6 +349,7 @@
onSelect={ms.handleSelect}
{option}
showBaseModelAvatar
{showFavIndicator}
/>
{/snippet}
@@ -358,7 +363,7 @@
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="[&:not(:first-child)]:mt-3 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground select-none"
sectionHeaderClass="[&:not(:first-child)]:mt-3 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground/80 select-none"
{showOrgName}
/>
</div>
@@ -16,8 +16,8 @@
sectionHeaderClass?: string;
onSelect: (modelId: string) => void;
onInfoClick: (modelName: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
/** Favorite models of every backend; shown on the favorites tab. */
renderOption?: import('svelte').Snippet<[ModelItem, boolean, boolean]>;
/** Favorite models of every backend, listed in their own section. */
favorites?: ModelItem[];
/** Show the organization name in every model id of the list. */
showOrgName?: boolean;
@@ -62,7 +62,7 @@
}
</script>
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean, showFavIndicator: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
@@ -78,6 +78,7 @@
{onSelect}
{option}
showBaseModelAvatar
{showFavIndicator}
/>
{/snippet}
@@ -89,7 +90,7 @@
</p>
{#each favorites as item (`fav-${item.option.id}`)}
{@render render(item, !showOrgName)}
{@render render(item, !showOrgName, false)}
{/each}
{/if}
@@ -111,12 +112,12 @@
<!-- Local models: one list, the loaded ones first. -->
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, !showOrgName)}
{@render render(item, !showOrgName, true)}
{/each}
{#each groups.available as group (group.orgName)}
{#each group.items as item (item.option.id)}
{@render render(item, !showOrgName)}
{@render render(item, !showOrgName, true)}
{/each}
{/each}
@@ -149,7 +150,7 @@
{#if provider.items.length > 0}
{#each provider.items as item (`${provider.backendId}-${item.option.id}`)}
{@render render(item, !showOrgName)}
{@render render(item, !showOrgName, true)}
{/each}
{#if onProviderOpen && provider.matched > provider.items.length}
@@ -26,6 +26,9 @@
isSelected: boolean;
isHighlighted: boolean;
isFav: boolean;
/** Show the filled heart at rest. Off inside the favorites section, where
* every row is a favorite. */
showFavIndicator?: boolean;
hideOrgName?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
@@ -45,7 +48,8 @@
onMouseEnter,
onSelect,
option,
showBaseModelAvatar = false
showBaseModelAvatar = false,
showFavIndicator = true
}: Props = $props();
// row actions follow the backend that serves the row, not the selected one
@@ -163,18 +167,53 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pointer-events-none flex items-center justify-center gap-0.75 pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
class="pointer-events-none flex items-center justify-center gap-2 pl-2 group-hover:pointer-events-auto [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100 {isFav &&
showFavIndicator
? ''
: 'opacity-0 group-hover:opacity-100'}"
onclick={(e) => e.stopPropagation()}
>
<!-- info button: only shown when model is loaded and callback is provided -->
{#if isLoaded && onInfoClick}
<!-- the wrapper above stays visible for a favorite, this one does not -->
<span class="flex opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100">
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={Info}
iconSize="h-2.5 w-2.5"
onclick={() => onInfoClick(option.model)}
tooltip="Model information"
tooltipAsTitle
/>
</span>
{/if}
{#if isFav}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={HeartOff}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Remove from favorites"
tooltipAsTitle
/>
<!-- a favorite keeps a colored stroke heart at rest; hovering the icon
itself swaps it for the crossed one -->
<span class="group/heart flex h-3 w-3 items-center justify-center">
<span class="flex group-hover/heart:hidden">
<ActionIcon
class="h-3 w-3 text-rose-500 hover:text-foreground"
icon={Heart}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Remove from favorites"
tooltipAsTitle
/>
</span>
<span class="hidden group-hover/heart:flex">
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={HeartOff}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Remove from favorites"
tooltipAsTitle
/>
</span>
</span>
{:else}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
@@ -185,18 +224,6 @@
tooltipAsTitle
/>
{/if}
<!-- info button: only shown when model is loaded and callback is provided -->
{#if isLoaded && onInfoClick}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={Info}
iconSize="h-2.5 w-2.5"
onclick={() => onInfoClick(option.model)}
tooltip="Model information"
tooltipAsTitle
/>
{/if}
</div>
{#if !canLoad}