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 ? (
-
@@ -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 (
-
+
);
}
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 (