From 1d0a3227d7c48ba099f49c341dda39d73b583e99 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Thu, 15 Oct 2026 15:38:30 +0200 Subject: [PATCH] [ui] Fold the No reasoning chip into the effort picker (#12716) fix(ui): fold the "No reasoning" chip into the effort picker The chat composer had two controls for one idea: the effort picker with a "None" level, and a separate "No reasoning" chip. The chip also hid reasoning traces that a model sends despite reasoning_effort "none", which "None" did not. The chip disabled the picker while it was on. Choosing "None" now hides those traces and stays selectable, so the chip, its toasts and the /reasoning slash action are removed. A chat saved with the old reasoningOff flag loads with effort "none", so it keeps hiding reasoning. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/react-ui/e2e/chat-composer.spec.js | 4 +-- core/http/react-ui/e2e/chat-reasoning.spec.js | 33 +++++++++++-------- .../http/react-ui/public/locales/en/chat.json | 10 +----- .../src/components/chat/ReasoningChips.jsx | 25 ++++---------- .../src/components/chat/chatActions.js | 2 -- core/http/react-ui/src/hooks/useChat.js | 15 +++++---- core/http/react-ui/src/pages/Chat.jsx | 8 ----- 7 files changed, 37 insertions(+), 60 deletions(-) diff --git a/core/http/react-ui/e2e/chat-composer.spec.js b/core/http/react-ui/e2e/chat-composer.spec.js index 7e1b15238..fedd45a46 100644 --- a/core/http/react-ui/e2e/chat-composer.spec.js +++ b/core/http/react-ui/e2e/chat-composer.spec.js @@ -72,7 +72,7 @@ test.describe('Chat composer', () => { await expect(list).toBeVisible() await expect(list.locator('.dk-cmd-label')).toHaveText(['Chat', 'View']) const names = await list.locator('.dk-cmd-name').allTextContents() - expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/reasoning', '/effort', '/canvas', '/find', '/settings', '/export', '/clear']) + expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/effort', '/canvas', '/find', '/settings', '/export', '/clear']) await input(page).fill('/set') await expect(list.locator('.dk-cmd-name')).toHaveText(['/settings']) @@ -149,7 +149,7 @@ test.describe('Chat composer', () => { await openChat(page, [chatOf('c1', 'Empty', 'qwen3-8b', [])]) await input(page).fill('/') const names = await page.getByRole('listbox', { name: 'Actions' }).locator('.dk-cmd-name').allTextContents() - expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/reasoning', '/effort', '/canvas', '/settings']) + expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/effort', '/canvas', '/settings']) }) test('"/" from outside a text field starts a command', async ({ page }) => { diff --git a/core/http/react-ui/e2e/chat-reasoning.spec.js b/core/http/react-ui/e2e/chat-reasoning.spec.js index ddd7a6324..d184f499f 100644 --- a/core/http/react-ui/e2e/chat-reasoning.spec.js +++ b/core/http/react-ui/e2e/chat-reasoning.spec.js @@ -1,8 +1,9 @@ import { test, expect } from './coverage-fixtures.js' import { chatOf, pair, mockChat, openChat, storedChats } from './chat-fixtures.js' -// The composer has two reasoning controls, stored per chat: an effort select -// and a "No reasoning" toggle. Both are also slash actions. +// The composer has one reasoning control, stored per chat: the effort select. +// "None" asks for no reasoning and hides what the model still sends. It is +// also the /effort slash action. const sendWith = async (page, answer) => { let body = null @@ -40,12 +41,13 @@ test.describe('Chat reasoning controls', () => { expect(body.reasoning_effort).toBe('high') }) - test('the toggle asks for "none" and hides reasoning the model still sends', async ({ page }) => { + test('"None" asks for "none" and hides reasoning the model still sends', async ({ page }) => { await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))]) - const toggle = page.getByTestId('chat-reasoning-off') - await toggle.click() - await expect(toggle).toHaveAttribute('aria-pressed', 'true') - await expect(page.getByTestId('chat-effort-chip')).toBeDisabled() + const chip = page.getByTestId('chat-effort-chip') + await chip.click() + await page.getByTestId('chat-effort-none').click() + await expect(chip).toContainText('None') + await expect(chip).toBeEnabled() const body = await sendWith(page, stream({ reasoning: 'secret plan' }, { content: 'The answer.' })) expect(body.reasoning_effort).toBe('none') await expect(page.getByText('The answer.')).toBeVisible() @@ -54,14 +56,17 @@ test.describe('Chat reasoning controls', () => { expect(history.some(m => m.role === 'thinking')).toBe(false) }) - test('the slash actions toggle reasoning and open the effort select', async ({ page }) => { + test('a chat saved with the old reasoningOff flag still hides reasoning', async ({ page }) => { + const chat = { ...chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello')), reasoningOff: true } + await openChat(page, [chat]) + await expect(page.getByTestId('chat-effort-chip')).toContainText('None') + const body = await sendWith(page, stream({ reasoning: 'secret plan' }, { content: 'The answer.' })) + expect(body.reasoning_effort).toBe('none') + await expect(page.getByText('secret plan')).toHaveCount(0) + }) + + test('the /effort slash action opens the effort select', async ({ page }) => { await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))]) - await page.getByTestId('chat-input').fill('/reasoning') - await page.getByTestId('chat-input').press('Enter') - await expect(page.getByTestId('chat-reasoning-off')).toHaveAttribute('aria-pressed', 'true') - await page.getByTestId('chat-input').fill('/reasoning') - await page.getByTestId('chat-input').press('Enter') - await expect(page.getByTestId('chat-reasoning-off')).toHaveAttribute('aria-pressed', 'false') await page.getByTestId('chat-input').fill('/effort') await page.getByTestId('chat-input').press('Enter') await expect(page.getByTestId('chat-effort-menu')).toBeVisible() diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index 6dfbed355..b714380ad 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -142,11 +142,7 @@ "low": "Low", "medium": "Medium", "high": "High" - }, - "offLabel": "No reasoning", - "offTitle": "Turn reasoning off for this chat: ask the model for no reasoning and hide any reasoning it sends", - "offToast": "Reasoning is off for this chat", - "onToast": "Reasoning is on for this chat" + } }, "deleteAllDialog": { "title": "Delete All Chats", @@ -233,10 +229,6 @@ "label": "Reasoning effort", "desc": "Choose how hard the model thinks" }, - "reasoning": { - "label": "Toggle reasoning", - "desc": "Turn reasoning off or on for this chat" - }, "assistant": { "label": "Manage mode", "desc": "Let this chat manage LocalAI" diff --git a/core/http/react-ui/src/components/chat/ReasoningChips.jsx b/core/http/react-ui/src/components/chat/ReasoningChips.jsx index cd6855169..cff9c96e4 100644 --- a/core/http/react-ui/src/components/chat/ReasoningChips.jsx +++ b/core/http/react-ui/src/components/chat/ReasoningChips.jsx @@ -3,26 +3,25 @@ import { useTranslation } from 'react-i18next' import Icon from '../Icon' import { REASONING_EFFORTS } from './chatActions' -// The two reasoning controls in the Chat composer, both stored per chat: -// the effort chip opens a menu. "Model default" sends no reasoning_effort. -// the traces chip a toggle. On, the chat asks for reasoning_effort "none" and -// the thread drops any reasoning the model still sends. +// The reasoning effort chip in the Chat composer, stored per chat. It opens a +// menu. "Model default" sends no reasoning_effort. "None" sends "none" and the +// thread drops any reasoning the model still sends. // The ref gives the page open() for the /effort action. -const ReasoningChips = forwardRef(function ReasoningChips({ effort, off, onEffort, onOff }, ref) { +const ReasoningChips = forwardRef(function ReasoningChips({ effort, onEffort }, ref) { const { t } = useTranslation('chat') const [open, setOpen] = useState(false) const rootRef = useRef(null) const chipRef = useRef(null) const menuRef = useRef(null) const choices = ['', ...REASONING_EFFORTS] - const current = off ? 'none' : (effort || '') + const current = effort || '' const nameOf = (v) => (v ? t(`reasoning.level.${v}`) : t('reasoning.default')) const close = useCallback((restoreFocus = false) => { setOpen(false) if (restoreFocus) chipRef.current?.focus() }, []) - const openMenu = useCallback(() => { if (!off) setOpen(true) }, [off]) + const openMenu = useCallback(() => setOpen(true), []) useImperativeHandle(ref, () => ({ open: openMenu }), [openMenu]) useEffect(() => { @@ -59,7 +58,6 @@ const ReasoningChips = forwardRef(function ReasoningChips({ effort, off, onEffor className="home-chip" aria-haspopup="menu" aria-expanded={open} - disabled={off} title={t('reasoning.effortTitle')} onClick={() => (open ? close() : openMenu())} data-testid="chat-effort-chip" @@ -94,17 +92,6 @@ const ReasoningChips = forwardRef(function ReasoningChips({ effort, off, onEffor )} - ) }) diff --git a/core/http/react-ui/src/components/chat/chatActions.js b/core/http/react-ui/src/components/chat/chatActions.js index 8e399800e..efc1b57f5 100644 --- a/core/http/react-ui/src/components/chat/chatActions.js +++ b/core/http/react-ui/src/components/chat/chatActions.js @@ -12,8 +12,6 @@ export const CHAT_SLASH_ACTIONS = [ { id: 'new', cmd: '/new', icon: 'plus', group: 'chat' }, { id: 'chats', cmd: '/chats', icon: 'history', group: 'chat' }, { id: 'assistant', cmd: '/assistant', icon: 'user-shield', group: 'chat', needs: 'admin' }, - // /reasoning comes first so that Enter on "/reasoning" picks it, not "Reasoning effort". - { id: 'reasoning', cmd: '/reasoning', icon: 'eye-off', group: 'chat' }, { id: 'effort', cmd: '/effort', icon: 'brain', group: 'chat' }, { id: 'canvas', cmd: '/canvas', icon: 'columns', group: 'view' }, { id: 'find', cmd: '/find', icon: 'search', group: 'view', needs: 'thread' }, diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index 292a28025..842d8a373 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -104,6 +104,11 @@ function loadChats() { if (stored) { const data = JSON.parse(stored) if (data && Array.isArray(data.chats)) { + // Older chats stored a separate reasoningOff flag. It now means + // reasoning_effort 'none'. + data.chats = data.chats.map(({ reasoningOff, ...chat }) => ( + reasoningOff ? { ...chat, reasoningEffort: 'none' } : chat + )) return data } } @@ -129,7 +134,6 @@ function saveChats(chats, activeChatId) { topP: chat.topP, topK: chat.topK, reasoningEffort: chat.reasoningEffort, - reasoningOff: chat.reasoningOff, tokenUsage: chat.tokenUsage, contextSize: chat.contextSize, createdAt: chat.createdAt, @@ -163,10 +167,9 @@ function createNewChat(model = '', systemPrompt = '', mcpMode = false) { temperature: null, topP: null, topK: null, - // '' leaves reasoning_effort to the model config. reasoningOff asks for - // 'none' and hides reasoning traces in the thread. + // '' leaves reasoning_effort to the model config. 'none' also hides + // reasoning traces in the thread. reasoningEffort: '', - reasoningOff: false, tokenUsage: { prompt: 0, completion: 0, total: 0 }, contextSize: null, createdAt: Date.now(), @@ -288,7 +291,7 @@ export function useChat(initialModel = '') { const temperature = activeChat.temperature const topP = activeChat.topP const topK = activeChat.topK - const hideReasoning = !!activeChat.reasoningOff + const hideReasoning = activeChat.reasoningEffort === 'none' const showReasoning = (text) => { if (!hideReasoning) setStreamingReasoning(text) } // Build user message content @@ -383,7 +386,7 @@ export function useChat(initialModel = '') { if (topP !== null && topP !== undefined) requestBody.top_p = topP if (topK !== null && topK !== undefined) requestBody.top_k = topK // reasoning_effort is honoured per request. 'none' also turns thinking off. - const reasoningEffort = hideReasoning ? 'none' : activeChat.reasoningEffort + const reasoningEffort = activeChat.reasoningEffort if (reasoningEffort) requestBody.reasoning_effort = reasoningEffort // contextSize is the model's input+output window, not an // output cap. Backends bound generation at remaining context diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 98c90075a..ab420bc90 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -1002,11 +1002,6 @@ export default function Chat() { /> ) - const toggleReasoningOff = (reasoningOff) => { - updateChatSettings(activeChat.id, { reasoningOff }) - addToast(t(reasoningOff ? 'reasoning.offToast' : 'reasoning.onToast'), 'info', 2000) - } - const mcp = ( updateChatSettings(activeChat.id, { reasoningEffort })} - onOff={toggleReasoningOff} />