diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index f8dd6ca3f8..e27bd3c6bd 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -112,14 +112,17 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const ready = route.length > 0 && !routeInvalid && !routeTaken && baseURL.length > 0 && models.length > 0 && modelFailure === undefined && keyFailure === undefined - // The one blocked gate worth a line under the form. The route id is omitted - // because its own field already explains itself, and a satisfied card says + // The one blocked gate worth a line under the form. A satisfied card says // nothing at all rather than printing an empty paragraph. const hint = failure !== undefined || ready // The key field prints its own failure directly beneath itself, so a card // blocked only by the key stays silent here rather than answering with the // next unmet gate — which is satisfied, and reads as a second, false fault. || keyFailure !== undefined + // Same for the route id, and it must be tested rather than assumed: the + // fallback arm below reads "no models yet", so an unmet route gate used to + // fall through to it and contradict the filled-in list right above. + || route.length === 0 || routeInvalid || routeTaken ? undefined : baseURL.length === 0 ? t('customNeedsBaseUrl') diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 831353bc5c..7d8f2efe27 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -790,6 +790,26 @@ describe('hand-declared providers', () => { expect(onClose).toHaveBeenCalledWith(true) }) + it('never contradicts a filled-in field with the next gate\u2019s copy', () => { + mountCard() + const routeField = screen.getByLabelText(en.customRoute) + fireEvent.change(routeField, { target: { value: '2' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + + // The route field explains itself right under the input; the shared line + // must stay silent rather than falling through to "no models yet" while + // the list above plainly has one. + expect(screen.getByText(en.customRouteInvalid)).toBeTruthy() + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + + // Fixing the route hands the line back to the gate that is actually unmet. + fireEvent.change(routeField, { target: { value: 'acme' } }) + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + expect(buttonNamed(en.create).disabled).toBe(false) + }) + it('refuses a route id whose derived credential reference would be illegal', () => { mountCard() const routeField = screen.getByLabelText(en.customRoute)