From d28687e2e144d9150657c27e8a8ea43ee89a09f4 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 21 Aug 2026 16:34:35 +0800 Subject: [PATCH] feat(webui): polish model preset interaction --- .../components/thread/ModelPresetBadge.tsx | 88 +++++++++++++++++-- webui/src/globals.css | 2 +- webui/src/tests/thread-composer.test.tsx | 50 ++++++++++- webui/src/tests/thread-shell.test.tsx | 28 +++--- 4 files changed, 148 insertions(+), 20 deletions(-) diff --git a/webui/src/components/thread/ModelPresetBadge.tsx b/webui/src/components/thread/ModelPresetBadge.tsx index a62849005..93011bde9 100644 --- a/webui/src/components/thread/ModelPresetBadge.tsx +++ b/webui/src/components/thread/ModelPresetBadge.tsx @@ -8,6 +8,12 @@ import { } from "react"; import { CircleHelp, Sparkles } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; @@ -113,6 +119,9 @@ export function ModelPresetBadge({ const pillStride = pillHeight + PILL_GAP_PX; const [motion, setMotion] = useState(null); const gestureRef = useRef(null); + const clickAnimationFrameRef = useRef(null); + const suppressClickRef = useRef(false); + const suppressClickTimerRef = useRef(null); function clearGesture() { const gesture = gestureRef.current; @@ -126,7 +135,17 @@ export function ModelPresetBadge({ clearGesture(); setMotion(null); } - return clearGesture; + return () => { + clearGesture(); + if (clickAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(clickAnimationFrameRef.current); + clickAnimationFrameRef.current = null; + } + if (suppressClickTimerRef.current !== null) { + window.clearTimeout(suppressClickTimerRef.current); + suppressClickTimerRef.current = null; + } + }; }, [canSwitch]); useEffect(() => { @@ -141,6 +160,42 @@ export function ModelPresetBadge({ setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false }); } + function suppressFollowingClick() { + suppressClickRef.current = true; + if (suppressClickTimerRef.current !== null) { + window.clearTimeout(suppressClickTimerRef.current); + } + suppressClickTimerRef.current = window.setTimeout(() => { + suppressClickRef.current = false; + suppressClickTimerRef.current = null; + }, 0); + } + + function cycleToNextPreset() { + if (!canSwitch || motion) return; + const nextVirtualIndex = currentIndex + 1; + const next = presets[wrapIndex(nextVirtualIndex, presets.length)]; + if (!next || next.name === activeName) return; + + // Mount the same five-pill track one step before its destination, then + // settle it into place so clicks share the drag interaction's motion. + setMotion({ index: nextVirtualIndex, remainder: -1, settling: false }); + clickAnimationFrameRef.current = window.requestAnimationFrame(() => { + clickAnimationFrameRef.current = null; + setMotion({ index: nextVirtualIndex, remainder: 0, settling: true }); + onPresetChange?.(next.name); + }); + } + + function handleClick() { + if (interactive) { + onClick?.(); + return; + } + if (suppressClickRef.current) return; + cycleToNextPreset(); + } + function handlePointerDown(event: PointerEvent) { if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return; if (event.pointerType === "mouse" && event.button !== 0) return; @@ -185,6 +240,7 @@ export function ModelPresetBadge({ if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) { event.currentTarget.releasePointerCapture?.(gesture.pointerId); } + if (gesture.active) suppressFollowingClick(); if (!commit || !gesture.active) { setMotion(null); return; @@ -213,8 +269,10 @@ export function ModelPresetBadge({ const previewPreset = presets[previewIndex]; const Container = interactive || canSwitch ? "button" : "span"; const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0; + const tooltipLabel = fallbackModelName + || [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); - return ( + const badge = ( ); + + if (!tooltipLabel) return badge; + return ( + + + {badge} + + {tooltipLabel} + + + + ); } function PresetPill({ @@ -309,7 +385,6 @@ function PresetPill({ label, modelDetail, provider, - providerLabel, needsSetup = false, fallbackModelName, isHero, @@ -320,7 +395,6 @@ function PresetPill({ label: string; modelDetail?: string | null; provider?: string | null; - providerLabel?: string | null; needsSetup?: boolean; fallbackModelName?: string | null; isHero: boolean; @@ -334,7 +408,6 @@ function PresetPill({ : provider || inferProviderFromModelName(modelDetail || label); const brand = providerBrand(inferredProvider); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); - const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); const logoTestId = offset !== undefined ? undefined : needsSetup @@ -355,7 +428,6 @@ function PresetPill({ { expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); }); + it("shows model details in the shared tooltip without a native title", async () => { + render( + , + ); + + const badge = screen.getByLabelText("gpt-4o"); + expect(badge).not.toHaveAttribute("title"); + fireEvent.focus(badge); + + expect(await screen.findByRole("tooltip")).toHaveTextContent("gpt-4o · OpenAI"); + }); + + it("smoothly cycles to the next preset on click", () => { + vi.useFakeTimers(); + let runFrame: FrameRequestCallback | null = null; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + runFrame = callback; + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + const { badge, onPresetChange } = renderPresetComposer(); + + fireEvent.click(badge); + + expect(badge).toHaveAttribute("data-switching", "true"); + const track = screen.getByTestId("composer-model-pill-track"); + expect(track).not.toHaveAttribute("data-settling"); + expect(track).toHaveStyle({ transform: "translate3d(0, -40px, 0)" }); + + act(() => runFrame?.(16)); + + expect(onPresetChange).toHaveBeenCalledWith("dflash"); + expect(badge).toHaveAttribute("data-settling", "true"); + expect(track).toHaveAttribute("data-settling", "true"); + expect(track).toHaveStyle({ transform: "translate3d(0, -80px, 0)" }); + + act(() => vi.advanceTimersByTime(260)); + expect(badge).not.toHaveAttribute("data-switching"); + }); + it("scrolls complete preset pills after a left-button long press and wraps", () => { vi.useFakeTimers(); const { badge, onPresetChange } = renderPresetComposer(); @@ -582,7 +629,6 @@ describe("ThreadComposer", () => { }); badge.dispatchEvent(idleTouchMove); expect(idleTouchMove.defaultPrevented).toBe(false); - fireEvent.click(badge); pointerDown(badge); fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); act(() => vi.advanceTimersByTime(500)); @@ -639,6 +685,8 @@ describe("ThreadComposer", () => { }); expect(onPresetChange).toHaveBeenCalledWith("dspro"); + fireEvent.click(badge); + expect(onPresetChange).toHaveBeenCalledTimes(1); expect(badge).toHaveAttribute("data-settling", "true"); expect(track).toHaveAttribute("data-settling", "true"); act(() => { diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 5f5610683..fb3b9dd06 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -609,8 +609,12 @@ describe("ThreadShell", () => { ), ); - expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); - expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument(); + const badge = await screen.findByLabelText("fast"); + expect(badge).not.toHaveAttribute("title"); + fireEvent.focus(badge); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "fast · gpt-5.5 · OpenAI Codex", + ); }); it("switches through every named preset while preserving call-order priority", async () => { @@ -692,7 +696,11 @@ describe("ThreadShell", () => { ), ); - expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument(); + const badge = await screen.findByLabelText("fast"); + fireEvent.focus(badge); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "fast · gpt-4 · Company Proxy", + ); expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument(); }); @@ -740,11 +748,11 @@ describe("ThreadShell", () => { expect(screen.getByText("Default")).toBeInTheDocument(); expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument(); expect(badge).toHaveAttribute("data-fallback", "true"); - expect(badge).toHaveAttribute( - "title", - "deepseek/deepseek-chat", - ); + expect(badge).not.toHaveAttribute("title"); expect(logo).not.toHaveAttribute("data-fallback"); + const trigger = screen.getByLabelText("Default"); + fireEvent.focus(trigger); + expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat"); act(() => { client._emitChat("fallback-model", { @@ -758,9 +766,9 @@ describe("ThreadShell", () => { screen.getByTestId("composer-model-logo-openai_codex").parentElement, ).not.toHaveAttribute("data-fallback"); }); - expect( - screen.getByTestId("composer-model-logo-openai_codex").parentElement, - ).toHaveAttribute("title", "Default · gpt-5.5 · OpenAI Codex"); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Default · gpt-5.5 · OpenAI Codex", + ); expect( screen.getByTestId("composer-model-logo-openai_codex").parentElement, ).toBe(badge);