Compare commits

..
Author SHA1 Message Date
Xubin Ren 47d83af0b6 feat(webui): add persistent Quick Chat 2026-07-31 00:53:10 +08:00
39 changed files with 409 additions and 966 deletions
+2 -1
View File
@@ -356,7 +356,8 @@ Providers that use the Responses API can keep reasoning context across a
conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models.
nanobot preserves Responses conversation state automatically for OpenAI
Responses, OpenAI Codex, Azure OpenAI, and compatible GitHub Copilot models.
Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required.
-2
View File
@@ -231,8 +231,6 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions.
### Custom OpenAI-Compatible Endpoint
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider.
+6 -5
View File
@@ -493,11 +493,12 @@ class SlackChannel(BaseChannel):
except Exception as e:
self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the turn lives in a thread: either the
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new
# thread for this channel message. DM roots have no thread_ts and keep the
# default per-chat session, so context doesn't bleed across thread boundaries.
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None
# Thread-scoped session key whenever the user is in a real thread
# (raw_thread_ts is set). DM threads get their own session, separate
# from the DM root, so context doesn't bleed across thread boundaries.
session_key = (
f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
)
media_paths: list[str] = []
file_markers: list[str] = []
for file_info in _as_json_list(event.get("files")) or []:
@@ -555,113 +555,6 @@ async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
def _channel_mention_request(envelope_id: str, ts: str) -> SimpleNamespace:
return SimpleNamespace(
type="events_api",
envelope_id=envelope_id,
payload={
"event": {
"type": "app_mention",
"user": "U1",
"channel": "C123",
"text": "<@UBOT> hello",
"ts": ts,
}
},
)
@pytest.mark.asyncio
async def test_channel_root_message_uses_thread_scoped_session() -> None:
"""A channel mention that opens a thread belongs to that thread's session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = _channel_mention_request("env-c1", "1700000000.000100")
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
@pytest.mark.asyncio
async def test_channel_root_messages_do_not_share_one_session() -> None:
"""Two threads opened in the same channel must not collapse into one session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
first = _channel_mention_request("env-c1", "1700000000.000100")
second = _channel_mention_request("env-c2", "1700000000.000200")
await channel._on_socket_request(client, first)
await channel._on_socket_request(client, second)
session_keys = [call.kwargs["session_key"] for call in channel._handle_message.await_args_list]
assert session_keys == [
"slack:C123:1700000000.000100",
"slack:C123:1700000000.000200",
]
@pytest.mark.asyncio
async def test_channel_root_message_without_reply_in_thread_uses_channel_session() -> None:
"""With reply_in_thread disabled no thread is opened, so the channel session is used."""
channel = SlackChannel(SlackConfig(enabled=True, reply_in_thread=False), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = _channel_mention_request("env-c3", "1700000000.000300")
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] is None
assert kwargs["metadata"]["slack"]["thread_ts"] is None
@pytest.mark.asyncio
async def test_channel_thread_reply_keeps_thread_session() -> None:
"""A reply inside a channel thread stays in the session opened by the root message."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = SimpleNamespace(
type="events_api",
envelope_id="env-c4",
payload={
"event": {
"type": "app_mention",
"user": "U1",
"channel": "C123",
"text": "<@UBOT> follow up",
"ts": "1700000000.000400",
"thread_ts": "1700000000.000100",
}
},
)
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
@pytest.mark.asyncio
async def test_slack_slash_command_skips_thread_context() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
+2 -25
View File
@@ -230,30 +230,9 @@ class WeixinChannel(BaseChannel):
self.logger.error("Failed to load Weixin account state", exc_info=True)
return False
def _save_state(self, *, force: bool = False) -> None:
def _save_state(self) -> None:
state_file = self._get_state_dir() / "account.json"
with suppress(Exception):
if not force and state_file.exists():
persisted: object = None
try:
persisted = json.loads(state_file.read_text())
except Exception:
persisted = None
persisted_token = ""
if isinstance(persisted, dict):
persisted_mapping = cast(dict[str, object], persisted)
persisted_token = str(persisted_mapping.get("token", "") or "")
configured_token_is_authoritative: bool = bool(self.config.token) and (
self._token == self.config.token
)
if (
persisted_token
and persisted_token != self._token
and not configured_token_is_authoritative
):
# A concurrent QR login may have committed a newer token.
# Never let an older runtime snapshot overwrite it.
return
data = {
"token": self._token,
"get_updates_buf": self._get_updates_buf,
@@ -510,7 +489,7 @@ class WeixinChannel(BaseChannel):
self._token = token
if base_url:
self.config.base_url = base_url
self._save_state(force=True)
self._save_state()
async def connect_close_client(self) -> None:
self._running = False
@@ -634,8 +613,6 @@ class WeixinChannel(BaseChannel):
remaining = self._session_pause_remaining_s()
if remaining > 0:
await asyncio.sleep(remaining)
if not self.config.token:
self._load_state()
return
body: dict[str, Any] = {
@@ -98,80 +98,6 @@ def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
assert restored._context_tokens == {"wx-user": "ctx-1"}
def test_save_state_preserves_token_committed_by_another_instance(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
channel._token = "old-token"
channel._save_state()
replacement = {
"token": "new-token",
"base_url": "https://new.example",
"get_updates_buf": "",
"context_tokens": {},
"typing_tickets": {},
}
(tmp_path / "account.json").write_text(json.dumps(replacement), encoding="utf-8")
channel._get_updates_buf = "stale-cursor"
channel._save_state()
assert json.loads((tmp_path / "account.json").read_text()) == replacement
def test_save_state_force_overwrites_replaced_token(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
(tmp_path / "account.json").write_text(json.dumps({"token": "old-token"}), encoding="utf-8")
channel.connect_commit_account(token="new-token", base_url="https://new.example")
saved = json.loads((tmp_path / "account.json").read_text())
assert saved["token"] == "new-token"
assert saved["base_url"] == "https://new.example"
def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
channel._token = "configured-token"
channel._get_updates_buf = "current-cursor"
(tmp_path / "account.json").write_text(
json.dumps({"token": "stale-token", "get_updates_buf": "stale-cursor"}),
encoding="utf-8",
)
channel._save_state()
saved = json.loads((tmp_path / "account.json").read_text())
assert saved["token"] == "configured-token"
assert saved["get_updates_buf"] == "current-cursor"
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
persisted = {"token": "persisted-token", "get_updates_buf": "persisted-cursor"}
(tmp_path / "account.json").write_text(json.dumps(persisted), encoding="utf-8")
channel._save_state()
assert json.loads((tmp_path / "account.json").read_text()) == persisted
@pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel()
@@ -536,56 +462,6 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
assert channel._session_pause_remaining_s() > 0
@pytest.mark.asyncio
async def test_poll_once_reloads_refreshed_state_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
channel._token = "old-token"
channel._save_state()
(tmp_path / "account.json").write_text(
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
await channel._poll_once()
assert channel._token == "new-token"
assert channel.config.base_url == "https://new.example"
@pytest.mark.asyncio
async def test_poll_once_keeps_explicit_token_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
channel._token = "configured-token"
(tmp_path / "account.json").write_text(
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
await channel._poll_once()
assert channel._token == "configured-token"
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
@pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay,
+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()
+6 -21
View File
@@ -958,34 +958,22 @@ class OpenAICompatProvider(LLMProvider):
model: str | None,
reasoning_effort: str | None,
) -> bool:
"""Choose Responses for providers/models that explicitly support it."""
"""Use Responses API only for direct OpenAI requests that benefit from it."""
if self._api_type == "chat_completions":
return False
spec_name = self._spec.name if self._spec is not None else None
model_name = self._request_model_name(model or self.default_model).lower()
supported_models = {
supported.lower()
for supported in getattr(self._spec, "responses_models", ())
}
model_responses = any(
model_name == supported or model_name.endswith(f"/{supported}")
for supported in supported_models
)
provider_responses = spec_name in ("openai", "github_copilot")
if not provider_responses and not model_responses:
if self._spec and self._spec.name not in ("openai", "github_copilot"):
return False
if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions.
return True
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
if self._spec is None or self._spec.name != "github_copilot":
if not _is_direct_openai_base(self._effective_base):
return False
model_name = (model or self.default_model).lower()
wants = False
if model_responses:
wants = True
elif reasoning_effort and reasoning_effort.lower() != "none":
if reasoning_effort and reasoning_effort.lower() != "none":
wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True
@@ -1111,13 +1099,11 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages)
)
)
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
instructions, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
provider=self._responses_state_provider(),
model=model_name,
preserve_reasoning=preserve_reasoning,
)
body: dict[str, Any] = {
@@ -1145,7 +1131,7 @@ class OpenAICompatProvider(LLMProvider):
if self._supports_temperature(model_name, reasoning_effort):
body["temperature"] = temperature
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
if not self._supports_temperature(model_name, reasoning_effort):
body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
@@ -1841,7 +1827,6 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(),
on_content_delta,
on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
capture=capture,
)
self._record_responses_success(model, reasoning_effort)
@@ -12,11 +12,7 @@ def _as_json_object(value: object) -> dict[str, Any] | None:
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def convert_messages(
messages: list[dict[str, Any]],
*,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]]]:
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items.
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
@@ -40,13 +36,6 @@ def convert_messages(
continue
if role == "assistant":
if preserve_reasoning:
reasoning = msg.get("reasoning_content")
if isinstance(reasoning, str) and reasoning:
input_items.append({
"type": "reasoning",
"content": reasoning,
})
if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({
+13 -35
View File
@@ -69,9 +69,7 @@ def _response_object(value: object) -> dict[str, Any] | None:
return object_value
dump = getattr(value, "model_dump", None)
if callable(dump):
dumped = _as_json_object(dump())
if dumped is not None:
return dumped
return _as_json_object(dump())
try:
return _as_json_object(vars(value))
except TypeError:
@@ -446,14 +444,6 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
for item in _response_object_list(output):
if item.get("type") != "reasoning":
continue
content = item.get("content")
if isinstance(content, str) and content:
parts.append(content)
elif isinstance(content, list):
for block in _response_object_list(cast(list[object], content)):
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
for summary in _response_object_list(item.get("summary")):
if summary.get("type") == "summary_text" and summary.get("text"):
text = summary.get("text")
@@ -493,9 +483,11 @@ def parse_response_output(
if isinstance(refusal, str):
content_parts.append(refusal)
elif item_type == "reasoning":
text = _extract_reasoning_summary_from_output([item])
if text:
reasoning_content = (reasoning_content or "") + text
for s in _response_object_list(item.get("summary")):
if s.get("type") == "summary_text" and s.get("text"):
text = s.get("text")
if isinstance(text, str):
reasoning_content = (reasoning_content or "") + text
elif item_type == "function_call":
call_id = item.get("call_id") or ""
item_id = item.get("id") or "fc_0"
@@ -540,7 +532,6 @@ async def consume_sdk_stream(
stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
@@ -551,7 +542,6 @@ async def consume_sdk_stream(
finish_reason = "stop"
usage: dict[str, int] = {}
reasoning_content: str | None = None
streamed_reasoning = False
refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = ""
@@ -582,19 +572,6 @@ async def consume_sdk_stream(
content += delta_text
if on_content_delta and delta_text:
await on_content_delta(delta_text)
elif event_type == "response.reasoning_text.delta":
delta_text = getattr(event, "delta", "") or ""
if delta_text:
reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True
if on_reasoning_delta:
await on_reasoning_delta(delta_text)
elif event_type == "response.reasoning_text.done":
text = getattr(event, "text", "") or ""
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.refusal.delta":
refusal_seen = True
delta_text = getattr(event, "delta", None)
@@ -712,12 +689,13 @@ async def consume_sdk_stream(
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
}
if not reasoning_content:
reasoning_content = _extract_reasoning_summary_from_output(
getattr(resp, "output", None)
)
if reasoning_content and on_reasoning_delta:
await on_reasoning_delta(reasoning_content)
for out_item in cast(list[Any], getattr(resp, "output", None) or []):
if getattr(out_item, "type", None) == "reasoning":
for s in cast(list[Any], getattr(out_item, "summary", None) or []):
if getattr(s, "type", None) == "summary_text":
text = getattr(s, "text", None)
if text:
reasoning_content = (reasoning_content or "") + text
elif event_type in {"error", "response.failed"}:
detail = getattr(event, "error", None) or getattr(event, "message", None) or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
+2 -9
View File
@@ -43,7 +43,6 @@ def prepare_responses_input(
state: ProviderConversationState | None,
provider: str,
model: str,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]], bool]:
"""Build a request from exact prior items plus only newly appended messages.
@@ -51,10 +50,7 @@ def prepare_responses_input(
When no compatible state exists, it is converted normally as a safe
fallback.
"""
instructions, fallback_items = convert_messages(
messages,
preserve_reasoning=preserve_reasoning,
)
instructions, fallback_items = convert_messages(messages)
if state is None or not responses_state_matches(
state,
provider=provider,
@@ -66,10 +62,7 @@ def prepare_responses_input(
if prior_items is None:
return instructions, fallback_items, False
_, delta_items = convert_messages(
state.pending_messages,
preserve_reasoning=preserve_reasoning,
)
_, delta_items = convert_messages(state.pending_messages)
logger.debug(
"Replaying Responses state: prior_items={} pending_messages={}",
len(prior_items),
-6
View File
@@ -111,11 +111,6 @@ class ProviderSpec:
# Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = ()
# Models that expose the OpenAI Responses wire format. This is model-level
# because providers may add Responses support incrementally (DeepSeek V4
# Flash is supported before V4 Pro).
responses_models: tuple[str, ...] = ()
# When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
@@ -466,7 +461,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.deepseek.com",
thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",),
),
# Gemini: Google's OpenAI-compatible endpoint
ProviderSpec(
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies = [
"filelock>=3.25.2",
"watchfiles>=1.1.1,<2.0.0",
"packaging>=24.0",
"tzdata>=2025.2",
"tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
+1 -1
View File
@@ -2558,7 +2558,7 @@ def test_optional_dependency_metadata_for_enable():
):
assert not any(dep.startswith(dep_name) for dep in required)
for dependency in (
"tzdata>=2025.2",
"tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
-30
View File
@@ -1,8 +1,4 @@
import json
import os
import subprocess
import sys
import textwrap
import warnings
import pytest
@@ -46,32 +42,6 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
def test_agent_timezones_use_packaged_data_without_system_database() -> None:
script = textwrap.dedent(
"""\
from zoneinfo import TZPATH
from nanobot.config.schema import Config
assert not TZPATH
for name in ("UTC", "Asia/Shanghai"):
config = Config.model_validate({"agents": {"defaults": {"timezone": name}}})
serialized = config.model_dump(mode="json", by_alias=True)
restored = Config.model_validate(serialized)
assert restored.agents.defaults.timezone == name
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
env=os.environ | {"PYTHONTZPATH": ""},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_provider_api_type_accepts_exact_values_only() -> None:
config = Config.model_validate({
"providers": {
-56
View File
@@ -150,22 +150,6 @@ class TestConvertMessages:
assert items[0]["content"][0]["type"] == "output_text"
assert items[0]["content"][0]["text"] == "I'll help"
def test_preserves_deepseek_reasoning_content(self):
_, items = convert_messages([
{"role": "assistant", "reasoning_content": "think first", "content": "answer"},
], preserve_reasoning=True)
assert items == [
{"type": "reasoning", "content": "think first"},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "answer"}],
"status": "completed",
"id": "msg_0",
},
]
def test_assistant_empty_content_skipped(self):
_, items = convert_messages([{"role": "assistant", "content": ""}])
assert len(items) == 0
@@ -555,22 +539,6 @@ class TestParseResponseOutput:
assert result.content == "42"
assert result.reasoning_content == "I think therefore I am."
def test_deepseek_reasoning_content_extracted(self):
resp = {
"output": [
{"type": "reasoning", "content": "think first"},
{"type": "message", "content": [
{"type": "output_text", "text": "answer"},
]},
],
"status": "completed", "usage": {},
}
result = parse_response_output(resp)
assert result.content == "answer"
assert result.reasoning_content == "think first"
def test_empty_output(self):
resp = {"output": [], "status": "completed", "usage": {}}
result = parse_response_output(resp)
@@ -1665,30 +1633,6 @@ class TestConsumeSdkStream:
_, _, _, _, reasoning = await consume_sdk_stream(stream())
assert reasoning == "thinking..."
@pytest.mark.asyncio
async def test_deepseek_reasoning_text_streamed(self):
events = [
MagicMock(type="response.reasoning_text.delta", delta="step 1 "),
MagicMock(type="response.reasoning_text.delta", delta="step 2"),
MagicMock(type="response.reasoning_text.done", text="step 1 step 2"),
]
emitted: list[str] = []
async def stream():
for event in events:
yield event
async def on_reasoning_delta(delta: str) -> None:
emitted.append(delta)
_, _, _, _, reasoning = await consume_sdk_stream(
stream(),
on_reasoning_delta=on_reasoning_delta,
)
assert reasoning == "step 1 step 2"
assert emitted == ["step 1 ", "step 2"]
@pytest.mark.asyncio
async def test_error_event_raises(self):
ev = MagicMock(type="error", error="rate_limit_exceeded")
@@ -29,32 +29,6 @@ def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True
def test_deepseek_v4_flash_uses_responses_by_model(provider):
provider._spec = type("Spec", (), {
"name": "deepseek",
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._effective_base = "https://api.deepseek.com"
provider.default_model = "deepseek-v4-flash"
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
provider._spec = type("Spec", (), {
"name": "deepseek",
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._effective_base = "https://api.deepseek.com"
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {}
+64 -9
View File
@@ -37,6 +37,12 @@ import {
import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import {
isQuickChatKey,
QUICK_CHAT_ID,
QUICK_CHAT_KEY,
quickChatSession,
} from "@/lib/quick-chat";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
BootstrapResponse,
@@ -225,6 +231,9 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path === "/quick-chat") {
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
try {
@@ -241,6 +250,7 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") {
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new";
@@ -947,8 +957,16 @@ function Shell({
deleteChat,
getSessionAutomations,
} = useSessions();
const regularSessions = useMemo(
() => sessions.filter((session) => !isQuickChatKey(session.key)),
[sessions],
);
const quickSession = useMemo(
() => quickChatSession(sessions.find((session) => isQuickChatKey(session.key))),
[sessions],
);
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
useSidebarState(regularSessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState<string | null>(
@@ -1114,8 +1132,10 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return quickSession;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
}, [sessions, activeKey, quickSession]);
const quickChatActive = isQuickChatKey(activeKey);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
@@ -1130,6 +1150,9 @@ function Shell({
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (quickChatActive) {
return workspaces?.default_scope ?? null;
}
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
@@ -1141,6 +1164,7 @@ function Shell({
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
quickChatActive,
workspaceOverrides,
workspaces?.default_scope,
]);
@@ -1161,7 +1185,10 @@ function Shell({
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
const knownChatIds = new Set([
QUICK_CHAT_ID,
...sessions.map((session) => session.chatId),
]);
setUpdatedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
@@ -1176,6 +1203,7 @@ function Shell({
useEffect(() => {
if (loading || !activeKey) return;
if (isQuickChatKey(activeKey)) return;
if (sessions.some((session) => session.key === activeKey)) return;
const currentRoute = readShellRoute();
navigate(
@@ -1417,6 +1445,18 @@ function Shell({
setMobileSidebarOpen(false);
}, [navigate]);
const onOpenQuickChat = useCallback(() => {
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setSessionSearchOpen(false);
navigate({
view: "chat",
activeKey: QUICK_CHAT_KEY,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
}, [navigate]);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope;
@@ -1682,6 +1722,7 @@ function Shell({
setMobileSidebarOpen(false);
const nextKey = (() => {
if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return activeKey;
if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null;
})();
@@ -1773,7 +1814,10 @@ function Shell({
});
}, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onTurnEnd = useDeferredTitleRefresh(
quickChatActive ? null : activeSession,
refresh,
);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
@@ -1863,7 +1907,9 @@ function Shell({
});
}, []);
const headerTitle = activeSession
const headerTitle = quickChatActive
? t("sidebar.quickChat")
: activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
@@ -1900,9 +1946,11 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions,
sessions: regularSessions,
activeKey,
loading,
quickChatActive,
onOpenQuickChat,
onNewChat,
onSelect: onSelectChat,
onRequestDelete,
@@ -2065,7 +2113,7 @@ function Shell({
<SessionSearchDialog
open
onOpenChange={setSessionSearchOpen}
sessions={sessions}
sessions={regularSessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
@@ -2090,7 +2138,7 @@ function Shell({
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onForkChat={quickChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
@@ -2099,13 +2147,20 @@ function Shell({
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceControls={
quickChatActive ? null : (workspaces?.controls ?? null)
}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
allowConversationReset={!quickChatActive}
showSessionInfo={!quickChatActive}
emptyStateGreeting={
quickChatActive ? t("quickChat.greeting") : undefined
}
/>
</div>
{view !== "chat" && (
+10
View File
@@ -3,6 +3,7 @@ import {
Archive,
Brain,
CalendarClock,
MessageCircle,
Menu,
Search,
Settings,
@@ -24,6 +25,8 @@ interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
quickChatActive: boolean;
onOpenQuickChat: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
@@ -139,6 +142,13 @@ export function Sidebar(props: SidebarProps) {
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.quickChat")}
onClick={props.onOpenQuickChat}
active={props.quickChatActive}
icon={<MessageCircle className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
+17 -5
View File
@@ -315,6 +315,9 @@ interface ThreadShellProps {
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
skills?: SkillSummary[];
allowConversationReset?: boolean;
showSessionInfo?: boolean;
emptyStateGreeting?: string;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -597,6 +600,9 @@ export function ThreadShell({
settingsSnapshot = null,
onOpenModelSettings,
skills = [],
allowConversationReset = true,
showSessionInfo = true,
emptyStateGreeting,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -622,6 +628,12 @@ export function ThreadShell({
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const availableSlashCommands = useMemo(
() => allowConversationReset
? slashCommands
: slashCommands.filter((command) => command.command !== "/new"),
[allowConversationReset, slashCommands],
);
const cliApps = useInstalledSettingItems({
getToken,
eventName: CLI_APPS_CHANGED_EVENT,
@@ -1374,7 +1386,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
@@ -1416,7 +1428,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
@@ -1442,10 +1454,10 @@ export function ThreadShell({
</div>
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<HeroGreeting text={t(heroGreetingKey)} />
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
</div>
);
const sessionInfoAction = historyKey ? (
const sessionInfoAction = historyKey && showSessionInfo ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
@@ -1488,7 +1500,7 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX;
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near);
const logicallyAtBottom = owner === "automatic" || near;
setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom,
);
@@ -557,7 +557,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!direction) return;
threadMotionRef.current?.handleUserScrollIntent(
canScrollInDirection(el, direction),
direction === "forward",
);
};
const handleWheel = (event: WheelEvent) => {
@@ -573,21 +572,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser();
};
let lastTouchY: number | null = null;
let touchStartY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
lastTouchY = event.touches[0]?.clientY ?? null;
touchStartY = event.touches[0]?.clientY ?? null;
};
const handleTouchMove = (event: TouchEvent) => {
const currentY = event.touches[0]?.clientY;
const scrollDeltaY =
lastTouchY !== null && currentY !== undefined
? lastTouchY - currentY
touchStartY !== null && currentY !== undefined
? touchStartY - currentY
: 0;
lastTouchY = currentY ?? null;
handleDirectionalInput(directionFromDelta(scrollDeltaY));
};
const handleTouchEnd = () => {
lastTouchY = null;
touchStartY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
+5 -34
View File
@@ -168,9 +168,6 @@ export class ThreadMotionCoordinator {
private measurementFrameId: number | null = null;
private geometryDirty = false;
private composerInputDuringTurn = false;
// A user leaving the live tail must first move beyond the near-bottom
// boundary, or explicitly reverse toward latest, before follow can resume.
private resumeFollowArmed = false;
constructor(options: ThreadMotionCoordinatorOptions) {
this.camera = options.camera;
@@ -201,7 +198,6 @@ export class ThreadMotionCoordinator {
if (isNewTurn) {
this.camera.cancel();
this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.promptPositioned = turn.entry === "restored";
this.mode = this.promptPositioned && turn.hasOutput
? "follow-output"
@@ -253,31 +249,15 @@ export class ThreadMotionCoordinator {
this.handleUserScrollIntent(true);
}
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void {
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
this.transitionToAutoFollow(false);
return;
}
handleUserScrollIntent(canScroll: boolean): void {
const event = canScroll ? "user-scroll" : "boundary-scroll";
const transitioned = this.transition(event);
if (this.mode === "browsing-history" && canScroll) {
this.resumeFollowArmed = towardLatest;
} else if (transitioned && this.mode === "browsing-history") {
this.resumeFollowArmed = false;
}
if (!transitioned) return;
if (!this.transition(event)) return;
this.camera.cancel();
}
resumeAutoFollow(): void {
this.transitionToAutoFollow(true);
}
private transitionToAutoFollow(cancelCamera: boolean): void {
if (!this.transition("resume-follow")) return;
this.resumeFollowArmed = false;
if (cancelCamera) this.camera.cancel();
this.onAutoFollow?.();
this.camera.cancel();
this.invalidateGeometry();
}
@@ -337,19 +317,11 @@ export class ThreadMotionCoordinator {
case "navigating-history":
if (!this.camera.isFollowing()) {
this.transition("navigation-settled");
if (nearBottom) {
this.resumeAutoFollow();
} else {
this.resumeFollowArmed = true;
}
if (nearBottom) this.resumeAutoFollow();
}
return "navigation";
case "browsing-history":
if (!nearBottom) {
this.resumeFollowArmed = true;
return "user";
}
if (!this.resumeFollowArmed) return "user";
if (!nearBottom) return "user";
this.resumeAutoFollow();
return "automatic";
default:
@@ -367,7 +339,6 @@ export class ThreadMotionCoordinator {
this.camera.cancel();
this.turn = { id: null, promptId: null, hasOutput: false };
this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.mode = "idle";
this.promptPositioned = false;
}
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Sidebar navigation",
"collapse": "Collapse sidebar",
"quickChat": "Quick Chat",
"newChat": "New topic",
"searchAria": "Search",
"searchPlaceholder": "Search",
@@ -60,6 +61,9 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "What's on your mind?"
},
"settings": {
"backToChat": "Back to chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navegación de la barra lateral",
"collapse": "Contraer barra lateral",
"quickChat": "Chat rápido",
"newChat": "Nuevo tema",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Habilidades"
}
},
"quickChat": {
"greeting": "¿Qué tienes en mente?"
},
"settings": {
"backToChat": "Volver al chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navigation de la barre latérale",
"collapse": "Réduire la barre latérale",
"quickChat": "Discussion rapide",
"newChat": "Nouveau sujet",
"searchAria": "Rechercher",
"searchPlaceholder": "Rechercher",
@@ -60,6 +61,9 @@
"title": "Compétences"
}
},
"quickChat": {
"greeting": "De quoi avez-vous envie de parler ?"
},
"settings": {
"backToChat": "Retour au chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navigasi bilah samping",
"collapse": "Ciutkan sidebar",
"quickChat": "Obrolan cepat",
"newChat": "Topik baru",
"searchAria": "Cari",
"searchPlaceholder": "Cari",
@@ -60,6 +61,9 @@
"title": "Skill"
}
},
"quickChat": {
"greeting": "Apa yang sedang kamu pikirkan?"
},
"settings": {
"backToChat": "Kembali ke chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる",
"quickChat": "クイックチャット",
"newChat": "新しいトピック",
"searchAria": "検索",
"searchPlaceholder": "検索",
@@ -60,6 +61,9 @@
"title": "スキル"
}
},
"quickChat": {
"greeting": "何について話しますか?"
},
"settings": {
"backToChat": "チャットに戻る",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "사이드바 탐색",
"collapse": "사이드바 접기",
"quickChat": "빠른 채팅",
"newChat": "새 주제",
"searchAria": "검색",
"searchPlaceholder": "검색",
@@ -60,6 +61,9 @@
"title": "스킬"
}
},
"quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?"
},
"settings": {
"backToChat": "채팅으로 돌아가기",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navegação da barra lateral",
"collapse": "Recolher barra lateral",
"quickChat": "Chat rápido",
"newChat": "Novo tópico",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "O que você está pensando?"
},
"settings": {
"backToChat": "Voltar para a conversa",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Điều hướng thanh bên",
"collapse": "Thu gọn thanh bên",
"quickChat": "Trò chuyện nhanh",
"newChat": "Chủ đề mới",
"searchAria": "Tìm kiếm",
"searchPlaceholder": "Tìm kiếm",
@@ -60,6 +61,9 @@
"title": "Kỹ năng"
}
},
"quickChat": {
"greeting": "Bạn đang nghĩ gì?"
},
"settings": {
"backToChat": "Quay lại chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "侧边栏导航",
"collapse": "收起侧边栏",
"quickChat": "随便聊聊",
"newChat": "新建话题",
"searchAria": "搜索",
"searchPlaceholder": "搜索",
@@ -60,6 +61,9 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊点什么?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "側邊欄導覽",
"collapse": "收合側邊欄",
"quickChat": "輕鬆聊聊",
"newChat": "新增話題",
"searchAria": "搜尋",
"searchPlaceholder": "搜尋",
@@ -60,6 +61,9 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊點什麼?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+22
View File
@@ -0,0 +1,22 @@
import type { ChatSummary } from "@/lib/types";
export const QUICK_CHAT_ID = "quick-chat";
export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`;
export function isQuickChatKey(key: string | null): boolean {
return key === QUICK_CHAT_KEY;
}
export function quickChatSession(persisted?: ChatSummary): ChatSummary {
return {
key: QUICK_CHAT_KEY,
channel: "websocket",
chatId: QUICK_CHAT_ID,
createdAt: persisted?.createdAt ?? null,
updatedAt: persisted?.updatedAt ?? null,
preview: persisted?.preview ?? "",
modelPreset: persisted?.modelPreset ?? null,
runStartedAt: persisted?.runStartedAt ?? null,
workspaceScope: persisted?.workspaceScope ?? null,
};
}
+80
View File
@@ -349,6 +349,86 @@ describe("App layout", () => {
).toBeTruthy();
});
it("opens a single fixed Quick Chat without provisioning a new session", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const quickChatButton = within(sidebar).getByRole("button", {
name: "Quick Chat",
});
fireEvent.click(quickChatButton);
expect(window.location.hash).toBe("#/quick-chat");
expect(quickChatButton).toHaveAttribute("aria-current", "page");
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
"/api/sessions/websocket%3Aquick-chat/webui-thread",
),
expect.anything(),
),
);
expect(createChatSpy).not.toHaveBeenCalled();
expect(document.title).toBe("Quick Chat · nanobot");
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
});
it("restores Quick Chat before it has a persisted session", async () => {
window.history.replaceState(null, "", "/#/quick-chat");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect(window.location.hash).toBe("#/quick-chat");
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
"/api/sessions/websocket%3Aquick-chat/webui-thread",
),
expect.anything(),
),
);
expect(
within(screen.getByRole("navigation", { name: "Sidebar navigation" }))
.getByRole("button", { name: "Quick Chat" }),
).toHaveAttribute("aria-current", "page");
});
it("keeps persisted Quick Chat out of the topic list and topic search", async () => {
mockSessions = [
{
key: "websocket:quick-chat",
channel: "websocket",
chatId: "quick-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "A private casual message",
},
{
key: "websocket:project-chat",
channel: "websocket",
chatId: "project-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "Project roadmap",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Project roadmap")).toBeInTheDocument();
expect(within(sidebar).queryByText("A private casual message")).not.toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", { name: "Search" }));
const dialog = await screen.findByRole("dialog", { name: "Search" });
expect(within(dialog).getByText("Project roadmap")).toBeInTheDocument();
expect(within(dialog).queryByText("A private casual message")).not.toBeInTheDocument();
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
isQuickChatKey,
QUICK_CHAT_ID,
QUICK_CHAT_KEY,
quickChatSession,
} from "@/lib/quick-chat";
describe("Quick Chat identity", () => {
it("uses one stable websocket session", () => {
expect(QUICK_CHAT_ID).toBe("quick-chat");
expect(QUICK_CHAT_KEY).toBe("websocket:quick-chat");
expect(isQuickChatKey(QUICK_CHAT_KEY)).toBe(true);
expect(isQuickChatKey("websocket:another-chat")).toBe(false);
});
it("keeps persisted metadata behind the fixed identity", () => {
expect(quickChatSession({
key: "websocket:quick-chat",
channel: "websocket",
chatId: "quick-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "hello",
modelPreset: "fast",
})).toMatchObject({
key: QUICK_CHAT_KEY,
channel: "websocket",
chatId: QUICK_CHAT_ID,
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "hello",
modelPreset: "fast",
});
});
});
-54
View File
@@ -410,9 +410,6 @@ describe("ThreadMotionCoordinator", () => {
expect(camera.jumpTo).toHaveBeenCalledWith(780);
coordinator.takeUserControl();
expect(coordinator.observeScroll(true)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history");
expect(coordinator.observeScroll(false)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history");
@@ -420,57 +417,6 @@ describe("ThreadMotionCoordinator", () => {
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
});
it("resumes shallow history browsing when user intent turns toward latest", () => {
const {
camera,
coordinator,
advanceFrame,
} = motionHarness({
scrollTop: 1_400,
});
coordinator.updateTurn({
id: "turn-1",
promptId: "prompt-1",
hasOutput: true,
});
advanceFrame();
camera.followTo.mockClear();
coordinator.handleUserScrollIntent(true);
expect(coordinator.observeScroll(true)).toBe("user");
advanceFrame();
expect(camera.followTo).not.toHaveBeenCalled();
coordinator.handleUserScrollIntent(true, true);
expect(coordinator.observeScroll(true)).toBe("automatic");
expect(coordinator.snapshot().mode).toBe("follow-output");
advanceFrame();
expect(camera.followTo).toHaveBeenCalledWith(1_400);
});
it("resumes shallow history browsing from forward intent at the boundary", () => {
const {
advanceFrame,
coordinator,
onAutoFollow,
} = motionHarness({
scrollTop: 1_400,
});
coordinator.updateTurn({
id: "turn-1",
promptId: "prompt-1",
hasOutput: true,
});
advanceFrame();
coordinator.handleUserScrollIntent(true);
expect(coordinator.observeScroll(true)).toBe("user");
coordinator.handleUserScrollIntent(false, true);
expect(coordinator.snapshot().mode).toBe("follow-output");
expect(onAutoFollow).toHaveBeenCalledTimes(1);
});
it("preserves history browsing when an active turn is cleared", () => {
const {
camera,
+68
View File
@@ -3369,6 +3369,74 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
});
it("removes session-management affordances from a fixed conversation", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/commands")) {
return httpJson({
commands: [
{
command: "/new",
title: "New chat",
description: "Reset this chat and start a fresh conversation.",
icon: "square-pen",
lifecycle: "finalize_active_turn",
accepts_args: false,
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N persisted messages.",
icon: "history",
arg_hint: "[n]",
lifecycle: "side_channel",
accepts_args: true,
},
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={session("quick-chat")}
title="Quick Chat"
onToggleSidebar={() => {}}
allowConversationReset={false}
showSessionInfo={false}
/>,
),
);
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /\/new/i })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Session details" }),
).not.toBeInTheDocument();
});
it("does not bring back welcome cards when image mode is enabled", async () => {
const client = makeClient();
const settings = modelSettings("deepseek-v4-pro", "deepseek");
-95
View File
@@ -763,101 +763,6 @@ describe("ThreadViewport", () => {
}
});
it("keeps shallow wheel and touch scrolling user-owned until intent reverses", async () => {
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
const threaded: UIMessage[] = [
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
];
const answer: UIMessage = {
id: "a2",
role: "assistant",
content: "streaming answer",
turnId: "turn-2",
isStreaming: true,
createdAt: 4,
};
const { container, rerender } = render(
<ThreadViewport
messages={threaded}
isStreaming
composer={<div>composer</div>}
/>,
);
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1_904 },
clientHeight: { configurable: true, value: 500 },
scrollTop: { configurable: true, writable: true, value: 1_404 },
});
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
expect(prompt).not.toBeNull();
Object.defineProperty(prompt, "offsetTop", {
configurable: true,
value: 1_420,
});
rerender(
<ThreadViewport
messages={[...threaded, answer]}
isStreaming
composer={<div>composer</div>}
activeTurnId="turn-2"
activeTurnStartedHere
/>,
);
await flushAnimationFrame();
followTo.mockClear();
act(() => {
fireEvent.wheel(scroller, { deltaY: -24 });
scroller.scrollTop = 1_380;
scroller.dispatchEvent(new Event("scroll"));
});
await flushAnimationFrame();
expect(followTo).not.toHaveBeenCalled();
expect(scroller.scrollTop).toBe(1_380);
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
act(() => {
scroller.scrollTop = 1_404;
scroller.dispatchEvent(new Event("scroll"));
fireEvent.wheel(scroller, { deltaY: 24 });
});
await flushAnimationFrame();
expect(followTo).toHaveBeenCalledWith(1_404);
expect(scroller.scrollTop).toBe(1_404);
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
.not.toBeInTheDocument();
followTo.mockClear();
act(() => {
fireEvent.touchStart(scroller, { touches: [{ clientY: 300 }] });
fireEvent.touchMove(scroller, { touches: [{ clientY: 324 }] });
scroller.scrollTop = 1_380;
scroller.dispatchEvent(new Event("scroll"));
});
await flushAnimationFrame();
expect(followTo).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
act(() => {
fireEvent.touchMove(scroller, { touches: [{ clientY: 300 }] });
scroller.scrollTop = 1_404;
scroller.dispatchEvent(new Event("scroll"));
fireEvent.touchEnd(scroller);
});
await flushAnimationFrame();
expect(followTo).toHaveBeenCalledWith(1_404);
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
.not.toBeInTheDocument();
});
it("keeps the scroll-to-bottom button above a growing composer", async () => {
const resizeObserver = stubResizeObserver();