mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Materialize the active provider runtime for empty-session first prompts before the WebSocket prewarm and user message are sent. This keeps a clean install that adds a MiniMax provider from starting the first turn without the selected provider runtime. Tested: bun test src/server/__tests__/conversations.test.ts -t "MiniMax provider turn" Tested: bun test src/server/__tests__/websocket-handler.test.ts src/server/__tests__/conversations.test.ts -t "prewarm|active provider id|MiniMax provider turn|first-turn runtime synchronization" Tested: cd desktop && bun run test -- --run src/pages/EmptySession.test.tsx -t "draft runtime|active provider runtime|creates a new session" Tested: cd desktop && bun run test -- --run src/components/controls/ModelSelector.test.tsx -t "defaults blank provider-scoped runtime selections|selects provider-scoped runtime models" Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: bun run check:desktop, unrelated full-suite failures in MessageList timestamp formatting and TabBar AbortSignal cleanup blocked the gate. Confidence: medium Scope-risk: narrow
579 lines
20 KiB
TypeScript
579 lines
20 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 { EffortLevel, ModelInfo } 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 { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
|
|
claudeOfficialLoggedIn: boolean,
|
|
openAIOfficialLoggedIn: 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 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,
|
|
))
|
|
}
|
|
|
|
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,
|
|
}: 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 runtimeSelection = useSessionRuntimeStore((state) =>
|
|
runtimeKey ? state.selections[runtimeKey] : undefined,
|
|
)
|
|
const [open, setOpen] = useState(false)
|
|
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
const requestedProvidersRef = useRef(false)
|
|
const requestedOAuthStatusRef = useRef(false)
|
|
|
|
const EFFORT_OPTIONS: { value: EffortLevel; 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: 'max', label: 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()
|
|
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
|
|
|
|
const openSelector = useCallback(() => {
|
|
if (!disabled) 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'),
|
|
roleLabels,
|
|
claudeOAuthStatus?.loggedIn === true,
|
|
openAIOAuthStatus?.loggedIn === true,
|
|
),
|
|
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, 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 selectedRuntimeEffort = activeRuntimeSelection?.effortLevel ?? effortLevel
|
|
|
|
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: EffortLevel) => {
|
|
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={() => handleRuntimeSelect({
|
|
providerId: choice.providerId,
|
|
modelId: model.id,
|
|
effortLevel: selectedRuntimeEffort,
|
|
})}
|
|
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>
|
|
|
|
{canEditRuntimeEffort && (
|
|
<div className="border-t border-[var(--color-border)] p-3">
|
|
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
|
{t('model.effort')}
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{EFFORT_OPTIONS.map((opt) => {
|
|
const isSelected = opt.value === selectedRuntimeEffort
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => {
|
|
handleRuntimeEffortSelect(opt.value)
|
|
}}
|
|
className={`
|
|
rounded-lg py-2 text-center text-xs font-semibold transition-colors
|
|
${isSelected
|
|
? 'bg-[var(--color-brand)] text-white'
|
|
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}
|
|
`}
|
|
>
|
|
{opt.label}
|
|
</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 ref={ref} className="relative min-w-0 shrink-0">
|
|
<button
|
|
onClick={() => !disabled && setOpen(!open)}
|
|
disabled={disabled}
|
|
className={`flex items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
compact ? 'max-w-[112px] px-2.5 py-1.5' : 'max-w-[280px] px-3 py-1.5'
|
|
}`}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
|
|
{buttonModelLabel}
|
|
</span>
|
|
{!compact && buttonProviderLabel && (
|
|
<span className="max-w-[108px] flex-shrink-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
|
|
{buttonProviderLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="material-symbols-outlined flex-shrink-0 text-[12px]">expand_more</span>
|
|
</button>
|
|
{dropdown}
|
|
</div>
|
|
)
|
|
})
|