Skip to content

Commit b16abec

Browse files
t3dotggclaude
andcommitted
fix(web): hide Ultrathink from the Claude effort picker
Typing ultrathink in the prompt still works. The picker, the mobile settings sheet, and keyboard effort stepping no longer offer it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 4146e51 commit b16abec

7 files changed

Lines changed: 47 additions & 124 deletions

File tree

‎apps/mobile/src/features/threads/ThreadSettingsSheet.tsx‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -560,12 +560,10 @@ function ThreadSettingsSessionProvider(
560560
props.onPromptChange(change.prompt);
561561
}
562562
}
563-
if (!("modelOptions" in change)) return;
564563
}
565-
const next =
566-
change && "modelOptions" in change
567-
? (change.modelOptions ?? [])
568-
: applyProviderOptionSelection(displayedDescriptors, { id, value });
564+
const next = change
565+
? (change.modelOptions ?? [])
566+
: applyProviderOptionSelection(displayedDescriptors, { id, value });
569567
if (!next) {
570568
return;
571569
}

‎apps/mobile/src/features/threads/thread-settings-options.test.ts‎

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,40 +24,17 @@ const effortDescriptor: Extract<ProviderOptionDescriptor, { type: "select" }> =
2424

2525
describe("selectableChoices", () => {
2626
// regression: #21406 bring model controls in line with web
27-
it("includes prompt-injected and workflow choices in declared order", () => {
27+
it("leaves out prompt-injected choices and keeps workflow choices in declared order", () => {
2828
expect(selectableChoices(effortDescriptor).map((choice) => choice.id)).toEqual([
2929
"low",
3030
"medium",
3131
"high",
32-
"ultrathink",
3332
"ultracode",
3433
]);
3534
});
3635
});
3736

3837
describe("prompt-controlled reasoning", () => {
39-
it("adds Ultrathink to the prompt without persisting an injected option", () => {
40-
const descriptors = [effortDescriptor];
41-
const change = getTraitSelectChange(
42-
{ ...getPromptControlledTraits(descriptors, "solve it"), descriptors },
43-
effortDescriptor,
44-
"solve it",
45-
"ultrathink",
46-
);
47-
expect(change).toEqual({ prompt: "Ultrathink:\nsolve it" });
48-
expect(
49-
getPromptControlledTraits(descriptors, change?.prompt ?? "").ultrathinkPromptControlled,
50-
).toBe(true);
51-
expect(
52-
getTraitSelectChange(
53-
{ ...getPromptControlledTraits(descriptors, ""), descriptors },
54-
effortDescriptor,
55-
"",
56-
"ultrathink",
57-
),
58-
).toEqual({ prompt: "Ultrathink:\n" });
59-
});
60-
6138
it("removes the prefix when choosing a regular effort and saves Ultracode as an option", () => {
6239
const descriptors = [effortDescriptor];
6340
const prompt = "Ultrathink:\nsolve it";
@@ -71,19 +48,11 @@ describe("prompt-controlled reasoning", () => {
7148
).toEqual({ prompt: "solve it", modelOptions: [{ id: "effort", value: "ultracode" }] });
7249
});
7350

74-
it("keeps body-controlled Ultrathink locked and preserves slash commands", () => {
51+
it("keeps body-controlled Ultrathink locked", () => {
7552
const descriptors = [effortDescriptor];
7653
const prompt = "please ultrathink about this";
7754
const selected = { ...getPromptControlledTraits(descriptors, prompt), descriptors };
7855
expect(selected.ultrathinkInBodyText).toBe(true);
7956
expect(getTraitSelectChange(selected, effortDescriptor, prompt, "low")).toBeNull();
80-
expect(
81-
getTraitSelectChange(
82-
{ ...getPromptControlledTraits(descriptors, "/review"), descriptors },
83-
effortDescriptor,
84-
"/review",
85-
"ultrathink",
86-
),
87-
).toEqual({ prompt: "/review" });
8857
});
8958
});

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2835,15 +2835,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28352835
direction,
28362836
});
28372837
if (!change) return;
2838-
if (change.modelOptions) {
2839-
useComposerDraftStore
2840-
.getState()
2841-
.setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, {
2842-
instanceId: selectedInstanceId,
2843-
model: selectedModel,
2844-
persistSticky: true,
2845-
});
2846-
}
2838+
useComposerDraftStore
2839+
.getState()
2840+
.setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, {
2841+
instanceId: selectedInstanceId,
2842+
model: selectedModel,
2843+
persistSticky: true,
2844+
});
28472845
if (change.prompt !== promptRef.current) {
28482846
applyPromptReplacementStable(0, promptRef.current.length, change.prompt, {
28492847
expandedCursorAfterReplace: change.cursor,

‎apps/web/src/components/chat/TraitsPicker.tsx‎

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
replaceDescriptorCurrentValue,
66
selectableProviderOptionChoices,
77
REASONING_SELECT_DESCRIPTOR_IDS,
8-
ULTRATHINK_PROMPT_PREFIX,
98
} from "@t3tools/client-runtime/model-traits";
109
import {
1110
type ModelSelection,
@@ -258,7 +257,8 @@ export function getReasoningTraitStep(input: {
258257
const descriptor = selected.selectDescriptors.find((entry) =>
259258
REASONING_SELECT_DESCRIPTOR_IDS.has(entry.id),
260259
);
261-
if (!descriptor || descriptor.options.length < 2) return null;
260+
const choices = descriptor ? selectableProviderOptionChoices(descriptor) : [];
261+
if (!descriptor || choices.length < 2) return null;
262262
const isPrimary = descriptor.id === selected.primarySelectDescriptor?.id;
263263
const current =
264264
isPrimary && selected.ultrathinkPromptControlled
@@ -268,26 +268,17 @@ export function getReasoningTraitStep(input: {
268268
{ instanceId: input.instanceId, model: input.model, options: input.modelOptions ?? [] },
269269
input.reportedModelSelection,
270270
);
271+
// Walk the full catalog order so a typed Ultrathink keeps its place, then land on a pickable level.
271272
const ordered = input.direction > 0 ? descriptor.options : descriptor.options.toReversed();
272273
const position =
273274
[current, getDescriptorStringValue(descriptor)]
274275
.map((value) => ordered.findIndex((option) => option.id === value))
275276
.find((index) => index >= 0) ?? -1;
276-
for (const option of [
277-
...ordered.slice(position + 1),
278-
...ordered.slice(0, Math.max(position, 0)),
279-
]) {
280-
const change = getTraitSelectChange(selected, descriptor, input.prompt, option.id);
281-
if (!change) return null;
282-
if ("modelOptions" in change) {
283-
return change.modelOptions
284-
? { prompt: change.prompt, modelOptions: change.modelOptions }
285-
: null;
286-
}
287-
// A slash command refuses the Ultrathink prefix, so skip that level.
288-
if (change.prompt.startsWith(ULTRATHINK_PROMPT_PREFIX)) return change;
289-
}
290-
return null;
277+
const next = [...ordered.slice(position + 1), ...ordered.slice(0, Math.max(position, 0))].find(
278+
(option) => choices.includes(option),
279+
);
280+
const change = next && getTraitSelectChange(selected, descriptor, input.prompt, next.id);
281+
return change?.modelOptions ? { prompt: change.prompt, modelOptions: change.modelOptions } : null;
291282
}
292283

293284
export interface TraitsMenuContentProps {
@@ -368,7 +359,7 @@ export function useTraitsSelection({
368359
onPromptEdit?.(
369360
(prompt) => getTraitSelectChange(selected, descriptor, prompt, value)?.prompt ?? prompt,
370361
);
371-
if ("modelOptions" in change) updateModelOptions(change.modelOptions);
362+
updateModelOptions(change.modelOptions);
372363
},
373364
changeBoolean: (descriptorId: string, value: boolean) => {
374365
updateModelOptions(

‎apps/web/src/components/chat/composerProviderState.test.tsx‎

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -324,48 +324,31 @@ describe("getComposerReasoningStep", () => {
324324
).toStrictEqual(selections(["thinking_level", "high"]));
325325
});
326326

327-
it("adds and strips the Ultrathink prefix while the caret stays on the same character", () => {
328-
for (const { prompt, modelOptions, direction, expected } of [
329-
{
330-
prompt: " Fix the bug \n",
331-
modelOptions: selections(["effort", "high"]),
332-
direction: 1,
333-
expected: { prompt: "Ultrathink:\nFix the bug", cursor: 20 },
334-
},
335-
{
336-
prompt: "Ultrathink:\n Fix the bug",
337-
modelOptions: selections(["effort", "medium"]),
338-
direction: -1,
339-
expected: {
340-
prompt: "Fix the bug",
341-
cursor: 8,
342-
modelOptions: selections(["effort", "high"]),
343-
},
344-
},
327+
it("never steps into Ultrathink and strips a typed prefix with the caret on the same character", () => {
328+
expect(step({ ...CLAUDE, modelOptions: selections(["effort", "high"]) })).toStrictEqual({
329+
prompt: "Fix the bug",
330+
cursor: 0,
331+
modelOptions: selections(["effort", "medium"]),
332+
});
333+
const prompt = "Ultrathink:\n Fix the bug";
334+
// A typed Ultrathink sits above the pickable levels, so up wraps and down lands on the top level.
335+
for (const [direction, to] of [
336+
[-1, "high"],
337+
[1, "medium"],
345338
] as const) {
346339
const result = step({
347340
...CLAUDE,
348341
prompt,
349342
cursor: prompt.indexOf("bug"),
350-
modelOptions,
343+
modelOptions: selections(["effort", "medium"]),
351344
direction,
352345
});
353-
expect(result).toStrictEqual(expected);
354-
expect(result?.prompt.slice(result.cursor)).toMatch(/^bug/);
346+
expect(result, `by ${direction}`).toStrictEqual({
347+
prompt: "Fix the bug",
348+
cursor: 8,
349+
modelOptions: selections(["effort", to]),
350+
});
355351
}
356-
expect(
357-
step({ ...CLAUDE, prompt: " Fix", modelOptions: selections(["effort", "high"]) })?.cursor,
358-
).toBe("Ultrathink:\n".length);
359-
});
360-
361-
it("skips Ultrathink for slash commands and does nothing when the prompt locks it", () => {
362-
expect(
363-
step({ ...CLAUDE, prompt: "/review", modelOptions: selections(["effort", "high"]) }),
364-
).toStrictEqual({
365-
prompt: "/review",
366-
cursor: 0,
367-
modelOptions: selections(["effort", "medium"]),
368-
});
369352
for (const [prompt, direction] of [
370353
["Please ultrathink harder", 1],
371354
["Ultrathink:\nPlease ultrathink harder", -1],

‎apps/web/src/components/chat/composerProviderState.tsx‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
152152
};
153153
}
154154

155-
/** Expanded caret positions stay on the same character across Ultrathink prefix changes. */
155+
/** The caret stays on the same character when stepping strips a typed Ultrathink prefix. */
156156
export function getComposerReasoningStep(input: {
157157
provider: ProviderDriverKind;
158158
instanceId: ProviderInstanceId;
@@ -167,7 +167,7 @@ export function getComposerReasoningStep(input: {
167167
}): {
168168
prompt: string;
169169
cursor: number;
170-
modelOptions?: ReadonlyArray<ProviderOptionSelection>;
170+
modelOptions: ReadonlyArray<ProviderOptionSelection>;
171171
} | null {
172172
const { prompt, cursor } = input;
173173
const { selections } = resolveComposerOptionSelections(
@@ -189,18 +189,10 @@ export function getComposerReasoningStep(input: {
189189
direction: input.direction,
190190
});
191191
if (!step) return null;
192-
const modelOptions = "modelOptions" in step ? { modelOptions: step.modelOptions } : {};
193-
if (step.prompt === prompt) return { prompt, cursor, ...modelOptions };
194-
// Adding the prefix also trims the prompt.
195-
const keptStart = "modelOptions" in step ? 0 : step.prompt.length - prompt.trim().length;
196-
const offset =
197-
"modelOptions" in step
198-
? step.prompt.length - prompt.length
199-
: keptStart - (prompt.length - prompt.trimStart().length);
200192
return {
201193
prompt: step.prompt,
202-
cursor: Math.min(step.prompt.length, Math.max(keptStart, cursor + offset)),
203-
...modelOptions,
194+
cursor: Math.max(0, cursor + step.prompt.length - prompt.length),
195+
modelOptions: step.modelOptions,
204196
};
205197
}
206198

‎packages/client-runtime/src/modelTraits.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,13 @@ import type {
44
ProviderOptionDescriptor,
55
} from "@t3tools/contracts";
66
import {
7-
applyClaudePromptEffortPrefix,
87
buildProviderOptionSelectionsFromDescriptors,
98
getProviderOptionCurrentLabel,
109
getProviderOptionCurrentValue,
1110
isClaudeUltrathinkPrompt,
1211
} from "@t3tools/shared/model";
1312
import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts";
1413

15-
export const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
16-
1714
export const REASONING_SELECT_DESCRIPTOR_IDS: ReadonlySet<string> = new Set([
1815
...REASONING_EFFORT_OPTION_IDS,
1916
"thinking",
@@ -51,10 +48,13 @@ export function getDescriptorStringValue(
5148
return typeof value === "string" ? value : null;
5249
}
5350

51+
// Prompt-injected levels such as Ultrathink still work when typed, but pickers do not offer them.
5452
export function selectableProviderOptionChoices(
5553
descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>,
5654
) {
57-
return descriptor.options;
55+
return descriptor.options.filter(
56+
(option) => !descriptor.promptInjectedValues?.includes(option.id),
57+
);
5858
}
5959

6060
export function getPromptControlledTraits(
@@ -81,15 +81,7 @@ export function getTraitSelectChange(
8181
prompt: string,
8282
value: string,
8383
) {
84-
if (!value) return null;
85-
if (descriptor.promptInjectedValues?.includes(value)) {
86-
return {
87-
prompt:
88-
prompt.trim().length === 0
89-
? ULTRATHINK_PROMPT_PREFIX
90-
: applyClaudePromptEffortPrefix(prompt, "ultrathink"),
91-
};
92-
}
84+
if (!value || descriptor.promptInjectedValues?.includes(value)) return null;
9385
const isPrimary = descriptor.id === selected.primarySelectDescriptor?.id;
9486
if (isPrimary && selected.ultrathinkInBodyText) return null;
9587
return {

0 commit comments

Comments
 (0)