From 33e6aa329b824d4629dd049e7831e3620445ffef Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:32:07 +0800
Subject: [PATCH] feat(webui): link model picker to settings
---
.../components/thread/ModelPresetBadge.tsx | 25 ++++++++++++++++++-
.../src/components/thread/ThreadComposer.tsx | 3 +++
webui/src/components/thread/ThreadShell.tsx | 2 ++
webui/src/i18n/locales/en/common.json | 1 +
webui/src/i18n/locales/es/common.json | 1 +
webui/src/i18n/locales/fr/common.json | 1 +
webui/src/i18n/locales/id/common.json | 1 +
webui/src/i18n/locales/ja/common.json | 1 +
webui/src/i18n/locales/ko/common.json | 1 +
webui/src/i18n/locales/pt-BR/common.json | 1 +
webui/src/i18n/locales/vi/common.json | 1 +
webui/src/i18n/locales/zh-CN/common.json | 1 +
webui/src/i18n/locales/zh-TW/common.json | 1 +
webui/src/tests/thread-composer.test.tsx | 18 ++++++++++++-
14 files changed, 56 insertions(+), 2 deletions(-)
diff --git a/webui/src/components/thread/ModelPresetBadge.tsx b/webui/src/components/thread/ModelPresetBadge.tsx
index 5476736c0..6bebf6579 100644
--- a/webui/src/components/thread/ModelPresetBadge.tsx
+++ b/webui/src/components/thread/ModelPresetBadge.tsx
@@ -6,7 +6,7 @@ import {
type KeyboardEvent,
type PointerEvent,
} from "react";
-import { Check, CircleHelp, Sparkles } from "lucide-react";
+import { Check, CircleHelp, SlidersHorizontal, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@@ -85,6 +85,7 @@ interface ModelPresetBadgeProps {
modelPreset?: string | null;
modelPresets?: ModelPresetOption[];
onPresetChange?: (name: string) => void;
+ onManageModels?: () => void;
onRequestComposerFocus?: () => void;
provider?: string | null;
providerLabel?: string | null;
@@ -100,6 +101,7 @@ export function ModelPresetBadge({
modelPreset,
modelPresets = [],
onPresetChange,
+ onManageModels,
onRequestComposerFocus,
provider,
providerLabel,
@@ -156,6 +158,11 @@ export function ModelPresetBadge({
requestAnimationFrame(() => onRequestComposerFocus?.());
};
+ const openModelSettings = () => {
+ setOpen(false);
+ onManageModels?.();
+ };
+
const clearGesture = () => {
const gesture = gestureRef.current;
if (gesture?.timer) clearTimeout(gesture.timer);
@@ -418,6 +425,22 @@ export function ModelPresetBadge({
/>
))}
+ {onManageModels ? (
+
+
+
+ ) : null}
);
diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx
index f420a161a..3bd2b9ebd 100644
--- a/webui/src/components/thread/ThreadComposer.tsx
+++ b/webui/src/components/thread/ThreadComposer.tsx
@@ -300,6 +300,7 @@ interface ThreadComposerProps {
modelNeedsSetup?: boolean;
fallbackModelName?: string | null;
onModelBadgeClick?: () => void;
+ onManageModels?: () => void;
contextUsage?: ComposerContextUsage | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
@@ -998,6 +999,7 @@ export function ThreadComposer({
modelNeedsSetup = false,
fallbackModelName = null,
onModelBadgeClick,
+ onManageModels,
contextUsage = null,
variant = "thread",
slashCommands = [],
@@ -2539,6 +2541,7 @@ export function ThreadComposer({
modelPreset={modelPreset}
modelPresets={modelPresets}
onPresetChange={onModelPresetChange}
+ onManageModels={onManageModels}
onRequestComposerFocus={() => textareaRef.current?.focus()}
provider={modelProvider}
providerLabel={modelProviderLabel}
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
index f2abe9e78..f07781bff 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -1519,6 +1519,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
+ onManageModels={onOpenModelSettings}
contextUsage={composerContextUsage}
variant={composerVariant}
slashCommands={availableSlashCommands}
@@ -1567,6 +1568,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
+ onManageModels={onOpenModelSettings}
contextUsage={composerContextUsage}
variant="hero"
slashCommands={availableSlashCommands}
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 524adb68b..0e2e7454c 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -1196,6 +1196,7 @@
"modelNotConfigured": "Model not configured",
"configureModel": "Configure model",
"switchModel": "Switch model for this chat",
+ "manageModels": "Manage models",
"context": {
"tooltip": "Context · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% used."
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 53c617bf1..ae1732955 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -1183,6 +1183,7 @@
"modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo",
"switchModel": "Cambiar el modelo de este chat",
+ "manageModels": "Gestionar modelos",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % usado."
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 245b87b5f..2893055d1 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle",
"switchModel": "Changer le modèle de cette conversation",
+ "manageModels": "Gérer les modèles",
"context": {
"tooltip": "Contexte · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % utilisé."
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 2c06bb75a..b2597c302 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model",
"switchModel": "Ganti model untuk percakapan ini",
+ "manageModels": "Kelola model",
"context": {
"tooltip": "Konteks · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% digunakan."
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index bc041a1d2..044d164a0 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定",
"switchModel": "この会話で使うモデルを切り替える",
+ "manageModels": "モデルを管理",
"context": {
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。{{percent}}% 使用中"
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index e33bdd925..8bf2dc2a6 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정",
"switchModel": "이 대화에서 사용할 모델 전환",
+ "manageModels": "모델 관리",
"context": {
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% 사용 중."
diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json
index 5525df084..1a55adfcc 100644
--- a/webui/src/i18n/locales/pt-BR/common.json
+++ b/webui/src/i18n/locales/pt-BR/common.json
@@ -1196,6 +1196,7 @@
"modelNotConfigured": "Modelo não configurado",
"configureModel": "Configurar modelo",
"switchModel": "Alternar o modelo desta conversa",
+ "manageModels": "Gerenciar modelos",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% usado."
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index f2a01bdda..d4794bfbb 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "Cấu hình mô hình",
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
+ "manageModels": "Quản lý mô hình",
"context": {
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. Đã dùng {{percent}}%."
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index a54857273..6359b5d79 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -1195,6 +1195,7 @@
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"switchModel": "切换本次对话所用模型",
+ "manageModels": "管理模型预设",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 45fb1f39d..e28d4a9b7 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -1182,6 +1182,7 @@
"modelNotConfigured": "尚未設定模型",
"configureModel": "設定模型",
"switchModel": "切換此對話使用的模型",
+ "manageModels": "管理模型預設",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index e8c26d843..f0a40d20b 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -322,7 +322,10 @@ const MODEL_PRESETS = [
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
];
-function renderPresetComposer(variant: "thread" | "hero" = "thread") {
+function renderPresetComposer(
+ variant: "thread" | "hero" = "thread",
+ onManageModels?: () => void,
+) {
const onPresetChange = vi.fn();
render(
,
@@ -610,6 +614,18 @@ describe("ThreadComposer", () => {
expect(badge).toHaveClass("w-fit");
});
+ it("opens model settings from the picker footer", async () => {
+ const onManageModels = vi.fn();
+ const { badge } = renderPresetComposer("thread", onManageModels);
+
+ fireEvent.click(badge);
+ const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
+ fireEvent.click(within(picker).getByRole("button", { name: "Manage models" }));
+
+ expect(onManageModels).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ });
+
it("keeps long-press drag switching alongside the click picker", () => {
vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer();