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.
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-06-09 23:31:44 +07:00
1 parent bac8f511e6
commit fe705ef47f
7 files changed
+148 -35

No files matched your search

@@ -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({
<div className="grid gap-1.5">
<InfoLabel tip={providerTip ?? t("providerTip")}>{providerLabel ?? t("provider")}</InfoLabel>
{enabledProviders.length > 0 ? (
<Select value={provider || "__empty__"} onValueChange={(v) => handleProviderChange(v === "__empty__" ? "" : v)}>
<Select value={provider || "__empty__"} onValueChange={(v) => handleProviderChange(v === "__empty__" ? "" : v)} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder={providerPlaceholder ?? t("selectProvider")} />
</SelectTrigger>
@@ -165,6 +168,7 @@ export function ProviderModelSelect({
value={provider}
onChange={(e) => handleProviderChange(e.target.value)}
placeholder={t("noProvidersConfigured")}
disabled={disabled}
/>
)}
</div>
@@ -195,6 +199,7 @@ export function ProviderModelSelect({
placeholder={modelsLoading ? t("loadingModels") : (modelPlaceholder ?? t("enterOrSelectModel"))}
allowCustom
customLabel={t("useCustomModel")}
disabled={disabled}
/>
</div>
{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")}
+15 -1
View File
@@ -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<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = React.useState<React.CSSProperties>({});
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<HTMLInputElement>) => {
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 && (
<ChevronDownIcon
className="text-muted-foreground absolute top-1/2 right-2.5 size-4 -translate-y-1/2 cursor-pointer opacity-50"
onClick={() => setOpen(!open)}
@@ -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 })}
/>
<NumberField label={t("delivery.quickAckDelay")} value={value.quick_ack?.min_delay_ms ?? 1000} disabled={disabled} onChange={(min_delay_ms) => 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 })}
/>
<NumberField label={t("delivery.timeoutMs")} value={value.intermediate_replies?.timeout_ms ?? 2500} disabled={disabled} onChange={(timeout_ms) => 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 (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label>{t("delivery.provider")}</Label>
<Input className="text-base md:text-sm" value={provider} disabled={disabled} placeholder={t("delivery.providerPlaceholder")} onChange={(e) => onProvider(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label>{t("delivery.model")}</Label>
<Input className="text-base md:text-sm" value={model} disabled={disabled} placeholder={t("delivery.modelPlaceholder")} onChange={(e) => onModel(e.target.value)} />
</div>
</div>
<ProviderModelSelect
provider={provider}
onProviderChange={onProvider}
model={model}
onModelChange={onModel}
disabled={disabled}
allowEmpty
providerLabel={t("delivery.provider")}
modelLabel={t("delivery.model")}
providerPlaceholder={t("delivery.providerPlaceholder")}
modelPlaceholder={t("delivery.modelPlaceholder")}
/>
);
}
@@ -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");
}
@@ -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 (
<div className="grid gap-3">
{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 (
<DeliveryProviderModelField
key={field.key}
providerField={field}
modelField={modelField}
providerValue={(values[field.key] as string) ?? ""}
modelValue={(values[modelKey] as string) ?? ""}
disabled={disabled}
onProviderChange={(provider) => {
onChange(field.key, provider || undefined);
onChange(modelKey, undefined);
}}
onModelChange={(model) => onChange(modelKey, model || undefined)}
/>
);
}
return (
<FieldRenderer
key={field.key}
@@ -80,6 +102,47 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont
);
}
function DeliveryProviderModelField({
providerField,
modelField,
providerValue,
modelValue,
disabled,
onProviderChange,
onModelChange,
}: {
providerField: FieldDef;
modelField?: FieldDef;
providerValue: string;
modelValue: string;
disabled?: boolean;
onProviderChange: (value: string) => 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 (
<ProviderModelSelect
provider={providerValue}
onProviderChange={onProviderChange}
model={modelValue}
onModelChange={onModelChange}
allowEmpty
disabled={disabled}
providerLabel={providerLabel}
modelLabel={modelLabel}
providerTip={providerTip}
modelTip={modelTip}
providerPlaceholder={providerField.placeholder}
modelPlaceholder={modelField?.placeholder}
/>
);
}
function FieldRenderer({
field,
value,
@@ -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", () => {
@@ -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}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<TextField label={t("behavior.provider")} value={value.quick_ack?.provider ?? ""} disabled={!value.enabled} placeholder={t("behavior.providerPlaceholder")} onChange={(provider) => patchAck({ provider })} />
<TextField label={t("behavior.model")} value={value.quick_ack?.model ?? ""} disabled={!value.enabled} placeholder={t("behavior.modelPlaceholder")} onChange={(model) => patchAck({ model })} />
</div>
<ProviderModelSelect
provider={value.quick_ack?.provider ?? ""}
onProviderChange={(provider) => 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")}
/>
<div className="grid grid-cols-3 gap-3">
<NumberField label={t("behavior.timeoutMs")} value={value.quick_ack?.timeout_ms ?? 2500} disabled={!value.enabled} onChange={(timeout_ms) => patchAck({ timeout_ms })} />
<NumberField label={t("behavior.maxTokens")} value={value.quick_ack?.max_tokens ?? 40} disabled={!value.enabled} onChange={(max_tokens) => patchAck({ max_tokens })} />
@@ -205,10 +214,18 @@ export function BehaviorChatCard({ value, onChange }: Props) {
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<TextField label={t("behavior.provider")} value={value.intermediate_replies?.provider ?? ""} disabled={!value.enabled} placeholder={t("behavior.providerPlaceholder")} onChange={(provider) => patchIntermediate({ provider })} />
<TextField label={t("behavior.model")} value={value.intermediate_replies?.model ?? ""} disabled={!value.enabled} placeholder={t("behavior.modelPlaceholder")} onChange={(model) => patchIntermediate({ model })} />
</div>
<ProviderModelSelect
provider={value.intermediate_replies?.provider ?? ""}
onProviderChange={(provider) => 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")}
/>
<div className="grid grid-cols-3 gap-3">
<NumberField label={t("behavior.timeoutMs")} value={value.intermediate_replies?.timeout_ms ?? 2500} disabled={!value.enabled} onChange={(timeout_ms) => patchIntermediate({ timeout_ms })} />
<NumberField label={t("behavior.maxTokens")} value={value.intermediate_replies?.max_tokens ?? 60} disabled={!value.enabled} onChange={(max_tokens) => 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 (
<div className="grid gap-1.5">
<Label>{label}</Label>
<Input className="text-base md:text-sm" value={value} disabled={disabled} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled: boolean; onChange: (v: number) => void }) {
return (
<div className="grid gap-1.5">