<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root bind:open={dropdownOpen}>
- <Tooltip.Root>
+ <!-- ignoreNonKeyboardFocus prevents the tooltip from flashing when the
+ menu closes and focus returns to the trigger -->
+ <Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.Trigger
import * as Tooltip from '$lib/components/ui/tooltip';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
- let subOpen = $state(false);
-
const reasoning = useReasoningMenu();
</script>
{#if reasoning.modelSupportsThinking}
- <DropdownMenu.Sub bind:open={subOpen}>
+ <DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.thinkingEnabled}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
- {:else}
+ {:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
+ {:else}
+ <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span
Reasoning
<span class="capitalize text-muted-foreground">
- {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'}
+ {reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
- <button
- type="button"
- class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
- class:bg-accent={reasoning.isSelected(level)}
- onclick={() => {
- reasoning.select(level);
- subOpen = false;
- }}
+ <DropdownMenu.Item
+ class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
+ level
+ )
+ ? 'bg-accent'
+ : ''}"
+ onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
</Tooltip.Content>
</Tooltip.Root>
{/if}
- </button>
+ </DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{#if reasoning.thinkingEnabled}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
- {:else}
+ {:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
+ {:else}
+ <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span class="flex-1">Reasoning</span>
<span class="text-xs capitalize text-muted-foreground">
- {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'}
+ {reasoning.currentEffort}
</span>
</Collapsible.Trigger>
+++ /dev/null
-<script lang="ts">
- import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { ReasoningEffort, MessageRole } from '$lib/enums';
- import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
- import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
- import type { ReasoningEffortLevel } from '$lib/types';
- import { DIALOG_SUBMENU_CONTENT, ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
- import {
- modelsStore,
- checkModelSupportsThinking,
- supportsThinking,
- propsCacheVersion,
- loadedModelIds
- } from '$lib/stores/models.svelte';
- import { chatStore } from '$lib/stores/chat.svelte';
- import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
- import { isRouterMode } from '$lib/stores/server.svelte';
- import type { DatabaseMessage } from '$lib/types/database';
-
- let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
- let currentEffort = $derived(conversationsStore.getReasoningEffort());
- let isOff = $derived(!thinkingEnabled);
- let subOpen = $state(false);
-
- // Get conversation model from message history
- let conversationModel = $derived(
- chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
- );
-
- let modelSupportsThinkingFromMessages = $derived.by(() => {
- const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
- if (!modelId) return false;
-
- const messages = conversationsStore.activeMessages;
-
- return messages.some(
- (m: DatabaseMessage) =>
- m.role === MessageRole.ASSISTANT && m.model === modelId && !!m.reasoningContent
- );
- });
-
- let modelSupportsThinking = $derived.by(() => {
- loadedModelIds();
- propsCacheVersion();
-
- if (isRouterMode()) {
- const modelId = modelsStore.selectedModelName || conversationModel;
- return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
- }
-
- return supportsThinking() || modelSupportsThinkingFromMessages;
- });
-
- function isSelected(item: ReasoningEffortLevel): boolean {
- if (item.isOff) return isOff;
-
- return thinkingEnabled && currentEffort === item.value;
- }
-
- function handleSelection(item: ReasoningEffortLevel) {
- if (item.isOff) {
- conversationsStore.setThinkingEnabled(false);
- } else {
- conversationsStore.setThinkingEnabled(true);
- conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
- }
- subOpen = false;
- }
-</script>
-
-{#if modelSupportsThinking}
- <DropdownMenu.Sub bind:open={subOpen}>
- <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
- {#if thinkingEnabled}
- <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
- {:else}
- <LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
- {/if}
-
- <span class="flex-1">Thinking</span>
-
- {#if thinkingEnabled}
- <span class="text-xs text-muted-foreground">{currentEffort}</span>
- {:else}
- <span class="text-xs text-muted-foreground">off</span>
- {/if}
- </DropdownMenu.SubTrigger>
-
- <DropdownMenu.SubContent class={DIALOG_SUBMENU_CONTENT}>
- {#each REASONING_EFFORT_LEVELS as level (level.value)}
- <button
- type="button"
- class="flex w-full cursor-pointer items-center gap-2"
- class:bg-accent={isSelected(level)}
- onclick={() => handleSelection(level)}
- >
- <span class="flex-1 text-left">{level.label}</span>
-
- {#if !level.isOff}
- <span class="text-[11px] text-muted-foreground opacity-60">
- {REASONING_EFFORT_TOKENS[level.value] === -1
- ? 'Unlimited'
- : `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
- </span>
- {/if}
-
- {#if level.hasInfo}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
- </Tooltip.Trigger>
- <Tooltip.Content side="left">
- <p>Maximum thinking effort with extended context usage</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
-
- {#if isSelected(level)}
- <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
- {/if}
- </button>
- {/each}
- </DropdownMenu.SubContent>
- </DropdownMenu.Sub>
-{/if}
* Keys match the ReasoningEffort enum values for type-safe lookups.
*/
export const REASONING_EFFORT_LABELS: Record<string, string> = {
+ [ReasoningEffort.DEFAULT]: 'Default',
[ReasoningEffort.OFF]: 'Off',
[ReasoningEffort.LOW]: 'Low',
[ReasoningEffort.MEDIUM]: 'Medium',
};
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' },
* These values are sent to the server and mapped to token budgets.
*/
export enum ReasoningEffort {
+ DEFAULT = 'default',
OFF = 'off',
LOW = 'low',
MEDIUM = 'medium',
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
readonly thinkingEnabled: boolean;
+ readonly isOff: boolean;
readonly currentEffort: ReasoningEffort;
readonly levels: ReasoningEffortLevel[];
isSelected(level: ReasoningEffortLevel): boolean;
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() {
get thinkingEnabled() {
return thinkingEnabled;
},
+ get isOff() {
+ return currentEffort === ReasoningEffort.OFF;
+ },
get currentEffort() {
return currentEffort;
},
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);
}
};
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;
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);
/** 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<ReasoningEffort | ReasoningEffort.OFF>(
- ConversationsStore.loadReasoningEffortDefault()
- );
-
- /** Last non-off reasoning effort, restored when re-enabling thinking globally */
- private lastNonOffEffort: ReasoningEffort | null = null;
+ pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
- /** 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;
}
}
// 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];
}
/**
- * Gets the effective thinking-enabled state for the active conversation.
+ * 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.
*/
- getThinkingEnabled(): boolean {
+ getReasoningEffort(): ReasoningEffort {
if (this.activeConversation) {
- if (this.activeConversation.thinkingEnabled !== undefined) {
- return this.activeConversation.thinkingEnabled;
+ if (this.activeConversation.reasoningEffort !== undefined) {
+ return this.activeConversation.reasoningEffort;
}
- }
- 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<void> {
- 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();
+ // 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.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.
- */
- getReasoningEffort(): ReasoningEffort | ReasoningEffort.OFF {
- if (this.activeConversation) {
- return this.activeConversation.reasoningEffort ?? this.pendingReasoningEffort;
}
return this.pendingReasoningEffort;
}
/**
* 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<void> {
if (!this.activeConversation) {
export interface ReasoningEffortLevel {
value: string;
label: string;
- isOff?: boolean;
hasInfo?: boolean;
}