diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbarTrigger.tsx index 20187624964..2ec8bb35bc6 100644 --- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx +++ b/apps/mobile/src/components/ComposerToolbarTrigger.tsx @@ -51,6 +51,7 @@ export function ComposerToolbarScroller(props: { readonly fadeOpaque: string; readonly fadeTransparent: string; readonly contentPaddingRight?: number; + readonly scrollEnabled?: boolean; }) { const [metrics, setMetrics] = useState({ contentWidth: 0, @@ -94,6 +95,7 @@ export function ComposerToolbarScroller(props: { onContentSizeChange={handleContentSizeChange} onLayout={handleLayout} onScroll={handleScroll} + scrollEnabled={props.scrollEnabled} scrollEventThrottle={16} showsHorizontalScrollIndicator={false} contentContainerStyle={{ diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 6c1b1038698..ea615d3d95f 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -2,19 +2,64 @@ import { useColorScheme } from "react-native"; import { Path, Svg } from "react-native-svg"; type ProviderIconProps = { + readonly experimentalProviderIcons?: boolean; readonly provider: string | null | undefined; readonly size?: number; + readonly tintColor?: string; }; export function ProviderIcon(props: ProviderIconProps) { const isDarkMode = useColorScheme() === "dark"; const size = props.size ?? 16; + const monochrome = props.tintColor ?? (isDarkMode ? "#EDECEC" : "#26251E"); + + if (props.experimentalProviderIcons && props.provider === "cursor") { + return ( + + + + ); + } + + if (props.experimentalProviderIcons && props.provider === "grok") { + return ( + + + + + ); + } + + if (props.experimentalProviderIcons && props.provider === "opencode") { + return ( + + + + + ); + } if (props.provider === "claudeAgent") { return ( @@ -24,7 +69,7 @@ export function ProviderIcon(props: ProviderIconProps) { return ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index aab896efe03..2aeec735e6f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -69,6 +69,9 @@ import { } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { ModelPickerPrototypeToolbar } from "./model-picker-prototype/ModelPickerPrototypeToolbar"; + +const MODEL_PICKER_PROTOTYPE_ENABLED = process.env.EXPO_PUBLIC_MODEL_PICKER_PROTOTYPE === "1"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -264,7 +267,6 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( ); }); - export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const isDarkMode = useColorScheme() === "dark"; const foregroundColor = useThemeColor("--color-foreground"); @@ -272,6 +274,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const [modelPickerGestureActive, setModelPickerGestureActive] = useState(false); + const [modelPickerMenuOpen, setModelPickerMenuOpen] = useState(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; @@ -719,7 +723,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - {connectionStatus ? ( + {connectionStatus && !modelPickerMenuOpen ? ( + {MODEL_PICKER_PROTOTYPE_ENABLED ? ( + + ) : null} void props.onPickDraftImages()} showChevron={false} /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={currentModelOption?.label ?? currentModelSelection.model} - /> - + {!MODEL_PICKER_PROTOTYPE_ENABLED ? ( + handleModelMenuAction(nativeEvent.event)} + > + + } + label={currentModelOption?.label ?? currentModelSelection.model} + /> + + ) : null} handleOptionsMenuAction(nativeEvent.event)} diff --git a/apps/mobile/src/features/threads/model-picker-prototype/ModelPickerPrototypeToolbar.tsx b/apps/mobile/src/features/threads/model-picker-prototype/ModelPickerPrototypeToolbar.tsx new file mode 100644 index 00000000000..66f4d86bf86 --- /dev/null +++ b/apps/mobile/src/features/threads/model-picker-prototype/ModelPickerPrototypeToolbar.tsx @@ -0,0 +1,969 @@ +import * as Haptics from "expo-haptics"; +import type { ModelSelection, ProviderOptionDescriptor } from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + Keyboard, + Modal, + Pressable, + ScrollView, + View, + type GestureResponderEvent, +} from "react-native"; +import Animated, { + Easing, + interpolate, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import Svg, { Path, Rect } from "react-native-svg"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../../components/AppText"; +import { SymbolView } from "../../../components/AppSymbol"; +import { OverlayPortal } from "../../../components/OverlayPortal"; +import { ProviderIcon } from "../../../components/ProviderIcon"; +import type { ModelOption } from "../../../lib/modelOptions"; +import { useThemeColor } from "../../../lib/useThemeColor"; +import { hasDeliberateGestureTravel, takeUniquePaletteIds } from "./modelPickerPrototypeState"; + +type ModelChoice = { + readonly id: string; + readonly compactLabel?: string; + readonly label: string; + readonly provider: string; + readonly selection: ModelSelection; +}; + +type ReasoningChoice = { + readonly id: string; + readonly compactLabel?: string; + readonly intensity: number | "max"; + readonly label: string; +}; + +const MARKING_MENU_WIDTH = 300; +const MARKING_MENU_HEIGHT = 230; +const MARKING_MENU_INNER_RADIUS = 96; +const MARKING_MENU_OUTER_RADIUS = 144; +const MARKING_MENU_LONG_PRESS_DELAY = 150; +const MARKING_MENU_GESTURE_MIN_RADIUS = MARKING_MENU_INNER_RADIUS; +const MARKING_MENU_GESTURE_MIN_TRAVEL = 16; +const MARKING_MENU_EXIT_DURATION = 90; +const MARKING_MENU_BACKDROP_OPACITY = 0.64; +const MARKING_MENU_OPTION_OPACITY = 0.94; +const MAX_PALETTE_CHOICES = 4; +const MODEL_MENU_GEOMETRY = { + center: { x: 90, y: 206 }, + endAngle: 360, + startAngle: 240, +} as const; +const REASONING_MENU_GEOMETRY = { + center: { x: MARKING_MENU_WIDTH / 2, y: 206 }, + endAngle: 345, + startAngle: 195, +} as const; + +const REASONING_OPTION_IDS = new Set(["reasoningEffort", "effort", "reasoning"]); + +function compactModelLabel(label: string): string { + const compact = label.replace(/^Claude\s+/i, "").replace(/^GPT-/i, ""); + if (compact.length <= 11) return compact; + const words = compact.split(/\s+/); + const tail = words.slice(-2).join(" "); + return tail.length <= 11 ? tail : `${compact.slice(0, 10)}…`; +} + +function compactReasoningLabel(label: string): string { + const normalized = label.trim().toLowerCase(); + if (normalized === "medium") return "Med"; + if (normalized === "extra high" || normalized === "xhigh") return "X-high"; + if (normalized === "ultrathink") return "Ultra"; + if (label.length <= 9) return label; + return `${label.slice(0, 8)}…`; +} + +function findReasoningDescriptor( + descriptors: ReadonlyArray, +): Extract | null { + const descriptor = descriptors.find( + (candidate) => + candidate.type === "select" && + (REASONING_OPTION_IDS.has(candidate.id) || /reasoning|effort/i.test(candidate.label)), + ); + return descriptor?.type === "select" ? descriptor : null; +} + +function reasoningIntensity(value: string, index: number, total: number): number | "max" { + const normalized = value.trim().toLowerCase(); + if (normalized === "max") return "max"; + if (normalized === "none") return 0; + if (normalized === "minimal" || normalized === "low") return 1; + if (normalized === "medium") return 2; + if (normalized === "high") return 3; + if (normalized === "xhigh" || normalized.startsWith("ultra")) return 4; + return Math.max(1, Math.min(4, Math.round(((index + 1) / Math.max(total, 1)) * 4))); +} + +function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((value) => right.has(value)); +} + +function initialPaletteIds( + choices: readonly T[], + selectedId: string, +): ReadonlySet { + const ids = choices.map((choice) => choice.id); + return takeUniquePaletteIds( + ids.includes(selectedId) ? [selectedId, ...ids] : ids, + MAX_PALETTE_CHOICES, + ); +} + +function usePaletteIds( + choices: readonly T[], + selectedId: string, +) { + const [enabledIds, setEnabledIds] = useState>(() => + initialPaletteIds(choices, selectedId), + ); + + useEffect(() => { + setEnabledIds((current) => { + const availableIds = new Set(choices.map((choice) => choice.id)); + const candidates = [selectedId, ...current, ...availableIds]; + const next = takeUniquePaletteIds( + candidates.filter((id) => availableIds.has(id)), + MAX_PALETTE_CHOICES, + ); + return setsEqual(current, next) ? current : next; + }); + }, [choices, selectedId]); + + return [enabledIds, setEnabledIds] as const; +} + +function polarPoint(center: { x: number; y: number }, radius: number, angle: number) { + const radians = (angle * Math.PI) / 180; + return { + x: center.x + radius * Math.cos(radians), + y: center.y + radius * Math.sin(radians), + }; +} + +function ringSectorPath( + center: { x: number; y: number }, + innerRadius: number, + outerRadius: number, + startAngle: number, + endAngle: number, +) { + const outerStart = polarPoint(center, outerRadius, startAngle); + const outerEnd = polarPoint(center, outerRadius, endAngle); + const innerEnd = polarPoint(center, innerRadius, endAngle); + const innerStart = polarPoint(center, innerRadius, startAngle); + const largeArc = endAngle - startAngle > 180 ? 1 : 0; + + return [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`, + "Z", + ].join(" "); +} + +function useMarkingMenuMotion(open: boolean, center: { readonly x: number; readonly y: number }) { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withTiming(open ? 1 : 0, { + duration: open ? 120 : 90, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, + }); + }, [open, progress]); + + return useAnimatedStyle(() => { + const scale = interpolate(progress.value, [0, 1], [0.82, 1]); + return { + opacity: progress.value, + transform: [ + { translateX: center.x }, + { translateY: center.y }, + { scale }, + { translateX: -center.x }, + { translateY: -center.y }, + ], + }; + }); +} + +function ReasoningIntensityIcon(props: { + readonly choice: ReasoningChoice; + readonly color: string; + readonly size: number; +}) { + const intensity = props.choice.intensity; + if (intensity === "max") { + return ( + + + + ); + } + + const heights = [5, 8, 11, 14] as const; + return ( + + {heights.map((height, index) => { + const active = index < intensity; + return ( + + ); + })} + + ); +} + +function PickerIcon(props: { + readonly choice: ModelChoice | ReasoningChoice; + readonly color: string; + readonly size?: number; + readonly tintProvider?: boolean; +}) { + if ("provider" in props.choice) { + return ( + + ); + } + return ( + + ); +} + +type MarkingItem = + | { + readonly id: T["id"]; + readonly kind: "choice"; + readonly choice: T; + } + | { readonly id: "edit"; readonly kind: "edit"; readonly label: "Edit" }; + +function PickerSheet(props: { + readonly children: ReactNode; + readonly closeLabel: string; + readonly onClose: () => void; + readonly visible: boolean; +}) { + const insets = useSafeAreaInsets(); + return ( + + + + + {props.children} + + + + ); +} + +function PaletteEditor(props: { + readonly choices: readonly T[]; + readonly enabledIds: ReadonlySet; + readonly lockedId: T["id"]; + readonly name: "model" | "reasoning"; + readonly onClose: () => void; + readonly onToggle: (choice: T) => void; + readonly visible: boolean; +}) { + const foreground = String(useThemeColor("--color-foreground")); + return ( + + + + Edit {props.name} palette + Keep two to four options. + + + Done + + + + {props.choices.map((choice, index) => { + const enabled = props.enabledIds.has(choice.id); + const disabled = + (enabled && (props.enabledIds.size <= 2 || choice.id === props.lockedId)) || + (!enabled && props.enabledIds.size >= MAX_PALETTE_CHOICES); + return ( + props.onToggle(choice)} + style={{ + borderTopColor: "rgba(127,127,127,0.14)", + borderTopWidth: index === 0 ? 0 : 1, + opacity: disabled ? 0.5 : 1, + }} + > + + + {choice.label} + + {enabled ? ( + + ) : null} + + ); + })} + + + ); +} + +function PickerFallbackSheet(props: { + readonly choices: readonly T[]; + readonly name: "model" | "reasoning"; + readonly onClose: () => void; + readonly onEditPalette: () => void; + readonly onSelect: (choice: T) => void; + readonly selectedId: string; + readonly visible: boolean; +}) { + const foreground = String(useThemeColor("--color-foreground")); + return ( + + Choose {props.name} + + {props.choices.map((choice, index) => { + const selected = choice.id === props.selectedId; + return ( + { + props.onSelect(choice); + props.onClose(); + }} + style={{ + borderTopColor: "rgba(127,127,127,0.14)", + borderTopWidth: index === 0 ? 0 : 1, + }} + > + + + {choice.label} + + {selected ? ( + + ) : null} + + ); + })} + { + props.onClose(); + requestAnimationFrame(props.onEditPalette); + }} + > + + Edit palette + + + + ); +} +function MarkingMenu(props: { + readonly choices: readonly T[]; + readonly placement: "center" | "corner"; + readonly selectedChoice: T; + readonly onEditPalette: () => void; + readonly onGestureActiveChange?: (active: boolean) => void; + readonly onMenuOpenChange?: (open: boolean) => void; + readonly onSelect: (choice: T) => void; +}) { + const primary = String(useThemeColor("--color-primary")); + const primaryForeground = String(useThemeColor("--color-primary-foreground")); + const foreground = String(useThemeColor("--color-foreground")); + const card = String(useThemeColor("--color-sheet")); + const subtleStrong = String(useThemeColor("--color-subtle-strong")); + const chevron = String(useThemeColor("--color-icon")); + const triggerRef = useRef(null); + const gestureOriginRef = useRef<{ x: number; y: number } | null>(null); + const gestureStartRef = useRef<{ x: number; y: number } | null>(null); + const gestureHasTravelledRef = useRef(false); + const menuOpenRef = useRef(false); + const gestureConsumedRef = useRef(false); + const highlightedIndexRef = useRef(null); + const closeTimerRef = useRef | null>(null); + const gestureActiveChangeRef = useRef(props.onGestureActiveChange); + const menuOpenChangeRef = useRef(props.onMenuOpenChange); + gestureActiveChangeRef.current = props.onGestureActiveChange; + menuOpenChangeRef.current = props.onMenuOpenChange; + const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); + const [fallbackOpen, setFallbackOpen] = useState(false); + const [overlayMounted, setOverlayMounted] = useState(false); + const [open, setOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(null); + const geometry = props.placement === "center" ? REASONING_MENU_GEOMETRY : MODEL_MENU_GEOMETRY; + const motionStyle = useMarkingMenuMotion(open, geometry.center); + const items = useMemo[]>( + () => [ + ...props.choices.map( + (choice): MarkingItem => ({ + id: choice.id, + kind: "choice", + choice, + }), + ), + { id: "edit", kind: "edit", label: "Edit" }, + ], + [props.choices], + ); + const sweep = geometry.endAngle - geometry.startAngle; + const segmentAngle = sweep / items.length; + const optionBox = props.placement === "corner" ? 48 : 54; + const readoutItem = + highlightedIndex === null + ? items.find((item) => item.kind === "choice" && item.id === props.selectedChoice.id) + : items[highlightedIndex]; + const readoutLabel = + readoutItem?.kind === "choice" + ? (readoutItem.choice.compactLabel ?? readoutItem.choice.label) + : "Edit palette"; + + useEffect( + () => () => { + if (closeTimerRef.current) clearTimeout(closeTimerRef.current); + gestureActiveChangeRef.current?.(false); + menuOpenChangeRef.current?.(false); + }, + [], + ); + + const openMenu = () => { + if (menuOpenRef.current) return; + if (closeTimerRef.current) clearTimeout(closeTimerRef.current); + gestureConsumedRef.current = true; + menuOpenRef.current = true; + setOverlayMounted(true); + setOpen(true); + props.onMenuOpenChange?.(true); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }; + + const updateHighlight = (event: GestureResponderEvent) => { + if (!menuOpenRef.current) return; + const origin = gestureOriginRef.current; + if (!origin) return; + const dx = event.nativeEvent.pageX - origin.x; + const dy = event.nativeEvent.pageY - origin.y; + const radius = Math.hypot(dx, dy); + const angle = (Math.atan2(dy, dx) * 180) / Math.PI; + const normalizedAngle = angle <= 0 ? angle + 360 : angle; + const insideRadius = radius >= MARKING_MENU_GESTURE_MIN_RADIUS; + const insideAngle = + normalizedAngle >= geometry.startAngle && normalizedAngle <= geometry.endAngle; + const nextIndex = + insideRadius && insideAngle + ? Math.min( + items.length - 1, + Math.floor((normalizedAngle - geometry.startAngle) / segmentAngle), + ) + : null; + if (nextIndex === highlightedIndexRef.current) return; + highlightedIndexRef.current = nextIndex; + setHighlightedIndex(nextIndex); + if (nextIndex !== null) void Haptics.selectionAsync(); + }; + + const handlePressMove = (event: GestureResponderEvent) => { + const current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }; + if ( + !hasDeliberateGestureTravel(gestureStartRef.current, current, MARKING_MENU_GESTURE_MIN_TRAVEL) + ) { + return; + } + gestureHasTravelledRef.current = true; + if (!menuOpenRef.current) { + openMenu(); + } + updateHighlight(event); + }; + + const closeMenu = () => { + menuOpenRef.current = false; + highlightedIndexRef.current = null; + setHighlightedIndex(null); + setOpen(false); + props.onMenuOpenChange?.(false); + closeTimerRef.current = setTimeout(() => { + setOverlayMounted(false); + closeTimerRef.current = null; + }, MARKING_MENU_EXIT_DURATION); + }; + + const commitGesture = (event: GestureResponderEvent) => { + props.onGestureActiveChange?.(false); + if (!menuOpenRef.current) return; + if (!gestureHasTravelledRef.current) { + closeMenu(); + return; + } + updateHighlight(event); + const committedIndex = highlightedIndexRef.current; + const item = committedIndex === null ? null : items[committedIndex]; + closeMenu(); + if (!item) return; + if (item.kind === "edit") { + Keyboard.dismiss(); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + requestAnimationFrame(props.onEditPalette); + return; + } + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + props.onSelect(item.choice); + }; + + return ( + + {overlayMounted ? ( + + + + + + {items.map((item, index) => { + const gap = 2; + const start = geometry.startAngle + index * segmentAngle + gap; + const end = geometry.startAngle + (index + 1) * segmentAngle - gap; + const highlighted = highlightedIndex === index; + const selected = item.kind === "choice" && item.id === props.selectedChoice.id; + return ( + + ); + })} + + {items.map((item, index) => { + const angle = geometry.startAngle + (index + 0.5) * segmentAngle; + const point = polarPoint(geometry.center, 120, angle); + const highlighted = highlightedIndex === index; + return ( + + {item.kind === "edit" ? ( + + ) : ( + + )} + {item.kind === "choice" ? ( + + {item.choice.compactLabel ?? item.choice.label} + + ) : null} + + ); + })} + + + {readoutLabel} + + + + + + ) : null} + { + if (gestureConsumedRef.current) { + gestureConsumedRef.current = false; + return; + } + Keyboard.dismiss(); + setFallbackOpen(true); + }} + onPressIn={(event) => { + props.onGestureActiveChange?.(true); + gestureConsumedRef.current = false; + gestureHasTravelledRef.current = false; + highlightedIndexRef.current = null; + setHighlightedIndex(null); + const touchDown = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + gestureStartRef.current = touchDown; + gestureOriginRef.current = touchDown; + setAnchor(touchDown); + triggerRef.current?.measureInWindow((x, y, width, height) => { + const measuredAnchor = { x: x + width / 2, y: y + height / 2 }; + gestureOriginRef.current = measuredAnchor; + setAnchor(measuredAnchor); + }); + }} + onPressMove={handlePressMove} + onPressOut={commitGesture} + pressRetentionOffset={320} + className={ + open + ? "h-11 flex-row items-center gap-2 rounded-full bg-subtle-strong px-3.5" + : "h-11 flex-row items-center gap-2 rounded-full bg-subtle px-3.5" + } + style={({ pressed }) => ({ + borderColor: "rgba(127,127,127,0.14)", + borderWidth: 1, + opacity: pressed ? 0.82 : 1, + transform: [{ scale: pressed ? 0.96 : 1 }], + })} + > + + {props.selectedChoice.label} + + + setFallbackOpen(false)} + onEditPalette={props.onEditPalette} + onSelect={props.onSelect} + selectedId={props.selectedChoice.id} + visible={fallbackOpen} + /> + + ); +} + +function MarkingMenuToolbar(props: { + readonly modelChoices: readonly ModelChoice[]; + readonly reasoningChoices: readonly ReasoningChoice[]; + readonly model: ModelChoice; + readonly reasoning: ReasoningChoice | null; + readonly onGestureActiveChange?: (active: boolean) => void; + readonly onMenuOpenChange?: (open: boolean) => void; + readonly onSelectModel: (choice: ModelChoice) => void; + readonly onSelectReasoning: (choice: ReasoningChoice) => void; +}) { + const [paletteEditor, setPaletteEditor] = useState<"model" | "reasoning" | null>(null); + const [enabledModelIds, setEnabledModelIds] = usePaletteIds(props.modelChoices, props.model.id); + const [enabledReasoningIds, setEnabledReasoningIds] = usePaletteIds( + props.reasoningChoices, + props.reasoning?.id ?? props.reasoningChoices[0]?.id ?? "", + ); + const paletteModels = useMemo( + () => props.modelChoices.filter((model) => enabledModelIds.has(model.id)), + [enabledModelIds, props.modelChoices], + ); + const paletteReasoning = useMemo( + () => props.reasoningChoices.filter((choice) => enabledReasoningIds.has(choice.id)), + [enabledReasoningIds, props.reasoningChoices], + ); + + return ( + + setPaletteEditor("model")} + onGestureActiveChange={props.onGestureActiveChange} + onMenuOpenChange={props.onMenuOpenChange} + onSelect={props.onSelectModel} + /> + {props.reasoning && paletteReasoning.length > 0 ? ( + setPaletteEditor("reasoning")} + onGestureActiveChange={props.onGestureActiveChange} + onMenuOpenChange={props.onMenuOpenChange} + onSelect={props.onSelectReasoning} + /> + ) : null} + setPaletteEditor(null)} + onToggle={(model) => { + setEnabledModelIds((current) => { + const next = new Set(current); + if (next.has(model.id)) next.delete(model.id); + else next.add(model.id); + return next; + }); + }} + /> + {props.reasoning ? ( + setPaletteEditor(null)} + onToggle={(choice) => { + setEnabledReasoningIds((current) => { + const next = new Set(current); + if (next.has(choice.id)) next.delete(choice.id); + else next.add(choice.id); + return next; + }); + }} + /> + ) : null} + + ); +} + +export function ModelPickerPrototypeToolbar(props: { + readonly currentModelSelection: ModelSelection; + readonly modelOptions: ReadonlyArray; + readonly onGestureActiveChange?: (active: boolean) => void; + readonly onMenuOpenChange?: (open: boolean) => void; + readonly onUpdateModelSelection: (selection: ModelSelection) => void; + readonly providerOptionDescriptors: ReadonlyArray; +}) { + const runtimeModels = useMemo( + () => + props.modelOptions.map((option) => ({ + id: option.key, + compactLabel: compactModelLabel(option.label), + label: option.label, + provider: option.providerDriver, + selection: option.selection, + })), + [props.modelOptions], + ); + const runtimeModel = + runtimeModels.find( + (choice) => + choice.selection.instanceId === props.currentModelSelection.instanceId && + choice.selection.model === props.currentModelSelection.model, + ) ?? runtimeModels[0]; + const reasoningDescriptor = useMemo( + () => findReasoningDescriptor(props.providerOptionDescriptors), + [props.providerOptionDescriptors], + ); + const runtimeReasoningChoices = useMemo( + () => + reasoningDescriptor?.options.map((option, index, options) => ({ + id: option.id, + compactLabel: compactReasoningLabel(option.label), + intensity: reasoningIntensity(option.id, index, options.length), + label: option.label, + })) ?? [], + [reasoningDescriptor], + ); + const currentReasoningValue = reasoningDescriptor + ? getProviderOptionCurrentValue(reasoningDescriptor) + : undefined; + const runtimeReasoning = reasoningDescriptor + ? (runtimeReasoningChoices.find((choice) => choice.id === currentReasoningValue) ?? + runtimeReasoningChoices[0] ?? + null) + : null; + + if (!runtimeModel) return null; + + return ( + { + props.onUpdateModelSelection(choice.selection); + }} + onSelectReasoning={(choice) => { + if (!reasoningDescriptor) return; + const nextDescriptors = props.providerOptionDescriptors.map((descriptor) => + descriptor.id === reasoningDescriptor.id && descriptor.type === "select" + ? { ...descriptor, currentValue: choice.id } + : descriptor, + ); + const options = buildProviderOptionSelectionsFromDescriptors(nextDescriptors); + props.onUpdateModelSelection( + options + ? { ...props.currentModelSelection, options } + : { + instanceId: props.currentModelSelection.instanceId, + model: props.currentModelSelection.model, + }, + ); + }} + /> + ); +} diff --git a/apps/mobile/src/features/threads/model-picker-prototype/modelPickerPrototypeState.test.ts b/apps/mobile/src/features/threads/model-picker-prototype/modelPickerPrototypeState.test.ts new file mode 100644 index 00000000000..5e10f7d1ff0 --- /dev/null +++ b/apps/mobile/src/features/threads/model-picker-prototype/modelPickerPrototypeState.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasDeliberateGestureTravel, takeUniquePaletteIds } from "./modelPickerPrototypeState"; + +describe("takeUniquePaletteIds", () => { + it("deduplicates before applying the palette limit", () => { + expect([...takeUniquePaletteIds(["b", "a", "b", "c", "d"], 4)]).toEqual(["b", "a", "c", "d"]); + }); +}); + +describe("hasDeliberateGestureTravel", () => { + it("does not treat the trigger's width as finger movement", () => { + const touchDown = { x: 170, y: 100 }; + + expect(hasDeliberateGestureTravel(touchDown, touchDown, 16)).toBe(false); + expect(hasDeliberateGestureTravel(touchDown, { x: 180, y: 100 }, 16)).toBe(false); + expect(hasDeliberateGestureTravel(touchDown, { x: 186, y: 100 }, 16)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/model-picker-prototype/modelPickerPrototypeState.ts b/apps/mobile/src/features/threads/model-picker-prototype/modelPickerPrototypeState.ts new file mode 100644 index 00000000000..77f92f18b2d --- /dev/null +++ b/apps/mobile/src/features/threads/model-picker-prototype/modelPickerPrototypeState.ts @@ -0,0 +1,20 @@ +export type GesturePoint = { + readonly x: number; + readonly y: number; +}; + +export function takeUniquePaletteIds( + ids: readonly string[], + maximumChoices: number, +): ReadonlySet { + return new Set([...new Set(ids)].slice(0, maximumChoices)); +} + +export function hasDeliberateGestureTravel( + start: GesturePoint | null, + current: GesturePoint, + minimumTravel: number, +): boolean { + if (!start) return false; + return Math.hypot(current.x - start.x, current.y - start.y) >= minimumTravel; +}