From 69c4296a3f87ae2374357ed601c52dd15175612f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 13 Jul 2026 10:20:15 +0800 Subject: [PATCH] feat(desktop): add effort slider popover #1007 --- .../controls/ModelSelector.test.tsx | 23 +- .../src/components/controls/ModelSelector.tsx | 106 +++++---- .../controls/ReasoningEffortPopover.test.tsx | 110 ++++++++++ .../controls/ReasoningEffortPopover.tsx | 201 ++++++++++++++++++ 4 files changed, 392 insertions(+), 48 deletions(-) create mode 100644 desktop/src/components/controls/ReasoningEffortPopover.test.tsx create mode 100644 desktop/src/components/controls/ReasoningEffortPopover.tsx 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 ( - - ) - })} -
-
- )} ) @@ -568,27 +552,63 @@ export const ModelSelector = forwardRef(function Mod : null return ( -
-
- expand_more - + expand_more + + + {canEditRuntimeEffort && selectedRuntimeEffort && runtimeEffortOptions.length > 0 && ( + + )} + {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( + <> + + + + , + ) + 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, + ) +}