Skip to content

Commit d72366d

Browse files
grichaclaude
andcommitted
Add model selection to mobile app settings and chat
- Add ModelInfo type and listModels API endpoint - Add model picker to settings for Claude Code and OpenCode - Add model selector in chat header with per-agent behavior - Claude Code: changing model mid-session starts new session - OpenCode: model locked after session starts - Update CodingAgents to use zen_token (match web) - Add build/ to mobile gitignore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 99d944e commit d72366d

File tree

4 files changed

+231
-24
lines changed

4 files changed

+231
-24
lines changed

mobile/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ yarn-error.*
3939
# generated native folders
4040
/ios
4141
/android
42+
build/

mobile/src/lib/api.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,24 @@ export interface Scripts {
4646

4747
export interface CodingAgents {
4848
opencode?: {
49-
api_key?: string
50-
api_base_url?: string
49+
zen_token?: string
50+
model?: string
5151
}
5252
github?: {
5353
token?: string
5454
}
5555
claude_code?: {
5656
oauth_token?: string
57+
model?: string
5758
}
5859
}
5960

61+
export interface ModelInfo {
62+
id: string
63+
name: string
64+
description?: string
65+
}
66+
6067
export type AgentType = 'claude-code' | 'opencode' | 'codex'
6168

6269
export interface SessionInfo {
@@ -185,6 +192,9 @@ function createClient() {
185192
update: (input: CodingAgents) => Promise<CodingAgents>
186193
}
187194
}
195+
models: {
196+
list: (input: { agentType: 'claude-code' | 'opencode'; workspaceName?: string }) => Promise<{ models: ModelInfo[] }>
197+
}
188198
}>(link)
189199
}
190200

@@ -244,4 +254,6 @@ export const api = {
244254
updateScripts: (data: Scripts) => client.config.scripts.update(data),
245255
getAgents: () => client.config.agents.get(),
246256
updateAgents: (data: CodingAgents) => client.config.agents.update(data),
257+
listModels: (agentType: 'claude-code' | 'opencode', workspaceName?: string) =>
258+
client.models.list({ agentType, workspaceName }),
247259
}

mobile/src/screens/SessionChatScreen.tsx

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,17 @@ import {
1010
Platform,
1111
ActivityIndicator,
1212
Keyboard,
13+
ActionSheetIOS,
1314
} from 'react-native'
1415
import { useSafeAreaInsets } from 'react-native-safe-area-context'
1516
import { useQuery } from '@tanstack/react-query'
16-
import { api, AgentType, getChatUrl, HOST_WORKSPACE_NAME } from '../lib/api'
17+
import { api, AgentType, getChatUrl, HOST_WORKSPACE_NAME, ModelInfo } from '../lib/api'
18+
19+
const FALLBACK_CLAUDE_MODELS: ModelInfo[] = [
20+
{ id: 'sonnet', name: 'Sonnet' },
21+
{ id: 'opus', name: 'Opus' },
22+
{ id: 'haiku', name: 'Haiku' },
23+
]
1724

1825
interface MessagePart {
1926
type: 'text' | 'tool_use' | 'tool_result'
@@ -222,11 +229,41 @@ export function SessionChatScreen({ route, navigation }: any) {
222229
const [hasMoreMessages, setHasMoreMessages] = useState(false)
223230
const [messageOffset, setMessageOffset] = useState(0)
224231
const [isLoadingMore, setIsLoadingMore] = useState(false)
232+
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined)
225233
const wsRef = useRef<WebSocket | null>(null)
226234
const flatListRef = useRef<FlatList>(null)
227235
const streamingPartsRef = useRef<MessagePart[]>([])
228236
const messageIdCounter = useRef(0)
229237
const hasLoadedInitial = useRef(false)
238+
const modelInitialized = useRef(false)
239+
240+
const fetchAgentType = agentType === 'opencode' ? 'opencode' : 'claude-code'
241+
242+
const { data: modelsData } = useQuery({
243+
queryKey: ['models', fetchAgentType],
244+
queryFn: () => api.listModels(fetchAgentType, workspaceName),
245+
})
246+
247+
const { data: agentsConfig } = useQuery({
248+
queryKey: ['agents'],
249+
queryFn: api.getAgents,
250+
})
251+
252+
const availableModels = useMemo(() => {
253+
if (modelsData?.models?.length) return modelsData.models
254+
if (fetchAgentType === 'claude-code') return FALLBACK_CLAUDE_MODELS
255+
return []
256+
}, [modelsData, fetchAgentType])
257+
258+
useEffect(() => {
259+
if (availableModels.length > 0 && !modelInitialized.current) {
260+
modelInitialized.current = true
261+
const configModel = fetchAgentType === 'opencode'
262+
? agentsConfig?.opencode?.model
263+
: agentsConfig?.claude_code?.model
264+
setSelectedModel(configModel || availableModels[0].id)
265+
}
266+
}, [availableModels, agentsConfig, fetchAgentType])
230267

231268
useEffect(() => {
232269
const showSub = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true))
@@ -470,11 +507,17 @@ export function SessionChatScreen({ route, navigation }: any) {
470507

471508
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100)
472509

473-
wsRef.current.send(JSON.stringify({
510+
const payload: Record<string, unknown> = {
474511
type: 'message',
475512
content: msg,
476513
sessionId: currentSessionId,
477-
}))
514+
}
515+
516+
if (selectedModel) {
517+
payload.model = selectedModel
518+
}
519+
520+
wsRef.current.send(JSON.stringify(payload))
478521
}
479522

