, prefix: string)}
+ {@const key = familyKey(prefix, family.key)}
+ {@const isExpanded = !collapsedFamilies.has(key)}
+
+
+{/snippet}
+
+{#snippet familyRows(items: ModelItem[], prefix: string)}
+ {#each groupModelFamilies(items, (item) => item.option.model) as family (familyKey(prefix, family.key))}
+ {@render familyRow(family, prefix)}
+
+ {#if !collapsedFamilies.has(familyKey(prefix, family.key))}
+ {#each family.entries as item (`family-${prefix}-${item.option.id}`)}
+ {@render render(item, !showOrgName)}
+ {/each}
+ {/if}
+ {/each}
+{/snippet}
+
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
@@ -165,11 +227,10 @@
{@render render(item, !showOrgName)}
{/each}
- {#each localGroups.available as group (group.orgName)}
- {#each group.items as item (item.option.id)}
- {@render render(item, !showOrgName)}
- {/each}
- {/each}
+ {@render familyRows(
+ localGroups.available.flatMap((group) => group.items),
+ 'local'
+ )}
{#if hasMoreLocal}
@@ -202,9 +263,7 @@
sticky
>
{#if provider.items.length > 0}
- {#each provider.items as item (`${provider.backendId}-${item.option.id}`)}
- {@render render(item, !showOrgName)}
- {/each}
+ {@render familyRows(provider.items, provider.backendId)}
{#if onProviderOpen && provider.matched > provider.items.length}
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 79c2c67fa9..f43596a469 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -131,6 +131,7 @@ export { isValidModelName, normalizeModelName, orgOf } from './model-names';
// Backend-qualified model option ids
export { backendIdFromModelId, qualifyModelId, rawModelId } from './model-option-id';
+export { groupModelFamilies, modelFamilyKey, type ModelFamilyGroup } from './model-families';
// Sidecar token utilities
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
diff --git a/tools/ui/src/lib/utils/model-families.ts b/tools/ui/src/lib/utils/model-families.ts
new file mode 100644
index 0000000000..342a91440d
--- /dev/null
+++ b/tools/ui/src/lib/utils/model-families.ts
@@ -0,0 +1,41 @@
+/** One family of a model list, and the entries it covers. */
+export interface ModelFamilyGroup {
+ entries: T[];
+ key: string;
+ label: string;
+}
+
+/**
+ * Family a repo belongs to, from its name. The version is dropped, so `Qwen3.5`,
+ * `Qwen3.8-27B` and `Qwen3.8-Flash-Next` all read as `Qwen`. A name that does not
+ * start with letters keeps its first segment.
+ */
+export function modelFamilyKey(repo: string): string {
+ const name = repo.split('/').pop() ?? repo;
+ const letters = name.match(/^[A-Za-z]+/);
+
+ return letters ? letters[0] : (name.split(/[-_.]/)[0] ?? name);
+}
+
+/** Fold entries into families, so `Qwen` collects its sizes and variants. */
+export function groupModelFamilies(
+ entries: T[],
+ modelOf: (entry: T) => string
+): ModelFamilyGroup[] {
+ const families = new Map>();
+
+ for (const entry of entries) {
+ const key = modelFamilyKey(modelOf(entry));
+ const family = families.get(key);
+
+ if (family) {
+ family.entries.push(entry);
+
+ continue;
+ }
+
+ families.set(key, { entries: [entry], key, label: key });
+ }
+
+ return Array.from(families.values());
+}