Skip to content

Commit 623010a

Browse files
author
Test
committed
🤖 feat: add model picker and shared catalog to mobile app
- Import KNOWN_MODELS from desktop constants for single source of truth - Add modelCatalog.ts with validation, display formatting, and LRU sanitization helpers - Implement useModelHistory hook with SecureStore persistence (max 8 recent models) - Create ModelPickerSheet component with search, recent chips, and grouped list by provider - Update useWorkspaceSettings/useWorkspaceDefaults to validate models against catalog - Wire picker into WorkspaceScreen above composer with model summary display - Add assertKnownModelId guard in sendMessage IPC call - Display human-friendly model names in streaming indicator Generated with `mux` Change-Id: I60fd0945056c26dea2e16d4c5d652cadf919c654 Signed-off-by: Test <test@example.com>
1 parent 0ffb55a commit 623010a

File tree

7 files changed

+515
-12
lines changed

7 files changed

+515
-12
lines changed

apps/mobile/src/api/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Constants from "expo-constants";
22
import { assert } from "../utils/assert";
3+
import { assertKnownModelId } from "../utils/modelCatalog";
34
import type { ChatStats } from "@shared/types/chatStats.ts";
45
import type { MuxMessage } from "@shared/types/message.ts";
56
import type {
@@ -279,6 +280,7 @@ export function createClient(cfg: CmuxMobileClientConfig = {}) {
279280
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
280281
> => {
281282
try {
283+
assertKnownModelId(options.model);
282284
assert(typeof message === "string" && message.trim().length > 0, "message required");
283285

284286
// If workspaceId is null, we're creating a new workspace
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import type { JSX } from "react";
2+
import { useMemo, useState } from "react";
3+
import {
4+
FlatList,
5+
Modal,
6+
Pressable,
7+
StyleSheet,
8+
TextInput,
9+
View,
10+
} from "react-native";
11+
import { Ionicons } from "@expo/vector-icons";
12+
import { useTheme } from "../theme";
13+
import { ThemedText } from "./ThemedText";
14+
import {
15+
formatModelSummary,
16+
getModelDisplayName,
17+
isKnownModelId,
18+
listKnownModels,
19+
} from "../utils/modelCatalog";
20+
21+
const ALL_MODELS = listKnownModels();
22+
23+
type KnownModel = (typeof ALL_MODELS)[number];
24+
25+
interface ModelPickerSheetProps {
26+
visible: boolean;
27+
onClose: () => void;
28+
selectedModel: string;
29+
onSelect: (modelId: string) => void;
30+
recentModels: string[];
31+
}
32+
33+
export function ModelPickerSheet(props: ModelPickerSheetProps): JSX.Element {
34+
const theme = useTheme();
35+
const [query, setQuery] = useState("");
36+
37+
const filteredModels = useMemo(() => {
38+
const normalized = query.trim().toLowerCase();
39+
if (!normalized) {
40+
return ALL_MODELS;
41+
}
42+
return ALL_MODELS.filter((model) => {
43+
const name = model.providerModelId.toLowerCase();
44+
const provider = model.provider.toLowerCase();
45+
return name.includes(normalized) || provider.includes(normalized);
46+
});
47+
}, [query]);
48+
49+
const recentModels = useMemo(() => {
50+
return props.recentModels.filter(isKnownModelId);
51+
}, [props.recentModels]);
52+
53+
const handleSelect = (modelId: string) => {
54+
props.onSelect(modelId);
55+
};
56+
57+
return (
58+
<Modal
59+
visible={props.visible}
60+
animationType="slide"
61+
presentationStyle="pageSheet"
62+
onRequestClose={props.onClose}
63+
>
64+
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
65+
<View style={styles.header}>
66+
<ThemedText variant="titleMedium" weight="semibold">
67+
Choose a model
68+
</ThemedText>
69+
<Pressable onPress={props.onClose} style={styles.closeButton}>
70+
<Ionicons name="close" size={20} color={theme.colors.foregroundPrimary} />
71+
</Pressable>
72+
</View>
73+
74+
<View
75+
style={[
76+
styles.searchWrapper,
77+
{ borderColor: theme.colors.inputBorder, backgroundColor: theme.colors.inputBackground },
78+
]}
79+
>
80+
<Ionicons name="search" size={16} color={theme.colors.foregroundMuted} />
81+
<TextInput
82+
value={query}
83+
onChangeText={setQuery}
84+
placeholder="Search models"
85+
placeholderTextColor={theme.colors.foregroundMuted}
86+
style={[styles.searchInput, { color: theme.colors.foregroundPrimary }]}
87+
autoCapitalize="none"
88+
autoCorrect={false}
89+
/>
90+
{query.length > 0 && (
91+
<Pressable onPress={() => setQuery("")}>
92+
<Ionicons name="close-circle" size={16} color={theme.colors.foregroundMuted} />
93+
</Pressable>
94+
)}
95+
</View>
96+
97+
{recentModels.length > 0 && (
98+
<View style={styles.section}>
99+
<ThemedText variant="label" style={{ color: theme.colors.foregroundMuted }}>
100+
Recent
101+
</ThemedText>
102+
<View style={styles.recentChips}>
103+
{recentModels.map((modelId) => (
104+
<Pressable
105+
key={modelId}
106+
onPress={() => handleSelect(modelId)}
107+
style={({ pressed }) => [
108+
styles.chip,
109+
{
110+
backgroundColor:
111+
props.selectedModel === modelId
112+
? theme.colors.accent
113+
: theme.colors.surfaceSecondary,
114+
opacity: pressed ? 0.8 : 1,
115+
},
116+
]}
117+
>
118+
<ThemedText
119+
variant="caption"
120+
style={{
121+
color:
122+
props.selectedModel === modelId
123+
? theme.colors.foregroundInverted
124+
: theme.colors.foregroundPrimary,
125+
fontWeight: "600",
126+
}}
127+
>
128+
{getModelDisplayName(modelId)}
129+
</ThemedText>
130+
</Pressable>
131+
))}
132+
</View>
133+
</View>
134+
)}
135+
136+
<FlatList
137+
data={filteredModels}
138+
keyExtractor={(item) => item.id}
139+
renderItem={({ item }) => (
140+
<Pressable
141+
onPress={() => handleSelect(item.id)}
142+
style={({ pressed }) => [
143+
styles.listItem,
144+
{
145+
backgroundColor: pressed
146+
? theme.colors.surfaceSecondary
147+
: theme.colors.background,
148+
},
149+
]}
150+
>
151+
<View style={{ flex: 1 }}>
152+
<ThemedText weight="semibold">{getModelDisplayName(item.id)}</ThemedText>
153+
<ThemedText variant="caption" style={{ color: theme.colors.foregroundMuted }}>
154+
{formatModelSummary(item.id)}
155+
</ThemedText>
156+
</View>
157+
{props.selectedModel === item.id && (
158+
<Ionicons name="checkmark-circle" size={20} color={theme.colors.accent} />
159+
)}
160+
</Pressable>
161+
)}
162+
ItemSeparatorComponent={() => (
163+
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.border }} />
164+
)}
165+
ListEmptyComponent={() => (
166+
<View style={{ padding: 24 }}>
167+
<ThemedText variant="caption" style={{ textAlign: "center" }}>
168+
No models match "{query}"
169+
</ThemedText>
170+
</View>
171+
)}
172+
style={{ flex: 1 }}
173+
/>
174+
</View>
175+
</Modal>
176+
);
177+
}
178+
179+
const styles = StyleSheet.create({
180+
container: {
181+
flex: 1,
182+
paddingHorizontal: 16,
183+
paddingTop: 12,
184+
},
185+
header: {
186+
flexDirection: "row",
187+
alignItems: "center",
188+
justifyContent: "space-between",
189+
marginBottom: 12,
190+
},
191+
closeButton: {
192+
padding: 8,
193+
},
194+
searchWrapper: {
195+
flexDirection: "row",
196+
alignItems: "center",
197+
borderWidth: 1,
198+
borderRadius: 8,
199+
paddingHorizontal: 12,
200+
paddingVertical: 8,
201+
gap: 8,
202+
marginBottom: 16,
203+
},
204+
searchInput: {
205+
flex: 1,
206+
fontSize: 16,
207+
},
208+
section: {
209+
marginBottom: 12,
210+
},
211+
recentChips: {
212+
flexDirection: "row",
213+
flexWrap: "wrap",
214+
gap: 8,
215+
marginTop: 8,
216+
},
217+
chip: {
218+
paddingVertical: 6,
219+
paddingHorizontal: 12,
220+
borderRadius: 999,
221+
},
222+
listItem: {
223+
flexDirection: "row",
224+
alignItems: "center",
225+
paddingVertical: 14,
226+
paddingHorizontal: 4,
227+
},
228+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useCallback, useEffect, useState } from "react";
2+
import * as SecureStore from "expo-secure-store";
3+
import {
4+
DEFAULT_MODEL_ID,
5+
assertKnownModelId,
6+
sanitizeModelSequence,
7+
} from "../utils/modelCatalog";
8+
9+
const STORAGE_KEY = "cmux.models.recent";
10+
const MAX_RECENT_MODELS = 8;
11+
const FALLBACK_RECENTS = [DEFAULT_MODEL_ID];
12+
13+
async function readStoredModels(): Promise<string[]> {
14+
try {
15+
const stored = await SecureStore.getItemAsync(STORAGE_KEY);
16+
if (!stored) {
17+
return FALLBACK_RECENTS.slice();
18+
}
19+
const parsed = JSON.parse(stored);
20+
if (!Array.isArray(parsed)) {
21+
return FALLBACK_RECENTS.slice();
22+
}
23+
return sanitizeModelSequence(parsed).slice(0, MAX_RECENT_MODELS);
24+
} catch (error) {
25+
if (process.env.NODE_ENV !== "production") {
26+
console.warn("Failed to read model history", error);
27+
}
28+
return FALLBACK_RECENTS.slice();
29+
}
30+
}
31+
32+
async function persistModels(models: string[]): Promise<void> {
33+
try {
34+
await SecureStore.setItemAsync(STORAGE_KEY, JSON.stringify(models.slice(0, MAX_RECENT_MODELS)));
35+
} catch (error) {
36+
if (process.env.NODE_ENV !== "production") {
37+
console.warn("Failed to persist model history", error);
38+
}
39+
}
40+
}
41+
42+
export function useModelHistory() {
43+
const [recentModels, setRecentModels] = useState<string[]>(FALLBACK_RECENTS.slice());
44+
const [isLoaded, setIsLoaded] = useState(false);
45+
46+
useEffect(() => {
47+
let cancelled = false;
48+
readStoredModels().then((models) => {
49+
if (cancelled) {
50+
return;
51+
}
52+
setRecentModels(models);
53+
setIsLoaded(true);
54+
});
55+
return () => {
56+
cancelled = true;
57+
};
58+
}, []);
59+
60+
const updateModels = useCallback((updater: (current: string[]) => string[]) => {
61+
setRecentModels((prev) => {
62+
const next = updater(prev);
63+
void persistModels(next);
64+
return next;
65+
});
66+
}, []);
67+
68+
const addRecentModel = useCallback(
69+
(modelId: string) => {
70+
assertKnownModelId(modelId);
71+
updateModels((prev) => sanitizeModelSequence([modelId, ...prev]).slice(0, MAX_RECENT_MODELS));
72+
},
73+
[updateModels]
74+
);
75+
76+
const replaceRecentModels = useCallback(
77+
(models: string[]) => {
78+
updateModels(() => sanitizeModelSequence(models).slice(0, MAX_RECENT_MODELS));
79+
},
80+
[updateModels]
81+
);
82+
83+
return {
84+
recentModels,
85+
isLoaded,
86+
addRecentModel,
87+
replaceRecentModels,
88+
};
89+
}

apps/mobile/src/hooks/useWorkspaceDefaults.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useCallback, useEffect, useState } from "react";
22
import * as SecureStore from "expo-secure-store";
33
import type { ThinkingLevel, WorkspaceMode } from "../types/settings";
4+
import {
5+
DEFAULT_MODEL_ID,
6+
assertKnownModelId,
7+
isKnownModelId,
8+
} from "../utils/modelCatalog";
49

510
export interface GlobalDefaults {
611
defaultMode: WorkspaceMode;
@@ -17,7 +22,7 @@ const STORAGE_KEY_1M_CONTEXT = "com.coder.cmux.defaults.use1MContext";
1722

1823
const DEFAULT_MODE: WorkspaceMode = "exec";
1924
const DEFAULT_REASONING: ThinkingLevel = "off";
20-
const DEFAULT_MODEL = "anthropic:claude-sonnet-4-5";
25+
const DEFAULT_MODEL = DEFAULT_MODEL_ID;
2126
const DEFAULT_1M_CONTEXT = false;
2227

2328
async function readGlobalMode(): Promise<WorkspaceMode> {
@@ -77,8 +82,10 @@ async function writeGlobalReasoning(level: ThinkingLevel): Promise<void> {
7782
async function readGlobalModel(): Promise<string> {
7883
try {
7984
// Try new key first
80-
let value = await SecureStore.getItemAsync(STORAGE_KEY_MODEL);
81-
if (value) return value;
85+
const value = await SecureStore.getItemAsync(STORAGE_KEY_MODEL);
86+
if (value && isKnownModelId(value)) {
87+
return value;
88+
}
8289

8390
return DEFAULT_MODEL;
8491
} catch (error) {
@@ -91,6 +98,7 @@ async function readGlobalModel(): Promise<string> {
9198

9299
async function writeGlobalModel(model: string): Promise<void> {
93100
try {
101+
assertKnownModelId(model);
94102
await SecureStore.setItemAsync(STORAGE_KEY_MODEL, model);
95103
} catch (error) {
96104
if (process.env.NODE_ENV !== "production") {
@@ -183,6 +191,7 @@ export function useWorkspaceDefaults(): {
183191
}, []);
184192

185193
const setDefaultModel = useCallback((model: string) => {
194+
assertKnownModelId(model);
186195
setDefaultModelState(model);
187196
void writeGlobalModel(model);
188197
}, []);

0 commit comments

Comments
 (0)