feat(ui): make the home model picker the shared model picker

Move HomeModelPicker to components/ModelPicker and give it what the
other pickers had: multiple selection with chips, free text, a clear
button, a search box on demand, a form-field look, and loading and
empty states. Every entry shows 'alias of X', disabled, pinned or
broken-alias tags from the capabilities listing, and entries that
cannot be used are not selectable. Auto-select skips them.

Add-ons keep HomeModelPicker as a name for the same component.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 08:40:07 +00:00
1 parent 688b12a038
commit dbb8e8a022
15 files changed
+454 -254

No files matched your search

+12 -1
View File
@@ -139,5 +139,16 @@
},
"complete": "ergänzen"
},
"notice": { "appUnavailable": "Die App ist nicht mehr verfügbar." }
"notice": { "appUnavailable": "Die App ist nicht mehr verfügbar." },
"picker": {
"aliasOf": "Alias von {{target}}",
"aliasBroken": "defekter Alias",
"disabled": "deaktiviert",
"pinned": "angeheftet",
"useTyped": "\"{{name}}\" verwenden",
"empty": "Keine Modelle verfügbar",
"add": "Modell hinzufügen",
"remove": "{{name}} entfernen",
"clear": "Leeren"
}
}
+10 -1
View File
@@ -171,7 +171,16 @@
"cold": "not loaded",
"loadedNow": "Loaded now",
"installed": "Installed",
"vision": "Understands images"
"vision": "Understands images",
"aliasOf": "alias of {{target}}",
"aliasBroken": "broken alias",
"disabled": "disabled",
"pinned": "pinned",
"useTyped": "Use \"{{name}}\"",
"empty": "No models available",
"add": "Add a model",
"remove": "Remove {{name}}",
"clear": "Clear"
},
"slash": {
"label": "Actions",
+12 -1
View File
@@ -139,5 +139,16 @@
},
"complete": "completar"
},
"notice": { "appUnavailable": "La aplicación ya no está disponible." }
"notice": { "appUnavailable": "La aplicación ya no está disponible." },
"picker": {
"aliasOf": "alias de {{target}}",
"aliasBroken": "alias roto",
"disabled": "desactivado",
"pinned": "fijado",
"useTyped": "Usar \"{{name}}\"",
"empty": "No hay modelos disponibles",
"add": "Añadir un modelo",
"remove": "Quitar {{name}}",
"clear": "Borrar"
}
}
+12 -1
View File
@@ -142,5 +142,16 @@
},
"complete": "compléter"
},
"notice": { "appUnavailable": "L’application n’est plus disponible." }
"notice": { "appUnavailable": "L’application n’est plus disponible." },
"picker": {
"aliasOf": "alias de {{target}}",
"aliasBroken": "alias cassé",
"disabled": "désactivé",
"pinned": "épinglé",
"useTyped": "Utiliser \"{{name}}\"",
"empty": "Aucun modèle disponible",
"add": "Ajouter un modèle",
"remove": "Retirer {{name}}",
"clear": "Effacer"
}
}
+12 -1
View File
@@ -139,5 +139,16 @@
},
"complete": "lengkapi"
},
"notice": { "appUnavailable": "Aplikasi tidak lagi tersedia." }
"notice": { "appUnavailable": "Aplikasi tidak lagi tersedia." },
"picker": {
"aliasOf": "alias dari {{target}}",
"aliasBroken": "alias rusak",
"disabled": "nonaktif",
"pinned": "disematkan",
"useTyped": "Gunakan \"{{name}}\"",
"empty": "Tidak ada model",
"add": "Tambah model",
"remove": "Hapus {{name}}",
"clear": "Kosongkan"
}
}
+12 -1
View File
@@ -139,5 +139,16 @@
},
"complete": "completa"
},
"notice": { "appUnavailable": "L’app non è più disponibile." }
"notice": { "appUnavailable": "L’app non è più disponibile." },
"picker": {
"aliasOf": "alias di {{target}}",
"aliasBroken": "alias non valido",
"disabled": "disattivato",
"pinned": "fissato",
"useTyped": "Usa \"{{name}}\"",
"empty": "Nessun modello disponibile",
"add": "Aggiungi un modello",
"remove": "Rimuovi {{name}}",
"clear": "Cancella"
}
}
+12 -1
View File
@@ -139,5 +139,16 @@
},
"complete": "완성"
},
"notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." }
"notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." },
"picker": {
"aliasOf": "{{target}}의 별칭",
"aliasBroken": "깨진 별칭",
"disabled": "비활성",
"pinned": "고정됨",
"useTyped": "\"{{name}}\" 사용",
"empty": "사용 가능한 모델 없음",
"add": "모델 추가",
"remove": "{{name}} 제거",
"clear": "지우기"
}
}
@@ -154,5 +154,16 @@
},
"complete": "completar"
},
"notice": { "appUnavailable": "O aplicativo não está mais disponível." }
"notice": { "appUnavailable": "O aplicativo não está mais disponível." },
"picker": {
"aliasOf": "alias de {{target}}",
"aliasBroken": "alias quebrado",
"disabled": "desativado",
"pinned": "fixado",
"useTyped": "Usar \"{{name}}\"",
"empty": "Nenhum modelo disponível",
"add": "Adicionar um modelo",
"remove": "Remover {{name}}",
"clear": "Limpar"
}
}
@@ -139,5 +139,16 @@
},
"complete": "补全"
},
"notice": { "appUnavailable": "应用已不可用。" }
"notice": { "appUnavailable": "应用已不可用。" },
"picker": {
"aliasOf": "{{target}} 的别名",
"aliasBroken": "别名失效",
"disabled": "已停用",
"pinned": "已固定",
"useTyped": "使用 \"{{name}}\"",
"empty": "没有可用模型",
"add": "添加模型",
"remove": "移除 {{name}}",
"clear": "清除"
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ import {
import LoadingSpinner from '../components/LoadingSpinner'
import Modal from '../components/Modal'
import Lightbox from '../components/Lightbox'
import HomeModelPicker from '../components/home/HomeModelPicker'
import ModelPicker from '../components/ModelPicker'
import { useMediaCapture, audioBufferToWavBlob } from '../hooks/useMediaCapture'
import { useModels } from '../hooks/useModels'
import { useResources } from '../hooks/useResources'
@@ -71,7 +71,7 @@ export const host = Object.freeze({
Section, Field, Select, TextInput, Switch, Choice, Callout, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink,
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
DistributionBars, EmbeddingInspector, TabSwitch,
LoadingSpinner, Modal, Lightbox, HomeModelPicker,
LoadingSpinner, Modal, Lightbox, ModelPicker, HomeModelPicker: ModelPicker,
},
hooks: {
useMediaCapture, useObjectUrl, useDelayedAction, audioBufferToWavBlob,
@@ -0,0 +1,326 @@
// eslint-disable-next-line no-unused-vars
import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModels } from '../hooks/useModels'
import { readLastModel, writeLastModel, readPreferredModel } from '../utils/lastModel'
import Icon from './Icon'
// How many models before the list gets a filter box, when `searchable` is left
// to decide.
const FILTER_FROM = 8
// The one model picker: a trigger and a listbox. A model is "warm" when the
// server reports it loaded, "cold" otherwise. Every entry the server lists
// shows what it is: an alias says which model it stands for, a disabled model
// cannot be chosen, a pinned one is marked, and an alias that points nowhere is
// listed as broken and cannot be chosen.
//
// value, onChange a name; with `multiple`, an array of names
// capability only models that have it (also read to remember a choice)
// multiple pick several; the list stays open and the picks show as chips
// allowCustom a typed name that is not in the list can be used as it is
// clearable a button empties the value
// searchable true: always a filter box; false: never; omitted: from 8 models
// variant "chip" (default, the compact Home chip) or "field" (a form control)
// autoSelect choose the remembered or the first model when the value is not listed
// models, loading the list is already held by the caller, so no second fetch
// grouped, loadedIds, showWarm, describe, onOpen, footer, labels
// defaultChoice, defaultSelected, onChooseDefault a first row for "use the default"
const ModelPicker = forwardRef(function ModelPicker(
{
value, onChange, capability, loadedIds, disabled = false, placeholder,
models: givenModels, loading: givenLoading, grouped = false, describe, onOpen, footer,
showWarm = true, labels, autoSelect = true, rememberSelection = true,
defaultChoice, defaultSelected = false, onChooseDefault, id, describedBy,
multiple = false, allowCustom = false, clearable = false, searchable,
variant = 'chip', className = '',
},
ref,
) {
const { t } = useTranslation('home')
const own = useModels(capability, { enabled: !givenModels })
const models = givenModels || own.models
const loading = givenModels ? !!givenLoading : own.loading
const byId = useMemo(() => new Map(models.map(m => [m.id, m])), [models])
const names = useMemo(() => models.map(m => m.id), [models])
const picked = useMemo(() => (multiple ? (Array.isArray(value) ? value : []) : []), [multiple, value])
const instanceId = useId()
const menuId = `${instanceId}-menu`
const listId = `${instanceId}-list`
// A null row is a non-model action, never an installed model ID.
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const rootRef = useRef(null)
const chipRef = useRef(null)
const menuRef = useRef(null)
const filterRef = useRef(null)
// A chooseable model is listed, enabled and, when it is an alias, resolved.
const usable = useCallback((name) => {
const m = byId.get(name)
return !!m && !m.disabled && !m.alias_error
}, [byId])
const usableNames = useMemo(() => names.filter(usable), [names, usable])
// The remembered model if it is still there, otherwise the first one.
// Auto-select is not a user choice, so it is not written back.
useEffect(() => {
if (multiple || allowCustom || !autoSelect || defaultSelected || usableNames.length === 0) return
if (value && usableNames.includes(value)) return
const preferred = readPreferredModel(capability)
const remembered = preferred && usableNames.includes(preferred) ? preferred : readLastModel(capability)
onChange(remembered && usableNames.includes(remembered) ? remembered : usableNames[0])
}, [usableNames, value, onChange, capability, autoSelect, defaultSelected, multiple, allowCustom])
const showFilter = searchable ?? names.length >= FILTER_FROM
// Loaded models first when the list is grouped; each group keeps its order.
// Rows are { name } for a model and { custom } for the typed name.
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const matches = (n) => {
if (!q) return true
const m = byId.get(n)
return n.toLowerCase().includes(q) || (m?.alias_of || '').toLowerCase().includes(q)
}
const found = names.filter(matches)
const ordered = grouped ? [...found.filter(n => loadedIds?.has(n)), ...found.filter(n => !loadedIds?.has(n))] : found
const rows = ordered.map(name => ({ name }))
const typed = query.trim()
if (allowCustom && typed && !names.includes(typed)) rows.push({ custom: typed })
return defaultChoice && (!q || defaultChoice.toLowerCase().includes(q)) ? [{ name: null }, ...rows] : rows
}, [names, byId, query, grouped, loadedIds, defaultChoice, allowCustom])
const close = useCallback((restoreFocus = false) => {
setOpen(false)
setQuery('')
if (restoreFocus) chipRef.current?.focus()
}, [])
const openMenu = useCallback(() => {
if (disabled || (names.length === 0 && !defaultChoice && !allowCustom)) return
setActive(defaultSelected || multiple ? 0 : Math.max(0, names.indexOf(value) + (defaultChoice ? 1 : 0)))
setQuery('')
setOpen(true)
onOpen?.()
}, [disabled, names, value, onOpen, defaultChoice, defaultSelected, multiple, allowCustom])
useImperativeHandle(ref, () => ({ open: openMenu, focus: () => chipRef.current?.focus() }), [openMenu])
useEffect(() => {
if (!open) return undefined
const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close() }
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open, close])
// Focus moves into the menu so the arrow keys work, and back to the chip when
// it closes by keyboard.
useEffect(() => {
if (!open) return
const el = filterRef.current || menuRef.current
el?.focus()
}, [open])
useEffect(() => {
if (!open) return
menuRef.current?.querySelector('[data-active="true"]')?.scrollIntoView?.({ block: 'nearest' })
}, [open, active, shown])
const pick = useCallback((row) => {
if (!row) return
if (row.name === null) { onChooseDefault?.(); close(true); return }
const name = row.custom ?? row.name
if (row.custom === undefined && !usable(name)) return
if (rememberSelection && !multiple) writeLastModel(capability, name)
if (multiple) {
onChange(picked.includes(name) ? picked.filter(n => n !== name) : [...picked, name])
setQuery('')
return
}
onChange(name)
close(true)
}, [capability, onChange, close, rememberSelection, onChooseDefault, usable, multiple, picked])
const onMenuKey = (e) => {
const n = shown.length
if (e.key === 'ArrowDown') { e.preventDefault(); if (n) setActive(a => (a + 1) % n) }
else if (e.key === 'ArrowUp') { e.preventDefault(); if (n) setActive(a => (a + n - 1) % n) }
else if (e.key === 'Home' && !query) { e.preventDefault(); setActive(0) }
else if (e.key === 'End' && !query) { e.preventDefault(); setActive(Math.max(0, n - 1)) }
else if (e.key === 'Enter') { e.preventDefault(); if (active < n) pick(shown[active]) }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) }
else if (e.key === 'Tab') close()
}
const isWarm = (name) => loadedIds?.has(name)
const current = multiple ? '' : (defaultSelected ? defaultChoice : value)
const emptyLabel = multiple ? (placeholder || t('picker.add')) : (placeholder || (loading ? t('picker.loading') : (labels?.none || t('picker.none'))))
const label = current || emptyLabel
const activeId = active < shown.length ? `${instanceId}-opt-${active}` : undefined
const trigger = variant === 'field' ? 'home-chip home-chip--field' : 'home-chip'
const badges = (name) => {
const m = byId.get(name)
if (!m) return null
return (
<>
{m.alias_of && !m.alias_error && <span className="home-menu__tag" data-kind="alias">{t('picker.aliasOf', { target: m.alias_of })}</span>}
{m.alias_error && <span className="home-menu__tag" data-kind="broken" title={m.alias_error}>{t('picker.aliasBroken')}</span>}
{m.disabled && <span className="home-menu__tag" data-kind="disabled">{t('picker.disabled')}</span>}
{m.pinned && <span className="home-menu__tag" data-kind="pinned">{t('picker.pinned')}</span>}
</>
)
}
return (
<div className={`home-pick${variant === 'field' ? ' home-pick--field' : ''} ${className}`.trim()} ref={rootRef}>
{multiple && picked.length > 0 && (
<ul className="home-pick__chips" aria-label={labels?.label || t('picker.label')}>
{picked.map(name => (
<li key={name} className="home-pick__tag">
<code>{name}</code>
<button type="button" className="home-pick__remove" disabled={disabled}
aria-label={t('picker.remove', { name })}
onClick={() => onChange(picked.filter(n => n !== name))}>
<Icon name="close" />
</button>
</li>
))}
</ul>
)}
<div className="home-pick__row">
<button
ref={chipRef}
id={id}
aria-label={labels?.label}
aria-describedby={[labels?.label && `${instanceId}-selection`, describedBy].filter(Boolean).join(' ') || undefined}
type="button"
className={`${trigger} home-chip--model`}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
title={labels?.title || t('picker.title')}
data-testid="home-model-chip"
onClick={() => (open ? close() : openMenu())}
onKeyDown={(e) => { if (e.key === 'ArrowDown' && !open) { e.preventDefault(); openMenu() } }}
>
<Icon name="cube" />
{showWarm && !multiple && !defaultSelected && <span className={`home-dot${value && isWarm(value) ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
<span id={labels?.label ? `${instanceId}-selection` : undefined} className="home-chip__text">{label}</span>
{!multiple && current && byId.get(current)?.alias_of && !byId.get(current)?.alias_error && (
<small className="home-chip__alias">{t('picker.aliasOf', { target: byId.get(current).alias_of })}</small>
)}
<Icon name="chevron-down" className="home-chip__caret" />
</button>
{clearable && !multiple && value && (
<button type="button" className="home-pick__remove" disabled={disabled}
aria-label={t('picker.clear')} onClick={() => onChange('')}>
<Icon name="close" />
</button>
)}
</div>
{open && (
<div className="home-menu home-menu--models" id={menuId} onKeyDown={onMenuKey}>
{(showFilter || allowCustom) && (
<input
ref={filterRef}
type="search"
className="home-menu__filter"
placeholder={t('picker.filter')}
aria-label={t('picker.filter')}
value={query}
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={activeId}
/>
)}
<ul
ref={menuRef}
id={listId}
className="home-menu__list"
role="listbox"
aria-multiselectable={multiple || undefined}
aria-label={labels?.label || t('picker.label')}
tabIndex={-1}
aria-activedescendant={activeId}
>
{!grouped && <li className="home-menu__head" role="presentation">{labels?.heading || t('picker.heading')}</li>}
{loading && <li className="home-menu__empty" role="presentation">{t('picker.loading')}</li>}
{!loading && shown.length === 0 && (
<li className="home-menu__empty" role="presentation">{names.length === 0 ? t('picker.empty') : t('picker.noMatch')}</li>
)}
{shown.map((row, i) => {
const { name, custom } = row
const warm = name != null && isWarm(name)
const info = name != null && describe ? describe(name, warm) : null
const chosen = custom !== undefined ? false
: name === null ? defaultSelected
: multiple ? picked.includes(name) : !defaultSelected && name === value
const blocked = name != null && !usable(name)
const prev = shown[i - 1]
const first = grouped && name != null && (i === 0 || prev?.name == null || isWarm(prev.name) !== warm)
return (
<Fragment key={custom !== undefined ? `${instanceId}-custom` : name === null ? `${instanceId}-default` : name}>
{first && (
<li className="home-menu__head" role="presentation">
{warm ? t('picker.loadedNow') : t('picker.installed')}
</li>
)}
<li
id={`${instanceId}-opt-${i}`}
role="option"
aria-selected={chosen}
aria-disabled={blocked || undefined}
data-active={i === active}
data-blocked={blocked || undefined}
className={`home-menu__item${info ? ' home-menu__item--rich' : ''}`}
onMouseMove={() => setActive(i)}
onClick={() => pick(row)}
>
{showWarm && name != null && <span className={`home-dot${warm ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
{custom !== undefined ? (
<code>{t('picker.useTyped', { name: custom })}</code>
) : info ? (
<>
<span className="home-menu__main">
<code>{name === null ? defaultChoice : name}</code>
{badges(name)}
{(info.size || info.vision) && (
<small className="home-menu__caps">
{info.size && <span>{info.size}</span>}
{info.vision && <Icon name="eye" title={t('picker.vision')} />}
</small>
)}
</span>
<span className="home-menu__state" data-tone={info.fit?.tone}>
<b>{chosen ? <Icon name="check" className="home-menu__check" /> : null}{warm ? t('picker.warm') : t('picker.cold')}</b>
{info.fit && <small>{info.fit.text}</small>}
</span>
</>
) : (
<>
<code>{name === null ? defaultChoice : name}</code>
{name != null && badges(name)}
{chosen
? <Icon name="check" className="home-menu__check" />
: (showWarm && name != null && !blocked && <small>{warm ? t('picker.warm') : t('picker.cold')}</small>)}
</>
)}
</li>
</Fragment>
)
})}
</ul>
{footer}
</div>
)}
</div>
)
})
export default ModelPicker
@@ -12,7 +12,7 @@ import { useModelFit } from '../../hooks/useModelFit'
import { useCommandsNotice } from '../../hooks/useCommandsNotice'
import { fillStyle, hostMemory, memoryFigure } from '../home/memory'
import HomeComposer from '../home/HomeComposer'
import HomeModelPicker from '../home/HomeModelPicker'
import ModelPicker from '../ModelPicker'
import AddonSlot from '../../addons/AddonSlot'
import AgentPicker from './AgentPicker'
import SkillsMenu from './SkillsMenu'
@@ -146,7 +146,7 @@ export default function ChatComposer({
const memory = hostMemory(modelFit.resources)
const memoryNumbers = memory ? memoryFigure(memory.used, memory.total) : null
const picker = (
<HomeModelPicker
<ModelPicker
ref={pickerRef}
value={effectiveModel}
onChange={chatId && onPickModel ? onPickModel : setDraftModel}
@@ -1,239 +0,0 @@
// eslint-disable-next-line no-unused-vars
import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModels } from '../../hooks/useModels'
import { readLastModel, writeLastModel, readPreferredModel } from '../../utils/lastModel'
import Icon from '../Icon'
// How many models before the list gets a filter box.
const FILTER_FROM = 8
// The model chip on the Home command bar and its listbox. A model is "warm"
// when the server reports it loaded (the same /system list the status strip
// reads), "cold" otherwise. Nothing about load time is shown: the API does not
// report it for an installed model.
//
// Chat uses the same chip and adds, all optional:
// models, loading the page already holds the list, so no second fetch
// grouped "Loaded now" and "Installed" instead of one list
// describe(name) { vision, size, fit: { tone, text } } for a row
// onOpen called when the list opens (to read memory and sizes)
// footer a node under the list (the memory bar)
// showWarm false for a list whose names are not loadable models (Talk's pipelines)
// labels { title, heading, label, none } to replace the chat wording
const HomeModelPicker = forwardRef(function HomeModelPicker(
{
value, onChange, capability, loadedIds, disabled = false, placeholder,
models: givenModels, loading: givenLoading, grouped = false, describe, onOpen, footer,
showWarm = true, labels, autoSelect = true, rememberSelection = true,
defaultChoice, defaultSelected = false, onChooseDefault, id, describedBy,
},
ref,
) {
const { t } = useTranslation('home')
const own = useModels(capability, { enabled: !givenModels })
const models = givenModels || own.models
const loading = givenModels ? !!givenLoading : own.loading
const names = useMemo(() => models.map(m => m.id), [models])
const instanceId = useId()
const menuId = `${instanceId}-menu`
const listId = `${instanceId}-list`
// A null row is a non-model action, never an installed model ID.
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const rootRef = useRef(null)
const chipRef = useRef(null)
const menuRef = useRef(null)
const filterRef = useRef(null)
// Same defaulting as ModelSelector: the remembered model if it is still
// there, otherwise the first one. Auto-select is not a user choice, so it is
// not written back.
useEffect(() => {
if (autoSelect && !defaultSelected && names.length > 0 && (!value || !names.includes(value))) {
const preferred = readPreferredModel(capability)
const remembered = preferred && names.includes(preferred) ? preferred : readLastModel(capability)
onChange(remembered && names.includes(remembered) ? remembered : names[0])
}
}, [names, value, onChange, capability, autoSelect, defaultSelected])
// Loaded models first when the list is grouped; each group keeps its order.
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const found = q ? names.filter(n => n.toLowerCase().includes(q)) : names
const ordered = grouped ? [...found.filter(n => loadedIds?.has(n)), ...found.filter(n => !loadedIds?.has(n))] : found
return defaultChoice && (!q || defaultChoice.toLowerCase().includes(q)) ? [null, ...ordered] : ordered
}, [names, query, grouped, loadedIds, defaultChoice])
const close = useCallback((restoreFocus = false) => {
setOpen(false)
setQuery('')
if (restoreFocus) chipRef.current?.focus()
}, [])
const openMenu = useCallback(() => {
if (disabled || (names.length === 0 && !defaultChoice)) return
setActive(defaultSelected ? 0 : Math.max(0, names.indexOf(value) + (defaultChoice ? 1 : 0)))
setQuery('')
setOpen(true)
onOpen?.()
}, [disabled, names, value, onOpen, defaultChoice, defaultSelected])
useImperativeHandle(ref, () => ({ open: openMenu, focus: () => chipRef.current?.focus() }), [openMenu])
useEffect(() => {
if (!open) return undefined
const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close() }
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open, close])
// Focus moves into the menu so the arrow keys work, and back to the chip when
// it closes by keyboard.
useEffect(() => {
if (!open) return
const el = filterRef.current || menuRef.current
el?.focus()
}, [open])
useEffect(() => {
if (!open) return
menuRef.current?.querySelector('[data-active="true"]')?.scrollIntoView?.({ block: 'nearest' })
}, [open, active, shown])
const pick = useCallback((name) => {
if (name === null) onChooseDefault?.()
else {
if (rememberSelection) writeLastModel(capability, name)
onChange(name)
}
close(true)
}, [capability, onChange, close, rememberSelection, onChooseDefault])
const onMenuKey = (e) => {
const n = shown.length
if (e.key === 'ArrowDown') { e.preventDefault(); if (n) setActive(a => (a + 1) % n) }
else if (e.key === 'ArrowUp') { e.preventDefault(); if (n) setActive(a => (a + n - 1) % n) }
else if (e.key === 'Home' && !query) { e.preventDefault(); setActive(0) }
else if (e.key === 'End' && !query) { e.preventDefault(); setActive(Math.max(0, n - 1)) }
else if (e.key === 'Enter') { e.preventDefault(); if (active < n) pick(shown[active]) }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) }
else if (e.key === 'Tab') close()
}
const isWarm = (name) => loadedIds?.has(name)
const label = (defaultSelected ? defaultChoice : value) || placeholder || (loading ? t('picker.loading') : (labels?.none || t('picker.none')))
const activeId = active < shown.length ? `${instanceId}-opt-${active}` : undefined
return (
<div className="home-pick" ref={rootRef}>
<button
ref={chipRef}
id={id}
aria-label={labels?.label}
aria-describedby={[labels?.label && `${instanceId}-selection`, describedBy].filter(Boolean).join(' ') || undefined}
type="button"
className="home-chip home-chip--model"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
title={labels?.title || t('picker.title')}
data-testid="home-model-chip"
onClick={() => (open ? close() : openMenu())}
onKeyDown={(e) => { if (e.key === 'ArrowDown' && !open) { e.preventDefault(); openMenu() } }}
>
<Icon name="cube" />
{showWarm && !defaultSelected && <span className={`home-dot${value && isWarm(value) ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
<span id={labels?.label ? `${instanceId}-selection` : undefined} className="home-chip__text">{label}</span>
<Icon name="chevron-down" className="home-chip__caret" />
</button>
{open && (
<div className="home-menu home-menu--models" id={menuId} onKeyDown={onMenuKey}>
{names.length >= FILTER_FROM && (
<input
ref={filterRef}
type="search"
className="home-menu__filter"
placeholder={t('picker.filter')}
aria-label={t('picker.filter')}
value={query}
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={activeId}
/>
)}
<ul
ref={menuRef}
id={listId}
className="home-menu__list"
role="listbox"
aria-label={labels?.label || t('picker.label')}
tabIndex={-1}
aria-activedescendant={activeId}
>
{!grouped && <li className="home-menu__head" role="presentation">{labels?.heading || t('picker.heading')}</li>}
{shown.length === 0 && <li className="home-menu__empty" role="presentation">{t('picker.noMatch')}</li>}
{shown.map((name, i) => {
const warm = isWarm(name)
const info = name !== null && describe ? describe(name, warm) : null
const selected = name === null ? defaultSelected : !defaultSelected && name === value
const first = grouped && (i === 0 || isWarm(shown[i - 1]) !== warm)
return (
<Fragment key={name === null ? `${instanceId}-default` : name}>
{first && (
<li className="home-menu__head" role="presentation">
{warm ? t('picker.loadedNow') : t('picker.installed')}
</li>
)}
<li
id={`${instanceId}-opt-${i}`}
role="option"
aria-selected={selected}
data-active={i === active}
className={`home-menu__item${info ? ' home-menu__item--rich' : ''}`}
onMouseMove={() => setActive(i)}
onClick={() => pick(name)}
>
{showWarm && name !== null && <span className={`home-dot${warm ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
{info ? (
<>
<span className="home-menu__main">
<code>{name === null ? defaultChoice : name}</code>
{(info.size || info.vision) && (
<small className="home-menu__caps">
{info.size && <span>{info.size}</span>}
{info.vision && <Icon name="eye" title={t('picker.vision')} />}
</small>
)}
</span>
<span className="home-menu__state" data-tone={info.fit?.tone}>
<b>{selected ? <Icon name="check" className="home-menu__check" /> : null}{warm ? t('picker.warm') : t('picker.cold')}</b>
{info.fit && <small>{info.fit.text}</small>}
</span>
</>
) : (
<>
<code>{name === null ? defaultChoice : name}</code>
{selected
? <Icon name="check" className="home-menu__check" />
: (showWarm && name !== null && <small>{warm ? t('picker.warm') : t('picker.cold')}</small>)}
</>
)}
</li>
</Fragment>
)
})}
</ul>
{footer}
</div>
)}
</div>
)
})
export default HomeModelPicker
@@ -265,3 +265,19 @@
.home-send-btn:active:not(:disabled),
.home-send-btn:hover:not(:disabled) .home-send-btn__glyph { transform: none; }
}
/* Shared model picker: form-field variant, alias and state tags, multi chips */
.home-pick--field { width: 100%; }
.home-pick--field .home-pick__row { display: flex; gap: var(--sp-1); }
.home-chip--field { flex: 1; width: 100%; justify-content: flex-start; height: var(--ctl-h); }
.home-chip--field .home-chip__caret { margin-left: auto; }
.home-chip__alias { color: var(--dk-muted); font-size: var(--fs-meta); white-space: nowrap; }
.home-pick__chips { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0 0 var(--sp-1); padding: 0; list-style: none; }
.home-pick__tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 4px 2px 10px; border-radius: var(--r-md); background: var(--dk-hover); }
.home-pick__tag code { font-family: var(--font-mono); font-size: var(--fs-small); background: none; padding: 0; }
.home-pick__remove { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dk-muted); cursor: pointer; padding: 4px; border-radius: var(--r-sm); }
.home-pick__remove:hover:not(:disabled) { background: var(--dk-hover); color: var(--dk-text); }
.home-menu__tag { flex: none; padding: 0 6px; border-radius: var(--r-sm); background: var(--dk-hover); color: var(--dk-muted); font-size: var(--fs-meta); white-space: nowrap; }
.home-menu__tag[data-kind="alias"] { background: var(--dk-accent-wash); color: var(--dk-accent-text); }
.home-menu__tag[data-kind="broken"] { color: var(--color-error, #c0392b); }
.home-menu__item[data-blocked="true"] { opacity: .55; cursor: not-allowed; }
+2 -2
View File
@@ -14,7 +14,7 @@ import './agents.css'
import './library.css'
import '../components/home/composer.css'
// eslint-disable-next-line no-unused-vars
import HomeModelPicker from '../components/home/HomeModelPicker'
import ModelPicker from '../components/ModelPicker'
// eslint-disable-next-line no-unused-vars
import NameList from '../components/ChatNameList'
// eslint-disable-next-line no-unused-vars
@@ -297,7 +297,7 @@ export default function AgentCreate() {
return (
<div className="form-group">
<label className="form-label" htmlFor="agent-model">{t('create.model')}</label>
<HomeModelPicker
<ModelPicker
id="agent-model"
labels={{ label: t('create.model') }}
autoSelect={false}