feat(webui): polish model preset interaction

This commit is contained in:
chengyongru
2026-08-21 16:49:30 +08:00
committed by chengyongru
parent 953d2ed217
commit d28687e2e1
4 changed files with 148 additions and 20 deletions
@@ -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<PresetMotion | null>(null);
const gestureRef = useRef<PresetGesture | null>(null);
const clickAnimationFrameRef = useRef<number | null>(null);
const suppressClickRef = useRef(false);
const suppressClickTimerRef = useRef<number | null>(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<HTMLElement>) {
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 = (
<Container
data-switching={motion ? "true" : undefined}
data-settling={motion?.settling ? "true" : undefined}
@@ -225,8 +283,9 @@ export function ModelPresetBadge({
aria-valuenow={canSwitch ? previewIndex : undefined}
aria-valuetext={canSwitch ? previewPreset?.name || label : undefined}
role={canSwitch ? "spinbutton" : undefined}
tabIndex={!interactive && !canSwitch ? 0 : undefined}
type={interactive || canSwitch ? "button" : undefined}
onClick={interactive ? onClick : undefined}
onClick={interactive || canSwitch ? handleClick : undefined}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
@@ -255,7 +314,6 @@ export function ModelPresetBadge({
label={label}
modelDetail={modelDetail}
provider={provider}
providerLabel={providerLabel}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
@@ -302,6 +360,24 @@ export function ModelPresetBadge({
) : null}
</Container>
);
if (!tooltipLabel) return badge;
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
collisionPadding={12}
className="max-w-[min(24rem,calc(100vw-2rem))] break-all"
>
{tooltipLabel}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
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({
<span
data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset}
title={fallbackModelName || title || undefined}
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",
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
+1 -1
View File
@@ -674,7 +674,7 @@
/*
* Composer controls compress against their actual container, not the viewport.
* Permission state has priority over the model label; both retain their full
* accessible names and title text when their visible labels are shortened.
* accessible names and tooltip text when their visible labels are shortened.
*/
.thread-composer-surface {
container-name: thread-composer;
+49 -1
View File
@@ -571,6 +571,53 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
it("shows model details in the shared tooltip without a native title", async () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
modelDetail="gpt-4o"
modelProvider="openai"
modelProviderLabel="OpenAI"
placeholder="Type your message..."
/>,
);
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(() => {
+18 -10
View File
@@ -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);