diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx new file mode 100644 index 0000000000000..ce6fe0966f021 --- /dev/null +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -0,0 +1,76 @@ +import type { ModelSelection } from "@t3tools/contracts"; + +import type { ModelOption } from "../lib/modelOptions"; +import { + composerModelOptionSummary, + resolveProviderOptionDescriptors, +} from "../lib/providerOptions"; +import { AppText as Text } from "./ui/AppText"; +import { SymbolView } from "./ui/AppSymbol"; +import { ComposerInlineControl } from "./ComposerToolbar"; +import { ProviderIcon } from "./ProviderIcon"; +import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; + +/** Keeps option indicators fixed while the model name yields space in a narrow toolbar. */ +export function ComposerModelControl(props: { + readonly modelOption: ModelOption | null | undefined; + readonly selection: ModelSelection | null; + readonly reportedSelection?: ModelSelection | null | undefined; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + const { scale } = useAndroidControlSizing(); + const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; + const { reasoningLabel, speedLabel } = composerModelOptionSummary({ + provider: props.modelOption?.providerDriver, + descriptors: resolveProviderOptionDescriptors({ + capabilities: props.modelOption?.capabilities, + selections: props.selection?.options, + }), + selection: props.selection, + reportedSelection: props.reportedSelection, + }); + return ( + ( + + )} + label={label} + suffix={ + reasoningLabel || speedLabel ? ( + <> + {reasoningLabel ? ( + + {reasoningLabel} + + ) : null} + {speedLabel ? ( + + ) : null} + + ) : undefined + } + maxWidth="100%" + onPress={props.onPress} + /> + ); +} diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index d6bbf9d992943..66ad1ecd52fdf 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -30,6 +30,7 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly renderIcon?: (size: number) => ReactNode; readonly label: string; + readonly suffix?: ReactNode; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; @@ -77,6 +78,9 @@ export function ComposerInlineControl(props: { > {props.label} + {props.suffix ? ( + {props.suffix} + ) : null} {props.showChevron === false ? null : ( - - - + + ( - - )} - label={flow.selectedModelOption?.label ?? "Choose model"} - maxWidth="100%" + modelOption={flow.selectedModelOption} + selection={flow.selectedModel} onPress={settingsSheetPresentation.open} /> diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1619612a5aef3..0d1055f104829 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -73,12 +73,8 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { GlassSurface } from "../../components/ui/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { fileRoutePathSegments } from "../files/filePath"; -import { - ComposerActionButton, - ComposerInlineControl, - ComposerToolbarRow, -} from "../../components/ComposerToolbar"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ComposerActionButton, ComposerToolbarRow } from "../../components/ComposerToolbar"; +import { ComposerModelControl } from "../../components/ComposerModelControl"; import { composerStripAttachments, type DraftComposerFileAttachment, @@ -1059,18 +1055,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps onPickFiles={props.onPickDraftFiles} /> - ( - - )} - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth="100%" + diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index 0935a181151bf..824e13a500574 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ModelCapabilities } from "@t3tools/contracts"; +import { + ProviderInstanceId, + type ModelCapabilities, + type ModelSelection, +} from "@t3tools/contracts"; -import { applyProviderOptionSelection, resolveProviderOptionDescriptors } from "./providerOptions"; +import { + applyProviderOptionSelection, + composerModelOptionSummary, + resolveProviderOptionDescriptors, +} from "./providerOptions"; const CODEX_CAPABILITIES: ModelCapabilities = { optionDescriptors: [ @@ -61,3 +69,244 @@ describe("mobile provider options", () => { ]); }); }); + +describe("composer model option summary", () => { + const selection: ModelSelection = { + instanceId: ProviderInstanceId.make("test-provider"), + model: "custom-model-with-a-long-name", + }; + function summary( + capabilities: ModelCapabilities | null = CODEX_CAPABILITIES, + selected = selection, + reported?: ModelSelection, + provider = "codex", + ) { + return composerModelOptionSummary({ + provider, + descriptors: resolveProviderOptionDescriptors({ + capabilities, + selections: selected.options, + }), + selection: selected, + reportedSelection: reported, + }); + } + + it("shows the effective default reasoning and hides standard speed", () => { + expect(summary()).toEqual({ reasoningLabel: "Medium", speedLabel: undefined }); + }); + + const openCodeCapabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + ], + }; + + it("omits an unselected OpenCode variant even when the catalog has a default", () => { + const reported: ModelSelection = { + ...selection, + options: [{ id: "variant", value: "high" }], + }; + for (const report of [ + undefined, + { ...reported, model: "other-model" }, + { ...reported, instanceId: ProviderInstanceId.make("other-provider") }, + ]) { + expect(summary(openCodeCapabilities, selection, report, "opencode")).toEqual({ + reasoningLabel: undefined, + speedLabel: undefined, + }); + } + }); + + it("preserves explicit and matching reported OpenCode variant labels", () => { + const reported: ModelSelection = { + ...selection, + options: [{ id: "variant", value: "low" }], + }; + expect(summary(openCodeCapabilities, selection, reported, "opencode").reasoningLabel).toBe( + "Low", + ); + expect( + summary( + openCodeCapabilities, + { ...selection, options: [{ id: "variant", value: "high" }] }, + reported, + "opencode", + ).reasoningLabel, + ).toBe("High"); + expect( + summary( + openCodeCapabilities, + selection, + { ...selection, options: [{ id: "variant", value: "default" }] }, + "opencode", + ).reasoningLabel, + ).toBe("Default"); + }); + + it("shows a selected reasoning label and Codex fast tier", () => { + expect( + summary(CODEX_CAPABILITIES, { + ...selection, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: "priority" }, + ], + }), + ).toEqual({ reasoningLabel: "High", speedLabel: "Fast" }); + }); + + it.each(["reasoningEffort", "reasoning", "effort", "variant", "thinking"])( + "uses advertised reasoning labels for %s", + (id) => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id, + label: "Reasoning", + type: "select", + options: [{ id: "xhigh", label: "Extra High", isDefault: true }], + }, + ], + }; + const selected = { ...selection, options: [{ id, value: "xhigh" }] }; + expect(summary(capabilities, selected)).toEqual({ + reasoningLabel: "Extra High", + speedLabel: undefined, + }); + }, + ); + + it.each(["claudeAgent", "cursor", "opencode"])( + "shows and clears boolean fast mode for %s", + (provider) => { + const capabilities: ModelCapabilities = { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }; + for (const value of [true, false]) { + expect( + summary( + capabilities, + { ...selection, options: [{ id: "fastMode", value }] }, + undefined, + provider, + ), + ).toEqual({ reasoningLabel: undefined, speedLabel: value ? "Fast" : undefined }); + } + }, + ); + + it("uses matching provider-reported options when no explicit override exists", () => { + const reported: ModelSelection = { + ...selection, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: "priority" }, + ], + }; + expect(summary(CODEX_CAPABILITIES, selection, reported)).toEqual({ + reasoningLabel: "High", + speedLabel: "Fast", + }); + expect( + summary( + CODEX_CAPABILITIES, + { + ...selection, + options: [ + { id: "reasoningEffort", value: "medium" }, + { id: "serviceTier", value: "default" }, + ], + }, + reported, + ), + ).toEqual({ reasoningLabel: "Medium", speedLabel: undefined }); + for (const other of [ + { ...reported, model: "other-model" }, + { ...reported, instanceId: ProviderInstanceId.make("other-provider") }, + ]) { + expect(summary(CODEX_CAPABILITIES, selection, other)).toEqual({ + reasoningLabel: "Medium", + speedLabel: undefined, + }); + } + }); + + it("does not turn unsupported or unrelated options into indicators", () => { + const selected: ModelSelection = { + ...selection, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }; + expect(summary(null, selected)).toEqual({ reasoningLabel: undefined, speedLabel: undefined }); + expect(summary({ optionDescriptors: [] }, selected)).toEqual({ + reasoningLabel: undefined, + speedLabel: undefined, + }); + expect( + summary({ + optionDescriptors: [ + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [{ id: "expanded", label: "Expanded", isDefault: true }], + }, + ], + }), + ).toEqual({ reasoningLabel: undefined, speedLabel: undefined }); + }); + + it("recognizes only advertised Codex fast and ultrafast tiers", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + { id: "ultrafast", label: "Ultrafast" }, + { id: "flex", label: "Flex" }, + ], + }, + ], + }; + for (const [value, expected] of [ + ["default", undefined], + ["priority", "Fast"], + ["ultrafast", "Ultrafast"], + ["flex", undefined], + ["unknown", undefined], + ] as const) { + const selected = { ...selection, options: [{ id: "serviceTier", value }] }; + expect(summary(capabilities, selected).speedLabel).toBe(expected); + expect(summary(capabilities, selected, undefined, "cursor").speedLabel).toBeUndefined(); + } + }); + + it("shows boolean thinking independently of speed", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [{ id: "thinking", label: "Thinking", type: "boolean" }], + }; + for (const value of [true, false]) { + expect(summary(capabilities, { ...selection, options: [{ id: "thinking", value }] })).toEqual( + { reasoningLabel: value ? "Thinking" : "No thinking", speedLabel: undefined }, + ); + } + }); +}); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index 0199b0fbd0fd1..16bda14132a8e 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -1,13 +1,70 @@ import type { ModelCapabilities, + ModelSelection, ProviderOptionDescriptor, ProviderOptionSelection, } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +/** Summarizes the effective reasoning and speed options without changing the selection. */ +export function composerModelOptionSummary(input: { + readonly provider: string | null | undefined; + readonly descriptors: ReadonlyArray; + readonly selection: ModelSelection | null; + readonly reportedSelection?: ModelSelection | null | undefined; +}) { + const reasoning = input.descriptors.find( + (descriptor) => + descriptor.type === "select" && + ["reasoningEffort", "reasoning", "effort", "variant", "thinking"].includes(descriptor.id), + ); + const thinking = input.descriptors.find( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", + ); + const thinkingValue = getProviderOptionCurrentValue( + thinking, + input.selection, + input.reportedSelection, + ); + const reasoningValue = getProviderOptionCurrentValue( + reasoning, + input.selection, + input.reportedSelection, + ); + const reasoningLabel = + reasoning && reasoningValue !== undefined + ? getProviderOptionCurrentLabel(reasoning, input.selection, input.reportedSelection) + : typeof thinkingValue === "boolean" + ? thinkingValue + ? "Thinking" + : "No thinking" + : undefined; + const speed = input.descriptors.find( + (descriptor) => + (descriptor.id === "fastMode" && descriptor.type === "boolean") || + (input.provider === "codex" && + descriptor.id === "serviceTier" && + descriptor.type === "select"), + ); + const speedValue = getProviderOptionCurrentValue(speed, input.selection, input.reportedSelection); + const speedChoice = + speed?.type === "select" ? speed.options.find((choice) => choice.id === speedValue) : undefined; + const speedLabel = + speed?.type === "boolean" + ? speedValue === true + ? "Fast" + : undefined + : speedChoice?.label === "Fast" || speedChoice?.label === "Ultrafast" + ? speedChoice.label + : undefined; + return { reasoningLabel, speedLabel }; +} + export function resolveProviderOptionDescriptors(input: { readonly capabilities: ModelCapabilities | null | undefined; readonly selections: ReadonlyArray | null | undefined;