mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
Revert HeroUI to v2
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Generated
+3089
-196
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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,4 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import { heroui } from "@heroui/react";
|
||||
import typography from "@tailwindcss/typography";
|
||||
export default {
|
||||
darkMode: "class",
|
||||
|
||||
Reference in New Issue
Block a user