480523
const interrupt = () => {
@@ -484,6 +527,40 @@ export function SessionChatScreen({ route, navigation }: any) {
484527
}
485528
}
486529

530+
const showModelPicker = () => {
531+
if (availableModels.length === 0) return
532+
if (isStreaming) return
533+
if (agentType === 'opencode' && currentSessionId) return
534+
535+
const options = [...availableModels.map(m => m.name), 'Cancel']
536+
ActionSheetIOS.showActionSheetWithOptions(
537+
{
538+
options,
539+
cancelButtonIndex: options.length - 1,
540+
title: 'Select Model',
541+
},
542+
(buttonIndex) => {
543+
if (buttonIndex < availableModels.length) {
544+
const newModel = availableModels[buttonIndex].id
545+
if (newModel !== selectedModel) {
546+
setSelectedModel(newModel)
547+
if (agentType !== 'opencode' && currentSessionId) {
548+
setCurrentSessionId(null)
549+
setMessages(prev => [...prev, {
550+
role: 'system',
551+
content: `Switching to model: ${availableModels[buttonIndex].name}`,
552+
id: `msg-model-${Date.now()}`,
553+
}])
554+
}
555+
}
556+
}
557+
}
558+
)
559+
}
560+
561+
const selectedModelName = availableModels.find(m => m.id === selectedModel)?.name || 'Model'
562+
const canChangeModel = !isStreaming && !(agentType === 'opencode' && currentSessionId)
563+
487564
const agentLabels: Record<AgentType, string> = {
488565
'claude-code': 'Claude Code',
489566
opencode: 'OpenCode',
@@ -517,7 +594,18 @@ export function SessionChatScreen({ route, navigation }: any) {
517594
</View>
518595
<Text style={styles.headerSubtitle}>{agentLabels[agentType as AgentType]}</Text>
519596
</View>
520-
<View style={styles.placeholder} />
597+
{availableModels.length > 0 && (
598+
<TouchableOpacity
599+
style={[styles.modelBtn, !canChangeModel && styles.modelBtnDisabled]}
600+
onPress={showModelPicker}
601+
disabled={!canChangeModel}
602+
>
603+
<Text style={[styles.modelBtnText, !canChangeModel && styles.modelBtnTextDisabled]}>
604+
{selectedModelName}
605+
</Text>
606+
</TouchableOpacity>
607+
)}
608+
{availableModels.length === 0 && <View style={styles.placeholder} />}
521609
</View>
522610

523611
<FlatList
@@ -639,6 +727,23 @@ const styles = StyleSheet.create({
639727
placeholder: {
640728
width: 44,
641729
},
730+
modelBtn: {
731+
backgroundColor: '#1c1c1e',
732+
paddingHorizontal: 10,
733+
paddingVertical: 6,
734+
borderRadius: 8,
735+
},
736+
modelBtnDisabled: {
737+
opacity: 0.5,
738+
},
739+
modelBtnText: {
740+
fontSize: 13,
741+
color: '#0a84ff',
742+
fontWeight: '500',
743+
},
744+
modelBtnTextDisabled: {
745+
color: '#8e8e93',
746+
},
642747
messageList: {
643748
padding: 16,
644749
flexGrow: 1,

0 commit comments

Comments
 (0)