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