From 1780a8adca136038670345e22f79aaf676025a35 Mon Sep 17 00:00:00 2001 From: Serge Alex Serbinenko <129629680+SergeSerb2@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:49:59 -0700 Subject: [PATCH] fix(mobile): hide inapplicable model settings in the picker Greyed-out reasoning and context rows cluttered the sheet for models that don't support them. Show only the selected model's options, and put short catalogs on inline chips so the panel stays compact. Co-authored-by: Cursor --- .../features/threads/ThreadSettingsSheet.tsx | 243 ++++++++++-------- .../thread-settings-sheet-state.test.ts | 79 +++++- .../threads/thread-settings-sheet-state.ts | 28 ++ 3 files changed, 239 insertions(+), 111 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index f87a41e0eefa..bd92e8bffcf3 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -13,6 +13,7 @@ import { import * as Haptics from "expo-haptics"; import { useCallback, useEffect, useRef, useState } from "react"; import { + LayoutAnimation, Modal, Platform, Pressable, @@ -31,7 +32,11 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; import { useThemeColor } from "../../lib/useThemeColor"; import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; -import { pendingModelAfterPress } from "./thread-settings-sheet-state"; +import { + pendingModelAfterPress, + usesInlineSelectChoices, + visibleSheetOptionDescriptors, +} from "./thread-settings-sheet-state"; import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; /** @@ -41,6 +46,19 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet */ const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex"]); +const SETTINGS_LAYOUT_ANIMATION = { + duration: 180, + create: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, + update: { type: LayoutAnimation.Types.easeInEaseOut }, + delete: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, +} as const; + /** * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, * covering model, provider options, runtime mode, and plan mode in one label. @@ -163,19 +181,14 @@ function ProviderHeader(props: { function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; - readonly disabled?: boolean; readonly onPress: () => void; }) { const iconSubtle = useThemeColor("--color-icon-subtle"); return ( {props.label} @@ -189,6 +202,61 @@ function DisclosureRow(props: { ); } +/** Inline mutually-exclusive choices for short catalogs (reasoning, runtime). */ +function ChoiceChipRow(props: { + readonly label: string; + readonly choices: ReadonlyArray<{ + readonly id: string; + readonly label: string; + readonly accessibilityLabel?: string; + }>; + readonly selectedId: string | undefined; + readonly onSelect: (id: string) => void; +}) { + const equalWidth = props.choices.length > 1 && props.choices.length <= 4; + return ( + + {props.label} + + {props.choices.map((choice) => { + const selected = choice.id === props.selectedId; + return ( + { + if (choice.id === props.selectedId) { + return; + } + void Haptics.selectionAsync(); + props.onSelect(choice.id); + }} + className={cn( + "rounded-full px-3 py-1.5 active:opacity-70", + selected ? "bg-primary" : "bg-subtle", + equalWidth && "min-w-0 flex-1 items-center", + )} + > + + {choice.label} + + + ); + })} + + + ); +} + /** Single option inside a submenu panel. */ function ChoiceRow(props: { readonly label: string; @@ -225,21 +293,14 @@ function ChoiceRow(props: { function SwitchRow(props: { readonly label: string; readonly value: boolean; - readonly disabled?: boolean; readonly onValueChange: (value: boolean) => void; }) { const activeTrack = String(useThemeColor("--color-switch-active")); const track = String(useThemeColor("--color-secondary-border")); return ( - + {props.label} { - const seen = new Map(); - for (const group of props.providerGroups) { - const driver = group.models[0]?.providerDriver; - if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { - continue; - } - for (const model of group.models) { - if (model.isLegacy) { - continue; - } - for (const descriptor of model.capabilities?.optionDescriptors ?? []) { - if (!seen.has(descriptor.label)) { - seen.set(descriptor.label, { type: descriptor.type }); - } - } - } - } - for (const descriptor of displayedDescriptors) { - if (!seen.has(descriptor.label)) { - seen.set(descriptor.label, { type: descriptor.type }); - } - } - return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); - })(); + const visibleDescriptors = visibleSheetOptionDescriptors(displayedDescriptors); const handleSave = () => { if (pendingModel) { @@ -410,43 +440,27 @@ export function ThreadSettingsSheet(props: { }); }; - const activeDescriptor = - submenu?.kind === "descriptor" - ? displayedDescriptors.find( - (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, - ) - : undefined; + const activeDescriptor = displayedDescriptors.find( + (descriptor) => + submenu !== null && descriptor.type === "select" && descriptor.id === submenu.id, + ); const submenuContent = - submenu?.kind === "runtime" + activeDescriptor?.type === "select" ? { - title: "Runtime", - rows: RUNTIME_MODE_CHOICES.map((choice) => ({ - id: choice.mode, + title: activeDescriptor.label, + rows: selectableChoices(activeDescriptor).map((choice) => ({ + id: choice.id, label: choice.label, - selected: choice.mode === props.runtimeMode, + selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), onPress: () => { void Haptics.selectionAsync(); - props.onUpdateRuntimeMode(choice.mode); + handleOptionChange(activeDescriptor.id, choice.id); setSubmenu(null); }, })), } - : activeDescriptor?.type === "select" - ? { - title: activeDescriptor.label, - rows: selectableChoices(activeDescriptor).map((choice) => ({ - id: choice.id, - label: choice.label, - selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), - onPress: () => { - void Haptics.selectionAsync(); - handleOptionChange(activeDescriptor.id, choice.id); - setSubmenu(null); - }, - })), - } - : null; + : null; return ( { void Haptics.selectionAsync(); + LayoutAnimation.configureNext(SETTINGS_LAYOUT_ANIMATION); // Re-tapping the applied model cancels staging. setPendingModel((current) => pendingModelAfterPress({ @@ -554,46 +569,56 @@ export function ThreadSettingsSheet(props: { - - {descriptorTemplate.map((entry) => { - const live = displayedDescriptors.find( - (descriptor) => descriptor.label === entry.label, - ); - if ((live?.type ?? entry.type) === "select") { + + {visibleDescriptors.map((descriptor) => { + if (descriptor.type === "select") { + if (usesInlineSelectChoices(descriptor)) { + const currentValue = getProviderOptionCurrentValue(descriptor); + return ( + ({ + id: choice.id, + label: choice.label, + }))} + onSelect={(id) => handleOptionChange(descriptor.id, id)} + /> + ); + } return ( { - if (live) { - setSubmenu({ kind: "descriptor", id: live.id }); - } - }} + key={descriptor.id} + label={descriptor.label} + value={getProviderOptionCurrentLabel(descriptor)} + onPress={() => setSubmenu({ kind: "descriptor", id: descriptor.id })} /> ); } return ( { - if (live) { - handleOptionChange(live.id, value); - } - }} + key={descriptor.id} + label={descriptor.label} + value={descriptor.currentValue ?? false} + onValueChange={(value) => handleOptionChange(descriptor.id, value)} /> ); })} - choice.mode === props.runtimeMode)?.label - } - onPress={() => setSubmenu({ kind: "runtime" })} + selectedId={props.runtimeMode} + choices={RUNTIME_MODE_CHOICES.map((choice) => ({ + id: choice.mode, + label: choice.shortLabel, + accessibilityLabel: `Runtime, ${choice.label}`, + }))} + onSelect={(id) => { + const choice = RUNTIME_MODE_CHOICES.find((candidate) => candidate.mode === id); + if (choice) { + props.onUpdateRuntimeMode(choice.mode); + } + }} /> { ).toBe(pressed); }); }); + +const reasoning: ProviderOptionDescriptor = { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + { id: "ultracode", label: "Ultracode" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], +}; + +const contextWindow: ProviderOptionDescriptor = { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200k", isDefault: true }, + { id: "1m", label: "1M" }, + ], + currentValue: "200k", +}; + +const fastMode: ProviderOptionDescriptor = { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: false, +}; + +describe("visible sheet option descriptors", () => { + it("keeps only the advertised options, dropping empty select catalogs", () => { + const emptySelect: ProviderOptionDescriptor = { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [{ id: "ultracode", label: "Ultracode" }], + currentValue: "ultracode", + }; + + expect( + visibleSheetOptionDescriptors([reasoning, fastMode, emptySelect]).map( + (descriptor) => descriptor.id, + ), + ).toEqual(["effort", "fastMode"]); + }); + + it("uses inline chips for short catalogs and a disclosure for long ones", () => { + expect(usesInlineSelectChoices(reasoning)).toBe(true); + expect(usesInlineSelectChoices(contextWindow)).toBe(true); + + const longCatalog: Extract = { + id: "modelVariant", + label: "Variant", + type: "select", + options: Array.from({ length: 8 }, (_, index) => ({ + id: `v${index}`, + label: `Variant ${index}`, + })), + }; + expect(usesInlineSelectChoices(longCatalog)).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index f0540dc5a971..4b1498604b61 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,4 +1,10 @@ +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; + import type { ModelOption } from "../../lib/modelOptions"; +import { selectableChoices } from "./thread-settings-menu"; + +/** Compact chip rows stay scannable; longer catalogs keep a disclosure. */ +export const INLINE_SELECT_CHOICE_LIMIT = 6; /** Preserve staged provider options when the highlighted model is tapped again. */ export function pendingModelAfterPress(input: { @@ -11,3 +17,25 @@ export function pendingModelAfterPress(input: { } return input.current?.key === input.pressed.key ? input.current : input.pressed; } + +/** + * Settings the displayed model actually advertises. Unsupported rows stay + * out of the sheet instead of rendering disabled — switching models should + * only show controls that can change something. + */ +export function visibleSheetOptionDescriptors( + descriptors: ReadonlyArray, +): ReadonlyArray { + return descriptors.filter((descriptor) => { + if (descriptor.type === "boolean") { + return true; + } + return selectableChoices(descriptor).length > 0; + }); +} + +export function usesInlineSelectChoices( + descriptor: Extract, +): boolean { + return selectableChoices(descriptor).length <= INLINE_SELECT_CHOICE_LIMIT; +}