fix(runtime-provider): clarify opencode model routes ux

This commit is contained in:
777genius 2026-05-24 21:58:18 +03:00
parent 2b3a184bef
commit c04871747c
5 changed files with 249 additions and 88 deletions

View file

@ -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",

View file

@ -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": "нужен тест",

View file

@ -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';

View file

@ -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 (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" title={reason} aria-label={reason}>
{children}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-72 text-pretty text-xs leading-relaxed">
{reason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
function directoryEntryMatchesQuery(
provider: RuntimeProviderDirectoryEntryDto,
query: string
@ -1449,7 +1482,7 @@ function ModelBadges({
{t('runtimeProvider.badges.local')}
</Badge>
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-200">
{t('runtimeProvider.badges.configured')}
{t('runtimeProvider.badges.knownRoute')}
</Badge>
</>
) : 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({
</div>
<div className="mt-3 space-y-2">
{!hasProjectContext ? (
<div
className="rounded-md border px-3 py-2 text-xs leading-5"
style={{
borderColor: 'rgba(251, 191, 36, 0.28)',
backgroundColor: 'rgba(251, 191, 36, 0.08)',
color: '#fde68a',
}}
>
{t('runtimeProvider.models.validationContextRequired')}
</div>
) : null}
{visibleModels.length === 0 ? (
<div className="rounded-md border border-dashed border-white/10 px-3 py-3 text-sm text-[var(--color-text-muted)]">
{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 (
<div
key={model.modelId}
@ -1912,61 +2022,57 @@ function ConfiguredOpenCodeModelsPanel({
<ModelBadges model={model} usedForNewTeams={selected} />
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
className="h-8"
disabled={!canTest}
title={canTest ? undefined : (contextRequiredTitle ?? unavailableTitle)}
onClick={() => {
if (!canTest) return;
void actions.testModel(model.providerId, model.modelId);
}}
>
{testing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<CheckCircle2 className="mr-1 size-3.5" />
)}
{t('runtimeProvider.actions.test')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8"
disabled={!canUse}
title={canUse ? undefined : unavailableTitle}
onClick={() => {
if (!canUse) return;
actions.useModelForNewTeams(model.modelId);
}}
>
{t('runtimeProvider.models.useInTeamPicker')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8"
disabled={!canSetDefault}
title={
canSetDefault
? undefined
: (contextRequiredTitle ??
(alreadyDefaultForScope
? t('runtimeProvider.models.alreadyDefault')
: unavailableTitle))
}
onClick={() => {
if (!canSetDefault) return;
void actions.setDefaultModel(model.providerId, model.modelId, defaultScope);
}}
>
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
{getDefaultScopeButtonLabel(defaultScope, t)}
</Button>
<DisabledActionTooltip reason={testDisabledReason}>
<Button
type="button"
size="sm"
variant="outline"
className="h-8"
disabled={!canTest}
onClick={() => {
if (!canTest) return;
void actions.testModel(model.providerId, model.modelId);
}}
>
{testing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<CheckCircle2 className="mr-1 size-3.5" />
)}
{t('runtimeProvider.actions.test')}
</Button>
</DisabledActionTooltip>
<DisabledActionTooltip reason={useDisabledReason}>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8"
disabled={!canUse}
onClick={() => {
if (!canUse) return;
actions.useModelForNewTeams(model.modelId);
}}
>
{t('runtimeProvider.models.useInTeamPicker')}
</Button>
</DisabledActionTooltip>
<DisabledActionTooltip reason={setDefaultDisabledReason}>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8"
disabled={!canSetDefault}
onClick={() => {
if (!canSetDefault) return;
void actions.setDefaultModel(model.providerId, model.modelId, defaultScope);
}}
>
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
{getDefaultScopeButtonLabel(defaultScope, t)}
</Button>
</DisabledActionTooltip>
</div>
</div>
<ModelResult result={result} />

View file

@ -653,9 +653,10 @@ describe('RuntimeProviderManagementPanelView', () => {
const row = host.querySelector<HTMLElement>(
'[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<HTMLElement>(
'[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(