From fe705ef47f2258f4fc730e0095a9d7bf8162cce6 Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Tue, 9 Jun 2026 23:31:44 +0700 Subject: [PATCH] fix(ui): use provider model selectors for delivery behavior Use shared provider/model selector UX for Delivery Behavior settings across workspace, agent, and channel configuration. Also closes disabled combobox dropdowns. --- .../shared/provider-model-select.tsx | 15 +++-- ui/web/src/components/ui/combobox.tsx | 16 ++++- .../agent-detail/agent-advanced-dialog.tsx | 27 ++++---- .../channel-delivery-provider-fields.ts | 11 ++++ ui/web/src/pages/channels/channel-fields.tsx | 63 +++++++++++++++++++ .../pages/channels/channel-schemas.test.ts | 9 +++ .../config/sections/behavior-chat-card.tsx | 42 ++++++++----- 7 files changed, 148 insertions(+), 35 deletions(-) create mode 100644 ui/web/src/pages/channels/channel-delivery-provider-fields.ts diff --git a/ui/web/src/components/shared/provider-model-select.tsx b/ui/web/src/components/shared/provider-model-select.tsx index 265bbffa..d094f5fb 100644 --- a/ui/web/src/components/shared/provider-model-select.tsx +++ b/ui/web/src/components/shared/provider-model-select.tsx @@ -43,6 +43,7 @@ interface ProviderModelSelectProps { modelFilter?: string; /** Extra models to prepend to the dropdown (e.g. curated embedding models not returned by API). */ extraModels?: { id: string; name: string }[]; + disabled?: boolean; } export function ProviderModelSelect({ @@ -64,6 +65,7 @@ export function ProviderModelSelect({ filterEmbedding, modelFilter, extraModels, + disabled, }: ProviderModelSelectProps) { const { t } = useTranslation("common"); const { providers } = useProviders(); @@ -92,10 +94,10 @@ export function ProviderModelSelect({ // Auto-select first enabled provider when none is set (unless allowEmpty). // Uses ref for callback so this only re-runs when provider or providers actually change. useEffect(() => { - if (!allowEmpty && !provider && enabledProviders.length > 0) { + if (!disabled && !allowEmpty && !provider && enabledProviders.length > 0) { onProviderChangeRef.current(enabledProviders[0]!.name); } - }, [allowEmpty, provider, enabledProviders]); + }, [allowEmpty, disabled, provider, enabledProviders]); const selectedProvider = useMemo( () => enabledProviders.find((p) => p.name === provider), @@ -118,6 +120,7 @@ export function ProviderModelSelect({ }, [llmChanged, verifyResult, onSaveBlockedChange]); const handleProviderChange = (v: string) => { + if (disabled) return; onProviderChange(v); // Only clear model when NOT in allowEmpty mode. // In allowEmpty mode (embedding config), both callbacks update the same @@ -129,7 +132,7 @@ export function ProviderModelSelect({ }; const handleVerify = async () => { - if (!selectedProviderId || !model.trim()) return; + if (disabled || !selectedProviderId || !model.trim()) return; await verify(selectedProviderId, model.trim()); }; @@ -138,7 +141,7 @@ export function ProviderModelSelect({
{providerLabel ?? t("provider")} {enabledProviders.length > 0 ? ( - handleProviderChange(v === "__empty__" ? "" : v)} disabled={disabled}> @@ -165,6 +168,7 @@ export function ProviderModelSelect({ value={provider} onChange={(e) => handleProviderChange(e.target.value)} placeholder={t("noProvidersConfigured")} + disabled={disabled} /> )}
@@ -195,6 +199,7 @@ export function ProviderModelSelect({ placeholder={modelsLoading ? t("loadingModels") : (modelPlaceholder ?? t("enterOrSelectModel"))} allowCustom customLabel={t("useCustomModel")} + disabled={disabled} /> {shouldShowVerify && ( @@ -203,7 +208,7 @@ export function ProviderModelSelect({ variant="outline" size="sm" className="h-9 shrink-0 px-3" - disabled={!selectedProviderId || !model.trim() || verifying} + disabled={disabled || !selectedProviderId || !model.trim() || verifying} onClick={handleVerify} > {verifying ? "..." : t("check")} diff --git a/ui/web/src/components/ui/combobox.tsx b/ui/web/src/components/ui/combobox.tsx index 8d63b1f4..70227d33 100644 --- a/ui/web/src/components/ui/combobox.tsx +++ b/ui/web/src/components/ui/combobox.tsx @@ -24,6 +24,7 @@ interface ComboboxProps { allowCustom?: boolean; /** Label for the custom value hint (default: "Use custom:"). */ customLabel?: string; + disabled?: boolean; } export function Combobox({ @@ -36,6 +37,7 @@ export function Combobox({ portalContainer, allowCustom, customLabel = "Use custom:", + disabled, }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); @@ -50,6 +52,13 @@ export function Combobox({ const dropdownRef = React.useRef(null); const [dropdownStyle, setDropdownStyle] = React.useState({}); + React.useEffect(() => { + if (!disabled) return; + setOpen(false); + setInputDirty(false); + inputDirtyRef.current = false; + }, [disabled]); + // Sync search text when value changes externally — show label if available React.useEffect(() => { // After handleSelect, skip sync while value is still the selected value. @@ -163,6 +172,7 @@ export function Combobox({ }, [options, search]); const handleSelect = (val: string) => { + if (disabled) return; selectedValueRef.current = val; // suppress value-sync until user types again onChange(val); onSelect?.(val); @@ -174,6 +184,7 @@ export function Combobox({ }; const handleInputChange = (e: React.ChangeEvent) => { + if (disabled) return; const val = e.target.value; selectedValueRef.current = null; // user is typing — resume normal value sync setSearch(val); @@ -186,6 +197,7 @@ export function Combobox({ }; const handleFocus = (e: React.FocusEvent) => { + if (disabled) return; // Only open dropdown on user-initiated focus (click/tab), not programmatic. // relatedTarget is null for programmatic focus or first tab into page. if (!e.relatedTarget && document.hasFocus()) return; @@ -237,13 +249,15 @@ export function Combobox({ value={search} onChange={handleInputChange} onFocus={handleFocus} + disabled={disabled} 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]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-1", + disabled && "cursor-not-allowed opacity-50", )} /> - {options.length > 0 && ( + {options.length > 0 && !disabled && ( setOpen(!open)} diff --git a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx index 9c16599c..fe8e263c 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -334,7 +335,7 @@ function DeliveryBehaviorSection({ disabled={disabled} provider={value.quick_ack?.provider ?? ""} model={value.quick_ack?.model ?? ""} - onProvider={(provider) => patchQuick({ provider })} + onProvider={(provider) => patchQuick({ provider, model: "" })} onModel={(model) => patchQuick({ model })} /> patchQuick({ min_delay_ms })} /> @@ -348,7 +349,7 @@ function DeliveryBehaviorSection({ disabled={disabled} provider={value.intermediate_replies?.provider ?? ""} model={value.intermediate_replies?.model ?? ""} - onProvider={(provider) => patchIntermediate({ provider })} + onProvider={(provider) => patchIntermediate({ provider, model: "" })} onModel={(model) => patchIntermediate({ model })} /> patchIntermediate({ timeout_ms })} /> @@ -361,16 +362,18 @@ function DeliveryBehaviorSection({ function DeliveryProviderFields({ disabled, provider, model, onProvider, onModel }: { disabled: boolean; provider: string; model: string; onProvider: (value: string) => void; onModel: (value: string) => void }) { const { t } = useTranslation("agents"); return ( -
-
- - onProvider(e.target.value)} /> -
-
- - onModel(e.target.value)} /> -
-
+ ); } diff --git a/ui/web/src/pages/channels/channel-delivery-provider-fields.ts b/ui/web/src/pages/channels/channel-delivery-provider-fields.ts new file mode 100644 index 00000000..717f36e1 --- /dev/null +++ b/ui/web/src/pages/channels/channel-delivery-provider-fields.ts @@ -0,0 +1,11 @@ +export function isDeliveryProviderKey(key: string) { + return /^chat_behavior\.(quick_ack|intermediate_replies)\.provider$/.test(key); +} + +export function isDeliveryModelKey(key: string) { + return /^chat_behavior\.(quick_ack|intermediate_replies)\.model$/.test(key); +} + +export function deliveryModelKey(providerKey: string) { + return providerKey.replace(/\.provider$/, ".model"); +} diff --git a/ui/web/src/pages/channels/channel-fields.tsx b/ui/web/src/pages/channels/channel-fields.tsx index a47c6d34..d1886f1b 100644 --- a/ui/web/src/pages/channels/channel-fields.tsx +++ b/ui/web/src/pages/channels/channel-fields.tsx @@ -13,7 +13,9 @@ import { } from "@/components/ui/select"; import { ToolNameSelect } from "@/components/shared/tool-name-select"; import { SkillNameSelect } from "@/components/shared/skill-name-select"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; import { BitrixPortalSelect } from "./bitrix24/bitrix-portal-select"; +import { deliveryModelKey, isDeliveryModelKey, isDeliveryProviderKey } from "./channel-delivery-provider-fields"; import type { FieldDef } from "./channel-schemas"; const INHERIT = "__inherit__"; @@ -41,6 +43,7 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont return (
{fields.map((field) => { + if (isDeliveryModelKey(field.key)) return null; // Conditional visibility: skip field if showWhen condition is not met if (field.showWhen) { const depValue = allValues[field.showWhen.key] ?? fields.find((f) => f.key === field.showWhen!.key)?.defaultValue; @@ -60,6 +63,25 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont disabledHint = field.disabledWhen.hint; } } + if (isDeliveryProviderKey(field.key)) { + const modelKey = deliveryModelKey(field.key); + const modelField = fields.find((f) => f.key === modelKey); + return ( + { + onChange(field.key, provider || undefined); + onChange(modelKey, undefined); + }} + onModelChange={(model) => onChange(modelKey, model || undefined)} + /> + ); + } return ( void; + onModelChange: (value: string) => void; +}) { + const { t } = useTranslation("channels"); + const providerLabel = t(`fieldConfig.${providerField.key}.label`, { defaultValue: providerField.label }); + const providerTip = providerField.help ? t(`fieldConfig.${providerField.key}.help`, { defaultValue: providerField.help }) : undefined; + const modelLabel = modelField ? t(`fieldConfig.${modelField.key}.label`, { defaultValue: modelField.label }) : t("fieldConfig.model.label", { defaultValue: "Model" }); + const modelTip = modelField?.help ? t(`fieldConfig.${modelField.key}.help`, { defaultValue: modelField.help }) : undefined; + + return ( + + ); +} + function FieldRenderer({ field, value, diff --git a/ui/web/src/pages/channels/channel-schemas.test.ts b/ui/web/src/pages/channels/channel-schemas.test.ts index b130f138..e7ddc2bf 100644 --- a/ui/web/src/pages/channels/channel-schemas.test.ts +++ b/ui/web/src/pages/channels/channel-schemas.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { configSchema } from "./channel-schemas"; +import { deliveryModelKey, isDeliveryModelKey, isDeliveryProviderKey } from "./channel-delivery-provider-fields"; import { normalizeReasoningDeliveryConfig, resolveReasoningDeliveryValue } from "./reasoning-delivery-config"; describe("telegram configSchema", () => { @@ -34,6 +35,14 @@ describe("telegram configSchema", () => { expect(quickMode.help).not.toMatch(/main LLM block reply/i); expect(quickMode.options!.map((option) => option.value)).toContain("sidecar_generated"); }); + + it("groups delivery provider and model fields for dropdown rendering", () => { + expect(isDeliveryProviderKey("chat_behavior.quick_ack.provider")).toBe(true); + expect(isDeliveryProviderKey("chat_behavior.intermediate_replies.provider")).toBe(true); + expect(isDeliveryModelKey("chat_behavior.quick_ack.model")).toBe(true); + expect(isDeliveryModelKey("chat_behavior.intermediate_replies.model")).toBe(true); + expect(deliveryModelKey("chat_behavior.intermediate_replies.provider")).toBe("chat_behavior.intermediate_replies.model"); + }); }); describe("pancake configSchema", () => { diff --git a/ui/web/src/pages/config/sections/behavior-chat-card.tsx b/ui/web/src/pages/config/sections/behavior-chat-card.tsx index b3f03544..25c02e8d 100644 --- a/ui/web/src/pages/config/sections/behavior-chat-card.tsx +++ b/ui/web/src/pages/config/sections/behavior-chat-card.tsx @@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { InfoLabel } from "@/components/shared/info-label"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; const quickAckModeValues = ["sidecar_generated", "llm_generated", "fixed_template", "off"] as const; type QuickAckMode = (typeof quickAckModeValues)[number]; @@ -154,10 +155,18 @@ export function BehaviorChatCard({ value, onChange }: Props) { disabled={!value.enabled} />
-
- patchAck({ provider })} /> - patchAck({ model })} /> -
+ patchAck({ provider, model: "" })} + model={value.quick_ack?.model ?? ""} + onModelChange={(model) => patchAck({ model })} + disabled={!value.enabled} + allowEmpty + providerLabel={t("behavior.provider")} + modelLabel={t("behavior.model")} + providerPlaceholder={t("behavior.providerPlaceholder")} + modelPlaceholder={t("behavior.modelPlaceholder")} + />
patchAck({ timeout_ms })} /> patchAck({ max_tokens })} /> @@ -205,10 +214,18 @@ export function BehaviorChatCard({ value, onChange }: Props) {
-
- patchIntermediate({ provider })} /> - patchIntermediate({ model })} /> -
+ patchIntermediate({ provider, model: "" })} + model={value.intermediate_replies?.model ?? ""} + onModelChange={(model) => patchIntermediate({ model })} + disabled={!value.enabled} + allowEmpty + providerLabel={t("behavior.provider")} + modelLabel={t("behavior.model")} + providerPlaceholder={t("behavior.providerPlaceholder")} + modelPlaceholder={t("behavior.modelPlaceholder")} + />
patchIntermediate({ timeout_ms })} /> patchIntermediate({ max_tokens })} /> @@ -260,15 +277,6 @@ function formatAckPreview(preview: PreviewResponse | null, t: (key: string, opti return `${t("behavior.previewAck")}: ${ack.content ?? ""}`; } -function TextField({ label, value, disabled, placeholder, onChange }: { label: string; value: string; disabled: boolean; placeholder?: string; onChange: (v: string) => void }) { - return ( -
- - onChange(e.target.value)} /> -
- ); -} - function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled: boolean; onChange: (v: number) => void }) { return (