Compare commits

..
8 changed files with 177 additions and 711 deletions
+7
View File
@@ -49,6 +49,13 @@ Use `/model` to inspect the current runtime model:
The response shows the current session's model and preset, plus the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
`/model <preset>` expects one of those preset names, not a provider model ID or
the preset's display label. For example, if `modelPresets.local` uses the Ollama
model `llama3.2`, run `/model local`, not `/model llama3.2`. If a model is currently
configured only as an inline fallback, save it as a named preset before selecting
it manually. Fallback order controls automatic failover; it is not a list of raw
model IDs accepted by `/model`.
To switch presets for future turns:
```text
+13
View File
@@ -147,6 +147,19 @@ 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 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
presets from **Settings → Models** are selectable. An inline fallback model that
has not been saved as a named preset is not a separate manual choice. Save it as
a named preset to make it selectable. The same switch is available in chat with
`/model <preset>`; see [Chat Commands: Model Presets](./chat-commands.md#model-presets).
For image generation, configure an image provider first and then use the WebUI
image mode from the composer. See [`image-generation.md`](./image-generation.md)
for provider setup and output behavior.
+9 -92
View File
@@ -12,9 +12,7 @@ from collections import OrderedDict
from contextlib import suppress
from pathlib import Path
from typing import Any, Literal, NamedTuple, cast
from urllib.parse import urlparse
import httpx
from pydantic import Field
from nanobot.bus.events import OutboundMessage
@@ -22,7 +20,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir
from nanobot.config.schema import Base
from nanobot.security.network import PinnedDNSAsyncTransport
class WhatsAppConfig(Base):
@@ -42,8 +39,6 @@ class _NeonizeAPI(NamedTuple):
MessageEv: Any
PairStatusEv: Any
build_jid: Any
detect_mime: Any
detect_buffer: Any
class _MediaInfo(NamedTuple):
@@ -57,15 +52,6 @@ class _MediaInfo(NamedTuple):
_NEONIZE_API: _NeonizeAPI | None = None
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
_REMOTE_MEDIA_MAX_BYTES = 32 * 1024 * 1024
_REMOTE_MEDIA_MAX_REDIRECTS = 5
_REMOTE_MEDIA_TIMEOUT_SECONDS = 120.0
# OGG is intentionally excluded: WhatsApp accepts only mono Opus, which MIME sniffing cannot prove.
_DIRECT_AUDIO_MIMETYPES = {"audio/aac", "audio/amr", "audio/mp4", "audio/mpeg"}
_MIMETYPE_ALIASES = {
"audio/x-hx-aac-adts": "audio/aac",
"audio/x-m4a": "audio/mp4",
}
def _default_database_path() -> Path:
@@ -82,15 +68,9 @@ def _load_neonize() -> _NeonizeAPI:
return _NEONIZE_API
try:
import magic
from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
from neonize.utils.jid import build_jid
detect_mime = getattr(magic, "from_file", None)
detect_buffer = getattr(magic, "from_buffer", None)
if not callable(detect_mime) or not callable(detect_buffer):
raise ImportError("python-magic does not expose from_file/from_buffer")
except ImportError as exc:
raise RuntimeError(
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
@@ -103,8 +83,6 @@ def _load_neonize() -> _NeonizeAPI:
MessageEv=MessageEv,
PairStatusEv=PairStatusEv,
build_jid=build_jid,
detect_mime=detect_mime,
detect_buffer=detect_buffer,
)
return _NEONIZE_API
@@ -439,84 +417,23 @@ class WhatsAppChannel(BaseChannel):
return api.build_jid(user, server)
async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
source: str | bytes
if media_path.startswith(("http://", "https://")):
source = await self._fetch_remote_media(media_path)
filename = Path(urlparse(media_path).path).name or "attachment"
else:
source = str(Path(media_path).expanduser())
filename = Path(source).name
mimetype = self._detect_mimetype(source)
path = str(Path(media_path).expanduser())
mime, _ = mimetypes.guess_type(path)
mimetype = mime or "application/octet-stream"
if mimetype.startswith("image/"):
await client.send_image(to, source)
await client.send_image(to, path)
elif mimetype.startswith("video/"):
await client.send_video(to, source)
elif mimetype in _DIRECT_AUDIO_MIMETYPES:
await client.send_audio(to, source)
await client.send_video(to, path)
elif mimetype.startswith("audio/"):
await client.send_audio(to, path)
else:
await client.send_document(
to,
source,
filename=filename,
path,
filename=Path(path).name,
mimetype=mimetype,
)
async def _fetch_remote_media(self, url: str) -> bytes:
timeout = httpx.Timeout(_REMOTE_MEDIA_TIMEOUT_SECONDS, connect=10.0)
async with httpx.AsyncClient(
transport=PinnedDNSAsyncTransport(),
follow_redirects=True,
max_redirects=_REMOTE_MEDIA_MAX_REDIRECTS,
timeout=timeout,
trust_env=False,
) as http:
async with http.stream("GET", url) as response:
response.raise_for_status()
declared_size = response.headers.get("content-length")
if (
declared_size
and declared_size.isdigit()
and int(declared_size) > _REMOTE_MEDIA_MAX_BYTES
):
raise ValueError(
f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit"
)
chunks: list[bytes] = []
total = 0
async for chunk in response.aiter_bytes():
total += len(chunk)
if total > _REMOTE_MEDIA_MAX_BYTES:
raise ValueError(
f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit"
)
chunks.append(chunk)
return b"".join(chunks)
def _detect_mimetype(self, source: str | bytes) -> str:
try:
api = _load_neonize()
detected = (
api.detect_buffer(source, mime=True)
if isinstance(source, bytes)
else api.detect_mime(source, mime=True)
)
except Exception as exc:
label = f"{len(source)} downloaded bytes" if isinstance(source, bytes) else source
self.logger.debug("Failed to inspect WhatsApp media {}: {}", label, exc)
detected = None
if isinstance(detected, str) and "/" in detected:
mimetype = detected.partition(";")[0].strip().lower()
return _MIMETYPE_ALIASES.get(mimetype, mimetype)
if isinstance(source, bytes):
return "application/octet-stream"
guessed, _ = mimetypes.guess_type(source)
return guessed or "application/octet-stream"
def _register_handlers(
self,
client: Any,
@@ -1,13 +1,11 @@
from __future__ import annotations
import asyncio
import mimetypes
import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import nanobot.channels.whatsapp.runtime as whatsapp_module
@@ -80,21 +78,7 @@ def _make_channel(config: dict | None = None) -> WhatsAppChannel:
return ch
def _make_send_client() -> SimpleNamespace:
return SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
def _patch_neonize_api(monkeypatch, detect_mime=None, detect_buffer=None) -> None:
detect_mime = detect_mime or (
lambda path, *, mime: mimetypes.guess_type(path)[0] or "application/octet-stream"
)
detect_buffer = detect_buffer or (lambda data, *, mime: "application/octet-stream")
def _patch_neonize_api(monkeypatch) -> None:
monkeypatch.setattr(
whatsapp_module,
"_NEONIZE_API",
@@ -105,8 +89,6 @@ def _patch_neonize_api(monkeypatch, detect_mime=None, detect_buffer=None) -> Non
MessageEv=object(),
PairStatusEv=object(),
build_jid=lambda user, server="s.whatsapp.net": (user, server),
detect_mime=detect_mime,
detect_buffer=detect_buffer,
),
)
@@ -196,7 +178,13 @@ async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
@pytest.mark.asyncio
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _make_send_client()
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
@@ -209,7 +197,13 @@ async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
@pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _make_send_client()
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
@@ -219,14 +213,14 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["photo.jpg", "clip.mp4", "voice.mp3", "report.pdf"],
media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_image.assert_awaited_once_with(jid, "photo.jpg")
client.send_video.assert_awaited_once_with(jid, "clip.mp4")
client.send_audio.assert_awaited_once_with(jid, "voice.mp3")
client.send_audio.assert_awaited_once_with(jid, "voice.ogg")
client.send_document.assert_awaited_once_with(
jid,
"report.pdf",
@@ -235,191 +229,6 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
)
@pytest.mark.asyncio
async def test_send_mislabeled_audio_as_document(monkeypatch) -> None:
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/x-wav")
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["recording.mpeg"],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_document.assert_awaited_once_with(
jid,
"recording.mpeg",
filename="recording.mpeg",
mimetype="audio/x-wav",
)
client.send_video.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_remote_mislabeled_audio_as_document(monkeypatch) -> None:
payload = b"remote wav payload"
media_url = "https://cdn.example/recording.mpeg?token=secret"
def handle_request(request: httpx.Request) -> httpx.Response:
assert str(request.url) == media_url
return httpx.Response(200, content=payload)
monkeypatch.setattr(
whatsapp_module,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(handle_request),
)
def detect_buffer(data: bytes, *, mime: bool) -> str:
assert data == payload
assert mime is True
return "audio/x-wav"
_patch_neonize_api(
monkeypatch,
detect_buffer=detect_buffer,
)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=[media_url],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_document.assert_awaited_once_with(
jid,
payload,
filename="recording.mpeg",
mimetype="audio/x-wav",
)
client.send_video.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_remote_media_blocks_private_url(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
with pytest.raises(httpx.RequestError, match="private/internal"):
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["http://127.0.0.1/recording.mpeg"],
)
)
client.send_video.assert_not_awaited()
client.send_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_remote_media_enforces_download_limit(monkeypatch) -> None:
monkeypatch.setattr(whatsapp_module, "_REMOTE_MEDIA_MAX_BYTES", 3)
monkeypatch.setattr(
whatsapp_module,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, content=b"1234")),
)
_patch_neonize_api(monkeypatch)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
with pytest.raises(ValueError, match="exceeds the 3-byte limit"):
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["https://cdn.example/recording.mpeg"],
)
)
client.send_video.assert_not_awaited()
client.send_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_unsupported_ogg_audio_as_document(monkeypatch) -> None:
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/ogg")
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["voice.ogg"],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_document.assert_awaited_once_with(
jid,
"voice.ogg",
filename="voice.ogg",
mimetype="audio/ogg",
)
client.send_audio.assert_not_awaited()
@pytest.mark.parametrize(
("detected_mimetype", "filename"),
[
("audio/x-m4a", "recording.m4a"),
("audio/x-hx-aac-adts", "recording.aac"),
],
)
@pytest.mark.asyncio
async def test_send_supported_audio_magic_aliases_inline(
monkeypatch, detected_mimetype: str, filename: str
) -> None:
_patch_neonize_api(
monkeypatch,
detect_mime=lambda path, *, mime: detected_mimetype,
)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=[filename],
)
)
client.send_audio.assert_awaited_once_with(("12345", "s.whatsapp.net"), filename)
client.send_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_when_disconnected_raises() -> None:
ch = _make_channel()
+99 -263
View File
@@ -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>
);
}
+5 -41
View File
@@ -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) {
+20 -97
View File
@@ -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 () => {
+7 -10
View File
@@ -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();