From d0746abae4d2890582143a6d06612d9369f6001a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 5 Aug 2026 14:09:12 +0200 Subject: [PATCH 01/11] feat(ui): manage visible provider models Extend the provider settings screen with searchable, virtualized per-provider model visibility controls so large plugin catalogs can be reduced to the models users actually need. Persist exact model allowlists as CodeNomad UI preferences without rewriting OpenCode configuration. Serialize optimistic preference updates across manager remounts, retain unavailable selections, respect visibility during default selection, and keep active session models usable. Add localized UI and failure states for every supported locale, pure normalization/filtering coverage, and the regression test to PR CI. Typecheck, the changed-behavior suite, browser-condition tests, and the production UI build pass. --- .github/workflows/pr-build.yml | 1 + packages/ui/src/components/model-selector.tsx | 118 ++++++---- .../provider-auth/provider-manager-modal.tsx | 112 +++++++--- .../provider-model-visibility-manager.tsx | 204 ++++++++++++++++++ .../ui/src/lib/i18n/messages/de/settings.ts | 21 ++ .../ui/src/lib/i18n/messages/en/settings.ts | 21 ++ .../ui/src/lib/i18n/messages/es/settings.ts | 21 ++ .../ui/src/lib/i18n/messages/fr/settings.ts | 21 ++ .../ui/src/lib/i18n/messages/he/settings.ts | 21 ++ .../ui/src/lib/i18n/messages/ja/settings.ts | 21 ++ .../ui/src/lib/i18n/messages/ne/settings.ts | 21 ++ .../ui/src/lib/i18n/messages/ru/settings.ts | 21 ++ .../src/lib/i18n/messages/zh-Hans/settings.ts | 21 ++ packages/ui/src/lib/model-visibility.test.ts | 43 ++++ packages/ui/src/lib/model-visibility.ts | 53 +++++ packages/ui/src/stores/preferences.tsx | 83 ++++++- packages/ui/src/stores/session-actions.ts | 7 + packages/ui/src/stores/session-models.ts | 30 ++- .../components/provider-model-visibility.css | 125 +++++++++++ packages/ui/src/styles/controls.css | 1 + 20 files changed, 886 insertions(+), 80 deletions(-) create mode 100644 packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx create mode 100644 packages/ui/src/lib/model-visibility.test.ts create mode 100644 packages/ui/src/lib/model-visibility.ts create mode 100644 packages/ui/src/styles/components/provider-model-visibility.css diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 02ccb32cf..33d19bc7b 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -109,6 +109,7 @@ jobs: packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/model-visibility.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index fba3680db..a1ad68f65 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -5,8 +5,9 @@ import { ChevronDown, PlugZap, Star } from "lucide-solid" import type { Model } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" -import { uiState, toggleFavoriteModelPreference } from "../stores/preferences" +import { getProviderModelVisibilityPreference, uiState, toggleFavoriteModelPreference } from "../stores/preferences" import { ProviderManagerModal } from "./provider-auth/provider-manager-modal" +import { isModelVisible } from "../lib/model-visibility" const log = getLogger("session") interface ModelSelectorProps { @@ -20,6 +21,7 @@ interface FlatModel extends Model { providerName: string key: string searchText: string + unavailable?: boolean } interface ModelGroup { @@ -67,7 +69,7 @@ export default function ModelSelector(props: ModelSelectorProps) { } }) - const allModels = createMemo(() => + const catalogModels = createMemo(() => instanceProviders().flatMap((p) => p.models.map((m) => ({ ...m, @@ -78,6 +80,14 @@ export default function ModelSelector(props: ModelSelectorProps) { ), ) + const allModels = createMemo(() => catalogModels().filter((model) => + isModelVisible( + getProviderModelVisibilityPreference(model.providerId), + model.id, + model.providerId === props.currentModel.providerId ? props.currentModel.modelId : undefined, + ), + )) + const sortedModels = createMemo(() => [...allModels()].sort((left, right) => { const providerComparison = compareIds(left.providerId, right.providerId) @@ -106,9 +116,22 @@ export default function ModelSelector(props: ModelSelectorProps) { const hasFavorites = createMemo(() => favoriteModels().length > 0) - const currentModelValue = createMemo(() => - allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId), - ) + const currentModelValue = createMemo(() => { + const current = props.currentModel + const found = catalogModels().find((model) => model.providerId === current.providerId && model.id === current.modelId) + if (found) return found + if (!current.providerId || !current.modelId) return undefined + const providerName = instanceProviders().find((provider) => provider.id === current.providerId)?.name ?? current.providerId + return { + id: current.modelId, + name: t("modelSelector.unavailableModel", { model: current.modelId }), + providerId: current.providerId, + providerName, + key: `${current.providerId}/${current.modelId}`, + searchText: `${current.modelId} ${providerName} ${current.providerId}`, + unavailable: true, + } + }) const currentModelIsFavorite = createMemo(() => { const current = props.currentModel @@ -134,10 +157,9 @@ export default function ModelSelector(props: ModelSelectorProps) { }) const visibleOptions = createMemo(() => { - if (!favoritesOnlyEnabled()) { - return sortedModels() - } - return favoriteModels() + const visible = favoritesOnlyEnabled() ? favoriteModels() : sortedModels() + const current = currentModelValue() + return current?.unavailable ? [current, ...visible] : visible }) const groupedVisibleOptions = createMemo(() => { @@ -170,7 +192,7 @@ export default function ModelSelector(props: ModelSelectorProps) { ) const handleChange = async (value: PickerOption | null) => { - if (!value || isProviderHeaderOption(value)) return + if (!value || isProviderHeaderOption(value) || value.unavailable) return await props.onModelChange({ providerId: value.providerId, modelId: value.id }) } @@ -278,7 +300,7 @@ export default function ModelSelector(props: ModelSelectorProps) { optionValue="key" optionTextValue="searchText" optionLabel={(option) => (isProviderHeaderOption(option) ? option.providerName : option.name)} - optionDisabled={isProviderHeaderOption} + optionDisabled={(option) => isProviderHeaderOption(option) || Boolean(option.unavailable)} placeholder={t("modelSelector.placeholder.search")} defaultFilter={customFilter} allowsEmptyCollection @@ -314,42 +336,44 @@ export default function ModelSelector(props: ModelSelectorProps) { - + {!model.unavailable && ( + + )} ) diff --git a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx index a7f4e5b81..772eba0b1 100644 --- a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx +++ b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx @@ -17,6 +17,10 @@ import { } from "../../lib/provider-auth" import { instances } from "../../stores/instances" import { fetchProviders } from "../../stores/sessions" +import { + ProviderModelVisibilityManager, + type ProviderVisibilityModel, +} from "./provider-model-visibility-manager" type AuthStage = "idle" | "prompts" | "authorizing" | "code" | "waiting" | "success" | "error" @@ -38,6 +42,7 @@ type ListedProvider = { id: string name: string modelCount: number + models: ProviderVisibilityModel[] source: "env" | "config" | "custom" | "api" | "unknown" } @@ -50,11 +55,13 @@ interface ProviderManagerModalProps { onOpenChange?: (open: boolean) => void } -function modelCountFromProvider(provider: any): number { +function modelsFromProvider(provider: any): ProviderVisibilityModel[] { const models = provider?.models - if (Array.isArray(models)) return models.length - if (models && typeof models === "object") return Object.keys(models).length - return 0 + if (!models || typeof models !== "object" || Array.isArray(models)) return [] + return Object.entries(models).map(([id, model]) => ({ + id, + name: typeof (model as any)?.name === "string" ? (model as any).name : id, + })) } export const ProviderManagerModal: Component = (props) => { @@ -66,6 +73,9 @@ export const ProviderManagerModal: Component = (props const [configData, setConfigData] = createSignal>({}) const [selectedProviderId, setSelectedProviderId] = createSignal(null) const [activeProviderId, setActiveProviderId] = createSignal(null) + const [managedProviderId, setManagedProviderId] = createSignal(null) + const manageModelButtons = new Map() + let managedProviderTriggerId: string | null = null const [selectedMethodIndex, setSelectedMethodIndex] = createSignal(0) const [apiKey, setApiKey] = createSignal("") const [promptValues, setPromptValues] = createSignal>({}) @@ -118,6 +128,10 @@ export const ProviderManagerModal: Component = (props availableProviders().filter((provider) => isConfiguredProvider(provider)), ) + const managedProvider = createMemo(() => + configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null, + ) + const getDisconnectMode = (provider: ListedProvider): DisconnectMode => { if (provider.source === "env") return "not-disconnectable" if (provider.source === "config" || configuredProviderIds().has(provider.id)) return "disable-in-config" @@ -270,15 +284,19 @@ export const ProviderManagerModal: Component = (props if (version !== loadVersion) return const nextConfigData = (configResponse?.data ?? {}) as Record const nextConfiguredIds = new Set(Object.keys((nextConfigData.provider ?? {}) as Record)) - const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => ({ - id: String(provider.id ?? ""), - name: String(provider.name ?? provider.id ?? ""), - modelCount: modelCountFromProvider(provider), - source: - provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api" - ? provider.source - : "unknown", - })).filter((provider) => provider.id.length > 0) + const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => { + const models = modelsFromProvider(provider) + return { + id: String(provider.id ?? ""), + name: String(provider.name ?? provider.id ?? ""), + modelCount: models.length, + models, + source: + provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api" + ? provider.source + : "unknown", + } as ListedProvider + }).filter((provider) => provider.id.length > 0) setAvailableProviders(listed) setConnectedProviderIds(new Set((providerListResponse?.data?.connected ?? []) as string[])) setConfiguredProviderIds(nextConfiguredIds) @@ -309,6 +327,7 @@ export const ProviderManagerModal: Component = (props setConfiguredProviderIds(new Set()) setConfigData({}) setSelectedProviderId(null) + setManagedProviderId(null) setLoadError(null) setLoading(false) } @@ -344,6 +363,24 @@ export const ProviderManagerModal: Component = (props await loadProviderData(authClient).catch(() => undefined) } + async function refreshProviderData() { + const authClient = client() + const instanceId = props.instanceId + if (!authClient) return + await (authClient as any).global.dispose().catch(() => undefined) + if (client() !== authClient || props.instanceId !== instanceId) return + await fetchProviders(instanceId).catch(() => undefined) + if (client() !== authClient || props.instanceId !== instanceId) return + await loadProviderData(authClient) + } + + function closeModelManager() { + setManagedProviderId(null) + queueMicrotask(() => { + if (managedProviderTriggerId) manageModelButtons.get(managedProviderTriggerId)?.focus() + }) + } + async function submitApiAuth(providerId: string, authClient: OpencodeClient, instanceId: string, operationVersion: number) { await requestData( (authClient as any).auth.set({ providerID: providerId, auth: { type: "api", key: apiKey().trim() } }), @@ -578,7 +615,7 @@ export const ProviderManagerModal: Component = (props - @@ -694,16 +731,43 @@ export const ProviderManagerModal: Component = (props

{t("settings.providers.configured.title")}

-
{t("settings.providers.loading")}
-
{t("settings.providers.empty.noConfiguredProviders")}
-
- {(provider) => ( -
-

{provider.name || provider.id}

{provider.id}

{methodSummary(provider.id)}

{describeProviderSource(provider)}

- -
- )}
-
+ +
{t("settings.providers.loading")}
+
{t("settings.providers.empty.noConfiguredProviders")}
+
+ {(provider) => ( +
+

{provider.name || provider.id}

{provider.id}

{methodSummary(provider.id)}

{describeProviderSource(provider)}

+ +
+ )}
+
+ + }> + {(provider) => ( + + )} +
diff --git a/packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx b/packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx new file mode 100644 index 000000000..cf3b2f19b --- /dev/null +++ b/packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx @@ -0,0 +1,204 @@ +import { VList } from "virtua/solid" +import { ArrowLeft } from "lucide-solid" +import { createEffect, createMemo, createSignal, on, onMount, Show, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { + getUnavailableSelectedModelIds, + normalizeModelVisibilityPreference, + seedCustomModelVisibility, + type ModelVisibilityPreference, +} from "../../lib/model-visibility" +import { useConfig } from "../../stores/preferences" + +export interface ProviderVisibilityModel { + id: string + name: string +} + +interface ProviderModelVisibilityManagerProps { + providerId: string + providerName: string + models: readonly ProviderVisibilityModel[] + onBack: () => void +} + +const compareModel = (left: ProviderVisibilityModel, right: ProviderVisibilityModel) => + left.name.localeCompare(right.name, undefined, { sensitivity: "base" }) || + left.id.localeCompare(right.id, undefined, { sensitivity: "base" }) + +export const ProviderModelVisibilityManager: Component = (props) => { + const { t } = useI18n() + const { + getProviderModelVisibilityPreference, + providerModelVisibilitySaveFailed, + setProviderModelVisibility, + } = useConfig() + const [query, setQuery] = createSignal("") + const [preference, setPreference] = createSignal({ mode: "all" }) + const headingId = "provider-model-visibility-heading" + let searchInput: HTMLInputElement | undefined + + const models = createMemo(() => [...props.models].sort(compareModel)) + const modelIds = createMemo(() => models().map((model) => model.id)) + const selectedIds = createMemo(() => { + const saved = preference() + return new Set(saved.mode === "custom" ? saved.modelIds : modelIds()) + }) + const selectedCurrentCount = createMemo(() => modelIds().filter((id) => selectedIds().has(id)).length) + const unavailableCount = createMemo(() => + getUnavailableSelectedModelIds(preference(), modelIds()).length, + ) + const filteredModels = createMemo(() => { + const value = query().trim().toLocaleLowerCase() + if (!value) return models() + return models().filter((model) => + model.name.toLocaleLowerCase().includes(value) || model.id.toLocaleLowerCase().includes(value), + ) + }) + + createEffect(on(() => props.providerId, (providerId) => { + setQuery("") + setPreference(getProviderModelVisibilityPreference(providerId)) + })) + + createEffect(() => { + setPreference(getProviderModelVisibilityPreference(props.providerId)) + }) + + onMount(() => queueMicrotask(() => searchInput?.focus())) + + const save = (next: ModelVisibilityPreference) => { + const normalized = normalizeModelVisibilityPreference(next) + setPreference(normalized) + void setProviderModelVisibility(props.providerId, normalized).catch(() => undefined) + } + + const setMode = (mode: "all" | "custom") => { + if (mode === "all") { + save({ mode: "all" }) + } else if (preference().mode !== "custom") { + save(seedCustomModelVisibility(modelIds())) + } + } + + const selectAll = () => { + const saved = preference() + const unavailable = saved.mode === "custom" ? getUnavailableSelectedModelIds(saved, modelIds()) : [] + save(seedCustomModelVisibility([...modelIds(), ...unavailable])) + } + + const toggleModel = (modelId: string, checked: boolean) => { + const saved = preference() + const ids = saved.mode === "custom" ? saved.modelIds : modelIds() + save({ + mode: "custom", + modelIds: checked ? [...ids, modelId] : ids.filter((id) => id !== modelId), + }) + } + + return ( +
+
+ +
+

+ {t("settings.providers.modelVisibility.title", { provider: props.providerName })} +

+

{t("settings.providers.modelVisibility.subtitle")}

+
+
+ +
+ {t("settings.providers.modelVisibility.mode.label")} + + +
+ +
+ + setQuery(event.currentTarget.value)} + placeholder={t("settings.providers.modelVisibility.search.placeholder")} + /> + + {t("settings.providers.modelVisibility.count", { selected: selectedCurrentCount(), total: models().length })} + + + +
+ + + + + + 0}> +

+ {unavailableCount() === 1 + ? t("settings.providers.modelVisibility.unavailable.one", { count: unavailableCount() }) + : t("settings.providers.modelVisibility.unavailable.other", { count: unavailableCount() })} +

+
+ + 0} + fallback={

{t("settings.providers.modelVisibility.empty")}

} + > + + {(model) => { + const position = () => filteredModels().findIndex((item) => item.id === model.id) + 1 + return ( + + ) + }} + +
+
+ ) +} diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 49794fa81..5f57b5e98 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "Zu Favoriten hinzufügen", "modelSelector.favorite.remove": "Aus Favoriten entfernen", "modelSelector.manageProviders": "Anbieter verwalten", + "modelSelector.unavailableModel": "{model} (nicht verfügbar)", + "modelSelector.error.noVisibleModels": "Wähle vor dem Senden einer Nachricht mindestens ein sichtbares Modell aus.", + "settings.providers.actions.manageModels": "Modelle verwalten", + "settings.providers.modelVisibility.back": "Zurück zu Anbietern", + "settings.providers.modelVisibility.title": "Modelle für {provider}", + "settings.providers.modelVisibility.subtitle": "Wählen Sie, welche Modelle CodeNomad in der Modellauswahl anzeigt.", + "settings.providers.modelVisibility.mode.label": "Modellsichtbarkeit", + "settings.providers.modelVisibility.mode.all": "Alle Modelle", + "settings.providers.modelVisibility.mode.all.description": "Aktuelle und neu gemeldete Modelle anzeigen.", + "settings.providers.modelVisibility.mode.custom": "Benutzerdefiniert", + "settings.providers.modelVisibility.mode.custom.description": "Nur ausgewählte Modell-IDs anzeigen.", + "settings.providers.modelVisibility.search.label": "Anbietermodelle suchen", + "settings.providers.modelVisibility.search.placeholder": "Nach Modellname oder ID suchen...", + "settings.providers.modelVisibility.count": "{selected} von {total} ausgewählt", + "settings.providers.modelVisibility.selectAll": "Alle auswählen", + "settings.providers.modelVisibility.selectNone": "Keine auswählen", + "settings.providers.modelVisibility.unavailable.one": "{count} ausgewähltes Modell ist derzeit nicht verfügbar.", + "settings.providers.modelVisibility.unavailable.other": "{count} ausgewählte Modelle sind derzeit nicht verfügbar.", + "settings.providers.modelVisibility.empty": "Keine Modelle entsprechen dieser Suche.", + "settings.providers.modelVisibility.listAriaLabel": "Modelle für {provider}", + "settings.providers.modelVisibility.saveFailed": "Die Modellsichtbarkeit konnte nicht gespeichert werden.", "thinkingSelector.variant.default": "Standard", "thinkingSelector.label": "Denken: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 7e79f5e06..76f6d7a00 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "Add to favorites", "modelSelector.favorite.remove": "Remove from favorites", "modelSelector.manageProviders": "Manage Providers", + "modelSelector.unavailableModel": "{model} (unavailable)", + "modelSelector.error.noVisibleModels": "Select at least one visible model before sending a message.", + "settings.providers.actions.manageModels": "Manage models", + "settings.providers.modelVisibility.back": "Back to providers", + "settings.providers.modelVisibility.title": "Models for {provider}", + "settings.providers.modelVisibility.subtitle": "Choose which models CodeNomad shows in model selectors.", + "settings.providers.modelVisibility.mode.label": "Model visibility mode", + "settings.providers.modelVisibility.mode.all": "All models", + "settings.providers.modelVisibility.mode.all.description": "Show current and newly reported models.", + "settings.providers.modelVisibility.mode.custom": "Custom", + "settings.providers.modelVisibility.mode.custom.description": "Show only selected model IDs.", + "settings.providers.modelVisibility.search.label": "Search provider models", + "settings.providers.modelVisibility.search.placeholder": "Search by model name or ID...", + "settings.providers.modelVisibility.count": "{selected} of {total} selected", + "settings.providers.modelVisibility.selectAll": "Select all", + "settings.providers.modelVisibility.selectNone": "Select none", + "settings.providers.modelVisibility.unavailable.one": "{count} selected model is currently unavailable.", + "settings.providers.modelVisibility.unavailable.other": "{count} selected models are currently unavailable.", + "settings.providers.modelVisibility.empty": "No models match this search.", + "settings.providers.modelVisibility.listAriaLabel": "Models for {provider}", + "settings.providers.modelVisibility.saveFailed": "Failed to save model visibility.", "thinkingSelector.variant.default": "Default", "thinkingSelector.label": "Thinking: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 268c30623..de135063c 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "Agregar a favoritos", "modelSelector.favorite.remove": "Quitar de favoritos", "modelSelector.manageProviders": "Gestionar proveedores", + "modelSelector.unavailableModel": "{model} (no disponible)", + "modelSelector.error.noVisibleModels": "Selecciona al menos un modelo visible antes de enviar un mensaje.", + "settings.providers.actions.manageModels": "Gestionar modelos", + "settings.providers.modelVisibility.back": "Volver a proveedores", + "settings.providers.modelVisibility.title": "Modelos de {provider}", + "settings.providers.modelVisibility.subtitle": "Elige qué modelos muestra CodeNomad en los selectores.", + "settings.providers.modelVisibility.mode.label": "Modo de visibilidad de modelos", + "settings.providers.modelVisibility.mode.all": "Todos los modelos", + "settings.providers.modelVisibility.mode.all.description": "Mostrar modelos actuales y nuevos.", + "settings.providers.modelVisibility.mode.custom": "Personalizado", + "settings.providers.modelVisibility.mode.custom.description": "Mostrar solo los ID de modelo seleccionados.", + "settings.providers.modelVisibility.search.label": "Buscar modelos del proveedor", + "settings.providers.modelVisibility.search.placeholder": "Buscar por nombre o ID del modelo...", + "settings.providers.modelVisibility.count": "{selected} de {total} seleccionados", + "settings.providers.modelVisibility.selectAll": "Seleccionar todos", + "settings.providers.modelVisibility.selectNone": "No seleccionar ninguno", + "settings.providers.modelVisibility.unavailable.one": "{count} modelo seleccionado no está disponible actualmente.", + "settings.providers.modelVisibility.unavailable.other": "{count} modelos seleccionados no están disponibles actualmente.", + "settings.providers.modelVisibility.empty": "Ningún modelo coincide con esta búsqueda.", + "settings.providers.modelVisibility.listAriaLabel": "Modelos de {provider}", + "settings.providers.modelVisibility.saveFailed": "No se pudo guardar la visibilidad de los modelos.", "thinkingSelector.variant.default": "Por defecto", "thinkingSelector.label": "Pensamiento: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 65c924f73..02455aef2 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "Ajouter aux favoris", "modelSelector.favorite.remove": "Retirer des favoris", "modelSelector.manageProviders": "Gérer les fournisseurs", + "modelSelector.unavailableModel": "{model} (indisponible)", + "modelSelector.error.noVisibleModels": "Sélectionnez au moins un modèle visible avant d’envoyer un message.", + "settings.providers.actions.manageModels": "Gérer les modèles", + "settings.providers.modelVisibility.back": "Retour aux fournisseurs", + "settings.providers.modelVisibility.title": "Modèles pour {provider}", + "settings.providers.modelVisibility.subtitle": "Choisissez les modèles affichés par CodeNomad dans les sélecteurs.", + "settings.providers.modelVisibility.mode.label": "Mode de visibilité des modèles", + "settings.providers.modelVisibility.mode.all": "Tous les modèles", + "settings.providers.modelVisibility.mode.all.description": "Afficher les modèles actuels et nouveaux.", + "settings.providers.modelVisibility.mode.custom": "Personnalisé", + "settings.providers.modelVisibility.mode.custom.description": "Afficher uniquement les identifiants sélectionnés.", + "settings.providers.modelVisibility.search.label": "Rechercher les modèles du fournisseur", + "settings.providers.modelVisibility.search.placeholder": "Rechercher par nom ou identifiant...", + "settings.providers.modelVisibility.count": "{selected} sur {total} sélectionnés", + "settings.providers.modelVisibility.selectAll": "Tout sélectionner", + "settings.providers.modelVisibility.selectNone": "Tout désélectionner", + "settings.providers.modelVisibility.unavailable.one": "{count} modèle sélectionné est actuellement indisponible.", + "settings.providers.modelVisibility.unavailable.other": "{count} modèles sélectionnés sont actuellement indisponibles.", + "settings.providers.modelVisibility.empty": "Aucun modèle ne correspond à cette recherche.", + "settings.providers.modelVisibility.listAriaLabel": "Modèles pour {provider}", + "settings.providers.modelVisibility.saveFailed": "Impossible d’enregistrer la visibilité des modèles.", "thinkingSelector.variant.default": "Par défaut", "thinkingSelector.label": "Réflexion : {variant}", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a7f9c7fb6..692da0e56 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "הוסף למועדפים", "modelSelector.favorite.remove": "הסר ממועדפים", "modelSelector.manageProviders": "נהל ספקים", + "modelSelector.unavailableModel": "{model} (לא זמין)", + "modelSelector.error.noVisibleModels": "יש לבחור לפחות מודל גלוי אחד לפני שליחת הודעה.", + "settings.providers.actions.manageModels": "ניהול מודלים", + "settings.providers.modelVisibility.back": "חזרה לספקים", + "settings.providers.modelVisibility.title": "מודלים עבור {provider}", + "settings.providers.modelVisibility.subtitle": "בחר אילו מודלים CodeNomad יציג בבוררי המודלים.", + "settings.providers.modelVisibility.mode.label": "מצב נראות מודלים", + "settings.providers.modelVisibility.mode.all": "כל המודלים", + "settings.providers.modelVisibility.mode.all.description": "הצג מודלים נוכחיים וחדשים.", + "settings.providers.modelVisibility.mode.custom": "מותאם אישית", + "settings.providers.modelVisibility.mode.custom.description": "הצג רק מזהי מודלים שנבחרו.", + "settings.providers.modelVisibility.search.label": "חיפוש מודלים של הספק", + "settings.providers.modelVisibility.search.placeholder": "חיפוש לפי שם או מזהה מודל...", + "settings.providers.modelVisibility.count": "{selected} מתוך {total} נבחרו", + "settings.providers.modelVisibility.selectAll": "בחר הכול", + "settings.providers.modelVisibility.selectNone": "בטל בחירת הכול", + "settings.providers.modelVisibility.unavailable.one": "מודל נבחר אחד אינו זמין כעת.", + "settings.providers.modelVisibility.unavailable.other": "{count} מודלים שנבחרו אינם זמינים כעת.", + "settings.providers.modelVisibility.empty": "אין מודלים התואמים לחיפוש.", + "settings.providers.modelVisibility.listAriaLabel": "מודלים עבור {provider}", + "settings.providers.modelVisibility.saveFailed": "שמירת נראות המודלים נכשלה.", "thinkingSelector.variant.default": "ברירת מחדל", "thinkingSelector.label": "חשיבה: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 6b266a108..4fd1fec78 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "お気に入りに追加", "modelSelector.favorite.remove": "お気に入りから削除", "modelSelector.manageProviders": "プロバイダーを管理", + "modelSelector.unavailableModel": "{model}(利用不可)", + "modelSelector.error.noVisibleModels": "メッセージを送信する前に、表示するモデルを1つ以上選択してください。", + "settings.providers.actions.manageModels": "モデルを管理", + "settings.providers.modelVisibility.back": "プロバイダーに戻る", + "settings.providers.modelVisibility.title": "{provider} のモデル", + "settings.providers.modelVisibility.subtitle": "CodeNomad のモデル選択に表示するモデルを選びます。", + "settings.providers.modelVisibility.mode.label": "モデル表示モード", + "settings.providers.modelVisibility.mode.all": "すべてのモデル", + "settings.providers.modelVisibility.mode.all.description": "現在および新しく報告されたモデルを表示します。", + "settings.providers.modelVisibility.mode.custom": "カスタム", + "settings.providers.modelVisibility.mode.custom.description": "選択したモデル ID のみ表示します。", + "settings.providers.modelVisibility.search.label": "プロバイダーのモデルを検索", + "settings.providers.modelVisibility.search.placeholder": "モデル名または ID で検索...", + "settings.providers.modelVisibility.count": "{total} 件中 {selected} 件を選択", + "settings.providers.modelVisibility.selectAll": "すべて選択", + "settings.providers.modelVisibility.selectNone": "すべて解除", + "settings.providers.modelVisibility.unavailable.one": "選択したモデル {count} 件は現在利用できません。", + "settings.providers.modelVisibility.unavailable.other": "選択したモデル {count} 件は現在利用できません。", + "settings.providers.modelVisibility.empty": "検索に一致するモデルはありません。", + "settings.providers.modelVisibility.listAriaLabel": "{provider} のモデル", + "settings.providers.modelVisibility.saveFailed": "モデルの表示設定を保存できませんでした。", "thinkingSelector.variant.default": "デフォルト", "thinkingSelector.label": "思考: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index cbfbb0953..0098295b9 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "मनपर्नेमा थप्नुहोस्", "modelSelector.favorite.remove": "मनपर्नेबाट हटाउनुहोस्", "modelSelector.manageProviders": "प्रदायकहरू व्यवस्थापन गर्नुहोस्", + "modelSelector.unavailableModel": "{model} (उपलब्ध छैन)", + "modelSelector.error.noVisibleModels": "सन्देश पठाउनु अघि कम्तीमा एउटा देखिने मोडेल चयन गर्नुहोस्।", + "settings.providers.actions.manageModels": "मोडेलहरू व्यवस्थापन गर्नुहोस्", + "settings.providers.modelVisibility.back": "प्रदायकहरूमा फर्कनुहोस्", + "settings.providers.modelVisibility.title": "{provider} का मोडेलहरू", + "settings.providers.modelVisibility.subtitle": "CodeNomad का मोडेल चयनकर्तामा देखिने मोडेलहरू छान्नुहोस्।", + "settings.providers.modelVisibility.mode.label": "मोडेल दृश्यता मोड", + "settings.providers.modelVisibility.mode.all": "सबै मोडेलहरू", + "settings.providers.modelVisibility.mode.all.description": "हालका र नयाँ मोडेलहरू देखाउनुहोस्।", + "settings.providers.modelVisibility.mode.custom": "अनुकूलित", + "settings.providers.modelVisibility.mode.custom.description": "चयन गरिएका मोडेल ID मात्र देखाउनुहोस्।", + "settings.providers.modelVisibility.search.label": "प्रदायकका मोडेलहरू खोज्नुहोस्", + "settings.providers.modelVisibility.search.placeholder": "मोडेल नाम वा ID बाट खोज्नुहोस्...", + "settings.providers.modelVisibility.count": "{total} मध्ये {selected} चयन गरियो", + "settings.providers.modelVisibility.selectAll": "सबै चयन गर्नुहोस्", + "settings.providers.modelVisibility.selectNone": "कुनै पनि चयन नगर्नुहोस्", + "settings.providers.modelVisibility.unavailable.one": "चयन गरिएको {count} मोडेल हाल उपलब्ध छैन।", + "settings.providers.modelVisibility.unavailable.other": "चयन गरिएका {count} मोडेलहरू हाल उपलब्ध छैनन्।", + "settings.providers.modelVisibility.empty": "यो खोजसँग मिल्ने मोडेल छैन।", + "settings.providers.modelVisibility.listAriaLabel": "{provider} का मोडेलहरू", + "settings.providers.modelVisibility.saveFailed": "मोडेल दृश्यता सुरक्षित गर्न असफल भयो।", "thinkingSelector.variant.default": "पूर्वनिर्धारित", "thinkingSelector.label": "सोचाइ: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index d364ab5a6..01317d680 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "Добавить в избранное", "modelSelector.favorite.remove": "Удалить из избранного", "modelSelector.manageProviders": "Управление провайдерами", + "modelSelector.unavailableModel": "{model} (недоступна)", + "modelSelector.error.noVisibleModels": "Перед отправкой сообщения выберите хотя бы одну видимую модель.", + "settings.providers.actions.manageModels": "Управление моделями", + "settings.providers.modelVisibility.back": "Назад к провайдерам", + "settings.providers.modelVisibility.title": "Модели {provider}", + "settings.providers.modelVisibility.subtitle": "Выберите модели, которые CodeNomad показывает в списках выбора.", + "settings.providers.modelVisibility.mode.label": "Режим видимости моделей", + "settings.providers.modelVisibility.mode.all": "Все модели", + "settings.providers.modelVisibility.mode.all.description": "Показывать текущие и новые модели.", + "settings.providers.modelVisibility.mode.custom": "Выборочно", + "settings.providers.modelVisibility.mode.custom.description": "Показывать только выбранные идентификаторы моделей.", + "settings.providers.modelVisibility.search.label": "Поиск моделей провайдера", + "settings.providers.modelVisibility.search.placeholder": "Поиск по имени или ID модели...", + "settings.providers.modelVisibility.count": "Выбрано {selected} из {total}", + "settings.providers.modelVisibility.selectAll": "Выбрать все", + "settings.providers.modelVisibility.selectNone": "Снять выбор", + "settings.providers.modelVisibility.unavailable.one": "{count} выбранная модель сейчас недоступна.", + "settings.providers.modelVisibility.unavailable.other": "{count} выбранных моделей сейчас недоступны.", + "settings.providers.modelVisibility.empty": "Нет моделей, соответствующих поиску.", + "settings.providers.modelVisibility.listAriaLabel": "Модели {provider}", + "settings.providers.modelVisibility.saveFailed": "Не удалось сохранить видимость моделей.", "thinkingSelector.variant.default": "По умолчанию", "thinkingSelector.label": "Размышления: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 1eed86ce0..c104f2ce8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -33,6 +33,27 @@ export const settingsMessages = { "modelSelector.favorite.add": "添加到收藏", "modelSelector.favorite.remove": "从收藏移除", "modelSelector.manageProviders": "管理提供商", + "modelSelector.unavailableModel": "{model}(不可用)", + "modelSelector.error.noVisibleModels": "发送消息前,请至少选择一个可见模型。", + "settings.providers.actions.manageModels": "管理模型", + "settings.providers.modelVisibility.back": "返回提供商列表", + "settings.providers.modelVisibility.title": "{provider} 的模型", + "settings.providers.modelVisibility.subtitle": "选择 CodeNomad 在模型选择器中显示的模型。", + "settings.providers.modelVisibility.mode.label": "模型可见性模式", + "settings.providers.modelVisibility.mode.all": "所有模型", + "settings.providers.modelVisibility.mode.all.description": "显示当前和新报告的模型。", + "settings.providers.modelVisibility.mode.custom": "自定义", + "settings.providers.modelVisibility.mode.custom.description": "仅显示已选择的模型 ID。", + "settings.providers.modelVisibility.search.label": "搜索提供商模型", + "settings.providers.modelVisibility.search.placeholder": "按模型名称或 ID 搜索...", + "settings.providers.modelVisibility.count": "已选择 {selected}/{total}", + "settings.providers.modelVisibility.selectAll": "全选", + "settings.providers.modelVisibility.selectNone": "全部取消", + "settings.providers.modelVisibility.unavailable.one": "当前有 {count} 个已选模型不可用。", + "settings.providers.modelVisibility.unavailable.other": "当前有 {count} 个已选模型不可用。", + "settings.providers.modelVisibility.empty": "没有与搜索匹配的模型。", + "settings.providers.modelVisibility.listAriaLabel": "{provider} 的模型", + "settings.providers.modelVisibility.saveFailed": "无法保存模型可见性设置。", "thinkingSelector.variant.default": "默认", "thinkingSelector.label": "思考:{variant}", diff --git a/packages/ui/src/lib/model-visibility.test.ts b/packages/ui/src/lib/model-visibility.test.ts new file mode 100644 index 000000000..31547e2f5 --- /dev/null +++ b/packages/ui/src/lib/model-visibility.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + getUnavailableSelectedModelIds, + isModelVisible, + normalizeModelVisibilityPreference, + normalizeModelVisibilityPreferences, + seedCustomModelVisibility, +} from "./model-visibility" + +test("normalizes malformed preferences fail-open", () => { + assert.deepEqual(normalizeModelVisibilityPreference(null), { mode: "all" }) + assert.deepEqual(normalizeModelVisibilityPreference({ mode: "custom" }), { mode: "all" }) + assert.deepEqual(normalizeModelVisibilityPreferences([]), {}) + assert.deepEqual(normalizeModelVisibilityPreferences({ broken: { mode: "nope" } }), { + broken: { mode: "all" }, + }) +}) + +test("keeps unique non-empty exact IDs without catalog-based deletion", () => { + assert.deepEqual( + normalizeModelVisibilityPreference({ mode: "custom", modelIds: ["Model-A", "model-a", "", "Model-A", 7] }), + { mode: "custom", modelIds: ["Model-A", "model-a"] }, + ) +}) + +test("matches IDs case-sensitively and overrides a hidden current model", () => { + const preference = { mode: "custom", modelIds: ["Model-A"] } as const + assert.equal(isModelVisible(preference, "Model-A"), true) + assert.equal(isModelVisible(preference, "model-a"), false) + assert.equal(isModelVisible(preference, "hidden", "hidden"), true) +}) + +test("new models follow mode while disappeared selections are retained", () => { + const custom = { mode: "custom", modelIds: ["known", "disappeared"] } as const + assert.equal(isModelVisible(custom, "new"), false) + assert.equal(isModelVisible({ mode: "all" }, "new"), true) + assert.deepEqual(getUnavailableSelectedModelIds(custom, ["known", "new"]), ["disappeared"]) + assert.deepEqual(seedCustomModelVisibility(["known", "new", "known"]), { + mode: "custom", + modelIds: ["known", "new"], + }) +}) diff --git a/packages/ui/src/lib/model-visibility.ts b/packages/ui/src/lib/model-visibility.ts new file mode 100644 index 000000000..edc3b093c --- /dev/null +++ b/packages/ui/src/lib/model-visibility.ts @@ -0,0 +1,53 @@ +export type ModelVisibilityPreference = + | { mode: "all" } + | { mode: "custom"; modelIds: readonly string[] } + +export type ModelVisibilityPreferences = Record + +export function normalizeModelVisibilityPreference(value: unknown): ModelVisibilityPreference { + if (!value || typeof value !== "object" || Array.isArray(value)) return { mode: "all" } + const candidate = value as { mode?: unknown; modelIds?: unknown } + if (candidate.mode === "all") return { mode: "all" } + if (candidate.mode !== "custom" || !Array.isArray(candidate.modelIds)) return { mode: "all" } + + return { + mode: "custom", + modelIds: Array.from(new Set(candidate.modelIds.filter( + (id): id is string => typeof id === "string" && id.trim().length > 0, + ))), + } +} + +export function normalizeModelVisibilityPreferences(value: unknown): ModelVisibilityPreferences { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + const result: ModelVisibilityPreferences = {} + for (const [providerId, preference] of Object.entries(value as Record)) { + if (!providerId) continue + result[providerId] = normalizeModelVisibilityPreference(preference) + } + return result +} + +export function isModelVisible( + preference: ModelVisibilityPreference | undefined, + modelId: string, + currentModelId?: string, +): boolean { + if (modelId === currentModelId) return true + const normalized = normalizeModelVisibilityPreference(preference) + return normalized.mode === "all" || normalized.modelIds.includes(modelId) +} + +export function getUnavailableSelectedModelIds( + preference: ModelVisibilityPreference | undefined, + currentModelIds: readonly string[], +): string[] { + const normalized = normalizeModelVisibilityPreference(preference) + if (normalized.mode === "all") return [] + const current = new Set(currentModelIds) + return normalized.modelIds.filter((id) => !current.has(id)) +} + +export function seedCustomModelVisibility(currentModelIds: readonly string[]): ModelVisibilityPreference { + return normalizeModelVisibilityPreference({ mode: "custom", modelIds: currentModelIds }) +} diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 2f39824b2..6412fa788 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -14,6 +14,12 @@ import { import { getLogger } from "../lib/logger" import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech" import { buildSpeechPatch } from "../lib/speech-patch" +import { + normalizeModelVisibilityPreference, + normalizeModelVisibilityPreferences, + type ModelVisibilityPreference, + type ModelVisibilityPreferences, +} from "../lib/model-visibility" const log = getLogger("actions") @@ -103,6 +109,7 @@ export interface UiSettings { usageMetricsExpansion: ExpansionPreference autoCleanupBlankSessions: boolean keepUnseenSubagentIdleStatus: boolean + modelVisibility: ModelVisibilityPreferences // OS notifications osNotificationsEnabled: boolean @@ -193,6 +200,7 @@ const defaultUiSettings: UiSettings = { usageMetricsExpansion: "collapsed", autoCleanupBlankSessions: true, keepUnseenSubagentIdleStatus: false, + modelVisibility: {}, osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -299,6 +307,7 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, keepUnseenSubagentIdleStatus: sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus, + modelVisibility: normalizeModelVisibilityPreferences(sanitized.modelVisibility), osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, osNotificationsAllowWhenVisible: sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, @@ -616,7 +625,11 @@ async function patchStateOwner(owner: string, patch: unknown) { function updateUiSettings(updates: Partial) { const current = uiConfigBucket() const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) - const patch = { settings: nextSettings } + const patch = { + settings: Object.fromEntries( + Object.keys(updates).map((key) => [key, nextSettings[key as keyof UiSettings]]), + ), + } void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) } @@ -624,6 +637,65 @@ function updatePreferences(updates: Partial): void { updateUiSettings(updates) } +const modelVisibilityWriteQueues = new Map>() +const [pendingModelVisibility, setPendingModelVisibility] = createSignal(new Map()) +const [modelVisibilityWriteFailures, setModelVisibilityWriteFailures] = createSignal(new Set()) + +function getProviderModelVisibilityPreference(providerId: string): ModelVisibilityPreference { + return pendingModelVisibility().get(providerId) + ?? normalizeModelVisibilityPreference(preferences().modelVisibility[providerId]) +} + +function providerModelVisibilitySaveFailed(providerId: string): boolean { + return modelVisibilityWriteFailures().has(providerId) +} + +async function setProviderModelVisibility(providerId: string, preference: ModelVisibilityPreference): Promise { + if (!providerId) return + const normalized = normalizeModelVisibilityPreference(preference) + setPendingModelVisibility((current) => new Map(current).set(providerId, normalized)) + setModelVisibilityWriteFailures((current) => { + const next = new Set(current) + next.delete(providerId) + return next + }) + const previous = modelVisibilityWriteQueues.get(providerId) ?? Promise.resolve() + const write = previous + .catch(() => undefined) + .then(() => patchConfigOwner("ui", { + settings: { + modelVisibility: { + [providerId]: normalized.mode === "all" ? { mode: "all", modelIds: null } : normalized, + }, + }, + })) + + modelVisibilityWriteQueues.set(providerId, write) + void write.then( + () => { + if (modelVisibilityWriteQueues.get(providerId) !== write) return + modelVisibilityWriteQueues.delete(providerId) + setPendingModelVisibility((current) => { + const next = new Map(current) + next.delete(providerId) + return next + }) + }, + (error) => { + log.error("Failed to update provider model visibility", error) + if (modelVisibilityWriteQueues.get(providerId) !== write) return + modelVisibilityWriteQueues.delete(providerId) + setPendingModelVisibility((current) => { + const next = new Map(current) + next.delete(providerId) + return next + }) + setModelVisibilityWriteFailures((current) => new Set(current).add(providerId)) + }, + ) + await write +} + function setThemePreference(preference: ThemePreference): void { if (themePreference() === preference) return void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error)) @@ -945,6 +1017,9 @@ interface ConfigContextValue { useTauriNativeEventTransport: typeof useTauriNativeEventTransport setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport updatePreferences: typeof updatePreferences + setProviderModelVisibility: typeof setProviderModelVisibility + getProviderModelVisibilityPreference: typeof getProviderModelVisibilityPreference + providerModelVisibilitySaveFailed: typeof providerModelVisibilitySaveFailed themePreference: typeof themePreference setThemePreference: typeof setThemePreference @@ -1008,6 +1083,9 @@ const configContextValue: ConfigContextValue = { useTauriNativeEventTransport, setUseTauriNativeEventTransport, updatePreferences, + setProviderModelVisibility, + getProviderModelVisibilityPreference, + providerModelVisibilitySaveFailed, themePreference, setThemePreference, serverSettings, @@ -1105,6 +1183,9 @@ export { themePreference, setThemePreference, updatePreferences, + setProviderModelVisibility, + getProviderModelVisibilityPreference, + providerModelVisibilitySaveFailed, setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 6c0db47c7..54d850688 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -12,6 +12,7 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" +import { tGlobal } from "../lib/i18n" const log = getLogger("actions") @@ -98,6 +99,9 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } + if (!session.model.providerId || !session.model.modelId) { + throw new Error(tGlobal("modelSelector.error.noVisibleModels")) + } const messageId = createId("msg") const textPartId = createId("prt") @@ -254,6 +258,9 @@ async function executeCustomCommand( if (!session) { throw new Error("Session not found") } + if (!session.model.providerId || !session.model.modelId) { + throw new Error(tGlobal("modelSelector.error.noVisibleModels")) + } const body: { command: string diff --git a/packages/ui/src/stores/session-models.ts b/packages/ui/src/stores/session-models.ts index 919e8a9ec..d32aadc2f 100644 --- a/packages/ui/src/stores/session-models.ts +++ b/packages/ui/src/stores/session-models.ts @@ -1,5 +1,6 @@ import { agents, providers } from "./session-state" -import { uiState, getAgentModelPreference } from "./preferences" +import { getProviderModelVisibilityPreference, uiState, getAgentModelPreference } from "./preferences" +import { isModelVisible } from "../lib/model-visibility" const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 @@ -14,12 +15,22 @@ function isModelValid( return provider.models.some((item) => item.id === model.modelId) } +function isModelSelectable( + instanceId: string, + model?: { providerId: string; modelId: string } | null, +): model is { providerId: string; modelId: string } { + return isModelValid(instanceId, model) && isModelVisible( + getProviderModelVisibilityPreference(model.providerId), + model.modelId, + ) +} + function getRecentModelPreferenceForInstance( instanceId: string, ): { providerId: string; modelId: string } | undefined { const recents = uiState().models.recents ?? [] for (const item of recents) { - if (isModelValid(instanceId, item)) { + if (isModelSelectable(instanceId, item)) { return item } } @@ -34,7 +45,7 @@ async function getDefaultModel( if (agentName) { const agent = instanceAgents.find((a) => a.name === agentName) - if (agent && agent.model && isModelValid(instanceId, agent.model)) { + if (agent && agent.model && isModelSelectable(instanceId, agent.model)) { return { providerId: agent.model.providerId, modelId: agent.model.modelId, @@ -42,7 +53,7 @@ async function getDefaultModel( } const stored = await getAgentModelPreference(instanceId, agentName) - if (isModelValid(instanceId, stored)) { + if (isModelSelectable(instanceId, stored)) { return stored } } @@ -54,7 +65,9 @@ async function getDefaultModel( for (const provider of instanceProviders) { if (provider.defaultModelId) { - const model = provider.models.find((m) => m.id === provider.defaultModelId) + const model = provider.models.find((m) => + m.id === provider.defaultModelId && isModelVisible(getProviderModelVisibilityPreference(provider.id), m.id), + ) if (model) { return { providerId: provider.id, @@ -64,9 +77,10 @@ async function getDefaultModel( } } - if (instanceProviders.length > 0) { - const firstProvider = instanceProviders[0] - const firstModel = firstProvider.models[0] + for (const firstProvider of instanceProviders) { + const firstModel = firstProvider.models.find((model) => + isModelVisible(getProviderModelVisibilityPreference(firstProvider.id), model.id), + ) if (firstModel) { return { providerId: firstProvider.id, diff --git a/packages/ui/src/styles/components/provider-model-visibility.css b/packages/ui/src/styles/components/provider-model-visibility.css new file mode 100644 index 000000000..fd98b82f4 --- /dev/null +++ b/packages/ui/src/styles/components/provider-model-visibility.css @@ -0,0 +1,125 @@ +.provider-model-visibility { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.provider-model-visibility-header { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-base); +} + +.provider-model-visibility-header > .selector-button { + flex: 0 0 auto; + width: auto; +} + +.provider-model-visibility-modes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + border: 0; +} + +.provider-model-visibility-mode { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.75rem; + border: 1px solid var(--border-base); + background: var(--surface-base); + color: var(--text-primary); + cursor: pointer; +} + +.provider-model-visibility-mode:has(input:checked) { + border-color: color-mix(in oklab, var(--accent-primary) 55%, var(--border-base)); + background: color-mix(in oklab, var(--accent-primary) 8%, var(--surface-base)); +} + +.provider-model-visibility-mode span, +.provider-model-visibility-item span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.2rem; +} + +.provider-model-visibility-mode small, +.provider-model-visibility-item small { + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-normal); + overflow-wrap: anywhere; +} + +.provider-model-visibility-toolbar { + display: grid; + grid-template-columns: minmax(10rem, 1fr) auto auto auto; + align-items: center; + gap: 0.5rem; +} + +.provider-model-visibility-count, +.provider-model-visibility-unavailable { + color: var(--text-secondary); + font-size: var(--font-size-xs); +} + +.provider-model-visibility-unavailable { + padding: 0.625rem 0.75rem; + border: 1px solid var(--border-base); + background: var(--surface-base); +} + +.provider-model-visibility-list { + height: min(22rem, 45dvh); + border: 1px solid var(--border-base); + background: var(--surface-base); +} + +.provider-model-visibility-item { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 3.375rem; + padding: 0.55rem 0.75rem; + border-bottom: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent); + color: var(--text-primary); + cursor: pointer; +} + +.provider-model-visibility-item:hover { + background: var(--surface-secondary); +} + +.provider-model-visibility-item:has(input:disabled) { + cursor: default; +} + +.provider-model-card-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +@media (max-width: 700px) { + .provider-model-visibility-modes, + .provider-model-visibility-toolbar { + grid-template-columns: 1fr; + } + + .provider-model-visibility-header, + .provider-model-card-actions { + align-items: stretch; + flex-direction: column; + } + + .provider-model-visibility-toolbar .selector-button { + width: 100%; + justify-content: center; + } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 2199a6441..f88e02100 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -14,3 +14,4 @@ @import "./components/settings-info.css"; @import "./components/config-files-settings.css"; @import "./components/provider-auth.css"; +@import "./components/provider-model-visibility.css"; From 059e08bf94ce0f159001c8c3e714a19175ac802b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 5 Aug 2026 15:24:57 +0200 Subject: [PATCH 02/11] fix(ui): keep model visibility in settings Keep the floating provider manager focused on quick connection and refresh actions. Expose the large-catalog visibility workflow only from the embedded Providers settings section, where the virtualized management view has sufficient context and space. --- .../provider-auth/provider-manager-modal.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx index 772eba0b1..430cafcf7 100644 --- a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx +++ b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx @@ -129,7 +129,9 @@ export const ProviderManagerModal: Component = (props ) const managedProvider = createMemo(() => - configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null, + props.embedded + ? configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null + : null, ) const getDisconnectMode = (provider: ListedProvider): DisconnectMode => { @@ -742,15 +744,17 @@ export const ProviderManagerModal: Component = (props From e799ba6c84e9d2fc4d9cfbd11114e78fa5b9e0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 01:59:57 +0200 Subject: [PATCH 03/11] fix(ui): simplify hidden model management Replace the virtualized allowlist workflow with a plain searchable checkbox list inspired by OpenChamber and OpenCode Desktop. Users can show or hide all models, while new provider models remain visible by default. Treat hiding as presentation state only: hidden models no longer affect defaults, execution, prompts, or commands, and an active hidden model remains usable. Persist exact hidden model IDs with the existing serialized optimistic writer. Update every locale and focused tests for hidden-model semantics. Root typecheck, targeted tests, production UI build, diff checks, and two Gatekeeper reviews pass. --- packages/ui/src/components/model-selector.tsx | 1 - .../provider-model-visibility-manager.tsx | 131 ++++-------------- .../ui/src/lib/i18n/messages/de/settings.ts | 20 +-- .../ui/src/lib/i18n/messages/en/settings.ts | 20 +-- .../ui/src/lib/i18n/messages/es/settings.ts | 20 +-- .../ui/src/lib/i18n/messages/fr/settings.ts | 20 +-- .../ui/src/lib/i18n/messages/he/settings.ts | 20 +-- .../ui/src/lib/i18n/messages/ja/settings.ts | 20 +-- .../ui/src/lib/i18n/messages/ne/settings.ts | 20 +-- .../ui/src/lib/i18n/messages/ru/settings.ts | 20 +-- .../src/lib/i18n/messages/zh-Hans/settings.ts | 20 +-- packages/ui/src/lib/model-visibility.test.ts | 40 +++--- packages/ui/src/lib/model-visibility.ts | 35 +---- packages/ui/src/stores/preferences.tsx | 2 +- packages/ui/src/stores/session-actions.ts | 7 - packages/ui/src/stores/session-models.ts | 30 ++-- .../components/provider-model-visibility.css | 40 +----- 17 files changed, 115 insertions(+), 351 deletions(-) diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index a1ad68f65..c82dfbd4b 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -84,7 +84,6 @@ export default function ModelSelector(props: ModelSelectorProps) { isModelVisible( getProviderModelVisibilityPreference(model.providerId), model.id, - model.providerId === props.currentModel.providerId ? props.currentModel.modelId : undefined, ), )) diff --git a/packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx b/packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx index cf3b2f19b..0ceea2a49 100644 --- a/packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx +++ b/packages/ui/src/components/provider-auth/provider-model-visibility-manager.tsx @@ -1,13 +1,6 @@ -import { VList } from "virtua/solid" import { ArrowLeft } from "lucide-solid" -import { createEffect, createMemo, createSignal, on, onMount, Show, type Component } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, onMount, Show, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" -import { - getUnavailableSelectedModelIds, - normalizeModelVisibilityPreference, - seedCustomModelVisibility, - type ModelVisibilityPreference, -} from "../../lib/model-visibility" import { useConfig } from "../../stores/preferences" export interface ProviderVisibilityModel { @@ -34,20 +27,14 @@ export const ProviderModelVisibilityManager: Component({ mode: "all" }) const headingId = "provider-model-visibility-heading" let searchInput: HTMLInputElement | undefined const models = createMemo(() => [...props.models].sort(compareModel)) const modelIds = createMemo(() => models().map((model) => model.id)) - const selectedIds = createMemo(() => { - const saved = preference() - return new Set(saved.mode === "custom" ? saved.modelIds : modelIds()) - }) - const selectedCurrentCount = createMemo(() => modelIds().filter((id) => selectedIds().has(id)).length) - const unavailableCount = createMemo(() => - getUnavailableSelectedModelIds(preference(), modelIds()).length, - ) + const preference = createMemo(() => getProviderModelVisibilityPreference(props.providerId)) + const hiddenIds = createMemo(() => new Set(preference().hiddenModelIds)) + const visibleCount = createMemo(() => modelIds().filter((id) => !hiddenIds().has(id)).length) const filteredModels = createMemo(() => { const value = query().trim().toLocaleLowerCase() if (!value) return models() @@ -56,45 +43,18 @@ export const ProviderModelVisibilityManager: Component props.providerId, (providerId) => { - setQuery("") - setPreference(getProviderModelVisibilityPreference(providerId)) - })) - - createEffect(() => { - setPreference(getProviderModelVisibilityPreference(props.providerId)) - }) - + createEffect(on(() => props.providerId, () => setQuery(""))) onMount(() => queueMicrotask(() => searchInput?.focus())) - const save = (next: ModelVisibilityPreference) => { - const normalized = normalizeModelVisibilityPreference(next) - setPreference(normalized) - void setProviderModelVisibility(props.providerId, normalized).catch(() => undefined) - } + const save = (hiddenModelIds: readonly string[]) => + void setProviderModelVisibility(props.providerId, { hiddenModelIds }).catch(() => undefined) - const setMode = (mode: "all" | "custom") => { - if (mode === "all") { - save({ mode: "all" }) - } else if (preference().mode !== "custom") { - save(seedCustomModelVisibility(modelIds())) - } + const toggleModel = (modelId: string, visible: boolean) => { + const current = preference().hiddenModelIds + save(visible ? current.filter((id) => id !== modelId) : [...current, modelId]) } - const selectAll = () => { - const saved = preference() - const unavailable = saved.mode === "custom" ? getUnavailableSelectedModelIds(saved, modelIds()) : [] - save(seedCustomModelVisibility([...modelIds(), ...unavailable])) - } - - const toggleModel = (modelId: string, checked: boolean) => { - const saved = preference() - const ids = saved.mode === "custom" ? saved.modelIds : modelIds() - save({ - mode: "custom", - modelIds: checked ? [...ids, modelId] : ids.filter((id) => id !== modelId), - }) - } + const hideAll = () => save([...preference().hiddenModelIds, ...modelIds()]) return (
@@ -111,28 +71,6 @@ export const ProviderModelVisibilityManager: Component -
- {t("settings.providers.modelVisibility.mode.label")} - - -
-
- {t("settings.providers.modelVisibility.count", { selected: selectedCurrentCount(), total: models().length })} + {t("settings.providers.modelVisibility.count.visible", { visible: visibleCount(), total: models().length })} - -
- - - - 0}> -

