mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
fix(webui): make model preset switching discoverable
This commit is contained in:
parent
8b134a13d2
commit
f770e4f53c
@ -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 <kbd>Up</kbd> / <kbd>Down</kbd> to
|
||||
move between presets or <kbd>Home</kbd> / <kbd>End</kbd> 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
|
||||
<kbd>Enter</kbd> or <kbd>Space</kbd> to open the menu, use the arrow keys to move,
|
||||
and press <kbd>Enter</kbd> 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
|
||||
|
||||
@ -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<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({
|
||||
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<PresetMotion | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(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 = (
|
||||
<PresetPill
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={provider}
|
||||
providerLabel={providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
showPicker={canSwitch}
|
||||
/>
|
||||
);
|
||||
|
||||
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<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
|
||||
if (canSwitch) {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" aria-label={label} className={badgeClassName}>
|
||||
{badgeContent}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="w-[min(20rem,calc(100vw-2rem))] rounded-[18px]"
|
||||
>
|
||||
<span
|
||||
data-testid="composer-model-pill-track"
|
||||
data-settling={motion.settling ? "true" : undefined}
|
||||
className="composer-model-pill-track ml-auto flex w-max max-w-full flex-col items-end gap-1 will-change-transform"
|
||||
onTransitionEnd={(event) => {
|
||||
if (motion.settling && event.currentTarget === event.target) setMotion(null);
|
||||
}}
|
||||
style={{
|
||||
paddingTop: isHero ? "10px" : "12px",
|
||||
transform: `translate3d(0, ${trackOffset}px, 0)`,
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeName}
|
||||
onValueChange={(name) => {
|
||||
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 (
|
||||
<PresetPill
|
||||
key={virtualIndex}
|
||||
label={preset.label || preset.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={scale}
|
||||
/>
|
||||
<DropdownMenuRadioItem
|
||||
key={preset.name}
|
||||
value={preset.name}
|
||||
className="min-h-[46px] items-start rounded-[14px] py-2.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-foreground">
|
||||
{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>
|
||||
</span>
|
||||
) : null}
|
||||
</Container>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
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<HTMLSpanElement | null>(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 (
|
||||
<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)]",
|
||||
"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",
|
||||
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",
|
||||
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
|
||||
data-testid={logoTestId}
|
||||
@ -422,6 +252,12 @@ function PresetPill({
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{showPicker ? (
|
||||
<ChevronDown
|
||||
className="thread-composer-model-chevron h-3.5 w-3.5 shrink-0 text-muted-foreground/75"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<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",
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user