* 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:
@@ -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,
|
||||||
|
|||||||
107
src/components/chat/VariantPicker.tsx
Normal file
107
src/components/chat/VariantPicker.tsx
Normal 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 },
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user