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"; } from "react";
import { CircleHelp, Sparkles } from "lucide-react"; import { CircleHelp, Sparkles } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
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";
@@ -113,6 +119,9 @@ export function ModelPresetBadge({
const pillStride = pillHeight + PILL_GAP_PX; const pillStride = pillHeight + PILL_GAP_PX;
const [motion, setMotion] = useState<PresetMotion | null>(null); const [motion, setMotion] = useState<PresetMotion | null>(null);
const gestureRef = useRef<PresetGesture | 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() { function clearGesture() {
const gesture = gestureRef.current; const gesture = gestureRef.current;
@@ -126,7 +135,17 @@ export function ModelPresetBadge({
clearGesture(); clearGesture();
setMotion(null); 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]); }, [canSwitch]);
useEffect(() => { useEffect(() => {
@@ -141,6 +160,42 @@ export function ModelPresetBadge({
setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false }); 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>) { function handlePointerDown(event: PointerEvent<HTMLElement>) {
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return; if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
if (event.pointerType === "mouse" && event.button !== 0) return; if (event.pointerType === "mouse" && event.button !== 0) return;
@@ -185,6 +240,7 @@ export function ModelPresetBadge({
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) { if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
event.currentTarget.releasePointerCapture?.(gesture.pointerId); event.currentTarget.releasePointerCapture?.(gesture.pointerId);
} }
if (gesture.active) suppressFollowingClick();
if (!commit || !gesture.active) { if (!commit || !gesture.active) {
setMotion(null); setMotion(null);
return; return;
@@ -213,8 +269,10 @@ export function ModelPresetBadge({
const previewPreset = presets[previewIndex]; const previewPreset = presets[previewIndex];
const Container = interactive || canSwitch ? "button" : "span"; const Container = interactive || canSwitch ? "button" : "span";
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0; const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0;
const tooltipLabel = fallbackModelName
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
return ( const badge = (
<Container <Container
data-switching={motion ? "true" : undefined} data-switching={motion ? "true" : undefined}
data-settling={motion?.settling ? "true" : undefined} data-settling={motion?.settling ? "true" : undefined}
@@ -225,8 +283,9 @@ export function ModelPresetBadge({
aria-valuenow={canSwitch ? previewIndex : undefined} aria-valuenow={canSwitch ? previewIndex : undefined}
aria-valuetext={canSwitch ? previewPreset?.name || label : undefined} aria-valuetext={canSwitch ? previewPreset?.name || label : undefined}
role={canSwitch ? "spinbutton" : undefined} role={canSwitch ? "spinbutton" : undefined}
tabIndex={!interactive && !canSwitch ? 0 : undefined}
type={interactive || canSwitch ? "button" : undefined} type={interactive || canSwitch ? "button" : undefined}
onClick={interactive ? onClick : undefined} onClick={interactive || canSwitch ? handleClick : undefined}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
@@ -255,7 +314,6 @@ export function ModelPresetBadge({
label={label} label={label}
modelDetail={modelDetail} modelDetail={modelDetail}
provider={provider} provider={provider}
providerLabel={providerLabel}
needsSetup={needsSetup} needsSetup={needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
isHero={isHero} isHero={isHero}
@@ -302,6 +360,24 @@ export function ModelPresetBadge({
) : null} ) : null}
</Container> </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({ function PresetPill({
@@ -309,7 +385,6 @@ function PresetPill({
label, label,
modelDetail, modelDetail,
provider, provider,
providerLabel,
needsSetup = false, needsSetup = false,
fallbackModelName, fallbackModelName,
isHero, isHero,
@@ -320,7 +395,6 @@ function PresetPill({
label: string; label: string;
modelDetail?: string | null; modelDetail?: string | null;
provider?: string | null; provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean; needsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
isHero: boolean; isHero: boolean;
@@ -334,7 +408,6 @@ function PresetPill({
: provider || inferProviderFromModelName(modelDetail || label); : provider || inferProviderFromModelName(modelDetail || label);
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 logoTestId = offset !== undefined const logoTestId = offset !== undefined
? undefined ? undefined
: needsSetup : needsSetup
@@ -355,7 +428,6 @@ function PresetPill({
<span <span
data-fallback={fallbackModelName ? "true" : undefined} data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset} data-preset-offset={offset}
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)]", 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. * Composer controls compress against their actual container, not the viewport.
* Permission state has priority over the model label; both retain their full * 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 { .thread-composer-surface {
container-name: thread-composer; container-name: thread-composer;
+49 -1
View File
@@ -571,6 +571,53 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); 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", () => { it("scrolls complete preset pills after a left-button long press and wraps", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer(); const { badge, onPresetChange } = renderPresetComposer();
@@ -582,7 +629,6 @@ describe("ThreadComposer", () => {
}); });
badge.dispatchEvent(idleTouchMove); badge.dispatchEvent(idleTouchMove);
expect(idleTouchMove.defaultPrevented).toBe(false); expect(idleTouchMove.defaultPrevented).toBe(false);
fireEvent.click(badge);
pointerDown(badge); pointerDown(badge);
fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
act(() => vi.advanceTimersByTime(500)); act(() => vi.advanceTimersByTime(500));
@@ -639,6 +685,8 @@ describe("ThreadComposer", () => {
}); });
expect(onPresetChange).toHaveBeenCalledWith("dspro"); expect(onPresetChange).toHaveBeenCalledWith("dspro");
fireEvent.click(badge);
expect(onPresetChange).toHaveBeenCalledTimes(1);
expect(badge).toHaveAttribute("data-settling", "true"); expect(badge).toHaveAttribute("data-settling", "true");
expect(track).toHaveAttribute("data-settling", "true"); expect(track).toHaveAttribute("data-settling", "true");
act(() => { act(() => {
+18 -10
View File
@@ -609,8 +609,12 @@ describe("ThreadShell", () => {
), ),
); );
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); const badge = await screen.findByLabelText("fast");
expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument(); 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 () => { 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(); expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
}); });
@@ -740,11 +748,11 @@ describe("ThreadShell", () => {
expect(screen.getByText("Default")).toBeInTheDocument(); expect(screen.getByText("Default")).toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument(); expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true"); expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).toHaveAttribute( expect(badge).not.toHaveAttribute("title");
"title",
"deepseek/deepseek-chat",
);
expect(logo).not.toHaveAttribute("data-fallback"); expect(logo).not.toHaveAttribute("data-fallback");
const trigger = screen.getByLabelText("Default");
fireEvent.focus(trigger);
expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat");
act(() => { act(() => {
client._emitChat("fallback-model", { client._emitChat("fallback-model", {
@@ -758,9 +766,9 @@ describe("ThreadShell", () => {
screen.getByTestId("composer-model-logo-openai_codex").parentElement, screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).not.toHaveAttribute("data-fallback"); ).not.toHaveAttribute("data-fallback");
}); });
expect( expect(screen.getByRole("tooltip")).toHaveTextContent(
screen.getByTestId("composer-model-logo-openai_codex").parentElement, "Default · gpt-5.5 · OpenAI Codex",
).toHaveAttribute("title", "Default · gpt-5.5 · OpenAI Codex"); );
expect( expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement, screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge); ).toBe(badge);