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. 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: To switch presets for future turns:
```text ```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 or MCP presets. The model badge shows the current model or preset and links back
to model settings when setup is incomplete. 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 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) image mode from the composer. See [`image-generation.md`](./image-generation.md)
for provider setup and output behavior. for provider setup and output behavior.
+9 -92
View File
@@ -12,9 +12,7 @@ from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any, Literal, NamedTuple, cast from typing import Any, Literal, NamedTuple, cast
from urllib.parse import urlparse
import httpx
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -22,7 +20,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir from nanobot.config.paths import get_media_dir, get_runtime_subdir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.security.network import PinnedDNSAsyncTransport
class WhatsAppConfig(Base): class WhatsAppConfig(Base):
@@ -42,8 +39,6 @@ class _NeonizeAPI(NamedTuple):
MessageEv: Any MessageEv: Any
PairStatusEv: Any PairStatusEv: Any
build_jid: Any build_jid: Any
detect_mime: Any
detect_buffer: Any
class _MediaInfo(NamedTuple): class _MediaInfo(NamedTuple):
@@ -57,15 +52,6 @@ class _MediaInfo(NamedTuple):
_NEONIZE_API: _NeonizeAPI | None = None _NEONIZE_API: _NeonizeAPI | None = None
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$") _JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token") _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: def _default_database_path() -> Path:
@@ -82,15 +68,9 @@ def _load_neonize() -> _NeonizeAPI:
return _NEONIZE_API return _NEONIZE_API
try: try:
import magic
from neonize.aioze.client import NewAClient from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
from neonize.utils.jid import build_jid 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: except ImportError as exc:
raise RuntimeError( raise RuntimeError(
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp" "WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
@@ -103,8 +83,6 @@ def _load_neonize() -> _NeonizeAPI:
MessageEv=MessageEv, MessageEv=MessageEv,
PairStatusEv=PairStatusEv, PairStatusEv=PairStatusEv,
build_jid=build_jid, build_jid=build_jid,
detect_mime=detect_mime,
detect_buffer=detect_buffer,
) )
return _NEONIZE_API return _NEONIZE_API
@@ -439,84 +417,23 @@ class WhatsAppChannel(BaseChannel):
return api.build_jid(user, server) return api.build_jid(user, server)
async def _send_media(self, client: Any, to: Any, media_path: str) -> None: async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
source: str | bytes path = str(Path(media_path).expanduser())
if media_path.startswith(("http://", "https://")): mime, _ = mimetypes.guess_type(path)
source = await self._fetch_remote_media(media_path) mimetype = mime or "application/octet-stream"
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)
if mimetype.startswith("image/"): if mimetype.startswith("image/"):
await client.send_image(to, source) await client.send_image(to, path)
elif mimetype.startswith("video/"): elif mimetype.startswith("video/"):
await client.send_video(to, source) await client.send_video(to, path)
elif mimetype in _DIRECT_AUDIO_MIMETYPES: elif mimetype.startswith("audio/"):
await client.send_audio(to, source) await client.send_audio(to, path)
else: else:
await client.send_document( await client.send_document(
to, to,
source, path,
filename=filename, filename=Path(path).name,
mimetype=mimetype, 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( def _register_handlers(
self, self,
client: Any, client: Any,
@@ -1,13 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import mimetypes
import sys import sys
import types import types
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest import pytest
import nanobot.channels.whatsapp.runtime as whatsapp_module import nanobot.channels.whatsapp.runtime as whatsapp_module
@@ -80,21 +78,7 @@ def _make_channel(config: dict | None = None) -> WhatsAppChannel:
return ch return ch
def _make_send_client() -> SimpleNamespace: def _patch_neonize_api(monkeypatch) -> None:
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")
monkeypatch.setattr( monkeypatch.setattr(
whatsapp_module, whatsapp_module,
"_NEONIZE_API", "_NEONIZE_API",
@@ -105,8 +89,6 @@ def _patch_neonize_api(monkeypatch, detect_mime=None, detect_buffer=None) -> Non
MessageEv=object(), MessageEv=object(),
PairStatusEv=object(), PairStatusEv=object(),
build_jid=lambda user, server="s.whatsapp.net": (user, server), 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 @pytest.mark.asyncio
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None: async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
_patch_neonize_api(monkeypatch) _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 = _make_channel()
ch._client = client ch._client = client
ch._connected = True ch._connected = True
@@ -209,7 +197,13 @@ async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None: async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
_patch_neonize_api(monkeypatch) _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 = _make_channel()
ch._client = client ch._client = client
ch._connected = True ch._connected = True
@@ -219,14 +213,14 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
channel="whatsapp", channel="whatsapp",
chat_id="12345@s.whatsapp.net", chat_id="12345@s.whatsapp.net",
content="", content="",
media=["photo.jpg", "clip.mp4", "voice.mp3", "report.pdf"], media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
) )
) )
jid = ("12345", "s.whatsapp.net") jid = ("12345", "s.whatsapp.net")
client.send_image.assert_awaited_once_with(jid, "photo.jpg") client.send_image.assert_awaited_once_with(jid, "photo.jpg")
client.send_video.assert_awaited_once_with(jid, "clip.mp4") 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( client.send_document.assert_awaited_once_with(
jid, jid,
"report.pdf", "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 @pytest.mark.asyncio
async def test_send_when_disconnected_raises() -> None: async def test_send_when_disconnected_raises() -> None:
ch = _make_channel() ch = _make_channel()
+99 -263
View File
@@ -1,13 +1,13 @@
import { import { useLayoutEffect, useRef, useState } from "react";
useEffect, import { ChevronDown, CircleHelp, Sparkles } from "lucide-react";
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { CircleHelp, Sparkles } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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";
@@ -33,54 +33,6 @@ interface ModelPresetBadgeProps {
onClick?: () => void; 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({ export function ModelPresetBadge({
label, label,
modelDetail, modelDetail,
@@ -110,204 +62,94 @@ export function ModelPresetBadge({
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
const interactive = Boolean(onClick); const interactive = Boolean(onClick);
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName)); const badgeClassName = cn(
const pillHeight = isHero ? 32 : 36; "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",
const pillStride = pillHeight + PILL_GAP_PX; (interactive || canSwitch) && "cursor-pointer focus-visible:outline-none",
const [motion, setMotion] = useState<PresetMotion | null>(null); isHero ? "h-8" : "h-9",
const gestureRef = useRef<PresetGesture | null>(null); );
const badgeContent = (
<PresetPill
label={label}
modelDetail={modelDetail}
provider={provider}
providerLabel={providerLabel}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
showPicker={canSwitch}
/>
);
function clearGesture() { if (canSwitch) {
const gesture = gestureRef.current; return (
if (gesture?.timer) clearTimeout(gesture.timer); <DropdownMenu modal={false}>
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); <DropdownMenuTrigger asChild>
gestureRef.current = null; <button type="button" aria-label={label} className={badgeClassName}>
} {badgeContent}
</button>
useEffect(() => { </DropdownMenuTrigger>
if (!canSwitch) { <DropdownMenuContent
clearGesture(); align="end"
setMotion(null); side="top"
} sideOffset={8}
return clearGesture; collisionPadding={12}
}, [canSwitch]); className="w-[min(20rem,calc(100vw-2rem))] rounded-[18px]"
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
> >
<span <DropdownMenuRadioGroup
data-testid="composer-model-pill-track" value={activeName}
data-settling={motion.settling ? "true" : undefined} onValueChange={(name) => {
className="composer-model-pill-track ml-auto flex w-max max-w-full flex-col items-end gap-1 will-change-transform" if (name !== activeName) onPresetChange?.(name);
onTransitionEnd={(event) => {
if (motion.settling && event.currentTarget === event.target) setMotion(null);
}}
style={{
paddingTop: isHero ? "10px" : "12px",
transform: `translate3d(0, ${trackOffset}px, 0)`,
}} }}
> >
{PILL_OFFSETS.map((offset) => { {presets.map((preset) => {
const virtualIndex = motion.index + offset; const detail = [...new Set([preset.model, preset.provider].filter(Boolean))]
const preset = presets[wrapIndex(virtualIndex, presets.length)]; .join(" · ");
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
return ( return (
<PresetPill <DropdownMenuRadioItem
key={virtualIndex} key={preset.name}
label={preset.label || preset.name} value={preset.name}
modelDetail={preset.model} className="min-h-[46px] items-start rounded-[14px] py-2.5"
provider={preset.provider} >
isHero={isHero} <span className="min-w-0 flex-1">
offset={offset} <span className="block truncate font-semibold text-foreground">
scale={scale} {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> </DropdownMenuRadioGroup>
</span> </DropdownMenuContent>
) : null} </DropdownMenu>
</Container> );
}
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({ function PresetPill({
className,
label, label,
modelDetail, modelDetail,
provider, provider,
@@ -315,10 +157,8 @@ function PresetPill({
needsSetup = false, needsSetup = false,
fallbackModelName, fallbackModelName,
isHero, isHero,
offset, showPicker = false,
scale,
}: { }: {
className?: string | false | null;
label: string; label: string;
modelDetail?: string | null; modelDetail?: string | null;
provider?: string | null; provider?: string | null;
@@ -326,8 +166,7 @@ function PresetPill({
needsSetup?: boolean; needsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
isHero: boolean; isHero: boolean;
offset?: number; showPicker?: boolean;
scale?: number;
}) { }) {
const labelRef = useRef<HTMLSpanElement | null>(null); const labelRef = useRef<HTMLSpanElement | null>(null);
const [labelOverflows, setLabelOverflows] = useState(false); const [labelOverflows, setLabelOverflows] = useState(false);
@@ -337,11 +176,9 @@ function PresetPill({
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 title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
const logoTestId = offset !== undefined const logoTestId = needsSetup
? undefined ? "composer-model-setup-icon"
: needsSetup : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
? "composer-model-setup-icon"
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
useLayoutEffect(() => { useLayoutEffect(() => {
const node = labelRef.current; const node = labelRef.current;
@@ -356,22 +193,15 @@ function PresetPill({
return ( return (
<span <span
data-fallback={fallbackModelName ? "true" : undefined} data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset}
title={fallbackModelName || title || undefined} 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)]", "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", "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", 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]", 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 <span
data-testid={logoTestId} data-testid={logoTestId}
@@ -422,6 +252,12 @@ function PresetPill({
> >
{label} {label}
</span> </span>
{showPicker ? (
<ChevronDown
className="thread-composer-model-chevron h-3.5 w-3.5 shrink-0 text-muted-foreground/75"
aria-hidden
/>
) : null}
</span> </span>
); );
} }
+5 -41
View File
@@ -738,54 +738,14 @@
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
} }
.thread-composer-model-badge:not([data-switching="true"]):active .thread-composer-model-badge:active > .composer-model-pill {
> .composer-model-pill {
transform: scale(0.98); 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) { @media (prefers-reduced-motion: reduce) {
.thread-composer-model-badge:active > .composer-model-pill { .thread-composer-model-badge:active > .composer-model-pill {
transform: none !important; 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) { @container thread-composer (max-width: 21rem) {
@@ -838,6 +798,10 @@
.thread-composer-model-label { .thread-composer-model-label {
display: none; display: none;
} }
.thread-composer-model-chevron {
display: none;
}
} }
@container thread-composer (max-width: 16rem) { @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 { 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 { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -313,28 +314,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
/>, />,
); );
return { return {
badge: screen.getByRole("spinbutton", { name: "Kimi" }), badge: screen.getByRole("button", { name: "Kimi" }),
onPresetChange, 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", () => { describe("ThreadComposer", () => {
it("focuses and sends a removable quoted answer excerpt", async () => { it("focuses and sends a removable quoted answer excerpt", async () => {
const onSend = vi.fn(); 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).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
expect(badge).not.toHaveClass("w-[5.75rem]"); expect(badge).not.toHaveClass("w-[5.75rem]");
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
@@ -461,93 +445,32 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
}); });
it("scrolls complete preset pills after a left-button long press and wraps", () => { it("opens a preset menu on click and switches the selected preset", async () => {
vi.useFakeTimers(); const user = userEvent.setup();
const { badge, onPresetChange } = renderPresetComposer(); const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9"); expect(badge).toHaveClass("h-9");
expect(badge).toHaveStyle({ touchAction: "manipulation" }); expect(badge).toHaveAttribute("aria-haspopup", "menu");
const idleTouchMove = new Event("touchmove", { expect(badge).toHaveAttribute("aria-expanded", "false");
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();
longPress(badge); await user.click(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",
});
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(onPresetChange).toHaveBeenCalledWith("dspro");
expect(badge).toHaveAttribute("data-settling", "true"); expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(track).toHaveAttribute("data-settling", "true");
act(() => {
vi.advanceTimersByTime(260);
});
expect(badge).not.toHaveAttribute("data-switching");
expect(badge).not.toHaveAttribute("data-settling");
}); });
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => { it("supports the same preset menu in hero mode", async () => {
vi.useFakeTimers(); const user = userEvent.setup();
const { badge, onPresetChange } = renderPresetComposer("hero"); const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8"); expect(badge).toHaveClass("h-8");
longPress(badge, 9); await user.click(badge);
expect(badge).toHaveAttribute("data-switching", "true"); await user.click(screen.getByRole("menuitemradio", { name: /DFlash.*deepseek/i }));
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); expect(onPresetChange).toHaveBeenCalledWith("dflash");
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
expect(badge).not.toHaveAttribute("data-switching");
expect(onPresetChange).not.toHaveBeenCalled();
}); });
it("transcribes voice input into the composer without sending", async () => { 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 { rerender } = render(view("default"));
const badge = await screen.findByRole("spinbutton", { name: "Default" }); const badge = await screen.findByRole("button", { name: "Default" });
expect(badge).toHaveTextContent("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( expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order", "preset-order",
"/model fast", "/model fast",
); );
expect(await screen.findByText("Fast")).toBeInTheDocument(); expect(await screen.findByText("Fast")).toBeInTheDocument();
fireEvent.keyDown( fireEvent.pointerDown(screen.getByRole("button", { name: "Fast" }));
screen.getByRole("spinbutton", { name: "Fast" }), fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Extra/ }));
{ key: "End" },
);
expect(client.sendSystemCommand).toHaveBeenLastCalledWith( expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order", "preset-order",
"/model extra", "/model extra",
@@ -972,10 +971,8 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view(null)); const { rerender } = render(view(null));
fireEvent.keyDown( fireEvent.pointerDown(await screen.findByRole("button", { name: "Default" }));
await screen.findByRole("spinbutton", { name: "Default" }), fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ }));
{ key: "ArrowDown" },
);
expect(await screen.findByText("Fast")).toBeInTheDocument(); expect(await screen.findByText("Fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled(); expect(client.sendSystemCommand).not.toHaveBeenCalled();