diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index ffc012d5..6dc3ca70 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -295,8 +295,8 @@ describe('ModelSelector', () => { render() - 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() + 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, diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index d1afa6dc..a14f17c4 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -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(function Mod runtimeKey ? state.selections[runtimeKey] : undefined, ) const [open, setOpen] = useState(false) + const [effortOpen, setEffortOpen] = useState(false) const [dropdownPosition, setDropdownPosition] = useState(null) const ref = useRef(null) + const effortButtonRef = useRef(null) const dropdownRef = useRef(null) const requestedProvidersRef = useRef(false) const requestedOAuthStatusRef = useRef(false) @@ -189,6 +192,13 @@ export const ModelSelector = forwardRef(function Mod { value: 'xhigh', label: t('settings.general.effort.xhigh') }, { value: 'max', label: t('settings.general.effort.max') }, ] + const effortLabels: Record = { + 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(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(function Mod )} - {canEditRuntimeEffort && ( - - - {t('model.effort')} - - - {runtimeEffortOptions.map((opt) => { - const isSelected = opt.value === selectedRuntimeEffort - return ( - { - 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} - - ) - })} - - - )} > ) @@ -568,27 +552,63 @@ export const ModelSelector = forwardRef(function Mod : null return ( - - !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' - }`} - > - + + + { + 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' + }`} + > {buttonModelLabel} - {!compact && buttonProviderLabel && ( + {!canEditRuntimeEffort && !compact && buttonProviderLabel && ( {buttonProviderLabel} )} - - expand_more - + expand_more + + + {canEditRuntimeEffort && selectedRuntimeEffort && runtimeEffortOptions.length > 0 && ( + { + 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]} + + )} + {dropdown} + {canEditRuntimeEffort && selectedRuntimeEffort && ( + option.value)} + value={selectedRuntimeEffort} + labels={effortLabels} + ariaLabel={t('model.effort')} + onChange={handleRuntimeEffortSelect} + onClose={() => setEffortOpen(false)} + /> + )} ) }) diff --git a/desktop/src/components/controls/ReasoningEffortPopover.test.tsx b/desktop/src/components/controls/ReasoningEffortPopover.test.tsx new file mode 100644 index 00000000..3808291b --- /dev/null +++ b/desktop/src/components/controls/ReasoningEffortPopover.test.tsx @@ -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> = {}) { + const anchorRef = createRef() + const onChange = vi.fn() + const onClose = vi.fn() + const view = render( + <> + 5.6 Sol 极高 + + 外部区域 + >, + ) + 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( + , + ) + 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) + }) +}) diff --git a/desktop/src/components/controls/ReasoningEffortPopover.tsx b/desktop/src/components/controls/ReasoningEffortPopover.tsx new file mode 100644 index 00000000..3302cbcd --- /dev/null +++ b/desktop/src/components/controls/ReasoningEffortPopover.tsx @@ -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 + options: ReasoningEffortLevel[] + value: ReasoningEffortLevel + labels: Record + 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(null) + const sliderRef = useRef(null) + const draggingRef = useRef(false) + const [position, setPosition] = useState(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( + + + + {labels[value]} + + + + + + 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) + } + }} + > + + + + + + {options.map((option, index) => ( + + ))} + + + + + , + document.body, + ) +}