feat: add MiniMax-M2.7 model, custom model input, combobox UX fix, mobile font-size audit (#282)

- Add MiniMax-M2.7 to minimax_native provider model list
- Fix combobox dropdown UX: show all options on re-focus instead of filtering by current selection
- Add allowCustom prop to combobox for typing custom model names not in the list
- Add i18n key useCustomModel for EN/VI/ZH
- Audit all raw input/select/textarea elements across web UI to use text-base (16px) on mobile, preventing iOS Safari auto-zoom on focus
This commit is contained in:
Goon authored and GitHub committed 2026-03-19 23:47:11 +07:00
1 parent 49540651af
commit 1454614ca8
21 files changed
+84 -29

No files matched your search

+1
View File
@@ -195,6 +195,7 @@ func minimaxModels() []ModelInfo {
// Chat / text
{ID: "MiniMax-Text-01", Name: "MiniMax Text 01"},
{ID: "MiniMax-M1", Name: "MiniMax M1"},
{ID: "MiniMax-M2.7", Name: "MiniMax M2.7"},
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
// Image generation
{ID: "image-01", Name: "Image 01"},
@@ -148,6 +148,8 @@ export function ProviderModelSelect({
onChange={onModelChange}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? t("loadingModels") : (modelPlaceholder ?? t("enterOrSelectModel"))}
allowCustom
customLabel={t("useCustomModel")}
/>
</div>
{shouldShowVerify && (
@@ -122,7 +122,7 @@ export function SkillNameSelect({
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? (placeholder ?? t("selectOrTypeSkills")) : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
+1 -1
View File
@@ -73,7 +73,7 @@ export function TagInput({
onKeyDown={handleKeyDown}
onBlur={() => { if (input.trim()) addTag(input); }}
placeholder={value.length === 0 ? (placeholder ?? t("typeAndPressEnter")) : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm outline-none"
/>
</div>
);
@@ -150,7 +150,7 @@ export function ToolNameSelect({
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? (placeholder ?? t("selectOrTypeTools")) : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
+52 -3
View File
@@ -16,6 +16,10 @@ interface ComboboxProps {
className?: string;
/** Render dropdown into a portal container (useful inside dialogs with overflow clipping). */
portalContainer?: React.RefObject<HTMLElement | null>;
/** Allow typing custom values not in the options list. Shows a hint in the dropdown. */
allowCustom?: boolean;
/** Label for the custom value hint (default: "Use custom:"). */
customLabel?: string;
}
export function Combobox({
@@ -25,9 +29,15 @@ export function Combobox({
placeholder,
className,
portalContainer,
allowCustom,
customLabel = "Use custom:",
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
// Track whether user actively typed since last focus — when false, show all options
const inputDirtyRef = React.useRef(false);
const [inputDirty, setInputDirty] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = React.useState<React.CSSProperties>({});
@@ -48,6 +58,8 @@ export function Combobox({
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setOpen(false);
setInputDirty(false);
inputDirtyRef.current = false;
}
};
document.addEventListener("mousedown", handler);
@@ -92,14 +104,23 @@ export function Combobox({
}
}, [open, search, resolvedPortal]);
// When not dirty (just focused), show all options. When dirty, filter by search.
const filtered = React.useMemo(() => {
if (!search) return options;
if (!inputDirty || !search) return options;
const q = search.toLowerCase();
return options.filter(
(o) =>
o.value.toLowerCase().includes(q) ||
(o.label && o.label.toLowerCase().includes(q)),
);
}, [options, search, inputDirty]);
// Check if typed value is a custom value (not matching any option exactly)
const isCustomValue = React.useMemo(() => {
if (!search.trim()) return false;
return !options.some(
(o) => o.value === search || o.label === search,
);
}, [options, search]);
const handleSelect = (val: string) => {
@@ -107,16 +128,33 @@ export function Combobox({
const match = options.find((o) => o.value === val);
setSearch(match?.label || val);
setOpen(false);
setInputDirty(false);
inputDirtyRef.current = false;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setSearch(val);
onChange(val);
if (!inputDirtyRef.current) {
inputDirtyRef.current = true;
setInputDirty(true);
}
if (!open && options.length > 0) setOpen(true);
};
const dropdownContent = open && filtered.length > 0 && (
const handleFocus = () => {
// Reset dirty state on focus — shows all options initially
inputDirtyRef.current = false;
setInputDirty(false);
if (options.length > 0) setOpen(true);
// Select all text so user can start typing to replace
requestAnimationFrame(() => inputRef.current?.select());
};
const showCustomHint = allowCustom && inputDirty && isCustomValue && search.trim();
const dropdownContent = open && (filtered.length > 0 || showCustomHint) && (
<div
ref={dropdownRef}
style={dropdownStyle}
@@ -136,15 +174,26 @@ export function Combobox({
)}
</button>
))}
{showCustomHint && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(search.trim())}
className="hover:bg-accent hover:text-accent-foreground text-muted-foreground flex w-full cursor-pointer items-center rounded-sm py-1.5 pl-2 text-sm italic outline-hidden select-none"
>
{customLabel} <span className="text-foreground ml-1 font-medium not-italic">{search.trim()}</span>
</button>
)}
</div>
);
return (
<div ref={containerRef} className={cn("relative", className)}>
<input
ref={inputRef}
value={search}
onChange={handleInputChange}
onFocus={() => options.length > 0 && setOpen(true)}
onFocus={handleFocus}
placeholder={placeholder}
className={cn(
"border-input placeholder:text-muted-foreground dark:bg-input/30 h-9 w-full rounded-md border bg-transparent px-3 py-1 pr-8 text-base md:text-sm shadow-xs outline-none transition-[color,box-shadow]",
+1
View File
@@ -99,6 +99,7 @@
"model": "Model",
"selectProvider": "Select provider",
"enterOrSelectModel": "Enter or select model",
"useCustomModel": "Use custom:",
"providerTip": "LLM provider name. Must match a configured provider.",
"modelTip": "Model ID to use.",
"check": "Check",
+1
View File
@@ -45,6 +45,7 @@
"manage": "Quản lý",
"messages": "tin nhắn",
"model": "Model",
"useCustomModel": "Dùng tuỳ chỉnh:",
"modelTip": "ID model để sử dụng.",
"modelVerified": "Model đã được xác minh",
"name": "Tên",
+1
View File
@@ -45,6 +45,7 @@
"manage": "管理",
"messages": "条消息",
"model": "模型",
"useCustomModel": "使用自定义:",
"modelTip": "要使用的模型 ID。",
"modelVerified": "模型已验证",
"name": "名称",
@@ -202,7 +202,7 @@ function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set<string>;
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onFocus={() => search.length >= 2 && setOpen(true)}
placeholder={t("instances.searchContacts")}
className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{open && search.length >= 2 && filtered.length > 0 && createPortal(
@@ -72,7 +72,7 @@ export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) {
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onFocus={() => search.length >= 2 && setOpen(true)}
placeholder={t("files.insertContact")}
className="h-8 w-full max-w-sm rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
className="h-8 w-full max-w-sm rounded-md border bg-transparent pl-7 pr-2 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{open && search.length >= 2 && contacts.length > 0 && createPortal(
+3 -3
View File
@@ -200,7 +200,7 @@ export function EventsPage() {
<select
value={teamFilter}
onChange={(e) => setTeamFilter(e.target.value)}
className="h-7 rounded-md border bg-background px-2 text-xs"
className="h-7 rounded-md border bg-background px-2 text-base md:text-xs"
>
<option value="all">{t("filters.allTeams")}</option>
{uniqueTeams.map((id) => (
@@ -215,7 +215,7 @@ export function EventsPage() {
<select
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
className="h-7 rounded-md border bg-background px-2 text-xs"
className="h-7 rounded-md border bg-background px-2 text-base md:text-xs"
>
<option value="all">{t("filters.allUsers")}</option>
{uniqueUsers.map((uid) => (
@@ -229,7 +229,7 @@ export function EventsPage() {
<select
value={chatFilter}
onChange={(e) => setChatFilter(e.target.value)}
className="h-7 rounded-md border bg-background px-2 text-xs"
className="h-7 rounded-md border bg-background px-2 text-base md:text-xs"
>
<option value="all">{t("filters.allChats")}</option>
{uniqueChats.map((cid) => (
@@ -49,7 +49,7 @@ export function KnowledgeGraphPage() {
id="kg-agent"
value={agentId}
onChange={(e) => { setAgentId(e.target.value); setUserIdFilter(""); }}
className="h-8 rounded-md border bg-background px-2 text-sm"
className="h-8 rounded-md border bg-background px-2 text-base md:text-sm"
>
<option value="">{t("filters.selectAgent")}</option>
{agents.map((a) => (
@@ -63,7 +63,7 @@ export function KnowledgeGraphPage() {
id="kg-scope"
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value)}
className="h-8 rounded-md border bg-background px-2 text-sm max-w-[240px]"
className="h-8 rounded-md border bg-background px-2 text-base md:text-sm max-w-[240px]"
>
<option value="">{t("filters.allScope")}</option>
{scopeOptions.map((o) => (
@@ -84,7 +84,7 @@ export function KnowledgeGraphPage() {
<select
value={agentId}
onChange={(e) => { setAgentId(e.target.value); setUserIdFilter(""); }}
className="mt-2 h-9 rounded-md border bg-background px-3 text-sm"
className="mt-2 h-9 rounded-md border bg-background px-3 text-base md:text-sm"
>
<option value="">{t("filters.selectAgent")}</option>
{agents.map((a) => (
+1 -1
View File
@@ -169,7 +169,7 @@ export function PairingForm({ onApproved }: PairingFormProps) {
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder={t("pairing.userIdPlaceholder")}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base md:text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoFocus
/>
</div>
+2 -2
View File
@@ -59,7 +59,7 @@ export function TokenForm({ onSubmit }: TokenFormProps) {
value={userId}
onChange={(e) => { setUserId(e.target.value); setError(null); }}
placeholder={t("token.userIdPlaceholder")}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base md:text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoFocus
disabled={connecting}
/>
@@ -75,7 +75,7 @@ export function TokenForm({ onSubmit }: TokenFormProps) {
value={token}
onChange={(e) => { setToken(e.target.value); setError(null); }}
placeholder={t("token.tokenPlaceholder")}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base md:text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={connecting}
/>
</div>
+2 -2
View File
@@ -70,7 +70,7 @@ export function LogsPage() {
<select
value={level}
onChange={(e) => startTail(e.target.value as LogLevel)}
className="h-8 cursor-pointer rounded-md border bg-background px-2 text-xs"
className="h-8 cursor-pointer rounded-md border bg-background px-2 text-base md:text-xs"
title={t("logLevelTitle")}
>
{levels.map((l) => (
@@ -110,7 +110,7 @@ export function LogsPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("filterPlaceholder")}
className="h-7 w-full rounded-md border bg-background pl-8 pr-3 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
className="h-7 w-full rounded-md border bg-background pl-8 pr-3 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex gap-0.5">
+4 -4
View File
@@ -194,18 +194,18 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
<>
<div className="grid gap-1.5">
<Label htmlFor="mcp-cmd">{t("form.command")}</Label>
<Input id="mcp-cmd" value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npx -y @modelcontextprotocol/server-everything" className="font-mono text-sm" />
<Input id="mcp-cmd" value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npx -y @modelcontextprotocol/server-everything" className="font-mono" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-args">{t("form.args")}</Label>
<Input id="mcp-args" value={args} onChange={(e) => setArgs(e.target.value)} placeholder={t("form.argsPlaceholder")} className="font-mono text-sm" />
<Input id="mcp-args" value={args} onChange={(e) => setArgs(e.target.value)} placeholder={t("form.argsPlaceholder")} className="font-mono" />
</div>
</>
) : (
<>
<div className="grid gap-1.5">
<Label htmlFor="mcp-url">{t("form.url")}</Label>
<Input id="mcp-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://localhost:3001/sse" className="font-mono text-sm" />
<Input id="mcp-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://localhost:3001/sse" className="font-mono" />
</div>
<div className="grid gap-1.5">
<Label>{t("form.headers")}</Label>
@@ -237,7 +237,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
<Label htmlFor="mcp-prefix">{t("form.toolPrefix")}</Label>
<div className="flex">
<span className="inline-flex items-center px-2.5 rounded-l-md border border-r-0 border-input bg-muted text-muted-foreground text-sm font-mono">mcp_</span>
<Input id="mcp-prefix" value={toolPrefix} onChange={(e) => setToolPrefix(e.target.value.replace(/[^a-z0-9_]/g, ""))} placeholder={name.replace(/-/g, "_") || "auto"} className="rounded-l-none font-mono text-sm" />
<Input id="mcp-prefix" value={toolPrefix} onChange={(e) => setToolPrefix(e.target.value.replace(/[^a-z0-9_]/g, ""))} placeholder={name.replace(/-/g, "_") || "auto"} className="rounded-l-none font-mono" />
</div>
<p className="text-xs text-muted-foreground">{t("form.toolPrefixHint")} Tools: <code className="text-[10px]">mcp_&#123;prefix&#125;__&#123;tool&#125;</code></p>
</div>
+1 -1
View File
@@ -408,7 +408,7 @@ function ToolMultiSelect({
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
@@ -109,7 +109,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
id="mc-agent"
value={selectedAgentId || parentAgentId || ""}
onChange={(e) => setSelectedAgentId(e.target.value)}
className="h-9 rounded-md border bg-background px-3 text-sm"
className="h-9 rounded-md border bg-background px-3 text-base md:text-sm"
>
<option value="">{t("createDialog.agentIdPlaceholder")}</option>
{agents.map((a) => (
@@ -158,7 +158,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className="h-9 rounded-md border bg-background px-3 text-sm"
className="h-9 rounded-md border bg-background px-3 text-base md:text-sm"
>
<option value="">{t("createDialog.selectGroupUser")}</option>
{knownUserIds.map((uid) => (
+2 -2
View File
@@ -134,7 +134,7 @@ export function MemoryPage() {
id="mem-agent"
value={agentId}
onChange={(e) => { setAgentId(e.target.value); setUserIdFilter(""); setPage(1); }}
className="h-9 rounded-md border bg-background px-3 text-sm"
className="h-9 rounded-md border bg-background px-3 text-base md:text-sm"
>
<option value="">{t("filters.allAgents")}</option>
{agents.map((a) => (
@@ -150,7 +150,7 @@ export function MemoryPage() {
id="mem-scope"
value={userIdFilter}
onChange={(e) => { setUserIdFilter(e.target.value); setPage(1); }}
className="h-9 rounded-md border bg-background px-3 text-sm min-w-[180px]"
className="h-9 rounded-md border bg-background px-3 text-base md:text-sm min-w-[180px]"
>
<option value="">{t("filters.allScope")}</option>
{userIds.map((uid) => (
@@ -137,7 +137,7 @@ export function SessionDetailPage({
<div className="flex items-center gap-1">
<input
autoFocus
className="h-7 rounded border bg-background px-2 text-sm font-medium outline-none focus:ring-1 focus:ring-ring"
className="h-7 rounded border bg-background px-2 text-base md:text-sm font-medium outline-none focus:ring-1 focus:ring-ring"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {