mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
647 lines
24 KiB
TypeScript
647 lines
24 KiB
TypeScript
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { OFFICIAL_MODELS } from '../../constants/modelCatalog'
|
|
import {
|
|
OPENAI_OFFICIAL_MODELS,
|
|
OPENAI_OFFICIAL_PROVIDER_ID,
|
|
} from '../../constants/openaiOfficialProvider'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useProviderStore } from '../../stores/providerStore'
|
|
import { DRAFT_RUNTIME_SELECTION_KEY, useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import type { SavedProvider } from '../../types/provider'
|
|
import type { RuntimeSelection } from '../../types/runtime'
|
|
import type { ModelInfo, ReasoningEffortLevel } from '../../types/settings'
|
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
|
import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
|
import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection'
|
|
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
|
|
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
|
|
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
|
|
import {
|
|
GROK_OFFICIAL_MODELS,
|
|
GROK_OFFICIAL_PROVIDER_ID,
|
|
} from '../../constants/grokOfficialProvider'
|
|
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
|
import { ReasoningEffortPopover } from './ReasoningEffortPopover'
|
|
|
|
type ProviderChoice = {
|
|
providerId: string | null
|
|
providerName: string
|
|
isDefault: boolean
|
|
models: ModelInfo[]
|
|
}
|
|
|
|
type Props = {
|
|
value?: string
|
|
onChange?: (modelId: string) => void
|
|
runtimeSelection?: RuntimeSelection
|
|
onRuntimeSelectionChange?: (selection: RuntimeSelection) => void
|
|
runtimeKey?: string
|
|
disabled?: boolean
|
|
compact?: boolean
|
|
fluid?: boolean
|
|
}
|
|
|
|
export type ModelSelectorHandle = {
|
|
open: () => void
|
|
}
|
|
|
|
type DropdownPosition = {
|
|
top: number | undefined
|
|
bottom: number | undefined
|
|
left: number
|
|
width: number
|
|
maxHeight: number
|
|
}
|
|
|
|
const DROPDOWN_WIDTH = 360
|
|
const DROPDOWN_GAP = 8
|
|
const VIEWPORT_MARGIN = 16
|
|
const DROPDOWN_MAX_HEIGHT = 420
|
|
const DROPDOWN_MIN_HEIGHT = 180
|
|
|
|
function officialChoices(
|
|
providerId: string | null,
|
|
models: ModelInfo[],
|
|
isDefault: boolean,
|
|
officialName: string,
|
|
): ProviderChoice {
|
|
return {
|
|
providerId,
|
|
providerName: officialName,
|
|
isDefault,
|
|
models,
|
|
}
|
|
}
|
|
|
|
function buildProviderModels(
|
|
provider: SavedProvider,
|
|
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
|
|
): ModelInfo[] {
|
|
const entries: Array<{ id: string; label: string }> = [
|
|
{ id: provider.models.main.trim(), label: labels.main },
|
|
{ id: provider.models.haiku.trim(), label: labels.haiku },
|
|
{ id: provider.models.sonnet.trim(), label: labels.sonnet },
|
|
{ id: provider.models.opus.trim(), label: labels.opus },
|
|
]
|
|
|
|
const byId = new Map<string, { id: string; labels: string[] }>()
|
|
for (const entry of entries) {
|
|
if (!entry.id) continue
|
|
const existing = byId.get(entry.id)
|
|
if (existing) {
|
|
if (!existing.labels.includes(entry.label)) {
|
|
existing.labels.push(entry.label)
|
|
}
|
|
continue
|
|
}
|
|
byId.set(entry.id, { id: entry.id, labels: [entry.label] })
|
|
}
|
|
|
|
return [...byId.values()].map((entry) => ({
|
|
id: entry.id,
|
|
name: entry.id,
|
|
description: entry.labels.join(' · '),
|
|
context: '',
|
|
}))
|
|
}
|
|
|
|
function buildProviderChoices(
|
|
providers: SavedProvider[],
|
|
activeId: string | null,
|
|
availableModels: ModelInfo[],
|
|
officialName: string,
|
|
openAIOfficialName: string,
|
|
grokOfficialName: string,
|
|
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
|
|
claudeOfficialLoggedIn: boolean,
|
|
openAIOfficialLoggedIn: boolean,
|
|
grokOfficialLoggedIn: boolean,
|
|
): ProviderChoice[] {
|
|
const claudeOfficialModels = activeId === null && availableModels.length > 0
|
|
? availableModels
|
|
: OFFICIAL_MODELS
|
|
const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0
|
|
? availableModels
|
|
: OPENAI_OFFICIAL_MODELS
|
|
const grokOfficialModels = activeId === GROK_OFFICIAL_PROVIDER_ID && availableModels.length > 0
|
|
? availableModels
|
|
: GROK_OFFICIAL_MODELS
|
|
|
|
const choices: ProviderChoice[] = []
|
|
|
|
if (claudeOfficialLoggedIn) {
|
|
choices.push(officialChoices(null, claudeOfficialModels, activeId === null, officialName))
|
|
}
|
|
if (openAIOfficialLoggedIn) {
|
|
choices.push(officialChoices(
|
|
OPENAI_OFFICIAL_PROVIDER_ID,
|
|
openAIOfficialModels,
|
|
activeId === OPENAI_OFFICIAL_PROVIDER_ID,
|
|
openAIOfficialName,
|
|
))
|
|
}
|
|
if (grokOfficialLoggedIn) {
|
|
choices.push(officialChoices(
|
|
GROK_OFFICIAL_PROVIDER_ID,
|
|
grokOfficialModels,
|
|
activeId === GROK_OFFICIAL_PROVIDER_ID,
|
|
grokOfficialName,
|
|
))
|
|
}
|
|
|
|
for (const provider of providers) {
|
|
choices.push({
|
|
providerId: provider.id,
|
|
providerName: provider.name,
|
|
isDefault: activeId === provider.id,
|
|
models: buildProviderModels(provider, labels),
|
|
})
|
|
}
|
|
|
|
return choices
|
|
}
|
|
|
|
export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function ModelSelector({
|
|
value,
|
|
onChange,
|
|
runtimeSelection: controlledRuntimeSelection,
|
|
onRuntimeSelectionChange,
|
|
runtimeKey,
|
|
disabled = false,
|
|
compact = false,
|
|
fluid = false,
|
|
}: Props = {}, selectorRef) {
|
|
const t = useTranslation()
|
|
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
|
|
const {
|
|
currentModel: storeModel,
|
|
availableModels,
|
|
effortLevel,
|
|
activeProviderName,
|
|
setModel,
|
|
} = useSettingsStore()
|
|
const {
|
|
providers,
|
|
activeId,
|
|
isLoading: providersLoading,
|
|
fetchProviders,
|
|
} = useProviderStore()
|
|
const claudeOAuthStatus = useHahaOAuthStore((s) => s.status)
|
|
const fetchClaudeOAuthStatus = useHahaOAuthStore((s) => s.fetchStatus)
|
|
const openAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.status)
|
|
const fetchOpenAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.fetchStatus)
|
|
const grokOAuthStatus = useHahaGrokOAuthStore((s) => s.status)
|
|
const fetchGrokOAuthStatus = useHahaGrokOAuthStore((s) => s.fetchStatus)
|
|
const runtimeSelection = useSessionRuntimeStore((state) =>
|
|
runtimeKey ? state.selections[runtimeKey] : undefined,
|
|
)
|
|
const [open, setOpen] = useState(false)
|
|
const [effortOpen, setEffortOpen] = useState(false)
|
|
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const effortButtonRef = useRef<HTMLButtonElement>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
const requestedProvidersRef = useRef(false)
|
|
const requestedOAuthStatusRef = useRef(false)
|
|
|
|
const EFFORT_OPTIONS: { value: ReasoningEffortLevel; label: string }[] = [
|
|
{ value: 'low', label: t('settings.general.effort.low') },
|
|
{ value: 'medium', label: t('settings.general.effort.medium') },
|
|
{ value: 'high', label: t('settings.general.effort.high') },
|
|
{ value: 'xhigh', label: t('settings.general.effort.xhigh') },
|
|
{ value: 'max', label: t('settings.general.effort.max') },
|
|
]
|
|
const effortLabels: Record<ReasoningEffortLevel, string> = {
|
|
low: t('settings.general.effort.low'),
|
|
medium: t('settings.general.effort.medium'),
|
|
high: t('settings.general.effort.high'),
|
|
xhigh: t('settings.general.effort.xhigh'),
|
|
max: t('settings.general.effort.max'),
|
|
}
|
|
|
|
const isControlled = value !== undefined
|
|
const isRuntimeScoped =
|
|
!isControlled &&
|
|
(runtimeKey !== undefined || onRuntimeSelectionChange !== undefined)
|
|
const canEditRuntimeEffort = runtimeKey !== undefined
|
|
|
|
useEffect(() => {
|
|
if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return
|
|
requestedProvidersRef.current = true
|
|
void fetchProviders()
|
|
}, [fetchProviders, isRuntimeScoped, providersLoading])
|
|
|
|
useEffect(() => {
|
|
if (!isRuntimeScoped || !open || requestedOAuthStatusRef.current) return
|
|
requestedOAuthStatusRef.current = true
|
|
void fetchClaudeOAuthStatus()
|
|
void fetchOpenAIOAuthStatus()
|
|
void fetchGrokOAuthStatus()
|
|
}, [fetchClaudeOAuthStatus, fetchGrokOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
|
|
|
|
const openSelector = useCallback(() => {
|
|
if (!disabled) {
|
|
setEffortOpen(false)
|
|
setOpen(true)
|
|
}
|
|
}, [disabled])
|
|
|
|
useImperativeHandle(selectorRef, () => ({
|
|
open: openSelector,
|
|
}), [openSelector])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handleClick = (e: MouseEvent) => {
|
|
const target = e.target as Node
|
|
if (
|
|
ref.current &&
|
|
!ref.current.contains(target) &&
|
|
!dropdownRef.current?.contains(target)
|
|
) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
const handleEsc = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
document.addEventListener('keydown', handleEsc)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClick)
|
|
document.removeEventListener('keydown', handleEsc)
|
|
}
|
|
}, [open])
|
|
|
|
const updateDropdownPosition = useCallback(() => {
|
|
const anchor = ref.current
|
|
if (!anchor) return
|
|
|
|
const rect = anchor.getBoundingClientRect()
|
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
|
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
|
const width = Math.min(DROPDOWN_WIDTH, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2))
|
|
const left = Math.min(
|
|
Math.max(VIEWPORT_MARGIN, rect.right - width),
|
|
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN),
|
|
)
|
|
const spaceBelow = viewportHeight - rect.bottom - DROPDOWN_GAP - VIEWPORT_MARGIN
|
|
const spaceAbove = rect.top - DROPDOWN_GAP - VIEWPORT_MARGIN
|
|
const placeBelow = spaceBelow >= DROPDOWN_MIN_HEIGHT || spaceBelow >= spaceAbove
|
|
const availableHeight = Math.max(
|
|
DROPDOWN_MIN_HEIGHT,
|
|
placeBelow ? spaceBelow : spaceAbove,
|
|
)
|
|
const maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableHeight)
|
|
|
|
setDropdownPosition({
|
|
top: placeBelow ? rect.bottom + DROPDOWN_GAP : undefined,
|
|
bottom: placeBelow ? undefined : (viewportHeight - rect.top + DROPDOWN_GAP),
|
|
left,
|
|
width,
|
|
maxHeight,
|
|
})
|
|
}, [])
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) {
|
|
setDropdownPosition(null)
|
|
return
|
|
}
|
|
updateDropdownPosition()
|
|
}, [open, updateDropdownPosition])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
window.addEventListener('resize', updateDropdownPosition)
|
|
window.addEventListener('scroll', updateDropdownPosition, true)
|
|
return () => {
|
|
window.removeEventListener('resize', updateDropdownPosition)
|
|
window.removeEventListener('scroll', updateDropdownPosition, true)
|
|
}
|
|
}, [open, updateDropdownPosition])
|
|
|
|
const roleLabels = useMemo(
|
|
() => ({
|
|
main: t('settings.providers.mainModel'),
|
|
haiku: t('settings.providers.haikuModel'),
|
|
sonnet: t('settings.providers.sonnetModel'),
|
|
opus: t('settings.providers.opusModel'),
|
|
}),
|
|
[t],
|
|
)
|
|
|
|
const providerChoices = useMemo(
|
|
() => buildProviderChoices(
|
|
providers,
|
|
activeId,
|
|
availableModels,
|
|
t('settings.providers.officialName'),
|
|
t('settings.providers.openaiOfficialName'),
|
|
t('settings.providers.grokOfficialName'),
|
|
roleLabels,
|
|
claudeOAuthStatus?.loggedIn === true,
|
|
openAIOAuthStatus?.loggedIn === true,
|
|
grokOAuthStatus?.loggedIn === true,
|
|
),
|
|
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, grokOAuthStatus, openAIOAuthStatus],
|
|
)
|
|
|
|
const selectedModel = isControlled
|
|
? availableModels.find((model) => model.id === value) || null
|
|
: storeModel
|
|
|
|
const activeRuntimeSelection = isRuntimeScoped
|
|
? controlledRuntimeSelection ?? runtimeSelection ?? resolveDefaultRuntimeSelection(
|
|
activeId,
|
|
activeProviderName,
|
|
providers,
|
|
storeModel?.id,
|
|
)
|
|
: null
|
|
|
|
const selectedProviderChoice = activeRuntimeSelection
|
|
? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) ?? null
|
|
: null
|
|
|
|
const selectedRuntimeModel = activeRuntimeSelection
|
|
? selectedProviderChoice?.models.find((model) => model.id === activeRuntimeSelection.modelId)
|
|
?? {
|
|
id: activeRuntimeSelection.modelId,
|
|
name: activeRuntimeSelection.modelId,
|
|
description: '',
|
|
context: '',
|
|
}
|
|
: null
|
|
|
|
const buttonModelLabel = isRuntimeScoped
|
|
? selectedRuntimeModel?.name ?? storeModel?.name ?? t('model.selectModel')
|
|
: selectedModel?.name ?? t('model.selectModel')
|
|
const buttonProviderLabel = isRuntimeScoped
|
|
? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName')
|
|
: null
|
|
const supportedRuntimeEfforts = selectedRuntimeModel?.supportedReasoningEfforts
|
|
const selectedRuntimeEffort = supportedRuntimeEfforts?.length === 0
|
|
? undefined
|
|
: activeRuntimeSelection?.effortLevel
|
|
?? selectedRuntimeModel?.defaultReasoningEffort
|
|
?? effortLevel
|
|
const runtimeEffortOptions = supportedRuntimeEfforts === undefined
|
|
? EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh')
|
|
: EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value))
|
|
|
|
const handleRuntimeSelect = (selection: RuntimeSelection) => {
|
|
onRuntimeSelectionChange?.(selection)
|
|
if (runtimeKey) {
|
|
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
|
|
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
|
|
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
|
|
}
|
|
}
|
|
setOpen(false)
|
|
}
|
|
|
|
const handleRuntimeEffortSelect = (level: ReasoningEffortLevel) => {
|
|
if (!activeRuntimeSelection) return
|
|
handleRuntimeSelect({
|
|
...activeRuntimeSelection,
|
|
effortLevel: level,
|
|
})
|
|
}
|
|
|
|
const dropdownContent = (
|
|
<>
|
|
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'p-3'}`} style={{ maxHeight: isMobileBrowser ? undefined : dropdownPosition?.maxHeight }}>
|
|
{!isMobileBrowser && (
|
|
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
|
{t('model.configuration')}
|
|
</div>
|
|
)}
|
|
|
|
{isRuntimeScoped ? (
|
|
<div className="space-y-3">
|
|
{providerChoices.map((choice) => (
|
|
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
|
|
<div className="flex items-center justify-between px-2 pt-1">
|
|
<span className="truncate text-[11px] font-semibold tracking-[0.01em] text-[var(--color-text-secondary)]">
|
|
{choice.providerName}
|
|
</span>
|
|
{choice.isDefault && (
|
|
<span className="flex-shrink-0 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
|
{t('settings.providers.default')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
{choice.models.map((model) => {
|
|
const isSelected =
|
|
activeRuntimeSelection?.providerId === choice.providerId &&
|
|
activeRuntimeSelection.modelId === model.id
|
|
return (
|
|
<button
|
|
key={`${choice.providerId ?? 'official'}:${model.id}`}
|
|
onClick={() => {
|
|
const supportedEfforts = model.supportedReasoningEfforts
|
|
const explicitEffort = activeRuntimeSelection?.effortLevel
|
|
const nextEffort = supportedEfforts === undefined
|
|
? explicitEffort ?? effortLevel
|
|
: supportedEfforts.length
|
|
? explicitEffort && supportedEfforts.includes(explicitEffort)
|
|
? explicitEffort
|
|
: model.defaultReasoningEffort ?? supportedEfforts[0]
|
|
: undefined
|
|
handleRuntimeSelect({
|
|
providerId: choice.providerId,
|
|
modelId: model.id,
|
|
...(nextEffort ? { effortLevel: nextEffort } : {}),
|
|
})
|
|
}}
|
|
className={`
|
|
w-full rounded-lg border px-3 text-left transition-colors
|
|
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
|
|
${isSelected
|
|
? 'border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
|
|
: 'border-transparent hover:bg-[var(--color-surface-hover)]'
|
|
}
|
|
`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
|
|
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
|
|
}`}>
|
|
{isSelected && (
|
|
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{model.name}
|
|
</div>
|
|
{model.description && (
|
|
<div className="mt-0.5 truncate pr-[6px] text-[10px] text-[var(--color-text-tertiary)]">
|
|
{model.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{availableModels.map((model) => {
|
|
const isSelected = model.id === selectedModel?.id
|
|
return (
|
|
<button
|
|
key={model.id}
|
|
onClick={() => {
|
|
if (isControlled) {
|
|
onChange?.(model.id)
|
|
} else {
|
|
void setModel(model.id)
|
|
}
|
|
setOpen(false)
|
|
}}
|
|
className={`
|
|
w-full rounded-lg px-3 text-left transition-colors
|
|
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
|
|
${isSelected
|
|
? 'border border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
|
|
: 'hover:bg-[var(--color-surface-hover)]'
|
|
}
|
|
`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
|
|
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
|
|
}`}>
|
|
{isSelected && (
|
|
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
|
|
{model.description && (
|
|
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
|
|
{model.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</>
|
|
)
|
|
|
|
const dropdown = open && dropdownPosition
|
|
? isMobileBrowser ? (
|
|
<MobileBottomSheet
|
|
open={open}
|
|
onClose={() => setOpen(false)}
|
|
title={t('model.configuration')}
|
|
closeLabel={t('tabs.close')}
|
|
ariaLabel={t('model.configuration')}
|
|
contentClassName="p-3"
|
|
panelRef={dropdownRef}
|
|
testId="model-selector-dropdown"
|
|
>
|
|
{dropdownContent}
|
|
</MobileBottomSheet>
|
|
) : createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
data-testid="model-selector-dropdown"
|
|
className="fixed z-[80] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
|
style={{
|
|
top: dropdownPosition.top,
|
|
bottom: dropdownPosition.bottom,
|
|
left: dropdownPosition.left,
|
|
width: dropdownPosition.width,
|
|
}}
|
|
>
|
|
{dropdownContent}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null
|
|
|
|
return (
|
|
<div
|
|
data-testid="model-selector-shell"
|
|
className={`relative min-w-0 ${fluid ? 'flex-1' : 'shrink-0'}`}
|
|
>
|
|
<div ref={ref} className={`flex min-w-0 items-stretch rounded-full bg-[var(--color-surface-container-low)] transition-colors hover:bg-[var(--color-surface-hover)] ${fluid ? 'w-full' : ''} ${disabled ? 'opacity-50' : ''}`}>
|
|
<button
|
|
onClick={() => {
|
|
if (disabled) return
|
|
setEffortOpen(false)
|
|
setOpen(!open)
|
|
}}
|
|
disabled={disabled}
|
|
aria-label={buttonProviderLabel ? `${buttonModelLabel}, ${buttonProviderLabel}` : undefined}
|
|
title={buttonProviderLabel ? `${buttonProviderLabel} · ${buttonModelLabel}` : undefined}
|
|
className={`flex min-w-0 items-center gap-2 rounded-l-full text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-not-allowed ${
|
|
compact ? `${fluid ? 'flex-1' : ''} max-w-[112px] py-1.5 pl-2.5 pr-1` : 'max-w-[220px] py-1.5 pl-3 pr-1'
|
|
}`}
|
|
>
|
|
<span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
|
|
{buttonModelLabel}
|
|
</span>
|
|
{!canEditRuntimeEffort && !compact && buttonProviderLabel && (
|
|
<span className="max-w-[108px] flex-shrink-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
|
|
{buttonProviderLabel}
|
|
</span>
|
|
)}
|
|
<span className="material-symbols-outlined flex-shrink-0 text-[12px]">expand_more</span>
|
|
</button>
|
|
|
|
{canEditRuntimeEffort && selectedRuntimeEffort && runtimeEffortOptions.length > 0 && (
|
|
<button
|
|
ref={effortButtonRef}
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-label={`${t('model.effort')}: ${effortLabels[selectedRuntimeEffort]}`}
|
|
aria-expanded={effortOpen}
|
|
onClick={() => {
|
|
if (disabled) return
|
|
setOpen(false)
|
|
setEffortOpen(!effortOpen)
|
|
}}
|
|
className={`rounded-r-full pr-3 text-[var(--color-text-tertiary)] outline-none transition-colors hover:text-[var(--color-text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-not-allowed ${compact ? 'pl-1 text-[10px]' : 'pl-1.5 text-xs'}`}
|
|
>
|
|
{effortLabels[selectedRuntimeEffort]}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{dropdown}
|
|
{canEditRuntimeEffort && selectedRuntimeEffort && (
|
|
<ReasoningEffortPopover
|
|
open={effortOpen}
|
|
anchorRef={effortButtonRef}
|
|
options={runtimeEffortOptions.map((option) => option.value)}
|
|
value={selectedRuntimeEffort}
|
|
labels={effortLabels}
|
|
ariaLabel={t('model.effort')}
|
|
onChange={handleRuntimeEffortSelect}
|
|
onClose={() => setEffortOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|