返回 DeepSeek-Reasonix
EffortSwitcher.tsx
根目录 / desktop / frontend / src / components / EffortSwitcher.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import { Check, ChevronsUpDown, Gauge } from "lucide-react";
3 import { asArray } from "../lib/array";
4 import type { EffortInfo } from "../lib/types";
5 import { ANCHORED_POPOVER_CLOSE_MS, AnchoredPopover } from "./AnchoredPopover";
6
7 export function EffortSwitcher({
8 effort,
9 disabled,
10 onPick,
11 }: {
12 effort?: EffortInfo;
13 disabled: boolean;
14 onPick: (level: string) => void;
15 }) {
16 const [open, setOpen] = useState(false);
17 const [closing, setClosing] = useState(false);
18 const triggerRef = useRef<HTMLButtonElement>(null);
19 const closeTimerRef = useRef<number | null>(null);
20 const options = asArray(effort?.options);
21 const label = (id: string) => options.find((option) => option.id === id)?.name || id;
22 const levels = effort?.options ? ["auto", ...options.map((option) => option.id)] : asArray(effort?.levels);
23 const current = effort?.current || "auto";
24
25 const clearCloseTimer = useCallback(() => {
26 if (closeTimerRef.current === null) return;
27 window.clearTimeout(closeTimerRef.current);
28 closeTimerRef.current = null;
29 }, []);
30
31 const openMenu = useCallback(() => {
32 clearCloseTimer();
33 setClosing(false);
34 setOpen(true);
35 }, [clearCloseTimer]);
36
37 const closeMenu = useCallback((afterClose?: () => void) => {
38 clearCloseTimer();
39 setClosing(true);
40 window.requestAnimationFrame(() => setOpen(false));
41 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
42 closeTimerRef.current = window.setTimeout(() => {
43 closeTimerRef.current = null;
44 setClosing(false);
45 afterClose?.();
46 }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
47 }, [clearCloseTimer]);
48
49 useEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
50 useEffect(() => {
51 if (!disabled) return;
52 clearCloseTimer();
53 setOpen(false);
54 setClosing(false);
55 }, [clearCloseTimer, disabled]);
56
57 const pick = (level: string) => {
58 closeMenu(() => {
59 if (level !== current) onPick(level);
60 });
61 };
62
63 if (!effort?.supported || levels.length === 0) return null;
64
65 return (
66 <div className="modelsw effortsw">
67 <button
68 ref={triggerRef}
69 type="button"
70 className={`modelsw__trigger effortsw__trigger ${current !== "auto" ? "effortsw__trigger--explicit" : ""}`}
71 disabled={disabled}
72 aria-expanded={open && !closing}
73 onClick={() => (open || closing ? closeMenu() : openMenu())}
74 >
75 <Gauge size={14} className="modelsw__kind" />
76 <span className="modelsw__label">{label(current)}</span>
77 <ChevronsUpDown size={11} />
78 </button>
79 <AnchoredPopover
80 open={open && !disabled}
81 closing={closing}
82 anchorRef={triggerRef}
83 onClose={() => closeMenu()}
84 className="modelsw__menu modelsw__menu--portal effortsw__menu"
85 align="end"
86 >
87 <div role="listbox">
88 {levels.map((level) => (
89 <button
90 key={level}
91 type="button"
92 role="option"
93 aria-selected={level === current}
94 className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`}
95 onClick={() => pick(level)}
96 >
97 <span className="modelsw__model">{label(level)}</span>
98 {level === current && <Check size={13} className="modelsw__check" />}
99 </button>
100 ))}
101 </div>
102 </AnchoredPopover>
103 </div>
104 );
105 }
106
106 lines Plain Text