From c04871747cebe334ae6a1ef0b45efd99f14dd462 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 24 May 2026 21:58:18 +0300 Subject: [PATCH] fix(runtime-provider): clarify opencode model routes ux --- .../renderer/locales/en/settings.json | 19 +- .../renderer/locales/ru/settings.json | 19 +- .../localization/renderer/resources.d.ts | 19 +- .../ui/RuntimeProviderManagementPanelView.tsx | 240 +++++++++++++----- ...RuntimeProviderManagementPanelView.test.ts | 40 ++- 5 files changed, 249 insertions(+), 88 deletions(-) diff --git a/src/features/localization/renderer/locales/en/settings.json b/src/features/localization/renderer/locales/en/settings.json index e9b1876b..2e8e2e23 100644 --- a/src/features/localization/renderer/locales/en/settings.json +++ b/src/features/localization/renderer/locales/en/settings.json @@ -55,16 +55,24 @@ "emptyRecommended": "No recommended models found.", "emptyRecommendedFree": "No recommended free models found.", "freeOnly": "Free only", - "launchableDescription": "Routes you can test or use in the team picker: local config, free built-in models, and current default.", - "launchableTitle": "Launchable OpenCode models", + "launchableDescription": "Known routes from OpenCode config, free built-in models, and the current default. Local routes need a successful test before they are ready for team launches.", + "launchableTitle": "OpenCode model routes", "loadingRoutes": "Loading OpenCode model routes...", "noRoutesMatch": "No OpenCode model routes match \"{{query}}\".", - "noneReported": "No launchable OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.", + "noneReported": "No OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.", "recommendedOnly": "Recommended only", "searchPlaceholder": "Search models", "selectProjectBeforeTesting": "Select a project context before testing models.", "selectProjectBeforeTestingDefaults": "Select a project context before testing or saving OpenCode defaults.", - "useInTeamPicker": "Use in team picker" + "testInProgress": "Model test is already running.", + "useInTeamPicker": "Save for team picker", + "validationContextRequired": "Select a validation context above to enable Test and Set default. Saving for team picker only stores the route for new teams.", + "actionsUnavailable": "Actions are temporarily unavailable.", + "defaultSaveInProgress": "OpenCode default is being saved.", + "routeUnavailableAuth": "This provider requires authentication before this model can be used.", + "routeUnavailableFailed": "This model route failed its last execution test.", + "routeUnavailableGeneric": "This model route cannot be used right now.", + "routeUnavailableUnknown": "This model is the current OpenCode default, but it is not available in the live catalog yet." }, "providers": { "catalog": "OpenCode provider catalog", @@ -99,10 +107,11 @@ "searchPlaceholder": "Search model routes" }, "badges": { - "usedInTeamPicker": "Used in team picker", + "usedInTeamPicker": "Saved for team picker", "free": "free", "local": "local", "configured": "configured", + "knownRoute": "known route", "connected": "connected", "verified": "verified", "needsTest": "needs test", diff --git a/src/features/localization/renderer/locales/ru/settings.json b/src/features/localization/renderer/locales/ru/settings.json index 6e346b9f..7412d935 100644 --- a/src/features/localization/renderer/locales/ru/settings.json +++ b/src/features/localization/renderer/locales/ru/settings.json @@ -55,16 +55,24 @@ "emptyRecommended": "Recommended models не найдены.", "emptyRecommendedFree": "Recommended free models не найдены.", "freeOnly": "Только free", - "launchableDescription": "Routes, которые можно тестировать или использовать в team picker: local config, free built-in models и текущий default.", - "launchableTitle": "Launchable OpenCode models", + "launchableDescription": "Известные routes из OpenCode config, free built-in models и текущий default. Local routes нужно проверить тестом перед запуском команд.", + "launchableTitle": "Маршруты моделей OpenCode", "loadingRoutes": "Загрузка OpenCode model routes...", "noRoutesMatch": "OpenCode model routes не найдены по запросу \"{{query}}\".", - "noneReported": "Launchable OpenCode model routes пока не получены. Настройте local route в OpenCode или используйте вкладку Providers для просмотра catalog providers.", + "noneReported": "OpenCode model routes пока не получены. Настройте local route в OpenCode или используйте вкладку Providers для просмотра catalog providers.", "recommendedOnly": "Только recommended", "searchPlaceholder": "Поиск моделей", "selectProjectBeforeTesting": "Выберите project context перед тестированием моделей.", "selectProjectBeforeTestingDefaults": "Выберите project context перед тестированием или сохранением OpenCode defaults.", - "useInTeamPicker": "Использовать в team picker" + "testInProgress": "Тест модели уже выполняется.", + "useInTeamPicker": "Сохранить для team picker", + "validationContextRequired": "Выберите validation context выше, чтобы включить Test и Set default. Сохранение для team picker только запоминает route для новых команд.", + "actionsUnavailable": "Действия временно недоступны.", + "defaultSaveInProgress": "OpenCode default сохраняется.", + "routeUnavailableAuth": "Этому provider нужна авторизация перед использованием модели.", + "routeUnavailableFailed": "Этот model route не прошёл последний execution test.", + "routeUnavailableGeneric": "Этот model route сейчас нельзя использовать.", + "routeUnavailableUnknown": "Эта модель выбрана текущим OpenCode default, но её пока нет в live catalog." }, "providers": { "catalog": "OpenCode provider catalog", @@ -99,10 +107,11 @@ "searchPlaceholder": "Поиск маршрутов моделей" }, "badges": { - "usedInTeamPicker": "Используется в выборе команды", + "usedInTeamPicker": "Сохранено для team picker", "free": "free", "local": "local", "configured": "настроено", + "knownRoute": "известный route", "connected": "подключено", "verified": "проверено", "needsTest": "нужен тест", diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts index d3655fe9..2184f0c1 100644 --- a/src/features/localization/renderer/resources.d.ts +++ b/src/features/localization/renderer/resources.d.ts @@ -2852,10 +2852,11 @@ export default interface Resources { default: 'default'; failed: 'failed'; free: 'free'; + knownRoute: 'known route'; local: 'local'; needsTest: 'needs test'; unknown: 'unknown'; - usedInTeamPicker: 'Used in team picker'; + usedInTeamPicker: 'Saved for team picker'; verified: 'verified'; }; compatibleEndpoint: { @@ -2889,22 +2890,30 @@ export default interface Resources { searchPlaceholder: 'Search model routes'; }; models: { + actionsUnavailable: 'Actions are temporarily unavailable.'; alreadyDefault: 'This is already the selected OpenCode default.'; + defaultSaveInProgress: 'OpenCode default is being saved.'; empty: 'No models found.'; emptyFree: 'No free models found.'; emptyRecommended: 'No recommended models found.'; emptyRecommendedFree: 'No recommended free models found.'; freeOnly: 'Free only'; - launchableDescription: 'Routes you can test or use in the team picker: local config, free built-in models, and current default.'; - launchableTitle: 'Launchable OpenCode models'; + launchableDescription: 'Known routes from OpenCode config, free built-in models, and the current default. Local routes need a successful test before they are ready for team launches.'; + launchableTitle: 'OpenCode model routes'; loadingRoutes: 'Loading OpenCode model routes...'; noRoutesMatch: 'No OpenCode model routes match "{{query}}".'; - noneReported: 'No launchable OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.'; + noneReported: 'No OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.'; recommendedOnly: 'Recommended only'; + routeUnavailableAuth: 'This provider requires authentication before this model can be used.'; + routeUnavailableFailed: 'This model route failed its last execution test.'; + routeUnavailableGeneric: 'This model route cannot be used right now.'; + routeUnavailableUnknown: 'This model is the current OpenCode default, but it is not available in the live catalog yet.'; searchPlaceholder: 'Search models'; selectProjectBeforeTesting: 'Select a project context before testing models.'; selectProjectBeforeTestingDefaults: 'Select a project context before testing or saving OpenCode defaults.'; - useInTeamPicker: 'Use in team picker'; + testInProgress: 'Model test is already running.'; + useInTeamPicker: 'Save for team picker'; + validationContextRequired: 'Select a validation context above to enable Test and Set default. Saving for team picker only stores the route for new teams.'; }; providers: { catalog: 'OpenCode provider catalog'; diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 7c475646..fddc58c8 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -14,6 +14,12 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { compareOpenCodeTeamModelRecommendations, @@ -212,6 +218,33 @@ function isDefaultForScope( return scopedDefault === model.modelId; } +const DisabledActionTooltip = ({ + reason, + children, +}: { + readonly reason: string | undefined; + readonly children: JSX.Element; +}): JSX.Element => { + if (!reason) { + return children; + } + + return ( + + + + + {children} + + + + {reason} + + + + ); +}; + function directoryEntryMatchesQuery( provider: RuntimeProviderDirectoryEntryDto, query: string @@ -1449,7 +1482,7 @@ function ModelBadges({ {t('runtimeProvider.badges.local')} - {t('runtimeProvider.badges.configured')} + {t('runtimeProvider.badges.knownRoute')} ) : null} @@ -1517,17 +1550,47 @@ function canUseOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean { ); } -function getOpenCodeRouteUnavailableTitle(model: RuntimeProviderModelDto): string | undefined { +function getOpenCodeRouteUnavailableTitle( + model: RuntimeProviderModelDto, + t: SettingsT +): string | undefined { if (isUnknownOpenCodeModelRoute(model)) { - return 'This model is the current OpenCode default, but it is not available in the live catalog yet.'; + return t('runtimeProvider.models.routeUnavailableUnknown'); } if (model.accessKind === 'not_authenticated') { - return ( - model.accessReason ?? 'This provider requires authentication before this model can be used.' - ); + return model.accessReason ?? t('runtimeProvider.models.routeUnavailableAuth'); } if (model.accessKind === 'execution_failed' || model.proofState === 'failed') { - return model.accessReason ?? 'This model route failed its last execution test.'; + return model.accessReason ?? t('runtimeProvider.models.routeUnavailableFailed'); + } + return undefined; +} + +function getDisabledActionReason(input: { + readonly disabled: boolean; + readonly contextRequiredTitle?: string; + readonly unavailableTitle?: string; + readonly busy: boolean; + readonly busyTitle: string; + readonly alreadyDefault?: boolean; + readonly alreadyDefaultTitle?: string; + readonly capabilityAvailable: boolean; + readonly t: SettingsT; +}): string | undefined { + if (input.disabled) { + return input.t('runtimeProvider.models.actionsUnavailable'); + } + if (input.contextRequiredTitle) { + return input.contextRequiredTitle; + } + if (input.busy) { + return input.busyTitle; + } + if (input.alreadyDefault) { + return input.alreadyDefaultTitle; + } + if (!input.capabilityAvailable) { + return input.unavailableTitle ?? input.t('runtimeProvider.models.routeUnavailableGeneric'); } return undefined; } @@ -1863,6 +1926,18 @@ function ConfiguredOpenCodeModelsPanel({
+ {!hasProjectContext ? ( +
+ {t('runtimeProvider.models.validationContextRequired')} +
+ ) : null} {visibleModels.length === 0 ? (
{t('runtimeProvider.models.noRoutesMatch', { query: query.trim() })} @@ -1873,20 +1948,55 @@ function ConfiguredOpenCodeModelsPanel({ const testing = state.testingModelIds.includes(model.modelId); const savingDefault = state.savingDefaultModelId === model.modelId; const result = state.modelResults[model.modelId]; - const unavailableTitle = getOpenCodeRouteUnavailableTitle(model); + const unavailableTitle = getOpenCodeRouteUnavailableTitle(model, t); const contextRequiredTitle = hasProjectContext ? undefined : t('runtimeProvider.models.selectProjectBeforeTestingDefaults'); const alreadyDefaultForScope = isDefaultForScope(model, state, defaultScope); - const canTest = - !disabled && hasProjectContext && !testing && canTestOpenCodeModelRoute(model); - const canUse = !disabled && canUseOpenCodeModelRoute(model); + const testCapabilityAvailable = canTestOpenCodeModelRoute(model); + const useCapabilityAvailable = canUseOpenCodeModelRoute(model); + const canTest = !disabled && hasProjectContext && !testing && testCapabilityAvailable; + const canUse = !disabled && useCapabilityAvailable; const canSetDefault = !disabled && hasProjectContext && !savingDefault && !alreadyDefaultForScope && - canUseOpenCodeModelRoute(model); + useCapabilityAvailable; + const testDisabledReason = canTest + ? undefined + : getDisabledActionReason({ + disabled, + contextRequiredTitle, + unavailableTitle, + busy: testing, + busyTitle: t('runtimeProvider.models.testInProgress'), + capabilityAvailable: testCapabilityAvailable, + t, + }); + const useDisabledReason = canUse + ? undefined + : getDisabledActionReason({ + disabled, + unavailableTitle, + busy: false, + busyTitle: '', + capabilityAvailable: useCapabilityAvailable, + t, + }); + const setDefaultDisabledReason = canSetDefault + ? undefined + : getDisabledActionReason({ + disabled, + contextRequiredTitle, + unavailableTitle, + busy: savingDefault, + busyTitle: t('runtimeProvider.models.defaultSaveInProgress'), + alreadyDefault: alreadyDefaultForScope, + alreadyDefaultTitle: t('runtimeProvider.models.alreadyDefault'), + capabilityAvailable: useCapabilityAvailable, + t, + }); return (
- - - + + + + + + + + +
diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 5bf4bc51..1a98cf96 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -653,9 +653,10 @@ describe('RuntimeProviderManagementPanelView', () => { const row = host.querySelector( '[data-testid="configured-opencode-model-row-llama.cpp/qwen-test:0.5b"]' ); - expect(host.textContent).toContain('Launchable OpenCode models'); + expect(host.textContent).toContain('OpenCode model routes'); + expect(host.textContent).toContain('Known routes from OpenCode config'); expect(row?.textContent).toContain('local'); - expect(row?.textContent).toContain('configured'); + expect(row?.textContent).toContain('known route'); expect(row?.textContent).toContain('needs test'); const buttons = Array.from(row?.querySelectorAll('button') ?? []); @@ -664,7 +665,7 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); await act(async () => { - buttons.find((button) => button.textContent?.includes('Use in team picker'))?.click(); + buttons.find((button) => button.textContent?.includes('Save for team picker'))?.click(); await Promise.resolve(); }); await act(async () => { @@ -847,10 +848,30 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('Launchable OpenCode models'); + expect(host.textContent).toContain('OpenCode model routes'); expect(host.textContent).toContain('llama.cpp/qwen-test:0.5b'); + expect(host.textContent).toContain( + 'Select a validation context above to enable Test and Set default' + ); expect(host.textContent).toContain('Providers'); expect(host.querySelector('[data-testid="runtime-provider-row-openrouter"]')).toBeNull(); + + const row = host.querySelector( + '[data-testid="configured-opencode-model-row-llama.cpp/qwen-test:0.5b"]' + ); + const buttons = Array.from(row?.querySelectorAll('button') ?? []); + expect(buttons.map((button) => [button.textContent?.trim(), button.disabled])).toEqual([ + ['Test', true], + ['Save for team picker', false], + ['Set all-projects default', true], + ]); + expect( + Array.from(row?.querySelectorAll('[title]') ?? []).some( + (element) => + element.getAttribute('title') === + 'Select a project context before testing or saving OpenCode defaults.' + ) + ).toBe(true); }); it('shows unknown OpenCode defaults without enabling launch actions', async () => { @@ -897,6 +918,13 @@ describe('RuntimeProviderManagementPanelView', () => { const buttons = Array.from(row?.querySelectorAll('button') ?? []); expect(buttons.map((button) => button.disabled)).toEqual([true, true, true]); + expect( + Array.from(row?.querySelectorAll('[title]') ?? []).some( + (element) => + element.getAttribute('title') === + 'This model is the current OpenCode default, but it is not available in the live catalog yet.' + ) + ).toBe(true); await act(async () => { buttons.forEach((button) => button.click()); await Promise.resolve(); @@ -1807,7 +1835,7 @@ describe('RuntimeProviderManagementPanelView', () => { }); expect(host.textContent).toContain('openrouter/openai/gpt-oss-20b:free'); - expect(host.textContent).toContain('Used in team picker'); + expect(host.textContent).toContain('Saved for team picker'); expect(host.textContent).toContain('Model probe passed'); expect(host.textContent).toContain('Recommended'); expect(host.textContent).toContain('Not recommended'); @@ -1818,7 +1846,7 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).not.toContain('Set OpenCode default'); expect( Array.from(host.querySelectorAll('button')).some( - (button) => button.textContent?.trim() === 'Use in team picker' + (button) => button.textContent?.trim() === 'Save for team picker' ) ).toBe(false); expect(