ui: add reasoning effort control to mobile add sheet (#25539)
The mobile "+" sheet was missing the reasoning effort section present in the desktop dropdown, so thinking could not be toggled on touch. Extract the shared derivation and selection logic into useReasoningMenu and consume it from both the desktop submenu and the mobile sheet, keeping a single source of truth and preserving each surface idiom.
This commit is contained in:
+20
-72
@@ -2,85 +2,31 @@
|
|||||||
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
|
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { ReasoningEffort } from '$lib/enums';
|
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||||
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';
|
|
||||||
|
|
||||||
let subOpen = $state(false);
|
let subOpen = $state(false);
|
||||||
|
|
||||||
let conversationModel = $derived(
|
const reasoning = useReasoningMenu();
|
||||||
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;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if modelSupportsThinking}
|
{#if reasoning.modelSupportsThinking}
|
||||||
<DropdownMenu.Sub bind:open={subOpen}>
|
<DropdownMenu.Sub bind:open={subOpen}>
|
||||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
<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" />
|
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
|
||||||
{:else}
|
{:else}
|
||||||
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
|
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
{/if}
|
{/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
|
Reasoning
|
||||||
|
|
||||||
<span class="capitalize text-muted-foreground">
|
<span class="capitalize text-muted-foreground">
|
||||||
{thinkingEnabled ? currentEffort : 'off'}
|
{reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
@@ -88,14 +34,18 @@
|
|||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
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
|
<button
|
||||||
type="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="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)}
|
class:bg-accent={reasoning.isSelected(level)}
|
||||||
onclick={() => handleSelection(level)}
|
onclick={() => {
|
||||||
|
reasoning.select(level);
|
||||||
|
subOpen = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if isSelected(level)}
|
{#if reasoning.isSelected(level)}
|
||||||
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-4 w-4 shrink-0"></div>
|
<div class="h-4 w-4 shrink-0"></div>
|
||||||
@@ -103,11 +53,9 @@
|
|||||||
|
|
||||||
<span class="flex-1">{level.label}</span>
|
<span class="flex-1">{level.label}</span>
|
||||||
|
|
||||||
{#if !level.isOff}
|
{#if tokenLabel}
|
||||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||||
{REASONING_EFFORT_TOKENS[level.value] === -1
|
{tokenLabel}
|
||||||
? 'Unlimited'
|
|
||||||
: `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+68
-1
@@ -10,10 +10,18 @@
|
|||||||
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
|
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
|
||||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||||
import { useToolsPanel } from '$lib/hooks/use-tools-panel.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 { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { McpLogo } from '$lib/components/app';
|
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 { HealthCheckStatus } from '$lib/enums';
|
||||||
import { AttachmentAction } from '$lib/enums/attachment.enums';
|
import { AttachmentAction } from '$lib/enums/attachment.enums';
|
||||||
|
|
||||||
@@ -48,6 +56,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let sheetOpen = $state(false);
|
let sheetOpen = $state(false);
|
||||||
|
let reasoningExpanded = $state(false);
|
||||||
let filesExpanded = $state(true);
|
let filesExpanded = $state(true);
|
||||||
let toolsExpanded = $state(false);
|
let toolsExpanded = $state(false);
|
||||||
let mcpExpanded = $state(false);
|
let mcpExpanded = $state(false);
|
||||||
@@ -67,6 +76,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toolsPanel = useToolsPanel();
|
const toolsPanel = useToolsPanel();
|
||||||
|
const reasoning = useReasoningMenu();
|
||||||
|
|
||||||
const sheetItemClass =
|
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';
|
'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';
|
||||||
@@ -91,6 +101,63 @@
|
|||||||
</Sheet.Header>
|
</Sheet.Header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
<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.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
|
||||||
<Collapsible.Trigger class={sheetItemClass}>
|
<Collapsible.Trigger class={sheetItemClass}>
|
||||||
{#if filesExpanded}
|
{#if filesExpanded}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user