diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index ecda2764756f5..a80ec75151530 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -560,12 +560,10 @@ function ThreadSettingsSessionProvider( props.onPromptChange(change.prompt); } } - if (!("modelOptions" in change)) return; } - const next = - change && "modelOptions" in change - ? (change.modelOptions ?? []) - : applyProviderOptionSelection(displayedDescriptors, { id, value }); + const next = change + ? (change.modelOptions ?? []) + : applyProviderOptionSelection(displayedDescriptors, { id, value }); if (!next) { return; } diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts index 81baead620e0b..5cba64375a29b 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts @@ -24,40 +24,17 @@ const effortDescriptor: Extract = describe("selectableChoices", () => { // regression: #21406 bring model controls in line with web - it("includes prompt-injected and workflow choices in declared order", () => { + it("leaves out prompt-injected choices and keeps workflow choices in declared order", () => { expect(selectableChoices(effortDescriptor).map((choice) => choice.id)).toEqual([ "low", "medium", "high", - "ultrathink", "ultracode", ]); }); }); describe("prompt-controlled reasoning", () => { - it("adds Ultrathink to the prompt without persisting an injected option", () => { - const descriptors = [effortDescriptor]; - const change = getTraitSelectChange( - { ...getPromptControlledTraits(descriptors, "solve it"), descriptors }, - effortDescriptor, - "solve it", - "ultrathink", - ); - expect(change).toEqual({ prompt: "Ultrathink:\nsolve it" }); - expect( - getPromptControlledTraits(descriptors, change?.prompt ?? "").ultrathinkPromptControlled, - ).toBe(true); - expect( - getTraitSelectChange( - { ...getPromptControlledTraits(descriptors, ""), descriptors }, - effortDescriptor, - "", - "ultrathink", - ), - ).toEqual({ prompt: "Ultrathink:\n" }); - }); - it("removes the prefix when choosing a regular effort and saves Ultracode as an option", () => { const descriptors = [effortDescriptor]; const prompt = "Ultrathink:\nsolve it"; @@ -71,19 +48,11 @@ describe("prompt-controlled reasoning", () => { ).toEqual({ prompt: "solve it", modelOptions: [{ id: "effort", value: "ultracode" }] }); }); - it("keeps body-controlled Ultrathink locked and preserves slash commands", () => { + it("keeps body-controlled Ultrathink locked", () => { const descriptors = [effortDescriptor]; const prompt = "please ultrathink about this"; const selected = { ...getPromptControlledTraits(descriptors, prompt), descriptors }; expect(selected.ultrathinkInBodyText).toBe(true); expect(getTraitSelectChange(selected, effortDescriptor, prompt, "low")).toBeNull(); - expect( - getTraitSelectChange( - { ...getPromptControlledTraits(descriptors, "/review"), descriptors }, - effortDescriptor, - "/review", - "ultrathink", - ), - ).toEqual({ prompt: "/review" }); }); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0cbdc4e3c5bee..c423773fb6fc6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2835,15 +2835,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) direction, }); if (!change) return; - if (change.modelOptions) { - useComposerDraftStore - .getState() - .setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, { - instanceId: selectedInstanceId, - model: selectedModel, - persistSticky: true, - }); - } + useComposerDraftStore + .getState() + .setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, { + instanceId: selectedInstanceId, + model: selectedModel, + persistSticky: true, + }); if (change.prompt !== promptRef.current) { applyPromptReplacementStable(0, promptRef.current.length, change.prompt, { expandedCursorAfterReplace: change.cursor, diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 8c50c5662fc49..4639c0a3ec043 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -5,7 +5,6 @@ import { replaceDescriptorCurrentValue, selectableProviderOptionChoices, REASONING_SELECT_DESCRIPTOR_IDS, - ULTRATHINK_PROMPT_PREFIX, } from "@t3tools/client-runtime/model-traits"; import { type ModelSelection, @@ -258,7 +257,8 @@ export function getReasoningTraitStep(input: { const descriptor = selected.selectDescriptors.find((entry) => REASONING_SELECT_DESCRIPTOR_IDS.has(entry.id), ); - if (!descriptor || descriptor.options.length < 2) return null; + const choices = descriptor ? selectableProviderOptionChoices(descriptor) : []; + if (!descriptor || choices.length < 2) return null; const isPrimary = descriptor.id === selected.primarySelectDescriptor?.id; const current = isPrimary && selected.ultrathinkPromptControlled @@ -268,26 +268,17 @@ export function getReasoningTraitStep(input: { { instanceId: input.instanceId, model: input.model, options: input.modelOptions ?? [] }, input.reportedModelSelection, ); + // Walk the full catalog order so a typed Ultrathink keeps its place, then land on a pickable level. const ordered = input.direction > 0 ? descriptor.options : descriptor.options.toReversed(); const position = [current, getDescriptorStringValue(descriptor)] .map((value) => ordered.findIndex((option) => option.id === value)) .find((index) => index >= 0) ?? -1; - for (const option of [ - ...ordered.slice(position + 1), - ...ordered.slice(0, Math.max(position, 0)), - ]) { - const change = getTraitSelectChange(selected, descriptor, input.prompt, option.id); - if (!change) return null; - if ("modelOptions" in change) { - return change.modelOptions - ? { prompt: change.prompt, modelOptions: change.modelOptions } - : null; - } - // A slash command refuses the Ultrathink prefix, so skip that level. - if (change.prompt.startsWith(ULTRATHINK_PROMPT_PREFIX)) return change; - } - return null; + const next = [...ordered.slice(position + 1), ...ordered.slice(0, Math.max(position, 0))].find( + (option) => choices.includes(option), + ); + const change = next && getTraitSelectChange(selected, descriptor, input.prompt, next.id); + return change?.modelOptions ? { prompt: change.prompt, modelOptions: change.modelOptions } : null; } export interface TraitsMenuContentProps { @@ -368,7 +359,7 @@ export function useTraitsSelection({ onPromptEdit?.( (prompt) => getTraitSelectChange(selected, descriptor, prompt, value)?.prompt ?? prompt, ); - if ("modelOptions" in change) updateModelOptions(change.modelOptions); + updateModelOptions(change.modelOptions); }, changeBoolean: (descriptorId: string, value: boolean) => { updateModelOptions( diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 84c7c3d8cb869..41bf3f213a0a9 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -324,48 +324,31 @@ describe("getComposerReasoningStep", () => { ).toStrictEqual(selections(["thinking_level", "high"])); }); - it("adds and strips the Ultrathink prefix while the caret stays on the same character", () => { - for (const { prompt, modelOptions, direction, expected } of [ - { - prompt: " Fix the bug \n", - modelOptions: selections(["effort", "high"]), - direction: 1, - expected: { prompt: "Ultrathink:\nFix the bug", cursor: 20 }, - }, - { - prompt: "Ultrathink:\n Fix the bug", - modelOptions: selections(["effort", "medium"]), - direction: -1, - expected: { - prompt: "Fix the bug", - cursor: 8, - modelOptions: selections(["effort", "high"]), - }, - }, + it("never steps into Ultrathink and strips a typed prefix with the caret on the same character", () => { + expect(step({ ...CLAUDE, modelOptions: selections(["effort", "high"]) })).toStrictEqual({ + prompt: "Fix the bug", + cursor: 0, + modelOptions: selections(["effort", "medium"]), + }); + const prompt = "Ultrathink:\n Fix the bug"; + // A typed Ultrathink sits above the pickable levels, so up wraps and down lands on the top level. + for (const [direction, to] of [ + [-1, "high"], + [1, "medium"], ] as const) { const result = step({ ...CLAUDE, prompt, cursor: prompt.indexOf("bug"), - modelOptions, + modelOptions: selections(["effort", "medium"]), direction, }); - expect(result).toStrictEqual(expected); - expect(result?.prompt.slice(result.cursor)).toMatch(/^bug/); + expect(result, `by ${direction}`).toStrictEqual({ + prompt: "Fix the bug", + cursor: 8, + modelOptions: selections(["effort", to]), + }); } - expect( - step({ ...CLAUDE, prompt: " Fix", modelOptions: selections(["effort", "high"]) })?.cursor, - ).toBe("Ultrathink:\n".length); - }); - - it("skips Ultrathink for slash commands and does nothing when the prompt locks it", () => { - expect( - step({ ...CLAUDE, prompt: "/review", modelOptions: selections(["effort", "high"]) }), - ).toStrictEqual({ - prompt: "/review", - cursor: 0, - modelOptions: selections(["effort", "medium"]), - }); for (const [prompt, direction] of [ ["Please ultrathink harder", 1], ["Ultrathink:\nPlease ultrathink harder", -1], diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 0e52e08a68aa2..c3ec70d0d5f7d 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -152,7 +152,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com }; } -/** Expanded caret positions stay on the same character across Ultrathink prefix changes. */ +/** The caret stays on the same character when stepping strips a typed Ultrathink prefix. */ export function getComposerReasoningStep(input: { provider: ProviderDriverKind; instanceId: ProviderInstanceId; @@ -167,7 +167,7 @@ export function getComposerReasoningStep(input: { }): { prompt: string; cursor: number; - modelOptions?: ReadonlyArray; + modelOptions: ReadonlyArray; } | null { const { prompt, cursor } = input; const { selections } = resolveComposerOptionSelections( @@ -189,18 +189,10 @@ export function getComposerReasoningStep(input: { direction: input.direction, }); if (!step) return null; - const modelOptions = "modelOptions" in step ? { modelOptions: step.modelOptions } : {}; - if (step.prompt === prompt) return { prompt, cursor, ...modelOptions }; - // Adding the prefix also trims the prompt. - const keptStart = "modelOptions" in step ? 0 : step.prompt.length - prompt.trim().length; - const offset = - "modelOptions" in step - ? step.prompt.length - prompt.length - : keptStart - (prompt.length - prompt.trimStart().length); return { prompt: step.prompt, - cursor: Math.min(step.prompt.length, Math.max(keptStart, cursor + offset)), - ...modelOptions, + cursor: Math.max(0, cursor + step.prompt.length - prompt.length), + modelOptions: step.modelOptions, }; } diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 734a84c468aa0..23ada4ea11c46 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -4,7 +4,6 @@ import type { ProviderOptionDescriptor, } from "@t3tools/contracts"; import { - applyClaudePromptEffortPrefix, buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, getProviderOptionCurrentValue, @@ -12,8 +11,6 @@ import { } from "@t3tools/shared/model"; import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; -export const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; - export const REASONING_SELECT_DESCRIPTOR_IDS: ReadonlySet = new Set([ ...REASONING_EFFORT_OPTION_IDS, "thinking", @@ -51,10 +48,13 @@ export function getDescriptorStringValue( return typeof value === "string" ? value : null; } +// Prompt-injected levels such as Ultrathink still work when typed, but pickers do not offer them. export function selectableProviderOptionChoices( descriptor: Extract, ) { - return descriptor.options; + return descriptor.options.filter( + (option) => !descriptor.promptInjectedValues?.includes(option.id), + ); } export function getPromptControlledTraits( @@ -81,15 +81,7 @@ export function getTraitSelectChange( prompt: string, value: string, ) { - if (!value) return null; - if (descriptor.promptInjectedValues?.includes(value)) { - return { - prompt: - prompt.trim().length === 0 - ? ULTRATHINK_PROMPT_PREFIX - : applyClaudePromptEffortPrefix(prompt, "ultrathink"), - }; - } + if (!value || descriptor.promptInjectedValues?.includes(value)) return null; const isPrimary = descriptor.id === selected.primarySelectDescriptor?.id; if (isPrimary && selected.ultrathinkInBodyText) return null; return {