From cf512566dc89d7a2bdefd43f6d4b85a1c18e93a7 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 22 Jul 2026 23:09:49 +0200 Subject: [PATCH] ui: Add a "Default" option for the reasoning selector (#25846) * ui: add Default reasoning option that defers to the server The webui always injected enable_thinking, overriding the chat template default and the --reasoning flag, breaking models that reason unconditionally (e.g. Gemma 4 E4B) on a fresh client. Default sends nothing so the server decides, Off and effort levels force the value as before. All choices are remembered. Also remove the boolean thinking API from the conversations store and drop ChatFormReasoningEffortSubmenu.svelte (dead code). * ui: close the whole menu tree on reasoning level selection The reasoning levels were raw buttons inside the SubContent, so selecting one only closed the submenu via manual state while the root dropdown stayed open. DropdownMenu.Item closes the full tree on select like the sibling entries and brings native keyboard navigation. * ui: prevent the add menu tooltip from flashing when the dropdown closes --- .../ChatFormActionAddDropdown.svelte | 4 +- .../ChatFormActionAddReasoningSubmenu.svelte | 27 ++-- .../ChatFormActionAddSheet.svelte | 6 +- .../ChatFormReasoningEffortSubmenu.svelte | 127 ------------------ .../ui/src/lib/constants/reasoning-effort.ts | 4 +- .../src/lib/enums/reasoning-effort.enums.ts | 1 + .../lib/hooks/use-reasoning-menu.svelte.ts | 19 +-- tools/ui/src/lib/services/chat.service.ts | 12 +- tools/ui/src/lib/stores/chat.svelte.ts | 7 +- .../ui/src/lib/stores/conversations.svelte.ts | 97 +++---------- tools/ui/src/lib/types/reasoning.ts | 1 - 11 files changed, 67 insertions(+), 238 deletions(-) delete mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte index 699605fd82..905c2fe6f0 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte @@ -71,7 +71,9 @@
- + + {#snippet child({ props })} {#if reasoning.modelSupportsThinking} - + {#if reasoning.thinkingEnabled} - {:else} + {:else if reasoning.isOff} + {:else} + {/if} - {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'} + {reasoning.currentEffort} @@ -37,14 +37,13 @@ > {#each reasoning.levels as level (level.value)} {@const tokenLabel = reasoning.tokenLabel(level)} - + {/each} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte index c533acc750..021e0e4539 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte @@ -116,14 +116,16 @@ {#if reasoning.thinkingEnabled} - {:else} + {:else if reasoning.isOff} + {:else} + {/if} Reasoning - {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'} + {reasoning.currentEffort} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte deleted file mode 100644 index 0a4277e94f..0000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -{#if modelSupportsThinking} - - - {#if thinkingEnabled} - - {:else} - - {/if} - - Thinking - - {#if thinkingEnabled} - {currentEffort} - {:else} - off - {/if} - - - - {#each REASONING_EFFORT_LEVELS as level (level.value)} - - {/each} - - -{/if} diff --git a/tools/ui/src/lib/constants/reasoning-effort.ts b/tools/ui/src/lib/constants/reasoning-effort.ts index 28a24420e6..f21ea588ad 100644 --- a/tools/ui/src/lib/constants/reasoning-effort.ts +++ b/tools/ui/src/lib/constants/reasoning-effort.ts @@ -6,6 +6,7 @@ import type { ReasoningEffortLevel } from '$lib/types'; * Keys match the ReasoningEffort enum values for type-safe lookups. */ export const REASONING_EFFORT_LABELS: Record = { + [ReasoningEffort.DEFAULT]: 'Default', [ReasoningEffort.OFF]: 'Off', [ReasoningEffort.LOW]: 'Low', [ReasoningEffort.MEDIUM]: 'Medium', @@ -14,7 +15,8 @@ export const REASONING_EFFORT_LABELS: Record = { }; export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [ - { value: ReasoningEffort.OFF, label: 'Off', isOff: true }, + { value: ReasoningEffort.DEFAULT, label: 'Default' }, + { value: ReasoningEffort.OFF, label: 'Off' }, { value: ReasoningEffort.LOW, label: 'Low' }, { value: ReasoningEffort.MEDIUM, label: 'Medium' }, { value: ReasoningEffort.HIGH, label: 'High' }, diff --git a/tools/ui/src/lib/enums/reasoning-effort.enums.ts b/tools/ui/src/lib/enums/reasoning-effort.enums.ts index 172f118e7f..6bf86ed4ec 100644 --- a/tools/ui/src/lib/enums/reasoning-effort.enums.ts +++ b/tools/ui/src/lib/enums/reasoning-effort.enums.ts @@ -3,6 +3,7 @@ * These values are sent to the server and mapped to token budgets. */ export enum ReasoningEffort { + DEFAULT = 'default', OFF = 'off', LOW = 'low', MEDIUM = 'medium', diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts index 6a459e66bd..ce9b77884d 100644 --- a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts +++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts @@ -17,6 +17,7 @@ import { isRouterMode } from '$lib/stores/server.svelte'; export interface UseReasoningMenuReturn { readonly modelSupportsThinking: boolean; readonly thinkingEnabled: boolean; + readonly isOff: boolean; readonly currentEffort: ReasoningEffort; readonly levels: ReasoningEffortLevel[]; isSelected(level: ReasoningEffortLevel): boolean; @@ -59,8 +60,10 @@ export function useReasoningMenu(): UseReasoningMenuReturn { return supportsThinking() || modelSupportsThinkingFromMessages; }); - const thinkingEnabled = $derived(conversationsStore.getThinkingEnabled()); const currentEffort = $derived(conversationsStore.getReasoningEffort()); + const thinkingEnabled = $derived( + currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT + ); return { get modelSupportsThinking() { @@ -69,6 +72,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn { get thinkingEnabled() { return thinkingEnabled; }, + get isOff() { + return currentEffort === ReasoningEffort.OFF; + }, get currentEffort() { return currentEffort; }, @@ -76,20 +82,15 @@ export function useReasoningMenu(): UseReasoningMenuReturn { return REASONING_EFFORT_LEVELS; }, isSelected(level: ReasoningEffortLevel): boolean { - if (level.isOff) return !thinkingEnabled; - return thinkingEnabled && currentEffort === level.value; + return currentEffort === level.value; }, tokenLabel(level: ReasoningEffortLevel): string | null { - if (level.isOff) return null; + if (level.value === ReasoningEffort.DEFAULT) return 'Model default'; const tokens = REASONING_EFFORT_TOKENS[level.value]; + if (tokens === undefined) return null; return tokens === -1 ? 'Unlimited' : `Max ${tokens.toLocaleString()} tokens`; }, select(level: ReasoningEffortLevel): void { - if (level.isOff) { - conversationsStore.setThinkingEnabled(false); - return; - } - conversationsStore.setThinkingEnabled(true); conversationsStore.setReasoningEffort(level.value as ReasoningEffort); } }; diff --git a/tools/ui/src/lib/services/chat.service.ts b/tools/ui/src/lib/services/chat.service.ts index 15f4cc3b5f..d2455614fb 100644 --- a/tools/ui/src/lib/services/chat.service.ts +++ b/tools/ui/src/lib/services/chat.service.ts @@ -271,10 +271,14 @@ export class ChatService { const reasoningBudgetTokens = enableThinking && reasoningEffort ? (REASONING_EFFORT_TOKENS[reasoningEffort] ?? -1) : -1; - requestBody.chat_template_kwargs = { - ...(requestBody.chat_template_kwargs ?? {}), - enable_thinking: enableThinking - }; + // an explicit user choice injects the kwarg, otherwise it is omitted so + // the server default applies (--reasoning flag or chat template) + if (enableThinking !== undefined) { + requestBody.chat_template_kwargs = { + ...(requestBody.chat_template_kwargs ?? {}), + enable_thinking: enableThinking + }; + } if (reasoningBudgetTokens >= 0) { requestBody.thinking_budget_tokens = reasoningBudgetTokens; diff --git a/tools/ui/src/lib/stores/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts index ab418ed9cd..801d6c838f 100644 --- a/tools/ui/src/lib/stores/chat.svelte.ts +++ b/tools/ui/src/lib/stores/chat.svelte.ts @@ -2373,9 +2373,12 @@ class ChatStore { if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true; - apiOptions.enableThinking = conversationsStore.getThinkingEnabled(); + // an explicit reasoning choice overrides the server default, DEFAULT sends nothing const effort = conversationsStore.getReasoningEffort(); - if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort; + if (effort !== ReasoningEffort.DEFAULT) { + apiOptions.enableThinking = effort !== ReasoningEffort.OFF; + if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort; + } if (hasValue(currentConfig.temperature)) apiOptions.temperature = Number(currentConfig.temperature); diff --git a/tools/ui/src/lib/stores/conversations.svelte.ts b/tools/ui/src/lib/stores/conversations.svelte.ts index 5c54e72ace..9649cfe03d 100644 --- a/tools/ui/src/lib/stores/conversations.svelte.ts +++ b/tools/ui/src/lib/stores/conversations.svelte.ts @@ -80,25 +80,17 @@ class ConversationsStore { /** Whether the store has been initialized */ isInitialized = $state(false); - /** Global (non-conversation-specific) thinking toggle default, derived from reasoning effort */ - pendingThinkingEnabled = $state(false); - /** Global (non-conversation-specific) reasoning effort default */ - pendingReasoningEffort = $state( - ConversationsStore.loadReasoningEffortDefault() - ); + pendingReasoningEffort = $state(ConversationsStore.loadReasoningEffortDefault()); - /** Last non-off reasoning effort, restored when re-enabling thinking globally */ - private lastNonOffEffort: ReasoningEffort | null = null; - - /** Load reasoning effort default from localStorage */ - private static loadReasoningEffortDefault(): ReasoningEffort | ReasoningEffort.OFF { - if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.OFF; + /** Load reasoning effort default from localStorage, DEFAULT defers to the server */ + private static loadReasoningEffortDefault(): ReasoningEffort { + if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT; try { const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY); - return (raw as ReasoningEffort | ReasoningEffort.OFF) || ReasoningEffort.OFF; + return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT; } catch { - return ReasoningEffort.OFF; + return ReasoningEffort.DEFAULT; } } @@ -235,17 +227,10 @@ class ConversationsStore { // servers without a per-conversation override to `mcpServers[i].enabled`, // and only explicit toggles are stored on the conversation. - // Inherit global thinking/reasoning defaults into the new conversation - const thinkingEnabled = this.getThinkingEnabled(); - conversation.thinkingEnabled = thinkingEnabled; - conversation.reasoningEffort = - this.pendingReasoningEffort === ReasoningEffort.OFF ? undefined : this.pendingReasoningEffort; + // Inherit the global reasoning default into the new conversation + conversation.reasoningEffort = this.pendingReasoningEffort; await DatabaseService.updateConversation(conversation.id, { - thinkingEnabled, - reasoningEffort: - this.pendingReasoningEffort === ReasoningEffort.OFF - ? undefined - : this.pendingReasoningEffort + reasoningEffort: this.pendingReasoningEffort }); this.conversations = [conversation, ...this.conversations]; @@ -793,63 +778,21 @@ class ConversationsStore { await this.setMcpServerOverride(serverId, undefined); } - /** - * Gets the effective thinking-enabled state for the active conversation. - * Returns the conversation override if set, otherwise the global default. - */ - getThinkingEnabled(): boolean { - if (this.activeConversation) { - if (this.activeConversation.thinkingEnabled !== undefined) { - return this.activeConversation.thinkingEnabled; - } - } - return this.getReasoningEffort() !== ReasoningEffort.OFF; - } - - /** - * Sets the thinking-enabled state for the active conversation. - * If no conversation exists, stores the global default. - * @param enabled - The enabled state - */ - async setThinkingEnabled(enabled: boolean): Promise { - if (!this.activeConversation) { - if (enabled) { - const effort = this.lastNonOffEffort ?? ReasoningEffort.LOW; - this.pendingReasoningEffort = effort; - this.saveReasoningEffortDefaults(); - } else { - if (this.pendingReasoningEffort !== ReasoningEffort.OFF) { - this.lastNonOffEffort = this.pendingReasoningEffort; - } - this.pendingReasoningEffort = ReasoningEffort.OFF; - this.saveReasoningEffortDefaults(); - } - return; - } - - this.activeConversation = { - ...this.activeConversation, - thinkingEnabled: enabled - }; - - await DatabaseService.updateConversation(this.activeConversation.id, { - thinkingEnabled: enabled - }); - - const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); - if (convIndex !== -1) { - this.conversations[convIndex].thinkingEnabled = enabled; - this.conversations = [...this.conversations]; - } - } - /** * Gets the effective reasoning effort for the active conversation. * Returns the conversation override if set, otherwise the global default. + * DEFAULT means no override is sent and the server decides. */ - getReasoningEffort(): ReasoningEffort | ReasoningEffort.OFF { + getReasoningEffort(): ReasoningEffort { if (this.activeConversation) { - return this.activeConversation.reasoningEffort ?? this.pendingReasoningEffort; + if (this.activeConversation.reasoningEffort !== undefined) { + return this.activeConversation.reasoningEffort; + } + // conversations created before the tri-state store an explicit + // opt-out only as thinkingEnabled = false + if (this.activeConversation.thinkingEnabled === false) { + return ReasoningEffort.OFF; + } } return this.pendingReasoningEffort; } @@ -857,7 +800,7 @@ class ConversationsStore { /** * Sets the reasoning effort for the active conversation. * If no conversation exists, stores the global default. - * @param effort - The effort level ('low' | 'medium' | 'high' | 'max') + * @param effort - The effort level ('default' | 'off' | 'low' | 'medium' | 'high' | 'max') */ async setReasoningEffort(effort: ReasoningEffort): Promise { if (!this.activeConversation) { diff --git a/tools/ui/src/lib/types/reasoning.ts b/tools/ui/src/lib/types/reasoning.ts index b72a3b77f1..417c79609f 100644 --- a/tools/ui/src/lib/types/reasoning.ts +++ b/tools/ui/src/lib/types/reasoning.ts @@ -1,6 +1,5 @@ export interface ReasoningEffortLevel { value: string; label: string; - isOff?: boolean; hasInfo?: boolean; }