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; +}