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();