- {unavailableCount() === 1 - ? t("settings.providers.modelVisibility.unavailable.one", { count: unavailableCount() }) - : t("settings.providers.modelVisibility.unavailable.other", { count: unavailableCount() })} -

+
0} - fallback={

{t("settings.providers.modelVisibility.empty")}

} + fallback={

{t("settings.providers.modelVisibility.list.empty")}

} > - - {(model) => { - const position = () => filteredModels().findIndex((item) => item.id === model.id) + 1 - return ( - + )} +
) diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 5f57b5e98..f38a3446e 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "Aus Favoriten entfernen", "modelSelector.manageProviders": "Anbieter verwalten", "modelSelector.unavailableModel": "{model} (nicht verfügbar)", - "modelSelector.error.noVisibleModels": "Wähle vor dem Senden einer Nachricht mindestens ein sichtbares Modell aus.", "settings.providers.actions.manageModels": "Modelle verwalten", "settings.providers.modelVisibility.back": "Zurück zu Anbietern", "settings.providers.modelVisibility.title": "Modelle für {provider}", "settings.providers.modelVisibility.subtitle": "Wählen Sie, welche Modelle CodeNomad in der Modellauswahl anzeigt.", - "settings.providers.modelVisibility.mode.label": "Modellsichtbarkeit", - "settings.providers.modelVisibility.mode.all": "Alle Modelle", - "settings.providers.modelVisibility.mode.all.description": "Aktuelle und neu gemeldete Modelle anzeigen.", - "settings.providers.modelVisibility.mode.custom": "Benutzerdefiniert", - "settings.providers.modelVisibility.mode.custom.description": "Nur ausgewählte Modell-IDs anzeigen.", "settings.providers.modelVisibility.search.label": "Anbietermodelle suchen", "settings.providers.modelVisibility.search.placeholder": "Nach Modellname oder ID suchen...", - "settings.providers.modelVisibility.count": "{selected} von {total} ausgewählt", - "settings.providers.modelVisibility.selectAll": "Alle auswählen", - "settings.providers.modelVisibility.selectNone": "Keine auswählen", - "settings.providers.modelVisibility.unavailable.one": "{count} ausgewähltes Modell ist derzeit nicht verfügbar.", - "settings.providers.modelVisibility.unavailable.other": "{count} ausgewählte Modelle sind derzeit nicht verfügbar.", - "settings.providers.modelVisibility.empty": "Keine Modelle entsprechen dieser Suche.", - "settings.providers.modelVisibility.listAriaLabel": "Modelle für {provider}", - "settings.providers.modelVisibility.saveFailed": "Die Modellsichtbarkeit konnte nicht gespeichert werden.", + "settings.providers.modelVisibility.count.visible": "{visible} von {total} sichtbar", + "settings.providers.modelVisibility.actions.showAll": "Alle anzeigen", + "settings.providers.modelVisibility.actions.hideAll": "Alle ausblenden", + "settings.providers.modelVisibility.list.empty": "Keine Modelle entsprechen dieser Suche.", + "settings.providers.modelVisibility.list.label": "Modelle für {provider}", + "settings.providers.modelVisibility.save.failed": "Die Modellsichtbarkeit konnte nicht gespeichert werden.", "thinkingSelector.variant.default": "Standard", "thinkingSelector.label": "Denken: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 76f6d7a00..0696825cd 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "Remove from favorites", "modelSelector.manageProviders": "Manage Providers", "modelSelector.unavailableModel": "{model} (unavailable)", - "modelSelector.error.noVisibleModels": "Select at least one visible model before sending a message.", "settings.providers.actions.manageModels": "Manage models", "settings.providers.modelVisibility.back": "Back to providers", "settings.providers.modelVisibility.title": "Models for {provider}", "settings.providers.modelVisibility.subtitle": "Choose which models CodeNomad shows in model selectors.", - "settings.providers.modelVisibility.mode.label": "Model visibility mode", - "settings.providers.modelVisibility.mode.all": "All models", - "settings.providers.modelVisibility.mode.all.description": "Show current and newly reported models.", - "settings.providers.modelVisibility.mode.custom": "Custom", - "settings.providers.modelVisibility.mode.custom.description": "Show only selected model IDs.", "settings.providers.modelVisibility.search.label": "Search provider models", "settings.providers.modelVisibility.search.placeholder": "Search by model name or ID...", - "settings.providers.modelVisibility.count": "{selected} of {total} selected", - "settings.providers.modelVisibility.selectAll": "Select all", - "settings.providers.modelVisibility.selectNone": "Select none", - "settings.providers.modelVisibility.unavailable.one": "{count} selected model is currently unavailable.", - "settings.providers.modelVisibility.unavailable.other": "{count} selected models are currently unavailable.", - "settings.providers.modelVisibility.empty": "No models match this search.", - "settings.providers.modelVisibility.listAriaLabel": "Models for {provider}", - "settings.providers.modelVisibility.saveFailed": "Failed to save model visibility.", + "settings.providers.modelVisibility.count.visible": "{visible} of {total} visible", + "settings.providers.modelVisibility.actions.showAll": "Show all", + "settings.providers.modelVisibility.actions.hideAll": "Hide all", + "settings.providers.modelVisibility.list.empty": "No models match this search.", + "settings.providers.modelVisibility.list.label": "Models for {provider}", + "settings.providers.modelVisibility.save.failed": "Failed to save model visibility.", "thinkingSelector.variant.default": "Default", "thinkingSelector.label": "Thinking: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index de135063c..07e8f7686 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "Quitar de favoritos", "modelSelector.manageProviders": "Gestionar proveedores", "modelSelector.unavailableModel": "{model} (no disponible)", - "modelSelector.error.noVisibleModels": "Selecciona al menos un modelo visible antes de enviar un mensaje.", "settings.providers.actions.manageModels": "Gestionar modelos", "settings.providers.modelVisibility.back": "Volver a proveedores", "settings.providers.modelVisibility.title": "Modelos de {provider}", "settings.providers.modelVisibility.subtitle": "Elige qué modelos muestra CodeNomad en los selectores.", - "settings.providers.modelVisibility.mode.label": "Modo de visibilidad de modelos", - "settings.providers.modelVisibility.mode.all": "Todos los modelos", - "settings.providers.modelVisibility.mode.all.description": "Mostrar modelos actuales y nuevos.", - "settings.providers.modelVisibility.mode.custom": "Personalizado", - "settings.providers.modelVisibility.mode.custom.description": "Mostrar solo los ID de modelo seleccionados.", "settings.providers.modelVisibility.search.label": "Buscar modelos del proveedor", "settings.providers.modelVisibility.search.placeholder": "Buscar por nombre o ID del modelo...", - "settings.providers.modelVisibility.count": "{selected} de {total} seleccionados", - "settings.providers.modelVisibility.selectAll": "Seleccionar todos", - "settings.providers.modelVisibility.selectNone": "No seleccionar ninguno", - "settings.providers.modelVisibility.unavailable.one": "{count} modelo seleccionado no está disponible actualmente.", - "settings.providers.modelVisibility.unavailable.other": "{count} modelos seleccionados no están disponibles actualmente.", - "settings.providers.modelVisibility.empty": "Ningún modelo coincide con esta búsqueda.", - "settings.providers.modelVisibility.listAriaLabel": "Modelos de {provider}", - "settings.providers.modelVisibility.saveFailed": "No se pudo guardar la visibilidad de los modelos.", + "settings.providers.modelVisibility.count.visible": "{visible} de {total} visibles", + "settings.providers.modelVisibility.actions.showAll": "Mostrar todos", + "settings.providers.modelVisibility.actions.hideAll": "Ocultar todos", + "settings.providers.modelVisibility.list.empty": "Ningún modelo coincide con esta búsqueda.", + "settings.providers.modelVisibility.list.label": "Modelos de {provider}", + "settings.providers.modelVisibility.save.failed": "No se pudo guardar la visibilidad de los modelos.", "thinkingSelector.variant.default": "Por defecto", "thinkingSelector.label": "Pensamiento: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 02455aef2..aa91d1841 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "Retirer des favoris", "modelSelector.manageProviders": "Gérer les fournisseurs", "modelSelector.unavailableModel": "{model} (indisponible)", - "modelSelector.error.noVisibleModels": "Sélectionnez au moins un modèle visible avant d’envoyer un message.", "settings.providers.actions.manageModels": "Gérer les modèles", "settings.providers.modelVisibility.back": "Retour aux fournisseurs", "settings.providers.modelVisibility.title": "Modèles pour {provider}", "settings.providers.modelVisibility.subtitle": "Choisissez les modèles affichés par CodeNomad dans les sélecteurs.", - "settings.providers.modelVisibility.mode.label": "Mode de visibilité des modèles", - "settings.providers.modelVisibility.mode.all": "Tous les modèles", - "settings.providers.modelVisibility.mode.all.description": "Afficher les modèles actuels et nouveaux.", - "settings.providers.modelVisibility.mode.custom": "Personnalisé", - "settings.providers.modelVisibility.mode.custom.description": "Afficher uniquement les identifiants sélectionnés.", "settings.providers.modelVisibility.search.label": "Rechercher les modèles du fournisseur", "settings.providers.modelVisibility.search.placeholder": "Rechercher par nom ou identifiant...", - "settings.providers.modelVisibility.count": "{selected} sur {total} sélectionnés", - "settings.providers.modelVisibility.selectAll": "Tout sélectionner", - "settings.providers.modelVisibility.selectNone": "Tout désélectionner", - "settings.providers.modelVisibility.unavailable.one": "{count} modèle sélectionné est actuellement indisponible.", - "settings.providers.modelVisibility.unavailable.other": "{count} modèles sélectionnés sont actuellement indisponibles.", - "settings.providers.modelVisibility.empty": "Aucun modèle ne correspond à cette recherche.", - "settings.providers.modelVisibility.listAriaLabel": "Modèles pour {provider}", - "settings.providers.modelVisibility.saveFailed": "Impossible d’enregistrer la visibilité des modèles.", + "settings.providers.modelVisibility.count.visible": "{visible} sur {total} visibles", + "settings.providers.modelVisibility.actions.showAll": "Tout afficher", + "settings.providers.modelVisibility.actions.hideAll": "Tout masquer", + "settings.providers.modelVisibility.list.empty": "Aucun modèle ne correspond à cette recherche.", + "settings.providers.modelVisibility.list.label": "Modèles pour {provider}", + "settings.providers.modelVisibility.save.failed": "Impossible d’enregistrer la visibilité des modèles.", "thinkingSelector.variant.default": "Par défaut", "thinkingSelector.label": "Réflexion : {variant}", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 692da0e56..e15b28c5d 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "הסר ממועדפים", "modelSelector.manageProviders": "נהל ספקים", "modelSelector.unavailableModel": "{model} (לא זמין)", - "modelSelector.error.noVisibleModels": "יש לבחור לפחות מודל גלוי אחד לפני שליחת הודעה.", "settings.providers.actions.manageModels": "ניהול מודלים", "settings.providers.modelVisibility.back": "חזרה לספקים", "settings.providers.modelVisibility.title": "מודלים עבור {provider}", "settings.providers.modelVisibility.subtitle": "בחר אילו מודלים CodeNomad יציג בבוררי המודלים.", - "settings.providers.modelVisibility.mode.label": "מצב נראות מודלים", - "settings.providers.modelVisibility.mode.all": "כל המודלים", - "settings.providers.modelVisibility.mode.all.description": "הצג מודלים נוכחיים וחדשים.", - "settings.providers.modelVisibility.mode.custom": "מותאם אישית", - "settings.providers.modelVisibility.mode.custom.description": "הצג רק מזהי מודלים שנבחרו.", "settings.providers.modelVisibility.search.label": "חיפוש מודלים של הספק", "settings.providers.modelVisibility.search.placeholder": "חיפוש לפי שם או מזהה מודל...", - "settings.providers.modelVisibility.count": "{selected} מתוך {total} נבחרו", - "settings.providers.modelVisibility.selectAll": "בחר הכול", - "settings.providers.modelVisibility.selectNone": "בטל בחירת הכול", - "settings.providers.modelVisibility.unavailable.one": "מודל נבחר אחד אינו זמין כעת.", - "settings.providers.modelVisibility.unavailable.other": "{count} מודלים שנבחרו אינם זמינים כעת.", - "settings.providers.modelVisibility.empty": "אין מודלים התואמים לחיפוש.", - "settings.providers.modelVisibility.listAriaLabel": "מודלים עבור {provider}", - "settings.providers.modelVisibility.saveFailed": "שמירת נראות המודלים נכשלה.", + "settings.providers.modelVisibility.count.visible": "{visible} מתוך {total} גלויים", + "settings.providers.modelVisibility.actions.showAll": "הצג הכול", + "settings.providers.modelVisibility.actions.hideAll": "הסתר הכול", + "settings.providers.modelVisibility.list.empty": "אין מודלים התואמים לחיפוש.", + "settings.providers.modelVisibility.list.label": "מודלים עבור {provider}", + "settings.providers.modelVisibility.save.failed": "שמירת נראות המודלים נכשלה.", "thinkingSelector.variant.default": "ברירת מחדל", "thinkingSelector.label": "חשיבה: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 4fd1fec78..a85a906e4 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "お気に入りから削除", "modelSelector.manageProviders": "プロバイダーを管理", "modelSelector.unavailableModel": "{model}(利用不可)", - "modelSelector.error.noVisibleModels": "メッセージを送信する前に、表示するモデルを1つ以上選択してください。", "settings.providers.actions.manageModels": "モデルを管理", "settings.providers.modelVisibility.back": "プロバイダーに戻る", "settings.providers.modelVisibility.title": "{provider} のモデル", "settings.providers.modelVisibility.subtitle": "CodeNomad のモデル選択に表示するモデルを選びます。", - "settings.providers.modelVisibility.mode.label": "モデル表示モード", - "settings.providers.modelVisibility.mode.all": "すべてのモデル", - "settings.providers.modelVisibility.mode.all.description": "現在および新しく報告されたモデルを表示します。", - "settings.providers.modelVisibility.mode.custom": "カスタム", - "settings.providers.modelVisibility.mode.custom.description": "選択したモデル ID のみ表示します。", "settings.providers.modelVisibility.search.label": "プロバイダーのモデルを検索", "settings.providers.modelVisibility.search.placeholder": "モデル名または ID で検索...", - "settings.providers.modelVisibility.count": "{total} 件中 {selected} 件を選択", - "settings.providers.modelVisibility.selectAll": "すべて選択", - "settings.providers.modelVisibility.selectNone": "すべて解除", - "settings.providers.modelVisibility.unavailable.one": "選択したモデル {count} 件は現在利用できません。", - "settings.providers.modelVisibility.unavailable.other": "選択したモデル {count} 件は現在利用できません。", - "settings.providers.modelVisibility.empty": "検索に一致するモデルはありません。", - "settings.providers.modelVisibility.listAriaLabel": "{provider} のモデル", - "settings.providers.modelVisibility.saveFailed": "モデルの表示設定を保存できませんでした。", + "settings.providers.modelVisibility.count.visible": "{total} 件中 {visible} 件を表示", + "settings.providers.modelVisibility.actions.showAll": "すべて表示", + "settings.providers.modelVisibility.actions.hideAll": "すべて非表示", + "settings.providers.modelVisibility.list.empty": "検索に一致するモデルはありません。", + "settings.providers.modelVisibility.list.label": "{provider} のモデル", + "settings.providers.modelVisibility.save.failed": "モデルの表示設定を保存できませんでした。", "thinkingSelector.variant.default": "デフォルト", "thinkingSelector.label": "思考: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 0098295b9..54edf4262 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "मनपर्नेबाट हटाउनुहोस्", "modelSelector.manageProviders": "प्रदायकहरू व्यवस्थापन गर्नुहोस्", "modelSelector.unavailableModel": "{model} (उपलब्ध छैन)", - "modelSelector.error.noVisibleModels": "सन्देश पठाउनु अघि कम्तीमा एउटा देखिने मोडेल चयन गर्नुहोस्।", "settings.providers.actions.manageModels": "मोडेलहरू व्यवस्थापन गर्नुहोस्", "settings.providers.modelVisibility.back": "प्रदायकहरूमा फर्कनुहोस्", "settings.providers.modelVisibility.title": "{provider} का मोडेलहरू", "settings.providers.modelVisibility.subtitle": "CodeNomad का मोडेल चयनकर्तामा देखिने मोडेलहरू छान्नुहोस्।", - "settings.providers.modelVisibility.mode.label": "मोडेल दृश्यता मोड", - "settings.providers.modelVisibility.mode.all": "सबै मोडेलहरू", - "settings.providers.modelVisibility.mode.all.description": "हालका र नयाँ मोडेलहरू देखाउनुहोस्।", - "settings.providers.modelVisibility.mode.custom": "अनुकूलित", - "settings.providers.modelVisibility.mode.custom.description": "चयन गरिएका मोडेल ID मात्र देखाउनुहोस्।", "settings.providers.modelVisibility.search.label": "प्रदायकका मोडेलहरू खोज्नुहोस्", "settings.providers.modelVisibility.search.placeholder": "मोडेल नाम वा ID बाट खोज्नुहोस्...", - "settings.providers.modelVisibility.count": "{total} मध्ये {selected} चयन गरियो", - "settings.providers.modelVisibility.selectAll": "सबै चयन गर्नुहोस्", - "settings.providers.modelVisibility.selectNone": "कुनै पनि चयन नगर्नुहोस्", - "settings.providers.modelVisibility.unavailable.one": "चयन गरिएको {count} मोडेल हाल उपलब्ध छैन।", - "settings.providers.modelVisibility.unavailable.other": "चयन गरिएका {count} मोडेलहरू हाल उपलब्ध छैनन्।", - "settings.providers.modelVisibility.empty": "यो खोजसँग मिल्ने मोडेल छैन।", - "settings.providers.modelVisibility.listAriaLabel": "{provider} का मोडेलहरू", - "settings.providers.modelVisibility.saveFailed": "मोडेल दृश्यता सुरक्षित गर्न असफल भयो।", + "settings.providers.modelVisibility.count.visible": "{total} मध्ये {visible} देखिने", + "settings.providers.modelVisibility.actions.showAll": "सबै देखाउनुहोस्", + "settings.providers.modelVisibility.actions.hideAll": "सबै लुकाउनुहोस्", + "settings.providers.modelVisibility.list.empty": "यो खोजसँग मिल्ने मोडेल छैन।", + "settings.providers.modelVisibility.list.label": "{provider} का मोडेलहरू", + "settings.providers.modelVisibility.save.failed": "मोडेल दृश्यता सुरक्षित गर्न असफल भयो।", "thinkingSelector.variant.default": "पूर्वनिर्धारित", "thinkingSelector.label": "सोचाइ: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 01317d680..34e4ebdd3 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "Удалить из избранного", "modelSelector.manageProviders": "Управление провайдерами", "modelSelector.unavailableModel": "{model} (недоступна)", - "modelSelector.error.noVisibleModels": "Перед отправкой сообщения выберите хотя бы одну видимую модель.", "settings.providers.actions.manageModels": "Управление моделями", "settings.providers.modelVisibility.back": "Назад к провайдерам", "settings.providers.modelVisibility.title": "Модели {provider}", "settings.providers.modelVisibility.subtitle": "Выберите модели, которые CodeNomad показывает в списках выбора.", - "settings.providers.modelVisibility.mode.label": "Режим видимости моделей", - "settings.providers.modelVisibility.mode.all": "Все модели", - "settings.providers.modelVisibility.mode.all.description": "Показывать текущие и новые модели.", - "settings.providers.modelVisibility.mode.custom": "Выборочно", - "settings.providers.modelVisibility.mode.custom.description": "Показывать только выбранные идентификаторы моделей.", "settings.providers.modelVisibility.search.label": "Поиск моделей провайдера", "settings.providers.modelVisibility.search.placeholder": "Поиск по имени или ID модели...", - "settings.providers.modelVisibility.count": "Выбрано {selected} из {total}", - "settings.providers.modelVisibility.selectAll": "Выбрать все", - "settings.providers.modelVisibility.selectNone": "Снять выбор", - "settings.providers.modelVisibility.unavailable.one": "{count} выбранная модель сейчас недоступна.", - "settings.providers.modelVisibility.unavailable.other": "{count} выбранных моделей сейчас недоступны.", - "settings.providers.modelVisibility.empty": "Нет моделей, соответствующих поиску.", - "settings.providers.modelVisibility.listAriaLabel": "Модели {provider}", - "settings.providers.modelVisibility.saveFailed": "Не удалось сохранить видимость моделей.", + "settings.providers.modelVisibility.count.visible": "Видно {visible} из {total}", + "settings.providers.modelVisibility.actions.showAll": "Показать все", + "settings.providers.modelVisibility.actions.hideAll": "Скрыть все", + "settings.providers.modelVisibility.list.empty": "Нет моделей, соответствующих поиску.", + "settings.providers.modelVisibility.list.label": "Модели {provider}", + "settings.providers.modelVisibility.save.failed": "Не удалось сохранить видимость моделей.", "thinkingSelector.variant.default": "По умолчанию", "thinkingSelector.label": "Размышления: {variant}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index c104f2ce8..1357ff69f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -34,26 +34,18 @@ export const settingsMessages = { "modelSelector.favorite.remove": "从收藏移除", "modelSelector.manageProviders": "管理提供商", "modelSelector.unavailableModel": "{model}(不可用)", - "modelSelector.error.noVisibleModels": "发送消息前,请至少选择一个可见模型。", "settings.providers.actions.manageModels": "管理模型", "settings.providers.modelVisibility.back": "返回提供商列表", "settings.providers.modelVisibility.title": "{provider} 的模型", "settings.providers.modelVisibility.subtitle": "选择 CodeNomad 在模型选择器中显示的模型。", - "settings.providers.modelVisibility.mode.label": "模型可见性模式", - "settings.providers.modelVisibility.mode.all": "所有模型", - "settings.providers.modelVisibility.mode.all.description": "显示当前和新报告的模型。", - "settings.providers.modelVisibility.mode.custom": "自定义", - "settings.providers.modelVisibility.mode.custom.description": "仅显示已选择的模型 ID。", "settings.providers.modelVisibility.search.label": "搜索提供商模型", "settings.providers.modelVisibility.search.placeholder": "按模型名称或 ID 搜索...", - "settings.providers.modelVisibility.count": "已选择 {selected}/{total}", - "settings.providers.modelVisibility.selectAll": "全选", - "settings.providers.modelVisibility.selectNone": "全部取消", - "settings.providers.modelVisibility.unavailable.one": "当前有 {count} 个已选模型不可用。", - "settings.providers.modelVisibility.unavailable.other": "当前有 {count} 个已选模型不可用。", - "settings.providers.modelVisibility.empty": "没有与搜索匹配的模型。", - "settings.providers.modelVisibility.listAriaLabel": "{provider} 的模型", - "settings.providers.modelVisibility.saveFailed": "无法保存模型可见性设置。", + "settings.providers.modelVisibility.count.visible": "显示 {visible}/{total}", + "settings.providers.modelVisibility.actions.showAll": "全部显示", + "settings.providers.modelVisibility.actions.hideAll": "全部隐藏", + "settings.providers.modelVisibility.list.empty": "没有与搜索匹配的模型。", + "settings.providers.modelVisibility.list.label": "{provider} 的模型", + "settings.providers.modelVisibility.save.failed": "无法保存模型可见性设置。", "thinkingSelector.variant.default": "默认", "thinkingSelector.label": "思考:{variant}", diff --git a/packages/ui/src/lib/model-visibility.test.ts b/packages/ui/src/lib/model-visibility.test.ts index 31547e2f5..7d33b3642 100644 --- a/packages/ui/src/lib/model-visibility.test.ts +++ b/packages/ui/src/lib/model-visibility.test.ts @@ -1,43 +1,39 @@ import assert from "node:assert/strict" import test from "node:test" import { - getUnavailableSelectedModelIds, isModelVisible, normalizeModelVisibilityPreference, normalizeModelVisibilityPreferences, - seedCustomModelVisibility, } from "./model-visibility" test("normalizes malformed preferences fail-open", () => { - assert.deepEqual(normalizeModelVisibilityPreference(null), { mode: "all" }) - assert.deepEqual(normalizeModelVisibilityPreference({ mode: "custom" }), { mode: "all" }) + assert.deepEqual(normalizeModelVisibilityPreference(null), { hiddenModelIds: [] }) + assert.deepEqual(normalizeModelVisibilityPreference({ mode: "custom", modelIds: ["legacy"] }), { hiddenModelIds: [] }) + assert.deepEqual(normalizeModelVisibilityPreference({ hiddenModelIds: "hidden" }), { hiddenModelIds: [] }) assert.deepEqual(normalizeModelVisibilityPreferences([]), {}) - assert.deepEqual(normalizeModelVisibilityPreferences({ broken: { mode: "nope" } }), { - broken: { mode: "all" }, + assert.deepEqual(normalizeModelVisibilityPreferences({ broken: { hiddenModelIds: null } }), { + broken: { hiddenModelIds: [] }, }) }) test("keeps unique non-empty exact IDs without catalog-based deletion", () => { assert.deepEqual( - normalizeModelVisibilityPreference({ mode: "custom", modelIds: ["Model-A", "model-a", "", "Model-A", 7] }), - { mode: "custom", modelIds: ["Model-A", "model-a"] }, + normalizeModelVisibilityPreference({ hiddenModelIds: ["Model-A", "model-a", "", "Model-A", 7] }), + { hiddenModelIds: ["Model-A", "model-a"] }, ) }) -test("matches IDs case-sensitively and overrides a hidden current model", () => { - const preference = { mode: "custom", modelIds: ["Model-A"] } as const - assert.equal(isModelVisible(preference, "Model-A"), true) - assert.equal(isModelVisible(preference, "model-a"), false) - assert.equal(isModelVisible(preference, "hidden", "hidden"), true) +test("matches hidden IDs exactly and case-sensitively", () => { + const preference = { hiddenModelIds: ["Model-A"] } + assert.equal(isModelVisible(preference, "Model-A"), false) + assert.equal(isModelVisible(preference, "model-a"), true) }) -test("new models follow mode while disappeared selections are retained", () => { - const custom = { mode: "custom", modelIds: ["known", "disappeared"] } as const - assert.equal(isModelVisible(custom, "new"), false) - assert.equal(isModelVisible({ mode: "all" }, "new"), true) - assert.deepEqual(getUnavailableSelectedModelIds(custom, ["known", "new"]), ["disappeared"]) - assert.deepEqual(seedCustomModelVisibility(["known", "new", "known"]), { - mode: "custom", - modelIds: ["known", "new"], - }) +test("new models and missing preferences are visible by default", () => { + assert.equal(isModelVisible({ hiddenModelIds: ["known"] }, "new"), true) + assert.equal(isModelVisible(undefined, "new"), true) +}) + +test("a current model receives no helper-level visibility override", () => { + assert.equal(isModelVisible({ hiddenModelIds: ["current"] }, "current"), false) }) diff --git a/packages/ui/src/lib/model-visibility.ts b/packages/ui/src/lib/model-visibility.ts index edc3b093c..629be5fb6 100644 --- a/packages/ui/src/lib/model-visibility.ts +++ b/packages/ui/src/lib/model-visibility.ts @@ -1,19 +1,15 @@ -export type ModelVisibilityPreference = - | { mode: "all" } - | { mode: "custom"; modelIds: readonly string[] } +export type ModelVisibilityPreference = { hiddenModelIds: readonly string[] } export type ModelVisibilityPreferences = Record export function normalizeModelVisibilityPreference(value: unknown): ModelVisibilityPreference { - if (!value || typeof value !== "object" || Array.isArray(value)) return { mode: "all" } - const candidate = value as { mode?: unknown; modelIds?: unknown } - if (candidate.mode === "all") return { mode: "all" } - if (candidate.mode !== "custom" || !Array.isArray(candidate.modelIds)) return { mode: "all" } + if (!value || typeof value !== "object" || Array.isArray(value)) return { hiddenModelIds: [] } + const hiddenModelIds = (value as { hiddenModelIds?: unknown }).hiddenModelIds + if (!Array.isArray(hiddenModelIds)) return { hiddenModelIds: [] } return { - mode: "custom", - modelIds: Array.from(new Set(candidate.modelIds.filter( - (id): id is string => typeof id === "string" && id.trim().length > 0, + hiddenModelIds: Array.from(new Set(hiddenModelIds.filter( + (id): id is string => typeof id === "string" && id.length > 0, ))), } } @@ -31,23 +27,6 @@ export function normalizeModelVisibilityPreferences(value: unknown): ModelVisibi export function isModelVisible( preference: ModelVisibilityPreference | undefined, modelId: string, - currentModelId?: string, ): boolean { - if (modelId === currentModelId) return true - const normalized = normalizeModelVisibilityPreference(preference) - return normalized.mode === "all" || normalized.modelIds.includes(modelId) -} - -export function getUnavailableSelectedModelIds( - preference: ModelVisibilityPreference | undefined, - currentModelIds: readonly string[], -): string[] { - const normalized = normalizeModelVisibilityPreference(preference) - if (normalized.mode === "all") return [] - const current = new Set(currentModelIds) - return normalized.modelIds.filter((id) => !current.has(id)) -} - -export function seedCustomModelVisibility(currentModelIds: readonly string[]): ModelVisibilityPreference { - return normalizeModelVisibilityPreference({ mode: "custom", modelIds: currentModelIds }) + return !normalizeModelVisibilityPreference(preference).hiddenModelIds.includes(modelId) } diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 6412fa788..3427692da 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -665,7 +665,7 @@ async function setProviderModelVisibility(providerId: string, preference: ModelV .then(() => patchConfigOwner("ui", { settings: { modelVisibility: { - [providerId]: normalized.mode === "all" ? { mode: "all", modelIds: null } : normalized, + [providerId]: normalized, }, }, })) diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 54d850688..6c0db47c7 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -12,7 +12,6 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" -import { tGlobal } from "../lib/i18n" const log = getLogger("actions") @@ -99,9 +98,6 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } - if (!session.model.providerId || !session.model.modelId) { - throw new Error(tGlobal("modelSelector.error.noVisibleModels")) - } const messageId = createId("msg") const textPartId = createId("prt") @@ -258,9 +254,6 @@ async function executeCustomCommand( if (!session) { throw new Error("Session not found") } - if (!session.model.providerId || !session.model.modelId) { - throw new Error(tGlobal("modelSelector.error.noVisibleModels")) - } const body: { command: string diff --git a/packages/ui/src/stores/session-models.ts b/packages/ui/src/stores/session-models.ts index d32aadc2f..919e8a9ec 100644 --- a/packages/ui/src/stores/session-models.ts +++ b/packages/ui/src/stores/session-models.ts @@ -1,6 +1,5 @@ import { agents, providers } from "./session-state" -import { getProviderModelVisibilityPreference, uiState, getAgentModelPreference } from "./preferences" -import { isModelVisible } from "../lib/model-visibility" +import { uiState, getAgentModelPreference } from "./preferences" const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 @@ -15,22 +14,12 @@ function isModelValid( return provider.models.some((item) => item.id === model.modelId) } -function isModelSelectable( - instanceId: string, - model?: { providerId: string; modelId: string } | null, -): model is { providerId: string; modelId: string } { - return isModelValid(instanceId, model) && isModelVisible( - getProviderModelVisibilityPreference(model.providerId), - model.modelId, - ) -} - function getRecentModelPreferenceForInstance( instanceId: string, ): { providerId: string; modelId: string } | undefined { const recents = uiState().models.recents ?? [] for (const item of recents) { - if (isModelSelectable(instanceId, item)) { + if (isModelValid(instanceId, item)) { return item } } @@ -45,7 +34,7 @@ async function getDefaultModel( if (agentName) { const agent = instanceAgents.find((a) => a.name === agentName) - if (agent && agent.model && isModelSelectable(instanceId, agent.model)) { + if (agent && agent.model && isModelValid(instanceId, agent.model)) { return { providerId: agent.model.providerId, modelId: agent.model.modelId, @@ -53,7 +42,7 @@ async function getDefaultModel( } const stored = await getAgentModelPreference(instanceId, agentName) - if (isModelSelectable(instanceId, stored)) { + if (isModelValid(instanceId, stored)) { return stored } } @@ -65,9 +54,7 @@ async function getDefaultModel( for (const provider of instanceProviders) { if (provider.defaultModelId) { - const model = provider.models.find((m) => - m.id === provider.defaultModelId && isModelVisible(getProviderModelVisibilityPreference(provider.id), m.id), - ) + const model = provider.models.find((m) => m.id === provider.defaultModelId) if (model) { return { providerId: provider.id, @@ -77,10 +64,9 @@ async function getDefaultModel( } } - for (const firstProvider of instanceProviders) { - const firstModel = firstProvider.models.find((model) => - isModelVisible(getProviderModelVisibilityPreference(firstProvider.id), model.id), - ) + if (instanceProviders.length > 0) { + const firstProvider = instanceProviders[0] + const firstModel = firstProvider.models[0] if (firstModel) { return { providerId: firstProvider.id, diff --git a/packages/ui/src/styles/components/provider-model-visibility.css b/packages/ui/src/styles/components/provider-model-visibility.css index fd98b82f4..50cc9fb69 100644 --- a/packages/ui/src/styles/components/provider-model-visibility.css +++ b/packages/ui/src/styles/components/provider-model-visibility.css @@ -17,30 +17,6 @@ width: auto; } -.provider-model-visibility-modes { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - border: 0; -} - -.provider-model-visibility-mode { - display: flex; - align-items: flex-start; - gap: 0.65rem; - padding: 0.75rem; - border: 1px solid var(--border-base); - background: var(--surface-base); - color: var(--text-primary); - cursor: pointer; -} - -.provider-model-visibility-mode:has(input:checked) { - border-color: color-mix(in oklab, var(--accent-primary) 55%, var(--border-base)); - background: color-mix(in oklab, var(--accent-primary) 8%, var(--surface-base)); -} - -.provider-model-visibility-mode span, .provider-model-visibility-item span { display: flex; min-width: 0; @@ -48,7 +24,6 @@ gap: 0.2rem; } -.provider-model-visibility-mode small, .provider-model-visibility-item small { color: var(--text-muted); font-size: var(--font-size-xs); @@ -63,20 +38,14 @@ gap: 0.5rem; } -.provider-model-visibility-count, -.provider-model-visibility-unavailable { +.provider-model-visibility-count { color: var(--text-secondary); font-size: var(--font-size-xs); } -.provider-model-visibility-unavailable { - padding: 0.625rem 0.75rem; - border: 1px solid var(--border-base); - background: var(--surface-base); -} - .provider-model-visibility-list { height: min(22rem, 45dvh); + overflow-y: auto; border: 1px solid var(--border-base); background: var(--surface-base); } @@ -96,10 +65,6 @@ background: var(--surface-secondary); } -.provider-model-visibility-item:has(input:disabled) { - cursor: default; -} - .provider-model-card-actions { display: flex; align-items: center; @@ -107,7 +72,6 @@ } @media (max-width: 700px) { - .provider-model-visibility-modes, .provider-model-visibility-toolbar { grid-template-columns: 1fr; } From de7e7a11e46cc878aded3841d3e1450dd60ecfb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 18:24:47 +0200 Subject: [PATCH 04/11] fix(ui): restore floating model management Expose the existing model visibility manager from both the embedded provider settings and the floating provider dialog. This keeps model hiding accessible from the model picker without duplicating state or UI logic. Validated with workspace typechecking, the focused model visibility tests, and the UI production build. --- .../provider-auth/provider-manager-modal.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx index 430cafcf7..772eba0b1 100644 --- a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx +++ b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx @@ -129,9 +129,7 @@ export const ProviderManagerModal: Component = (props ) const managedProvider = createMemo(() => - props.embedded - ? configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null - : null, + configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null, ) const getDisconnectMode = (provider: ListedProvider): DisconnectMode => { @@ -744,17 +742,15 @@ export const ProviderManagerModal: Component = (props From e3e452ba5a83f783d8ddca2cebaca173c2f1601a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 21:06:41 +0200 Subject: [PATCH 05/11] fix(ui): address model visibility review findings Refresh provider metadata without disposing active OpenCode instances. Keep the Combobox selection aligned with its visible collection while preserving an accessible current-model label when that model is hidden. Propagate YAML persistence failures and roll back the in-memory document so model visibility does not appear saved when disk writes fail. Add a focused regression test for the write-failure boundary. Validated with UI, Electron, and server typechecks; focused visibility and YAML-store tests; the UI production build; and the server suite apart from an unrelated local default-branch assertion in git-worktrees.test.ts. --- .../src/settings/yaml-doc-store.test.ts | 26 +++++++++++++++++++ .../server/src/settings/yaml-doc-store.ts | 11 +++++++- packages/ui/src/components/model-selector.tsx | 18 ++++++++++--- .../provider-auth/provider-manager-modal.tsx | 13 +--------- 4 files changed, 52 insertions(+), 16 deletions(-) create mode 100644 packages/server/src/settings/yaml-doc-store.test.ts diff --git a/packages/server/src/settings/yaml-doc-store.test.ts b/packages/server/src/settings/yaml-doc-store.test.ts new file mode 100644 index 000000000..3f684aeed --- /dev/null +++ b/packages/server/src/settings/yaml-doc-store.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import { YamlDocStore } from "./yaml-doc-store" + +describe("YamlDocStore", () => { + it("reports persistence failures without replacing the cached document", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const parent = path.join(root, "settings") + const file = path.join(parent, "config.yaml") + const store = new YamlDocStore(file, { warn() {} } as any) + + try { + store.replace({ version: 1 }) + fs.rmSync(parent, { recursive: true }) + fs.writeFileSync(parent, "blocks directory creation") + + assert.throws(() => store.replace({ version: 2 })) + assert.deepEqual(store.get(), { version: 1 }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/settings/yaml-doc-store.ts b/packages/server/src/settings/yaml-doc-store.ts index 91c5540c8..a9d82ed0d 100644 --- a/packages/server/src/settings/yaml-doc-store.ts +++ b/packages/server/src/settings/yaml-doc-store.ts @@ -58,9 +58,17 @@ export class YamlDocStore { replace(next: unknown): SettingsDoc { const normalized = normalizeDoc(next) + const previousCache = this.cache + const previousLoaded = this.loaded this.cache = normalized this.loaded = true - this.persist() + try { + this.persist() + } catch (error) { + this.cache = previousCache + this.loaded = previousLoaded + throw error + } return this.cache } @@ -105,6 +113,7 @@ export class YamlDocStore { fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8") } catch (error) { this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc") + throw error } } } diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index c82dfbd4b..7584f1d75 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -190,6 +190,17 @@ export default function ModelSelector(props: ModelSelectorProps) { ]), ) + const comboboxValue = createMemo(() => { + const current = currentModelValue() + if (!current) return undefined + const option = pickerOptions().find((item) => item.key === current.key) + return option && !isProviderHeaderOption(option) ? option : undefined + }) + + const currentModelLabel = createMemo(() => + t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") }), + ) + const handleChange = async (value: PickerOption | null) => { if (!value || isProviderHeaderOption(value) || value.unavailable) return await props.onModelChange({ providerId: value.providerId, modelId: value.id }) @@ -289,7 +300,7 @@ export default function ModelSelector(props: ModelSelectorProps) {