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();