feat: add reasoning effort picker to session screen (#47) (#51)

* feat(#49): improve project picker with recents + server projects

- New Session modal now shows:
  - Current project as tappable row (tap to create session immediately)
  - Recent Projects section: list of previously used dirs as tappable rows
  - Server Projects section: projects known to opencode server (from /project API)
  - Manual path input as fallback (unchanged behavior)
- Modal body is now scrollable to handle long lists
- All selection paths call addRecentDirectory to keep recents up to date
- TypeScript clean (pre-existing VariantPicker.tsx error unrelated)

* feat: add reasoning effort (variant) picker to session screen (#47)

- Add VariantPicker bottom sheet component (low/medium/high/auto)
- Add variant state to catalog store, reset on model change
- Pass variant through sendMessage -> sdk.session.prompt()
- Add reasoning chip to toolbar, shown only for models with variants
- Parse model.variants from provider API response in catalog and sdk types

API field: variant in POST /session/:id/prompt_async
Server maps variant -> reasoningEffort via model variant config

* fix(models): preserve reasoning effort across messages

Reset the selected variant only when the provider/model pair actually changes, including catalog reloads and agent-driven model switches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Den
2026-07-16 17:32:56 -07:00
committed by GitHub
parent 36421b4d30
commit c1736bd426
6 changed files with 192 additions and 6 deletions

View File

@@ -26,6 +26,7 @@ import {
StatusIndicator, StatusIndicator,
SlashPopover, SlashPopover,
ModelPicker, ModelPicker,
VariantPicker,
ImageAttachments, ImageAttachments,
SessionInfo, SessionInfo,
type SlashCommand, type SlashCommand,
@@ -78,6 +79,7 @@ export default function SessionScreen() {
const flatListRef = useRef<FlatList>(null) const flatListRef = useRef<FlatList>(null)
const modelSheetRef = useRef<BottomSheet>(null) const modelSheetRef = useRef<BottomSheet>(null)
const variantSheetRef = useRef<BottomSheet>(null)
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([]) const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false) const [showInfo, setShowInfo] = useState(false)
@@ -115,6 +117,8 @@ export default function SessionScreen() {
const agent = catalog.agent || "" const agent = catalog.agent || ""
const model = catalog.model const model = catalog.model
const setModel = catalog.setModel const setModel = catalog.setModel
const variant = catalog.variant
const setVariant = catalog.setVariant
const cycleAgent = catalog.cycleAgent const cycleAgent = catalog.cycleAgent
// Permission & question state // Permission & question state
@@ -338,7 +342,7 @@ export default function SessionScreen() {
// Messages are queued server-side when the session is busy. // Messages are queued server-side when the session is busy.
// No need to abort - just send and it will be processed after current response. // No need to abort - just send and it will be processed after current response.
try { try {
await sendMessage(text, model || undefined, agent || undefined, files) await sendMessage(text, model || undefined, agent || undefined, files, variant || undefined)
} catch (err) { } catch (err) {
console.error("Send failed:", err) console.error("Send failed:", err)
// Restore the user's text and attachments so their input isn't lost. // Restore the user's text and attachments so their input isn't lost.
@@ -456,6 +460,14 @@ export default function SessionScreen() {
const agentColor = currentAgent?.color || "#8b5cf6" const agentColor = currentAgent?.color || "#8b5cf6"
const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default" const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default"
// Variants for current model (for reasoning effort picker)
const currentModelVariants = useMemo(() => {
if (!model) return undefined
const provider = providers.find((p) => p.id === model.providerID)
const found = provider?.models.find((m) => m.id === model.modelID)
return found?.variants
}, [model, providers])
return ( return (
<> <>
<Stack.Screen <Stack.Screen
@@ -611,6 +623,18 @@ export default function SessionScreen() {
{modelLabel} {modelLabel}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{currentModelVariants && Object.keys(currentModelVariants).length > 0 && (
<TouchableOpacity
style={[s.variantChip, isDark && s.variantChipDark, variant && s.variantChipActive]}
onPress={() => variantSheetRef.current?.expand()}
>
<Ionicons name="flash-outline" size={14} color={variant ? "#8b5cf6" : isDark ? "#888888" : "#666666"} />
<Text style={[s.variantLabel, isDark && s.metaDark, variant && s.variantLabelActive]} numberOfLines={1}>
{variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Auto"}
</Text>
</TouchableOpacity>
)}
</View> </View>
{/* Attachment preview */} {/* Attachment preview */}
@@ -678,6 +702,15 @@ export default function SessionScreen() {
isDark={isDark} isDark={isDark}
onSelect={handleModelSelect} onSelect={handleModelSelect}
/> />
{/* Reasoning effort (variant) picker bottom sheet */}
<VariantPicker
sheetRef={variantSheetRef}
variants={currentModelVariants}
selected={variant}
isDark={isDark}
onSelect={setVariant}
/>
</> </>
) )
} }
@@ -771,6 +804,21 @@ const s = StyleSheet.create({
modelChipDark: { backgroundColor: "#1a1a1a" }, modelChipDark: { backgroundColor: "#1a1a1a" },
modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 }, modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 },
// Variant (reasoning effort) chip
variantChip: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
},
variantChipDark: { backgroundColor: "#1a1a1a" },
variantChipActive: { backgroundColor: "#f5f3ff" },
variantLabel: { fontSize: 12, color: "#666666" },
variantLabelActive: { color: "#8b5cf6" },
// Input // Input
inputContainer: { inputContainer: {
padding: 12, padding: 12,

View File

@@ -0,0 +1,107 @@
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList } from "@gorhom/bottom-sheet"
interface VariantOption {
id: string | null
label: string
description: string
}
interface Props {
variants: Record<string, { reasoningEffort?: string }> | undefined
selected: string | null
isDark: boolean
onSelect: (variant: string | null) => void
sheetRef: React.RefObject<BottomSheet | null>
}
const AUTO_OPTION: VariantOption = {
id: null,
label: "Auto",
description: "Use model default reasoning",
}
const EFFORT_DESCRIPTIONS: Record<string, string> = {
low: "Faster, less thorough reasoning",
medium: "Balanced reasoning and speed",
high: "Deep, thorough reasoning",
}
export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }: Props) {
const options: VariantOption[] = [
AUTO_OPTION,
...Object.keys(variants || {}).map((id) => ({
id,
label: id.charAt(0).toUpperCase() + id.slice(1),
description: EFFORT_DESCRIPTIONS[id] ?? id,
})),
]
const handleSelect = (id: string | null) => {
onSelect(id)
sheetRef.current?.close()
}
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={["30%", "50%"]}
enablePanDownToClose
backgroundStyle={isDark ? s.sheetDark : s.sheet}
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
)}
>
<View style={s.header}>
<Text style={[s.title, isDark && s.textWhite]}>Reasoning Effort</Text>
</View>
<BottomSheetFlatList
data={options}
keyExtractor={(item: VariantOption) => item.id ?? "auto"}
renderItem={({ item }: { item: VariantOption }) => {
const active = item.id === selected
return (
<TouchableOpacity
style={[s.row, isDark && s.rowDark, active && (isDark ? s.rowSelectedDark : s.rowSelected)]}
onPress={() => handleSelect(item.id)}
>
<View style={s.rowText}>
<Text style={[s.rowName, isDark && s.textWhite]}>{item.label}</Text>
<Text style={[s.rowDesc, isDark && s.metaDark]}>{item.description}</Text>
</View>
{active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
</TouchableOpacity>
)
}}
contentContainerStyle={s.content}
/>
</BottomSheet>
)
}
const s = StyleSheet.create({
sheet: { backgroundColor: "#ffffff" },
sheetDark: { backgroundColor: "#1a1a1a" },
header: { paddingHorizontal: 16, paddingBottom: 12 },
title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
metaDark: { color: "#666666" },
content: { paddingBottom: 40 },
row: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e5e5",
},
rowDark: { borderBottomColor: "#2a2a2a" },
rowSelected: { backgroundColor: "#f5f3ff" },
rowSelectedDark: { backgroundColor: "#1f1a2e" },
rowText: { flex: 1 },
rowName: { fontSize: 15, fontWeight: "600", color: "#0a0a0a" },
rowDesc: { fontSize: 12, color: "#999999", marginTop: 2 },
})

View File

@@ -7,6 +7,7 @@ export { QuestionPrompt } from "./QuestionPrompt"
export { StatusIndicator } from "./StatusIndicator" export { StatusIndicator } from "./StatusIndicator"
export { SlashPopover, type SlashCommand } from "./SlashPopover" export { SlashPopover, type SlashCommand } from "./SlashPopover"
export { ModelPicker } from "./ModelPicker" export { ModelPicker } from "./ModelPicker"
export { VariantPicker } from "./VariantPicker"
export { ImageAttachments, type Attachment } from "./ImageAttachments" export { ImageAttachments, type Attachment } from "./ImageAttachments"
export { DirectorySwitcher } from "./DirectorySwitcher" export { DirectorySwitcher } from "./DirectorySwitcher"
export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet" export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet"

View File

@@ -418,6 +418,7 @@ export function createClient(config: ClientConfig) {
cost?: { input: number; output: number } cost?: { input: number; output: number }
limit: { context: number; output: number } limit: { context: number; output: number }
status?: "alpha" | "beta" | "deprecated" | "active" status?: "alpha" | "beta" | "deprecated" | "active"
variants?: Record<string, { reasoningEffort?: string }>
} }
> >
}> }>

View File

@@ -9,6 +9,7 @@ export interface ProviderModel {
reasoning: boolean reasoning: boolean
attachment: boolean attachment: boolean
limit?: { context: number; output: number } limit?: { context: number; output: number }
variants?: Record<string, { reasoningEffort?: string }>
} }
export interface Provider { export interface Provider {
@@ -23,6 +24,10 @@ interface ModelSelection {
modelID: string modelID: string
} }
function sameModel(left: ModelSelection | null, right: ModelSelection | null) {
return left?.providerID === right?.providerID && left?.modelID === right?.modelID
}
interface CatalogState { interface CatalogState {
agents: Agent[] agents: Agent[]
commands: Command[] commands: Command[]
@@ -31,12 +36,14 @@ interface CatalogState {
// Current selections // Current selections
agent: string // agent name, e.g. "build" agent: string // agent name, e.g. "build"
model: ModelSelection | null model: ModelSelection | null
variant: string | null // model variant for reasoning effort (e.g. "low", "medium", "high")
loaded: boolean loaded: boolean
// Actions // Actions
load: () => Promise<void> load: () => Promise<void>
setAgent: (name: string) => void setAgent: (name: string) => void
setModel: (selection: ModelSelection | null) => void setModel: (selection: ModelSelection | null) => void
setVariant: (variant: string | null) => void
cycleAgent: (direction?: 1 | -1) => void cycleAgent: (direction?: 1 | -1) => void
} }
@@ -47,6 +54,7 @@ export const useCatalog = create<CatalogState>((set, get) => ({
defaults: {}, defaults: {},
agent: "", agent: "",
model: null, model: null,
variant: null,
loaded: false, loaded: false,
load: async () => { load: async () => {
@@ -81,6 +89,7 @@ export const useCatalog = create<CatalogState>((set, get) => ({
reasoning: m.reasoning ?? false, reasoning: m.reasoning ?? false,
attachment: m.attachment ?? false, attachment: m.attachment ?? false,
limit: m.limit, limit: m.limit,
variants: m.variants,
})), })),
})) }))
.filter((p) => p.models.length > 0) .filter((p) => p.models.length > 0)
@@ -104,17 +113,36 @@ export const useCatalog = create<CatalogState>((set, get) => ({
agentModel: defaultAgent?.model || null, agentModel: defaultAgent?.model || null,
}) })
set({ agents: visible, commands, providers, defaults, agent, model, loaded: true }) set((state) => ({
agents: visible,
commands,
providers,
defaults,
agent,
model,
variant: sameModel(state.model, model) ? state.variant : null,
loaded: true,
}))
}, },
setAgent: (name) => { setAgent: (name) => {
const match = get().agents.find((a) => a.name === name) const match = get().agents.find((a) => a.name === name)
if (!match) return if (!match) return
const model = match.model || get().model const model = match.model || get().model
set({ agent: name, model }) set((state) => ({
agent: name,
model,
variant: sameModel(state.model, model) ? state.variant : null,
}))
}, },
setModel: (selection) => set({ model: selection }), setModel: (selection) =>
set((state) => ({
model: selection,
variant: sameModel(state.model, selection) ? state.variant : null,
})),
setVariant: (variant) => set({ variant }),
cycleAgent: (direction = 1) => { cycleAgent: (direction = 1) => {
const { agents, agent } = get() const { agents, agent } = get()

View File

@@ -45,6 +45,7 @@ interface SessionsState {
model?: { providerID: string; modelID: string }, model?: { providerID: string; modelID: string },
agent?: string, agent?: string,
files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>, files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>,
variant?: string,
) => Promise<void> ) => Promise<void>
abortSession: () => Promise<void> abortSession: () => Promise<void>
refreshMessages: () => Promise<void> refreshMessages: () => Promise<void>
@@ -222,7 +223,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
} }
}, },
sendMessage: async (text, model, agent, files) => { sendMessage: async (text, model, agent, files, variant) => {
const client = clientFor(get().currentSession?.directory) const client = clientFor(get().currentSession?.directory)
const session = get().currentSession const session = get().currentSession
if (!client || !session) { if (!client || !session) {
@@ -287,7 +288,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
} }
// Fire and forget - SSE events will update messages/parts/status in real-time // Fire and forget - SSE events will update messages/parts/status in real-time
client.session.prompt(session.id, { parts: promptParts, model, agent }).catch((err) => { client.session.prompt(session.id, { parts: promptParts, model, agent, variant }).catch((err) => {
console.error("Failed to send message:", err) console.error("Failed to send message:", err)
// The user may have switched sessions while this send was in flight. Clear // The user may have switched sessions while this send was in flight. Clear
// the sending flag for the session we actually sent to (keyed by id, safe), // the sending flag for the session we actually sent to (keyed by id, safe),