Revert HeroUI to v2

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-04-27 02:56:29 +00:00
parent cdd0ea98eb
commit fbfd3015ab
7 changed files with 3266 additions and 372 deletions
+4 -4
View File
@@ -70,8 +70,8 @@
- `.openhands/setup.sh` installs frontend dependencies with `npm ci` when needed, creates `.env` from `.env.sample` if missing, appends `VITE_WORKING_DIR` for this repo when unset, and generates `src/i18n/declaration.ts` via `npm run make-i18n`.
- `.openhands/pre-commit.sh` mirrors the repo's local quality gate with `npm run lint && npm run test`.
- `.github/workflows/pr-review.yml` is configured for on-demand OpenHands reviews only (`review-this` label or requesting `openhands-agent` / `all-hands-bot`), uses the OpenHands app LLM proxy defaults, and expects a repository `LLM_API_KEY` secret.
- HeroUI v3 migration notes:
- `@heroui/react` no longer exports the old Tailwind `heroui()` plugin or v2 autocomplete item/section components; `hero.ts` / `tailwind.config.js` should not import `heroui` from `@heroui/react`.
- Tailwind v4 integration now works by importing `@heroui/styles/themes/default` in `src/tailwind.css` and sourcing the v3 package files from `node_modules/@heroui/react/dist/**/*`.
- For settings-style dropdowns, the previous `Autocomplete` + `AutocompleteItem`/`AutocompleteSection` API is incompatible with v3; this repo now uses a lightweight custom input/dropdown implementation in `src/components/features/settings/settings-dropdown-input.tsx`, and `ModelSelector` builds on that wrapper.
- HeroUI rollback / migration notes:
- The attempted HeroUI v3 upgrade changed global theme wiring and homepage design tokens enough that the repo currently prefers `@heroui/react@2.8.10` until a broader visual validation pass is done.
- Keep the v2 Tailwind integration active via `@plugin '../hero.ts'` in `src/tailwind.css` and source HeroUI classes from `node_modules/@heroui/theme/dist/**/*`.
- The settings UI currently relies on the v2 `Autocomplete` + `AutocompleteItem`/`AutocompleteSection` APIs in `settings-dropdown-input.tsx` and `model-selector.tsx`; a future v3 retry will need to replace those controls again.
+3089 -196
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@
"node": ">=22.12.0"
},
"dependencies": {
"@heroui/react": "3.0.3",
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.18",
"@monaco-editor/react": "4.7.0",
"@openhands/typescript-client": "github:OpenHands/typescript-client#39228dd",
@@ -1,8 +1,8 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { OptionalTag } from "./optional-tag";
import { cn } from "#/utils/utils";
interface SettingsDropdownInputProps {
testId: string;
@@ -39,6 +39,7 @@ export function SettingsDropdownInput({
isLoading,
defaultSelectedKey,
selectedKey,
isClearable,
allowsCustomValue,
required,
onSelectionChange,
@@ -49,132 +50,50 @@ export function SettingsDropdownInput({
inputClassName,
}: SettingsDropdownInputProps) {
const { t } = useTranslation();
const rootRef = useClickOutsideElement<HTMLLabelElement>(() => setIsOpen(false));
const [isOpen, setIsOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const currentSelectedKey = selectedKey ?? defaultSelectedKey;
React.useEffect(() => {
const selectedItem = items.find(
(item) => item.key.toString() === currentSelectedKey,
);
if (selectedItem) {
setInputValue(selectedItem.label);
return;
}
if (allowsCustomValue && currentSelectedKey) {
setInputValue(currentSelectedKey);
return;
}
setInputValue("");
}, [allowsCustomValue, currentSelectedKey, items]);
const filteredItems = React.useMemo(() => {
if (!inputValue) {
return items;
}
return items.filter((item) =>
defaultFilter
? defaultFilter(item.label, inputValue)
: item.label.toLowerCase().includes(inputValue.toLowerCase()),
);
}, [defaultFilter, inputValue, items]);
const handleSelect = (item: { key: React.Key; label: string }) => {
setInputValue(item.label);
setIsOpen(false);
onInputChange?.(item.label);
onSelectionChange?.(item.key);
};
const handleInputChange = (value: string) => {
setInputValue(value);
setIsOpen(true);
onInputChange?.(value);
if (!value) {
onSelectionChange?.(null);
return;
}
const exactMatch = items.find((item) => item.label === value);
if (exactMatch) {
onSelectionChange?.(exactMatch.key);
}
};
return (
<label className={cn("flex flex-col gap-2.5", wrapperClassName)} ref={rootRef}>
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
{label && (
<div className="flex items-center gap-1">
<span className="text-sm">{label}</span>
{showOptionalTag && <OptionalTag />}
</div>
)}
<div className="relative w-full">
<div
className={cn(
"flex items-center gap-2 bg-tertiary border border-[#717888] h-10 w-full max-w-[680px] rounded-sm px-2",
{
"opacity-60 cursor-not-allowed": isDisabled || isLoading,
},
inputWrapperClassName,
)}
>
{startContent}
<input
aria-label={typeof label === "string" ? label : name}
className={cn(
"bg-transparent border-0 outline-none w-full placeholder:italic",
inputClassName,
)}
data-testid={testId}
disabled={isDisabled || isLoading}
name={name}
onChange={(event) => handleInputChange(event.target.value)}
onClick={() => setIsOpen(true)}
onFocus={() => setIsOpen(true)}
placeholder={isLoading ? t("HOME$LOADING") : placeholder}
required={required}
value={inputValue}
/>
<button
aria-label={`${name}-toggle`}
className="text-xs text-white/70"
disabled={isDisabled || isLoading}
onClick={(event) => {
event.preventDefault();
setIsOpen((open) => !open);
}}
type="button"
>
▾
</button>
</div>
{isOpen && filteredItems.length > 0 && !(isDisabled || isLoading) && (
<ul className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl border border-[#717888] bg-tertiary p-1 shadow-lg">
{filteredItems.map((item) => (
<li key={item.key.toString()}>
<button
className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-[#717888]"
onClick={() => handleSelect(item)}
onMouseDown={(event) => event.preventDefault()}
type="button"
>
{item.label}
</button>
</li>
))}
</ul>
<Autocomplete
aria-label={typeof label === "string" ? label : name}
data-testid={testId}
name={name}
items={items}
defaultSelectedKey={defaultSelectedKey}
selectedKey={selectedKey}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isClearable={isClearable}
isDisabled={isDisabled || isLoading}
isLoading={isLoading}
placeholder={isLoading ? t("HOME$LOADING") : placeholder}
allowsCustomValue={allowsCustomValue}
isRequired={required}
className="w-full"
classNames={{
popoverContent: "bg-tertiary rounded-xl",
}}
inputProps={{
classNames: {
inputWrapper: cn(
"bg-tertiary border border-[#717888] h-10 w-full max-w-[680px] rounded-sm p-2 placeholder:italic",
inputWrapperClassName,
),
input: inputClassName,
},
}}
defaultFilter={defaultFilter}
startContent={startContent || null}
>
{(item) => (
<AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>
)}
</div>
</Autocomplete>
</label>
);
}
@@ -1,6 +1,10 @@
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
} from "@heroui/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
import { mapProvider } from "#/utils/map-provider";
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
@@ -30,6 +34,7 @@ export function ModelSelector({
wrapperClassName,
labelClassName,
}: ModelSelectorProps) {
const [, setLitellmId] = React.useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
null,
);
@@ -42,50 +47,58 @@ export function ModelSelector({
error: modelsError,
} = useProviderModels(selectedProvider);
React.useEffect(() => {
if (!currentModel) {
return;
}
const { provider, model } = extractModelAndProvider(currentModel);
setSelectedProvider(provider || null);
setSelectedModel(model);
onDefaultValuesChanged?.(provider || null, model);
}, [currentModel, onDefaultValuesChanged]);
const providerItems = React.useMemo(
() =>
[...providers]
.sort((left, right) => Number(right.verified) - Number(left.verified))
.map((provider) => ({
key: provider.name,
label: mapProvider(provider.name),
})),
const verifiedProviders = React.useMemo(
() => providers.filter((p) => p.verified),
[providers],
);
const unverifiedProviders = React.useMemo(
() => providers.filter((p) => !p.verified),
[providers],
);
const modelItems = React.useMemo(
() =>
[...providerModels]
.sort((left, right) => Number(right.verified) - Number(left.verified))
.map((model) => ({
key: model.name,
label: model.name,
})),
const verifiedModels = React.useMemo(
() => providerModels.filter((m) => m.verified),
[providerModels],
);
const unverifiedModels = React.useMemo(
() => providerModels.filter((m) => !m.verified),
[providerModels],
);
const handleChangeProvider = (provider: string | null) => {
React.useEffect(() => {
if (currentModel) {
const { provider, model } = extractModelAndProvider(currentModel);
setLitellmId(currentModel);
setSelectedProvider(provider || null);
setSelectedModel(model);
onDefaultValuesChanged?.(provider || null, model);
}
}, [currentModel]);
const handleChangeProvider = (provider: string) => {
setSelectedProvider(provider);
setSelectedModel(null);
setLitellmId(`${provider}/`);
onChange?.(provider, null);
};
const handleChangeModel = (model: string | null) => {
const handleChangeModel = (model: string) => {
let fullModel = `${selectedProvider}/${model}`;
if (selectedProvider === "openai") {
// LiteLLM lists OpenAI models without the openai/ prefix
fullModel = model;
}
setLitellmId(fullModel);
setSelectedModel(model);
onChange?.(selectedProvider, model);
};
const clear = () => {
setSelectedProvider(null);
setLitellmId(null);
};
const { t } = useTranslation();
return (
@@ -96,21 +109,54 @@ export function ModelSelector({
)}
>
<fieldset className="flex flex-col gap-2.5 w-full">
<SettingsDropdownInput
testId="llm-provider-input"
<label className={cn("text-sm", labelClassName)}>
{t(I18nKey.LLM$PROVIDER)}
</label>
<Autocomplete
data-testid="llm-provider-input"
isRequired
isVirtualized={false}
name="llm-provider-input"
label={<span className={cn("text-sm", labelClassName)}>{t(I18nKey.LLM$PROVIDER)}</span>}
items={providerItems}
isDisabled={isDisabled}
aria-label={t(I18nKey.LLM$PROVIDER)}
placeholder={t(I18nKey.LLM$SELECT_PROVIDER_PLACEHOLDER)}
selectedKey={selectedProvider ?? undefined}
onSelectionChange={(key) => handleChangeProvider(key?.toString() ?? null)}
onInputChange={(value) => {
if (!value) {
handleChangeProvider(null);
}
isClearable={false}
onSelectionChange={(e) => {
if (e?.toString()) handleChangeProvider(e.toString());
}}
/>
onInputChange={(value) => !value && clear()}
defaultSelectedKey={selectedProvider ?? undefined}
selectedKey={selectedProvider}
classNames={{
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
}}
inputProps={{
classNames: {
inputWrapper:
"bg-tertiary border border-[#717888] h-10 w-full rounded-sm p-2 placeholder:italic",
},
}}
>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
{verifiedProviders.map((provider) => (
<AutocompleteItem
data-testid={`provider-item-${provider.name}`}
key={provider.name}
>
{mapProvider(provider.name)}
</AutocompleteItem>
))}
</AutocompleteSection>
{unverifiedProviders.length > 0 ? (
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
{unverifiedProviders.map((provider) => (
<AutocompleteItem key={provider.name}>
{mapProvider(provider.name)}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null}
</Autocomplete>
</fieldset>
{selectedProvider === "openhands" && (
@@ -125,17 +171,52 @@ export function ModelSelector({
)}
<fieldset className="flex flex-col gap-2.5 w-full">
<SettingsDropdownInput
testId="llm-model-input"
name="llm-model-input"
label={<span className={cn("text-sm", labelClassName)}>{t(I18nKey.LLM$MODEL)}</span>}
items={modelItems}
isDisabled={isDisabled || !selectedProvider}
<label className={cn("text-sm", labelClassName)}>
{t(I18nKey.LLM$MODEL)}
</label>
<Autocomplete
data-testid="llm-model-input"
isRequired
isVirtualized={false}
isLoading={isLoadingModels}
name="llm-model-input"
aria-label={t(I18nKey.LLM$MODEL)}
placeholder={t(I18nKey.LLM$SELECT_MODEL_PLACEHOLDER)}
selectedKey={selectedModel ?? undefined}
onSelectionChange={(key) => handleChangeModel(key?.toString() ?? null)}
/>
isClearable={false}
onSelectionChange={(e) => {
if (e?.toString()) handleChangeModel(e.toString());
}}
isDisabled={isDisabled || !selectedProvider}
selectedKey={selectedModel}
defaultSelectedKey={selectedModel ?? undefined}
classNames={{
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
}}
inputProps={{
classNames: {
inputWrapper:
"bg-tertiary border border-[#717888] h-10 w-full rounded-sm p-2 placeholder:italic",
},
}}
>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
{verifiedModels.map((model) => (
<AutocompleteItem key={model.name}>{model.name}</AutocompleteItem>
))}
</AutocompleteSection>
{unverifiedModels.length > 0 ? (
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
{unverifiedModels.map((model) => (
<AutocompleteItem
data-testid={`model-item-${model.name}`}
key={model.name}
>
{model.name}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null}
</Autocomplete>
{modelsError && (
<p data-testid="models-error" className="text-danger text-xs">
{t(I18nKey.CONFIGURATION$ERROR_FETCH_MODELS)}
+2 -2
View File
@@ -1,8 +1,8 @@
@import "tailwindcss";
@import "@heroui/styles/themes/default" layer(base);
@plugin '../hero.ts';
@config "../tailwind.config.js";
@source '../node_modules/@heroui/react/dist/**/*.{js,ts,jsx,tsx}';
@source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@plugin 'tailwind-scrollbar' {
nocompatible: true;
+1
View File
@@ -1,4 +1,5 @@
/** @type {import('tailwindcss').Config} */
import { heroui } from "@heroui/react";
import typography from "@tailwindcss/typography";
export default {
darkMode: "class",