diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte index 070fd3ac66..caea7a0227 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte @@ -2,85 +2,31 @@ import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Tooltip from '$lib/components/ui/tooltip'; - import { ReasoningEffort } 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 { - 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'; + import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte'; let subOpen = $state(false); - 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) => m.role === '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; - }); - - let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled()); - let currentEffort = $derived(conversationsStore.getReasoningEffort()); - let isOff = $derived(!thinkingEnabled); - - 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; - } + const reasoning = useReasoningMenu(); -{#if modelSupportsThinking} +{#if reasoning.modelSupportsThinking} - {#if thinkingEnabled} + {#if reasoning.thinkingEnabled} {:else} {/if} - + Reasoning - {thinkingEnabled ? currentEffort : 'off'} + {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'} @@ -88,14 +34,18 @@ - {#each REASONING_EFFORT_LEVELS as level (level.value)} + {#each reasoning.levels as level (level.value)} + {@const tokenLabel = reasoning.tokenLabel(level)} + {/each} + + + + {/if} + (filesExpanded = open)}> {#if filesExpanded} diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts new file mode 100644 index 0000000000..6a459e66bd --- /dev/null +++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts @@ -0,0 +1,96 @@ +import { ReasoningEffort } from '$lib/enums'; +import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort'; +import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens'; +import type { ReasoningEffortLevel } from '$lib/types'; +import type { DatabaseMessage } from '$lib/types/database'; +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'; + +export interface UseReasoningMenuReturn { + readonly modelSupportsThinking: boolean; + readonly thinkingEnabled: boolean; + readonly currentEffort: ReasoningEffort; + readonly levels: ReasoningEffortLevel[]; + isSelected(level: ReasoningEffortLevel): boolean; + tokenLabel(level: ReasoningEffortLevel): string | null; + select(level: ReasoningEffortLevel): void; +} + +/** + * Shared reactive state and helpers for the reasoning effort menu. + * + * Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`) + * and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the + * thinking-support derivation and the effort selection logic. + */ +export function useReasoningMenu(): UseReasoningMenuReturn { + const conversationModel = $derived( + chatStore.getConversationModel(activeMessages() as DatabaseMessage[]) + ); + + // a router chat can carry reasoning from an earlier turn before the props + // cache is primed, so a model that already produced thinking still qualifies + const modelSupportsThinkingFromMessages = $derived.by(() => { + const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null; + if (!modelId) return false; + + return conversationsStore.activeMessages.some( + (m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent + ); + }); + + const modelSupportsThinking = $derived.by(() => { + loadedModelIds(); + propsCacheVersion(); + + if (isRouterMode()) { + const modelId = modelsStore.selectedModelName || conversationModel; + return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages; + } + + return supportsThinking() || modelSupportsThinkingFromMessages; + }); + + const thinkingEnabled = $derived(conversationsStore.getThinkingEnabled()); + const currentEffort = $derived(conversationsStore.getReasoningEffort()); + + return { + get modelSupportsThinking() { + return modelSupportsThinking; + }, + get thinkingEnabled() { + return thinkingEnabled; + }, + get currentEffort() { + return currentEffort; + }, + get levels() { + return REASONING_EFFORT_LEVELS; + }, + isSelected(level: ReasoningEffortLevel): boolean { + if (level.isOff) return !thinkingEnabled; + return thinkingEnabled && currentEffort === level.value; + }, + tokenLabel(level: ReasoningEffortLevel): string | null { + if (level.isOff) return null; + const tokens = REASONING_EFFORT_TOKENS[level.value]; + 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); + } + }; +}