fix(webui): make model preset switching discoverable

This commit is contained in:
chengyongru 2026-08-01 12:14:37 +08:00
parent 8b134a13d2
commit f770e4f53c
5 changed files with 136 additions and 417 deletions

View File

@ -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 or MCP presets. The model badge shows the current model or preset and links back
to model settings when setup is incomplete. to model settings when setup is incomplete.
When two or more named model presets are configured, the badge also acts as a When two or more named model presets are configured, the badge shows a dropdown
vertical preset selector. Press and hold the badge for about 0.4 seconds, drag up indicator and acts as a preset selector. Click or tap it, then choose the preset
or down, and release on the preset you want. A plain click does not open a menu. you want from the menu. For keyboard access, focus the badge and press
For keyboard access, focus the badge and use <kbd>Up</kbd> / <kbd>Down</kbd> to <kbd>Enter</kbd> or <kbd>Space</kbd> to open the menu, use the arrow keys to move,
move between presets or <kbd>Home</kbd> / <kbd>End</kbd> to jump to the first or and press <kbd>Enter</kbd> to select.
last preset.
The selection applies to future turns in the current session and persists with 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 that session; it does not change the default for other sessions. Only named

View File

@ -1,13 +1,13 @@
import { import { useLayoutEffect, useRef, useState } from "react";
useEffect, import { ChevronDown, CircleHelp, Sparkles } from "lucide-react";
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { CircleHelp, Sparkles } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -33,54 +33,6 @@ interface ModelPresetBadgeProps {
onClick?: () => void; onClick?: () => void;
} }
interface PresetGesture {
active: boolean;
baseIndex: number;
latestY: number;
pointerId: number;
startY: number;
step: number;
target: HTMLElement;
timer: ReturnType<typeof setTimeout> | 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({ export function ModelPresetBadge({
label, label,
modelDetail, modelDetail,
@ -110,204 +62,94 @@ export function ModelPresetBadge({
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
const interactive = Boolean(onClick); const interactive = Boolean(onClick);
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName)); const badgeClassName = cn(
const pillHeight = isHero ? 32 : 36; "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",
const pillStride = pillHeight + PILL_GAP_PX; (interactive || canSwitch) && "cursor-pointer focus-visible:outline-none",
const [motion, setMotion] = useState<PresetMotion | null>(null); isHero ? "h-8" : "h-9",
const gestureRef = useRef<PresetGesture | null>(null); );
const badgeContent = (
<PresetPill
label={label}
modelDetail={modelDetail}
provider={provider}
providerLabel={providerLabel}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
showPicker={canSwitch}
/>
);
function clearGesture() { if (canSwitch) {
const gesture = gestureRef.current; return (
if (gesture?.timer) clearTimeout(gesture.timer); <DropdownMenu modal={false}>
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); <DropdownMenuTrigger asChild>
gestureRef.current = null; <button type="button" aria-label={label} className={badgeClassName}>
} {badgeContent}
</button>
useEffect(() => { </DropdownMenuTrigger>
if (!canSwitch) { <DropdownMenuContent
clearGesture(); align="end"
setMotion(null); side="top"
} sideOffset={8}
return clearGesture; collisionPadding={12}
}, [canSwitch]); className="w-[min(20rem,calc(100vw-2rem))] rounded-[18px]"
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<HTMLElement>) {
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<HTMLElement>) {
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<HTMLElement>, 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<HTMLElement>) {
if (!canSwitch) return;
const targetByKey: Record<string, number> = {
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 (
<Container
data-switching={motion ? "true" : undefined}
data-settling={motion?.settling ? "true" : undefined}
aria-label={label}
aria-orientation={canSwitch ? "vertical" : undefined}
aria-valuemax={canSwitch ? presets.length - 1 : undefined}
aria-valuemin={canSwitch ? 0 : undefined}
aria-valuenow={canSwitch ? previewIndex : undefined}
aria-valuetext={canSwitch ? previewPreset?.label || label : undefined}
role={canSwitch ? "spinbutton" : undefined}
type={interactive || canSwitch ? "button" : undefined}
onClick={interactive ? onClick : undefined}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerLeave={(event) => {
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",
)}
>
<PresetPill
className={motion && "invisible"}
label={label}
modelDetail={modelDetail}
provider={provider}
providerLabel={providerLabel}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
/>
{motion ? (
<span
data-testid="composer-model-pill-viewport"
className={cn(
"composer-model-pill-viewport pointer-events-none absolute right-0 w-max max-w-[calc(44vw+0.5rem)] overflow-hidden bg-transparent pl-2 sm:max-w-[18.5rem]",
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
)}
aria-hidden
> >
<span <DropdownMenuRadioGroup
data-testid="composer-model-pill-track" value={activeName}
data-settling={motion.settling ? "true" : undefined} onValueChange={(name) => {
className="composer-model-pill-track ml-auto flex w-max max-w-full flex-col items-end gap-1 will-change-transform" if (name !== activeName) onPresetChange?.(name);
onTransitionEnd={(event) => {
if (motion.settling && event.currentTarget === event.target) setMotion(null);
}}
style={{
paddingTop: isHero ? "10px" : "12px",
transform: `translate3d(0, ${trackOffset}px, 0)`,
}} }}
> >
{PILL_OFFSETS.map((offset) => { {presets.map((preset) => {
const virtualIndex = motion.index + offset; const detail = [...new Set([preset.model, preset.provider].filter(Boolean))]
const preset = presets[wrapIndex(virtualIndex, presets.length)]; .join(" · ");
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
return ( return (
<PresetPill <DropdownMenuRadioItem
key={virtualIndex} key={preset.name}
label={preset.label || preset.name} value={preset.name}
modelDetail={preset.model} className="min-h-[46px] items-start rounded-[14px] py-2.5"
provider={preset.provider} >
isHero={isHero} <span className="min-w-0 flex-1">
offset={offset} <span className="block truncate font-semibold text-foreground">
scale={scale} {preset.label || preset.name}
/> </span>
{detail ? (
<span className="mt-0.5 block truncate text-[11.5px] text-muted-foreground">
{detail}
</span>
) : null}
</span>
</DropdownMenuRadioItem>
); );
})} })}
</span> </DropdownMenuRadioGroup>
</span> </DropdownMenuContent>
) : null} </DropdownMenu>
</Container> );
}
if (interactive) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={badgeClassName}
>
{badgeContent}
</button>
);
}
return (
<span aria-label={label} className={badgeClassName}>
{badgeContent}
</span>
); );
} }
function PresetPill({ function PresetPill({
className,
label, label,
modelDetail, modelDetail,
provider, provider,
@ -315,10 +157,8 @@ function PresetPill({
needsSetup = false, needsSetup = false,
fallbackModelName, fallbackModelName,
isHero, isHero,
offset, showPicker = false,
scale,
}: { }: {
className?: string | false | null;
label: string; label: string;
modelDetail?: string | null; modelDetail?: string | null;
provider?: string | null; provider?: string | null;
@ -326,8 +166,7 @@ function PresetPill({
needsSetup?: boolean; needsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
isHero: boolean; isHero: boolean;
offset?: number; showPicker?: boolean;
scale?: number;
}) { }) {
const labelRef = useRef<HTMLSpanElement | null>(null); const labelRef = useRef<HTMLSpanElement | null>(null);
const [labelOverflows, setLabelOverflows] = useState(false); const [labelOverflows, setLabelOverflows] = useState(false);
@ -337,11 +176,9 @@ function PresetPill({
const brand = providerBrand(inferredProvider); const brand = providerBrand(inferredProvider);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
const logoTestId = offset !== undefined const logoTestId = needsSetup
? undefined ? "composer-model-setup-icon"
: needsSetup : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
? "composer-model-setup-icon"
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
useLayoutEffect(() => { useLayoutEffect(() => {
const node = labelRef.current; const node = labelRef.current;
@ -356,22 +193,15 @@ function PresetPill({
return ( return (
<span <span
data-fallback={fallbackModelName ? "true" : undefined} data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset}
title={fallbackModelName || title || undefined} title={fallbackModelName || title || undefined}
className={cn( className={cn(
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70", "composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]", "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45", "transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45",
showPicker && "group-hover/model-badge:border-border group-hover/model-badge:text-foreground/85",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200", needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]", isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
offset !== undefined && "composer-model-pill-dock",
className,
)} )}
style={scale === undefined ? undefined : {
height: `${isHero ? 32 : 36}px`,
transform: `scale(${scale.toFixed(4)})`,
zIndex: Math.round(scale * 100),
}}
> >
<span <span
data-testid={logoTestId} data-testid={logoTestId}
@ -422,6 +252,12 @@ function PresetPill({
> >
{label} {label}
</span> </span>
{showPicker ? (
<ChevronDown
className="thread-composer-model-chevron h-3.5 w-3.5 shrink-0 text-muted-foreground/75"
aria-hidden
/>
) : null}
</span> </span>
); );
} }

View File

@ -738,54 +738,14 @@
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
} }
.thread-composer-model-badge:not([data-switching="true"]):active .thread-composer-model-badge:active > .composer-model-pill {
> .composer-model-pill {
transform: scale(0.98); 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) { @media (prefers-reduced-motion: reduce) {
.thread-composer-model-badge:active > .composer-model-pill { .thread-composer-model-badge:active > .composer-model-pill {
transform: none !important; 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) { @container thread-composer (max-width: 21rem) {
@ -838,6 +798,10 @@
.thread-composer-model-label { .thread-composer-model-label {
display: none; display: none;
} }
.thread-composer-model-chevron {
display: none;
}
} }
@container thread-composer (max-width: 16rem) { @container thread-composer (max-width: 16rem) {

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; 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 { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
@ -313,28 +314,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
/>, />,
); );
return { return {
badge: screen.getByRole("spinbutton", { name: "Kimi" }), badge: screen.getByRole("button", { name: "Kimi" }),
onPresetChange, 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", () => { describe("ThreadComposer", () => {
it("focuses and sends a removable quoted answer excerpt", async () => { it("focuses and sends a removable quoted answer excerpt", async () => {
const onSend = vi.fn(); 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).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
expect(badge).not.toHaveClass("w-[5.75rem]"); expect(badge).not.toHaveClass("w-[5.75rem]");
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
@ -461,93 +445,32 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
}); });
it("scrolls complete preset pills after a left-button long press and wraps", () => { it("opens a preset menu on click and switches the selected preset", async () => {
vi.useFakeTimers(); const user = userEvent.setup();
const { badge, onPresetChange } = renderPresetComposer(); const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9"); expect(badge).toHaveClass("h-9");
expect(badge).toHaveStyle({ touchAction: "manipulation" }); expect(badge).toHaveAttribute("aria-haspopup", "menu");
const idleTouchMove = new Event("touchmove", { expect(badge).toHaveAttribute("aria-expanded", "false");
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();
longPress(badge); await user.click(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<HTMLElement>(".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<HTMLElement>("[data-preset-offset='0']");
expect(centeredPill).toHaveTextContent("Kimi");
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
expect(
track.querySelector<HTMLElement>("[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",
});
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(onPresetChange).toHaveBeenCalledWith("dspro");
expect(badge).toHaveAttribute("data-settling", "true"); expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(track).toHaveAttribute("data-settling", "true");
act(() => {
vi.advanceTimersByTime(260);
});
expect(badge).not.toHaveAttribute("data-switching");
expect(badge).not.toHaveAttribute("data-settling");
}); });
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => { it("supports the same preset menu in hero mode", async () => {
vi.useFakeTimers(); const user = userEvent.setup();
const { badge, onPresetChange } = renderPresetComposer("hero"); const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8"); expect(badge).toHaveClass("h-8");
longPress(badge, 9); await user.click(badge);
expect(badge).toHaveAttribute("data-switching", "true"); await user.click(screen.getByRole("menuitemradio", { name: /DFlash.*deepseek/i }));
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); expect(onPresetChange).toHaveBeenCalledWith("dflash");
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
expect(badge).not.toHaveAttribute("data-switching");
expect(onPresetChange).not.toHaveBeenCalled();
}); });
it("transcribes voice input into the composer without sending", async () => { it("transcribes voice input into the composer without sending", async () => {

View File

@ -586,19 +586,18 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view("default")); 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"); expect(badge).toHaveTextContent("Default");
fireEvent.keyDown(badge, { key: "ArrowDown" }); fireEvent.pointerDown(badge);
fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ }));
expect(client.sendSystemCommand).toHaveBeenCalledWith( expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order", "preset-order",
"/model fast", "/model fast",
); );
expect(await screen.findByText("Fast")).toBeInTheDocument(); expect(await screen.findByText("Fast")).toBeInTheDocument();
fireEvent.keyDown( fireEvent.pointerDown(screen.getByRole("button", { name: "Fast" }));
screen.getByRole("spinbutton", { name: "Fast" }), fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Extra/ }));
{ key: "End" },
);
expect(client.sendSystemCommand).toHaveBeenLastCalledWith( expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order", "preset-order",
"/model extra", "/model extra",
@ -972,10 +971,8 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view(null)); const { rerender } = render(view(null));
fireEvent.keyDown( fireEvent.pointerDown(await screen.findByRole("button", { name: "Default" }));
await screen.findByRole("spinbutton", { name: "Default" }), fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ }));
{ key: "ArrowDown" },
);
expect(await screen.findByText("Fast")).toBeInTheDocument(); expect(await screen.findByText("Fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled(); expect(client.sendSystemCommand).not.toHaveBeenCalled();