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();
</script>
-{#if modelSupportsThinking}
+{#if reasoning.modelSupportsThinking}
<DropdownMenu.Sub bind:open={subOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
- {#if thinkingEnabled}
+ {#if reasoning.thinkingEnabled}
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
{:else}
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
{/if}
- <span class="text-sm inline-flex gap-2 {!thinkingEnabled ? 'text-muted-foreground' : ''}">
+ <span
+ class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
+ ? 'text-muted-foreground'
+ : ''}"
+ >
Reasoning
<span class="capitalize text-muted-foreground">
- {thinkingEnabled ? currentEffort : 'off'}
+ {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'}
</span>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
>
- {#each REASONING_EFFORT_LEVELS as level (level.value)}
+ {#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={isSelected(level)}
- onclick={() => handleSelection(level)}
+ class:bg-accent={reasoning.isSelected(level)}
+ onclick={() => {
+ reasoning.select(level);
+ subOpen = false;
+ }}
>
- {#if isSelected(level)}
+ {#if reasoning.isSelected(level)}
<Check class="h-4 w-4 shrink-0 text-foreground" />
{:else}
<div class="h-4 w-4 shrink-0"></div>
<span class="flex-1">{level.label}</span>
- {#if !level.isOff}
+ {#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
- {REASONING_EFFORT_TOKENS[level.value] === -1
- ? 'Unlimited'
- : `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
+ {tokenLabel}
</span>
{/if}
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
+ import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { McpLogo } from '$lib/components/app';
- import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
+ import {
+ PencilRuler,
+ ChevronDown,
+ ChevronRight,
+ Lightbulb,
+ LightbulbOff,
+ Check
+ } from '@lucide/svelte';
import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums';
}: Props = $props();
let sheetOpen = $state(false);
+ let reasoningExpanded = $state(false);
let filesExpanded = $state(true);
let toolsExpanded = $state(false);
let mcpExpanded = $state(false);
);
const toolsPanel = useToolsPanel();
+ const reasoning = useReasoningMenu();
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
</Sheet.Header>
<div class="flex flex-col gap-1 px-1.5 pb-2">
+ {#if reasoning.modelSupportsThinking}
+ <Collapsible.Root
+ open={reasoningExpanded}
+ onOpenChange={(open) => (reasoningExpanded = open)}
+ >
+ <Collapsible.Trigger class={sheetItemClass}>
+ {#if reasoningExpanded}
+ <ChevronDown class="h-4 w-4 shrink-0" />
+ {:else}
+ <ChevronRight class="h-4 w-4 shrink-0" />
+ {/if}
+
+ {#if reasoning.thinkingEnabled}
+ <Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
+ {:else}
+ <LightbulbOff class="h-4 w-4 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'}
+ </span>
+ </Collapsible.Trigger>
+
+ <Collapsible.Content>
+ <div class="flex flex-col gap-0.5 pl-4">
+ {#each reasoning.levels as level (level.value)}
+ {@const tokenLabel = reasoning.tokenLabel(level)}
+ <button
+ type="button"
+ class={sheetItemRowClass}
+ class:bg-accent={reasoning.isSelected(level)}
+ onclick={() => reasoning.select(level)}
+ >
+ <div class="flex min-w-0 items-center gap-3">
+ {#if reasoning.isSelected(level)}
+ <Check class="h-4 w-4 shrink-0 text-foreground" />
+ {:else}
+ <div class="h-4 w-4 shrink-0"></div>
+ {/if}
+
+ <span class="text-sm">{level.label}</span>
+ </div>
+
+ {#if tokenLabel}
+ <span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
+ {tokenLabel}
+ </span>
+ {/if}
+ </button>
+ {/each}
+ </div>
+ </Collapsible.Content>
+ </Collapsible.Root>
+ {/if}
+
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if filesExpanded}
--- /dev/null
+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);
+ }
+ };
+}