feat(desktop): add effort slider popover #1007

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 10:20:15 +08:00
parent 5871188084
commit 69c4296a3f
4 changed files with 392 additions and 48 deletions

View File

@ -295,8 +295,8 @@ describe('ModelSelector', () => {
render(<ModelSelector runtimeKey="session-1" />)
await clickByRole(/provider-main/i)
await clickByRole(/^High$/)
await clickByRole('Effort: Max')
fireEvent.keyDown(screen.getByRole('slider', { name: 'Effort' }), { key: 'ArrowLeft' })
expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({
providerId: 'provider-a',
@ -420,6 +420,17 @@ describe('ModelSelector', () => {
render(<ModelSelector runtimeKey="session-openai-effort" />)
expect(screen.getByRole('button', { name: 'GPT-5.6-Sol, ChatGPT Official' })).toHaveAttribute(
'title',
'ChatGPT Official · GPT-5.6-Sol',
)
expect(screen.queryByTestId('model-provider-badge')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Effort: Max' })).toBeInTheDocument()
await clickByRole('Effort: Max')
expect(screen.getByRole('slider', { name: 'Effort' })).toHaveAttribute('aria-valuemax', '4')
expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(5)
fireEvent.keyDown(screen.getByRole('slider', { name: 'Effort' }), { key: 'Escape' })
await clickByRole(/GPT-5\.6-Sol/i)
await clickByRole(/GPT-5\.5/)
@ -429,9 +440,11 @@ describe('ModelSelector', () => {
effortLevel: 'medium',
})
await clickByRole(/GPT-5\.5/i)
expect(screen.queryByRole('button', { name: /^Max$/ })).not.toBeInTheDocument()
await clickByRole(/^X-High$/)
expect(screen.getByRole('button', { name: 'Effort: Medium' })).toBeInTheDocument()
await clickByRole('Effort: Medium')
expect(screen.getByRole('slider', { name: 'Effort' })).toHaveAttribute('aria-valuemax', '3')
fireEvent.keyDown(screen.getByRole('slider', { name: 'Effort' }), { key: 'End' })
expect(screen.getByRole('slider', { name: 'Effort' })).toHaveAttribute('aria-valuetext', 'X-High')
expect(useSessionRuntimeStore.getState().selections['session-openai-effort']).toEqual({
providerId: OPENAI_OFFICIAL_PROVIDER_ID,

View File

@ -19,6 +19,7 @@ import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
import { ReasoningEffortPopover } from './ReasoningEffortPopover'
type ProviderChoice = {
providerId: string | null
@ -176,8 +177,10 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
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)
@ -189,6 +192,13 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
{ 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 =
@ -210,7 +220,10 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
const openSelector = useCallback(() => {
if (!disabled) setOpen(true)
if (!disabled) {
setEffortOpen(false)
setOpen(true)
}
}, [disabled])
useImperativeHandle(selectorRef, () => ({
@ -503,35 +516,6 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
)}
</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 gap-1.5 ${runtimeEffortOptions.length === 5 ? 'grid-cols-5' : 'grid-cols-4'}`}>
{runtimeEffortOptions.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-[var(--color-on-primary)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>
)}
</>
)
@ -568,27 +552,63 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
: 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">
<div className="relative min-w-0 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)] ${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 ? '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>
{!compact && buttonProviderLabel && (
{!canEditRuntimeEffort && !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>
<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>
)
})

View File

@ -0,0 +1,110 @@
import { createRef } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ReasoningEffortPopover } from './ReasoningEffortPopover'
const options = ['low', 'medium', 'high', 'xhigh', 'max'] as const
const labels = {
low: '低',
medium: '中',
high: '高',
xhigh: '极高',
max: '最大',
}
afterEach(cleanup)
function renderPopover(overrides: Partial<React.ComponentProps<typeof ReasoningEffortPopover>> = {}) {
const anchorRef = createRef<HTMLButtonElement>()
const onChange = vi.fn()
const onClose = vi.fn()
const view = render(
<>
<button ref={anchorRef}>5.6 Sol </button>
<ReasoningEffortPopover
open
anchorRef={anchorRef}
options={[...options]}
value="xhigh"
labels={labels}
onChange={onChange}
onClose={onClose}
{...overrides}
/>
<button></button>
</>,
)
return { ...view, anchorRef, onChange, onClose }
}
describe('ReasoningEffortPopover', () => {
it('renders every model-supported stop and exposes the selected localized value', () => {
renderPopover()
const slider = screen.getByRole('slider', { name: '推理强度' })
expect(slider).toHaveAttribute('aria-valuemin', '0')
expect(slider).toHaveAttribute('aria-valuemax', '4')
expect(slider).toHaveAttribute('aria-valuenow', '3')
expect(slider).toHaveAttribute('aria-valuetext', '极高')
expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(5)
expect(screen.getByText('极高')).toBeInTheDocument()
expect(screen.getByTestId('reasoning-effort-fill')).toHaveClass('bg-[#3798f7]')
})
it('selects a discrete stop from the track', () => {
const { onChange } = renderPopover()
const slider = screen.getByRole('slider', { name: '推理强度' })
vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
width: 400,
height: 48,
top: 0,
right: 400,
bottom: 48,
left: 0,
toJSON: () => ({}),
})
fireEvent.click(slider, { clientX: 200 })
expect(onChange).toHaveBeenCalledWith('high')
})
it('supports keyboard navigation and clamps at supported endpoints', () => {
const { onChange, rerender, anchorRef } = renderPopover({ value: 'low' })
const slider = screen.getByRole('slider', { name: '推理强度' })
fireEvent.keyDown(slider, { key: 'ArrowLeft' })
fireEvent.keyDown(slider, { key: 'ArrowRight' })
fireEvent.keyDown(slider, { key: 'End' })
expect(onChange.mock.calls).toEqual([['medium'], ['max']])
rerender(
<ReasoningEffortPopover
open
anchorRef={anchorRef}
options={[...options]}
value="max"
labels={labels}
onChange={onChange}
onClose={vi.fn()}
/>,
)
fireEvent.keyDown(screen.getByRole('slider', { name: '推理强度' }), { key: 'ArrowRight' })
expect(onChange.mock.calls).toEqual([['medium'], ['max']])
})
it('closes on Escape and outside pointer interaction', () => {
const { onClose } = renderPopover()
const slider = screen.getByRole('slider', { name: '推理强度' })
fireEvent.keyDown(slider, { key: 'Escape' })
fireEvent.pointerDown(screen.getByRole('button', { name: '外部区域' }))
expect(onClose).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,201 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronRight, Zap } from 'lucide-react'
import type { ReasoningEffortLevel } from '../../types/settings'
type Props = {
open: boolean
anchorRef: React.RefObject<HTMLElement>
options: ReasoningEffortLevel[]
value: ReasoningEffortLevel
labels: Record<ReasoningEffortLevel, string>
onChange: (value: ReasoningEffortLevel) => void
onClose: () => void
ariaLabel?: string
}
type PopoverPosition = {
bottom: number
left: number
width: number
}
const POPOVER_WIDTH = 360
const VIEWPORT_MARGIN = 16
const POPOVER_GAP = 10
export function ReasoningEffortPopover({
open,
anchorRef,
options,
value,
labels,
onChange,
onClose,
ariaLabel = '推理强度',
}: Props) {
const popoverRef = useRef<HTMLDivElement>(null)
const sliderRef = useRef<HTMLDivElement>(null)
const draggingRef = useRef(false)
const [position, setPosition] = useState<PopoverPosition | null>(null)
const selectedIndex = Math.max(0, options.indexOf(value))
const maxIndex = Math.max(0, options.length - 1)
const fillPercent = maxIndex === 0 ? 0 : (selectedIndex / maxIndex) * 100
useLayoutEffect(() => {
if (!open) {
setPosition(null)
return
}
const updatePosition = () => {
const rect = anchorRef.current?.getBoundingClientRect()
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
const width = Math.min(POPOVER_WIDTH, viewportWidth - VIEWPORT_MARGIN * 2)
const anchorRight = rect?.right ?? viewportWidth - VIEWPORT_MARGIN
const anchorTop = rect?.top ?? window.innerHeight / 2
const left = Math.min(
Math.max(VIEWPORT_MARGIN, anchorRight - width),
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN),
)
setPosition({
bottom: Math.max(VIEWPORT_MARGIN, window.innerHeight - anchorTop + POPOVER_GAP),
left,
width,
})
}
updatePosition()
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
return () => {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}, [anchorRef, open])
useEffect(() => {
if (!open) return
const handleOutsidePointer = (event: PointerEvent) => {
const target = event.target as Node
if (!popoverRef.current?.contains(target) && !anchorRef.current?.contains(target)) {
onClose()
}
}
document.addEventListener('pointerdown', handleOutsidePointer)
return () => document.removeEventListener('pointerdown', handleOutsidePointer)
}, [anchorRef, onClose, open])
if (!open || !position || options.length === 0) return null
const selectFromClientX = (clientX: number) => {
const rect = sliderRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) return
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
const nextIndex = Math.round(ratio * maxIndex)
const nextValue = options[nextIndex]
if (nextValue && nextValue !== value) onChange(nextValue)
}
const moveBy = (offset: number) => {
const nextIndex = Math.min(maxIndex, Math.max(0, selectedIndex + offset))
const nextValue = options[nextIndex]
if (nextValue && nextValue !== value) onChange(nextValue)
}
return createPortal(
<div
ref={popoverRef}
data-testid="reasoning-effort-popover"
className="fixed z-[90] rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-6 pb-7 pt-5 shadow-[0_18px_48px_rgba(15,23,42,0.14)]"
style={{ bottom: position.bottom, left: position.left, width: position.width }}
>
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-1 text-[17px] font-semibold text-[var(--color-text-secondary)]">
<span>{labels[value]}</span>
<ChevronRight aria-hidden="true" className="h-5 w-5 text-[var(--color-outline)]" strokeWidth={2.2} />
</div>
<Zap aria-hidden="true" className="h-6 w-6 text-[var(--color-outline)]" strokeWidth={2} />
</div>
<div
ref={sliderRef}
role="slider"
tabIndex={0}
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={maxIndex}
aria-valuenow={selectedIndex}
aria-valuetext={labels[value]}
className="group relative flex h-12 touch-none cursor-pointer items-center outline-none focus-visible:ring-2 focus-visible:ring-[#3798f7] focus-visible:ring-offset-4 focus-visible:ring-offset-[var(--color-surface-container-lowest)]"
onClick={(event) => selectFromClientX(event.clientX)}
onPointerDown={(event) => {
draggingRef.current = true
event.currentTarget.setPointerCapture?.(event.pointerId)
selectFromClientX(event.clientX)
}}
onPointerMove={(event) => {
if (draggingRef.current) selectFromClientX(event.clientX)
}}
onPointerUp={(event) => {
if (!draggingRef.current) return
draggingRef.current = false
selectFromClientX(event.clientX)
event.currentTarget.releasePointerCapture?.(event.pointerId)
}}
onPointerCancel={() => {
draggingRef.current = false
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
anchorRef.current?.focus()
return
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
event.preventDefault()
moveBy(-1)
} else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
event.preventDefault()
moveBy(1)
} else if (event.key === 'Home') {
event.preventDefault()
const firstValue = options[0]
if (firstValue && firstValue !== value) onChange(firstValue)
} else if (event.key === 'End') {
event.preventDefault()
const lastValue = options[maxIndex]
if (lastValue && lastValue !== value) onChange(lastValue)
}
}}
>
<div className="absolute inset-x-0 h-10 overflow-hidden rounded-full bg-[var(--color-surface-container-high)] shadow-[inset_0_0_0_1px_var(--color-border)]">
<div
data-testid="reasoning-effort-fill"
className="h-full rounded-full bg-[#3798f7] transition-[width] duration-200 motion-reduce:transition-none"
style={{ width: `${fillPercent}%` }}
/>
</div>
<div className="absolute inset-x-0 flex items-center justify-between px-[22px]">
{options.map((option, index) => (
<span
key={option}
data-testid="reasoning-effort-stop"
className={`h-2 w-2 rounded-full ${index <= selectedIndex ? 'bg-white/45' : 'bg-[var(--color-outline)]/55'}`}
/>
))}
</div>
<div
aria-hidden="true"
className="absolute top-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full border border-[var(--color-border)] bg-white shadow-[0_3px_9px_rgba(15,23,42,0.14)] transition-[left] duration-200 motion-reduce:transition-none"
style={{ left: `${fillPercent}%` }}
/>
</div>
</div>,
document.body,
)
}