From f770e4f53c285e24da3d4125e652b3276298b4b1 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 1 Aug 2026 12:14:37 +0800 Subject: [PATCH] fix(webui): make model preset switching discoverable --- docs/webui.md | 11 +- .../components/thread/ModelPresetBadge.tsx | 362 +++++------------- webui/src/globals.css | 46 +-- webui/src/tests/thread-composer.test.tsx | 117 +----- webui/src/tests/thread-shell.test.tsx | 17 +- 5 files changed, 136 insertions(+), 417 deletions(-) diff --git a/docs/webui.md b/docs/webui.md index 7d533e26b..22fb3124f 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -147,12 +147,11 @@ transcription is configured, slash commands, and `@` mentions for installed Apps or MCP presets. The model badge shows the current model or preset and links back to model settings when setup is incomplete. -When two or more named model presets are configured, the badge also acts as a -vertical preset selector. Press and hold the badge for about 0.4 seconds, drag up -or down, and release on the preset you want. A plain click does not open a menu. -For keyboard access, focus the badge and use Up / Down to -move between presets or Home / End to jump to the first or -last preset. +When two or more named model presets are configured, the badge shows a dropdown +indicator and acts as a preset selector. Click or tap it, then choose the preset +you want from the menu. For keyboard access, focus the badge and press +Enter or Space to open the menu, use the arrow keys to move, +and press Enter to select. The selection applies to future turns in the current session and persists with that session; it does not change the default for other sessions. Only named diff --git a/webui/src/components/thread/ModelPresetBadge.tsx b/webui/src/components/thread/ModelPresetBadge.tsx index 317d459f7..8b5709040 100644 --- a/webui/src/components/thread/ModelPresetBadge.tsx +++ b/webui/src/components/thread/ModelPresetBadge.tsx @@ -1,13 +1,13 @@ -import { - useEffect, - useLayoutEffect, - useRef, - useState, - type KeyboardEvent, - type PointerEvent, -} from "react"; -import { CircleHelp, Sparkles } from "lucide-react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { ChevronDown, CircleHelp, Sparkles } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; @@ -33,54 +33,6 @@ interface ModelPresetBadgeProps { onClick?: () => void; } -interface PresetGesture { - active: boolean; - baseIndex: number; - latestY: number; - pointerId: number; - startY: number; - step: number; - target: HTMLElement; - timer: ReturnType | null; -} - -interface PresetMotion { - index: number; - remainder: number; - settling: boolean; -} - -const LONG_PRESS_MS = 400; -const PRESS_SLOP_PX = 8; -const PILL_GAP_PX = 4; -const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const; -const HANDOFF_THRESHOLD = 0.56; -const DOCK_MAX_SCALE = 1.08; -const DOCK_RADIUS = 1.5; -const SETTLE_MS = 180; - -function wrapIndex(index: number, length: number): number { - return ((index % length) + length) % length; -} - -function dockScale(distanceFromFocus: number): number { - const distance = Math.abs(distanceFromFocus); - if (distance >= DOCK_RADIUS) return 1; - const influence = (1 + Math.cos(Math.PI * distance / DOCK_RADIUS)) / 2; - return 1 + (DOCK_MAX_SCALE - 1) * influence; -} - -function stepWithHysteresis(raw: number, current: number): number { - let next = current; - while (raw > next + HANDOFF_THRESHOLD) next += 1; - while (raw < next - HANDOFF_THRESHOLD) next -= 1; - return next; -} - -function preventTouchScroll(event: TouchEvent) { - if (event.cancelable) event.preventDefault(); -} - export function ModelPresetBadge({ label, modelDetail, @@ -110,204 +62,94 @@ export function ModelPresetBadge({ : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); const interactive = Boolean(onClick); const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; - const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName)); - const pillHeight = isHero ? 32 : 36; - const pillStride = pillHeight + PILL_GAP_PX; - const [motion, setMotion] = useState(null); - const gestureRef = useRef(null); + const badgeClassName = cn( + "thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none", + (interactive || canSwitch) && "cursor-pointer focus-visible:outline-none", + isHero ? "h-8" : "h-9", + ); + const badgeContent = ( + + ); - function clearGesture() { - const gesture = gestureRef.current; - if (gesture?.timer) clearTimeout(gesture.timer); - if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); - gestureRef.current = null; - } - - useEffect(() => { - if (!canSwitch) { - clearGesture(); - setMotion(null); - } - return clearGesture; - }, [canSwitch]); - - useEffect(() => { - if (!motion?.settling) return; - const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80); - return () => clearTimeout(timer); - }, [motion?.settling]); - - function updateMotion(gesture: PresetGesture, clientY: number) { - const raw = -(clientY - gesture.startY) / pillStride; - gesture.step = stepWithHysteresis(raw, gesture.step); - setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false }); - } - - function handlePointerDown(event: PointerEvent) { - if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return; - if (event.pointerType === "mouse" && event.button !== 0) return; - const gesture: PresetGesture = { - active: false, - baseIndex: currentIndex, - latestY: event.clientY, - pointerId: event.pointerId, - startY: event.clientY, - step: 0, - target: event.currentTarget, - timer: null, - }; - gesture.timer = setTimeout(() => { - if (gestureRef.current !== gesture) return; - gesture.active = true; - updateMotion(gesture, gesture.latestY); - gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false }); - try { - gesture.target.setPointerCapture(gesture.pointerId); - } catch { /* The pointer may already have ended. */ } - }, LONG_PRESS_MS); - gestureRef.current = gesture; - } - - function handlePointerMove(event: PointerEvent) { - const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId) return; - gesture.latestY = event.clientY; - if (!gesture.active) { - if (Math.abs(event.clientY - gesture.startY) > PRESS_SLOP_PX) clearGesture(); - return; - } - event.preventDefault(); - updateMotion(gesture, event.clientY); - } - - function finishGesture(event: PointerEvent, commit: boolean) { - const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId) return; - clearGesture(); - if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) { - event.currentTarget.releasePointerCapture?.(gesture.pointerId); - } - if (!commit || !gesture.active) { - setMotion(null); - return; - } - const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)]; - setMotion((current) => current && { ...current, remainder: 0, settling: true }); - if (selected && selected.name !== activeName) onPresetChange?.(selected.name); - } - - function handleKeyDown(event: KeyboardEvent) { - if (!canSwitch) return; - const targetByKey: Record = { - ArrowUp: currentIndex - 1, - ArrowDown: currentIndex + 1, - Home: 0, - End: presets.length - 1, - }; - const target = targetByKey[event.key]; - if (target === undefined) return; - event.preventDefault(); - const next = presets[wrapIndex(target, presets.length)]; - if (next?.name !== activeName) onPresetChange?.(next.name); - } - - const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length); - const previewPreset = presets[previewIndex]; - const Container = interactive || canSwitch ? "button" : "span"; - const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0; - - return ( - { - const gesture = gestureRef.current; - if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture(); - }} - onPointerUp={(event) => finishGesture(event, true)} - onPointerCancel={(event) => finishGesture(event, false)} - onLostPointerCapture={(event) => finishGesture(event, false)} - onContextMenu={(event) => { - if (gestureRef.current?.active) event.preventDefault(); - }} - onDragStart={(event) => event.preventDefault()} - style={{ touchAction: canSwitch ? "manipulation" : undefined }} - className={cn( - "thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none", - interactive && "cursor-pointer", - canSwitch && "cursor-grab select-none focus-visible:outline-none", - motion && "z-10 cursor-grabbing", - isHero ? "h-8" : "h-9", - )} - > - - {motion ? ( - + + + + - { - if (motion.settling && event.currentTarget === event.target) setMotion(null); - }} - style={{ - paddingTop: isHero ? "10px" : "12px", - transform: `translate3d(0, ${trackOffset}px, 0)`, + { + if (name !== activeName) onPresetChange?.(name); }} > - {PILL_OFFSETS.map((offset) => { - const virtualIndex = motion.index + offset; - const preset = presets[wrapIndex(virtualIndex, presets.length)]; - const scale = motion.settling ? 1 : dockScale(offset - motion.remainder); + {presets.map((preset) => { + const detail = [...new Set([preset.model, preset.provider].filter(Boolean))] + .join(" · "); return ( - + + + + {preset.label || preset.name} + + {detail ? ( + + {detail} + + ) : null} + + ); })} - - - ) : null} - + + + + ); + } + + if (interactive) { + return ( + + ); + } + + return ( + + {badgeContent} + ); } function PresetPill({ - className, label, modelDetail, provider, @@ -315,10 +157,8 @@ function PresetPill({ needsSetup = false, fallbackModelName, isHero, - offset, - scale, + showPicker = false, }: { - className?: string | false | null; label: string; modelDetail?: string | null; provider?: string | null; @@ -326,8 +166,7 @@ function PresetPill({ needsSetup?: boolean; fallbackModelName?: string | null; isHero: boolean; - offset?: number; - scale?: number; + showPicker?: boolean; }) { const labelRef = useRef(null); const [labelOverflows, setLabelOverflows] = useState(false); @@ -337,11 +176,9 @@ function PresetPill({ 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 - ? "composer-model-setup-icon" - : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`; + const logoTestId = needsSetup + ? "composer-model-setup-icon" + : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`; useLayoutEffect(() => { const node = labelRef.current; @@ -356,22 +193,15 @@ function PresetPill({ return ( {label} + {showPicker ? ( + + ) : null} ); } diff --git a/webui/src/globals.css b/webui/src/globals.css index c5b2c13db..aa2395cc0 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -738,54 +738,14 @@ mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); } -.thread-composer-model-badge:not([data-switching="true"]):active - > .composer-model-pill { +.thread-composer-model-badge:active > .composer-model-pill { transform: scale(0.98); } -@keyframes composer-model-pill-viewport-enter { - from { - transform: scale(0.9074); - } - - to { - transform: scale(1); - } -} - -.composer-model-pill-viewport { - transform-origin: right center; - animation: composer-model-pill-viewport-enter 210ms - cubic-bezier(0.2, 0.8, 0.2, 1) both; - -webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); - mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent); -} - -.composer-model-pill-dock { - transform-origin: right center; - transition-property: none; - will-change: transform; -} - -.composer-model-pill-track[data-settling="true"], -.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock { - transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); -} - @media (prefers-reduced-motion: reduce) { .thread-composer-model-badge:active > .composer-model-pill { transform: none !important; } - - .composer-model-pill-track[data-settling="true"], - .composer-model-pill-dock { - transition: none; - will-change: auto; - } - - .composer-model-pill-viewport { - animation: none; - } } @container thread-composer (max-width: 21rem) { @@ -838,6 +798,10 @@ .thread-composer-model-label { display: none; } + + .thread-composer-model-chevron { + display: none; + } } @container thread-composer (max-width: 16rem) { diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 5753f6e00..dcb3fe854 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; @@ -313,28 +314,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") { />, ); return { - badge: screen.getByRole("spinbutton", { name: "Kimi" }), + badge: screen.getByRole("button", { name: "Kimi" }), onPresetChange, }; } -function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) { - fireEvent.pointerDown(badge, { - button, - clientY, - isPrimary: true, - pointerId, - pointerType: "mouse", - }); -} - -function longPress(badge: HTMLElement, pointerId = 7) { - pointerDown(badge, pointerId); - act(() => { - vi.advanceTimersByTime(400); - }); -} - describe("ThreadComposer", () => { it("focuses and sends a removable quoted answer excerpt", async () => { const onSend = vi.fn(); @@ -428,7 +412,7 @@ describe("ThreadComposer", () => { />, ); - const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" }); + const badge = screen.getByRole("button", { name: "gpt-5.6-sol" }); expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]"); expect(badge).not.toHaveClass("w-[5.75rem]"); expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); @@ -461,93 +445,32 @@ describe("ThreadComposer", () => { expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); }); - it("scrolls complete preset pills after a left-button long press and wraps", () => { - vi.useFakeTimers(); + it("opens a preset menu on click and switches the selected preset", async () => { + const user = userEvent.setup(); const { badge, onPresetChange } = renderPresetComposer(); expect(badge).toHaveClass("h-9"); - expect(badge).toHaveStyle({ touchAction: "manipulation" }); - const idleTouchMove = new Event("touchmove", { - bubbles: true, - cancelable: true, - }); - 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)); - fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); - expect(onPresetChange).not.toHaveBeenCalled(); + expect(badge).toHaveAttribute("aria-haspopup", "menu"); + expect(badge).toHaveAttribute("aria-expanded", "false"); - longPress(badge); - expect(badge).toHaveAttribute("data-switching", "true"); - const viewport = screen.getByTestId("composer-model-pill-viewport"); - expect(viewport).toHaveClass( - "right-0", - "w-max", - "max-w-[calc(44vw+0.5rem)]", - "overflow-hidden", - "-top-3", - "-bottom-3", - ); - const track = screen.getByTestId("composer-model-pill-track"); - expect(track).toHaveClass("w-max", "max-w-full", "items-end", "gap-1"); - const activeTouchMove = new Event("touchmove", { - bubbles: true, - cancelable: true, - }); - badge.dispatchEvent(activeTouchMove); - expect(activeTouchMove.defaultPrevented).toBe(true); - const pills = track.querySelectorAll(".composer-model-pill"); - expect(pills).toHaveLength(5); - expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true); - expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true); - expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true); - const centeredPill = track.querySelector("[data-preset-offset='0']"); - expect(centeredPill).toHaveTextContent("Kimi"); - expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" }); - expect( - track.querySelector("[data-preset-offset='1']"), - ).toHaveStyle({ transform: "scale(1.0200)" }); - - fireEvent.pointerMove(badge, { - clientY: 122, - pointerId: 7, - pointerType: "mouse", - }); - expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("Kimi"); - fireEvent.pointerMove(badge, { - clientY: 123, - pointerId: 7, - pointerType: "mouse", - }); - expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("DS Pro"); - fireEvent.pointerUp(badge, { - clientY: 123, - pointerId: 7, - pointerType: "mouse", - }); + await user.click(badge); + expect(badge).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("menuitemradio", { name: /Kimi.*moonshot/i })) + .toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("menuitemradio", { name: /DFlash.*deepseek/i })) + .toBeInTheDocument(); + await user.click(screen.getByRole("menuitemradio", { name: /DS Pro.*deepseek/i })); expect(onPresetChange).toHaveBeenCalledWith("dspro"); - expect(badge).toHaveAttribute("data-settling", "true"); - expect(track).toHaveAttribute("data-settling", "true"); - act(() => { - vi.advanceTimersByTime(260); - }); - expect(badge).not.toHaveAttribute("data-switching"); - expect(badge).not.toHaveAttribute("data-settling"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); - it("supports the same long-press switcher in hero mode and cancels pointercancel", () => { - vi.useFakeTimers(); + it("supports the same preset menu in hero mode", async () => { + const user = userEvent.setup(); const { badge, onPresetChange } = renderPresetComposer("hero"); expect(badge).toHaveClass("h-8"); - longPress(badge, 9); - expect(badge).toHaveAttribute("data-switching", "true"); - fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); - fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); - expect(badge).not.toHaveAttribute("data-switching"); - expect(onPresetChange).not.toHaveBeenCalled(); + await user.click(badge); + await user.click(screen.getByRole("menuitemradio", { name: /DFlash.*deepseek/i })); + expect(onPresetChange).toHaveBeenCalledWith("dflash"); }); it("transcribes voice input into the composer without sending", async () => { diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 3961d3a95..f4375be2f 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -586,19 +586,18 @@ describe("ThreadShell", () => { )); const { rerender } = render(view("default")); - const badge = await screen.findByRole("spinbutton", { name: "Default" }); + const badge = await screen.findByRole("button", { name: "Default" }); expect(badge).toHaveTextContent("Default"); - fireEvent.keyDown(badge, { key: "ArrowDown" }); + fireEvent.pointerDown(badge); + fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ })); expect(client.sendSystemCommand).toHaveBeenCalledWith( "preset-order", "/model fast", ); expect(await screen.findByText("Fast")).toBeInTheDocument(); - fireEvent.keyDown( - screen.getByRole("spinbutton", { name: "Fast" }), - { key: "End" }, - ); + fireEvent.pointerDown(screen.getByRole("button", { name: "Fast" })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Extra/ })); expect(client.sendSystemCommand).toHaveBeenLastCalledWith( "preset-order", "/model extra", @@ -972,10 +971,8 @@ describe("ThreadShell", () => { )); const { rerender } = render(view(null)); - fireEvent.keyDown( - await screen.findByRole("spinbutton", { name: "Default" }), - { key: "ArrowDown" }, - ); + fireEvent.pointerDown(await screen.findByRole("button", { name: "Default" })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ })); expect(await screen.findByText("Fast")).toBeInTheDocument(); expect(client.sendSystemCommand).not.toHaveBeenCalled();