mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat(webui): polish model preset interaction
This commit is contained in:
@@ -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)]",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user