mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
feat(desktop): add effort slider popover #1007
This commit is contained in:
parent
5871188084
commit
69c4296a3f
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
110
desktop/src/components/controls/ReasoningEffortPopover.test.tsx
Normal file
110
desktop/src/components/controls/ReasoningEffortPopover.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
201
desktop/src/components/controls/ReasoningEffortPopover.tsx
Normal file
201
desktop/src/components/controls/ReasoningEffortPopover.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user