feat(gemini): support mid-session model change (#379)

This commit is contained in:
Junmo Kim
2026-04-01 11:15:11 +08:00
committed by GitHub
parent fa0ce1ca94
commit 4eb88c5d7e
13 changed files with 277 additions and 26 deletions
@@ -20,7 +20,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
import { applySuggestion } from '@/utils/applySuggestion'
import { usePlatform } from '@/hooks/usePlatform'
import { usePWAInstall } from '@/hooks/usePWAInstall'
import { isClaudeFlavor } from '@/lib/agentFlavorUtils'
import { isClaudeFlavor, supportsModelChange } from '@/lib/agentFlavorUtils'
import { markSkillUsed } from '@/lib/recent-skills'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
@@ -28,7 +28,7 @@ import { StatusBar } from '@/components/AssistantChat/StatusBar'
import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem'
import { useTranslation } from '@/lib/use-translation'
import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions'
import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions'
import { getClaudeComposerEffortOptions } from './claudeEffortOptions'
export interface TextInputState {
@@ -266,8 +266,8 @@ export function HappyComposer(props: {
[agentFlavor]
)
const claudeModelOptions = useMemo(
() => getClaudeComposerModelOptions(model),
[model]
() => getModelOptionsForFlavor(agentFlavor, model),
[agentFlavor, model]
)
const claudeEffortOptions = useMemo(
() => getClaudeComposerEffortOptions(effort),
@@ -352,9 +352,9 @@ export function HappyComposer(props: {
useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && isClaudeFlavor(agentFlavor)) {
if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && supportsModelChange(agentFlavor)) {
e.preventDefault()
onModelChange(getNextClaudeComposerModel(model))
onModelChange(getNextModelForFlavor(agentFlavor, model))
haptic('light')
}
}
@@ -439,7 +439,7 @@ export function HappyComposer(props: {
const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0)
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0)
const showModelSettings = Boolean(onModelChange && isClaudeFlavor(agentFlavor))
const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor))
const showEffortSettings = Boolean(onEffortChange && isClaudeFlavor(agentFlavor))
const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)
const showAbortButton = true
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions'
describe('getModelOptionsForFlavor', () => {
it('returns Gemini model options for gemini flavor', () => {
const options = getModelOptionsForFlavor('gemini')
expect(options[0]).toEqual({ value: null, label: 'Default' })
expect(options.some((o) => o.value === 'gemini-3-flash-preview')).toBe(true)
expect(options.some((o) => o.value === 'gemini-2.5-flash')).toBe(true)
})
it('returns Claude model options for claude flavor', () => {
const options = getModelOptionsForFlavor('claude')
expect(options[0]).toEqual({ value: null, label: 'Auto' })
expect(options.some((o) => o.value === 'sonnet')).toBe(true)
expect(options.some((o) => o.value === 'opus')).toBe(true)
})
it('includes custom Gemini model from env/config in options', () => {
const options = getModelOptionsForFlavor('gemini', 'gemini-custom-experiment')
expect(options.some((o) => o.value === 'gemini-custom-experiment')).toBe(true)
})
it('does not duplicate a preset Gemini model', () => {
const options = getModelOptionsForFlavor('gemini', 'gemini-2.5-flash')
const flashCount = options.filter((o) => o.value === 'gemini-2.5-flash').length
expect(flashCount).toBe(1)
})
})
describe('getNextModelForFlavor', () => {
it('cycles Gemini models', () => {
const next = getNextModelForFlavor('gemini', null)
expect(next).not.toBeNull()
})
it('cycles Claude models', () => {
const next = getNextModelForFlavor('claude', null)
expect(next).not.toBeNull()
})
})
@@ -0,0 +1,40 @@
import { MODEL_OPTIONS } from '@/components/NewSession/types'
import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions'
import type { ClaudeComposerModelOption } from './claudeModelOptions'
export type ModelOption = ClaudeComposerModelOption
function getGeminiModelOptions(currentModel?: string | null): ModelOption[] {
const options = MODEL_OPTIONS.gemini.map((m) => ({
value: m.value === 'auto' ? null : m.value,
label: m.label
}))
const normalized = currentModel?.trim() || null
if (normalized && !options.some((o) => o.value === normalized)) {
options.splice(1, 0, { value: normalized, label: normalized })
}
return options
}
function getNextGeminiModel(currentModel?: string | null): string | null {
const options = getGeminiModelOptions(currentModel)
const currentIndex = options.findIndex((o) => o.value === (currentModel ?? null))
if (currentIndex === -1) {
return options[0]?.value ?? null
}
return options[(currentIndex + 1) % options.length]?.value ?? null
}
export function getModelOptionsForFlavor(flavor: string | undefined | null, currentModel?: string | null): ModelOption[] {
if (flavor === 'gemini') {
return getGeminiModelOptions(currentModel)
}
return getClaudeComposerModelOptions(currentModel)
}
export function getNextModelForFlavor(flavor: string | undefined | null, currentModel?: string | null): string | null {
if (flavor === 'gemini') {
return getNextGeminiModel(currentModel)
}
return getNextClaudeComposerModel(currentModel)
}
+4 -6
View File
@@ -1,3 +1,5 @@
import { GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS } from '@hapi/protocol'
export type AgentType = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
export type SessionType = 'simple' | 'worktree'
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh'
@@ -23,12 +25,8 @@ export const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]
],
cursor: [],
gemini: [
{ value: 'auto', label: 'Auto' },
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'auto', label: 'Default' },
...GEMINI_MODEL_PRESETS.map(m => ({ value: m, label: GEMINI_MODEL_LABELS[m] })),
],
opencode: [],
}
+4
View File
@@ -13,3 +13,7 @@ export function isCursorFlavor(flavor?: string | null): boolean {
export function isKnownFlavor(flavor?: string | null): boolean {
return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) || isCursorFlavor(flavor)
}
export function supportsModelChange(flavor?: string | null): boolean {
return flavor === 'claude' || flavor === 'gemini'
}